${esc(t('onboarding_api_key_help_keyless')||'')}
`:''; - return `${helpHtml}`; + return `${helpHtml}`; } function _getOnboardingSelectedModel(){ diff --git a/static/sessions.js b/static/sessions.js index 318d88ca..0cc3ab2d 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -387,6 +387,15 @@ async function loadSession(sid){ _setActiveSessionUrl(S.session.session_id); const activeStreamId=S.session.active_stream_id||null; + // If the server says the session is idle, discard any browser-side inflight + // cache left behind by a crashed/restarted stream. Otherwise the UI can keep + // showing a permanent thinking/running state even though active_streams=0. + if(!activeStreamId&&INFLIGHT[sid]){ + delete INFLIGHT[sid]; + if(typeof clearInflightState==='function') clearInflightState(sid); + S.activeStreamId=null; + S.busy=false; + } // Phase 2a: If session is streaming, restore from INFLIGHT cache before // loading full messages (INFLIGHT state is self-contained and sufficient). @@ -1654,7 +1663,7 @@ function renderSessionListFromCache(){ if(s.parent_session_id){ const branchInd=document.createElement('span'); branchInd.className='session-branch-indicator'; - branchInd.textContent='\u2482'; // ⑂ + branchInd.textContent='\u2442'; // ⑂ branchInd.title=(typeof t==='function'?t('forked_from'):'Forked from')+' '+s.parent_session_id; branchInd.style.cursor='pointer'; branchInd.onclick=(e)=>{ diff --git a/static/style.css b/static/style.css index bae5cfb7..153ba8bb 100644 --- a/static/style.css +++ b/static/style.css @@ -735,6 +735,8 @@ .msg-body pre code{background:none;padding:0;border-radius:0;color:var(--pre-text);font-size:13px;line-height:1.6;} /* Keep original theme background — prevent prism-tomorrow from overriding --code-bg */ .msg-body pre[class*="language-"],.msg-body pre code[class*="language-"]{background:var(--code-bg) !important;} + /* Fix #1463: Prism YAML grammar collapses newlines inside token spans — force pre */ + .msg-body pre code.language-yaml .token{white-space:pre !important;} .pre-header{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);padding:8px 16px 8px;background:var(--input-bg);border-radius:10px 10px 0 0;border:1px solid var(--border);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:6px;} .pre-header::before{content:'';width:8px;height:8px;border-radius:50%;background:var(--muted);opacity:.4;} .pre-header+pre{border-radius:0 0 10px 10px;border-top:none;margin-top:0;} @@ -1128,6 +1130,8 @@ .preview-md pre code{background:none;padding:0;color:var(--pre-text);font-size:11.5px;line-height:1.55;} /* Keep original theme background — prevent prism-tomorrow from overriding --code-bg */ .preview-md pre[class*="language-"],.preview-md pre code[class*="language-"]{background:var(--code-bg) !important;} + /* Fix #1463: Prism YAML grammar collapses newlines inside token spans — force pre */ + .preview-md pre code.language-yaml .token{white-space:pre !important;} .preview-md blockquote{border-left:3px solid var(--blue);padding-left:12px;color:var(--muted);font-style:italic;margin:8px 0;} .preview-md blockquote p{margin:0;} .preview-md strong{color:var(--strong);font-weight:600;}.preview-md em{color:var(--em);} diff --git a/static/sw.js b/static/sw.js index 9e43db66..cb8e2071 100644 --- a/static/sw.js +++ b/static/sw.js @@ -7,18 +7,18 @@ // Cache version is injected by the server at request time (routes.py /sw.js handler). // Bumps automatically whenever the git commit changes — no manual edits needed. -const CACHE_NAME = 'hermes-shell-__CACHE_VERSION__'; +const CACHE_NAME = 'hermes-shell-__WEBUI_VERSION__'; // Static assets that form the app shell. // -// Versioned assets (CSS + JS) include `?v=__CACHE_VERSION__` to match the +// Versioned assets (CSS + JS) include `?v=__WEBUI_VERSION__` to match the // query string the page sends — see index.html. Without the version query // here, every cache lookup against `?v=...` URLs would miss and fall through // to network, defeating the pre-cache. // // Unversioned assets (`./`, manifest.json, favicons) are referenced from // index.html without a cache-bust query, so they stay unversioned here too. -const VQ = '?v=__CACHE_VERSION__'; +const VQ = '?v=__WEBUI_VERSION__'; const SHELL_ASSETS = [ './', './static/style.css' + VQ, diff --git a/tests/test_465_session_branching.py b/tests/test_465_session_branching.py index 058a0862..8be147fc 100644 --- a/tests/test_465_session_branching.py +++ b/tests/test_465_session_branching.py @@ -225,7 +225,7 @@ def test_sidebar_parent_indicator(): "sessions.js should check parent_session_id" assert 'session-branch-indicator' in src, \ "Should have session-branch-indicator class" - assert '\\u2482' in src, \ + assert '\\u2442' in src, \ "Should use ⑂ character for parent indicator" diff --git a/tests/test_gateway_sync.py b/tests/test_gateway_sync.py index d2dff359..e9eef69a 100644 --- a/tests/test_gateway_sync.py +++ b/tests/test_gateway_sync.py @@ -208,6 +208,41 @@ def test_gateway_sessions_appear_when_enabled(): post('/api/settings', {'show_cli_sessions': False}) +def test_webui_state_db_session_without_sidecar_appears_when_agent_sessions_enabled(): + """Regression: WebUI-origin rows in state.db can recover missing JSON sidecars.""" + conn = _ensure_state_db() + sid = 'webui_state_only_001' + try: + _insert_agent_session_row( + conn, + session_id=sid, + source='webui', + title='Recovered WebUI Session', + model='openai/gpt-5', + messages=2, + ) + + post('/api/settings', {'show_cli_sessions': True}) + + data, status = get('/api/sessions') + assert status == 200 + sessions = data.get('sessions', []) + recovered = [s for s in sessions if s.get('session_id') == sid] + assert len(recovered) == 1, ( + "WebUI-origin sessions that exist in state.db but have no JSON sidecar " + "should be surfaced through the agent-session bridge for recovery." + ) + assert recovered[0].get('source_tag') == 'webui' + assert recovered[0].get('is_cli_session') is True + finally: + try: + _remove_test_sessions(conn, sid) + conn.close() + except Exception: + pass + post('/api/settings', {'show_cli_sessions': False}) + + def test_gateway_sessions_without_messages_are_hidden_from_sidebar(): """Regression: empty agent session rows must not appear as broken sidebar entries.""" conn = _ensure_state_db() diff --git a/tests/test_pwa_manifest_sw.py b/tests/test_pwa_manifest_sw.py index 40220dec..730d4a9a 100644 --- a/tests/test_pwa_manifest_sw.py +++ b/tests/test_pwa_manifest_sw.py @@ -2,7 +2,7 @@ Covers: - manifest.json is valid JSON with required PWA fields -- sw.js has the `__CACHE_VERSION__` placeholder the server replaces at request time +- sw.js has the `__WEBUI_VERSION__` placeholder the server replaces at request time - sw.js offline-fallback uses a resolved promise (not `caches.match() || fallback` which is broken — Promise objects are always truthy in `||` checks, so the fallback Response would never be used) @@ -52,11 +52,30 @@ class TestManifest: class TestServiceWorker: def test_sw_has_cache_version_placeholder(self): src = SW.read_text(encoding="utf-8") - assert "__CACHE_VERSION__" in src, ( - "sw.js must contain __CACHE_VERSION__ placeholder for the server " + assert "__WEBUI_VERSION__" in src, ( + "sw.js must contain __WEBUI_VERSION__ placeholder for the server " "handler at /sw.js to replace with WEBUI_VERSION at request time" ) + def test_sw_js_has_no_merge_conflict_markers(self): + """Regression guard for v0.50.279 stage build: a leftover git conflict + marker in static/sw.js made the file fail to parse as JavaScript even + though the substring-based source-string tests still passed (the + ``__WEBUI_VERSION__`` token was present, just inside the conflict block). + + A broken sw.js means the install handler throws on script load → SW + never reaches activated state → old SW keeps controlling the page → + every "old SW deletes other caches" guarantee is forfeited and frontend + cache-bust pathways silently break. Caught by Opus advisor pre-merge, + ship blocked. This test would have caught it too. + """ + src = SW.read_text(encoding="utf-8") + for marker in ("<<<<<<<", "=======\n", ">>>>>>>"): + assert marker not in src, ( + f"static/sw.js contains conflict marker {marker!r}; " + "the merge resolution did not actually land. Reject ship." + ) + def test_sw_bypasses_api_and_stream(self): src = SW.read_text(encoding="utf-8") assert "/api/" in src, "SW must bypass /api/* (no cached auth/session responses)" @@ -117,8 +136,8 @@ class TestPWARoutes: idx = src.find('"/sw.js"') assert idx != -1, "routes.py must handle /sw.js" block = src[idx:idx + 1000] - assert "__CACHE_VERSION__" in block, ( - "sw.js route must replace __CACHE_VERSION__ with the current WEBUI_VERSION" + assert "__WEBUI_VERSION__" in block, ( + "sw.js route must replace __WEBUI_VERSION__ with the current WEBUI_VERSION" ) assert "WEBUI_VERSION" in block, ( "sw.js route must import and use WEBUI_VERSION for cache busting" @@ -185,7 +204,7 @@ class TestIndexHtmlIntegration: def test_sw_shell_assets_match_versioned_asset_urls(self): """The service worker's SHELL_ASSETS pre-cache list must use the same - `?v=__CACHE_VERSION__` suffix on JS+CSS that index.html sends, so that + `?v=__WEBUI_VERSION__` suffix on JS+CSS that index.html sends, so that the pre-cached entries actually serve when the page requests them. Without this, every `cache.match()` for a versioned asset URL (e.g. @@ -208,13 +227,13 @@ class TestIndexHtmlIntegration: "terminal.js", "onboarding.js", ): - # Either inline `?v=__CACHE_VERSION__` or via the VQ constant + # Either inline `?v=__WEBUI_VERSION__` or via the VQ constant # produces a URL string the cache lookup can match. - has_inline = f"{asset}?v=__CACHE_VERSION__" in src + has_inline = f"{asset}?v=__WEBUI_VERSION__" in src has_concat = f"{asset}' + VQ" in src or f"{asset}\" + VQ" in src assert has_inline or has_concat, ( f"sw.js SHELL_ASSETS entry for {asset} must carry " - "?v=__CACHE_VERSION__ to match the URL the page requests" + "?v=__WEBUI_VERSION__ to match the URL the page requests" ) def test_index_route_url_encodes_asset_version(self): diff --git a/tests/test_stale_stream_cleanup.py b/tests/test_stale_stream_cleanup.py new file mode 100644 index 00000000..fe117d01 --- /dev/null +++ b/tests/test_stale_stream_cleanup.py @@ -0,0 +1,65 @@ +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +ROUTES_SRC = (REPO / "api" / "routes.py").read_text(encoding="utf-8") +SESSIONS_SRC = (REPO / "static" / "sessions.js").read_text(encoding="utf-8") +SW_SRC = (REPO / "static" / "sw.js").read_text(encoding="utf-8") + + +def test_stale_stream_cleanup_helper_exists(): + assert "def _clear_stale_stream_state(session)" in ROUTES_SRC + assert "stream_id in STREAMS" in ROUTES_SRC + assert "session.active_stream_id = None" in ROUTES_SRC + assert "session.pending_user_message = None" in ROUTES_SRC + assert "session.pending_attachments = []" in ROUTES_SRC + assert "session.pending_started_at = None" in ROUTES_SRC + assert "session.save()" in ROUTES_SRC + + +def test_session_load_clears_stale_stream_before_response(): + load_pos = ROUTES_SRC.index("s = get_session(sid, metadata_only=(not load_messages))") + cleanup_pos = ROUTES_SRC.index("_clear_stale_stream_state(s)", load_pos) + response_pos = ROUTES_SRC.index('"active_stream_id": getattr(s, "active_stream_id", None)', cleanup_pos) + assert load_pos < cleanup_pos < response_pos + + +def test_chat_start_clears_stale_pending_state_not_only_active_id(): + stale_comment_pos = ROUTES_SRC.index("# Stale stream id from a previous run; clear and continue.") + cleanup_pos = ROUTES_SRC.index("_clear_stale_stream_state(s)", stale_comment_pos) + stream_id_pos = ROUTES_SRC.index("stream_id = uuid.uuid4().hex", cleanup_pos) + assert stale_comment_pos < cleanup_pos < stream_id_pos + + +def test_frontend_drops_inflight_cache_when_server_session_is_idle(): + marker = "If the server says the session is idle, discard any browser-side inflight" + marker_pos = SESSIONS_SRC.index(marker) + window = SESSIONS_SRC[marker_pos:marker_pos + 500] + assert "if(!activeStreamId&&INFLIGHT[sid])" in window + assert "delete INFLIGHT[sid]" in window + assert "clearInflightState" in window + assert "S.busy=false" in window + + +def test_service_worker_cache_bumped_for_frontend_fix_delivery(): + """The SW CACHE_NAME must be keyed on the WEBUI_VERSION placeholder so + every release naturally invalidates the previous shell cache and delivers + the frontend half of the stale-stream cleanup fix to existing browsers. + + Originally pinned a manual `-stale-stream-cleanup1` suffix on + `CACHE_NAME` (PR #1525 author shipped that to force-bump existing + SWs). During the v0.50.279 stage build that suffix collided with the + independent #1517 placeholder rename (`__CACHE_VERSION__` → + `__WEBUI_VERSION__`), so the maintainer dropped the manual suffix in + favor of the canonical version-token path. The natural bump still + invalidates the old cache via `keys.filter((k) => k !== CACHE_NAME)` + in the activate handler — same delivery guarantee, less churn. + """ + # CACHE_NAME must include the WEBUI_VERSION placeholder so each release + # produces a different cache name. The activate handler then deletes any + # cache whose key != current CACHE_NAME, so the old shell is reaped on + # every upgrade and the new sessions.js (with the INFLIGHT[sid] clear) + # ships to existing browsers. + assert "CACHE_NAME = 'hermes-shell-__WEBUI_VERSION__'" in SW_SRC, ( + "SW CACHE_NAME must include __WEBUI_VERSION__ so each release " + "invalidates the previous cache and delivers frontend changes." + ) diff --git a/tests/test_streaming_max_tokens_quota.py b/tests/test_streaming_max_tokens_quota.py new file mode 100644 index 00000000..2e37734d --- /dev/null +++ b/tests/test_streaming_max_tokens_quota.py @@ -0,0 +1,39 @@ +"""Regression coverage for WebUI streaming provider failure handling. + +The incident this guards against: WebUI-created AIAgent instances did not pass +config.yaml's max_tokens, so a fallback Claude model via OpenRouter requested its +native 64k output ceiling and failed with HTTP 402 "more credits / fewer +max_tokens". The stream then looked like a stuck Thinking card instead of a +clear quota error. +""" +from pathlib import Path + + +STREAMING = Path(__file__).resolve().parents[1] / "api" / "streaming.py" + + +def _src() -> str: + return STREAMING.read_text(encoding="utf-8") + + +def test_streaming_passes_configured_max_tokens_to_agent(): + src = _src() + assert "_raw_max_tokens = _cfg.get('max_tokens')" in src + assert "_agent_cfg_for_tokens.get('max_tokens')" in src + assert "_agent_kwargs['max_tokens'] = _max_tokens_cfg" in src + + +def test_streaming_agent_cache_signature_includes_max_tokens_and_fallback(): + src = _src() + assert "_max_tokens_cfg or ''" in src + assert "_fallback_resolved or {}" in src + + +def test_openrouter_more_credits_error_is_classified_as_quota(): + src = _src() + assert "'more credits' in _err_lower" in src + assert "'can only afford' in _err_lower" in src + assert "'fewer max_tokens' in _err_lower" in src + assert "'more credits' in _exc_lower" in src + assert "'can only afford' in _exc_lower" in src + assert "'fewer max_tokens' in _exc_lower" in src