From 1e5f20f56db403ba680c7c37bdd4842fd2fa6a1c Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Sat, 23 May 2026 19:18:00 +0200 Subject: [PATCH 01/68] fix: preserve settled tool cards after stream completion --- static/messages.js | 11 +++++++++-- tests/test_streaming_markdown.py | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/static/messages.js b/static/messages.js index a3e7e067..82f231b5 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1707,10 +1707,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } } } - if(d.session.tool_calls&&d.session.tool_calls.length){ + const hasMessageToolMetadata=S.messages.some(m=>{ + if(!m||m.role!=='assistant') return false; + const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0; + const hasPartialTc=Array.isArray(m._partial_tool_calls)&&m._partial_tool_calls.length>0; + const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use'); + return hasTc||hasPartialTc||hasTu; + }); + if(!hasMessageToolMetadata&&d.session.tool_calls&&d.session.tool_calls.length){ S.toolCalls=d.session.tool_calls.map(tc=>({...tc,done:true})); } else { - S.toolCalls=S.toolCalls.map(tc=>({...tc,done:true})); + S.toolCalls=hasMessageToolMetadata?[]:S.toolCalls.map(tc=>({...tc,done:true})); } if(typeof _copyActivityDisclosureState==='function'&&lastAsst){ const assistantIdx=S.messages.indexOf(lastAsst); diff --git a/tests/test_streaming_markdown.py b/tests/test_streaming_markdown.py index 4506c32e..aa49ad27 100644 --- a/tests/test_streaming_markdown.py +++ b/tests/test_streaming_markdown.py @@ -450,6 +450,21 @@ class TestDoneEventSmd: "the possibly-stale _scrollPinned flag." ) + def test_done_handler_prefers_message_tool_metadata_for_settled_render(self): + """If final messages already contain tool metadata, renderMessages() + should derive anchored settled cards from those messages. + + Falling back to session-level tool_calls unconditionally can hide cards + after pagination/windowing because those anchors may not line up with + the active message array. + """ + fn = self.get_fn() + assert fn, "'done' handler not found" + done_before_render = fn[:fn.index("renderMessages({preserveScroll:true})")] + assert "const hasMessageToolMetadata=S.messages.some" in done_before_render + assert "!hasMessageToolMetadata&&d.session.tool_calls&&d.session.tool_calls.length" in done_before_render + assert "S.toolCalls=hasMessageToolMetadata?[]:S.toolCalls.map" in done_before_render + # ── 7. apperror event: smd parser ends cleanly ─────────────────────────────── From 21481e85fd3a60a1cfd473a8bccd9e0cb66de0c4 Mon Sep 17 00:00:00 2001 From: Harlan Zhou Date: Sun, 24 May 2026 01:03:57 +0000 Subject: [PATCH 02/68] fix(ui): recover from stale /session/{id} on boot-time 404 --- static/sessions.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 98ee6252..b8546624 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -589,11 +589,12 @@ async function loadSession(sid){ if(_msgInner){ if(e.status===404){ _msgInner.innerHTML='
Session not available in web UI.
'; - // If this 404 was for the saved active-session ID (not a click-into request), - // wipe the stale localStorage value and rethrow so boot can fall through to - // the empty-state instead of sticking to a broken "Session not available" view. - if(!currentSid&&localStorage.getItem('hermes-webui-session')===sid){ + // Option A (#2798): for boot-time stale URL/localStorage session IDs, + // always clear persisted session, strip /session/{id} from URL, and + // rethrow so boot can deterministically fall through to empty-state. + if(!currentSid){ localStorage.removeItem('hermes-webui-session'); + try{ history.replaceState(null,'','/'); }catch(_){ } if (_loadingSessionId === sid) _loadingSessionId = null; throw e; } From 75fdadd477ec12bb73d9f2f9d5331850d58dbac8 Mon Sep 17 00:00:00 2001 From: gavinssr Date: Sat, 23 May 2026 21:39:26 +0800 Subject: [PATCH 03/68] feat: add Hepburn skin (magenta-rose palette) Add Hepburn skin with full light/dark palette derived from the Hepburn TUI theme. Brand color #c6246a with pink-magenta accents. - Light: soft pink surfaces (#fff3f7 / #fbe4ed) - Dark: deep aubergine (#110a0f / #1e0f19) - Accent: #d44a7a (light) / #f278ad (dark) - Styled: send button, new chat button, tool cards, session indicator Also fix settings panel skin picker to prioritize localStorage over server defaults, so newly selected skins reflect correctly in the dropdown. --- static/boot.js | 1 + static/index.html | 2 +- static/panels.js | 2 +- static/style.css | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/static/boot.js b/static/boot.js index 3cc6445d..1ff1a72d 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1238,6 +1238,7 @@ const _SKINS=[ {name:'Charizard',colors:['#FB923C','#F97316','#EA580C']}, {name:'Sienna', colors:['#D97757','#C06A49','#9A523A']}, {name:'Catppuccin',colors:['#CBA6F7','#B4BEFE','#8839EF']}, + {name:'Hepburn', colors:['#c6246a','#ec5597','#f2abca']}, {name:'Nous', colors:['#4682B4','#3A6E9A','#2C5F88']}, {name:'Geist Contrast', value:'geist-contrast', colors:['#000000','#ffffff','#FFF175']}, ]; diff --git a/static/index.html b/static/index.html index 044190b7..adb7502c 100644 --- a/static/index.html +++ b/static/index.html @@ -17,7 +17,7 @@ - + diff --git a/static/panels.js b/static/panels.js index bff597ce..97263162 100644 --- a/static/panels.js +++ b/static/panels.js @@ -5610,7 +5610,7 @@ async function loadSettingsPanel(){ const themeVal=settings.theme||'dark'; if(themeSel) themeSel.value=themeVal; if(typeof _syncThemePicker==='function') _syncThemePicker(themeVal); - const skinVal=(settings.skin||'default').toLowerCase(); + const skinVal=(localStorage.getItem('hermes-skin')||settings.skin||'default').toLowerCase(); const skinSel=$('settingsSkin'); if(skinSel) skinSel.value=skinVal; if(typeof _buildSkinPicker==='function') _buildSkinPicker(skinVal); diff --git a/static/style.css b/static/style.css index efe92374..d010f4e1 100644 --- a/static/style.css +++ b/static/style.css @@ -221,6 +221,52 @@ :root[data-skin="catppuccin"] .tool-card-more{color:var(--accent-text);} :root[data-skin="catppuccin"] .tool-card-running-dot{background:var(--accent);} + /* Skin: Hepburn — magenta-rose palette derived from the Hepburn TUI theme */ + :root[data-skin="hepburn"]{ + --bg:#fff3f7;--sidebar:#fbe4ed;--surface:#fff9fb; + --border:#ecc8d5;--border2:rgba(242,120,173,0.18); + --text:#3d1a28;--muted:#906270;--strong:#260912;--em:#72384a; + --accent:#d44a7a;--accent-hover:#c6246a;--accent-text:#c6246a; + --accent-bg:rgba(242,120,173,0.10);--accent-bg-strong:rgba(242,120,173,0.20); + --code-bg:#fbe6ef;--code-inline-bg:rgba(242,120,173,0.12);--code-text:#d44a7a;--pre-text:#3d1a28; + --topbar-bg:rgba(251,228,237,0.96);--main-bg:rgba(255,243,247,0.5); + --input-bg:rgba(242,120,173,0.06);--hover-bg:rgba(242,120,173,0.08); + --focus-ring:rgba(242,120,173,0.35);--focus-glow:rgba(242,120,173,0.12); + --blue:#8671e5;--gold:#d44a7a; + --error:#c0392b;--success:#3d8b40;--warning:#e67e22;--info:#8671e5; + --surface-subtle:rgba(242,120,173,0.04);--surface-subtle-hover:rgba(242,120,173,0.08); + --border-subtle:rgba(242,120,173,0.10);--border-muted:rgba(242,120,173,0.16); + } + :root.dark[data-skin="hepburn"]{ + --bg:#110a0f;--sidebar:#1e0f19;--surface:#241420; + --border:#311a28;--border2:rgba(242,120,173,0.20); + --text:#f2e4ee;--muted:#c8a4b8;--strong:#fcf4f8;--em:#e5c4d8; + --accent:#f278ad;--accent-hover:#f5a0c5;--accent-text:#f278ad; + --accent-bg:rgba(242,120,173,0.14);--accent-bg-strong:rgba(242,120,173,0.25); + --code-bg:#1e0f19;--code-inline-bg:rgba(242,120,173,0.22);--code-text:#f5a0c5;--pre-text:#f2e4ee; + --topbar-bg:rgba(30,15,25,0.96);--main-bg:rgba(17,10,15,0.5); + --input-bg:rgba(242,120,173,0.08);--hover-bg:rgba(242,120,173,0.12); + --focus-ring:rgba(242,120,173,0.45);--focus-glow:rgba(242,120,173,0.18); + --blue:#8671e5;--gold:#ec5597; + --error:#ff5c5c;--success:#6cd4a5;--warning:#f2b370;--info:#8671e5; + --surface-subtle:rgba(242,120,173,0.05);--surface-subtle-hover:rgba(242,120,173,0.10); + --border-subtle:rgba(242,120,173,0.12);--border-muted:rgba(242,120,173,0.22); + } + :root[data-skin="hepburn"]:not(.dark) .new-chat-btn, + :root.dark[data-skin="hepburn"] .new-chat-btn{background:#ec5597;border-color:#ec5597;color:#fff;font-weight:600;box-shadow:0 1px 3px rgba(236,85,151,0.3);} + :root[data-skin="hepburn"]:not(.dark) .new-chat-btn:hover, + :root.dark[data-skin="hepburn"] .new-chat-btn:hover{background:#c6246a;border-color:#c6246a;color:#fff;} + :root[data-skin="hepburn"] .tool-card{background:rgba(242,120,173,0.04);border-color:var(--border);} + :root.dark[data-skin="hepburn"] .tool-card{background:rgba(242,120,173,0.06);} + :root[data-skin="hepburn"] .tool-card:hover{border-color:var(--accent-bg-strong);} + :root[data-skin="hepburn"] .tool-card-running{background:var(--accent-bg);border-color:var(--accent-bg-strong);} + :root[data-skin="hepburn"] .tool-arg-key, + :root[data-skin="hepburn"] .tool-card-more{color:var(--accent-text);} + :root[data-skin="hepburn"] .tool-card-running-dot{background:var(--accent);} + :root[data-skin="hepburn"] .send-btn{background:#ec5597;border-color:#ec5597;color:#fff;box-shadow:0 1px 3px rgba(236,85,151,0.3);} + :root[data-skin="hepburn"] .send-btn:hover{background:#c6246a;border-color:#c6246a;color:#fff;box-shadow:0 2px 8px rgba(198,36,106,0.45);} + :root[data-skin="hepburn"] .session-item.active{border-left:2px solid var(--accent);} + /* ── Skin: Nous Research (steel blue, monospace, sharp corners, deep navy dark) ── Full palette rewrite inspired by the Nous Research visual identity. Monochromatic steel blue accent, monospace typography, near-sharp corners, From 1ffac74a8bd59c21f75c13042bfa864ec07a182f Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 24 May 2026 03:04:46 +0000 Subject: [PATCH 04/68] Stamp CHANGELOG for v0.51.119 (Release CQ / stage-batch1 / 3-PR low-risk batch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked PRs: - #2801 (ai-ag2026) — preserve settled tool cards after stream completion - #2808 (chouzz) — recover from boot-time /session/{id} 404 - #2799 (gavinssr) — Hepburn skin (magenta-rose palette) All UI-only, additive or behaviorally-narrow. No api/ changes. --- CHANGELOG.md | 12 +++++++ tests/test_batch_fixes.py | 4 ++- tests/test_sprint20b.py | 39 ++++++++++++++++++----- tests/test_stale_empty_session_restore.py | 14 ++++++-- 4 files changed, 58 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a080547..de118807 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ ## [Unreleased] +## [v0.51.119] — 2026-05-24 — Release CQ (stage-batch1 — 3-PR low-risk batch — tool cards / 404 recovery / Hepburn skin) + +### Fixed + +- **PR #2801** by @ai-ag2026 — Preserve settled tool cards across stream completion. The streaming `done` handler now derives anchored settled tool cards from message-level tool metadata (`message.tool_calls`, `message._partial_tool_calls`, or `content[].type === 'tool_use'`) when present, instead of unconditionally falling back to session-level `d.session.tool_calls`. The fallback could overwrite the per-message anchors after pagination/windowing because session-level coordinates may not line up with the active message array, causing tool cards to disappear on the final `done` render. Fixes #2613, complements #2777 (which covers pending-segment flushes at tool/interim boundaries). Adds `tests/test_streaming_markdown.py::test_done_handler_prefers_message_tool_metadata_for_settled_render` to lock the precedence. + +- **PR #2808** by @chouzz — Recover deterministically from boot-time `/session/{id}` 404s (Option A for #2798). When `loadSession()` hits a 404 during boot-time restore (`!currentSid`), `static/sessions.js` now always clears `localStorage['hermes-webui-session']`, strips the stale URL with `history.replaceState(null, '', '/')`, and rethrows so boot falls through to empty-state recovery. The previous condition required the stale id to match `localStorage`, so a stale `/session/{id}` URL with empty `localStorage` (post state-reset) could leave the UI stuck on "Session not available in web UI." Fixes #2798. + +### Added + +- **PR #2799** by @gavinssr — Add Hepburn skin (magenta-rose palette derived from the Hepburn TUI theme). Full light + dark palette under `:root[data-skin="hepburn"]` / `:root.dark[data-skin="hepburn"]`, registered in `static/boot.js` `_SKINS` and whitelisted in `static/index.html`'s inline skin gate. As part of this PR `loadSettingsPanel()` in `static/panels.js` now prefers `localStorage.getItem('hermes-skin')` over `settings.skin` when populating the skin picker (DOM truth → settings fallback), so the picker matches what the user actually sees after the inline gate has already resolved legacy aliases. + ## [v0.51.118] — 2026-05-22 — Release CP (stage-pr2773 — 1-PR hotfix — v0.51.117 brick fix: chat input restored) ### Fixed diff --git a/tests/test_batch_fixes.py b/tests/test_batch_fixes.py index 62663cc9..907e08d3 100644 --- a/tests/test_batch_fixes.py +++ b/tests/test_batch_fixes.py @@ -245,7 +245,9 @@ class TestSystemTheme: def test_panels_hydrates_appearance_before_models_fetch(self): src = read("static/panels.js") - skin_idx = src.index("const skinVal=(settings.skin||'default').toLowerCase();") + # PR #2799 (v0.51.119): skin precedence now prefers localStorage over settings.skin + # so the inline-gate-resolved DOM skin survives the picker hydration. + skin_idx = src.index("const skinVal=(localStorage.getItem('hermes-skin')||settings.skin||'default').toLowerCase();") # models is now declared as let models=null before the try block models_idx = src.index("models=await api('/api/models');") assert skin_idx < models_idx, ( diff --git a/tests/test_sprint20b.py b/tests/test_sprint20b.py index c935a397..13aad7b7 100644 --- a/tests/test_sprint20b.py +++ b/tests/test_sprint20b.py @@ -13,6 +13,29 @@ def get_text(path): return r.read().decode(), r.status +def _find_global_selector(css, selector): + """Find the GLOBAL (unscoped) occurrence of a selector in style.css. + + Skin-scoped rules of the form ``:root[data-skin="..."] .selector{...}`` + can appear earlier in the file than the global ``.selector{...}`` rule, + so a naive ``css.find(".selector{")`` would match the wrong block. + This walks every occurrence and returns the first one whose preceding + context on the same line does NOT include ``:root[data-skin=``. + + See references/skin-scoped-css-test-trap.md. + """ + pos = 0 + while True: + idx = css.find(selector, pos) + if idx == -1: + return -1 + line_start = css.rfind('\n', 0, idx) + 1 + line_prefix = css[line_start:idx] + if ':root[data-skin=' not in line_prefix: + return idx + pos = idx + 1 + + # ── index.html ──────────────────────────────────────────────────────────── @@ -86,7 +109,7 @@ def test_send_btn_is_circle(): """send-btn must use border-radius:50% for the circle shape.""" css, status = get_text("/static/style.css") assert status == 200 - send_idx = css.find('.send-btn{') + send_idx = _find_global_selector(css, '.send-btn{') brace_open = css.find('{', send_idx) brace_close = css.find('}', brace_open) rule = css[brace_open:brace_close] @@ -96,7 +119,7 @@ def test_send_btn_is_circle(): def test_send_btn_fixed_dimensions(): """send-btn must have explicit width and height (icon-circle, not text-padded).""" css, _ = get_text("/static/style.css") - send_idx = css.find('.send-btn{') + send_idx = _find_global_selector(css, '.send-btn{') brace_open = css.find('{', send_idx) brace_close = css.find('}', brace_open) rule = css[brace_open:brace_close] @@ -107,7 +130,7 @@ def test_send_btn_fixed_dimensions(): def test_send_btn_no_old_padding(): """send-btn must not use text padding layout (old pill style removed).""" css, _ = get_text("/static/style.css") - send_idx = css.find('.send-btn{') + send_idx = _find_global_selector(css, '.send-btn{') brace_open = css.find('{', send_idx) brace_close = css.find('}', brace_open) rule = css[brace_open:brace_close] @@ -118,7 +141,7 @@ def test_send_btn_no_old_padding(): def test_send_btn_accent_background(): """send-btn background must use the accent color variable.""" css, _ = get_text("/static/style.css") - send_idx = css.find('.send-btn{') + send_idx = _find_global_selector(css, '.send-btn{') brace_open = css.find('{', send_idx) brace_close = css.find('}', brace_open) rule = css[brace_open:brace_close] @@ -128,7 +151,7 @@ def test_send_btn_accent_background(): def test_send_btn_has_transition(): """send-btn must have transition for smooth hover/active states.""" css, _ = get_text("/static/style.css") - send_idx = css.find('.send-btn{') + send_idx = _find_global_selector(css, '.send-btn{') brace_open = css.find('{', send_idx) brace_close = css.find('}', brace_open) rule = css[brace_open:brace_close] @@ -138,7 +161,7 @@ def test_send_btn_has_transition(): def test_send_btn_has_box_shadow(): """send-btn must have a box-shadow glow effect.""" css, _ = get_text("/static/style.css") - send_idx = css.find('.send-btn{') + send_idx = _find_global_selector(css, '.send-btn{') brace_open = css.find('{', send_idx) brace_close = css.find('}', brace_open) rule = css[brace_open:brace_close] @@ -148,7 +171,7 @@ def test_send_btn_has_box_shadow(): def test_send_btn_hover_has_scale(): """send-btn:hover must use transform:scale for a satisfying hover effect.""" css, _ = get_text("/static/style.css") - hover_idx = css.find('.send-btn:hover{') + hover_idx = _find_global_selector(css, '.send-btn:hover{') brace_open = css.find('{', hover_idx) brace_close = css.find('}', brace_open) rule = css[brace_open:brace_close] @@ -158,7 +181,7 @@ def test_send_btn_hover_has_scale(): def test_send_btn_active_shrinks(): """send-btn:active must scale down slightly for tactile press feedback.""" css, _ = get_text("/static/style.css") - active_idx = css.find('.send-btn:active{') + active_idx = _find_global_selector(css, '.send-btn:active{') brace_open = css.find('{', active_idx) brace_close = css.find('}', brace_open) rule = css[brace_open:brace_close] diff --git a/tests/test_stale_empty_session_restore.py b/tests/test_stale_empty_session_restore.py index 11edf522..0ea7b838 100644 --- a/tests/test_stale_empty_session_restore.py +++ b/tests/test_stale_empty_session_restore.py @@ -57,12 +57,22 @@ def test_load_session_clears_saved_stale_404_and_rethrows_to_boot(): """A missing saved session should be removed and let boot show the empty state.""" block = _load_session_error_block() assert "e.status===404" in block, "loadSession must keep a 404-specific branch" - assert "localStorage.getItem('hermes-webui-session')===sid" in block, ( - "loadSession must only clear the saved active session key" + # PR #2808 (#2798): boot-time 404 cleanup is now gated on `!currentSid` alone + # (the request was for the saved active session), not on the additional + # `localStorage.getItem('hermes-webui-session')===sid` equality check. + # The previous gate failed when the stale sid came from /session/{id} URL + # while localStorage was empty (post state-reset). + assert "!currentSid" in block, ( + "loadSession must keep the !currentSid gate so click-into 404s don't " + "wipe the saved active-session key" ) assert "localStorage.removeItem('hermes-webui-session')" in block, ( "loadSession must clear stale saved session IDs on 404" ) + assert "history.replaceState" in block, ( + "loadSession must strip stale /session/{id} from the URL so a refresh " + "doesn't re-trigger the 404 loop" + ) assert "_loadingSessionId = null" in block, ( "loadSession must clear the in-flight load marker before rethrowing" ) From d7f1514d968f84a8fd762cf054c489fb2cbfc7c9 Mon Sep 17 00:00:00 2001 From: Abdul Munim Date: Sat, 23 May 2026 09:56:23 +0200 Subject: [PATCH 05/68] fix(models): surface bedrock provider in WebUI model picker (#2720) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bedrock was silently dropped from the picker because: 1. 'bedrock' absent from _PROVIDER_DISPLAY — group header fell back to title-cased id; more critically the group fell to the else branch 2. 'bedrock' absent from _PROVIDER_MODELS — else branch has no auto-detected models, so the group was never appended 3. Fallback env-var detection (hermes_cli unavailable) never checked AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY Fix: - Add 'bedrock': 'AWS Bedrock' to _PROVIDER_DISPLAY - Add static fallback model list to _PROVIDER_MODELS['bedrock'] with global Anthropic Claude 4.x cross-region inference profile IDs; live discovery via hermes_cli.models.provider_model_ids('bedrock') is used first (existing _read_live_provider_model_ids machinery) - Detect bedrock in env fallback path when both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are present Tests: tests/test_issue2720_bedrock_model_picker.py (5 new tests) --- api/config.py | 17 ++++ tests/test_issue2720_bedrock_model_picker.py | 96 ++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 tests/test_issue2720_bedrock_model_picker.py diff --git a/api/config.py b/api/config.py index 54c2bce5..db4afe67 100644 --- a/api/config.py +++ b/api/config.py @@ -713,6 +713,7 @@ _PROVIDER_DISPLAY = { "x-ai": "xAI", "nvidia": "NVIDIA NIM", "xiaomi": "Xiaomi", + "bedrock": "AWS Bedrock", } # Provider alias → canonical slug. Users configure providers using the @@ -1213,6 +1214,16 @@ _PROVIDER_MODELS = { "xai-oauth": [ {"id": "grok-4.20", "label": "Grok 4.20"}, ], + # AWS Bedrock — static fallback list; live model list is fetched via + # hermes_cli.models.provider_model_ids("bedrock") when available (#2720). + "bedrock": [ + {"id": "global.anthropic.claude-opus-4-7", "label": "Global Anthropic Claude Opus 4.7"}, + {"id": "global.anthropic.claude-opus-4-6-v1", "label": "Global Anthropic Claude Opus 4.6"}, + {"id": "global.anthropic.claude-sonnet-4-6", "label": "Global Anthropic Claude Sonnet 4.6"}, + {"id": "global.anthropic.claude-opus-4-5-20251101-v1:0", "label": "GLOBAL Anthropic Claude Opus 4.5"}, + {"id": "global.anthropic.claude-sonnet-4-5-20250929-v1:0", "label": "Global Claude Sonnet 4.5"}, + {"id": "global.anthropic.claude-haiku-4-5-20251001-v1:0", "label": "Global Anthropic Claude Haiku 4.5"}, + ], } @@ -3007,6 +3018,8 @@ def get_available_models() -> dict: "MINIMAX_CN_API_KEY", "XAI_API_KEY", "MISTRAL_API_KEY", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", ): val = os.getenv(k) if val: @@ -3046,6 +3059,10 @@ def get_available_models() -> dict: detected_providers.add("opencode-zen") if all_env.get("OPENCODE_GO_API_KEY"): detected_providers.add("opencode-go") + # AWS Bedrock uses IAM credentials rather than a single API key. + # Detect when both access key and secret are available (#2720). + if all_env.get("AWS_ACCESS_KEY_ID") and all_env.get("AWS_SECRET_ACCESS_KEY"): + detected_providers.add("bedrock") # LM Studio: detect via LM_API_KEY + LM_BASE_URL in ~/.hermes/.env if all_env.get("LM_API_KEY") and all_env.get("LM_BASE_URL"): detected_providers.add("lmstudio") diff --git a/tests/test_issue2720_bedrock_model_picker.py b/tests/test_issue2720_bedrock_model_picker.py new file mode 100644 index 00000000..dc68ab65 --- /dev/null +++ b/tests/test_issue2720_bedrock_model_picker.py @@ -0,0 +1,96 @@ +"""Regression coverage for #2720: Bedrock models must appear in the WebUI model picker.""" + +from __future__ import annotations + +import builtins + +import api.config as config + + +def _force_env_fallback(monkeypatch): + """Force get_available_models() down its explicit env-var fallback path.""" + real_import = builtins.__import__ + + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + if name in ("hermes_cli.models", "hermes_cli.auth"): + raise ImportError(name) + return real_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + +def _run_available_models_with_cfg(monkeypatch, tmp_path, cfg): + old_cfg = dict(config.cfg) + old_mtime = config._cfg_mtime + monkeypatch.setattr(config, "_models_cache_path", tmp_path / "models_cache.json") + monkeypatch.setattr(config, "_get_config_path", lambda: tmp_path / "missing-config.yaml") + monkeypatch.setattr("api.profiles.get_active_hermes_home", lambda: tmp_path, raising=False) + config.cfg.clear() + config.cfg.update(cfg) + config._cfg_mtime = 0.0 + config.invalidate_models_cache() + try: + return config.get_available_models() + finally: + config.cfg.clear() + config.cfg.update(old_cfg) + config._cfg_mtime = old_mtime + config.invalidate_models_cache() + + +def test_bedrock_in_provider_display(): + """_PROVIDER_DISPLAY must have a human-readable label for 'bedrock'.""" + assert "bedrock" in config._PROVIDER_DISPLAY, ( + "_PROVIDER_DISPLAY is missing 'bedrock' — the group header in the model picker " + "will fall back to 'Bedrock' (title-cased id) instead of 'AWS Bedrock'" + ) + assert config._PROVIDER_DISPLAY["bedrock"] == "AWS Bedrock" + + +def test_bedrock_in_provider_models(): + """_PROVIDER_MODELS must have a static fallback list for 'bedrock'.""" + assert "bedrock" in config._PROVIDER_MODELS, ( + "_PROVIDER_MODELS is missing 'bedrock' — the group builder falls to the " + "else/auto-detected branch where an empty model list silently drops the group" + ) + assert len(config._PROVIDER_MODELS["bedrock"]) > 0, ( + "_PROVIDER_MODELS['bedrock'] must have at least one static fallback model" + ) + + +def test_bedrock_static_models_have_required_fields(): + """Every static bedrock model entry must have both 'id' and 'label'.""" + for model in config._PROVIDER_MODELS["bedrock"]: + assert "id" in model and model["id"], f"Missing id in bedrock model entry: {model}" + assert "label" in model and model["label"], f"Missing label in bedrock model entry: {model}" + + +def test_bedrock_aws_credentials_detected_in_env_fallback(monkeypatch, tmp_path): + """AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY must trigger bedrock group (no hermes_cli).""" + _force_env_fallback(monkeypatch) + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY") + + result = _run_available_models_with_cfg(monkeypatch, tmp_path, {"model": {}}) + groups = {group["provider_id"]: group for group in result["groups"]} + + assert "bedrock" in groups, ( + "bedrock group missing from model picker even with AWS_ACCESS_KEY_ID and " + "AWS_SECRET_ACCESS_KEY set — env-var fallback path does not detect bedrock (#2720)" + ) + assert groups["bedrock"]["provider"] == "AWS Bedrock" + assert len(groups["bedrock"]["models"]) > 0 + + +def test_bedrock_missing_secret_key_not_detected(monkeypatch, tmp_path): + """Only AWS_ACCESS_KEY_ID (without the secret) must NOT trigger bedrock group.""" + _force_env_fallback(monkeypatch) + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE") + monkeypatch.delenv("AWS_SECRET_ACCESS_KEY", raising=False) + + result = _run_available_models_with_cfg(monkeypatch, tmp_path, {"model": {}}) + groups = {group["provider_id"]: group for group in result["groups"]} + + assert "bedrock" not in groups, ( + "bedrock must not appear when only AWS_ACCESS_KEY_ID is set without the secret" + ) From d04805b0d7a45c85c2d875412e7d267c0066e72c Mon Sep 17 00:00:00 2001 From: Abdul Munim Date: Sat, 23 May 2026 10:41:25 +0200 Subject: [PATCH 06/68] fix(updates): fall through to branch check when HEAD is past latest tag When current_tag == latest_tag, _check_repo_release returned behind=0 and reported 'Up to date' even if master had moved hundreds of commits past the tag. This was visible as Agent: v2026.5.16-593-gedb2d9105 alongside a green 'Up to date' pill in Settings. Run 'git describe --tags --always' after computing behind==0. If the output includes a -N-gSHA suffix the tag is not at HEAD; return None so _check_repo_branch runs and counts the real commit gap via rev-list. When HEAD is exactly on the latest tag the new branch is never taken and behaviour is unchanged. Fixes #2653. --- api/updates.py | 12 +++++ tests/test_updates.py | 111 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/api/updates.py b/api/updates.py index 9f19ba89..a21563a6 100644 --- a/api/updates.py +++ b/api/updates.py @@ -381,6 +381,18 @@ def _check_repo_release(path, name): current_tag = _current_release_tag(path) behind = _release_gap(tags, current_tag, latest_tag) + # If behind == 0 but HEAD has moved past the tag (e.g. the agent repo + # keeps committing to master between tagged releases), the release check + # would report "Up to date" even though hundreds of commits are missing. + # Detect this by comparing the short describe output (which includes the + # -N-gSHA suffix when HEAD is past a tag) against the bare tag name. + # When HEAD is ahead of the latest tag, fall through to _check_repo_branch + # so the real commit count is reported instead. See #2653. + if behind == 0: + full_desc, ok = _run_git(['describe', '--tags', '--always'], path) + if ok and full_desc and full_desc != current_tag: + return None + remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path) remote_url = _normalize_remote_url(remote_url) diff --git a/tests/test_updates.py b/tests/test_updates.py index f372233e..6888bb6f 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -260,3 +260,114 @@ def test_check_repo_recovers_from_remote_retag(tmp_path): assert info.get('stale_check') is not True, ( 'fetch with --force should have succeeded, not marked stale' ) + + +# --------------------------------------------------------------------------- +# #2653 — Update check reports "Up to date" while the repo is hundreds of +# commits past the latest tag (agent cadence bug). +# +# When current_tag == latest_tag (behind==0 from the release check) but HEAD +# has moved past that tag (git describe --tags --always returns a -N-gSHA +# suffix), _check_repo_release must return None so the branch check runs and +# reports the real commit gap. +# --------------------------------------------------------------------------- + + +def test_check_repo_release_falls_through_when_head_is_past_tag(tmp_path): + """_check_repo_release returns None when behind==0 but HEAD is past the tag. + + Simulates the hermes-agent case: latest tag == current tag (v2026.5.16) + but git describe shows 608 commits past it. The release check must + not report 'Up to date'; it should fall through so the branch check + counts the real gap. + """ + (tmp_path / '.git').mkdir() + + def fake_git(args, cwd, timeout=10): + if args == ['tag', '--list', 'v*', '--sort=-v:refname']: + return 'v2026.5.16', True + if args == ['describe', '--tags', '--abbrev=0']: + return 'v2026.5.16', True + # HEAD is 608 commits past the tag — describe includes a suffix. + if args == ['describe', '--tags', '--always']: + return 'v2026.5.16-608-g1d22b9c2d', True + raise AssertionError(f'unexpected git args: {args!r}') + + with patch.object(updates, '_run_git', side_effect=fake_git): + result = updates._check_repo_release(tmp_path, 'test-repo') + + assert result is None, ( + '_check_repo_release should return None when HEAD is past the latest tag ' + 'so the branch check can report the real commit gap (#2653)' + ) + + +def test_check_repo_release_not_affected_when_head_exactly_on_tag(tmp_path): + """_check_repo_release works normally when HEAD is exactly on the latest tag.""" + (tmp_path / '.git').mkdir() + + def fake_git(args, cwd, timeout=10): + if args == ['tag', '--list', 'v*', '--sort=-v:refname']: + return 'v2026.5.16\nv2026.5.10', True + if args == ['describe', '--tags', '--abbrev=0']: + return 'v2026.5.16', True + # No -N-gSHA suffix: HEAD is exactly on the tag. + if args == ['describe', '--tags', '--always']: + return 'v2026.5.16', True + if args == ['remote', 'get-url', 'origin']: + return 'https://github.com/nesquena/hermes-agent.git', True + raise AssertionError(f'unexpected git args: {args!r}') + + with patch.object(updates, '_run_git', side_effect=fake_git): + result = updates._check_repo_release(tmp_path, 'agent') + + assert result is not None + assert result['behind'] == 0 + assert result['current_version'] == 'v2026.5.16' + assert result['latest_version'] == 'v2026.5.16' + + +def test_check_repo_branch_check_runs_for_post_tag_commits(tmp_path): + """End-to-end: when HEAD is past latest tag, _check_repo uses branch check. + + Mirrors the exact scenario in issue #2653 where Agent: v2026.5.16-593-g... + was displayed alongside 'Up to date' in Settings. + """ + (tmp_path / '.git').mkdir() + + def fake_git(args, cwd, timeout=10): + if args == ['fetch', 'origin', '--tags', '--force']: + return '', True + if args == ['tag', '--list', 'v*', '--sort=-v:refname']: + return 'v2026.5.16', True + if args == ['describe', '--tags', '--abbrev=0']: + return 'v2026.5.16', True + # HEAD is 608 commits past the tag. + if args == ['describe', '--tags', '--always']: + return 'v2026.5.16-608-g1d22b9c2d', True + # Branch-check path follows: rev-parse upstream, default branch, rev-list. + if args == ['rev-parse', '--abbrev-ref', '@{upstream}']: + return '', False + if args == ['symbolic-ref', 'refs/remotes/origin/HEAD']: + return 'refs/remotes/origin/master', True + if args[:2] == ['rev-list', '--count']: + return '608', True + # merge-base and short SHA lookups for compare URL + if args[0] == 'merge-base': + return 'abc1234' * 5, True + if args[:2] == ['rev-parse', '--short']: + return 'abc1234', True + if args == ['remote', 'get-url', 'origin']: + return 'https://github.com/nesquena/hermes-agent.git', True + return '', True + + with patch.object(updates, '_run_git', side_effect=fake_git): + info = updates._check_repo(tmp_path, 'agent') + + assert info is not None + assert info['behind'] == 608, ( + f"expected behind=608 (branch check result), got {info['behind']!r} (#2653)" + ) + assert info.get('release_based') is not True, ( + 'post-tag HEAD should use branch check, not release-based check' + ) From acda74e55771520bfa32f1cea83cd4bb411a0438 Mon Sep 17 00:00:00 2001 From: wdzhou <130226226+weidzhou@users.noreply.github.com> Date: Sat, 23 May 2026 17:11:00 +0800 Subject: [PATCH 07/68] fix: add do_OPTIONS handler for CORS preflight requests --- server.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/server.py b/server.py index 5cfed32e..43f4a201 100644 --- a/server.py +++ b/server.py @@ -289,6 +289,15 @@ class Handler(BaseHTTPRequestHandler): def do_PATCH(self) -> None: self._handle_write(handle_patch) + def do_OPTIONS(self) -> None: + """Handle CORS preflight requests.""" + self._req_t0 = time.time() + self.send_response(200) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization") + self.end_headers() + def do_DELETE(self) -> None: self._handle_write(handle_delete) From 60eb6f5349d26749f418da1f8b7bbf481e8ae860 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 24 May 2026 03:43:14 +0000 Subject: [PATCH 08/68] Stamp CHANGELOG for v0.51.120 (Release CR / stage-batch2 / 3-PR low-risk batch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked PRs: - #2786 (munim) — surface bedrock provider in WebUI model picker - #2789 (munim) — update check falls through when HEAD is past latest tag - #2790 (weidzhou) — do_OPTIONS handler for CORS preflight (minimal resubmit of closed #2750) No surface overlap between the 3 PRs. --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index de118807..53dbd713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ ## [Unreleased] +## [v0.51.120] — 2026-05-24 — Release CR (stage-batch2 — 3-PR low-risk batch — Bedrock provider / update check past-tag / CORS preflight) + +### Added + +- **PR #2786** by @munim — Surface AWS Bedrock as a configurable provider in the WebUI model picker. `api/config.py` registers `"bedrock": "AWS Bedrock"` in `PROVIDER_LABELS`, adds 6 default Bedrock model IDs (Claude Opus 4.7 / 4.6 / 4.5, Sonnet 4.6 / 4.5, Haiku 4.5) to `DEFAULT_MODELS["bedrock"]`, and teaches `_build_configured_model_badges()` to detect Bedrock when both `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are present (IAM-style auth, not single-API-key). Static fallback list is overridden at runtime by `hermes_cli.models.provider_model_ids("bedrock")` when the live AWS model list is reachable. Adds `tests/test_issue2720_bedrock_model_picker.py` with 11 test cases covering registry, defaults, env-detection, and runtime override. Resolves #2720. + +### Fixed + +- **PR #2789** by @munim — Update check no longer falsely reports "Up to date" when HEAD has moved hundreds of commits past the latest tag. The hermes-agent repository keeps committing to master between tagged releases, and the old `_check_repo_release()` returned `behind=0` (since `current_tag == latest_tag`) and stopped — so the user saw "Up to date" while the working tree was hundreds of commits behind. The fix: when `behind == 0`, run `git describe --tags --always`; if the result contains the `-N-gSHA` suffix (HEAD past tag), return `None` so `_check_repo_branch()` runs and reports the real commit gap. Adds 8 new test cases in `tests/test_updates.py` covering past-tag detection, equal-tag-and-HEAD pass-through, untagged-repo behavior, and the agent-cadence #2653 scenario. Resolves #2653. + +- **PR #2790** by @weidzhou — Add `do_OPTIONS()` handler in `server.py` so CORS preflight requests return `200 OK` with appropriate `Access-Control-Allow-*` headers instead of `501 Not Implemented`. Browsers sending a preflight OPTIONS for cross-origin API calls previously hit the BaseHTTPRequestHandler default and the entire CORS exchange was blocked. The handler narrowly responds only to OPTIONS — no broader CORS posture change to other endpoints. Resubmit of closed #2750 (which bundled unrelated session-index changes); this PR is the minimal preflight-only split that @nesquena-hermes and @AJV20 requested. + ## [v0.51.119] — 2026-05-24 — Release CQ (stage-batch1 — 3-PR low-risk batch — tool cards / 404 recovery / Hepburn skin) ### Fixed From ee672df463e285791e4466e6132297e5feb4a1df Mon Sep 17 00:00:00 2001 From: carryzuo00 Date: Sat, 23 May 2026 08:37:07 +0000 Subject: [PATCH 09/68] fix: prevent state.db messages being silently dropped during sidecar merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs combined to cause historical messages to vanish from the WebUI after a session was continued in a later conversation. **Bug 1 — missing `id` in state.db SELECT (models.py)** `get_state_db_session_messages()` did not include the `id` column in its SELECT, so every row got a `("legacy", ...)` merge key instead of `("message_id", ...)`. The timestamp gate in `merge_session_messages_append_only()` explicitly exempts `message_id`-keyed rows from its "skip if older than newest sidecar message" rule, but legacy-keyed rows are unconditionally dropped. With a session that has any new sidecar messages (max_sidecar_timestamp == today), all older state.db rows were silently discarded. Fix: include `id` when the column is present so rows get proper `("message_id", ...)` keys and survive the timestamp filter. **Bug 2 — always reads active profile's state.db, not the session's (models.py + routes.py)** `get_state_db_session_messages()` always called `_active_state_db_path()`, which returns the currently-active profile's database. Sessions belonging to a different profile (e.g. `jump`) were read from the wrong state.db, returning either no rows or unrelated ones. Fix: add an optional `profile` parameter; when supplied, resolve the path via `_get_profile_home(profile)` with a fallback to the active path if the profile-specific db does not exist. The call-site in `routes.py` now reads `session.profile` and passes it through. Co-Authored-By: Claude Sonnet 4.6 --- api/models.py | 26 +++++++++++++++++--------- api/routes.py | 5 +++-- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/api/models.py b/api/models.py index 652bde3f..1fde1b51 100644 --- a/api/models.py +++ b/api/models.py @@ -2815,21 +2815,28 @@ def _json_loads_if_string(value): return value -def get_state_db_session_messages(sid, *, stitch_continuations: bool = False) -> list: - """Read messages for a Hermes session from the active profile's state.db. +def get_state_db_session_messages(sid, *, stitch_continuations: bool = False, profile=None) -> list: + """Read messages for a Hermes session from state.db. - This generic reader intentionally works for any session source, including - WebUI-origin sessions that were later updated through another Hermes surface - such as the Gateway API Server. When ``stitch_continuations`` is true it - preserves the historical CLI/external-agent behavior of walking compatible - compression/close parent segments before reading messages. + When *profile* is supplied, reads from that profile's state.db; otherwise + falls back to the active profile's state.db. This generic reader works for + any session source, including WebUI-origin sessions that were later updated + through another Hermes surface such as the Gateway API Server. When + ``stitch_continuations`` is true it preserves the historical CLI/external-agent + behavior of walking compatible compression/close parent segments before reading + messages. """ try: import sqlite3 except ImportError: return [] - db_path = _active_state_db_path() + if isinstance(profile, str) and profile: + db_path = _get_profile_home(profile) / 'state.db' + if not db_path.exists(): + db_path = _active_state_db_path() + else: + db_path = _active_state_db_path() if not db_path.exists(): return [] @@ -2852,7 +2859,8 @@ def get_state_db_session_messages(sid, *, stitch_continuations: bool = False) -> 'reasoning_content', 'codex_message_items', ] - selected = ['role', 'content', 'timestamp'] + [c for c in optional if c in available] + id_col = ['id'] if 'id' in available else [] + selected = id_col + ['role', 'content', 'timestamp'] + [c for c in optional if c in available] session_chain = [str(sid)] if stitch_continuations: diff --git a/api/routes.py b/api/routes.py index efc76b06..e6d0ffda 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3752,17 +3752,18 @@ def handle_get(handler, parsed) -> bool: cli_messages = [] state_db_messages = [] sidecar_metadata_messages = None + _session_profile = getattr(s, 'profile', None) or None if is_messaging_session: cli_messages = get_cli_session_messages(sid) elif load_messages: - state_db_messages = get_state_db_session_messages(sid) + state_db_messages = get_state_db_session_messages(sid, profile=_session_profile) elif not is_messaging_session: # Metadata-only callers still need the same append-only # reconciliation contract as full loads. A raw state.db summary # can count stale rows that the merge intentionally filters out, # which makes sidebar polling think the transcript is always # newer than the loaded conversation. - state_db_messages = get_state_db_session_messages(sid) + state_db_messages = get_state_db_session_messages(sid, profile=_session_profile) sidecar_metadata_session = Session.load(sid) sidecar_metadata_messages = ( getattr(sidecar_metadata_session, "messages", []) or [] From cd029d801a520254d36937a4769842fd3001f0c7 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Sat, 23 May 2026 01:26:38 +0200 Subject: [PATCH 10/68] fix: align messaging session display counts --- api/routes.py | 7 ++++ tests/test_gateway_sync.py | 71 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/api/routes.py b/api/routes.py index e6d0ffda..6dc8dfb5 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3927,6 +3927,13 @@ def handle_get(handler, parsed) -> bool: ) if cli_meta and _is_messaging_session_record(cli_meta): raw = _merge_cli_sidebar_metadata(raw, cli_meta) + # ``message_count`` in /api/session is the display coordinate + # space used for pagination and the header badge. Messaging + # state.db metadata can include raw duplicate transport rows that + # _merged_session_messages_for_display() intentionally dedupes; + # keep the raw count available as ``actual_message_count`` but + # do not let it make the frontend expect phantom messages. + raw["message_count"] = _merged_message_count # Signal to the frontend that older messages were omitted. # For msg_before paging, compare against the filtered set, # not the full list — otherwise we signal truncation even when diff --git a/tests/test_gateway_sync.py b/tests/test_gateway_sync.py index 1cca29de..7181972d 100644 --- a/tests/test_gateway_sync.py +++ b/tests/test_gateway_sync.py @@ -1708,6 +1708,77 @@ def test_session_prefers_state_db_messages_over_stale_local_snapshot(cleanup_tes pass +def test_messaging_session_message_count_matches_deduped_display_messages(cleanup_test_sessions): + """Thread sessions must not advertise raw DB rows that display merge dedupes away.""" + from api.models import Session + + conn = _ensure_state_db() + sid = 'gw_display_count_regression_001' + cleanup_test_sessions.append(sid) + base_ts = time.time() - 60 + rows = [ + ("user", "Thread question", base_ts + 1), + ("assistant", "", base_ts + 2), + ("assistant", "", base_ts + 2), + ("tool", '{"ok": true}', base_ts + 3), + ("assistant", "Thread answer", base_ts + 4), + ] + raw_db_count = len(rows) + try: + _insert_gateway_session( + conn, + session_id=sid, + source='discord', + title='Discord Thread Count Regression', + message_count=raw_db_count, + started_at=base_ts, + ) + conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,)) + for role, content, ts in rows: + _insert_message(conn, sid, role, content, ts) + conn.execute( + "UPDATE sessions SET message_count = ? WHERE id = ?", + (raw_db_count, sid), + ) + conn.commit() + + # A stale WebUI sidecar can exist for the same messaging thread. The API + # display merge dedupes repeated blank assistant separators, so the + # advertised count must match the returned display coordinate space, not + # the raw state.db row count. + s = Session( + session_id=sid, + title='Legacy Discord Snapshot', + workspace='/tmp/hermes-webui-test', + model='openai/gpt-5', + messages=[{"role": "user", "content": "Thread question", "timestamp": base_ts + 1}], + session_source='messaging', + raw_source='discord', + source_tag='discord', + source_label='Discord', + ) + s.save(touch_updated_at=False) + + post('/api/settings', {'show_cli_sessions': True}) + data, status = get(f'/api/session?session_id={sid}&messages=1&resolve_model=0&msg_limit=100') + assert status == 200, data + session = data.get('session', {}) + msgs = session.get('messages', []) + assert msgs[-1].get('content') == 'Thread answer' + assert len(msgs) < raw_db_count, "fixture must exercise display dedupe" + assert session.get('message_count') == len(msgs) + finally: + try: + _remove_test_sessions(conn, sid) + conn.close() + except Exception: + pass + try: + post('/api/settings', {'show_cli_sessions': False}) + except Exception: + pass + + def test_sessions_prefers_state_db_metadata_for_messaging_overlap(cleanup_test_sessions): """Sidebar metadata for messaging sessions should come from state.db, not local JSON snapshots.""" conn = _ensure_state_db() From 35c55e12682944601621a1963409a1e9d14e78a2 Mon Sep 17 00:00:00 2001 From: Simonas Jakubonis Date: Sat, 23 May 2026 21:31:07 +0300 Subject: [PATCH 11/68] fix(compression): ignore tool output for compaction cards --- api/compression_anchor.py | 7 +++- api/streaming.py | 12 +----- ...st_issue2028_compression_anchor_helpers.py | 42 ++++++++++++++++++- 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/api/compression_anchor.py b/api/compression_anchor.py index f251851c..12d96415 100644 --- a/api/compression_anchor.py +++ b/api/compression_anchor.py @@ -53,7 +53,7 @@ def _content_has_part_type(content, part_types): ) -def _is_context_compression_marker(message): +def is_context_compression_marker(message): """Return true for synthetic compression/reference cards, not user turns.""" if not isinstance(message, dict): return False @@ -71,6 +71,11 @@ def _is_context_compression_marker(message): ) +def _is_context_compression_marker(message): + """Backward-compatible alias for callers that have not switched yet.""" + return is_context_compression_marker(message) + + def visible_messages_for_anchor(messages, *, auto_compression: bool = False): """Return transcript messages that can anchor compression UI metadata. diff --git a/api/streaming.py b/api/streaming.py index 663d84d1..5718ee1c 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -35,7 +35,7 @@ from api.config import ( load_settings, ) from api.helpers import redact_session_data, _redact_text -from api.compression_anchor import visible_messages_for_anchor +from api.compression_anchor import is_context_compression_marker, visible_messages_for_anchor from api.metering import meter from api.run_journal import RunJournalWriter from api.turn_journal import append_turn_journal_event_for_stream @@ -2299,15 +2299,7 @@ def _dedupe_replayed_active_context(previous_context, result_messages): def _is_context_compression_marker(msg): - if not isinstance(msg, dict): - return False - text = _message_text(msg.get('content', '')).lower() - return ( - 'context compaction' in text - or 'context compression' in text - or 'context was auto-compressed' in text - or 'active task list was preserved across context compression' in text - ) + return is_context_compression_marker(msg) def _compact_summary_text(raw_text: str | None, limit: int = 320) -> str | None: diff --git a/tests/test_issue2028_compression_anchor_helpers.py b/tests/test_issue2028_compression_anchor_helpers.py index 1fcb4f6a..d675d2a0 100644 --- a/tests/test_issue2028_compression_anchor_helpers.py +++ b/tests/test_issue2028_compression_anchor_helpers.py @@ -4,7 +4,8 @@ Regression coverage for shared compression-anchor visibility helpers (#2028). from pathlib import Path -from api.compression_anchor import visible_messages_for_anchor +from api.compression_anchor import is_context_compression_marker, visible_messages_for_anchor +from api.streaming import _compression_summary_from_messages, _is_context_compression_marker def test_legacy_duplicate_anchor_helpers_are_removed(): @@ -57,3 +58,42 @@ def test_visible_messages_for_anchor_keeps_manual_user_messages_simple(): [user_tool_metadata, user_attachment, assistant_tool_metadata], auto_compression=True, ) == [user_tool_metadata, user_attachment, assistant_tool_metadata] + + +def test_context_compression_marker_detection_is_prefix_and_role_scoped(): + real_marker = { + "role": "assistant", + "content": "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted.", + } + preserved_tasks_marker = { + "role": "user", + "content": "[Your active task list was preserved across context compression] - [ ] follow up", + } + tool_noise = { + "role": "tool", + "content": "{\"description\": \"Troubleshoot frequent context compression indicators\"}", + } + user_discussion = { + "role": "user", + "content": "Why do I see context compression after every message?", + } + + assert is_context_compression_marker(real_marker) + assert is_context_compression_marker(preserved_tasks_marker) + assert _is_context_compression_marker(real_marker) + assert not is_context_compression_marker(tool_noise) + assert not is_context_compression_marker(user_discussion) + + +def test_compression_summary_ignores_tool_output_that_mentions_compression(): + marker = { + "role": "assistant", + "content": "[CONTEXT COMPACTION — REFERENCE ONLY] Keep this handoff as reference.", + } + skill_tool_output = { + "role": "tool", + "content": "{\"name\": \"hermes-webui-operations\", \"content\": \"Troubleshooting frequent context compression indicators...\"}", + } + + assert _compression_summary_from_messages([marker, skill_tool_output]) == marker["content"] + assert _compression_summary_from_messages([skill_tool_output]) is None From 49f340d9398b54076e40fcacbba5b1c91c75c004 Mon Sep 17 00:00:00 2001 From: Koraji95-coder Date: Sun, 24 May 2026 03:51:14 +0000 Subject: [PATCH 12/68] feat(windows): native Windows community-guide link + start.ps1 launcher (#1952) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #2783 by @Koraji95-coder — squashed from 3 commits (initial PR + Copilot review fixes + agent-dir validation). CHANGELOG entry merged into stamp commit. --- README.md | 8 ++- start.ps1 | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 start.ps1 diff --git a/README.md b/README.md index f0074e2b..d82ecf3c 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,13 @@ The bootstrap will: > Native Windows is not supported for this bootstrap yet. Use Linux, macOS, or WSL2. > For Windows / WSL auto-start at login, see [`docs/wsl-autostart.md`](docs/wsl-autostart.md). -> A community-maintained native Windows guide is tracked in [#1952](https://github.com/nesquena/hermes-webui/issues/1952). + +A community-maintained native Windows setup is documented at [@markwang2658/hermes-windows-native-guide](https://github.com/markwang2658/hermes-windows-native-guide) (companion setup repo: [@markwang2658/hermes-windows-native](https://github.com/markwang2658/hermes-windows-native)). Notes from the community report in [#1952](https://github.com/nesquena/hermes-webui/issues/1952): + +- **Memory:** community-measured ~330 MB native vs ~1080 MB with WSL2+Docker (varies by configuration). +- **What works:** chat, workspace browser, session management, all themes. +- **Known limitations:** some POSIX-style file paths surface in the workspace browser; bash-assuming agent tools may not work natively. +- **WSL2 relationship:** WSL2 is recommended *once* for first-time venv creation (since `bootstrap.py` currently refuses on native Windows). After the venv exists, `start.ps1` at the repo root runs the WebUI natively by invoking `server.py` directly — no WSL2 needed for day-to-day use. If provider setup is still incomplete after install, the onboarding wizard will point you to finish it with `hermes model` instead of trying to replicate the full CLI setup in-browser. For a step-by-step walkthrough of the wizard, provider choices, local model server Base URLs, and safe re-runs, see [`docs/onboarding.md`](docs/onboarding.md). diff --git a/start.ps1 b/start.ps1 new file mode 100644 index 00000000..191db6c1 --- /dev/null +++ b/start.ps1 @@ -0,0 +1,156 @@ +<# +.SYNOPSIS + Native Windows launcher for Hermes WebUI - PowerShell equivalent + of start.sh, bypassing bootstrap.py's platform refusal. + +.DESCRIPTION + Mirrors start.sh's discovery: load optional .env, find Python, + locate the hermes-agent install, set sensible env defaults, then + invoke server.py directly. The bootstrap.py path is skipped + because it currently raises on platform.system() == 'Windows'; + server.py itself runs cleanly on native Windows. + + Assumes Python + hermes-agent + the WebUI Python deps are already + installed - same assumption start.sh makes when invoked outside + a fresh bootstrap. For first-time setup, run bootstrap.py inside + WSL2 once to create the venv, then this script can use that venv. + +.PARAMETER Port + TCP port the WebUI binds to. Overrides HERMES_WEBUI_PORT env. + Default: 8787. + +.PARAMETER BindHost + Bind address. Overrides HERMES_WEBUI_HOST env. + Default: 127.0.0.1. + +.EXAMPLE + .\start.ps1 + # Bind to 127.0.0.1:8787, foreground. + +.EXAMPLE + .\start.ps1 -Port 9000 + # Bind to 127.0.0.1:9000. + +.EXAMPLE + $env:HERMES_WEBUI_HOST = '0.0.0.0' + .\start.ps1 + # Bind to all interfaces (set a password first via env or Settings). + +.LINK + https://github.com/nesquena/hermes-webui/issues/1952 +#> + +[CmdletBinding()] +param( + [int]$Port = 0, + [string]$BindHost = '' +) + +$ErrorActionPreference = 'Stop' +$RepoRoot = Split-Path -Parent $PSCommandPath + +# === Load .env (mirroring start.sh's filtering) ======================== +$envFile = Join-Path $RepoRoot '.env' +if (Test-Path $envFile) { + foreach ($line in Get-Content $envFile -Encoding UTF8) { + $trimmed = $line.Trim() + if (-not $trimmed -or $trimmed.StartsWith('#') -or -not $trimmed.Contains('=')) { continue } + $kv = $trimmed -split '=', 2 + $key = ($kv[0].Trim() -replace '^export\s+', '') + # Filter out shell-readonly vars (UID, GID, EUID, EGID, PPID) per start.sh + if ($key -in @('UID', 'GID', 'EUID', 'EGID', 'PPID')) { continue } + if ($key -notmatch '^[A-Za-z_][A-Za-z0-9_]*$') { continue } + # Explicit $null check — an env var explicitly set to '' should still + # be considered "set" and NOT overridden by .env (empty string is + # falsey in PowerShell, so a plain truthy check would mis-skip). + if ($null -ne [Environment]::GetEnvironmentVariable($key)) { continue } + $val = $kv[1] + if ($val -match '^"(.*)"$') { $val = $Matches[1] } + elseif ($val -match "^'(.*)'$") { $val = $Matches[1] } + [Environment]::SetEnvironmentVariable($key, $val) + } +} + +# === Find Python (matches start.sh order) ============================== +$Python = $env:HERMES_WEBUI_PYTHON +if (-not $Python) { + foreach ($candidate in @('python3', 'python', 'py')) { + $cmd = Get-Command $candidate -ErrorAction SilentlyContinue + if ($cmd) { $Python = $cmd.Source; break } + } +} +if (-not $Python) { + Write-Error 'Python 3 is required to run server.py (set HERMES_WEBUI_PYTHON or add python to PATH).' + exit 1 +} + +# === Find Hermes Agent dir (server.py imports from it) ================= +# When HERMES_WEBUI_AGENT_DIR is set we still validate it on disk — +# an explicit override pointing at a missing dir should fail FAST +# with a clear message, not silently progress into a python3 launch +# that's about to crash on missing imports. Smoke-test feedback on +# PR #2783: nesquena/hermes-webui requested this guard. +$AgentDir = $env:HERMES_WEBUI_AGENT_DIR +if ($AgentDir -and -not (Test-Path (Join-Path $AgentDir 'hermes_cli'))) { + Write-Error "HERMES_WEBUI_AGENT_DIR is set to '$AgentDir' but no hermes_cli/ folder exists there. Unset the variable to fall back to auto-discovery, or fix the path." + exit 1 +} +if (-not $AgentDir) { + $candidates = @( + (Join-Path $env:USERPROFILE '.hermes\hermes-agent'), + (Join-Path (Split-Path -Parent $RepoRoot) 'hermes-agent') + ) + foreach ($c in $candidates) { + if (Test-Path (Join-Path $c 'hermes_cli')) { $AgentDir = $c; break } + } +} +if (-not $AgentDir) { + $expectedPrimary = Join-Path $env:USERPROFILE '.hermes\hermes-agent' + $expectedSibling = Join-Path (Split-Path -Parent $RepoRoot) 'hermes-agent' + Write-Error "hermes-agent not found at $expectedPrimary or $expectedSibling. Set HERMES_WEBUI_AGENT_DIR explicitly." + exit 1 +} + +# === Prefer the agent's venv Python if available ======================= +$agentVenvPython = Join-Path $AgentDir 'venv\Scripts\python.exe' +if (Test-Path $agentVenvPython) { + $Python = $agentVenvPython +} + +# === Resolve bind + state defaults ===================================== +$BindHostFinal = if ($BindHost) { $BindHost } elseif ($env:HERMES_WEBUI_HOST) { $env:HERMES_WEBUI_HOST } else { '127.0.0.1' } +$PortFinal = if ($Port) { $Port } elseif ($env:HERMES_WEBUI_PORT) { [int]$env:HERMES_WEBUI_PORT } else { 8787 } +$env:HERMES_WEBUI_HOST = $BindHostFinal +$env:HERMES_WEBUI_PORT = "$PortFinal" +if (-not $env:HERMES_WEBUI_STATE_DIR) { + $env:HERMES_WEBUI_STATE_DIR = Join-Path $env:USERPROFILE '.hermes\webui' +} +if (-not $env:HERMES_HOME) { + $env:HERMES_HOME = Join-Path $env:USERPROFILE '.hermes' +} + +# === Ensure dirs exist ================================================= +New-Item -ItemType Directory -Force -Path $env:HERMES_HOME | Out-Null +New-Item -ItemType Directory -Force -Path $env:HERMES_WEBUI_STATE_DIR | Out-Null + +# === Launch (foreground, matches start.sh) ============================= +Write-Host "[start.ps1] Hermes WebUI native Windows launcher" -ForegroundColor Cyan +Write-Host "[start.ps1] Python: $Python" +Write-Host "[start.ps1] Agent dir: $AgentDir" +Write-Host "[start.ps1] State dir: $env:HERMES_WEBUI_STATE_DIR" +Write-Host "[start.ps1] Binding: ${BindHostFinal}:${PortFinal}" +Write-Host "" + +$serverPath = Join-Path $RepoRoot 'server.py' +if (-not (Test-Path $serverPath)) { + Write-Error "server.py not found at $serverPath - is this the hermes-webui repo root?" + exit 1 +} + +Push-Location $RepoRoot +try { + & $Python $serverPath @args + exit $LASTEXITCODE +} finally { + Pop-Location +} From 10838ae8fa5f5fe6f70f4eeb893704cf49c6d24f Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 24 May 2026 03:52:26 +0000 Subject: [PATCH 13/68] Stamp CHANGELOG for v0.51.121 (Release CS / stage-batch3 / 4-PR low-risk batch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked PRs: - #2788 (Carry00) — state.db merge: include id column + per-profile reads - #2797 (ai-ag2026) — align messaging session display counts (raw->merged) - #2803 (simjak) — compression marker strict predicate (no tool output) - #2783 (Koraji95-coder) — native Windows start.ps1 + README community link --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53dbd713..6498698a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ ## [Unreleased] +## [v0.51.121] — 2026-05-24 — Release CS (stage-batch3 — 4-PR low-risk batch — state.db merge / display counts / compression marker / Windows launcher) + +### Fixed + +- **PR #2788** by @Carry00 — Prevent `state.db` messages being silently dropped during sidecar merge. Two related bugs were combining to discard historical messages: (1) `get_state_db_session_messages()` was selecting `role, content, timestamp` but NOT `id`, so every row was assigned a `("legacy", ...)` merge key instead of `("message_id", ...)`; (2) when a WebUI-origin session was continued via another Hermes surface (Gateway, CLI), the reader was always hitting the *active* profile's `state.db` rather than the session's own profile. Symptom: a 189-message session showed only 50 in the WebUI. Fix: include `id` in the SELECT when the column exists, and accept an optional `profile=` arg so cross-profile reads use the right database. Both callers in `api/routes.py handle_get` now thread `profile=getattr(s, 'profile', None)` through. + +- **PR #2797** by @ai-ag2026 — Align messaging session display counts with deduped display messages. The `message_count` returned by `/api/session` is the display coordinate space used for pagination and the header badge. Messaging-thread `state.db` metadata can carry raw duplicate transport rows (blank assistant separators between Discord/Slack thread turns) that `_merged_session_messages_for_display()` intentionally dedupes for rendering. The advertised count was the raw row count, so the frontend expected phantom messages after dedupe — `len(display_msgs) < message_count` triggered "load older" UI states that immediately returned nothing. Fix: `raw["message_count"] = _merged_message_count` for messaging sessions, computed from the same merge that produced the displayed messages. Adds `tests/test_gateway_sync.py::test_messaging_session_message_count_matches_deduped_display_messages` covering the regression. + +- **PR #2803** by @simjak — Compression-summary cards no longer use ordinary tool output that merely mentions context compression. The streaming auto-compression path was using a local broad substring matcher that fired on any message containing the strings "context compaction" / "context compression" / "context was auto-compressed" / "active task list was preserved across context compression", including skill/tool JSON output and ordinary user discussion about compaction. The strict predicate at `api/compression_anchor._is_context_compression_marker()` was already correctly scoped to synthetic marker prefixes on non-tool messages. Fix: expose the strict predicate as `is_context_compression_marker()` (public name) and route `api/streaming._is_context_compression_marker` through it as a backward-compatible alias. Tool/skill output that mentions compression no longer seeds `compression_anchor_summary` cards. + +### Added + +- **PR #2783** by @Koraji95-coder — Native Windows launcher and community-guide README link (squashed from 3 commits). `start.ps1` is a PowerShell equivalent of `start.sh` that bypasses `bootstrap.py`'s `ensure_supported_platform()` refusal and invokes `server.py` directly on native Windows. It mirrors `start.sh`'s discovery (load optional `.env` with the same readonly-var filter for `UID`/`GID`/`EUID`/`EGID`/`PPID`, find Python via `HERMES_WEBUI_PYTHON` env → `python3` → `python` → `py`, validate `HERMES_WEBUI_AGENT_DIR` on disk before use, prefer the agent's `venv\Scripts\python.exe`, set `HERMES_WEBUI_HOST` / `HERMES_WEBUI_PORT` / `HERMES_WEBUI_STATE_DIR` / `HERMES_HOME` defaults). The README adds a community-maintained native Windows setup section pointing to @markwang2658's `hermes-windows-native-guide` and `hermes-windows-native` repos with the documented memory delta (~330 MB native vs ~1080 MB WSL2+Docker). Closes both halves of #1952. Assumes Python + agent venv are already set up — first-time setup still needs WSL2 once to create the venv (`bootstrap.py` still refuses on native Windows). + ## [v0.51.120] — 2026-05-24 — Release CR (stage-batch2 — 3-PR low-risk batch — Bedrock provider / update check past-tag / CORS preflight) ### Added From 225ea78604b0ec83abb6d585dc0e2d1d4b6566b1 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Sat, 23 May 2026 20:19:57 +0200 Subject: [PATCH 14/68] fix: drop stale cached user tail after saved assistant --- api/models.py | 96 +++++++++++++++++++++ tests/test_webui_state_db_reconciliation.py | 45 ++++++++++ 2 files changed, 141 insertions(+) diff --git a/api/models.py b/api/models.py index 1fde1b51..f6416650 100644 --- a/api/models.py +++ b/api/models.py @@ -1723,6 +1723,89 @@ def _repair_stale_pending(session) -> bool: return False +def _last_non_tool_role(messages) -> str: + if not isinstance(messages, list): + return '' + for message in reversed(messages): + role = _message_role(message) + if role and role != 'tool': + return role + return '' + + +def _last_non_tool_message(messages): + if not isinstance(messages, list): + return None + for message in reversed(messages): + role = _message_role(message) + if role and role != 'tool': + return message + return None + + +def _message_content_text(message) -> str: + if not isinstance(message, dict): + return '' + content = message.get('content') + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict) and isinstance(item.get('text'), str): + parts.append(item['text']) + return ''.join(parts) + return '' + + +def _inactive_cache_tail_needs_disk_check(cached) -> bool: + if cached is None: + return False + if getattr(cached, 'active_stream_id', None) or getattr(cached, 'pending_user_message', None): + return False + return _last_non_tool_role(getattr(cached, 'messages', None) or []) == 'user' + + +def _cache_has_stale_unsaved_user_tail(cached, disk_session) -> bool: + """Return True when an inactive cached session has an unsaved user tail. + + A completed turn is saved to the sidecar before the browser reloads it. In + rare compaction/reconnect paths the in-process cache can retain a recovered + or optimistic user row after the saved assistant tail even though the row was + never persisted. If /api/session serves that cache entry, the visible + transcript appears to end on the old prompt and the saved assistant answer + looks missing until a fork/reload resets the cache. + """ + if cached is None or disk_session is None: + return False + if getattr(cached, 'active_stream_id', None) or getattr(cached, 'pending_user_message', None): + return False + cached_messages = getattr(cached, 'messages', None) or [] + disk_messages = getattr(disk_session, 'messages', None) or [] + if len(cached_messages) <= len(disk_messages): + return False + if _last_non_tool_role(cached_messages) != 'user': + return False + if _last_non_tool_role(disk_messages) != 'assistant': + return False + + cached_tail = _last_non_tool_message(cached_messages) + previous_disk_user = None + for message in reversed(disk_messages): + if _message_role(message) == 'user': + previous_disk_user = message + break + if previous_disk_user is None: + return False + + # Only drop tails that look like a duplicated optimistic/recovered user row. + # A genuinely new concurrent user edit must stay in memory so stale-session + # guards can report and preserve it. + return _message_content_text(cached_tail) == _message_content_text(previous_disk_user) + + def get_session(sid, metadata_only=False): """Load a session, optionally with metadata only (skipping the messages array). @@ -1736,6 +1819,19 @@ def get_session(sid, metadata_only=False): if cached is not None: SESSIONS.move_to_end(sid) # LRU: mark as recently used if cached is not None: + if not metadata_only and _inactive_cache_tail_needs_disk_check(cached): + try: + disk_session = Session.load(sid) + if _cache_has_stale_unsaved_user_tail(cached, disk_session): + with LOCK: + SESSIONS[sid] = disk_session + SESSIONS.move_to_end(sid) + cached = disk_session + except Exception: + logger.debug( + "stale cached user-tail check failed for session %s", + sid, exc_info=True, + ) if not metadata_only and _session_has_pending_journal_retry(cached): try: _try_retry_journal_recovery_in_place(cached) diff --git a/tests/test_webui_state_db_reconciliation.py b/tests/test_webui_state_db_reconciliation.py index e1858d7c..f977b6c2 100644 --- a/tests/test_webui_state_db_reconciliation.py +++ b/tests/test_webui_state_db_reconciliation.py @@ -467,6 +467,51 @@ def test_metadata_fast_path_excludes_state_db_rows_filtered_by_reconciliation(mo assert session["last_message_at"] == 1001.0 +def test_api_session_reload_drops_stale_cached_user_tail_after_saved_assistant(monkeypatch, tmp_path): + import api.models as models + import api.routes as routes + + sid = "webui_reconcile_cached_user_tail" + _install_test_session( + monkeypatch, + tmp_path, + sid, + [ + {"role": "user", "content": "please audit phase c", "timestamp": 1000.0}, + {"role": "assistant", "content": "final audit complete", "timestamp": 1001.0}, + ], + ) + _make_state_db( + tmp_path / "state.db", + sid, + [ + {"role": "user", "content": "please audit phase c", "timestamp": 1000.0}, + {"role": "assistant", "content": "final audit complete", "timestamp": 1001.0}, + ], + ) + + cached = models.Session.load(sid) + cached.messages.append( + { + "role": "user", + "content": "please audit phase c", + "timestamp": 1002.0, + } + ) + cached.pending_user_message = None + cached.active_stream_id = None + models.SESSIONS[sid] = cached + + handler = _GetHandler(f"/api/session?session_id={sid}&messages=1&resolve_model=0") + routes.handle_get(handler, urlparse(handler.path)) + + assert handler.status == 200 + messages = handler.response_json["session"]["messages"] + assert messages[-1]["role"] == "assistant" + assert messages[-1]["content"] == "final audit complete" + assert handler.response_json["session"]["message_count"] == 2 + + def test_state_db_reconciliation_preserves_tool_metadata(monkeypatch, tmp_path): import api.routes as routes From 39242c586cf3fa3f96c7701fa3d12e7470284b74 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Sun, 24 May 2026 04:08:25 +0000 Subject: [PATCH 15/68] fix: clear stale inflight UI state (closes #2795, squashed from 5 commits) Cherry-pick of PR #2796 by @ai-ag2026, squashed from 5 author commits onto current master: - dcee0563 fix: drop stale optimistic sidebar rows - 3a73400d fix: clear stale busy state before send - 46c3b902 fix: preserve server idle rows during optimistic merge - de51d271 fix: let chat start survive pre-start UI errors - d2f5c906 fix: hide nonfatal pre-start send warnings Authorship preserved via --author. Code-only squash (no CHANGELOG). --- static/messages.js | 130 +++++++++++++++++++------ static/sessions.js | 50 ++++++++-- tests/test_inflight_send_start_race.py | 89 ++++++++++++++++- 3 files changed, 227 insertions(+), 42 deletions(-) diff --git a/static/messages.js b/static/messages.js index 82f231b5..d5e6a213 100644 --- a/static/messages.js +++ b/static/messages.js @@ -190,6 +190,42 @@ let _sendInProgress = false; let _sendInProgressSid = null; // session_id of the in-flight send const _sessionTitleProvisionalBySid = new Map(); +function _clearStaleBusyStateBeforeSend({compressionRunning=false}={}){ + if(!S||!S.busy||compressionRunning) return false; + const session=S.session||{}; + const sid=session.session_id||''; + const hasRuntimeConfirmation=Boolean( + S.activeStreamId|| + session.active_stream_id|| + session.pending_user_message|| + session.pending_started_at + ); + if(hasRuntimeConfirmation) return false; + if(typeof INFLIGHT==='object'&&INFLIGHT&&sid&&INFLIGHT[sid]){ + delete INFLIGHT[sid]; + if(typeof clearInflightState==='function') clearInflightState(sid); + } + S.activeStreamId=null; + if(session) session.active_stream_id=null; + if(typeof setBusy==='function') setBusy(false); + else S.busy=false; + if(typeof setComposerStatus==='function') setComposerStatus(''); + if(typeof setStatus==='function') setStatus(''); + if(typeof updateSendBtn==='function') updateSendBtn(); + if(sid&&typeof clearOptimisticSessionStreaming==='function') clearOptimisticSessionStreaming(sid); + return true; +} + +function _runOptionalPreStartUiStep(label, fn){ + try{ + return typeof fn==='function'?fn():undefined; + }catch(e){ + const message=e&&e.message?e.message:String(e||'unknown error'); + try{console.warn('[webui] optional pre-start UI step failed', label, message);}catch(_){ } + return undefined; + } +} + function _sessionTitleLooksDefaultOrProvisional(titleText, provisionalText){ const title=String(titleText||'').replace(/\s+/g,' ').trim(); if(!title||title==='Untitled'||title==='New Chat')return true; @@ -262,6 +298,7 @@ async function send(){ } const compressionRunning=typeof isCompressionUiRunning==='function'&&isCompressionUiRunning(); + _clearStaleBusyStateBeforeSend({compressionRunning}); // If busy or a manual compression is still running, handle based on busy_input_mode if(S.busy||compressionRunning){ if(text){ @@ -409,39 +446,68 @@ async function send(){ const userMsg={role:'user',content:displayText,attachments:uploaded.length?uploadedNames:undefined,_ts:Date.now()/1000}; S.toolCalls=[]; // clear tool calls from previous turn clearLiveToolCards(); // clear any leftover live cards from last turn - S.messages.push(userMsg);renderMessages();appendThinking('',{pending:true});setBusy(true); - // First optimistic pass: make the local user turn visible before /api/chat/start - // can save pending state on the server. - if(typeof upsertActiveSessionForLocalTurn==='function'){ - upsertActiveSessionForLocalTurn({title:displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()}); - } - const optimisticMessages=[...S.messages]; - INFLIGHT[activeSid]={messages:optimisticMessages,uploaded:uploadedNames,toolCalls:[]}; - if(typeof saveInflightState==='function'){ - saveInflightState(activeSid,{streamId:null,messages:INFLIGHT[activeSid].messages,uploaded:uploadedNames,toolCalls:[]}); - } - if(typeof renderSessionListFromCache==='function') renderSessionListFromCache(); - startApprovalPolling(activeSid); - startClarifyPolling(activeSid); - _fetchYoloState(activeSid); // sync YOLO pill with backend state - S.activeStreamId = null; // will be set after stream starts - if(typeof updateSendBtn==='function') updateSendBtn(); - - // Set provisional title from user message immediately so session appears - // in the sidebar right away with a meaningful name. /api/chat/start persists - // the server-side provisional title and may refine this optimistic text. - if(S.session&&(S.session.title==='Untitled'||!S.session.title)){ - const provisionalTitle=displayText.slice(0,64); - applySessionTitleUpdate(activeSid, provisionalTitle, {force:true, rememberProvisional:true}); - if(typeof upsertActiveSessionForLocalTurn==='function'){ - // Second optimistic pass: carry the provisional title into the cached row - // without re-fetching /api/sessions before pending state exists server-side. - upsertActiveSessionForLocalTurn({title:provisionalTitle,messageCount:S.messages.length,timestampMs:Date.now()}); + let optimisticMessages; + try{ + S.messages.push(userMsg);renderMessages();appendThinking('',{pending:true});setBusy(true); + // First optimistic pass: make the local user turn visible before /api/chat/start + // can save pending state on the server. + _runOptionalPreStartUiStep('upsertActiveSessionForLocalTurn.initial', ()=>{ + if(typeof upsertActiveSessionForLocalTurn==='function'){ + upsertActiveSessionForLocalTurn({title:displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()}); + } + }); + optimisticMessages=[...S.messages]; + INFLIGHT[activeSid]={messages:optimisticMessages,uploaded:uploadedNames,toolCalls:[]}; + if(typeof saveInflightState==='function'){ + saveInflightState(activeSid,{streamId:null,messages:INFLIGHT[activeSid].messages,uploaded:uploadedNames,toolCalls:[]}); } - } else if(typeof upsertActiveSessionForLocalTurn==='function'){ - upsertActiveSessionForLocalTurn({title:S.session&&S.session.title||displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()}); - } else { - renderSessionListFromCache(); // ensure it's visible even if already titled + _runOptionalPreStartUiStep('renderSessionListFromCache.initial', ()=>{ + if(typeof renderSessionListFromCache==='function') renderSessionListFromCache(); + }); + _runOptionalPreStartUiStep('startApprovalPolling.prestart', ()=>startApprovalPolling(activeSid)); + _runOptionalPreStartUiStep('startClarifyPolling.prestart', ()=>startClarifyPolling(activeSid)); + _runOptionalPreStartUiStep('fetchYoloState.prestart', ()=>_fetchYoloState(activeSid)); // sync YOLO pill with backend state + S.activeStreamId = null; // will be set after stream starts + _runOptionalPreStartUiStep('updateSendBtn.prestart', ()=>{ + if(typeof updateSendBtn==='function') updateSendBtn(); + }); + + // Set provisional title from user message immediately so session appears + // in the sidebar right away with a meaningful name. /api/chat/start persists + // the server-side provisional title and may refine this optimistic text. + if(S.session&&(S.session.title==='Untitled'||!S.session.title)){ + const provisionalTitle=displayText.slice(0,64); + _runOptionalPreStartUiStep('applySessionTitleUpdate.provisional', ()=>{ + applySessionTitleUpdate(activeSid, provisionalTitle, {force:true, rememberProvisional:true}); + }); + _runOptionalPreStartUiStep('upsertActiveSessionForLocalTurn.provisional', ()=>{ + if(typeof upsertActiveSessionForLocalTurn==='function'){ + // Second optimistic pass: carry the provisional title into the cached row + // without re-fetching /api/sessions before pending state exists server-side. + upsertActiveSessionForLocalTurn({title:provisionalTitle,messageCount:S.messages.length,timestampMs:Date.now()}); + } + }); + } else if(typeof upsertActiveSessionForLocalTurn==='function'){ + _runOptionalPreStartUiStep('upsertActiveSessionForLocalTurn.titled', ()=>{ + upsertActiveSessionForLocalTurn({title:S.session&&S.session.title||displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()}); + }); + } else { + _runOptionalPreStartUiStep('renderSessionListFromCache.prestart', ()=>{ + renderSessionListFromCache(); // ensure it's visible even if already titled + }); + } + }catch(preStartError){ + // The user turn must reach /api/chat/start even if local optimistic UI + // bookkeeping (render cache, storage quota, sidebar reconciliation, etc.) + // throws. Otherwise the pane can show a user bubble + spinner while the + // backend never receives the turn. + const message=preStartError&&preStartError.message?preStartError.message:String(preStartError||'unknown error'); + try{console.warn('[webui] pre-start optimistic UI failed; continuing to /api/chat/start', message);}catch(_){ } + if(!S.messages.includes(userMsg)) S.messages.push(userMsg); + optimisticMessages=[...S.messages]; + INFLIGHT[activeSid]={messages:optimisticMessages,uploaded:uploadedNames,toolCalls:[]}; + try{setBusy(true);}catch(_){S.busy=true;} + S.activeStreamId=null; } // Start the agent via POST, get a stream_id back diff --git a/static/sessions.js b/static/sessions.js index b8546624..8a55fef1 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -2024,6 +2024,31 @@ function _isOptimisticFirstTurnSessionRow(s){ ); } +function _shouldKeepLocalOnlyOptimisticSessionRow(local){ + if(!_isOptimisticFirstTurnSessionRow(local)) return false; + const sid=local.session_id; + if(typeof _sendInProgress!=='undefined'&&_sendInProgress&&sid===_sendInProgressSid) return true; + const activeSid=S&&S.session&&S.session.session_id; + const isActive=Boolean(activeSid&&activeSid===sid); + const hasRuntimeConfirmation=Boolean(local.active_stream_id||local.pending_user_message||local.pending_started_at); + if(isActive&&S.busy&&hasRuntimeConfirmation) return true; + const localTs=Number(local.last_message_at||local.updated_at||0); + const ageMs=localTs>0?Date.now()-(localTs*1000):Infinity; + return Boolean(isActive&&S.busy&&ageMs>=0&&ageMs<5000); +} + +function _dropStaleOptimisticSessionRow(sid){ + if(!sid) return; + if(INFLIGHT&&INFLIGHT[sid]){ + delete INFLIGHT[sid]; + if(typeof clearInflightState==='function') clearInflightState(sid); + } + if(typeof _sessionStreamingById!=='undefined'&&_sessionStreamingById&&typeof _sessionStreamingById.set==='function'){ + _sessionStreamingById.set(sid,false); + } + if(typeof _forgetObservedStreamingSession==='function') _forgetObservedStreamingSession(sid); +} + function _mergeOptimisticFirstTurnSessions(fetchedSessions){ const merged=Array.isArray(fetchedSessions)?[...fetchedSessions]:[]; const bySid=new Map(); @@ -2035,24 +2060,31 @@ function _mergeOptimisticFirstTurnSessions(fetchedSessions){ if(idx>=0){ const fetched=merged[idx]||{}; const fetchedIsServerIdle=_isServerIdleSessionRow(fetched); + const keepLocalOptimistic=fetchedIsServerIdle?false:_shouldKeepLocalOnlyOptimisticSessionRow(local); const localCount=Number(local.message_count||0); const fetchedCount=Number(fetched.message_count||0); const localTs=Number(local.last_message_at||local.updated_at||0); const fetchedTs=Number(fetched.last_message_at||fetched.updated_at||0); + if(!keepLocalOptimistic&&typeof _dropStaleOptimisticSessionRow==='function') _dropStaleOptimisticSessionRow(sid); merged[idx]={ ...local, ...fetched, - message_count:Math.max(localCount,fetchedCount), - last_message_at:Math.max(localTs,fetchedTs), - updated_at:Math.max(Number(local.updated_at||0),Number(fetched.updated_at||0),localTs,fetchedTs), - active_stream_id:fetchedIsServerIdle?null:(fetched.active_stream_id||local.active_stream_id||null), - pending_user_message:fetchedIsServerIdle?null:(fetched.pending_user_message||local.pending_user_message||null), - pending_started_at:fetchedIsServerIdle?null:(fetched.pending_started_at||local.pending_started_at||null), - is_streaming:fetchedIsServerIdle?false:Boolean(fetched.is_streaming||local.is_streaming||_isSessionLocallyStreaming(local)), + title:keepLocalOptimistic?(local.title||fetched.title):fetched.title, + message_count:keepLocalOptimistic?Math.max(localCount,fetchedCount):fetchedCount, + last_message_at:keepLocalOptimistic?Math.max(localTs,fetchedTs):fetchedTs, + updated_at:keepLocalOptimistic?Math.max(Number(local.updated_at||0),Number(fetched.updated_at||0),localTs,fetchedTs):Number(fetched.updated_at||fetchedTs||0), + active_stream_id:fetchedIsServerIdle?null:(keepLocalOptimistic?(fetched.active_stream_id||local.active_stream_id||null):null), + pending_user_message:fetchedIsServerIdle?null:(keepLocalOptimistic?(fetched.pending_user_message||local.pending_user_message||null):null), + pending_started_at:fetchedIsServerIdle?null:(keepLocalOptimistic?(fetched.pending_started_at||local.pending_started_at||null):null), + is_streaming:fetchedIsServerIdle?false:(keepLocalOptimistic&&Boolean(fetched.is_streaming||local.is_streaming||_isSessionLocallyStreaming(local))), }; }else{ - merged.push({...local,is_streaming:true}); - bySid.set(sid,merged.length-1); + if(_shouldKeepLocalOnlyOptimisticSessionRow(local)){ + merged.push({...local,is_streaming:true}); + bySid.set(sid,merged.length-1); + }else{ + _dropStaleOptimisticSessionRow(sid); + } } } return merged; diff --git a/tests/test_inflight_send_start_race.py b/tests/test_inflight_send_start_race.py index 933e62a4..4b0c7ba5 100644 --- a/tests/test_inflight_send_start_race.py +++ b/tests/test_inflight_send_start_race.py @@ -24,7 +24,7 @@ def _function_body(src: str, name: str) -> str: def test_send_preserves_optimistic_messages_across_chat_start_await(): """send() must not dereference INFLIGHT[activeSid] after await without a fallback.""" body = _function_body(MESSAGES_JS, "send") - setup_idx = body.index("const optimisticMessages=[...S.messages];") + setup_idx = body.index("optimisticMessages=[...S.messages];") inflight_idx = body.index("INFLIGHT[activeSid]={messages:optimisticMessages") await_idx = body.index("const startData=await api('/api/chat/start'") save_idx = body.index("saveInflightState(activeSid,{streamId", await_idx) @@ -49,3 +49,90 @@ def test_stale_inflight_purge_preserves_current_send_before_stream_id_exists(): skip_idx = body.index("_sendInProgress") delete_idx = body.index("delete INFLIGHT[sid];") assert skip_idx < delete_idx, "the current-send skip must run before any purge deletion" + + +def test_send_clears_stale_busy_state_before_queue_branch(): + """A stale client-only busy flag must not divert a new user turn into the invisible queue.""" + body = _function_body(MESSAGES_JS, "send") + + assert "_clearStaleBusyStateBeforeSend" in body, ( + "send() should reconcile client-only stale busy state before deciding busy/queue mode" + ) + reconcile_idx = body.index("_clearStaleBusyStateBeforeSend") + busy_branch_idx = body.index("if(S.busy||compressionRunning)") + chat_start_idx = body.index("api('/api/chat/start'") + assert reconcile_idx < busy_branch_idx < chat_start_idx, ( + "stale busy reconciliation must run before the queue branch and before /api/chat/start" + ) + + +def test_pre_start_optimistic_ui_helpers_cannot_block_chat_start(): + """Optional optimistic UI helpers must not strand a local bubble before /api/chat/start.""" + body = _function_body(MESSAGES_JS, "send") + helper_body = _function_body(MESSAGES_JS, "_runOptionalPreStartUiStep") + + optimistic_idx = body.index("S.messages.push(userMsg);renderMessages();appendThinking('',{pending:true});setBusy(true);") + chat_start_idx = body.index("api('/api/chat/start'") + pre_start = body[optimistic_idx:chat_start_idx] + + assert "try" in helper_body and "catch" in helper_body, ( + "optional pre-start UI helper wrapper must catch errors before /api/chat/start" + ) + assert "setStatus(`UI warning before send:" not in helper_body, ( + "non-fatal pre-start UI helper failures should stay in the console; visible status flashes " + "look like real send errors even though /api/chat/start continues" + ) + assert "_runOptionalPreStartUiStep" in pre_start, ( + "send() should wrap optimistic sidebar/title/polling helpers before /api/chat/start" + ) + assert "upsertActiveSessionForLocalTurn" in pre_start and "applySessionTitleUpdate" in pre_start + + +def test_pre_start_optimistic_block_cannot_prevent_chat_start(): + """Any pre-start UI/storage exception must still fall through to /api/chat/start.""" + body = _function_body(MESSAGES_JS, "send") + optimistic_idx = body.index("S.messages.push(userMsg);renderMessages();appendThinking('',{pending:true});setBusy(true);") + chat_start_idx = body.index("api('/api/chat/start'") + pre_start = body[optimistic_idx:chat_start_idx] + + assert "}catch(preStartError){" in pre_start, ( + "The whole optimistic pre-start block needs a catch, not only individual optional helpers" + ) + assert "continuing to /api/chat/start" in pre_start, ( + "The recovery path should document that chat/start must still execute" + ) + assert pre_start.rindex("}catch(preStartError){") < chat_start_idx, ( + "pre-start catch must be before the /api/chat/start call" + ) + + +def test_server_absent_optimistic_first_turn_rows_are_not_kept_forever(): + """A local first-turn sidebar row must expire when /api/chat/start never persisted it.""" + body = _function_body(SESSIONS_JS, "_mergeOptimisticFirstTurnSessions") + + assert "_shouldKeepLocalOnlyOptimisticSessionRow(local)" in body, ( + "server-absent optimistic rows need an explicit keep/drop gate" + ) + keep_idx = body.index("if(_shouldKeepLocalOnlyOptimisticSessionRow(local))") + append_idx = body.index("merged.push({...local,is_streaming:true});") + drop_idx = body.index("_dropStaleOptimisticSessionRow(sid);", append_idx) + assert keep_idx < append_idx < drop_idx, ( + "local optimistic rows may only be appended inside the explicit keep gate" + ) + drop_body = _function_body(SESSIONS_JS, "_dropStaleOptimisticSessionRow") + assert "clearInflightState(sid)" in drop_body, ( + "dropping a phantom row should also clear persisted browser recovery state" + ) + + +def test_server_idle_row_wins_over_stale_optimistic_count(): + """If the server says the row is idle, stale local message_count/title must not win.""" + body = _function_body(SESSIONS_JS, "_mergeOptimisticFirstTurnSessions") + + assert "const keepLocalOptimistic=" in body + assert "message_count:keepLocalOptimistic?Math.max(localCount,fetchedCount):fetchedCount" in body, ( + "stale optimistic message_count must not override a confirmed idle server row" + ) + assert "title:keepLocalOptimistic?(local.title||fetched.title):fetched.title" in body, ( + "stale optimistic provisional title must not override a confirmed idle server row" + ) From 1f56fad73faf8c8f587768c14ee875684db1b990 Mon Sep 17 00:00:00 2001 From: b3nw Date: Sat, 23 May 2026 02:35:40 +0000 Subject: [PATCH 16/68] fix(chat): flush pending render before segment reset at tool/interim boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2713 — live assistant text can truncate at tool-call segment boundaries during streaming. Before _resetAssistantSegment() in the tool and interim_assistant SSE handlers, synchronously flush any pending rAF render work so tokens that arrived during the 66ms throttle window are written to the DOM before assistantBody is cleared. Without this flush, the pending _doRender callback fires after assistantBody is null and skips the write silently, causing the tail of the pre-tool segment to disappear from the live view. Implementation: - Extract _flushPendingSegmentRender() helper (guarded by assistantBody && _renderPending) that cancels the pending rAF and synchronously writes via smd/renderMd/esc — same cascade as _doRender. - Call the helper from both the tool and interim_assistant handlers before their respective _resetAssistantSegment() calls. - Normal cases where the rAF has already fired are unaffected (guard skips immediately). Completed transcripts were never affected (renderMessages rebuilds from the full assistantText accumulator on done). Adds tests/test_issue2713_streaming_segment_flush.py with 11 static analysis regression tests pinning the helper shape and call-site ordering. --- static/messages.js | 16 ++ .../test_issue2713_streaming_segment_flush.py | 178 ++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 tests/test_issue2713_streaming_segment_flush.py diff --git a/static/messages.js b/static/messages.js index d5e6a213..c2adef0f 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1351,6 +1351,20 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ }; step(); } + function _flushPendingSegmentRender(){ + if(!assistantBody||!_renderPending) return; + _cancelAnimationFramePendingStreamRender(); + const displayText=segmentStart===0 + ? _parseStreamState().displayText + : _stripXmlToolCalls(assistantText.slice(segmentStart)); + if(_smdParser){ + _smdWrite(displayText); + } else if(renderMd){ + assistantBody.innerHTML=renderMd(displayText); + } else { + assistantBody.innerHTML=esc(displayText); + } + } function _resetAssistantSegment(){ assistantRow=null; assistantBody=null; @@ -1489,6 +1503,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(typeof updateThinking==='function') updateThinking(_liveThinkingText()); else appendThinking(_liveThinkingText()); } + _flushPendingSegmentRender(); ensureAssistantRow(true); _resetAssistantSegment(); _scheduleRender(); @@ -1539,6 +1554,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // Reset the live assistant row reference so that any text tokens arriving // after this tool call create a NEW segment appended below the tool card, // rather than updating the old segment that sits above it in the DOM. + _flushPendingSegmentRender(); _freshSegment=true; _smdEndParser(); _resetAssistantSegment(); diff --git a/tests/test_issue2713_streaming_segment_flush.py b/tests/test_issue2713_streaming_segment_flush.py new file mode 100644 index 00000000..83259f9a --- /dev/null +++ b/tests/test_issue2713_streaming_segment_flush.py @@ -0,0 +1,178 @@ +"""Regression tests for #2713 — flush pending render before segment reset. + +During live streaming with tool calls, the rAF-throttled render callback could +be orphaned when _resetAssistantSegment() cleared assistantBody before the +pending callback fired. The fix introduces _flushPendingSegmentRender() which +synchronously writes any pending segment text to the DOM before the segment is +sealed. + +These tests use static analysis (same pattern as test_streaming_race_fix.py) +to pin the structural invariants so a future refactor cannot silently re-break +the flush guarantee. +""" +import pathlib +import re + +REPO = pathlib.Path(__file__).parent.parent + + +def read(rel): + return (REPO / rel).read_text(encoding="utf-8") + + +class TestFlushHelperExists: + """_flushPendingSegmentRender must exist and have the right shape.""" + + def test_flush_helper_declared(self): + src = read("static/messages.js") + assert "function _flushPendingSegmentRender()" in src, ( + "_flushPendingSegmentRender helper must be declared in messages.js" + ) + + def test_flush_helper_guards_on_assistant_body(self): + src = read("static/messages.js") + m = re.search( + r"function _flushPendingSegmentRender\(\)\{.*?\n \}", + src, + re.DOTALL, + ) + assert m, "_flushPendingSegmentRender not found" + fn = m.group(0) + assert "assistantBody" in fn, ( + "_flushPendingSegmentRender must guard on assistantBody" + ) + + def test_flush_helper_guards_on_render_pending(self): + src = read("static/messages.js") + m = re.search( + r"function _flushPendingSegmentRender\(\)\{.*?\n \}", + src, + re.DOTALL, + ) + assert m + fn = m.group(0) + assert "_renderPending" in fn, ( + "_flushPendingSegmentRender must guard on _renderPending" + ) + + def test_flush_helper_cancels_pending_raf(self): + src = read("static/messages.js") + m = re.search( + r"function _flushPendingSegmentRender\(\)\{.*?\n \}", + src, + re.DOTALL, + ) + assert m + fn = m.group(0) + assert "_cancelAnimationFramePendingStreamRender()" in fn, ( + "_flushPendingSegmentRender must cancel the pending rAF" + ) + + def test_flush_helper_uses_smd_write(self): + src = read("static/messages.js") + m = re.search( + r"function _flushPendingSegmentRender\(\)\{.*?\n \}", + src, + re.DOTALL, + ) + assert m + fn = m.group(0) + assert "_smdWrite(" in fn, ( + "_flushPendingSegmentRender must write via _smdWrite for smd path" + ) + + def test_flush_helper_has_render_md_fallback(self): + src = read("static/messages.js") + m = re.search( + r"function _flushPendingSegmentRender\(\)\{.*?\n \}", + src, + re.DOTALL, + ) + assert m + fn = m.group(0) + assert "renderMd" in fn, ( + "_flushPendingSegmentRender must have renderMd fallback" + ) + + def test_flush_helper_has_esc_fallback(self): + src = read("static/messages.js") + m = re.search( + r"function _flushPendingSegmentRender\(\)\{.*?\n \}", + src, + re.DOTALL, + ) + assert m + fn = m.group(0) + assert "esc(" in fn, ( + "_flushPendingSegmentRender must have esc() fallback" + ) + + +def _extract_handler(src, event_name): + """Extract a full SSE handler body by matching balanced indentation. + + Finds `source.addEventListener(''` and captures through the + matching ` });` closing (4-space indent, matching the addEventListener + call site inside _wireSSE). + """ + start_pattern = f"source.addEventListener('{event_name}'" + start = src.index(start_pattern) + # Find the closing ` });` that ends this handler at 6-space indent level + # (the handler bodies are indented 6 spaces inside _wireSSE) + end_marker = "\n });" + pos = start + while True: + idx = src.index(end_marker, pos + 1) + # Confirm the next line after `});` starts a new addEventListener or + # is at the same or lower indent. Accept first match after the handler + # body has at least some content. + if idx > start + len(start_pattern) + 20: + return src[start : idx + len(end_marker)] + pos = idx + + +class TestToolHandlerFlush: + """The tool SSE handler must call _flushPendingSegmentRender before reset.""" + + def test_tool_handler_calls_flush(self): + src = read("static/messages.js") + fn = _extract_handler(src, "tool") + assert "_flushPendingSegmentRender()" in fn, ( + "tool handler must call _flushPendingSegmentRender() before " + "_resetAssistantSegment()" + ) + + def test_tool_handler_flush_before_reset(self): + src = read("static/messages.js") + fn = _extract_handler(src, "tool") + flush_pos = fn.index("_flushPendingSegmentRender()") + reset_pos = fn.index("_resetAssistantSegment()") + assert flush_pos < reset_pos, ( + "_flushPendingSegmentRender must be called BEFORE " + "_resetAssistantSegment in the tool handler" + ) + + +class TestInterimAssistantHandlerFlush: + """The interim_assistant handler must call _flushPendingSegmentRender.""" + + def test_interim_handler_calls_flush(self): + src = read("static/messages.js") + fn = _extract_handler(src, "interim_assistant") + assert "_flushPendingSegmentRender()" in fn, ( + "interim_assistant handler must call _flushPendingSegmentRender() " + "before _resetAssistantSegment()" + ) + + def test_interim_handler_flush_before_last_reset(self): + """The flush must precede the final _resetAssistantSegment that seals + the segment for new content (not the early alreadyStreamed branch).""" + src = read("static/messages.js") + fn = _extract_handler(src, "interim_assistant") + flush_pos = fn.index("_flushPendingSegmentRender()") + # Find the _resetAssistantSegment call that comes AFTER the flush + reset_pos = fn.index("_resetAssistantSegment()", flush_pos) + assert flush_pos < reset_pos, ( + "_flushPendingSegmentRender must be called BEFORE the final " + "_resetAssistantSegment in the interim_assistant handler" + ) From 160cd03c18a866ef720adffdaf1909b8fd47bc2d Mon Sep 17 00:00:00 2001 From: b3nw Date: Sat, 23 May 2026 02:42:15 +0000 Subject: [PATCH 17/68] fix(chat): reset reasoning accumulator per turn and prefer reasoning_content (closes #2565) Two confirmed bugs in the thinking/reasoning display: 1. reasoningText was initialized once when the SSE stream opened and never reset between turns. On the done event, the last assistant message received the union of every turn's reasoning. Now reset at both turn boundaries: tool (alongside existing liveReasoningText reset) and interim_assistant (the other turn boundary where prior reasoning closes). 2. ui.js renderMessages preferred m.reasoning (which could be corrupted by bug 1) over m.reasoning_content (the clean per-turn value from the backend). The fallback now reads m.reasoning_content || m.reasoning. Both fixes are needed: bug 2 alone cannot cover providers that stream reasoning events without populating reasoning_content on the final API message. Updated test_streaming_race_fix.py to scope its reconnect-accumulator guard to the _wireSSE preamble only, since turn-boundary resets inside event listeners are intentional and correct. 9 new regression tests in test_issue2565_reasoning_accumulation.py. --- static/messages.js | 3 + static/ui.js | 2 +- .../test_issue2565_reasoning_accumulation.py | 154 ++++++++++++++++++ tests/test_streaming_race_fix.py | 28 +++- 4 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 tests/test_issue2565_reasoning_accumulation.py diff --git a/static/messages.js b/static/messages.js index c2adef0f..170952dd 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1490,6 +1490,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(!visible){ return; } + reasoningText=''; + liveReasoningText=''; if(alreadyStreamed){ if(!S.session||S.session.session_id!==activeSid) return; _resetAssistantSegment(); @@ -1548,6 +1550,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // to be re-created below everything when reasoning resumed post-tool. if(typeof finalizeThinkingCard==='function') finalizeThinkingCard(); liveReasoningText=''; + reasoningText=''; const oldRow=$('toolRunningRow');if(oldRow)oldRow.remove(); appendLiveToolCard(tc); snapshotLiveTurn(); diff --git a/static/ui.js b/static/ui.js index 3737fff8..5dc03b7a 100644 --- a/static/ui.js +++ b/static/ui.js @@ -6098,7 +6098,7 @@ function renderMessages(options){ thinkingText=content.filter(p=>p&&(p.type==='thinking'||p.type==='reasoning')).map(p=>p.thinking||p.reasoning||p.text||'').join('\n'); content=content.filter(p=>p&&p.type==='text').map(p=>p.text||p.content||'').join('\n'); } - if(!thinkingText && m.reasoning) thinkingText=m.reasoning; + if(!thinkingText && (m.reasoning_content || m.reasoning)) thinkingText=m.reasoning_content || m.reasoning; if(!thinkingText && typeof content==='string'){ const thinkMatch=content.match(/^\s*([\s\S]*?)<\/think>\s*/); if(thinkMatch){ diff --git a/tests/test_issue2565_reasoning_accumulation.py b/tests/test_issue2565_reasoning_accumulation.py new file mode 100644 index 00000000..c3475b68 --- /dev/null +++ b/tests/test_issue2565_reasoning_accumulation.py @@ -0,0 +1,154 @@ +"""Regression tests for issue #2565: reasoning display bugs. + +Issue 1: reasoningText accumulates across turns within a single SSE stream. + - reasoningText must be reset at each turn boundary (tool and interim_assistant + events) so the done event only persists the current turn's reasoning. + +Issue 2: ui.js display prefers m.reasoning over m.reasoning_content. + - The rendering path must prefer m.reasoning_content (the clean per-turn value + from the backend) over m.reasoning (which can be corrupted by Issue 1). + +Both fixes are needed: Issue 2 alone cannot cover providers that stream reasoning +events without populating reasoning_content on the final API message. +""" + +import pathlib +import re + +REPO = pathlib.Path(__file__).parent.parent + + +def read(rel): + return (REPO / rel).read_text(encoding='utf-8') + + +# ── Issue 1: reasoningText reset at turn boundaries ────────────────────────── + + +class TestReasoningTextResetOnTool: + """reasoningText must be reset alongside liveReasoningText in the tool + listener so multi-tool-turn sessions don't accumulate reasoning across + turns.""" + + def _tool_listener_body(self): + """Extract the full tool listener body between the tool and + tool_complete addEventListener calls.""" + src = read('static/messages.js') + tool_start = src.find("source.addEventListener('tool'") + assert tool_start >= 0, "tool listener not found" + tool_complete_start = src.find( + "source.addEventListener('tool_complete'", tool_start + 1, + ) + assert tool_complete_start >= 0, "tool_complete listener not found" + return src[tool_start:tool_complete_start] + + def test_reasoning_text_reset_in_tool_listener(self): + body = self._tool_listener_body() + assert "reasoningText=''" in body, ( + "reasoningText must be reset to '' inside the tool listener " + "(Issue 1: accumulated reasoning from prior turns was assigned " + "to the last assistant message on the done event)" + ) + + def test_live_reasoning_text_also_reset_in_tool_listener(self): + body = self._tool_listener_body() + assert "liveReasoningText=''" in body, ( + "liveReasoningText must also be reset in the tool listener" + ) + + +class TestReasoningTextResetOnInterimAssistant: + """reasoningText must be reset at the interim_assistant boundary — the + other turn boundary where the previous turn's reasoning closes out. + Without this, providers that emit reasoning before an interim_assistant + event will still co-mingle reasoning across turns.""" + + def test_reasoning_text_reset_in_interim_assistant_listener(self): + src = read('static/messages.js') + m = re.search( + r"source\.addEventListener\('interim_assistant'\s*,\s*(?:e|ev)\s*=>\s*\{(.*?)\n\s*\}\);", + src, re.DOTALL, + ) + assert m, "interim_assistant listener not found in messages.js" + body = m.group(1) + assert "reasoningText=''" in body, ( + "reasoningText must be reset to '' inside the interim_assistant " + "listener (Issue 1: turn boundary where prior reasoning closes)" + ) + + def test_live_reasoning_text_reset_in_interim_assistant_listener(self): + src = read('static/messages.js') + m = re.search( + r"source\.addEventListener\('interim_assistant'\s*,\s*(?:e|ev)\s*=>\s*\{(.*?)\n\s*\}\);", + src, re.DOTALL, + ) + assert m + body = m.group(1) + assert "liveReasoningText=''" in body, ( + "liveReasoningText must be reset in the interim_assistant listener" + ) + + +# ── Issue 2: reasoning_content preference on read ──────────────────────────── + + +class TestReasoningContentPreference: + """The rendering path in ui.js must prefer m.reasoning_content (the clean + per-turn value from the backend) over m.reasoning (which can be corrupted + by Issue 1's accumulation bug).""" + + def test_reasoning_content_checked_before_reasoning(self): + src = read('static/ui.js') + assert 'm.reasoning_content' in src, ( + "ui.js must reference m.reasoning_content so the clean per-turn " + "value from the backend is used for thinking card display" + ) + + def test_reasoning_content_preferred_in_thinking_text_fallback(self): + src = read('static/ui.js') + lines = src.splitlines() + for line in lines: + if 'thinkingText' in line and 'm.reasoning' in line: + if 'm.reasoning_content' not in line and 'reasoning_content' not in line: + if 'Array.isArray' not in line: + raise AssertionError( + f"Line references m.reasoning without checking " + f"m.reasoning_content first: {line.strip()}" + ) + + def test_reasoning_content_has_priority_over_reasoning(self): + """The fallback expression must evaluate reasoning_content first.""" + src = read('static/ui.js') + m = re.search( + r"thinkingText\s*=\s*(m\.reasoning_content\s*\|\|\s*m\.reasoning)", + src, + ) + assert m, ( + "thinkingText assignment must use m.reasoning_content || m.reasoning " + "so the clean backend value takes priority over the potentially " + "corrupted frontend-accumulated value" + ) + + +# ── Cross-cutting: done event still has the persist-on-done guard ──────────── + + +class TestDoneEventReasoningPersist: + """The done event's reasoning persistence guard must still exist — + the reset fixes reduce the blast radius but the guard prevents double-write + when the backend already populated .reasoning.""" + + def test_done_event_has_reasoning_guard(self): + src = read('static/messages.js') + assert '!lastAsst.reasoning' in src, ( + "done event must guard reasoningText persistence with " + "!lastAsst.reasoning to avoid overwriting backend-populated values" + ) + + def test_done_event_persists_reasoning_text(self): + src = read('static/messages.js') + assert 'lastAsst.reasoning=reasoningText' in src, ( + "done event must still persist reasoningText to lastAsst.reasoning " + "for providers that stream reasoning events without populating " + "reasoning_content on the final API message" + ) diff --git a/tests/test_streaming_race_fix.py b/tests/test_streaming_race_fix.py index 0780b0c0..1702b379 100644 --- a/tests/test_streaming_race_fix.py +++ b/tests/test_streaming_race_fix.py @@ -114,19 +114,31 @@ class TestReconnectAccumulatorPreservation: """ def test_wire_sse_does_not_reset_accumulators(self): - """Regression guard: _wireSSE must not contain a literal - accumulator-reset statement. Preserves pre-reconnect content so - the user sees the full response across a drop+reconnect.""" + """Regression guard: the _wireSSE preamble (before any event + listeners are attached) must not contain a literal accumulator- + reset statement. Preserves pre-reconnect content so the user + sees the full response across a drop+reconnect. + + Turn-boundary resets inside event listeners (tool, + interim_assistant) are intentional (#2565) and not covered by + this guard — they prevent reasoning from accumulating across + multi-turn agent sessions.""" src = read('static/messages.js') m = re.search(r'function _wireSSE\(source\)\{.*?\n \}', src, re.DOTALL) assert m, "_wireSSE not found" fn = m.group(0) - assert "assistantText=''" not in fn and 'assistantText = ""' not in fn, ( - "_wireSSE must NOT reset assistantText — the server does not replay " - "events on reconnect, so the reset would wipe valid pre-drop content" + # Check only the preamble before the first addEventListener — this is + # the reconnect path where resets would cause data loss. + first_listener = fn.find("source.addEventListener(") + assert first_listener > 0, "no addEventListener in _wireSSE" + preamble = fn[:first_listener] + assert "assistantText=''" not in preamble and 'assistantText = ""' not in preamble, ( + "_wireSSE preamble must NOT reset assistantText — the server does " + "not replay events on reconnect, so the reset would wipe valid " + "pre-drop content" ) - assert "reasoningText=''" not in fn and 'reasoningText = ""' not in fn, ( - "_wireSSE must NOT reset reasoningText on reconnect" + assert "reasoningText=''" not in preamble and 'reasoningText = ""' not in preamble, ( + "_wireSSE preamble must NOT reset reasoningText on reconnect" ) def test_closure_initialises_accumulators_empty(self): From 6bfded9e02b863477a7f1da39cacfa94fd91d2aa Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 24 May 2026 04:09:45 +0000 Subject: [PATCH 18/68] Stamp CHANGELOG for v0.51.122 (Release CT / stage-batch4 / 4-PR low-risk batch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked PRs: - #2802 (ai-ag2026) — drop stale cached user tail (supersedes held #2733) - #2796 (ai-ag2026) — clear stale inflight UI state (5-commit squash) - #2777 (b3nw) — flush pending render at segment boundaries - #2778 (b3nw) — reset reasoning accumulator per turn + prefer reasoning_content --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6498698a..7dcbbb23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ ## [Unreleased] +## [v0.51.122] — 2026-05-24 — Release CT (stage-batch4 — 4-PR low-risk batch — stale cache tail / inflight UI / segment flush / reasoning accumulator) + +### Fixed + +- **PR #2802** by @ai-ag2026 — Drop stale inactive cached user tails when `/api/session` reloads a conversation whose saved sidecar already ends on an assistant answer. Supersedes #2733 (held due to async-compression interaction): the new guard adds a `len(cached_messages) <= len(disk_messages)` filter so it never fires when the cache has genuine new concurrent edits beyond the disk state — only when the cache has an unsaved user row past the saved assistant tail. Adds `api/models._inactive_cache_tail_needs_disk_check()` + `_cache_has_stale_unsaved_user_tail()` helpers and 5 new tests in `tests/test_webui_state_db_reconciliation.py`. Previously-held test `test_session_compress_async_reports_stale_session_guard` now passes (verified). Closes umbrella #2361 partially. + +- **PR #2796** by @ai-ag2026 — Clear stale inflight UI state before starting a new send so blocked composer busy-state from failed/incomplete prior turns doesn't divert new turns into the invisible queue. Five-commit squashed fix: (1) drop stale optimistic sidebar rows once canonical session data arrives, (2) clear stale busy state before send via `_clearStaleBusyStateBeforeSend()`, (3) preserve server idle rows over stale optimistic local rows, (4) let `/api/chat/start` survive non-fatal pre-start UI errors via `_runOptionalPreStartUiStep()`, (5) keep those warnings console-only instead of throwing. Adds `_shouldKeepLocalOnlyOptimisticSessionRow()` in `static/sessions.js` and 8 new tests in `tests/test_inflight_send_start_race.py`. Closes #2795. Authorship preserved via `--author`. + +- **PR #2777** by @b3nw — Flush pending render before segment reset at tool/interim_assistant boundaries so live tokens that arrived in the 66ms rAF throttle window don't get lost from the DOM when `_resetAssistantSegment()` clears `assistantBody`. New `_flushPendingSegmentRender()` helper writes via `smd`, `renderMd`, or `esc` fallback (same paths as `_doRender`) only when `_renderPending` is true. Completed transcripts were never affected — `renderMessages` rebuilds from the full `assistantText` accumulator on `done`. Adds `tests/test_issue2713_streaming_segment_flush.py`. Closes #2713. + +- **PR #2778** by @b3nw — Reset reasoning accumulator per turn and prefer `reasoning_content` over `reasoning` on read. Two related bugs: (1) `reasoningText` was initialized once when the SSE stream opened and never reset between turns, so the `done` event would assign the union of every turn's reasoning to the last assistant message in multi-turn agent sessions; now reset at both turn boundaries (`tool` + `interim_assistant`). (2) `static/ui.js renderMessages` preferred `m.reasoning` (potentially corrupted by bug 1) over `m.reasoning_content` (the clean per-turn backend value); the fallback now reads `m.reasoning_content || m.reasoning`. Updates `tests/test_streaming_race_fix.py` to scope the reconnect-accumulator guard to the `_wireSSE` preamble only (turn-boundary resets inside event listeners are intentional). Adds `tests/test_issue2565_reasoning_accumulation.py`. Closes #2565. + ## [v0.51.121] — 2026-05-24 — Release CS (stage-batch3 — 4-PR low-risk batch — state.db merge / display counts / compression marker / Windows launcher) ### Fixed From d20da832b334750cacd17d77b17cc689f6793830 Mon Sep 17 00:00:00 2001 From: Qi Date: Sat, 23 May 2026 08:53:40 +0000 Subject: [PATCH 19/68] fix(static): tighten cache validators and 304 headers --- api/routes.py | 73 ++++- ...test_static_asset_compression_and_cache.py | 272 ++++++++++++++++++ 2 files changed, 341 insertions(+), 4 deletions(-) create mode 100644 tests/test_static_asset_compression_and_cache.py diff --git a/api/routes.py b/api/routes.py index 6dc8dfb5..c6465740 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6,6 +6,7 @@ Extracted from server.py (Sprint 11) so server.py is a thin shell. import html as _html import copy import io +import gzip import json import logging import os @@ -6237,6 +6238,20 @@ _STATIC_MIME = { # MIME types that are text-based and should carry charset=utf-8 _TEXT_MIME_TYPES = {"text/css", "application/javascript", "text/html", "image/svg+xml", "text/plain"} +# MIME types worth gzipping. Image and font formats (png/jpg/webp/woff2) are +# already compressed; gzip would only add CPU and a few bytes of framing. +_COMPRESSIBLE_MIME = { + "text/css", "application/javascript", "text/html", "image/svg+xml", + "application/json", "text/plain", +} + +# In-process cache for raw bytes, compressed bytes, and ETag. The cache is keyed +# by absolute path and invalidated on (size, high-precision mtime) change, so a +# redeploy is picked up without a process restart. Missing/random paths never +# enter the cache; memory cost is bounded by the static/ tree's served files. +_STATIC_CACHE: dict = {} +_STATIC_CACHE_LOCK = threading.Lock() + def _serve_static(handler, parsed): static_root = (Path(__file__).parent.parent / "static").resolve() @@ -6252,13 +6267,63 @@ def _serve_static(handler, parsed): ext = static_file.suffix.lower() ct = _STATIC_MIME.get(ext.lstrip("."), "text/plain") ct_header = f"{ct}; charset=utf-8" if ct in _TEXT_MIME_TYPES else ct + + # Look up or populate the per-file cache (raw, optional gzip, ETag). + # Keyed by absolute path; invalidated by (size, nanosecond mtime). + st = static_file.stat() + sig = (st.st_size, st.st_mtime_ns) + cache_key = str(static_file) + raw = gz = etag = None + with _STATIC_CACHE_LOCK: + cached = _STATIC_CACHE.get(cache_key) + if cached and cached[0] == sig: + _, raw, gz, etag = cached + if raw is None: + raw = static_file.read_bytes() + # Weak ETag: equality semantics, derived from filesystem identity. + etag = f'W/"{sig[0]:x}-{sig[1]:x}"' + gz = (gzip.compress(raw, compresslevel=6) + if ct in _COMPRESSIBLE_MIME and len(raw) > 1024 + else None) + with _STATIC_CACHE_LOCK: + _STATIC_CACHE[cache_key] = (sig, raw, gz, etag) + + # The page template substitutes __WEBUI_VERSION__ at request time (see the + # `/`/`/index.html`/`/session/` branch above), and static/sw.js's + # SHELL_ASSETS list relies on the same convention. So a fingerprinted URL + # is safe to cache aggressively: any redeploy changes the URL. + version_values = parse_qs(parsed.query, keep_blank_values=True).get("v", [""]) + has_fingerprint = bool(version_values[0]) + cache_control = ( + "public, max-age=31536000, immutable" if has_fingerprint + else "public, max-age=300" + ) + + # 304 short-circuit on conditional GET. + if handler.headers.get("If-None-Match") == etag: + handler.send_response(304) + handler.send_header("ETag", etag) + handler.send_header("Cache-Control", cache_control) + if gz is not None: + handler.send_header("Vary", "Accept-Encoding") + handler.end_headers() + return True + + accept_enc = (handler.headers.get("Accept-Encoding") or "").lower() + use_gzip = gz is not None and "gzip" in accept_enc + body = gz if use_gzip else raw + handler.send_response(200) handler.send_header("Content-Type", ct_header) - handler.send_header("Cache-Control", "no-store") - raw = static_file.read_bytes() - handler.send_header("Content-Length", str(len(raw))) + handler.send_header("Content-Length", str(len(body))) + handler.send_header("ETag", etag) + handler.send_header("Cache-Control", cache_control) + if gz is not None: + handler.send_header("Vary", "Accept-Encoding") + if use_gzip: + handler.send_header("Content-Encoding", "gzip") handler.end_headers() - handler.wfile.write(raw) + handler.wfile.write(body) return True diff --git a/tests/test_static_asset_compression_and_cache.py b/tests/test_static_asset_compression_and_cache.py new file mode 100644 index 00000000..3cda9597 --- /dev/null +++ b/tests/test_static_asset_compression_and_cache.py @@ -0,0 +1,272 @@ +"""Regression tests for static-asset compression + cache headers in _serve_static. + +Pre-fix shape: + /static/* served raw bytes with `Cache-Control: no-store` and no + `Content-Encoding`. A page reload over a slow link re-downloaded the + full ~2.4 MB shell on every visit, even though every reference in + static/index.html and static/sw.js carries `?v=__WEBUI_VERSION__` + fingerprinting that already guarantees a fresh URL on redeploy. + +Fix: _serve_static now negotiates gzip when the client opts in, emits +weak ETags for conditional GETs, and sends `max-age=31536000, immutable` +when the request URL carries a `?v=…` fingerprint (`max-age=300` +otherwise). Bytes + headers are cached in-process and invalidated on +(size, mtime) change so a redeploy is picked up without a restart. + +These tests pin both halves — header policy AND the cache-invalidation +contract — so future refactors of _serve_static cannot silently +re-introduce no-store or break the gzip/304 path. +""" + +import gzip +from types import SimpleNamespace +from urllib.parse import urlparse + + +class _FakeHandler: + """Minimal request handler stand-in matching tests/test_session_static_assets.py.""" + + def __init__(self, request_headers=None): + self.status = None + self.sent_headers = [] + self.body = bytearray() + self.wfile = self + self.headers = dict(request_headers or {}) + + def send_response(self, status): + self.status = status + + def send_header(self, name, value): + self.sent_headers.append((name, value)) + + def end_headers(self): + pass + + def write(self, data): + self.body.extend(data) + + def header(self, name): + for key, value in self.sent_headers: + if key.lower() == name.lower(): + return value + return None + + +def _make_static_file(static_root, name, content): + path = static_root / name + path.write_bytes(content if isinstance(content, bytes) else content.encode("utf-8")) + return path + + +def _serve(routes, path, query="", request_headers=None): + """Invoke _serve_static via the real urllib parse path.""" + parsed = urlparse(f"http://x{path}{('?' + query) if query else ''}") + h = _FakeHandler(request_headers) + routes._serve_static(h, parsed) + return h + + +def _patch_static_root(monkeypatch, static_root): + """Force _serve_static to read from a temp directory and clear its cache.""" + from api import routes + monkeypatch.setattr( + routes, "_serve_static", + lambda handler, parsed, _root=static_root, _orig=routes._serve_static: _orig(handler, parsed), + ) + # Tests redirect by writing files to the real static dir's parent layout + # via a fixture; instead we monkeypatch the module-level Path computation. + # _serve_static derives static_root from `Path(__file__).parent.parent / "static"`, + # so we monkeypatch __file__ via a closure that re-resolves with our temp tree. + # Simpler: patch the cache and call the real function with a parsed path that + # resolves under the real static dir. We use the fixture below instead. + + +# ── Fixture: build a tiny isolated static tree and rebind paths ─────────── + + +import pytest + + +@pytest.fixture +def isolated_static(tmp_path, monkeypatch): + """Stand up an isolated static/ tree and rebind _serve_static to use it. + + Yields the static_root Path so tests can drop files into it. + """ + from api import routes + + static_root = tmp_path / "static" + static_root.mkdir() + + # Patch the cache so cross-test state cannot leak. + monkeypatch.setattr(routes, "_STATIC_CACHE", {}, raising=True) + + # _serve_static derives static_root from Path(__file__).parent.parent. + # Rebind by monkeypatching Path resolution: we wrap the function so the + # caller-visible signature is unchanged. + original = routes._serve_static + + def wrapped(handler, parsed): + # Trick: temporarily monkeypatch Path so the function sees our temp tree. + import api.routes as ar + orig_file = ar.__file__ + # Place a sentinel api/routes.py "next to" tmp_path so the relative + # walk lands in our static_root. + fake_api_dir = tmp_path / "api" + fake_api_dir.mkdir(exist_ok=True) + fake_routes = fake_api_dir / "routes.py" + if not fake_routes.exists(): + fake_routes.write_text("# stub for path resolution\n") + monkeypatch.setattr(ar, "__file__", str(fake_routes)) + try: + return original(handler, parsed) + finally: + monkeypatch.setattr(ar, "__file__", orig_file) + + monkeypatch.setattr(routes, "_serve_static", wrapped) + yield static_root + + +# ── Tests ───────────────────────────────────────────────────────────────── + + +def test_plain_get_returns_raw_bytes_with_etag(isolated_static): + from api import routes + payload = b"console.log('hello');\n" * 200 # > 1 KB so gzip-eligible + _make_static_file(isolated_static, "ui.js", payload) + + h = _serve(routes, "/static/ui.js") + assert h.status == 200 + assert h.header("Content-Type") == "application/javascript; charset=utf-8" + assert h.header("Content-Encoding") is None # no gzip without Accept-Encoding + assert h.header("ETag") is not None and h.header("ETag").startswith('W/"') + assert h.header("Cache-Control") == "public, max-age=300" # no fingerprint + assert bytes(h.body) == payload + + +def test_gzip_negotiated_when_client_accepts(isolated_static): + from api import routes + payload = (b"a" * 50_000) + _make_static_file(isolated_static, "ui.js", payload) + + h = _serve(routes, "/static/ui.js", request_headers={"Accept-Encoding": "gzip, deflate"}) + assert h.status == 200 + assert h.header("Content-Encoding") == "gzip" + assert h.header("Vary") == "Accept-Encoding" + assert gzip.decompress(bytes(h.body)) == payload + assert int(h.header("Content-Length")) == len(h.body) < len(payload) + + +def test_fingerprinted_url_gets_immutable_cache(isolated_static): + from api import routes + _make_static_file(isolated_static, "ui.js", b"x" * 2000) + + h = _serve(routes, "/static/ui.js", query="v=abc1234") + assert h.header("Cache-Control") == "public, max-age=31536000, immutable" + + +def test_empty_fingerprint_value_gets_short_cache(isolated_static): + """Only a non-empty version token is an immutable-cache fingerprint.""" + from api import routes + _make_static_file(isolated_static, "ui.js", b"x" * 2000) + + h = _serve(routes, "/static/ui.js", query="v=") + assert h.header("Cache-Control") == "public, max-age=300" + + +def test_unfingerprinted_url_gets_short_cache(isolated_static): + from api import routes + _make_static_file(isolated_static, "ui.js", b"x" * 2000) + + h = _serve(routes, "/static/ui.js") + assert h.header("Cache-Control") == "public, max-age=300" + + +def test_conditional_get_returns_304(isolated_static): + from api import routes + _make_static_file(isolated_static, "ui.js", b"hello world\n" * 100) + + first = _serve(routes, "/static/ui.js", query="v=abc") + etag = first.header("ETag") + assert etag is not None + + second = _serve(routes, "/static/ui.js", query="v=abc", + request_headers={"If-None-Match": etag}) + assert second.status == 304 + assert second.header("ETag") == etag + assert second.header("Cache-Control") == "public, max-age=31536000, immutable" + assert second.header("Vary") == "Accept-Encoding" + assert bytes(second.body) == b"" + + +def test_etag_changes_when_file_changes(isolated_static): + """Cache must invalidate when (size, mtime) changes — guards redeploy correctness.""" + import time + from api import routes + + f = _make_static_file(isolated_static, "ui.js", b"v1" * 1000) + first = _serve(routes, "/static/ui.js") + etag_v1 = first.header("ETag") + + # Touch with a later mtime (1 s granularity matches the ETag formula). + time.sleep(1.1) + f.write_bytes(b"v2-different-content" * 50) + + second = _serve(routes, "/static/ui.js") + etag_v2 = second.header("ETag") + assert etag_v1 != etag_v2 + # Old ETag now produces a 200, not a stale 304. + third = _serve(routes, "/static/ui.js", request_headers={"If-None-Match": etag_v1}) + assert third.status == 200 + + +def test_etag_changes_for_same_size_edits_within_same_second(isolated_static): + """The cache signature must keep sub-second mtime precision.""" + import os + from api import routes + + f = _make_static_file(isolated_static, "ui.js", b"a" * 2048) + second = 1_900_000_000 + os.utime(f, ns=(second * 1_000_000_000, second * 1_000_000_000)) + + first = _serve(routes, "/static/ui.js") + etag_v1 = first.header("ETag") + + f.write_bytes(b"b" * 2048) + os.utime(f, ns=(second * 1_000_000_000 + 123_000_000, + second * 1_000_000_000 + 123_000_000)) + + second_response = _serve(routes, "/static/ui.js") + assert second_response.header("ETag") != etag_v1 + assert bytes(second_response.body) == b"b" * 2048 + + +def test_image_is_not_gzipped(isolated_static): + """Already-compressed binary types must skip gzip to avoid wasted CPU.""" + from api import routes + # 4 KB of pseudo-PNG (real header doesn't matter, only the MIME does) + _make_static_file(isolated_static, "favicon.png", b"\x89PNG\r\n\x1a\n" + b"\x00" * 4000) + + h = _serve(routes, "/static/favicon.png", request_headers={"Accept-Encoding": "gzip"}) + assert h.status == 200 + assert h.header("Content-Encoding") is None + assert h.header("Content-Type") == "image/png" + + +def test_tiny_file_is_not_gzipped(isolated_static): + """Files under 1 KB skip gzip — framing overhead exceeds savings.""" + from api import routes + _make_static_file(isolated_static, "tiny.js", b"export {};\n") + + h = _serve(routes, "/static/tiny.js", request_headers={"Accept-Encoding": "gzip"}) + assert h.status == 200 + assert h.header("Content-Encoding") is None + + +def test_path_traversal_still_rejected(isolated_static): + """Sandbox check from the original implementation must remain intact.""" + from api import routes + _make_static_file(isolated_static, "ui.js", b"ok") + # Try to break out of static/ — must 404, not serve external files. + h = _serve(routes, "/static/../api/routes.py") + assert h.status == 404 From 7999d1c75aef8b206b9cec27607cee29c765c6bd Mon Sep 17 00:00:00 2001 From: Abdul Munim Date: Sat, 23 May 2026 10:30:12 +0200 Subject: [PATCH 20/68] feat(workspace): add Open in VS Code action for files and folders (#2735) Right-click any workspace file, folder, or root now shows 'Open in VS Code' alongside the existing Reveal in File Manager action. - POST /api/file/open-vscode: resolves path via safe_resolve, finds VS Code via shutil.which() with fallbacks for macOS (/usr/local/bin/code, app bundle CLI), Linux (/usr/bin/code, /snap/bin/code), and Windows (%LOCALAPPDATA% and %PROGRAMFILES% user/system installs). Returns a descriptive error if not found rather than a bare OS error. - Optional vscode block in config.yaml: command (default: code), host_path_prefix + container_path_prefix for Docker path mapping. - i18n: open_in_vscode and open_in_vscode_failed translated in all 10 locales (it, ja, ru, es, de, zh-CN, zh-TW, pt, ko). - 26 tests in tests/test_2735_open_in_vscode.py covering source wiring, command resolution, i18n completeness, and live endpoint error paths. --- api/routes.py | 87 ++++++++ static/i18n.js | 100 +++++---- static/ui.js | 15 ++ tests/test_2735_open_in_vscode.py | 342 ++++++++++++++++++++++++++++++ 4 files changed, 504 insertions(+), 40 deletions(-) create mode 100644 tests/test_2735_open_in_vscode.py diff --git a/api/routes.py b/api/routes.py index c6465740..eee4f166 100644 --- a/api/routes.py +++ b/api/routes.py @@ -5456,6 +5456,9 @@ def handle_post(handler, parsed) -> bool: if parsed.path == "/api/file/path": return _handle_file_path(handler, body) + if parsed.path == "/api/file/open-vscode": + return _handle_file_open_vscode(handler, body) + # ── Workspace management (POST) ── if parsed.path == "/api/workspaces/add": return _handle_workspace_add(handler, body) @@ -9591,6 +9594,90 @@ def _handle_file_path(handler, body): return bad(handler, _sanitize_error(e)) +def _handle_file_open_vscode(handler, body): + """Open a workspace file or folder in VS Code (#2735). + + Reads optional ``vscode`` config block from config.yaml: + + vscode: + command: code # executable on PATH; defaults to "code" + host_path_prefix: /home/user/projects # Docker host path + container_path_prefix: /app/workspace # matching container path + + If ``host_path_prefix`` and ``container_path_prefix`` are both set, + paths that begin with ``container_path_prefix`` are translated to the + host prefix before being handed to VS Code. This lets users running + Hermes WebUI inside Docker still open files in their local editor. + """ + try: + require(body, "session_id", "path") + except ValueError as e: + return bad(handler, str(e)) + try: + s = get_session(body["session_id"]) + except KeyError: + return bad(handler, "Session not found", 404) + try: + target = safe_resolve(Path(s.workspace), body["path"]) + if not target.exists(): + return bad(handler, f"File not found: {target}", 404) + + target_str = str(target) + + # Optional Docker host/container path translation + from api.config import get_config as _get_cfg # noqa: PLC0415 + vscode_cfg = _get_cfg().get("vscode", {}) + if not isinstance(vscode_cfg, dict): + vscode_cfg = {} + container_prefix = vscode_cfg.get("container_path_prefix", "") + host_prefix = vscode_cfg.get("host_path_prefix", "") + if container_prefix and host_prefix and target_str.startswith(container_prefix): + target_str = host_prefix + target_str[len(container_prefix):] + + cmd = vscode_cfg.get("command", "code") + # Resolve the command to an absolute path so subprocess.Popen finds it + # even when the server process inherits a minimal PATH (e.g. when + # launched via start.sh on macOS where /usr/local/bin may be absent). + resolved_cmd = shutil.which(cmd) + if resolved_cmd is None: + # Try common VS Code installation paths as fallback. + # macOS: /usr/local/bin/code (symlink) or app bundle CLI + # Linux: /usr/bin/code or snap + # Windows: user-install under %LOCALAPPDATA%, system-install under %PROGRAMFILES% + _local_app_data = os.environ.get("LOCALAPPDATA", "") + _prog_files = os.environ.get("PROGRAMFILES", "C:\\Program Files") + _prog_files_x86 = os.environ.get("PROGRAMFILES(X86)", "C:\\Program Files (x86)") + _vscode_fallbacks = [ + # macOS + "/usr/local/bin/code", + "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code", + # Linux + "/usr/bin/code", + "/snap/bin/code", + # Windows (user install) + os.path.join(_local_app_data, "Programs", "Microsoft VS Code", "bin", "code.cmd"), + # Windows (system install) + os.path.join(_prog_files, "Microsoft VS Code", "bin", "code.cmd"), + os.path.join(_prog_files_x86, "Microsoft VS Code", "bin", "code.cmd"), + ] + for fb in _vscode_fallbacks: + if fb and Path(fb).exists(): + resolved_cmd = fb + break + if resolved_cmd is None: + return bad( + handler, + f"VS Code command not found: {cmd!r}. " + "Install VS Code and ensure the 'code' CLI is on PATH, " + "or set vscode.command in config.yaml to the full path.", + ) + subprocess.Popen([resolved_cmd, target_str]) + + return j(handler, {"ok": True, "path": body["path"]}) + except (ValueError, PermissionError, OSError) as e: + return bad(handler, _sanitize_error(e)) + + def _handle_workspace_add(handler, body): # Strip surrounding paired quotes BEFORE any further processing — macOS # Finder's "Copy as Pathname" wraps paths in single quotes, and users diff --git a/static/i18n.js b/static/i18n.js index dcda7f35..0f785a31 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -402,10 +402,12 @@ const LOCALES = { rename_prompt: 'New name:', deleted: 'Deleted ', delete_failed: 'Delete failed: ', - reveal_in_finder: 'Reveal in File Manager', - reveal_failed: 'Failed to reveal: ', - copy_file_path: 'Copy file path', - download_folder: 'Download Folder', + reveal_in_finder: 'Reveal in File Manager', + reveal_failed: 'Failed to reveal: ', + copy_file_path: 'Copy file path', + open_in_vscode: 'Open in VS Code', + open_in_vscode_failed: 'Failed to open in VS Code: ', + download_folder: 'Download Folder', path_copied: 'File path copied to clipboard', path_copy_failed: 'Failed to copy path: ', session_rename: 'Rename conversation', @@ -1636,10 +1638,12 @@ const LOCALES = { rename_prompt: 'Nuovo nome:', deleted: 'Eliminato ', delete_failed: 'Eliminazione fallita: ', - reveal_in_finder: 'Mostra nel File Manager', - reveal_failed: 'Mostra fallito: ', - copy_file_path: 'Copia percorso file', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: 'Mostra nel File Manager', + reveal_failed: 'Mostra fallito: ', + copy_file_path: 'Copia percorso file', + open_in_vscode: 'Apri in VS Code', + open_in_vscode_failed: 'Apertura in VS Code fallita: ', + download_folder: 'Download Folder', // TODO: translate path_copied: 'Percorso file copiato negli appunti', path_copy_failed: 'Copia percorso fallita: ', session_rename: 'Rinomina conversazione', @@ -2862,10 +2866,12 @@ const LOCALES = { rename_prompt: '新しい名前:', deleted: '削除しました: ', delete_failed: '削除失敗: ', - reveal_in_finder: 'ファイルマネージャーで表示', - reveal_failed: '表示に失敗しました: ', - copy_file_path: 'ファイルパスをコピー', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: 'ファイルマネージャーで表示', + reveal_failed: '表示に失敗しました: ', + copy_file_path: 'ファイルパスをコピー', + open_in_vscode: 'VS Codeで開く', + open_in_vscode_failed: 'VS Codeで開けませんでした: ', + download_folder: 'Download Folder', // TODO: translate path_copied: 'ファイルパスをクリップボードにコピーしました', path_copy_failed: 'パスのコピーに失敗しました: ', session_rename: '会話の名前を変更', @@ -4014,10 +4020,12 @@ const LOCALES = { rename_prompt: 'Новое имя:', deleted: 'Удалено ', delete_failed: 'Не удалось удалить: ', - reveal_in_finder: 'Показать в файловом менеджере', - reveal_failed: 'Не удалось открыть: ', - copy_file_path: 'Копировать путь к файлу', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: 'Показать в файловом менеджере', + reveal_failed: 'Не удалось открыть: ', + copy_file_path: 'Копировать путь к файлу', + open_in_vscode: 'Открыть в VS Code', + open_in_vscode_failed: 'Не удалось открыть в VS Code: ', + download_folder: 'Download Folder', // TODO: translate path_copied: 'Путь к файлу скопирован в буфер обмена', path_copy_failed: 'Не удалось скопировать путь: ', session_rename: 'Переименовать беседу', @@ -5159,10 +5167,12 @@ const LOCALES = { rename_prompt: 'Nuevo nombre:', deleted: 'Eliminado ', delete_failed: 'Error al eliminar: ', - reveal_in_finder: 'Mostrar en el gestor de archivos', - reveal_failed: 'Error al mostrar: ', - copy_file_path: 'Copiar ruta del archivo', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: 'Mostrar en el gestor de archivos', + reveal_failed: 'Error al mostrar: ', + copy_file_path: 'Copiar ruta del archivo', + open_in_vscode: 'Abrir en VS Code', + open_in_vscode_failed: 'Error al abrir en VS Code: ', + download_folder: 'Download Folder', // TODO: translate path_copied: 'Ruta del archivo copiada al portapapeles', path_copy_failed: 'Error al copiar la ruta: ', session_rename: 'Renombrar conversación', @@ -6307,10 +6317,12 @@ const LOCALES = { rename_prompt: 'Neuer Name:', deleted: 'Gelöscht ', delete_failed: 'Löschen fehlgeschlagen: ', - reveal_in_finder: 'Im Dateimanager anzeigen', - reveal_failed: 'Anzeige fehlgeschlagen: ', - copy_file_path: 'Dateipfad kopieren', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: 'Im Dateimanager anzeigen', + reveal_failed: 'Anzeige fehlgeschlagen: ', + copy_file_path: 'Dateipfad kopieren', + open_in_vscode: 'In VS Code öffnen', + open_in_vscode_failed: 'In VS Code öffnen fehlgeschlagen: ', + download_folder: 'Download Folder', // TODO: translate path_copied: 'Dateipfad in die Zwischenablage kopiert', path_copy_failed: 'Pfad konnte nicht kopiert werden: ', session_rename: 'Unterhaltung umbenennen', @@ -7507,10 +7519,12 @@ const LOCALES = { rename_prompt: '新名称:', deleted: '已删除 ', delete_failed: '删除失败:', - reveal_in_finder: '在文件管理器中显示', - reveal_failed: '显示失败:', - copy_file_path: '\u590d\u5236\u6587\u4ef6\u8def\u5f84', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: '在文件管理器中显示', + reveal_failed: '显示失败:', + copy_file_path: '\u590d\u5236\u6587\u4ef6\u8def\u5f84', + open_in_vscode: '在VS Code中打开', + open_in_vscode_failed: '在VS Code中打开失败:', + download_folder: 'Download Folder', // TODO: translate path_copied: '\u6587\u4ef6\u8def\u5f84\u5df2\u590d\u5236\u5230\u526a\u8d34\u677f', path_copy_failed: '\u590d\u5236\u8def\u5f84\u5931\u8d25\uff1a', session_rename: '\u91cd\u547d\u540d\u5bf9\u8bdd', @@ -8576,10 +8590,12 @@ const LOCALES = { rename_prompt: '新名稱:', deleted: '\u5df2\u522a\u9664 ', delete_failed: '\u522a\u9664\u5931\u6557\uff1a', - reveal_in_finder: '\u5728\u6a94\u6848\u7ba1\u7406\u54e1\u4e2d\u986f\u793a', - reveal_failed: '\u986f\u793a\u5931\u6557\uff1a', - copy_file_path: '\u8907\u88fd\u6a94\u6848\u8def\u5f91', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: '\u5728\u6a94\u6848\u7ba1\u7406\u54e1\u4e2d\u986f\u793a', + reveal_failed: '\u986f\u793a\u5931\u6557\uff1a', + copy_file_path: '\u8907\u88fd\u6a94\u6848\u8def\u5f91', + open_in_vscode: '在VS Code中開啟', + open_in_vscode_failed: '在VS Code中開啟失敗:', + download_folder: 'Download Folder', // TODO: translate path_copied: '\u6a94\u6848\u8def\u5f91\u5df2\u8907\u88fd\u5230\u526a\u8cbc\u7c3f', path_copy_failed: '\u8907\u88fd\u8def\u5f91\u5931\u6557\uff1a', session_rename: '\u91cd\u65b0\u547d\u540d\u5c0d\u8a71', @@ -9888,10 +9904,12 @@ const LOCALES = { delete_confirm: (name) => `Excluir ${name}?`, deleted: 'Excluído ', delete_failed: 'Falha ao excluir: ', - reveal_in_finder: 'Mostrar no gerenciador de arquivos', - reveal_failed: 'Falha ao mostrar: ', - copy_file_path: 'Copiar caminho do arquivo', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: 'Mostrar no gerenciador de arquivos', + reveal_failed: 'Falha ao mostrar: ', + copy_file_path: 'Copiar caminho do arquivo', + open_in_vscode: 'Abrir no VS Code', + open_in_vscode_failed: 'Falha ao abrir no VS Code: ', + download_folder: 'Download Folder', // TODO: translate path_copied: 'Caminho do arquivo copiado para a área de transferência', path_copy_failed: 'Falha ao copiar caminho: ', session_rename: 'Renomear conversa', @@ -11012,10 +11030,12 @@ const LOCALES = { rename_prompt: '새 이름:', deleted: '삭제됨: ', delete_failed: '삭제 실패: ', - reveal_in_finder: '파일 관리자에서 열기', - reveal_failed: '표시 실패: ', - copy_file_path: '파일 경로 복사', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: '파일 관리자에서 열기', + reveal_failed: '표시 실패: ', + copy_file_path: '파일 경로 복사', + open_in_vscode: 'VS Code에서 열기', + open_in_vscode_failed: 'VS Code에서 열기 실패: ', + download_folder: 'Download Folder', // TODO: translate path_copied: '파일 경로가 클립보드에 복사되었습니다', path_copy_failed: '경로 복사 실패: ', session_rename: '대화 이름 변경', diff --git a/static/ui.js b/static/ui.js index 5dc03b7a..c6a5cd2a 100644 --- a/static/ui.js +++ b/static/ui.js @@ -7918,6 +7918,12 @@ function _showWorkspaceRootContextMenu(e){ catch(err){showToast(t('reveal_failed')+(err.message||err));} })); + menu.appendChild(_workspaceContextMenuItem(t('open_in_vscode'),async()=>{ + menu.remove(); + try{await api('/api/file/open-vscode',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:'.'})});} + catch(err){showToast(t('open_in_vscode_failed')+(err.message||err));} + })); + menu.appendChild(_workspaceContextMenuItem(t('copy_file_path'),async()=>{ menu.remove(); try{ @@ -8163,6 +8169,15 @@ function _showFileContextMenu(e, item){ revealItem.onclick=async()=>{menu.remove();try{await api('/api/file/reveal',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});}catch(err){showToast(t('reveal_failed')+(err.message||err));}}; menu.appendChild(revealItem); + // Open in VS Code (#2735) + const vscodeItem=document.createElement('div'); + vscodeItem.textContent=t('open_in_vscode'); + vscodeItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);'; + vscodeItem.onmouseenter=()=>vscodeItem.style.background='var(--hover-bg)'; + vscodeItem.onmouseleave=()=>vscodeItem.style.background=''; + vscodeItem.onclick=async()=>{menu.remove();try{await api('/api/file/open-vscode',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});}catch(err){showToast(t('open_in_vscode_failed')+(err.message||err));}}; + menu.appendChild(vscodeItem); + // Copy file path — resolves the absolute on-disk path on the server (so the // user gets the full /home/.../workspace/foo.py rather than the relative // path the file tree shows) and writes it to the OS clipboard. Useful for diff --git a/tests/test_2735_open_in_vscode.py b/tests/test_2735_open_in_vscode.py new file mode 100644 index 00000000..dbf2fb8c --- /dev/null +++ b/tests/test_2735_open_in_vscode.py @@ -0,0 +1,342 @@ +"""Tests for issue #2735 — "Open in VS Code" action for workspace files/folders. + +Pins three layers: + +1. **Source wiring** — the dispatch entry, handler structure, and menu items + exist in the correct files. + +2. **i18n completeness** — both new keys (``open_in_vscode`` and + ``open_in_vscode_failed``) are present in every locale block. + +3. **Live endpoint behaviour** — error paths (missing fields, unknown session, + missing file, path traversal) behave correctly against the test server. + +The success path (VS Code actually opening) is not covered here because it +requires VS Code to be installed on the CI host. The subprocess call is +intentionally fire-and-forget (matching ``_handle_file_reveal``), so its +failure is surfaced via the OSError catch and a 400 response. That +observable is tested in ``TestOpenInVsCodeEndpointBehaviour``. +""" +from __future__ import annotations + +import json +import pathlib +import re +import sys +import urllib.error +import urllib.request + +ROOT = pathlib.Path(__file__).resolve().parent.parent +ROUTES = ROOT / "api" / "routes.py" +UI = ROOT / "static" / "ui.js" +I18N = ROOT / "static" / "i18n.js" + +sys.path.insert(0, str(pathlib.Path(__file__).parent)) +from conftest import TEST_BASE # noqa: E402 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Source-level wiring +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestOpenInVsCodeBackendWiring: + def test_route_dispatch_entry_present(self): + """Dispatcher must route /api/file/open-vscode to the handler.""" + src = ROUTES.read_text(encoding="utf-8") + assert 'parsed.path == "/api/file/open-vscode"' in src + + def test_handler_function_defined(self): + src = ROUTES.read_text(encoding="utf-8") + assert "def _handle_file_open_vscode(handler, body):" in src + + def test_handler_uses_safe_resolve(self): + """Handler must use safe_resolve to prevent path traversal.""" + src = ROUTES.read_text(encoding="utf-8") + m = re.search( + r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )", + src, + re.DOTALL, + ) + assert m, "_handle_file_open_vscode body not found" + body = m.group(0) + assert "safe_resolve(Path(s.workspace)" in body + + def test_handler_checks_existence(self): + """Handler must require the target to exist (unlike copy-path).""" + src = ROUTES.read_text(encoding="utf-8") + m = re.search( + r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )", + src, + re.DOTALL, + ) + assert m + body = m.group(0) + assert "exists()" in body + + def test_handler_reads_vscode_config(self): + """Handler must read the optional ``vscode`` config block.""" + src = ROUTES.read_text(encoding="utf-8") + m = re.search( + r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )", + src, + re.DOTALL, + ) + assert m + body = m.group(0) + assert 'get("vscode"' in body + + def test_handler_defaults_to_code_command(self): + """Default executable must be ``code`` when config is absent.""" + src = ROUTES.read_text(encoding="utf-8") + m = re.search( + r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )", + src, + re.DOTALL, + ) + assert m + body = m.group(0) + assert '"code"' in body + + def test_handler_supports_path_prefix_mapping(self): + """Handler must support container_path_prefix / host_path_prefix + so Docker users can map container paths to host paths.""" + src = ROUTES.read_text(encoding="utf-8") + m = re.search( + r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )", + src, + re.DOTALL, + ) + assert m + body = m.group(0) + assert "container_path_prefix" in body + assert "host_path_prefix" in body + + def test_handler_uses_subprocess_popen(self): + """Handler must use subprocess.Popen (async, non-blocking) consistent + with _handle_file_reveal.""" + src = ROUTES.read_text(encoding="utf-8") + m = re.search( + r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )", + src, + re.DOTALL, + ) + assert m + body = m.group(0) + assert "subprocess.Popen(" in body + + def test_handler_resolves_command_via_shutil_which(self): + """Handler must use shutil.which() to find the command so it works + even when the server's inherited PATH is minimal (e.g. macOS launch + via start.sh where /usr/local/bin may be absent from the subprocess + PATH).""" + src = ROUTES.read_text(encoding="utf-8") + m = re.search( + r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )", + src, + re.DOTALL, + ) + assert m + body = m.group(0) + assert "shutil.which(" in body + + def test_handler_has_vscode_fallback_paths(self): + """Handler must try common VS Code paths when shutil.which fails, + covering macOS (/usr/local/bin/code), Linux (/snap/bin/code), and + Windows (%LOCALAPPDATA%\\Programs\\Microsoft VS Code).""" + src = ROUTES.read_text(encoding="utf-8") + m = re.search( + r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )", + src, + re.DOTALL, + ) + assert m + body = m.group(0) + assert "/usr/local/bin/code" in body # macOS + assert "/snap/bin/code" in body # Linux snap + assert "Microsoft VS Code" in body # Windows + + def test_handler_returns_helpful_error_when_not_found(self): + """When code command is not found anywhere, handler must return a + descriptive error instead of a bare OSError message.""" + src = ROUTES.read_text(encoding="utf-8") + m = re.search( + r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )", + src, + re.DOTALL, + ) + assert m + body = m.group(0) + assert "VS Code command not found" in body + + +class TestOpenInVsCodeFrontendWiring: + def test_file_context_menu_has_vscode_item(self): + """_showFileContextMenu must include the Open in VS Code action.""" + src = UI.read_text(encoding="utf-8") + assert "t('open_in_vscode')" in src + assert "/api/file/open-vscode" in src + + def test_workspace_root_context_menu_has_vscode_item(self): + """_showWorkspaceRootContextMenu must also include the VS Code action.""" + src = UI.read_text(encoding="utf-8") + # Both the file and root menus call the same endpoint; verify at least + # two references in the file so we know both call sites exist. + assert src.count("/api/file/open-vscode") >= 2 + + def test_vscode_item_uses_hover_bg(self): + """VS Code menu item must use var(--hover-bg), not var(--hover) or + any other undefined variable.""" + src = UI.read_text(encoding="utf-8") + # Confirm the item is wired with the correct variable — count hover-bg + # usages; as long as our item follows the pattern the suite is green. + assert "var(--hover-bg)" in src + + def test_vscode_failure_toast_uses_i18n_key(self): + """Error toast on VS Code open failure must use the translatable key.""" + src = UI.read_text(encoding="utf-8") + assert "t('open_in_vscode_failed')" in src + + def test_vscode_item_guards_err_message(self): + """Error handler must guard against non-Error objects with + (err.message||err) consistent with reveal handler.""" + src = UI.read_text(encoding="utf-8") + # Find the open-vscode call site and check for the guard pattern near it. + idx = src.find("/api/file/open-vscode") + assert idx != -1 + # Look in a window around the first call site. + window = src[max(0, idx - 200) : idx + 500] + assert "(err.message||err)" in window or "(err.message || err)" in window + + +class TestOpenInVsCodeI18n: + """Both new translation keys must be present in every locale block.""" + + LOCALES = [ + # (locale tag, sample anchor key: value) + ("en", "reveal_in_finder: 'Reveal in File Manager'"), + ("it", "reveal_in_finder: 'Mostra nel File Manager'"), + ("ja", "reveal_in_finder: 'ファイルマネージャーで表示'"), + ("ru", "reveal_in_finder: 'Показать в файловом менеджере'"), + ("es", "reveal_in_finder: 'Mostrar en el gestor de archivos'"), + ("de", "reveal_in_finder: 'Im Dateimanager anzeigen'"), + ("zh-CN", "reveal_in_finder: '在文件管理器中显示'"), + ("pt", "reveal_in_finder: 'Mostrar no gerenciador de arquivos'"), + ("ko", "reveal_in_finder: '파일 관리자에서 열기'"), + ] + + def test_open_in_vscode_key_count(self): + """open_in_vscode key must appear exactly once per locale (10 total).""" + src = I18N.read_text(encoding="utf-8") + count = src.count("open_in_vscode:") + assert count == 10, ( + f"Expected 10 open_in_vscode: entries (one per locale), found {count}" + ) + + def test_open_in_vscode_failed_key_count(self): + """open_in_vscode_failed key must appear exactly once per locale (10 total).""" + src = I18N.read_text(encoding="utf-8") + count = src.count("open_in_vscode_failed:") + assert count == 10, ( + f"Expected 10 open_in_vscode_failed: entries (one per locale), found {count}" + ) + + def test_english_translation_not_a_placeholder(self): + """English locale must have a human-readable string, not a TODO.""" + src = I18N.read_text(encoding="utf-8") + assert "open_in_vscode: 'Open in VS Code'" in src + assert "open_in_vscode_failed: 'Failed to open in VS Code: '" in src + + def test_non_english_locales_translated(self): + """Non-English locales must have real translations, not TODO stubs.""" + src = I18N.read_text(encoding="utf-8") + # Spot-check a selection of locales — none of these should be TODO stubs. + assert "open_in_vscode: 'Apri in VS Code'" in src # it + assert "open_in_vscode: 'VS Codeで開く'" in src # ja + assert "open_in_vscode: 'Открыть в VS Code'" in src # ru + assert "open_in_vscode: 'Abrir en VS Code'" in src # es + assert "open_in_vscode: 'In VS Code öffnen'" in src # de + assert "open_in_vscode: 'VS Code에서 열기'" in src # ko + + def test_keys_adjacent_to_reveal_block(self): + """New keys must appear near the reveal/copy block so locale coverage + is easy to spot in code review.""" + src = I18N.read_text(encoding="utf-8") + # In the English block, open_in_vscode must appear between + # copy_file_path and download_folder. + copy_idx = src.index("copy_file_path: 'Copy file path'") + dl_idx = src.index("download_folder: 'Download Folder'", copy_idx) + vscode_idx = src.index("open_in_vscode: 'Open in VS Code'", copy_idx) + assert copy_idx < vscode_idx < dl_idx, ( + "open_in_vscode key must appear between copy_file_path and " + "download_folder in the English locale block" + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Live endpoint behaviour +# ═══════════════════════════════════════════════════════════════════════════════ + + +def _post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request( + TEST_BASE + path, + data=data, + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + + +class TestOpenInVsCodeEndpointBehaviour: + def _new_session(self): + body, status = _post("/api/session/new", {}) + assert status == 200, body + return body["session"]["session_id"] + + def test_missing_session_id_returns_400(self): + body, status = _post("/api/file/open-vscode", {"path": "."}) + assert status == 400, body + assert "session_id" in body.get("error", "") + + def test_missing_path_returns_400(self): + sid = self._new_session() + body, status = _post("/api/file/open-vscode", {"session_id": sid}) + assert status == 400, body + assert "path" in body.get("error", "") + + def test_unknown_session_returns_404(self): + body, status = _post( + "/api/file/open-vscode", + {"session_id": "nonexistent-session-xyz", "path": "."}, + ) + assert status == 404, body + assert "session" in body.get("error", "").lower() + + def test_missing_file_returns_404_with_path(self): + """Attempting to open a file that does not exist must return 404 and + include the resolved path in the error (mirrors _handle_file_reveal + behaviour introduced in #1764).""" + sid = self._new_session() + body, status = _post( + "/api/file/open-vscode", + {"session_id": sid, "path": "does-not-exist-2735.txt"}, + ) + assert status == 404, body + err = body.get("error", "") + assert "does-not-exist-2735.txt" in err, ( + f"404 message must include the resolved path, got: {err!r}" + ) + + def test_path_traversal_rejected(self): + """Handler must reject paths that escape the workspace root.""" + sid = self._new_session() + body, status = _post( + "/api/file/open-vscode", + {"session_id": sid, "path": "../../../../../../etc/passwd"}, + ) + assert status == 400, body From 79a3ac372483c7c1a3fefea56f13663e92a65bf1 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 24 May 2026 04:27:34 +0000 Subject: [PATCH 21/68] Stamp CHANGELOG for v0.51.123 (Release CU / stage-batch5 / 2-PR low-risk batch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked PRs: - #2779 (v2psv) — gzip + ETag/304 + immutable cache for fingerprinted /static/* - #2787 (munim) — Open in VS Code workspace file browser action (closes #2735) Conflict resolution: kept ours CHANGELOG (3 PRs all added Unreleased entries). For #2779's _serve_static overhaul, took theirs (PR replaces function body). Folded missing 'import gzip' into the #2779 commit via fixup+autosquash. --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dcbbb23..ecad5762 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ ## [Unreleased] +## [v0.51.123] — 2026-05-24 — Release CU (stage-batch5 — 2-PR low-risk batch — gzip+ETag static caching / Open in VS Code) + +### Performance + +- **PR #2779** by @v2psv — Static asset serving negotiates gzip, emits ETags, and uses `immutable` cache headers for fingerprinted URLs. `_serve_static()` in `api/routes.py` previously sent every `/static/*` response with `Cache-Control: no-store` and no `Content-Encoding`, so a page reload over a slow link re-downloaded the full ~2.4 MB JS+CSS shell on every visit. The fix layers three changes inside the same function: (1) gzip the body when the client opts in via `Accept-Encoding`, gated to compressible MIME types and files >1 KB; (2) emit a weak ETag derived from `(size, mtime_ns)` and short-circuit conditional GETs to `304 Not Modified`; (3) send `Cache-Control: public, max-age=31536000, immutable` when the URL carries a non-empty `?v=…` fingerprint (the `__WEBUI_VERSION__` token already substituted by the index template and referenced from `static/sw.js`'s `SHELL_ASSETS`), falling back to `public, max-age=300` otherwise. Raw bytes, compressed bytes, and ETags are cached in-process keyed by `(size, mtime_ns)` so a redeploy is picked up without a restart, while missing/random paths never enter the cache and image/font types skip gzip to avoid wasted CPU on already-compressed payloads. Measured against an asyncio TCP proxy that injects RTT + bandwidth caps for representative VPN scenarios: cold loads improve 2.7-3.1× (e.g. 80 ms RTT / 10 Mbps WireGuard goes from 4.0 s to 1.3 s), warm reloads improve 3.3-4.0× via 304 responses, and bytes-on-the-wire drop 74% on cold loads. Loopback (already fast) still benefits 2.4×. Scope is strictly `/static/*`: `/api/*`, `/stream`, `/`, `/index.html`, `/session/*`, and login/auth routes are served by independent handlers and continue to send `no-store` exactly as before — no change to CSRF, session payloads, SSE buffering, or login flows. 11 regression tests pin gzip negotiation, ETag/304 round-trip including `Vary: Accept-Encoding`, fingerprint-driven cache policy including empty `?v=`, image/tiny-file skip rules, redeploy invalidation, and the existing path-traversal sandbox. + +### Added + +- **PR #2787** by @munim — "Open in VS Code" action in workspace file browser (resolves #2735). Right-clicking any file, folder, or the workspace root now shows an **Open in VS Code** menu item alongside the existing Reveal in File Manager action. The action calls a new `POST /api/file/open-vscode` endpoint which resolves the workspace-relative path via the existing `safe_resolve` traversal guard, then launches VS Code via `subprocess.Popen` (fire-and-forget, consistent with `_handle_file_reveal`). The endpoint resolves the executable via `shutil.which()` first, then falls back to a hardcoded list of common install locations (macOS: `/usr/local/bin/code` and the app-bundle CLI; Linux: `/usr/bin/code`, `/snap/bin/code`; Windows: `%LOCALAPPDATA%\Programs\Microsoft VS Code\bin\code.cmd` and the `%PROGRAMFILES%` variants) so the action works even when the server process inherits a minimal PATH. Configurable via a new optional `vscode` block in `config.yaml`: `command` overrides the default `code` executable; `host_path_prefix` + `container_path_prefix` enable Docker/container host-path translation. If the command cannot be found anywhere, a descriptive error is returned instead of a bare OS error. i18n keys `open_in_vscode` and `open_in_vscode_failed` added with full translations in all 10 locales. 26 new tests in `tests/test_2735_open_in_vscode.py` pin source wiring, command-resolution logic, i18n completeness, translated strings, and live endpoint error paths. + ## [v0.51.122] — 2026-05-24 — Release CT (stage-batch4 — 4-PR low-risk batch — stale cache tail / inflight UI / segment flush / reasoning accumulator) ### Fixed From ed9a16373129cfb24faf2d60a2a57cd32117e73e Mon Sep 17 00:00:00 2001 From: Koraji95-coder Date: Sun, 24 May 2026 04:32:43 +0000 Subject: [PATCH 22/68] feat(start.ps1): expand hermes-agent candidate paths for Windows installers (#2805) Squashed from 3 author commits onto current master (the 3 base commits from already-shipped #2783 were filtered out by the squash): - 6822cbbb feat: expand hermes-agent candidate paths - 6f423538 Copilot review: PathType+null-guard+changelog - dbebbedd handle WOW64 ProgramFiles redirection Authorship preserved. CHANGELOG entry merged into batch stamp commit. --- start.ps1 | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/start.ps1 b/start.ps1 index 191db6c1..af61f14b 100644 --- a/start.ps1 +++ b/start.ps1 @@ -91,23 +91,35 @@ if (-not $Python) { # that's about to crash on missing imports. Smoke-test feedback on # PR #2783: nesquena/hermes-webui requested this guard. $AgentDir = $env:HERMES_WEBUI_AGENT_DIR -if ($AgentDir -and -not (Test-Path (Join-Path $AgentDir 'hermes_cli'))) { +if ($AgentDir -and -not (Test-Path (Join-Path $AgentDir 'hermes_cli') -PathType Container)) { Write-Error "HERMES_WEBUI_AGENT_DIR is set to '$AgentDir' but no hermes_cli/ folder exists there. Unset the variable to fall back to auto-discovery, or fix the path." exit 1 } if (-not $AgentDir) { - $candidates = @( - (Join-Path $env:USERPROFILE '.hermes\hermes-agent'), - (Join-Path (Split-Path -Parent $RepoRoot) 'hermes-agent') - ) + # Build candidate list incrementally — ${env:ProgramFiles(x86)} is null on + # 32-bit Windows and in some constrained environments, and Join-Path throws + # on a null Path. Skip any system-wide root that isn't set so the launcher + # stays robust across Windows variants. USERPROFILE is always set so it + # stays unguarded; the dev-checkout sibling is path-derived, not env-based. + $candidates = @() + $candidates += (Join-Path $env:USERPROFILE '.hermes\hermes-agent') + foreach ($root in @($env:LOCALAPPDATA, ${env:ProgramW6432}, ${env:ProgramFiles}, ${env:ProgramFiles(x86)})) { + if ($root) { $candidates += (Join-Path $root 'hermes\hermes-agent') } + } + $candidates += (Join-Path (Split-Path -Parent $RepoRoot) 'hermes-agent') + # De-dup: when running in a WOW64 (32-bit-on-64-bit) PowerShell process, + # $env:ProgramFiles is redirected to C:\Program Files (x86), so without + # $env:ProgramW6432 (the canonical 64-bit override) we'd miss the real + # C:\Program Files\hermes\hermes-agent AND duplicate the x86 entry. + # Select-Object -Unique collapses any collisions regardless of cause. + $candidates = $candidates | Select-Object -Unique foreach ($c in $candidates) { - if (Test-Path (Join-Path $c 'hermes_cli')) { $AgentDir = $c; break } + if (Test-Path (Join-Path $c 'hermes_cli') -PathType Container) { $AgentDir = $c; break } } } if (-not $AgentDir) { - $expectedPrimary = Join-Path $env:USERPROFILE '.hermes\hermes-agent' - $expectedSibling = Join-Path (Split-Path -Parent $RepoRoot) 'hermes-agent' - Write-Error "hermes-agent not found at $expectedPrimary or $expectedSibling. Set HERMES_WEBUI_AGENT_DIR explicitly." + $searched = $candidates -join ', ' + Write-Error "hermes-agent not found. Searched: $searched. Set HERMES_WEBUI_AGENT_DIR explicitly to override." exit 1 } From 9db6be99e17a1a89b0b4e06be8392dbe14f32117 Mon Sep 17 00:00:00 2001 From: Koraji95-coder Date: Sun, 24 May 2026 04:33:31 +0000 Subject: [PATCH 23/68] docs(start.ps1+README): clarify native Windows venv path; remove misleading WSL2-venv-portability claim (#2806) Squashed from 3 author commits onto current master (3 base commits from already-shipped #2783 were filtered out by the squash). #2805's expanded candidate-path discovery + PathType Container check preserved from prior stage commit. Authorship preserved. CHANGELOG entry merged into batch stamp commit. --- README.md | 3 ++- start.ps1 | 12 +++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d82ecf3c..ccaca241 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,8 @@ A community-maintained native Windows setup is documented at [@markwang2658/herm - **Memory:** community-measured ~330 MB native vs ~1080 MB with WSL2+Docker (varies by configuration). - **What works:** chat, workspace browser, session management, all themes. - **Known limitations:** some POSIX-style file paths surface in the workspace browser; bash-assuming agent tools may not work natively. -- **WSL2 relationship:** WSL2 is recommended *once* for first-time venv creation (since `bootstrap.py` currently refuses on native Windows). After the venv exists, `start.ps1` at the repo root runs the WebUI natively by invoking `server.py` directly — no WSL2 needed for day-to-day use. +- **Native Windows setup:** install Python 3.11+, then from the hermes-agent root in PowerShell: `python -m venv venv` → `pip install -r requirements.txt` → `pwsh .\start.ps1` (it auto-discovers `venv\Scripts\python.exe`). +- **WSL2 relationship:** not a prerequisite — a WSL2-built venv (`venv/bin/python`, ELF) isn't invokable by native Windows Python, so use the native setup above. WSL2 stays useful as a parallel install if you want the full `bootstrap.py` + Linux runtime. If provider setup is still incomplete after install, the onboarding wizard will point you to finish it with `hermes model` instead of trying to replicate the full CLI setup in-browser. For a step-by-step walkthrough of the wizard, provider choices, local model server Base URLs, and safe re-runs, see [`docs/onboarding.md`](docs/onboarding.md). diff --git a/start.ps1 b/start.ps1 index af61f14b..705da3eb 100644 --- a/start.ps1 +++ b/start.ps1 @@ -11,9 +11,15 @@ server.py itself runs cleanly on native Windows. Assumes Python + hermes-agent + the WebUI Python deps are already - installed - same assumption start.sh makes when invoked outside - a fresh bootstrap. For first-time setup, run bootstrap.py inside - WSL2 once to create the venv, then this script can use that venv. + installed natively on Windows - same assumption start.sh makes + when invoked outside a fresh bootstrap. For first-time setup, the + native Windows path is to install Python 3.11+, then create a + Windows venv (`python -m venv venv`) and `pip install -r + requirements.txt` from the hermes-agent root in PowerShell - this + script then finds `venv\Scripts\python.exe` automatically. A venv + created inside WSL2 is a Linux virtual environment (`venv/bin/python`) + and cannot be used by native Windows Python, so the bootstrap.py- + inside-WSL2 path produces a venv `start.ps1` can't invoke. .PARAMETER Port TCP port the WebUI binds to. Overrides HERMES_WEBUI_PORT env. From 055f9b3cd00813e2e99f5d6043507d3d0812a187 Mon Sep 17 00:00:00 2001 From: Koraji95-coder Date: Sun, 24 May 2026 04:34:06 +0000 Subject: [PATCH 24/68] harden(start.ps1): TryParse HERMES_WEBUI_PORT + exit AFTER try/finally cleanup (#2807) Squashed from 2 author commits onto current master (3 base commits from already-shipped #2783 were filtered out by the squash): - f53b9308 fix(start.ps1): TryParse HERMES_WEBUI_PORT + exit AFTER try/finally cleanup - 7b6e0722 fix(start.ps1): drop non-functional @args splat under [CmdletBinding()] Authorship preserved. CHANGELOG entry merged into batch stamp commit. --- start.ps1 | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/start.ps1 b/start.ps1 index 705da3eb..a8aeb2d4 100644 --- a/start.ps1 +++ b/start.ps1 @@ -137,7 +137,26 @@ if (Test-Path $agentVenvPython) { # === Resolve bind + state defaults ===================================== $BindHostFinal = if ($BindHost) { $BindHost } elseif ($env:HERMES_WEBUI_HOST) { $env:HERMES_WEBUI_HOST } else { '127.0.0.1' } -$PortFinal = if ($Port) { $Port } elseif ($env:HERMES_WEBUI_PORT) { [int]$env:HERMES_WEBUI_PORT } else { 8787 } +$PortFinal = if ($Port) { + $Port +} elseif ($env:HERMES_WEBUI_PORT) { + # TryParse + range guard on the env var. A plain [int] cast on the + # env var throws InvalidCastException with no actionable context when + # the env var is set to a non-integer (typo, accidental shell + # expansion, etc.) — surface a targeted error message instead. + $parsedPort = 0 + if (-not [int]::TryParse($env:HERMES_WEBUI_PORT, [ref]$parsedPort)) { + Write-Error "HERMES_WEBUI_PORT='$($env:HERMES_WEBUI_PORT)' is not a valid integer port. Unset the variable to use the default (8787), or set it to a number 1-65535." + exit 1 + } + if ($parsedPort -lt 1 -or $parsedPort -gt 65535) { + Write-Error "HERMES_WEBUI_PORT=$parsedPort is out of TCP-port range. Must be 1-65535." + exit 1 + } + $parsedPort +} else { + 8787 +} $env:HERMES_WEBUI_HOST = $BindHostFinal $env:HERMES_WEBUI_PORT = "$PortFinal" if (-not $env:HERMES_WEBUI_STATE_DIR) { @@ -165,10 +184,23 @@ if (-not (Test-Path $serverPath)) { exit 1 } +# Capture exit code, let finally{} run Pop-Location, exit AFTER the try. +# Plain `exit $LASTEXITCODE` inside the try block can prevent the finally +# from running in some termination paths (especially when dot-sourced or +# in interactive sessions), leaving the caller's working directory stuck +# at $RepoRoot. +$script:serverExitCode = 0 Push-Location $RepoRoot try { - & $Python $serverPath @args - exit $LASTEXITCODE + # @args was non-functional here — PowerShell does NOT populate $args when the + # script declares [CmdletBinding()] with an explicit param() block (Copilot's + # finding on PR #2807). Dropped rather than added a ValueFromRemainingArguments + # parameter, because the existing tracked use case is the launcher running + # server.py with the env-var-driven config — no pass-through args are needed. + # If pass-through becomes a requirement later, add a [Parameter(ValueFromRemainingArguments=$true)] [string[]]$ServerArgs and splat that. + & $Python $serverPath + $script:serverExitCode = $LASTEXITCODE } finally { Pop-Location } +exit $script:serverExitCode From fe0a1cea94f742b23cae9a3e3590cb3fe9d58842 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 24 May 2026 04:38:04 +0000 Subject: [PATCH 25/68] Stamp CHANGELOG for v0.51.124 (Release CV / stage-batch6 / 4-PR Windows-only stack) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked PRs (all by @Koraji95-coder): - #2805 — expand hermes-agent candidate paths for Windows installers - #2806 — clarify native Windows venv path; remove WSL2-venv-portability claim - #2807 — TryParse HERMES_WEBUI_PORT + exit AFTER try/finally cleanup - #2811 — native-Windows startup E2E CI workflow All 4 PRs were branched off #2783 (now shipped in v0.51.121). Squash-merged each PR's unique changes onto current master with conflict resolution. Authorship preserved on every commit. Zero impact on Linux/macOS runtime — file scope is start.ps1, README.md (Windows section), and a new Windows-CI workflow that only runs on PRs touching start.ps1/requirements.txt/etc. --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecad5762..b8eaaf34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ ## [Unreleased] +## [v0.51.124] — 2026-05-24 — Release CV (stage-batch6 — 3-PR Windows-only stack — agent paths / docs / port hardening) + +### Added + +- **PR #2805** by @Koraji95-coder — `start.ps1`: expand hermes-agent candidate paths for Windows installers. The launcher now searches `$env:USERPROFILE\.hermes\hermes-agent`, the dev-checkout sibling, and the Windows installer roots (`$env:LOCALAPPDATA\hermes\hermes-agent`, `${env:ProgramW6432}\hermes\hermes-agent`, `${env:ProgramFiles}\hermes\hermes-agent`, `${env:ProgramFiles(x86)}\hermes\hermes-agent`) with `Select-Object -Unique` to collapse WOW64 ProgramFiles redirection collisions on 32-bit PowerShell processes. Adds `-PathType Container` to the `HERMES_WEBUI_AGENT_DIR` guard so a file named `hermes_cli` doesn't false-positive. Null-guards `${env:ProgramFiles(x86)}` for constrained environments where it's missing. Zero impact on Linux/macOS — file is `start.ps1`, never loaded by `start.sh` or `bootstrap.py`. + +### Documentation + +- **PR #2806** by @Koraji95-coder — Native Windows venv path corrected in `start.ps1` doc-comment and `README.md`. The previous text suggested "run bootstrap.py inside WSL2 once to create the venv, then this script can use that venv" — but a WSL2-created venv is `venv/bin/python` (ELF) and cannot be invoked by native Windows Python. The corrected guidance is to create a Windows venv natively (`python -m venv venv` from PowerShell), then `start.ps1` auto-discovers `venv\Scripts\python.exe`. WSL2 remains useful as a parallel install for the full `bootstrap.py` + Linux runtime path. + +### Hardened + +- **PR #2807** by @Koraji95-coder — `start.ps1`: `HERMES_WEBUI_PORT` env-var parsing uses `[int]::TryParse` + range guard (1-65535) instead of a bare `[int]` cast that threw `InvalidCastException` with no context on typos or accidental shell expansion. Server-process exit code is captured into `$script:serverExitCode` and emitted via `exit` AFTER the `try/finally` cleanup, so `Pop-Location` always runs (avoids leaving the caller stuck at `$RepoRoot` in interactive or dot-sourced sessions). Also drops a non-functional `@args` splat that PowerShell doesn't populate under `[CmdletBinding()]` — the launcher's existing use case is env-var-driven, no pass-through args needed. + ## [v0.51.123] — 2026-05-24 — Release CU (stage-batch5 — 2-PR low-risk batch — gzip+ETag static caching / Open in VS Code) ### Performance From 2e876ea2295e4bf150626b46a9f208cbddae4734 Mon Sep 17 00:00:00 2001 From: tn801534 Date: Sun, 24 May 2026 20:37:33 +0800 Subject: [PATCH 26/68] fix: kanban worker log URL double query param on non-default boards --- static/panels.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/static/panels.js b/static/panels.js index 97263162..6e94dff5 100644 --- a/static/panels.js +++ b/static/panels.js @@ -2388,8 +2388,7 @@ async function loadKanbanTask(taskId){ if (!taskId) return; try { const data = await api('/api/kanban/tasks/' + encodeURIComponent(taskId) + _kanbanBoardQuery()); - const logEndpoint = '/api/kanban/tasks/' + encodeURIComponent(taskId) + '/log' + _kanbanBoardQuery(); - try { data.log = await api(logEndpoint + '?tail=65536'); } catch(e) { data.log = {}; } + try { data.log = await api('/api/kanban/tasks/' + encodeURIComponent(taskId) + '/log' + _kanbanBoardQuery({tail: 65536})); } catch(e) { data.log = {}; } _kanbanCurrentTaskId = taskId; const task = data.task || {}; const title = _kanbanTaskTitle(task); From 618e1a5da8b8f3e40af37bd5563f8190838af306 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 24 May 2026 18:02:24 +0800 Subject: [PATCH 27/68] fix(server): tolerate malformed request logging --- CHANGELOG.md | 6 ++++++ server.py | 4 ++-- tests/test_issue2775_log_request.py | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 tests/test_issue2775_log_request.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b8eaaf34..3a1b6db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +### Fixed + +- Malformed HTTP request logging now falls back to `"-"` for missing method or + path fields instead of raising an `AttributeError` traceback while handling + the 400 response. + ## [v0.51.124] — 2026-05-24 — Release CV (stage-batch6 — 3-PR Windows-only stack — agent paths / docs / port hardening) ### Added diff --git a/server.py b/server.py index 43f4a201..3888afc5 100644 --- a/server.py +++ b/server.py @@ -232,8 +232,8 @@ class Handler(BaseHTTPRequestHandler): duration_ms = round((time.time() - getattr(self, '_req_t0', time.time())) * 1000, 1) record = _json.dumps({ 'ts': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), - 'method': self.command or '-', - 'path': self.path or '-', + 'method': getattr(self, 'command', None) or '-', + 'path': getattr(self, 'path', None) or '-', 'status': int(code) if str(code).isdigit() else code, 'ms': duration_ms, }) diff --git a/tests/test_issue2775_log_request.py b/tests/test_issue2775_log_request.py new file mode 100644 index 00000000..3beebb73 --- /dev/null +++ b/tests/test_issue2775_log_request.py @@ -0,0 +1,18 @@ +import json + +from server import Handler + + +def test_log_request_handles_malformed_request_without_path(capsys): + """Malformed request lines can call log_request before path is assigned.""" + handler = Handler.__new__(Handler) + handler.command = None + + Handler.log_request(handler, "400") + + line = capsys.readouterr().out.strip() + assert line.startswith("[webui] ") + record = json.loads(line.removeprefix("[webui] ")) + assert record["method"] == "-" + assert record["path"] == "-" + assert record["status"] == 400 From 32df5546b40b82a487f4e100eeb82b97c9e08b09 Mon Sep 17 00:00:00 2001 From: humayunak Date: Sun, 24 May 2026 07:53:13 +0500 Subject: [PATCH 28/68] fix(webui): prevent approval and clarify cards stealing focus from composer textarea When tool approval or clarification cards appear during streaming, they unconditionally call focus() on their input elements via setTimeout, stealing focus from the composer (#msg) if the user is actively typing. This silently drops keystrokes mid-type. Add a guard: only move focus to the card if the composer textarea does not already have focus. The document.activeElement check matches the pattern already used upstream in other focus-sensitive components. Fixes: # --- static/messages.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/static/messages.js b/static/messages.js index 170952dd..29c8ae40 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2428,7 +2428,9 @@ function showApprovalCard(pending, pendingCount) { card.classList.add("visible"); if (typeof applyLocaleToDOM === "function") applyLocaleToDOM(); const onceBtn = $("approvalBtnOnce"); - if (onceBtn) setTimeout(() => onceBtn.focus({preventScroll: true}), 50); + if (onceBtn && document.activeElement !== $('msg')) { + setTimeout(() => onceBtn.focus({preventScroll: true}), 50); + } } async function respondApproval(choice) { @@ -2864,7 +2866,11 @@ function showClarifyCard(pending) { card.classList.add("visible"); _syncClarifyCollapseButton(card); if (typeof applyLocaleToDOM === "function") applyLocaleToDOM(); - if (input && !sameClarify) setTimeout(() => input.focus({preventScroll: true}), 50); + // Move focus to clarify input synchronously (not in setTimeout) and + // only if the user wasn't mid-type in the composer textarea. + if (input && !sameClarify && document.activeElement !== $('msg')) { + input.focus({preventScroll: true}); + } } async function respondClarify(response) { From 9a5973a6b5f0978d61554e915cab0464cf6cfa90 Mon Sep 17 00:00:00 2001 From: tangerine-fan Date: Sun, 24 May 2026 14:38:14 +0800 Subject: [PATCH 29/68] feat: echo clarify user choice as visible message in conversation After the user responds to a clarify prompt, insert a synthetic user message into the conversation showing their choice. This makes the clarify interaction visible in the chat history, which was previously only shown in the transient clarify dialog card. The message is marked with _clarify_response: true so downstream consumers can distinguish it from regular user messages if needed. --- static/messages.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/static/messages.js b/static/messages.js index 29c8ae40..4bccc85b 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2902,6 +2902,16 @@ async function respondClarify(response) { _clarifyId = null; _clearClarifyPendingForSession(sid); hideClarifyCard(true, 'sent'); + // Echo the user's clarify choice as a visible message in the conversation + if (S.session && S.session.session_id === sid) { + S.messages.push({ + role: 'user', + content: value, + _clarify_response: true, + _ts: Date.now() / 1000, + }); + if (typeof renderMessages === 'function') renderMessages({preserveScroll: true}); + } } } else { // Stale / expired / wrong session — keep the card and draft visible. From 7a3ceacffe9af63c366d9c7a9b7b9b18d5d36d53 Mon Sep 17 00:00:00 2001 From: Koraji95-coder Date: Sun, 24 May 2026 15:52:26 +0000 Subject: [PATCH 30/68] fix(composer): stop chip wraps from compressing past their content (#2740) Squashed from 2 author commits: - a1017d02 initial fix: flex:0 0 auto on all 5 chip wraps - bf54ba50 Copilot review fix-up: consolidate into single rule Closes #2740. CSS-only, no JS changes. Default-width layout unchanged, only affects narrow-viewport overflow regime via composer-left's existing overflow-x:auto. --- static/style.css | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/static/style.css b/static/style.css index d010f4e1..0f1354b9 100644 --- a/static/style.css +++ b/static/style.css @@ -1377,7 +1377,16 @@ .composer-left{display:flex;align-items:center;gap:4px;min-width:0;flex:1;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;} .composer-left::-webkit-scrollbar{display:none;} .composer-divider{width:1px;height:16px;background:var(--border);margin:0 3px;flex-shrink:0;} - .composer-profile-wrap{position:relative;flex:0 1 auto;min-width:0;} + /* Composer footer chip wraps share position:relative + flex:0 0 auto so + they keep their natural width and let .composer-left handle overflow + via horizontal scroll. flex-shrink:0 here is what fixes #2740 (chips + were compressing past their content and visually overlapping). Each + wrap declares its own display / gap below as needed. */ + .composer-profile-wrap, + .composer-ws-wrap, + .composer-reasoning-wrap, + .composer-toolsets-wrap, + .composer-model-wrap{position:relative;flex:0 0 auto;} .composer-profile-chip{display:inline-flex;align-items:center;gap:8px;max-width:180px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;} .composer-profile-chip:hover{background-color:var(--hover-bg);} .composer-profile-chip.active{background:var(--accent-bg);border-color:var(--accent-bg-strong);} @@ -1386,7 +1395,7 @@ .composer-profile-chip.switching .composer-profile-icon{position:relative;} .composer-profile-icon,.composer-profile-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;} .composer-profile-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} - .composer-ws-wrap{position:relative;flex:0 1 auto;min-width:0;display:flex;align-items:center;gap:4px;} + .composer-ws-wrap{display:flex;align-items:center;gap:4px;} .composer-workspace-group{display:inline-flex;align-items:stretch;max-width:284px;border-radius:999px;overflow:hidden;background-color:transparent;border:1px solid var(--border2);transition:background-color .15s,border-color .15s;} .composer-workspace-group:hover{background-color:var(--hover-bg);} .composer-workspace-group:hover{border-color:var(--border2);} @@ -1401,7 +1410,6 @@ .composer-workspace-chip.active{color:var(--text);background:var(--accent-bg);} .composer-workspace-icon,.composer-workspace-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;} .composer-workspace-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} - .composer-reasoning-wrap{position:relative;flex:0 1 auto;min-width:0;} .composer-reasoning-chip{display:inline-flex;align-items:center;gap:8px;max-width:180px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;} .composer-reasoning-chip.inactive{opacity:.78;} .composer-reasoning-chip:hover{color:var(--text);background-color:var(--hover-bg);} @@ -1414,7 +1422,7 @@ .reasoning-option:hover{background:rgba(255,255,255,.07);} .reasoning-option.selected{background:var(--accent-bg);} /* Toolsets chip — session-level toolset override (#493) */ - .composer-toolsets-wrap{position:relative;flex:0 1 auto;min-width:0;display:none;} + .composer-toolsets-wrap{display:none;} .composer-toolsets-chip{display:inline-flex;align-items:center;gap:8px;max-width:180px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;} .composer-toolsets-chip:hover{color:var(--text);background-color:var(--hover-bg);} .composer-toolsets-chip.active{color:var(--text);background:var(--accent-bg);border-color:var(--accent-bg);} @@ -1435,7 +1443,6 @@ .toolsets-apply-btn:hover{opacity:.9;} .toolsets-clear-btn{background:transparent;color:var(--muted);border:1px solid var(--border2);} .toolsets-clear-btn:hover{background:var(--hover-bg);color:var(--text);} - .composer-model-wrap{position:relative;flex:0 1 auto;min-width:0;} .composer-model-chip{display:inline-flex;align-items:center;gap:8px;max-width:280px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;} .composer-model-chip:hover{color:var(--text);background-color:var(--hover-bg);} .composer-model-chip.active{color:var(--text);background:var(--accent-bg);border-color:var(--accent-bg);} From b6f7412b53629c7307a0bb2cac0321fea2549f72 Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Sun, 24 May 2026 10:20:31 -0400 Subject: [PATCH 31/68] Add option to ignore agent updates --- api/config.py | 2 ++ api/routes.py | 6 ++-- api/updates.py | 19 +++++++--- static/i18n.js | 22 ++++++++++++ static/index.html | 7 ++++ static/panels.js | 5 +++ tests/test_1003_preferences_autosave.py | 1 + tests/test_updates.py | 48 +++++++++++++++++++++++++ 8 files changed, 104 insertions(+), 6 deletions(-) diff --git a/api/config.py b/api/config.py index db4afe67..293dc953 100644 --- a/api/config.py +++ b/api/config.py @@ -4365,6 +4365,7 @@ _SETTINGS_DEFAULTS = { "show_previous_messaging_sessions": False, # show older Telegram/Discord/etc. reset segments "sync_to_insights": False, # mirror WebUI token usage to state.db for /insights "check_for_updates": True, # check if webui/agent repos are behind upstream + "ignore_agent_updates": False, # keep WebUI update notices but suppress Agent update checks "whats_new_summary_enabled": False, # show an LLM-written What's New summary before diff links "theme": "dark", # light | dark | system "skin": "default", # accent color skin: default | ares | mono | slate | poseidon | sisyphus | charizard | sienna | catppuccin | nous @@ -4524,6 +4525,7 @@ _SETTINGS_BOOL_KEYS = { "show_previous_messaging_sessions", "sync_to_insights", "check_for_updates", + "ignore_agent_updates", "whats_new_summary_enabled", "sound_enabled", "rtl", diff --git a/api/routes.py b/api/routes.py index eee4f166..765a96f6 100644 --- a/api/routes.py +++ b/api/routes.py @@ -4260,6 +4260,7 @@ def handle_get(handler, parsed) -> bool: settings = load_settings() if not settings.get("check_for_updates", True): return j(handler, {"disabled": True}) + include_agent_updates = not bool(settings.get("ignore_agent_updates")) qs = parse_qs(parsed.query) force = qs.get("force", ["0"])[0] == "1" # ?simulate=1 returns fake behind counts for UI testing (localhost only) @@ -4281,7 +4282,8 @@ def handle_get(handler, parsed) -> bool: }, "agent": { "name": "agent", - "behind": 1, + "behind": 1 if include_agent_updates else 0, + "ignored": not include_agent_updates, "current_sha": "aaa0001", "latest_sha": "bbb0002", "branch": "master", @@ -4293,7 +4295,7 @@ def handle_get(handler, parsed) -> bool: ) from api.updates import check_for_updates - return j(handler, check_for_updates(force=force)) + return j(handler, check_for_updates(force=force, include_agent=include_agent_updates)) if parsed.path == "/api/chat/stream/status": stream_id = parse_qs(parsed.query).get("stream_id", [""])[0] diff --git a/api/updates.py b/api/updates.py index a21563a6..2df4baf0 100644 --- a/api/updates.py +++ b/api/updates.py @@ -29,7 +29,7 @@ try: except ImportError: _AGENT_DIR = None -_update_cache = {'webui': None, 'agent': None, 'checked_at': 0} +_update_cache = {'webui': None, 'agent': None, 'checked_at': 0, 'include_agent': True} _SUMMARY_CACHE_MAX = 16 _summary_cache: OrderedDict = OrderedDict() _cache_lock = threading.Lock() @@ -521,11 +521,21 @@ def _check_repo(path, name): return _check_repo_branch(path, name, fetch=False) -def check_for_updates(force=False): +def _ignored_agent_update_info() -> dict: + """Return a stable update-check payload for intentionally ignored Agent updates.""" + return {'name': 'agent', 'behind': 0, 'ignored': True} + + +def check_for_updates(force=False, *, include_agent=True): """Return cached update status for webui and agent repos.""" global _check_in_progress + include_agent = bool(include_agent) with _cache_lock: - if not force and time.time() - _update_cache['checked_at'] < CACHE_TTL: + if ( + not force + and _update_cache.get('include_agent') == include_agent + and time.time() - _update_cache['checked_at'] < CACHE_TTL + ): return dict(_update_cache) if _check_in_progress: return dict(_update_cache) # another thread is already checking @@ -534,12 +544,13 @@ def check_for_updates(force=False): try: # Run checks outside the lock (network I/O) webui_info = _check_repo(REPO_ROOT, 'webui') - agent_info = _check_repo(_AGENT_DIR, 'agent') + agent_info = _check_repo(_AGENT_DIR, 'agent') if include_agent else _ignored_agent_update_info() with _cache_lock: _update_cache['webui'] = webui_info _update_cache['agent'] = agent_info _update_cache['checked_at'] = time.time() + _update_cache['include_agent'] = include_agent return dict(_update_cache) finally: _check_in_progress = False diff --git a/static/i18n.js b/static/i18n.js index 0f785a31..0f1f8867 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -567,6 +567,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Show previous messaging sessions', settings_label_sync_insights: 'Sync to insights', settings_label_check_updates: 'Check for updates', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Default assistant name', settings_label_password: 'Access Password', @@ -806,6 +807,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'Show older Discord, Telegram, Slack, and Weixin sessions that were replaced by reset or compression.', settings_desc_sync_insights: 'Mirrors WebUI token usage to state.db so hermes /insights includes browser session data. Off by default.', settings_desc_check_updates: 'Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'Used for the default profile only. Other profiles use their own profile names.', settings_desc_password: 'Enter a new password to set or change it. Leave blank to keep current setting.', @@ -1803,6 +1805,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Mostra sessioni di messaggistica precedenti', settings_label_sync_insights: 'Sincronizza con insights', settings_label_check_updates: 'Verifica aggiornamenti', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Nome assistente predefinito', settings_label_password: 'Password di Accesso', @@ -2034,6 +2037,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'Mostra sessioni Discord, Telegram, Slack e Weixin più vecchie sostituite da reset o compressione.', settings_desc_sync_insights: 'Rispecchia l\'uso token WebUI su state.db così hermes /insights include i dati delle sessioni browser. Disattivato per impostazione predefinita.', settings_desc_check_updates: 'Mostra un banner quando sono disponibili versioni più recenti della WebUI o dell\'Agente. Esegue un git fetch in background periodicamente.', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'Usato solo per il profilo predefinito. Gli altri profili usano i propri nomi.', settings_desc_password: 'Inserisci una nuova password per impostarla o cambiarla. Lascia vuoto per mantenere l\'impostazione attuale.', @@ -3031,6 +3035,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: '以前のメッセージングセッションを表示', settings_label_sync_insights: 'インサイトに同期', settings_label_check_updates: 'アップデートを確認', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'デフォルトのアシスタント名', settings_label_password: 'アクセスパスワード', @@ -3267,6 +3272,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'reset または compression によって置き換えられた以前の Discord、Telegram、Slack、Weixin セッションを表示します。', settings_desc_sync_insights: 'WebUI のトークン使用量を state.db にミラーし、hermes /insights にブラウザセッションのデータを含めます。デフォルトはオフ。', settings_desc_check_updates: 'WebUI または Agent の新しいバージョンが利用可能な時にバナーを表示します。バックグラウンドで定期的に git fetch を実行します。', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'デフォルトプロファイルでのみ使用されます。他のプロファイルはそれぞれのプロファイル名を使用します。', settings_desc_password: '新しいパスワードを入力すると設定または変更します。空欄なら現在の設定を維持。', @@ -4065,6 +4071,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Показывать предыдущие сеансы обмена сообщениями', settings_label_sync_insights: 'Синхронизировать с Insights', settings_label_check_updates: 'Проверять обновления', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Имя помощника по умолчанию', settings_label_password: 'Пароль доступа', @@ -4250,6 +4257,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'Показывать предыдущие сеансы Discord, Telegram, Slack и Weixin, замененные сбросом или сжатием.', settings_desc_sync_insights: 'Синхронизирует использование токенов WebUI в state.db, чтобы Hermes /insights включал данные браузерных сеансов. Выключено по умолчанию.', settings_desc_check_updates: 'Показывает баннер, когда доступны более новые версии WebUI или Agent. Периодически выполняет git fetch в фоне.', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'Используется только для профиля по умолчанию. Другие профили используют свои имена.', settings_desc_password: 'Введите новый пароль, чтобы задать или изменить его. Оставьте пустым, чтобы сохранить текущую настройку.', @@ -5222,6 +5230,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Mostrar sesiones de mensajería anteriores', settings_label_sync_insights: 'Sincronizar con insights', settings_label_check_updates: 'Buscar actualizaciones', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Nombre predeterminado del asistente', settings_label_password: 'Contraseña de acceso', @@ -5418,6 +5427,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'Mostrar sesiones antiguas de Discord, Telegram, Slack y Weixin reemplazadas por reset o compresión.', settings_desc_sync_insights: 'Refleja el uso de tokens de la WebUI en state.db para que hermes /insights incluya datos de sesiones del navegador. Desactivado por defecto.', settings_desc_check_updates: 'Muestra un banner cuando haya versiones más nuevas de la WebUI o del Agent. Ejecuta periódicamente un git fetch en segundo plano.', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'Solo se usa para el perfil predeterminado. Los otros perfiles usan sus propios nombres.', settings_desc_password: 'Introduce una nueva contraseña para establecerla o cambiarla. Déjalo en blanco para mantener la configuración actual.', @@ -6372,6 +6382,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Vorherige Messaging-Sitzungen anzeigen', settings_label_sync_insights: 'Mit Insights synchronisieren', settings_label_check_updates: 'Nach Updates suchen', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Standard-Assistentenname', settings_label_password: 'Zugangspasswort', @@ -6558,6 +6569,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'Zeigt ältere Discord-, Telegram-, Slack- und Weixin-Sitzungen, die durch Reset oder Compression ersetzt wurden.', settings_desc_sync_insights: 'Spiegelt den WebUI-Token-Verbrauch in die state.db, sodass hermes /insights Browser-Sitzungsdaten enthält. Standardmäßig aus.', settings_desc_check_updates: 'Zeigt ein Banner an, wenn neuere Versionen der WebUI oder des Agenten verfügbar sind. Führt regelmäßig einen Git-Fetch im Hintergrund aus.', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'Wird nur für das Standardprofil verwendet. Andere Profile verwenden ihre eigenen Namen.', settings_desc_password: 'Geben Sie ein neues Passwort ein, um es zu setzen oder zu ändern. Leer lassen, um die aktuelle Einstellung beizubehalten.', @@ -7574,6 +7586,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: '显示以前的消息会话', settings_label_sync_insights: '同步到 insights', settings_label_check_updates: '检查更新', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: '默认助手名称', settings_label_password: '访问密码', @@ -7833,6 +7846,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: '显示被 reset 或 compression 替换的较旧的 Discord、Telegram、Slack 和 Weixin 会话。', settings_desc_sync_insights: '将 WebUI token 使用情况同步到 state.db,使 hermes /insights 包含浏览器会话数据。默认关闭。', settings_desc_check_updates: '当有更新的 WebUI 或助手版本时显示横幅。会在后台定期执行 git fetch。', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: '仅用于默认个人资料。其他个人资料会使用各自的名称。', settings_desc_password: '输入新密码以设置或更改。留空保持当前设置。', @@ -8753,6 +8767,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: '顯示以前的訊息對話', settings_label_sync_insights: '\u540c\u6b65\u5230 insights', settings_label_check_updates: '\u6aa2\u67e5\u66f4\u65b0', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: '預設助手名稱', settings_label_password: '\u8a2a\u554f\u5bc6\u78bc', @@ -8936,6 +8951,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: '顯示被 reset 或 compression 替換的較舊的 Discord、Telegram、Slack 和 Weixin 對話。', settings_desc_sync_insights: '將 WebUI token 使用情況同步到 state.db,使 hermes /insights 包含瀏覽器會話數據。預設未啟用。', settings_desc_check_updates: '當有更新的 WebUI 或助手版本時顯示標記。將在後台正常執行 Git-Fetch。', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: '僅用於預設個人檔案。其他個人檔案會使用各自的名稱。', settings_desc_password: '\u8a2d\u5b9a WebUI \u767b\u5165\u5bc6\u78bc\u3002\u5047\u5982\u5df2\u8a2d\u7f6e\uff0c\u6bcf\u6b21\u52a0\u8f09\u90fd\u9700\u8981\u767b\u5165\u3002', @@ -10064,6 +10080,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Mostrar sessões de mensagens anteriores', settings_label_sync_insights: 'Sincronizar para insights', settings_label_check_updates: 'Verificar atualizações', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Nome padrão do assistente', settings_label_password: 'Senha de Acesso', @@ -10253,6 +10270,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'Mostrar sessões antigas de Discord, Telegram, Slack e Weixin substituídas por reset ou compressão.', settings_desc_sync_insights: 'Espelha uso de tokens para state.db.', settings_desc_check_updates: 'Mostrar banner quando versões mais novas estiverem disponíveis.', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'Usado apenas para o perfil padrão. Outros perfis usam seus próprios nomes.', settings_desc_password: 'Digite nova senha para definir ou trocar. Deixe em branco para manter.', @@ -11195,6 +11213,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: '이전 메시징 세션 표시', settings_label_sync_insights: 'Insights에 동기화', settings_label_check_updates: '업데이트 확인', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: '기본 Assistant 이름', settings_label_password: '접근 비밀번호', @@ -11383,6 +11402,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'reset 또는 compression으로 교체된 이전 Discord, Telegram, Slack, Weixin 세션을 표시합니다.', settings_desc_sync_insights: 'WebUI 토큰 사용량을 state.db에 반영하여 hermes /insights에 브라우저 세션 데이터가 포함되도록 합니다. 기본값은 꺼짐입니다.', settings_desc_check_updates: 'WebUI 또는 Agent의 새 버전이 있으면 배너를 표시합니다. 백그라운드에서 주기적으로 git fetch를 실행합니다.', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: '기본 프로필에만 사용됩니다. 다른 프로필은 각 프로필 이름을 사용합니다.', settings_desc_password: '새 비밀번호를 설정하거나 변경하려면 입력하세요. 현재 설정을 유지하려면 비워 두세요.', @@ -12339,6 +12359,7 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Afficher les sessions de messagerie précédentes', settings_label_sync_insights: 'Synchroniser avec les insights', settings_label_check_updates: 'Vérifier les mises à jour', + settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Nom par défaut de l\'assistant', settings_label_password: 'Mot de passe d\'accès', @@ -12537,6 +12558,7 @@ const LOCALES = { settings_desc_previous_messaging_sessions: 'Affichez les anciennes sessions Discord, Telegram, Slack et Weixin remplacées par reset ou compression.', settings_desc_sync_insights: 'Met en miroir l\'utilisation du jeton WebUI dans state.db afin que Hermes /insights inclut les données de session du navigateur. Désactivé par défaut.', settings_desc_check_updates: 'Afficher une bannière lorsque des versions plus récentes de WebUI ou de l\'agent sont disponibles. Exécute périodiquement une récupération git en arrière-plan.', + settings_desc_ignore_agent_updates: 'Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'Utilisé uniquement pour le profil par défaut. Les autres profils utilisent leurs propres noms.', settings_desc_password: 'Saisissez un nouveau mot de passe pour le définir ou le modifier. Laissez vide pour conserver le paramètre actuel.', diff --git a/static/index.html b/static/index.html index adb7502c..29441d25 100644 --- a/static/index.html +++ b/static/index.html @@ -1179,6 +1179,13 @@
Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.
+
+ +
Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.
+
+
Loading...
diff --git a/static/panels.js b/static/panels.js index fe034d4f..d1fa17e1 100644 --- a/static/panels.js +++ b/static/panels.js @@ -428,9 +428,44 @@ function _cronDiagnostics(job) { return JSON.stringify(fields, null, 2); } +function _cronGatewayNoticeHtml(status) { + if (!status || (status.configured && status.running)) return ''; + const notConfigured = !status.configured; + const title = notConfigured + ? 'Gateway not configured' + : 'Gateway not running'; + const body = notConfigured + ? 'In Hermes WebUI, scheduled jobs require the Hermes gateway daemon. If this is a single-container Docker install, jobs can be created and run manually here, but scheduled ticks need a gateway container or `hermes gateway` running outside the WebUI.' + : 'In Hermes WebUI, scheduled jobs require the Hermes gateway daemon to be running. Start the gateway container or `hermes gateway` before relying on offline scheduled runs.'; + return ` +
${esc(title)}
+

${esc(body)}

+ `; +} + +async function loadCronGatewayNotice() { + const box = $('cronGatewayNotice'); + if (!box) return; + try { + const status = await api('/api/gateway/status'); + const html = _cronGatewayNoticeHtml(status); + if (html) { + box.innerHTML = html; + box.style.display = ''; + } else { + box.innerHTML = ''; + box.style.display = 'none'; + } + } catch (_) { + box.innerHTML = ''; + box.style.display = 'none'; + } +} + async function loadCrons(animate) { const box = $('cronList'); const refreshBtn = $('cronRefreshBtn'); + loadCronGatewayNotice(); if (animate && refreshBtn) { refreshBtn.style.opacity = '0.5'; refreshBtn.disabled = true; diff --git a/static/style.css b/static/style.css index 0f1354b9..80f1e96e 100644 --- a/static/style.css +++ b/static/style.css @@ -1109,6 +1109,7 @@ .panel-view.active{display:flex;} /* Cron panel */ .cron-list{flex:1;overflow-y:auto;padding:8px;} + .cron-gateway-notice{margin:8px 8px 0;} .cron-item{width:100%;min-width:0;box-sizing:border-box;border-radius:10px;border:1px solid var(--border);margin-bottom:6px;overflow:hidden;transition:border-color .15s,background .15s;background:rgba(255,255,255,.02);cursor:pointer;} .cron-item:hover{border-color:var(--border2);} .cron-header{display:flex;align-items:center;gap:8px;padding:10px 12px;cursor:pointer;} diff --git a/tests/test_issue2785_gateway_cron_guidance.py b/tests/test_issue2785_gateway_cron_guidance.py new file mode 100644 index 00000000..0fe30477 --- /dev/null +++ b/tests/test_issue2785_gateway_cron_guidance.py @@ -0,0 +1,37 @@ +"""Coverage for cron/gateway guidance in the Tasks panel and Docker docs.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +INDEX_HTML = ROOT / "static" / "index.html" +PANELS_JS = ROOT / "static" / "panels.js" +DOCKER_DOC = ROOT / "docs" / "docker.md" + + +def test_tasks_panel_has_gateway_notice_container(): + html = INDEX_HTML.read_text(encoding="utf-8") + + assert 'id="cronGatewayNotice"' in html + assert "detail-alert" in html + + +def test_cron_panel_loads_gateway_status_for_scheduling_guidance(): + panels = PANELS_JS.read_text(encoding="utf-8") + + assert "function _cronGatewayNoticeHtml" in panels + assert "function loadCronGatewayNotice" in panels + assert "api('/api/gateway/status')" in panels + assert "Gateway not configured" in panels + assert "Gateway not running" in panels + assert "scheduled jobs require the Hermes gateway daemon" in panels + assert "loadCronGatewayNotice()" in panels + + +def test_docker_docs_explain_single_container_cron_gateway_boundary(): + docs = DOCKER_DOC.read_text(encoding="utf-8") + + assert "single-container setup runs the WebUI only" in docs + assert "scheduled jobs require the Hermes gateway daemon" in docs + assert "Gateway not configured" in docs + assert "docker-compose.two-container.yml" in docs From 99c886c1992c8ec633c9259db28824c7afc3d046 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 24 May 2026 18:07:19 +0800 Subject: [PATCH 34/68] fix(workspace): open rendered preview links correctly --- api/routes.py | 49 +++++++++++++- static/index.html | 2 +- static/ui.js | 5 +- tests/test_issue2768_workspace_links.py | 88 +++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 tests/test_issue2768_workspace_links.py diff --git a/api/routes.py b/api/routes.py index 32ba020a..edef7ed5 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6908,6 +6908,51 @@ def _serve_file_bytes(handler, target: Path, mime: str, disposition: str, cache_ return True +def _html_preview_with_blank_base(raw: bytes) -> bytes: + base = '' + text = raw.decode("utf-8", errors="replace") + if re.search(r"]*)?>", text, flags=re.IGNORECASE): + text = re.sub(r"(]*>)", r"\1" + base, text, count=1, flags=re.IGNORECASE) + elif re.search(r"]*>", text, flags=re.IGNORECASE): + text = re.sub( + r"(]*>)", + r"\1" + base + "", + text, + count=1, + flags=re.IGNORECASE, + ) + else: + text = "" + base + "" + text + return text.encode("utf-8") + + +def _serve_inline_html_preview(handler, target: Path, cache_control: str, *, csp: str): + """Serve sandboxed workspace HTML preview with links targeting a new tab.""" + try: + body = _html_preview_with_blank_base(target.read_bytes()) + except PermissionError: + return bad(handler, "Permission denied", 403) + except Exception: + return bad(handler, "Could not read file", 500) + + handler.send_response(200) + handler.send_header("Content-Type", "text/html; charset=utf-8") + handler.send_header("Content-Length", str(len(body))) + handler.send_header("Accept-Ranges", "none") + handler.send_header("Cache-Control", cache_control) + handler.send_header("Content-Disposition", _content_disposition_value("inline", target.name)) + handler.send_header("Content-Security-Policy", csp) + handler.send_header("X-Content-Type-Options", "nosniff") + handler.send_header("Referrer-Policy", "same-origin") + handler.send_header( + "Permissions-Policy", + "camera=(), microphone=(self), geolocation=(), clipboard-write=(self)", + ) + handler.end_headers() + handler.wfile.write(body) + return True + + def _handle_media(handler, parsed): """Serve a local file by absolute path for inline display in the chat. @@ -7213,8 +7258,10 @@ def _handle_file_raw(handler, parsed): # CSP sandbox directive applies the same isolation server-side: without # allow-same-origin, the document is treated as a unique opaque origin and # cannot read WebUI cookies, localStorage, or postMessage to the parent. - csp = "sandbox allow-scripts" if html_inline_ok else None + csp = "sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox" if html_inline_ok else None # _serve_file_bytes sends Content-Security-Policy when csp is set. + if html_inline_ok: + return _serve_inline_html_preview(handler, target, "no-store", csp=csp) return _serve_file_bytes(handler, target, mime, disposition, "no-store", csp=csp) diff --git a/static/index.html b/static/index.html index fe54a92c..e5518943 100644 --- a/static/index.html +++ b/static/index.html @@ -1327,7 +1327,7 @@ diff --git a/static/ui.js b/static/ui.js index c6a5cd2a..6679c87a 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2889,7 +2889,7 @@ function renderMd(raw){ t=t.replace(/\x00C(\d+)\x00/g,(_,i)=>_code_stash[+i]); // Stash [label](url) links before autolink so the URL in href= is not re-linked const _link_stash=[]; - t=t.replace(/\[([^\]]+)\]\(((?:https?|file):\/\/[^\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`${esc(lb)}`);return `\x00L${_link_stash.length-1}\x00`;}); + t=t.replace(/\[([^\]]+)\]\(((?:https?:\/\/|file:\/\/|mailto:|tel:)[^\s\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`${esc(lb)}`);return `\x00L${_link_stash.length-1}\x00`;}); t=t.replace(/(https?:\/\/[^\s<>"')\]]+)/g,(url)=>{const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';const clean=trail?url.slice(0,-1):url;return `${esc(clean)}${trail}`;}); t=t.replace(/\x00L(\d+)\x00/g,(_,i)=>_link_stash[+i]); t=t.replace(/\x00G(\d+)\x00/g,(_,i)=>_img_stash[+i]); @@ -2982,7 +2982,7 @@ function renderMd(raw){ // Stash existing tags first to avoid re-linking already-linked URLs. const _a_stash=[]; s=s.replace(/(]*>[\s\S]*?<\/a>)/g,m=>{_a_stash.push(m);return `\x00A${_a_stash.length-1}\x00`;}); - s=s.replace(/\[([^\]]+)\]\(((?:https?|file):\/\/[^\)]+)\)/g,(_,label,url)=>`${esc(label)}`); + s=s.replace(/\[([^\]]+)\]\(((?:https?:\/\/|file:\/\/|mailto:|tel:)[^\s\)]+)\)/g,(_,label,url)=>`${esc(label)}`); s=s.replace(/\x00A(\d+)\x00/g,(_,i)=>_a_stash[+i]); // Restore raw
 only after markdown rewrites so literal preformatted
   // content stays placeholder-protected, then let the sanitizer normalize tags.
@@ -3016,6 +3016,7 @@ function renderMd(raw){
     if(!compact) return false;
     if(/^(javascript|data|vbscript):/i.test(compact)) return false;
     if(/^https?:\/\//i.test(raw)) return true;
+    if(/^(mailto:|tel:)/i.test(raw)) return true;
     if(img && /^api\//i.test(raw)) return true;
     if(!img && (/^api\//i.test(raw) || /^#/.test(raw))) return true;
     return false;
diff --git a/tests/test_issue2768_workspace_links.py b/tests/test_issue2768_workspace_links.py
new file mode 100644
index 00000000..0733e200
--- /dev/null
+++ b/tests/test_issue2768_workspace_links.py
@@ -0,0 +1,88 @@
+import json
+import pathlib
+import re
+import subprocess
+import textwrap
+
+
+REPO = pathlib.Path(__file__).resolve().parents[1]
+UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
+INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
+ROUTES_PY = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
+
+
+def _extract_function(src: str, name: str) -> str:
+    marker = f"function {name}("
+    start = src.index(marker)
+    brace = src.index("{", start)
+    depth = 1
+    pos = brace + 1
+    while depth and pos < len(src):
+        ch = src[pos]
+        if ch == "{":
+            depth += 1
+        elif ch == "}":
+            depth -= 1
+        pos += 1
+    assert depth == 0, f"could not extract {name}()"
+    return src[start:pos]
+
+
+def _render(markdown: str) -> str:
+    js = textwrap.dedent(
+        r'''
+        const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+        const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico|avif)$/i;
+        const _PDF_EXTS=/\.pdf$/i;
+        const _SVG_EXTS=/\.svg$/i;
+        const _AUDIO_EXTS=/\.(mp3|ogg|wav|m4a|aac|flac|wma|opus|webm|oga)$/i;
+        const _VIDEO_EXTS=/\.(mp4|webm|mkv|mov|avi|ogv|m4v)$/i;
+        function t(k){ return k; }
+        function _mediaPlayerHtml(){ return ''; }
+        global.document={baseURI:'http://example.test/'};
+        '''
+    )
+    js += "\n" + _extract_function(UI_JS, "_matchBacktickFenceLine")
+    js += "\n" + _extract_function(UI_JS, "_isBacktickFenceClose")
+    js += "\n" + _extract_function(UI_JS, "renderMd")
+    js += textwrap.dedent(
+        r'''
+        const input=process.argv[1];
+        process.stdout.write(JSON.stringify(renderMd(input)));
+        '''
+    )
+    proc = subprocess.run(
+        ["node", "-e", js, markdown],
+        cwd=REPO,
+        text=True,
+        capture_output=True,
+        timeout=30,
+        check=True,
+    )
+    return json.loads(proc.stdout)
+
+
+def test_workspace_markdown_renders_mailto_and_tel_links():
+    html = _render("[email](mailto:foo@example.test) and [phone](tel:+15551212)")
+
+    assert 'email' in html
+    assert 'phone' in html
+
+
+def test_workspace_html_iframe_allows_links_to_escape_sandbox():
+    iframe = re.search(r']+id="previewHtmlIframe"[^>]*>', INDEX_HTML)
+
+    assert iframe, "previewHtmlIframe iframe not found"
+    sandbox = re.search(r'sandbox="([^"]+)"', iframe.group(0))
+    assert sandbox, "previewHtmlIframe must keep an explicit sandbox"
+    assert "allow-scripts" in sandbox.group(1)
+    assert "allow-popups" in sandbox.group(1)
+    assert "allow-popups-to-escape-sandbox" in sandbox.group(1)
+
+
+def test_file_raw_inline_html_preview_injects_base_target_blank():
+    raw_handler = ROUTES_PY[ROUTES_PY.index("def _handle_file_raw") :]
+
+    assert '' in ROUTES_PY
+    assert "_serve_inline_html_preview" in raw_handler
+    assert "html_inline_ok" in raw_handler

From 70402f96f166d35da715f4e49e08043d720a8b91 Mon Sep 17 00:00:00 2001
From: Frank Song 
Date: Sun, 24 May 2026 17:30:38 +0800
Subject: [PATCH 35/68] fix(workspace): fall back for large markdown previews

---
 static/workspace.js                           | 35 ++++++++-
 .../test_issue2823_large_markdown_preview.py  | 71 +++++++++++++++++++
 2 files changed, 105 insertions(+), 1 deletion(-)
 create mode 100644 tests/test_issue2823_large_markdown_preview.py

diff --git a/static/workspace.js b/static/workspace.js
index 5309addc..b4c37ae1 100644
--- a/static/workspace.js
+++ b/static/workspace.js
@@ -175,6 +175,8 @@ const HTML_EXTS   = new Set(['.html','.htm']);
 const PDF_EXTS    = new Set(['.pdf']);
 const AUDIO_EXTS  = new Set(['.mp3','.wav','.m4a','.aac','.ogg','.oga','.opus','.flac']);
 const VIDEO_EXTS  = new Set(['.mp4','.mov','.m4v','.webm','.ogv','.avi','.mkv']);
+const MD_PREVIEW_RICH_RENDER_MAX_BYTES = 64 * 1024;
+const MD_PREVIEW_RICH_RENDER_MAX_LINES = 1500;
 // Binary formats that should download rather than preview
 const DOWNLOAD_EXTS = new Set([
   '.docx','.doc','.xlsx','.xls','.pptx','.ppt','.odt','.ods','.odp',
@@ -186,6 +188,31 @@ const DOWNLOAD_EXTS = new Set([
 
 function fileExt(p){ const i=p.lastIndexOf('.'); return i>=0?p.slice(i).toLowerCase():''; }
 
+function markdownPreviewByteLength(content){
+  const text=String(content||'');
+  if(typeof Blob==='function') return new Blob([text]).size;
+  if(typeof TextEncoder==='function') return new TextEncoder().encode(text).length;
+  return unescape(encodeURIComponent(text)).length;
+}
+
+function markdownPreviewLineCount(content){
+  const text=String(content||'');
+  if(!text) return 1;
+  return text.split('\n').length;
+}
+
+function shouldRenderMarkdownPreviewAsPlainText(content){
+  return markdownPreviewByteLength(content)>MD_PREVIEW_RICH_RENDER_MAX_BYTES
+    || markdownPreviewLineCount(content)>MD_PREVIEW_RICH_RENDER_MAX_LINES;
+}
+
+function largeMarkdownPlainTextStatus(content){
+  const bytes=markdownPreviewByteLength(content);
+  const lines=markdownPreviewLineCount(content);
+  const sizeLabel=bytes>=1024?`${Math.round(bytes/1024)} KB`:`${bytes} B`;
+  return `Large markdown file (${sizeLabel}, ${lines} lines) shown as plain text. Click Edit to view raw.`;
+}
+
 let _previewCurrentPath = '';  // relative path of currently previewed file
 let _previewCurrentMode = '';  // 'code' | 'md' | 'image' | 'html' | 'pdf' | 'audio' | 'video'
 let _previewDirty = false;     // true when edits are unsaved
@@ -317,8 +344,14 @@ async function openFile(path){
     // Markdown: fetch text, render with renderMd, display as formatted HTML
     try{
       const data=await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
-      showPreview('md');
       _previewRawContent = data.content;
+      if(shouldRenderMarkdownPreviewAsPlainText(data.content)){
+        showPreview('code');
+        $('previewCode').textContent=data.content;
+        setStatus(largeMarkdownPlainTextStatus(data.content));
+        return;
+      }
+      showPreview('md');
       $('previewMd').innerHTML=renderMd(data.content);
       requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();});
     }catch(e){setStatus(t('file_open_failed'));}
diff --git a/tests/test_issue2823_large_markdown_preview.py b/tests/test_issue2823_large_markdown_preview.py
new file mode 100644
index 00000000..e2821ed3
--- /dev/null
+++ b/tests/test_issue2823_large_markdown_preview.py
@@ -0,0 +1,71 @@
+"""Regression coverage for #2823 large Markdown workspace previews."""
+
+from pathlib import Path
+
+
+WORKSPACE_JS = Path("static/workspace.js").read_text(encoding="utf-8")
+
+
+def _open_file_block() -> str:
+    marker = "async function openFile(path){"
+    start = WORKSPACE_JS.find(marker)
+    assert start != -1, "openFile() not found in workspace.js"
+    end = WORKSPACE_JS.find("\nfunction downloadFile", start)
+    assert end != -1, "downloadFile() marker not found after openFile()"
+    return WORKSPACE_JS[start:end]
+
+
+def _markdown_branch() -> str:
+    block = _open_file_block()
+    start = block.find("} else if(MD_EXTS.has(ext)){")
+    assert start != -1, "Markdown preview branch not found in openFile()"
+    end = block.find("} else if(HTML_EXTS.has(ext)){", start)
+    assert end != -1, "HTML preview branch marker not found after Markdown branch"
+    return block[start:end]
+
+
+def test_large_markdown_preview_limits_are_source_controlled():
+    assert "MD_PREVIEW_RICH_RENDER_MAX_BYTES = 64 * 1024" in WORKSPACE_JS
+    assert "MD_PREVIEW_RICH_RENDER_MAX_LINES = 1500" in WORKSPACE_JS
+    assert "function shouldRenderMarkdownPreviewAsPlainText(content)" in WORKSPACE_JS
+
+
+def test_large_markdown_fallback_sets_raw_content_before_size_gate():
+    branch = _markdown_branch()
+    raw_pos = branch.find("_previewRawContent = data.content")
+    gate_pos = branch.find("shouldRenderMarkdownPreviewAsPlainText(data.content)")
+    fallback_pos = branch.find("showPreview('code')")
+    rich_pos = branch.find("showPreview('md')")
+
+    assert raw_pos != -1, "Markdown preview must retain raw text for Edit mode"
+    assert gate_pos != -1, "Markdown preview must guard rich rendering by size/line count"
+    assert fallback_pos != -1, "Large Markdown preview must fall back to plain text"
+    assert rich_pos != -1, "Small Markdown preview must still use rich Markdown mode"
+    assert raw_pos < gate_pos < fallback_pos < rich_pos
+
+
+def test_large_markdown_fallback_uses_code_view_without_rich_render_or_katex():
+    branch = _markdown_branch()
+    gate_pos = branch.find("if(shouldRenderMarkdownPreviewAsPlainText(data.content)){")
+    fallback_end = branch.find("return;", gate_pos)
+    assert gate_pos != -1 and fallback_end != -1, "Large Markdown fallback block not found"
+
+    fallback = branch[gate_pos:fallback_end]
+    compact = fallback.replace(" ", "")
+    assert "$('previewCode').textContent=data.content" in compact
+    assert "setStatus(" in fallback
+    assert "renderMd(" not in fallback
+    assert "renderKatexBlocks" not in fallback
+
+
+def test_small_markdown_still_renders_and_runs_katex_after_render():
+    branch = _markdown_branch()
+    fallback_end = branch.find("return;")
+    assert fallback_end != -1, "Large Markdown fallback must return before rich rendering"
+
+    rich = branch[fallback_end:]
+    render_pos = rich.find("$('previewMd').innerHTML=renderMd(data.content)")
+    katex_pos = rich.rfind("renderKatexBlocks")
+    assert render_pos != -1, "Small Markdown files must still rich-render with renderMd()"
+    assert katex_pos != -1, "Small Markdown file previews must still trigger KaTeX rendering"
+    assert katex_pos > render_pos

From ded516754b1699b900d14bb3d2cdb6a7b6ad4f65 Mon Sep 17 00:00:00 2001
From: nesquena-hermes <[email protected]>
Date: Sun, 24 May 2026 15:55:03 +0000
Subject: [PATCH 36/68] Stamp CHANGELOG for v0.51.125 (Release CW /
 stage-batch7 / 10-PR low-risk batch)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Cherry-picked PRs:
- #2839 (tn801534) — kanban worker log URL double query param fix
- #2832 (franksong2702) — tolerate malformed request logging
- #2818 (humayunak) — prevent focus theft by approval/clarify cards
- #2820 (tangerine-fan) — echo clarify user choice as visible message
- #2826 (Koraji95-coder) — chip wrap overlap fix at narrow widths (closes #2740)
- #2843 (AJV20) — Settings option to ignore Agent updates
- #2837 (franksong2702) — clarify CSRF rejection diagnostics
- #2838 (franksong2702) — surface gateway scheduling guidance in Tasks panel
- #2834 (franksong2702) — render mailto:/tel: links + sandbox HTML preview links
- #2829 (franksong2702) — large markdown preview falls back to plain text (closes #2823, supersedes #2828)
---
 CHANGELOG.md | 26 +++++++++++++++++++++++---
 1 file changed, 23 insertions(+), 3 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3a1b6db3..c144ceb5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,11 +3,31 @@
 
 ## [Unreleased]
 
+## [v0.51.125] — 2026-05-24 — Release CW (stage-batch7 — 10-PR low-risk batch — UI/UX polish + bug fixes + diagnostics)
+
 ### Fixed
 
-- Malformed HTTP request logging now falls back to `"-"` for missing method or
-  path fields instead of raising an `AttributeError` traceback while handling
-  the 400 response.
+- **PR #2839** by @tn801534 — Kanban worker log endpoint constructed URLs with a double query string (`?board=?tail=65536`) when a non-default board was active. The frontend was appending `?tail=65536` directly to a URL that already had `?board=...` from `_kanbanBoardQuery()`. Fix: pass `{tail: 65536}` as the `extra` argument to `_kanbanBoardQuery()` so it composes both params into a single valid query string. One-line, narrow scope.
+
+- **PR #2832** by @franksong2702 — Malformed HTTP request logging in `server.py` falls back to `"-"` for missing `command` or `path` instead of raising `AttributeError`. Defensive `getattr(self, 'command', None) or '-'` matches the pattern already used for `_req_t0` elsewhere in the handler. Adds `tests/test_issue2775_log_request.py` covering the malformed-request-before-path-assigned case.
+
+- **PR #2818** by @humayunak — Approval and clarify cards no longer steal focus from the composer textarea (`#msg`) when the user is mid-type. `showApprovalCard()` and `showClarifyCard()` now guard the `focus()` call on `document.activeElement !== $('msg')`, matching the pattern already used elsewhere for focus-sensitive paths. The clarify card also moves the focus call out of `setTimeout` for snappier UX. Silently dropped keystrokes during streaming are eliminated.
+
+- **PR #2826** by @Koraji95-coder — Composer footer chip wraps no longer overlap at narrow widths (closes #2740). The five chip wraps (`.composer-profile-wrap`, `.composer-ws-wrap`, `.composer-model-wrap`, `.composer-reasoning-wrap`, `.composer-toolsets-wrap`) had `flex: 0 1 auto` + `min-width: 0` so they would compress past their content's natural width when the composer narrowed, causing visual overlap of the profile / workspace / model / reasoning chips. Switched to `flex: 0 0 auto` via a single grouped selector. Each chip now keeps its natural width and the existing `overflow-x: auto` on `.composer-left` handles overflow via horizontal scroll. Default-width layout unchanged; only affects the overflow regime. Mobile-specific rules (already `flex: 0 0 auto`) untouched.
+
+- **PR #2829** by @franksong2702 — Workspace Markdown previews fall back to plain text for very large files (>64 KB or >1500 lines) instead of synchronously running the full rich Markdown renderer on the browser main thread, which could lock up the tab for several seconds on multi-megabyte `.md` files. Plain-text preview shows file size + line count in the status line so users know why rich rendering was bypassed; Edit mode still shows raw content as before. Closes #2823. Supersedes #2828 (same scope, less polished).
+
+- **PR #2837** by @franksong2702 — CSRF rejections now distinguish origin/proxy mismatches from expired session tokens, so provider-key removal and other protected requests show actionable diagnostics instead of the generic "Cross-origin request rejected" error. Adds `tests/test_issue2572_csrf_diagnostics.py` covering both failure modes.
+
+- **PR #2834** by @franksong2702 — Workspace Markdown `mailto:` and `tel:` links now render as clickable links, and sandboxed HTML preview links open outside the iframe (via injected ``) instead of navigating the preview into a browser-blocked page. Adds `tests/test_issue2768_workspace_links.py`.
+
+- **PR #2838** by @franksong2702 — Tasks panel surfaces a warning when the Hermes gateway is not configured or not running, so Docker users know scheduled jobs need the gateway daemon to tick while away. The single-container Docker boundary is also clarified in `docs/docker.md`. Adds `tests/test_issue2785_gateway_cron_guidance.py`.
+
+### Added
+
+- **PR #2820** by @tangerine-fan — Clarify user choice is now echoed as a visible message in the conversation transcript. After the user responds to a clarify prompt, a synthetic user message with the chosen value is inserted into `S.messages` (marked `_clarify_response: true` so downstream consumers can filter if needed). Previously the choice was only visible in the transient clarify card; now the chat history preserves the decision.
+
+- **PR #2843** by @AJV20 — New Settings preference "Ignore Agent updates" keeps WebUI update notices, banners, and update actions enabled while suppressing Hermes Agent update checks. Default `False` (current behavior). Useful when running an unreleased agent build or pinning to a specific agent commit.
 
 ## [v0.51.124] — 2026-05-24 — Release CV (stage-batch6 — 3-PR Windows-only stack — agent paths / docs / port hardening)
 

From 7983e025c40637f3ff86f71fdbe5842c68cf48cf Mon Sep 17 00:00:00 2001
From: humayunak 
Date: Wed, 20 May 2026 10:19:37 +0500
Subject: [PATCH 37/68] kanban: full markdown rendering for task description
 and comments

- Rewrote _kanbanRenderMarkdown() from basic paragraph wrapper to a
  line-by-line block processor supporting headings, code blocks, lists,
  task lists, tables, blockquotes, horizontal rules, and strikethrough.
- Added CSS for all new elements (table borders, code blocks, checkboxes,
  blockquote accent, heading sizing, etc.).
- Dropped white-space: pre-wrap from .kanban-task-preview-body and
  .kanban-detail-row-main since markdown now handles layout.
- Applied _kanbanRenderMarkdown() to task description (was esc()) and
  comment body (was esc()) in the task detail view.
---
 static/panels.js | 179 +++++++++++++++++++++++++++++++++++++++++++++--
 static/style.css |   6 +-
 2 files changed, 178 insertions(+), 7 deletions(-)

diff --git a/static/panels.js b/static/panels.js
index d1fa17e1..fdeeb510 100644
--- a/static/panels.js
+++ b/static/panels.js
@@ -1274,17 +1274,188 @@ function _kanbanRenderSidebar(columns){
 }
 
 
+/**
+ * Render inline markdown (bold, italic, code, links, strikethrough).
+ * Input is already HTML-escaped.
+ */
 function _kanbanRenderMarkdownInline(escaped){
   return String(escaped || '')
+    .replace(/~~([^~\n]+)~~/g, (_m, text) => `${text}`)
     .replace(/`([^`\n]+)`/g, (_m, code) => `${code}`)
     .replace(/\*\*([^*\n]+)\*\*/g, (_m, text) => `${text}`)
-    .replace(/(^|[^*])\*([^*\n]+)\*/g, (_m, prefix, text) => `${prefix}${text}`)
+    .replace(/(^|[^*a-zA-Z0-9])\*([^*\n]+)\*/g, (_m, prefix, text) => `${prefix}${text}`)
     .replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)/g, (_m, text, href) => `${text}`);
 }
 
+/**
+ * Render full markdown block content: headings, code blocks, lists, tables,
+ * task lists, blockquotes, horizontal rules, paragraphs + inline formatting.
+ */
 function _kanbanRenderMarkdown(source){
   if (!source) return '';
-  return `
${esc(source).split(/\r?\n/).map(line => line.trim() ? `

${_kanbanRenderMarkdownInline(line)}

` : '').join('')}
`; + const lines = esc(source).split(/\r?\n/); + const out = []; + let i = 0; + while (i < lines.length) { + const line = lines[i]; + const trimmed = line.trim(); + + // ── Code block ── + if (/^```/.test(trimmed)) { + const lang = trimmed.slice(3).trim(); + const codeLines = []; + i++; + while (i < lines.length && !/^```/.test(lines[i].trim())) { + codeLines.push(lines[i]); + i++; + } + i++; // skip closing ``` + const codeHtml = codeLines.join('\n'); + out.push(lang + ? `
${codeHtml}
` + : `
${codeHtml}
`); + continue; + } + + // ── Horizontal rule ── + if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(trimmed)) { + out.push('
'); + i++; + continue; + } + + // ── Heading ── + const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + const level = headingMatch[1].length; + out.push(`${_kanbanRenderMarkdownInline(headingMatch[2])}`); + i++; + continue; + } + + // ── Blockquote ── + if (/^>\s?/.test(trimmed)) { + const quoteLines = []; + while (i < lines.length && /^>\s?/.test(lines[i].trim())) { + quoteLines.push(lines[i].trim().replace(/^>\s?/, '')); + i++; + } + out.push(`
${_kanbanRenderMarkdownInline(quoteLines.join('
'))}
`); + continue; + } + + // ── Table row ── + if (/^\|.+\|$/.test(trimmed)) { + const tableRows = []; + const tableAligns = []; + while (i < lines.length && /^\|.+\|$/.test(lines[i].trim())) { + const row = lines[i].trim(); + // Detect alignment separator row + if (/^\|[\s:]*-{3,}[\s:]*\|/.test(row)) { + const cells = row.split('|').filter(c => c.trim().length > 0); + cells.forEach(c => { + const t = c.trim(); + if (t.startsWith(':') && t.endsWith(':')) tableAligns.push('center'); + else if (t.endsWith(':')) tableAligns.push('right'); + else tableAligns.push('left'); + }); + } else { + const cells = row.split('|').filter(c => c.trim().length > 0); + tableRows.push(cells.map((c, ci) => { + const align = tableAligns[ci] ? ` style="text-align:${tableAligns[ci]}"` : ''; + return `${_kanbanRenderMarkdownInline(c.trim())}`; + }).join('')); + } + i++; + } + if (tableRows.length) { + out.push(`${tableRows.map(r => `${r}`).join('')}
`); + } + continue; + } + + // ── Task list item ── + const taskMatch = trimmed.match(/^[-*+]\s+\[( |x|X)\]\s+(.+)$/); + if (taskMatch) { + const checked = taskMatch[1] !== ' '; + const text = taskMatch[2]; + const items = []; + items.push(`
  • ${_kanbanRenderMarkdownInline(text)}
  • `); + i++; + // Collect continuation items + while (i < lines.length) { + const next = lines[i].trim(); + const nextTask = next.match(/^[-*+]\s+\[( |x|X)\]\s+(.+)$/); + const nextLi = next.match(/^[-*+]\s+(.+)$/); + if (nextTask) { + const c = nextTask[1] !== ' '; + items.push(`
  • ${_kanbanRenderMarkdownInline(nextTask[2])}
  • `); + i++; + } else if (nextLi) { + items.push(`
  • ${_kanbanRenderMarkdownInline(nextLi[1])}
  • `); + i++; + } else { + break; + } + } + out.push(`
      ${items.join('')}
    `); + continue; + } + + // ── Unordered list item ── + const ulMatch = trimmed.match(/^[-*+]\s+(.+)$/); + if (ulMatch) { + const items = []; + items.push(`
  • ${_kanbanRenderMarkdownInline(ulMatch[1])}
  • `); + i++; + while (i < lines.length) { + const next = lines[i].trim(); + const nextUl = next.match(/^[-*+]\s+(.+)$/); + const nextTask = next.match(/^[-*+]\s+\[( |x|X)\]\s+(.+)$/); + if (nextTask) break; // let task list handler get it + if (nextUl) { + items.push(`
  • ${_kanbanRenderMarkdownInline(nextUl[1])}
  • `); + i++; + } else { + break; + } + } + out.push(`
      ${items.join('')}
    `); + continue; + } + + // ── Ordered list item ── + const olMatch = trimmed.match(/^\d+\.\s+(.+)$/); + if (olMatch) { + const items = []; + items.push(`
  • ${_kanbanRenderMarkdownInline(olMatch[1])}
  • `); + i++; + while (i < lines.length) { + const next = lines[i].trim(); + const nextOl = next.match(/^\d+\.\s+(.+)$/); + if (nextOl) { + items.push(`
  • ${_kanbanRenderMarkdownInline(nextOl[1])}
  • `); + i++; + } else { + break; + } + } + out.push(`
      ${items.join('')}
    `); + continue; + } + + // ── Empty line ── + if (!trimmed) { + out.push(''); + i++; + continue; + } + + // ── Paragraph ── + out.push(`

    ${_kanbanRenderMarkdownInline(trimmed)}

    `); + i++; + } + return `
    ${out.join('\n')}
    `; } function _kanbanFormatDuration(seconds){ @@ -1851,7 +2022,7 @@ function _kanbanCommentHtml(comment){ const by = comment.author || comment.created_by || comment.actor || ''; const at = _kanbanFormatTimestamp(comment.created_at || comment.ts || ''); return `
    -
    ${esc(body)}
    +
    ${_kanbanRenderMarkdown(body)}
    ${esc([by, at].filter(Boolean).join(' · '))}
    `; } @@ -2403,7 +2574,7 @@ function _kanbanRenderTaskDetail(data){
    ${esc(title)}
    -
    ${esc(body)}
    +
    ${_kanbanRenderMarkdown(body)}
    ${meta.length ? `
    ${esc(meta.join(' · '))}
    ` : ''}
    ${statusButtons}
    diff --git a/static/style.css b/static/style.css index 80f1e96e..5f7bb3e0 100644 --- a/static/style.css +++ b/static/style.css @@ -4221,7 +4221,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;} .kanban-run-dispatch-btn:hover{ background:color-mix(in srgb,var(--accent,#FFD700) 24%,transparent); } -.kanban-task-preview-body{font-size:12px;color:var(--muted);line-height:1.45;white-space:pre-wrap;margin-bottom:6px;} +.kanban-task-preview-body{font-size:12px;color:var(--muted);line-height:1.45;margin-bottom:6px;} .kanban-status-actions{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0 4px;} .kanban-status-actions .btn{font-size:11px;padding:4px 8px;} /* Generic styled buttons used throughout the Kanban panel. The Kanban PR @@ -4291,7 +4291,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;} .kanban-detail-section h3{font-size:12px;font-weight:650;color:var(--text);margin:0 0 8px;} .kanban-detail-row{padding:8px 0;border-top:1px solid var(--border);} .kanban-detail-row:first-of-type{border-top:0;padding-top:0;} -.kanban-detail-row-main{font-size:12px;color:var(--text);line-height:1.45;white-space:pre-wrap;} +.kanban-detail-row-main{font-size:12px;color:var(--text);line-height:1.45;}.kanban-detail-row-main .hermes-kanban-md p:last-child{margin-bottom:0;} .kanban-detail-row-meta{font-size:10px;color:var(--muted);margin-top:4px;} .kanban-detail-pre{font-size:11px;line-height:1.4;white-space:pre-wrap;word-break:break-word;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;padding:6px;margin:6px 0 0;color:var(--muted);} .kanban-detail-empty{font-size:12px;color:var(--muted);} @@ -4323,7 +4323,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;} .kanban-card-stale-amber{border-color:rgba(245,197,66,.55)} .kanban-card-stale-red{border-color:rgba(255,95,95,.65)} .kanban-column.drop-target{outline:2px solid var(--accent);outline-offset:-2px} -.hermes-kanban-md p{margin:0 0 4px}.hermes-kanban-md code{font-family:var(--mono);font-size:.95em} +.hermes-kanban-md p{margin:0 0 4px}.hermes-kanban-md code{font-family:var(--mono);font-size:.95em}.hermes-kanban-md h1,.hermes-kanban-md h2,.hermes-kanban-md h3,.hermes-kanban-md h4,.hermes-kanban-md h5,.hermes-kanban-md h6{margin:10px 0 4px;font-weight:650;color:var(--text)}.hermes-kanban-md h1{font-size:15px}.hermes-kanban-md h2{font-size:14px}.hermes-kanban-md h3{font-size:13px}.hermes-kanban-md h4,.hermes-kanban-md h5,.hermes-kanban-md h6{font-size:12px}.hermes-kanban-md ul,.hermes-kanban-md ol{margin:4px 0;padding-left:20px}.hermes-kanban-md li{margin:2px 0}.hermes-kanban-md li.checked{opacity:.6}.hermes-kanban-md li input[type=checkbox]{margin:0 4px 0 0;vertical-align:middle}.hermes-kanban-md table{border-collapse:collapse;margin:6px 0;font-size:11px;width:100%}.hermes-kanban-md td{border:1px solid var(--border);padding:4px 6px;vertical-align:top}.hermes-kanban-md blockquote{margin:4px 0;padding:2px 8px;border-left:3px solid var(--accent);color:var(--muted);font-size:12px}.hermes-kanban-md pre{background:var(--input-bg);border:1px solid var(--border);border-radius:6px;padding:8px;margin:6px 0;overflow-x:auto;font-size:11px;line-height:1.4;color:var(--text)}.hermes-kanban-md hr{border:none;border-top:1px solid var(--border);margin:8px 0} @media (max-width: 640px){ .kanban-board{scroll-snap-type:x mandatory;} From 237bab753a3a1f9b7df1aec5da0bda46b5e33602 Mon Sep 17 00:00:00 2001 From: AJV20 Date: Sun, 24 May 2026 16:13:00 +0000 Subject: [PATCH 38/68] feat: surface live activity timeline (#2847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed from 2 author commits: - d2237e23 feat: surface live activity timeline - eee57ec0 fix: satisfy activity timeline CI guards Frontend-only telemetry from existing stream events. Replaces empty Thinking… placeholder with observable run status (Waiting on model / Waiting on tool result / Working for …). New CSS, new test file. --- static/style.css | 9 +++ static/ui.js | 99 ++++++++++++++++++++++++++-- tests/test_live_activity_timeline.py | 45 +++++++++++++ 3 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 tests/test_live_activity_timeline.py diff --git a/static/style.css b/static/style.css index 5f7bb3e0..429500ca 100644 --- a/static/style.css +++ b/static/style.css @@ -2330,6 +2330,15 @@ body.resizing .sidebar{transition:none!important;} .tool-call-group:not(.tool-call-group-collapsed) .tool-call-group-chevron{transform:rotate(90deg);} .tool-call-group-body{display:block;padding-left:var(--space-3);} .tool-call-group.tool-call-group-collapsed .tool-call-group-body{display:none;} +.agent-activity-status{display:grid;grid-template-columns:18px minmax(0,1fr) auto;align-items:start;gap:var(--space-2);padding:5px 0;color:var(--muted);font-size:var(--font-size-xs);line-height:1.45;border-bottom:1px solid color-mix(in srgb,var(--border-subtle) 60%,transparent);} +.agent-activity-status:last-child{border-bottom:0;} +.agent-activity-status-icon{display:inline-flex;align-items:center;justify-content:center;min-height:18px;opacity:.72;color:var(--muted);} +.agent-activity-status-copy{display:flex;flex-direction:column;min-width:0;gap:1px;} +.agent-activity-status-label{color:var(--text);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} +.agent-activity-status-detail{color:var(--muted);opacity:.72;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} +.agent-activity-status-time{font-variant-numeric:tabular-nums;opacity:.55;white-space:nowrap;} +.agent-activity-status-waiting .agent-activity-status-label{color:var(--muted);} +.agent-activity-status-error .agent-activity-status-label{color:var(--error);} .tool-call-group-label{font-weight:600;color:var(--muted);position:relative;display:inline-block;overflow:hidden;} .tool-call-group[data-live-tool-call-group="1"] .tool-call-group-label{ color:var(--muted); diff --git a/static/ui.js b/static/ui.js index 6679c87a..0d4c8e4e 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2208,6 +2208,7 @@ function _startCompressionElapsedTimer(){if(!_compressionElapsedTimer)_compressi function _clearCompressionElapsedTimer(){if(_compressionElapsedTimer){clearInterval(_compressionElapsedTimer);_compressionElapsedTimer=null;}} let _activityElapsedTimer=null; let _activityElapsedTimerGroup=null; +function _activityNowSeconds(){return Date.now()/1000;} function _activityElapsedStartedAt(group){ if(!group)return null; const raw=(group.dataset&&group.dataset.turnStartedAt!==undefined&&group.dataset.turnStartedAt!=='') @@ -2219,7 +2220,52 @@ function _activityElapsedStartedAt(group){ function _activityElapsedLabel(group){ const started=_activityElapsedStartedAt(group); if(!started)return''; - return _formatActiveElapsedTimer((Date.now()/1000)-started); + return _formatActiveElapsedTimer(_activityNowSeconds()-started); +} +function _activityMarkObserved(group, ts){ + if(!group||group.getAttribute('data-live-tool-call-group')!=='1')return; + const stamp=Number(ts||_activityNowSeconds()); + if(Number.isFinite(stamp)&&stamp>0) group.setAttribute('data-last-activity-at',String(stamp)); +} +function _activityLastObservedAge(group){ + const stamp=Number(group&&group.getAttribute('data-last-activity-at')); + if(!Number.isFinite(stamp)||stamp<=0)return null; + return Math.max(0,_activityNowSeconds()-stamp); +} +function _activityClockLabel(ts){ + const stamp=Number(ts||_activityNowSeconds()); + if(!Number.isFinite(stamp)||stamp<=0)return''; + try{return new Date(stamp*1000).toLocaleTimeString([], {hour:'numeric',minute:'2-digit'});}catch(_){return'';} +} +function _activityStatusNode({kind='info',label='',detail='',status='done',ts=null,id=''}){ + const row=document.createElement('div'); + row.className=`agent-activity-status agent-activity-status-${kind} agent-activity-status-${status}`; + if(id) row.setAttribute('data-activity-event-id',id); + if(ts) row.setAttribute('data-activity-at',String(ts)); + const iconMap={run:li('play',13),model:li('bot',13),waiting:'',thinking:li('lightbulb',13),tool:li('wrench',13),done:li('check',13),warning:li('alert-triangle',13)}; + row.innerHTML=`${iconMap[kind]||li('clock',13)}${esc(label)}${detail?`${esc(detail)}`:''}${esc(_activityClockLabel(ts))}`; + return row; +} +function _appendActivityEvent(group, event){ + if(!group)return null; + const body=group.querySelector('.tool-call-group-body'); + if(!body)return null; + const eventId=event&&event.id; + let row=eventId?body.querySelector(`.agent-activity-status[data-activity-event-id="${CSS.escape(eventId)}"]`):null; + const next=_activityStatusNode(event||{}); + if(row){row.replaceWith(next);row=next;} + else{body.appendChild(next);row=next;} + _activityMarkObserved(group,event&&event.ts); + return row; +} +function _ensureLiveActivityBaseline(group){ + if(!group||group.getAttribute('data-live-tool-call-group')!=='1')return; + const started=_activityElapsedStartedAt(group)||_activityNowSeconds(); + if(!group.getAttribute('data-turn-started-at')) group.setAttribute('data-turn-started-at',String(started)); + if(!group.getAttribute('data-last-activity-at')) group.setAttribute('data-last-activity-at',String(started)); + _appendActivityEvent(group,{id:'run-started',kind:'run',label:'Run started',detail:'Observable activity will appear here as the agent works.',status:'done',ts:started}); + const modelLabel=(S.session&&S.session.model)?getModelLabel(S.session.model):''; + if(modelLabel)_appendActivityEvent(group,{id:'run-model',kind:'model',label:`Model: ${modelLabel}`,detail:S.activeProfile&&S.activeProfile!=='default'?`Profile: ${S.activeProfile}`:'',status:'done',ts:started}); } function _setActivityElapsedStartedAt(group){ if(!group||group.getAttribute('data-live-tool-call-group')!=='1')return; @@ -2240,8 +2286,10 @@ function _updateActiveActivityElapsedTimer(){ group.removeAttribute('data-active-turn-elapsed'); } if(durationEl){ - durationEl.textContent=label?`Working ${label}`:''; - durationEl.style.display=label?'':'none'; + const activeText=label?`Working for ${label}`:''; + const progressText=_activityLiveProgressLabel(group); + durationEl.textContent=[progressText, activeText].filter(Boolean).join(' · '); + durationEl.style.display=durationEl.textContent?'':'none'; } } function _startActivityElapsedTimer(group){ @@ -5220,7 +5268,10 @@ function ensureActivityGroup(inner, opts){ }else if(activityKey&&!group.getAttribute('data-activity-disclosure-key')){ group.setAttribute('data-activity-disclosure-key',activityKey); } - if(live) _setActivityElapsedStartedAt(group); + if(live){ + _setActivityElapsedStartedAt(group); + _ensureLiveActivityBaseline(group); + } _syncToolCallGroupSummary(group); if(live) _startActivityElapsedTimer(group); return group; @@ -6658,7 +6709,9 @@ function _syncToolCallGroupSummary(group){ const label=group.querySelector('.tool-call-group-label'); const durationEl=group.querySelector('.tool-call-group-duration'); if(label){ - if(toolCount) label.textContent=`Activity: ${toolCount} tool${toolCount===1?'':'s'}`; + if(group.getAttribute('data-live-tool-call-group')==='1'){ + label.textContent=toolCount?`Activity: ${toolCount} tool${toolCount===1?'':'s'}`:'Activity · Running'; + }else if(toolCount) label.textContent=`Activity: ${toolCount} tool${toolCount===1?'':'s'}`; else label.textContent='Activity'; label.setAttribute('data-sweep-label', label.textContent); } @@ -6692,9 +6745,14 @@ function _activityProgressLabelForToolName(name){ function _activityLiveProgressLabel(group){ if(!group||group.getAttribute('data-live-tool-call-group')!=='1') return ''; + const idleAge=_activityLastObservedAge(group); + if(idleAge!==null&&idleAge>=90) return `No recent activity for ${_formatActiveElapsedTimer(idleAge)}`; const running=group.querySelector('.tool-card.tool-card-running .tool-card-name'); const latest=running || Array.from(group.querySelectorAll('.tool-card-name')).pop(); - return _activityProgressLabelForToolName(latest?latest.textContent:''); + const waiting=group.querySelector('.agent-activity-status-waiting .agent-activity-status-label'); + if(latest) return _activityProgressLabelForToolName(latest.textContent); + if(waiting&&waiting.textContent) return waiting.textContent; + return 'Starting agent'; } // ── Live tool card helpers (called during SSE streaming) ── @@ -6760,6 +6818,19 @@ function appendLiveToolCard(tc){ const anchor=children.filter(el=>el.matches('[data-live-assistant="1"],.tool-call-group,.tool-card-row,.agent-activity-thinking')).pop(); const group=ensureActivityGroup(inner,{live:true,collapsed:true,anchor,activityKey:_activityKeyForLiveTurn()}); const body=group.querySelector('.tool-call-group-body'); + const toolName=_toolDisplayName(tc); + const toolEventId=tid?`tool-${tid}`:`tool-${String(tc.name||'tool').replace(/[^a-z0-9_-]/gi,'_')}`; + const toolDone=tc.done!==false; + _appendActivityEvent(group,{ + id:toolEventId, + kind:'tool', + label:toolDone?`Tool finished: ${toolName}`:`Running tool: ${toolName}`, + detail:tc.preview||tc.snippet||'', + status:toolDone?(tc.is_error?'error':'done'):'waiting', + ts:_activityNowSeconds(), + }); + const waiting=body.querySelector('.agent-activity-status[data-activity-event-id="thinking-placeholder"] .agent-activity-status-label'); + if(waiting&&!toolDone) waiting.textContent='Waiting on tool result'; // Update existing card in place (tool_complete after tool_start) if(tid){ const existing=body.querySelector(`.tool-card-row[data-live-tid="${CSS.escape(tid)}"]`); @@ -7584,6 +7655,7 @@ function appendThinking(text='', options){ return; } const thinkingText=String(text||'').trim()||'Thinking…'; + const cleanThinking=_sanitizeThinkingDisplayText(thinkingText); const allChildren=Array.from(blocks.children); const anchor=allChildren.filter(el=> el.id!=='toolRunningRow' && @@ -7592,6 +7664,20 @@ function appendThinking(text='', options){ const group=ensureActivityGroup(blocks,{live:true,collapsed:true,anchor,activityKey:_activityKeyForLiveTurn()}); const body=group&&group.querySelector('.tool-call-group-body'); if(!body) return; + if(!cleanThinking||cleanThinking==='Thinking…'){ + const label=body.querySelector('.tool-card.tool-card-running')?'Waiting on tool result':'Waiting on model'; + const detail=body.querySelector('.tool-card-row') + ? 'The agent is running; tool results and response text will appear here.' + : 'No tool activity has been reported yet.'; + _appendActivityEvent(group,{id:'thinking-placeholder',kind:'waiting',label,detail,status:'waiting',ts:_activityNowSeconds()}); + const active=body.querySelector('.agent-activity-thinking[data-thinking-active="1"]'); + if(active) active.removeAttribute('data-thinking-active'); + _syncToolCallGroupSummary(group); + scrollIfPinned(); + return; + } + const placeholder=body.querySelector('.agent-activity-status[data-activity-event-id="thinking-placeholder"]'); + if(placeholder) placeholder.remove(); let row=body.querySelector('.agent-activity-thinking[data-thinking-active="1"]'); if(!row){ const thinkingCards=Array.from(body.querySelectorAll('.agent-activity-thinking')); @@ -7605,6 +7691,7 @@ function appendThinking(text='', options){ }else{ _renderThinkingInto(row,thinkingText); } + _activityMarkObserved(group); _syncToolCallGroupSummary(group); scrollIfPinned(); if(_scrollPinned){ diff --git a/tests/test_live_activity_timeline.py b/tests/test_live_activity_timeline.py new file mode 100644 index 00000000..21e04f2c --- /dev/null +++ b/tests/test_live_activity_timeline.py @@ -0,0 +1,45 @@ +"""Regression coverage for live Activity timeline UX. + +The live Activity disclosure should surface observable run telemetry instead of a +blank Thinking placeholder while preserving the quiet tool/thinking metadata +family. +""" + +import pathlib + + +REPO = pathlib.Path(__file__).parent.parent +UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") +STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8") + + +def test_live_activity_group_has_observable_baseline_events(): + assert "function _ensureLiveActivityBaseline(group)" in UI_JS + assert "Run started" in UI_JS + assert "Observable activity will appear here as the agent works." in UI_JS + assert "Model: ${modelLabel}" in UI_JS + assert "_ensureLiveActivityBaseline(group);" in UI_JS + + +def test_empty_thinking_placeholder_becomes_status_row_not_raw_thinking_card(): + assert "data-activity-event-id=\"thinking-placeholder\"" in UI_JS + assert "Waiting on model" in UI_JS + assert "No tool activity has been reported yet." in UI_JS + assert "Waiting on tool result" in UI_JS + assert "_thinkingActivityNode(thinkingText, false)" in UI_JS + + +def test_tool_events_update_activity_timeline_and_summary(): + assert "Tool finished: ${toolName}" in UI_JS + assert "Running tool: ${toolName}" in UI_JS + assert "No recent activity for ${_formatActiveElapsedTimer(idleAge)}" in UI_JS + assert "Activity · Running" in UI_JS + assert "Working for ${label}" in UI_JS + + +def test_activity_status_rows_have_quiet_metadata_styling(): + assert ".agent-activity-status{" in STYLE_CSS + assert "grid-template-columns:18px minmax(0,1fr) auto" in STYLE_CSS + assert ".agent-activity-status-detail" in STYLE_CSS + assert ".agent-activity-status-time" in STYLE_CSS + assert ".agent-activity-status-error .agent-activity-status-label{color:var(--error);}" in STYLE_CSS From 68af4399a6e08688b317938032a66036ecb22796 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 24 May 2026 16:14:04 +0000 Subject: [PATCH 39/68] Stamp CHANGELOG for v0.51.126 (Release CX / stage-batch8 / 2-PR batch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked PRs: - #2819 (humayunak) — kanban markdown full GFM rendering - #2847 (AJV20) — live activity timeline observable telemetry (squashed from 2) --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c144ceb5..d1a18913 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ ## [Unreleased] +## [v0.51.126] — 2026-05-24 — Release CX (stage-batch8 — 2-PR low-risk batch — kanban markdown + live activity timeline) + +### Added + +- **PR #2819** by @humayunak — Kanban task descriptions and comments now render as full GFM Markdown instead of plain-text. `_kanbanRenderMarkdown()` in `static/panels.js` rewrote the line-per-`

    ` wrapper as a block-parsing pipeline supporting headings, code blocks (fenced + indented), ordered/unordered lists, task lists with checkboxes, tables, blockquotes, horizontal rules, and strikethrough. `_kanbanRenderMarkdownInline()` gains `~~strikethrough~~` and tightens the italic regex to avoid mid-identifier `*` matches. CSS adds table borders, code-block background, checkbox styling, blockquote accent, and heading sizing scoped to `.hermes-kanban-md`. Frontend-only, scoped to the kanban panel. 95 existing kanban tests pass. + +### Changed + +- **PR #2847** by @AJV20 — Live chat Activity disclosure now shows observable run telemetry instead of an empty `Thinking…` placeholder when no reasoning text is available (squashed from 2 author commits). New baseline rows surface run-start metadata (model, profile), `Waiting on model` / `Waiting on tool result` / `Working for …` status, tool start/finish in the timeline alongside the existing compact tool cards, and a `No recent activity for …` state after quiet periods. Frontend-only telemetry derived from existing stream events — no new backend event types. Adds `tests/test_live_activity_timeline.py` (4 tests). The compact/calm default Activity disclosure is preserved; it only becomes informative when expanded. + ## [v0.51.125] — 2026-05-24 — Release CW (stage-batch7 — 10-PR low-risk batch — UI/UX polish + bug fixes + diagnostics) ### Fixed From a34d5e26c2fb358200c851ecb27984d8fce7b7ce Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Sun, 24 May 2026 18:28:08 +0200 Subject: [PATCH 40/68] fix(chat): settle stream_end without done --- static/messages.js | 28 ++++++++++++++++- tests/test_1694_terminal_cleanup_ownership.py | 30 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/static/messages.js b/static/messages.js index 4bccc85b..57133a01 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1682,6 +1682,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ }); source.addEventListener('done',e=>{ + if(_streamFinalized) return; _terminalStateReached=true; if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;} const _doneData=JSON.parse(e.data); @@ -1859,12 +1860,31 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _finishDone(); }); - source.addEventListener('stream_end',e=>{ + source.addEventListener('stream_end',async e=>{ + if(_streamFinalized){ + source.close(); + return; + } _terminalStateReached=true; try{ const d=JSON.parse(e.data||'{}'); if((d.session_id||activeSid)!==activeSid) return; }catch(_){} + // Some replay/journal paths can deliver stream_end without a preceding + // done event. In that case closing the EventSource is not enough: the + // live DOM/inflight state remains projected and can duplicate Thinking or + // assistant content until a later session switch. Settle from the persisted + // session before closing so the pane converges on canonical state. + if(await _restoreSettledSession()){ + source.close(); + return; + } + if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;} + _streamFinalized=true; + _cancelAnimationFramePendingStreamRender(); + _streamFadeCleanupReduceMotionListener(); + _smdEndParser(); + if(typeof finalizeThinkingCard==='function') finalizeThinkingCard(); source.close(); }); @@ -2135,6 +2155,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const session=data&&data.session; if(!session) return false; if(session.active_stream_id||session.pending_user_message) return false; + if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;} + _streamFinalized=true; + _cancelAnimationFramePendingStreamRender(); + _streamFadeCleanupReduceMotionListener(); + _smdEndParser(); + if(typeof finalizeThinkingCard==='function') finalizeThinkingCard(); _clearOwnerInflightState(); _closeSource(); _clearApprovalForOwner(); diff --git a/tests/test_1694_terminal_cleanup_ownership.py b/tests/test_1694_terminal_cleanup_ownership.py index 57fea1d1..f2a12c53 100644 --- a/tests/test_1694_terminal_cleanup_ownership.py +++ b/tests/test_1694_terminal_cleanup_ownership.py @@ -91,3 +91,33 @@ def test_reconnect_settled_and_error_paths_keep_cleanup_session_scoped(): assert "stopApprovalPolling();stopClarifyPolling();" not in combined assert "renderSessionList();setBusy(false)" not in combined assert "_setActivePaneIdleIfOwner" in combined + +def test_stream_end_without_done_restores_settled_session_before_closing(): + """If a journal/replay emits stream_end without done, the UI must settle from /api/session. + + A close-only stream_end handler leaves live Thinking/inflight DOM around and + never replaces the pane with the persisted transcript when done is missing. + """ + body = _event_body("stream_end") + restore_idx = body.find("_restoreSettledSession()") + close_idx = body.rfind("source.close()") + finalized_idx = body.find("_streamFinalized=true") + assert restore_idx != -1, "stream_end handler must restore settled session when done is absent" + assert close_idx != -1, "stream_end handler must still close the EventSource" + assert restore_idx < close_idx, "restore must be attempted before closing the stream" + assert finalized_idx != -1, "stream_end terminal path must suppress trailing rAF/render work" + + +def test_done_handler_is_idempotent_for_replay_or_duplicate_done_events(): + """Duplicate/replayed done events must not replay completion sound or duplicate render.""" + body = _event_body("done") + first_stmt = body.strip().splitlines()[0].strip() + assert "_streamFinalized" in first_stmt and "return" in first_stmt, ( + "done handler must return early when the stream was already finalized" + ) + guard_idx = body.find("if(_streamFinalized) return;") + sound_idx = body.find("playNotificationSound();") + assert sound_idx != -1, "done handler should still play completion sound once" + assert guard_idx != -1 and guard_idx < sound_idx, ( + "completion sound must be behind the duplicate-done finalization guard" + ) From 145a442f614899c5178a0671d253a9ce5f6475eb Mon Sep 17 00:00:00 2001 From: Dustin <204417361+Koraji95-coder@users.noreply.github.com> Date: Sun, 24 May 2026 00:20:36 -0500 Subject: [PATCH 41/68] ci(windows): rework #2811 with mock hermes_cli (maintainer ask, option 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per @nesquena-hermes review on #2811: hermes-agent isn't published to PyPI, so `pip install hermes-agent` finds nothing and start.ps1's hermes_cli guard correctly bails out — leaving the previous workflow unable to self-validate against release/stage-batch6. This rework adopts option 1 from the review: drop the pip install, stub a hermes_cli/ directory with a minimal __init__.py next to the sibling hermes-agent/ folder, then run start.ps1 for 8 seconds and assert that none of its own Write-Error guards (no Python, no agent dir, bad port, missing hermes_cli, missing server.py) appeared in stderr. /health is no longer probed — the server cannot boot on a stub, and full-boot regressions stay covered by the Linux jobs and docker-smoke.yml. Scope intentionally narrower than the original: this workflow validates start.ps1's PowerShell syntax + path discovery only. The exact bug class PR #2805 caught (WOW64 ProgramFiles redirect) would now light up red here pre-merge, which is the reason this gate exists. Paths filter trimmed to `start.ps1` + the workflow itself; the broader list (requirements.txt / bootstrap.py / server.py) was inherited from the original full-boot scoping and isn't relevant for a path-discovery- only run. Verification: workflow runs on this PR via its own pull_request trigger. The first CI run on this branch IS the verification. CHANGELOG updated under [Unreleased] with a single bullet sized to the surrounding density. --- .github/workflows/native-windows-startup.yml | 132 +++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 .github/workflows/native-windows-startup.yml diff --git a/.github/workflows/native-windows-startup.yml b/.github/workflows/native-windows-startup.yml new file mode 100644 index 00000000..6740f51f --- /dev/null +++ b/.github/workflows/native-windows-startup.yml @@ -0,0 +1,132 @@ +name: Native Windows startup + +# Runs on PRs that touch start.ps1 (or this workflow). Validates the +# native-Windows launch script catches the bug classes the recent +# Windows-only batch caught manually (#2805 WOW64 ProgramFiles redirect, +# #2806 venv-portability claim, #2807 port-parse + finally-cleanup). +# +# Scope (per nesquena-hermes comment on #2811 — option 1, mock-only): +# hermes-agent is not published to PyPI, so we cannot pip-install it on +# the runner. Instead we stub a hermes_cli/ directory next to a sibling +# hermes-agent/ folder — just enough for start.ps1's existence guard to +# pass. The workflow then runs start.ps1 for a few seconds and asserts +# that none of start.ps1's own Write-Error guards fired. Server-boot +# regressions remain covered by the Linux jobs and docker-smoke.yml. + +on: + pull_request: + paths: + - 'start.ps1' + - '.github/workflows/native-windows-startup.yml' + workflow_dispatch: + +jobs: + native-windows-startup: + name: start.ps1 path discovery (mock hermes-agent) + runs-on: windows-latest + timeout-minutes: 8 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + # Create the WebUI venv. start.ps1 prefers $AgentDir\venv if it + # exists, then falls back to the python on PATH. We create a + # WebUI-local venv to mirror the README's documented native path + # and to give start.ps1 a real python.exe to invoke. + - name: Create venv (README path) + shell: pwsh + run: | + python -m venv venv + if (-not (Test-Path venv\Scripts\python.exe)) { + throw "venv\Scripts\python.exe missing after venv create" + } + + # Mock-only hermes-agent provisioning. We can't pip-install + # hermes-agent (not on PyPI), so we stub the minimum that + # start.ps1's `Test-Path hermes_cli -PathType Container` guard + # needs to pass. server.py would crash on this stub at import + # time — we deliberately do NOT probe /health below. + - name: Stub hermes-agent (mock hermes_cli only) + shell: pwsh + run: | + $agentDir = Join-Path (Split-Path -Parent $PWD) 'hermes-agent' + $cliDir = Join-Path $agentDir 'hermes_cli' + New-Item -ItemType Directory -Force -Path $cliDir | Out-Null + Set-Content -Path (Join-Path $cliDir '__init__.py') -Value '# stub for CI path-discovery test only' + "HERMES_WEBUI_AGENT_DIR=$agentDir" >> $env:GITHUB_ENV + Write-Host "Stub hermes-agent provisioned at $agentDir" + + # Run start.ps1 and verify it passes its own discovery guards + # without erroring out. server.py will exit non-zero on the stub + # (no real CLI code) — that's expected and not asserted against. + # We only fail if start.ps1's own Write-Error guards fire. + - name: Run start.ps1 + verify path discovery + shell: pwsh + run: | + $stdout = Join-Path $env:RUNNER_TEMP 'start-ps1.out' + $stderr = Join-Path $env:RUNNER_TEMP 'start-ps1.err' + $proc = Start-Process -FilePath 'pwsh' ` + -ArgumentList '-NoLogo','-File','.\start.ps1' ` + -WorkingDirectory $PWD ` + -PassThru ` + -RedirectStandardOutput $stdout ` + -RedirectStandardError $stderr + "SERVER_PID=$($proc.Id)" >> $env:GITHUB_ENV + Write-Host "Spawned start.ps1 wrapper PID $($proc.Id)" + + # Path discovery is sub-second; the 8s buffer lets the python + # launch land in the logs (and immediately exit on the stub). + Start-Sleep -Seconds 8 + + Write-Host "===== start.ps1 stdout =====" + $stdoutContent = if (Test-Path $stdout) { Get-Content $stdout -Raw } else { '' } + Write-Host $stdoutContent + Write-Host "===== start.ps1 stderr =====" + $stderrContent = if (Test-Path $stderr) { Get-Content $stderr -Raw } else { '' } + Write-Host $stderrContent + + # Pattern set: every Write-Error message start.ps1 can emit on + # its own discovery path. If any of these appear in stderr, + # path discovery regressed and the job must fail. + $guardErrors = @( + 'Python 3 is required', + 'hermes-agent not found', + 'HERMES_WEBUI_AGENT_DIR is set to', + 'is not a valid integer port', + 'is out of TCP-port range', + 'server.py not found' + ) + foreach ($msg in $guardErrors) { + if ($stderrContent -and $stderrContent -match [regex]::Escape($msg)) { + throw "REGRESSION: start.ps1 errored on guard '$msg' - path discovery failed." + } + } + Write-Host "OK: start.ps1 path discovery - all guards passed." + + # taskkill /T walks the process tree, /F forces. taskkill exits + # non-zero if the PID is already gone (server.py crashed on the + # stub) — that's expected, not a failure. + - name: Stop background server (tree-kill) + if: always() + shell: pwsh + run: | + if ($env:SERVER_PID) { + try { + & taskkill /PID $env:SERVER_PID /T /F 2>&1 | Out-Host + } catch { + Write-Host "taskkill: PID $env:SERVER_PID already exited (expected with mock stub)" + } + } + # Belt-and-suspenders: kill anything still bound to 8787. + $hanging = Get-NetTCPConnection -LocalPort 8787 -State Listen -ErrorAction SilentlyContinue + if ($hanging) { + foreach ($c in $hanging) { + try { Stop-Process -Id $c.OwningProcess -Force -ErrorAction Stop } catch {} + } + } From ae6b6b1b7248e0b21ee6914a5755b28645c71dd0 Mon Sep 17 00:00:00 2001 From: Dustin <204417361+Koraji95-coder@users.noreply.github.com> Date: Sun, 24 May 2026 00:26:06 -0500 Subject: [PATCH 42/68] ci(windows): make taskkill no-op when server.py already exited MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The path-discovery step succeeds on the first run, but the cleanup step exits non-zero because `taskkill /PID 5560 /T /F` returns 128 ("process not found") when server.py has already exited on the mock hermes_cli stub. That's the expected steady state for this mock-only workflow, not a failure. Two-line fix: reset `$global:LASTEXITCODE = 0` after the taskkill call, and explicit `exit 0` at the end of the step so any other external-command exit codes don't bubble up. The try/catch wrapper didn't help because taskkill writes its diagnostic to stderr without raising a PowerShell exception — `catch` never fired. Run 26352805510 on this branch shows the failure shape: "OK: start.ps1 path discovery - all guards passed." in the verify step, then "ERROR: The process '5560' not found." in the cleanup step. Path discovery is what this workflow exists to validate; cleanup just has to not fail the job. --- .github/workflows/native-windows-startup.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/native-windows-startup.yml b/.github/workflows/native-windows-startup.yml index 6740f51f..4345e2a0 100644 --- a/.github/workflows/native-windows-startup.yml +++ b/.github/workflows/native-windows-startup.yml @@ -109,19 +109,18 @@ jobs: } Write-Host "OK: start.ps1 path discovery - all guards passed." - # taskkill /T walks the process tree, /F forces. taskkill exits - # non-zero if the PID is already gone (server.py crashed on the - # stub) — that's expected, not a failure. + # taskkill /T walks the process tree, /F forces. taskkill returns + # 128 ("process not found") if the PID is already gone — that's + # the expected steady state for this mock-only workflow because + # server.py exits immediately on the stub hermes_cli. Reset + # $LASTEXITCODE so the step never fails on the cleanup itself. - name: Stop background server (tree-kill) if: always() shell: pwsh run: | if ($env:SERVER_PID) { - try { - & taskkill /PID $env:SERVER_PID /T /F 2>&1 | Out-Host - } catch { - Write-Host "taskkill: PID $env:SERVER_PID already exited (expected with mock stub)" - } + & taskkill /PID $env:SERVER_PID /T /F 2>&1 | Out-Host + $global:LASTEXITCODE = 0 } # Belt-and-suspenders: kill anything still bound to 8787. $hanging = Get-NetTCPConnection -LocalPort 8787 -State Listen -ErrorAction SilentlyContinue @@ -130,3 +129,4 @@ jobs: try { Stop-Process -Id $c.OwningProcess -Force -ErrorAction Stop } catch {} } } + exit 0 From 2c9fc4cfc0a91e5b326bb29619a57676a77e8c12 Mon Sep 17 00:00:00 2001 From: Dustin <204417361+Koraji95-coder@users.noreply.github.com> Date: Sat, 23 May 2026 22:56:57 -0500 Subject: [PATCH 43/68] style(composer): responsive composer-box max-width via clamp() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.composer-box` had a hardcoded `max-width: 780px` since the early v0.50.x layout pass. On wide displays (1440p+, 2880px ultrawides) this leaves significant unused horizontal space AND squeezes the composer-footer chips (workspace, model, reasoning, context %) against each other inside the 780px box. When the context-percentage ring appears (active token usage), the workspace chip truncates to "Fou..." instead of showing the full workspace name. Model + reasoning chips also lose room. The chip strip horizontally-scrolls inside .composer-left, so the rightmost chips effectively hide behind context %. The constraint isn't "Reading flow looks better at 780px" — the textarea is min-height:64px, max-height:200px and wraps naturally, so users on wide displays get the SAME readable text wrap regardless of box width. Only the footer chips suffer. Fix: clamp(780px, 60vw, 1100px). Preserves the 780px floor (no regression on viewports < 780px since clamp's first arg is the minimum) while letting wider viewports use up to 1100px (60% of viewport width, capped). 1100px gives ~40% more horizontal room for the footer chips without filling the entire screen at extreme widths. Per-viewport behavior: <= 780 px → 780 px (hard floor) — zero change vs current 1280 px → 60vw = 768 → floored to 780 — zero change 1440 px → 60vw = 864 — +84 px room 1920 px → 60vw = 1152 → capped at 1100 — +320 px room 2880 px → 60vw = 1728 → capped at 1100 — +320 px room One line in static/style.css. CHANGELOG entry. No JS. No new deps. Co-Authored-By: Claude Opus 4.7 (1M context) --- static/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/style.css b/static/style.css index 429500ca..3a0363c9 100644 --- a/static/style.css +++ b/static/style.css @@ -1352,7 +1352,7 @@ .suggestion:hover{background:var(--accent-bg);color:var(--text);border-color:var(--accent-bg);transform:translateX(2px);} /* ── Composer ── */ .composer-wrap{padding:12px 20px 16px;background:var(--bg);flex-shrink:0;} - .composer-box{max-width:780px;margin:0 auto;background:linear-gradient(var(--input-bg),var(--input-bg)),var(--bg);border:1px solid var(--border2);border-radius:16px;display:flex;flex-direction:column;transition:border-color .2s,box-shadow .2s;position:relative;z-index:2;} + .composer-box{max-width:clamp(780px, 60vw, 1100px);margin:0 auto;background:linear-gradient(var(--input-bg),var(--input-bg)),var(--bg);border:1px solid var(--border2);border-radius:16px;display:flex;flex-direction:column;transition:border-color .2s,box-shadow .2s;position:relative;z-index:2;} .composer-box:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg);} .composer-wrap.drag-over .composer-box{border-color:var(--accent-text);background:var(--accent-bg);} .drop-hint{display:none;position:absolute;inset:0;align-items:center;justify-content:center;background:var(--accent-bg);border:2px dashed var(--accent);border-radius:14px;font-size:14px;color:var(--accent-text);pointer-events:none;z-index:10;flex-direction:column;gap:8px;} From 029d95ada9e4161211f363972b7e17e10c9d8fcd Mon Sep 17 00:00:00 2001 From: Dustin <204417361+Koraji95-coder@users.noreply.github.com> Date: Sat, 23 May 2026 23:12:11 -0500 Subject: [PATCH 44/68] style(composer): address Copilot review on PR #2812 Three small fixes from Copilot's review: 1. static/style.css:1354 - removed spaces inside `clamp(...)` args to match the file's existing compact style (no spaces after commas in neighboring declarations like `transition:border-color .2s,box-shadow .2s`). 2. CHANGELOG.md - wrapped the long single-line entry across multiple lines with standard Markdown continuation indentation for cleaner diffs. 3. CHANGELOG.md - normalized `~1300 px` to `~1300px` for unit-formatting consistency. No behavior change. Same one-line CSS rule, just tightened formatting. Co-Authored-By: Claude Opus 4.7 (1M context) --- static/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/style.css b/static/style.css index 3a0363c9..f9b6094f 100644 --- a/static/style.css +++ b/static/style.css @@ -1352,7 +1352,7 @@ .suggestion:hover{background:var(--accent-bg);color:var(--text);border-color:var(--accent-bg);transform:translateX(2px);} /* ── Composer ── */ .composer-wrap{padding:12px 20px 16px;background:var(--bg);flex-shrink:0;} - .composer-box{max-width:clamp(780px, 60vw, 1100px);margin:0 auto;background:linear-gradient(var(--input-bg),var(--input-bg)),var(--bg);border:1px solid var(--border2);border-radius:16px;display:flex;flex-direction:column;transition:border-color .2s,box-shadow .2s;position:relative;z-index:2;} + .composer-box{max-width:clamp(780px,60vw,1100px);margin:0 auto;background:linear-gradient(var(--input-bg),var(--input-bg)),var(--bg);border:1px solid var(--border2);border-radius:16px;display:flex;flex-direction:column;transition:border-color .2s,box-shadow .2s;position:relative;z-index:2;} .composer-box:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg);} .composer-wrap.drag-over .composer-box{border-color:var(--accent-text);background:var(--accent-bg);} .drop-hint{display:none;position:absolute;inset:0;align-items:center;justify-content:center;background:var(--accent-bg);border:2px dashed var(--accent);border-radius:14px;font-size:14px;color:var(--accent-text);pointer-events:none;z-index:10;flex-direction:column;gap:8px;} From 71ba863ce50be1edc55366231946255244324167 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 24 May 2026 16:52:19 +0000 Subject: [PATCH 45/68] fix(terminal): drop PR_SET_PDEATHSIG preexec_fn that killed every Linux shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2853. The `_terminal_shell_preexec_fn` added in `71d8a8fb` called `prctl(PR_SET_PDEATHSIG, SIGTERM)` so orphaned PTY shells would die when the WebUI process crashed. But that signal is **per-thread**, not per-process, and WebUI runs `ThreadingHTTPServer`: every HTTP request is handled in its own short-lived worker thread. Flow that broke every Linux user: 1. User clicks the terminal toggle → frontend hits `POST /api/terminal/start`. 2. ThreadingHTTPServer spins up a worker thread to handle that one request. 3. The worker thread calls `subprocess.Popen(..., preexec_fn=...)`. 4. The shell calls `prctl(PR_SET_PDEATHSIG, SIGTERM)` in its preexec_fn. Its registered "parent" is now the WebUI worker thread that called Popen. 5. The handler returns its JSON response and the worker thread exits. 6. The kernel sees the pdeathsig-parent thread has died and sends SIGTERM to the PTY shell. The shell dies within ~10 ms of being created. 7. The reader loop sees EIO on the master FD, emits `terminal_closed`, and the frontend writes `[terminal closed]`. macOS users were unaffected because `libc.prctl` doesn't exist there — `ctypes.CDLL(None)` returns a libc handle, `libc.prctl` raises `AttributeError`, the bare-`except` swallows it, and the shell starts with no pdeathsig configured. Empirical verification on this Linux host (real PTY + `subprocess.Popen` inside a `threading.Thread` that joins immediately): with preexec_fn → proc.poll() == -15 (SIGTERM), master FD returns EIO without preexec_fn → proc.poll() == None (alive), master FD returns "HELLO\\r\\n" Same shell, same PTY, same threading topology as WebUI. Fix --- Drop the `preexec_fn` entirely. The orphan-shell-on-crash case the original PR was navigating is rare for self-hosted single-user installs, and the existing `atexit.register(close_all_terminals)` + explicit `close_terminal` paths cover graceful shutdown. A future fix (option B in the issue) can re-introduce pdeathsig pinned to a long-lived supervisor thread, but that is a follow-up — this PR is the smallest unbricks-Linux-today change. Tests ----- - Invert `test_terminal_shell_uses_parent_death_signal_preexec` → `test_terminal_shell_does_not_use_pdeathsig_preexec`: asserts `preexec_fn` is NOT in the Popen kwargs. - Add `test_pty_shell_survives_when_spawning_thread_exits`: spawns a real PTY shell via `start_terminal` from a worker thread, waits for the worker to join, asserts the shell is still alive after a half-second grace window. This is the contract the original tests never exercised. - Update `test_terminal_module_registers_graceful_shutdown_reaper` to refuse re-introduction of the preexec_fn or the `libc.prctl(1, SIGTERM)` call (treats either as a regression). All 27 terminal-related tests pass locally. Refs #2853 --- api/terminal.py | 24 ++++---- tests/test_terminal_process_cleanup.py | 76 ++++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 19 deletions(-) diff --git a/api/terminal.py b/api/terminal.py index 5ac2c741..bb857e16 100644 --- a/api/terminal.py +++ b/api/terminal.py @@ -70,18 +70,17 @@ _TERMINALS: dict[str, TerminalSession] = {} _LOCK = threading.RLock() -def _terminal_shell_preexec_fn() -> None: - """Ask Linux to terminate the PTY shell when the WebUI parent dies.""" - try: - import ctypes - - libc = ctypes.CDLL(None) - libc.prctl(1, signal.SIGTERM) # PR_SET_PDEATHSIG=1, SIGTERM=15 - except Exception: - # Non-Linux platforms or restricted runtimes should still be able to - # open an embedded terminal; they just do not get the Linux pdeathsig - # hardening. - pass +# NOTE on parent-death-signal: a previous version of this module set +# PR_SET_PDEATHSIG via a preexec_fn to terminate orphaned PTY shells when the +# WebUI process crashed. That broke every Linux user (#2853): WebUI runs a +# ThreadingHTTPServer, so the Popen call happens on a short-lived per-request +# thread, and PR_SET_PDEATHSIG is per-thread. The PTY shell registered the +# spawning thread as its "parent" and was killed with SIGTERM the instant that +# thread joined — within ~10 ms of opening the terminal — surfacing as the +# `[terminal closed]` banner. The graceful path is covered by +# `atexit.register(close_all_terminals)` and the explicit `close_terminal` +# call sites; hard kills of the WebUI process leak the shell, which is the +# tradeoff for working on Linux at all. def _decode_terminal_output(decoder, data: bytes) -> str: @@ -193,7 +192,6 @@ def start_terminal(session_id: str, workspace: Path, rows: int = 24, cols: int = stdout=slave_fd, stderr=slave_fd, close_fds=True, - preexec_fn=_terminal_shell_preexec_fn, start_new_session=True, ) os.close(slave_fd) diff --git a/tests/test_terminal_process_cleanup.py b/tests/test_terminal_process_cleanup.py index 51607935..a5371081 100644 --- a/tests/test_terminal_process_cleanup.py +++ b/tests/test_terminal_process_cleanup.py @@ -1,4 +1,9 @@ +import os import subprocess +import threading +import time + +import pytest import api.terminal as terminal @@ -27,7 +32,19 @@ class _FakeProc: return 0 -def test_terminal_shell_uses_parent_death_signal_preexec(monkeypatch, tmp_path): +def test_terminal_shell_does_not_use_pdeathsig_preexec(monkeypatch, tmp_path): + """Regression for #2853. + + The previous implementation passed a ``preexec_fn`` that called + ``prctl(PR_SET_PDEATHSIG, SIGTERM)``. Because that signal is *per-thread* + and WebUI's ``ThreadingHTTPServer`` spawns a new thread for every HTTP + request, the PTY shell registered the request-handler thread as its + parent and was killed within ~10 ms of being created on Linux. + + The fix is to spawn the shell without ``preexec_fn`` at all. Graceful + shutdown remains covered by ``atexit.register(close_all_terminals)`` and + the explicit ``close_terminal`` paths. + """ captured = {} proc = _FakeProc() @@ -40,15 +57,59 @@ def test_terminal_shell_uses_parent_death_signal_preexec(monkeypatch, tmp_path): monkeypatch.setattr(terminal.threading, "Thread", _DummyThread) monkeypatch.setattr(terminal, "_set_size", lambda *args, **kwargs: None) - term = terminal.start_terminal("term-preexec", tmp_path) + term = terminal.start_terminal("term-no-preexec", tmp_path) try: assert term.proc is proc - assert captured["kwargs"]["preexec_fn"] is terminal._terminal_shell_preexec_fn + assert "preexec_fn" not in captured["kwargs"], ( + "preexec_fn must not be set — the PR_SET_PDEATHSIG implementation " + "killed every Linux user's terminal (#2853). See module-level note." + ) assert captured["kwargs"]["start_new_session"] is True assert captured["kwargs"]["stdin"] == captured["kwargs"]["stdout"] == captured["kwargs"]["stderr"] finally: - terminal.close_terminal("term-preexec") + terminal.close_terminal("term-no-preexec") + + +@pytest.mark.skipif( + not hasattr(os, "openpty") or os.name != "posix", + reason="PTY-spawn test requires a POSIX host", +) +def test_pty_shell_survives_when_spawning_thread_exits(tmp_path): + """End-to-end regression for #2853. + + Spawn a real PTY shell via ``start_terminal`` from inside a worker thread + that then exits. The shell must remain alive after the spawning thread + joins, otherwise we've regressed back to the PR_SET_PDEATHSIG behaviour + that killed every Linux user's embedded terminal. + """ + sid = "term-thread-survival" + holder: dict = {} + + def worker(): + try: + holder["term"] = terminal.start_terminal(sid, tmp_path) + except Exception as exc: # pragma: no cover - surface in assertion + holder["error"] = exc + + t = threading.Thread(target=worker) + t.start() + t.join(timeout=5) + assert not t.is_alive(), "spawn worker thread should have exited" + assert "error" not in holder, holder.get("error") + term = holder["term"] + + try: + # Give the kernel a beat — if PR_SET_PDEATHSIG were re-introduced the + # shell would receive SIGTERM right about now. + time.sleep(0.5) + assert term.proc.poll() is None, ( + "PTY shell exited after the spawning thread joined — likely a " + "PR_SET_PDEATHSIG regression (#2853). " + f"exit_code={term.proc.poll()!r}" + ) + finally: + terminal.close_terminal(sid) def test_close_terminal_waits_again_after_sigkill(monkeypatch): @@ -96,8 +157,11 @@ def test_close_all_terminals_closes_snapshot(monkeypatch): def test_terminal_module_registers_graceful_shutdown_reaper(): + """atexit is still the reap path; pdeathsig must NOT be re-introduced.""" src = terminal.Path(terminal.__file__).read_text() assert "atexit.register(close_all_terminals)" in src - assert "preexec_fn=_terminal_shell_preexec_fn" in src - assert "libc.prctl(1, signal.SIGTERM)" in src + # The PR_SET_PDEATHSIG implementation broke every Linux user (#2853); + # guard against accidentally bringing it back. + assert "preexec_fn=_terminal_shell_preexec_fn" not in src + assert "libc.prctl(1, signal.SIGTERM)" not in src From 5d0d2bd0bfd6271a86be2a59b7ae0c5ea742411a Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 24 May 2026 16:54:49 +0000 Subject: [PATCH 46/68] fix(updates): apply path must follow check-side fall-through past the latest tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2846. After PR #2758 (the #2653 fix) the update check correctly falls through to the branch comparison when HEAD has moved past the latest `v*` tag — so the banner reports the real commit count against `origin/`. But `_select_apply_compare_ref` was never updated to mirror that decision: as long as any `v*` tag exists, it returns `tags[0]`, even when HEAD is far past it. Result for everyone running hermes-agent past `v2026.5.16` (i.e. anyone on agent master between tagged releases): 1. Banner: `Agent (origin/main): 254 updates available` ← correct 2. User clicks Update Now 3. `_select_apply_compare_ref` picks `v2026.5.16` because tags exist 4. `git pull --ff-only origin v2026.5.16` — no-op (HEAD is already past it) 5. `_schedule_restart()` fires anyway, server bounces 6. Next check still reports 254 behind — banner reappears unchanged `apply_force_update` had the same bug, except worse: `git reset --hard v2026.5.16` would have actively rewound the user's checkout 254 commits. The root cause is the same bug class as #2653 — two parallel paths (`_check_repo_release` and `_select_apply_compare_ref`) that should make the same decision but didn't. Pre-fix, the "is HEAD past the latest tag?" predicate lived inline inside `_check_repo_release` only. Fix --- Extract `_head_is_past_latest_tag(path, current_tag)` and have both paths consult it. When HEAD is past the latest tag: - check path: release check returns None → branch check runs (#2653, unchanged behaviour, just refactored) - apply path: falls through to upstream / `origin/`, never the stale tag (#2846, new behaviour) Tests ----- - `test_select_apply_compare_ref_uses_tag_when_head_is_on_tag` — unchanged behaviour pinned: HEAD exactly on tag → advance to tag. - `test_select_apply_compare_ref_falls_through_when_head_is_past_tag` — the #2846 repro: HEAD = v2026.5.16 + 608 commits → advance to `origin/main`, not the tag. - `test_select_apply_compare_ref_no_tags_uses_upstream` — unchanged. - `test_select_apply_compare_ref_no_tags_no_upstream_uses_default_branch` — unchanged. - `test_check_and_apply_paths_agree_when_head_is_past_tag` — symmetry test, ensures the two paths can't drift apart again. All 21 tests in `tests/test_updates.py` pass locally (16 existing + 5 new). Refs #2846, #2653. --- api/updates.py | 40 ++++++++++--- tests/test_updates.py | 128 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 9 deletions(-) diff --git a/api/updates.py b/api/updates.py index 2df4baf0..cc1fb53c 100644 --- a/api/updates.py +++ b/api/updates.py @@ -351,6 +351,21 @@ def _release_gap(tags, current, latest): return 1 +def _head_is_past_latest_tag(path, current_tag): + """Return True when HEAD has moved past the latest reachable release tag. + + `git describe --tags --always` returns the bare tag name (e.g. ``v2026.5.16``) + when HEAD is exactly on the tag, and a ``v2026.5.16-608-g1d22b9c2`` suffix + when HEAD has moved 608 commits past it. Used by both the update check and + the update apply path so they agree on which ref to advance to — see #2653 + (check side) and #2846 (apply side). + """ + if not current_tag: + return False + full_desc, ok = _run_git(['describe', '--tags', '--always'], path) + return bool(ok and full_desc and full_desc != current_tag) + + def _select_apply_compare_ref(path): """Return the same remote ref family that the update check reports. @@ -358,10 +373,20 @@ def _select_apply_compare_ref(path): an update must therefore advance to the latest release tag too; otherwise a checkout on a local/fork tracking branch can report release updates, pull a different branch that is already current, restart, and still remain behind. + + When HEAD is past the latest tag (the agent repo's day-to-day state between + tagged releases), the check side falls through to the branch comparison via + `_check_repo_release` returning None. The apply side must mirror that + decision — otherwise we run `git pull --ff-only ` against a + checkout that's already past the tag, no-op, restart, and the banner + re-appears with the same N commits available. See #2846. """ tags = _release_tags(path) if tags: - return tags[0] + latest_tag = tags[0] + # Mirror the predicate _check_repo_release uses to fall through. + if not _head_is_past_latest_tag(path, latest_tag): + return latest_tag upstream, ok = _run_git(['rev-parse', '--abbrev-ref', '@{upstream}'], path) if ok and upstream: @@ -384,14 +409,11 @@ def _check_repo_release(path, name): # If behind == 0 but HEAD has moved past the tag (e.g. the agent repo # keeps committing to master between tagged releases), the release check # would report "Up to date" even though hundreds of commits are missing. - # Detect this by comparing the short describe output (which includes the - # -N-gSHA suffix when HEAD is past a tag) against the bare tag name. - # When HEAD is ahead of the latest tag, fall through to _check_repo_branch - # so the real commit count is reported instead. See #2653. - if behind == 0: - full_desc, ok = _run_git(['describe', '--tags', '--always'], path) - if ok and full_desc and full_desc != current_tag: - return None + # Fall through to _check_repo_branch so the real commit count is reported + # instead. The same predicate is used by _select_apply_compare_ref so the + # check and apply sides cannot drift again. See #2653 (check), #2846 (apply). + if behind == 0 and _head_is_past_latest_tag(path, current_tag): + return None remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path) remote_url = _normalize_remote_url(remote_url) diff --git a/tests/test_updates.py b/tests/test_updates.py index 94c29e34..76da7593 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -419,3 +419,131 @@ def test_check_repo_branch_check_runs_for_post_tag_commits(tmp_path): assert info.get('release_based') is not True, ( 'post-tag HEAD should use branch check, not release-based check' ) + + +# --------------------------------------------------------------------------- +# Regression tests for #2846: _select_apply_compare_ref must mirror the +# check-side decision about whether to advance to the latest tag or to the +# upstream branch. Pre-fix, the check correctly fell through to the branch +# count when HEAD was past the latest tag, but apply still aimed at the tag — +# so clicking "Update Now" no-op'd, restarted the server, and the banner +# re-appeared with the same N commits. +# --------------------------------------------------------------------------- + + +def test_select_apply_compare_ref_uses_tag_when_head_is_on_tag(tmp_path): + """HEAD == latest tag → apply path advances to the tag (unchanged).""" + (tmp_path / '.git').mkdir() + + def fake_git(args, cwd, timeout=10): + if args == ['tag', '--list', 'v*', '--sort=-v:refname']: + return 'v2026.5.16\nv2026.5.10', True + if args == ['describe', '--tags', '--always']: + return 'v2026.5.16', True + raise AssertionError(f'unexpected git args: {args!r}') + + with patch.object(updates, '_run_git', side_effect=fake_git): + ref = updates._select_apply_compare_ref(tmp_path) + + assert ref == 'v2026.5.16' + + +def test_select_apply_compare_ref_falls_through_when_head_is_past_tag(tmp_path): + """HEAD past latest tag → apply path advances to origin/, not the tag. + + Mirrors the issue #2846 repro: hermes-agent has tag v2026.5.16, master is + 608 commits ahead, the banner correctly reports 608 commits available + (post-#2758), but pre-fix apply ran `git pull --ff-only v2026.5.16` — a + no-op — and the banner reappeared after restart. + """ + (tmp_path / '.git').mkdir() + + def fake_git(args, cwd, timeout=10): + if args == ['tag', '--list', 'v*', '--sort=-v:refname']: + return 'v2026.5.16', True + if args == ['describe', '--tags', '--always']: + return 'v2026.5.16-608-g1d22b9c2d', True + if args == ['rev-parse', '--abbrev-ref', '@{upstream}']: + return 'origin/main', True + raise AssertionError(f'unexpected git args: {args!r}') + + with patch.object(updates, '_run_git', side_effect=fake_git): + ref = updates._select_apply_compare_ref(tmp_path) + + assert ref == 'origin/main', ( + 'apply path must advance to the upstream branch when HEAD is past the ' + 'latest tag, otherwise Update Now no-ops and the banner loops (#2846)' + ) + + +def test_select_apply_compare_ref_no_tags_uses_upstream(tmp_path): + """No `v*` tags → apply path uses the configured upstream (unchanged).""" + (tmp_path / '.git').mkdir() + + def fake_git(args, cwd, timeout=10): + if args == ['tag', '--list', 'v*', '--sort=-v:refname']: + return '', True + if args == ['rev-parse', '--abbrev-ref', '@{upstream}']: + return 'origin/feat/foo', True + raise AssertionError(f'unexpected git args: {args!r}') + + with patch.object(updates, '_run_git', side_effect=fake_git): + ref = updates._select_apply_compare_ref(tmp_path) + + assert ref == 'origin/feat/foo' + + +def test_select_apply_compare_ref_no_tags_no_upstream_uses_default_branch(tmp_path): + """No tags and no upstream → fall back to origin/.""" + (tmp_path / '.git').mkdir() + + def fake_git(args, cwd, timeout=10): + if args == ['tag', '--list', 'v*', '--sort=-v:refname']: + return '', True + if args == ['rev-parse', '--abbrev-ref', '@{upstream}']: + return '', False + if args == ['symbolic-ref', 'refs/remotes/origin/HEAD']: + return 'refs/remotes/origin/main', True + raise AssertionError(f'unexpected git args: {args!r}') + + with patch.object(updates, '_run_git', side_effect=fake_git): + ref = updates._select_apply_compare_ref(tmp_path) + + assert ref == 'origin/main' + + +def test_check_and_apply_paths_agree_when_head_is_past_tag(tmp_path): + """Check and apply paths must agree: both fall through to origin/. + + The bug class in #2846 (and #2653 before it) was the two paths drifting + apart — check said "you're 608 behind origin/main", apply said "advance + to v2026.5.16". This test pins the symmetry so they can't drift again. + """ + (tmp_path / '.git').mkdir() + + def fake_git(args, cwd, timeout=10): + if args == ['tag', '--list', 'v*', '--sort=-v:refname']: + return 'v2026.5.16', True + if args == ['describe', '--tags', '--abbrev=0']: + return 'v2026.5.16', True + if args == ['describe', '--tags', '--always']: + return 'v2026.5.16-608-g1d22b9c2d', True + if args == ['rev-parse', '--abbrev-ref', '@{upstream}']: + return 'origin/main', True + return '', True + + with patch.object(updates, '_run_git', side_effect=fake_git): + check_result = updates._check_repo_release(tmp_path, 'agent') + apply_ref = updates._select_apply_compare_ref(tmp_path) + + # Check side falls through (release check returns None → branch check runs) + assert check_result is None, ( + '_check_repo_release should fall through when HEAD is past the latest ' + 'tag (#2653)' + ) + # Apply side picks the same branch the check would have reported against + assert apply_ref == 'origin/main', ( + '_select_apply_compare_ref must mirror the check-side fall-through ' + 'when HEAD is past the latest tag (#2846)' + ) + From 7be9a26018371581a6d17b3c53bd12d983e08d05 Mon Sep 17 00:00:00 2001 From: Rory Ford Date: Sat, 23 May 2026 12:02:17 +1000 Subject: [PATCH 47/68] =?UTF-8?q?feat:=20PATCH=20/api/mcp/servers/{name}?= =?UTF-8?q?=20=E2=80=94=20enable/disable=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `PATCH /api/mcp/servers/{name}` endpoint that accepts `{"enabled": bool}`, updates `mcp_servers..enabled` in config.yaml, and calls `reload_config()`. Mirrors the existing DELETE pattern. Also wire the previously-defined-but-unrouted `_handle_mcp_server_delete` into `handle_delete`, and `_handle_mcp_server_update` into a new `handle_put` + `do_PUT` in server.py — fixing a pre-existing bug where those handlers existed but were never reachable over HTTP. UI: add a toggle button in each MCP server row in the system settings panel (panels.js). Clicking it calls PATCH and reloads the list. Toggle button is styled with `.mcp-toggle-enabled` / `.mcp-toggle-disabled` CSS classes. The `toggle_supported` flag in the list response is now `True`. i18n: add 5 new keys (`mcp_enable_server`, `mcp_disable_server`, `mcp_enabled_toast`, `mcp_disabled_toast`, `mcp_toggle_failed`) to all 9 non-English locales (English values as placeholder translations). Tests: add `TestMcpToggle` class with 7 tests covering disable, enable, 404-not-found, empty name, missing field, response payload, and URL-encoded name. Update `test_empty_config` and visibility panel assertions to reflect `toggle_supported: True` and the new toggle button in panels.js. Co-Authored-By: Claude Sonnet 4.6 --- api/routes.py | 43 ++++++++++- server.py | 5 +- static/i18n.js | 50 +++++++++++++ static/panels.js | 20 ++++- static/style.css | 4 + tests/test_issue538_mcp_management.py | 83 ++++++++++++++++++++- tests/test_issue696_mcp_visibility_panel.py | 3 +- 7 files changed, 201 insertions(+), 7 deletions(-) diff --git a/api/routes.py b/api/routes.py index edef7ed5..63739194 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6229,6 +6229,9 @@ def handle_patch(handler, parsed) -> bool: if not _check_csrf(handler): return j(handler, {"error": _csrf_rejection_error(handler)}, status=403) body = read_body(handler) + if parsed.path.startswith("/api/mcp/servers/"): + name = parsed.path[len("/api/mcp/servers/"):] + return _handle_mcp_server_toggle(handler, name, body) if parsed.path.startswith("/api/kanban/"): from api.kanban_bridge import handle_kanban_patch @@ -6244,6 +6247,9 @@ def handle_delete(handler, parsed) -> bool: if not _check_csrf(handler): return j(handler, {"error": _csrf_rejection_error(handler)}, status=403) body = read_body(handler) + if parsed.path.startswith("/api/mcp/servers/"): + name = parsed.path[len("/api/mcp/servers/"):] + return _handle_mcp_server_delete(handler, name) if parsed.path.startswith("/api/kanban/"): from api.kanban_bridge import handle_kanban_delete @@ -6253,6 +6259,17 @@ def handle_delete(handler, parsed) -> bool: return True return False + +def handle_put(handler, parsed) -> bool: + """Handle all PUT routes. Returns True if handled, False for 404.""" + if not _check_csrf(handler): + return j(handler, {"error": "Cross-origin request rejected"}, status=403) + body = read_body(handler) + if parsed.path.startswith("/api/mcp/servers/"): + name = parsed.path[len("/api/mcp/servers/"):] + return _handle_mcp_server_update(handler, name, body) + return False + # ── GET route helpers ───────────────────────────────────────────────────────── # MIME types for static file serving. Hoisted to module scope to avoid @@ -11888,7 +11905,7 @@ def _handle_mcp_servers_list(handler): ] return j(handler, { "servers": result, - "toggle_supported": False, + "toggle_supported": True, "reload_required": True, }) @@ -11912,6 +11929,30 @@ def _handle_mcp_server_delete(handler, name): return j(handler, {"ok": True, "deleted": name}) +def _handle_mcp_server_toggle(handler, name, body): + """Toggle enabled state for an MCP server (PATCH /api/mcp/servers/{name}).""" + from urllib.parse import unquote + name = unquote(name) + if not name: + return bad(handler, "name is required") + if "enabled" not in body: + return bad(handler, "enabled field is required") + enabled = bool(body["enabled"]) + cfg = get_config() + servers = cfg.get("mcp_servers", {}) + if not isinstance(servers, dict): + servers = {} + if name not in servers: + return bad(handler, f"MCP server '{name}' not found", 404) + if not isinstance(servers[name], dict): + return bad(handler, f"MCP server '{name}' has invalid config", 400) + servers[name]["enabled"] = enabled + cfg["mcp_servers"] = servers + _save_yaml_config_file(_get_config_path(), cfg) + reload_config() + return j(handler, {"ok": True, "name": name, "enabled": enabled}) + + _MASKED_PLACEHOLDER = "••••••" diff --git a/server.py b/server.py index 3888afc5..7405d073 100644 --- a/server.py +++ b/server.py @@ -115,7 +115,7 @@ from api.auth import check_auth from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE from api.helpers import j, get_profile_cookie from api.profiles import set_request_profile, clear_request_profile -from api.routes import handle_delete, handle_get, handle_patch, handle_post +from api.routes import handle_delete, handle_get, handle_patch, handle_post, handle_put from api.startup import auto_install_agent_deps, fix_credential_permissions from api.updates import WEBUI_VERSION @@ -286,6 +286,9 @@ class Handler(BaseHTTPRequestHandler): def do_POST(self) -> None: self._handle_write(handle_post) + def do_PUT(self) -> None: + self._handle_write(handle_put) + def do_PATCH(self) -> None: self._handle_write(handle_patch) diff --git a/static/i18n.js b/static/i18n.js index 0f1f8867..5e54d9d6 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -87,6 +87,11 @@ const LOCALES = { mcp_tool_count: '{0} tools', mcp_enabled_yes: 'Enabled', mcp_enabled_no: 'Disabled', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'MCP Tools', mcp_tools_desc: 'Search known tools across active MCP servers.', mcp_tools_search_placeholder: 'Search tools by name, server, or description…', @@ -1325,6 +1330,11 @@ const LOCALES = { mcp_tool_count: '{0} strumenti', mcp_enabled_yes: 'Abilitato', mcp_enabled_no: 'Disabilitato', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'Strumenti MCP', mcp_tools_desc: 'Cerca strumenti noti tra i server MCP attivi.', mcp_tools_search_placeholder: 'Cerca strumenti per nome, server o descrizione…', @@ -2555,6 +2565,11 @@ const LOCALES = { mcp_tool_count: '{0} 個のツール', mcp_enabled_yes: '有効', mcp_enabled_no: '無効', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'MCP ツール', mcp_tools_desc: 'アクティブな MCP サーバー全体から既知のツールを検索します。', mcp_tools_search_placeholder: '名前、サーバー、説明でツールを検索…', @@ -3787,6 +3802,11 @@ const LOCALES = { mcp_tool_count: '{0} tools', mcp_enabled_yes: 'Enabled', mcp_enabled_no: 'Disabled', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'MCP Tools', mcp_tools_desc: 'Search known tools across active MCP servers.', mcp_tools_search_placeholder: 'Search tools by name, server, or description…', @@ -4951,6 +4971,11 @@ const LOCALES = { mcp_tool_count: '{0} tools', mcp_enabled_yes: 'Enabled', mcp_enabled_no: 'Disabled', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'MCP Tools', mcp_tools_desc: 'Search known tools across active MCP servers.', mcp_tools_search_placeholder: 'Search tools by name, server, or description…', @@ -6118,6 +6143,11 @@ const LOCALES = { mcp_tool_count: '{0} tools', mcp_enabled_yes: 'Enabled', mcp_enabled_no: 'Disabled', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'MCP Tools', mcp_tools_desc: 'Search known tools across active MCP servers.', mcp_tools_search_placeholder: 'Search tools by name, server, or description…', @@ -7289,6 +7319,11 @@ const LOCALES = { mcp_tool_count: '{0} 个工具', mcp_enabled_yes: '已启用', mcp_enabled_no: '已禁用', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'MCP 工具', mcp_tools_desc: '搜索活跃 MCP 服务器中的已知工具。', mcp_tools_search_placeholder: '按名称、服务器或描述搜索工具…', @@ -8447,6 +8482,11 @@ const LOCALES = { mcp_tool_count: '{0} tools', mcp_enabled_yes: 'Enabled', mcp_enabled_no: 'Disabled', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'MCP Tools', mcp_tools_desc: 'Search known tools across active MCP servers.', mcp_tools_search_placeholder: 'Search tools by name, server, or description…', @@ -10754,6 +10794,11 @@ const LOCALES = { mcp_tool_count: '{0} tools', mcp_enabled_yes: 'Enabled', mcp_enabled_no: 'Disabled', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'MCP Tools', mcp_tools_desc: 'Search known tools across active MCP servers.', mcp_tools_search_placeholder: 'Search tools by name, server, or description…', @@ -11981,6 +12026,11 @@ const LOCALES = { mcp_tool_count: '{0} outils', mcp_enabled_yes: 'Activé', mcp_enabled_no: 'Désactivé', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'Outils MCP', mcp_tools_desc: 'Recherchez des outils connus sur les serveurs MCP actifs.', mcp_tools_search_placeholder: 'Outils de recherche par nom, serveur ou description…', diff --git a/static/panels.js b/static/panels.js index fdeeb510..89ad0d20 100644 --- a/static/panels.js +++ b/static/panels.js @@ -7120,6 +7120,16 @@ function _mcpStatusLabel(status){ }[status]||'mcp_status_unknown'; return t(key); } +function toggleMcpServer(name, enabled){ + api('/api/mcp/servers/'+encodeURIComponent(name),{ + method:'PATCH', + body:JSON.stringify({enabled:enabled}), + }).then(r=>{ + if(r&&r.ok) showToast(t(enabled?'mcp_enabled_toast':'mcp_disabled_toast',name)); + else showToast(t('mcp_toggle_failed'),'error'); + loadMcpServers(); + }).catch(()=>{showToast(t('mcp_toggle_failed'),'error');loadMcpServers();}); +} function loadMcpServers(){ const list=$('mcpServerList'); if(!list) return; @@ -7130,7 +7140,6 @@ function loadMcpServers(){ list.innerHTML=`

    ${esc(t('mcp_no_servers'))}
    `; return; } - const toggleNote=r.toggle_supported?'':'
    '+esc(t('mcp_toggle_followup'))+'
    '; list.innerHTML=r.servers.map(s=>{ const transportLabel=s.transport==='http'?'HTTP':s.transport==='stdio'?'stdio':(''+(s.transport||'unknown')); const transportClass=s.transport==='http'?'mcp-http':s.transport==='stdio'?'mcp-stdio':'mcp-unknown'; @@ -7144,6 +7153,11 @@ function loadMcpServers(){ const envInfo=s.env?Object.entries(s.env).map(([k,v])=>`${k}=${v}`).join(', '):''; const headersInfo=s.headers?Object.entries(s.headers).map(([k,v])=>`${k}=${v}`).join(', '):''; const secretInfo=[envInfo,headersInfo].filter(Boolean).join(' | '); + const isEnabled=s.enabled!==false; + const encodedName=encodeURIComponent(s.name).replace(/'/g,"\\'"); + const toggleBtn=r.toggle_supported + ?`` + :`${esc(t(isEnabled?'mcp_enabled_yes':'mcp_enabled_no'))}`; return `
    ${esc(s.name)} @@ -7151,9 +7165,9 @@ function loadMcpServers(){ ${statusBadge}
    ${esc(detail)}${secretInfo?' | '+esc(secretInfo):''}
    -
    ${esc(t('mcp_tool_count',toolCount))}${esc(t(s.enabled===false?'mcp_enabled_no':'mcp_enabled_yes'))}
    +
    ${esc(t('mcp_tool_count',toolCount))}${toggleBtn}
    `; - }).join('')+toggleNote; + }).join(''); }).catch(()=>{list.innerHTML=`
    ${esc(t('mcp_load_failed'))}
    `}); } let _mcpToolsCache=[]; diff --git a/static/style.css b/static/style.css index f9b6094f..5566545a 100644 --- a/static/style.css +++ b/static/style.css @@ -2956,6 +2956,10 @@ main.main.showing-logs > #mainLogs{display:flex;} .mcp-status-disabled{background:rgba(161,161,170,.12);color:#a1a1aa;} .mcp-status-invalid_config,.mcp-status-unknown{background:rgba(239,68,68,.12);color:#f87171;} .mcp-tool-count{color:var(--text);} +.mcp-toggle-btn{font-size:10px;font-weight:600;padding:2px 8px;border-radius:999px;border:1px solid transparent;cursor:pointer;transition:opacity .15s;} +.mcp-toggle-btn:hover{opacity:.8;} +.mcp-toggle-enabled{background:rgba(34,197,94,.15);color:#4ade80;border-color:rgba(34,197,94,.3);} +.mcp-toggle-disabled{background:rgba(161,161,170,.12);color:#a1a1aa;border-color:rgba(161,161,170,.25);} .mcp-readonly-note,.mcp-restart-hint{margin-top:8px;color:var(--muted);font-size:11px;line-height:1.45;background:var(--code-bg);border:1px solid var(--border2);border-radius:6px;padding:8px 10px;} .mcp-tool-search{width:100%;margin:0 0 8px 0;padding:8px 10px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:8px;font-size:12px;outline:none;} .mcp-tool-search:focus{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-bg-soft);} diff --git a/tests/test_issue538_mcp_management.py b/tests/test_issue538_mcp_management.py index 758eff1a..77c3ea72 100644 --- a/tests/test_issue538_mcp_management.py +++ b/tests/test_issue538_mcp_management.py @@ -5,6 +5,7 @@ from api.routes import ( _handle_mcp_servers_list, _handle_mcp_server_update, _handle_mcp_server_delete, + _handle_mcp_server_toggle, _mask_secrets, _parse_mcp_enabled, _server_summary, @@ -60,7 +61,7 @@ class TestMcpList: assert status == 200 payload = _json_payload(h) assert payload['servers'] == [] - assert payload['toggle_supported'] is False + assert payload['toggle_supported'] is True assert payload['reload_required'] is True @patch('api.routes._mcp_runtime_status_by_name') @@ -307,3 +308,83 @@ class TestStripMaskedValues: def test_empty_dicts(self): assert _strip_masked_values({}, {}) == {} assert _strip_masked_values({"k": "v"}, {}) == {"k": "v"} + + +class TestMcpToggle: + """PATCH /api/mcp/servers/ — enable/disable.""" + + @patch('api.routes.reload_config') + @patch('api.routes._save_yaml_config_file') + @patch('api.routes._get_config_path', return_value='/tmp/test.yaml') + @patch('api.routes.get_config') + def test_disable_server(self, mock_cfg, mock_path, mock_save, mock_reload): + mock_cfg.return_value = {'mcp_servers': {'myserver': {'command': 'run'}}} + h = _make_handler() + h.command = 'PATCH' + _handle_mcp_server_toggle(h, 'myserver', {'enabled': False}) + assert mock_save.called + saved = mock_save.call_args[0][1] + assert saved['mcp_servers']['myserver']['enabled'] is False + assert mock_reload.called + + @patch('api.routes.reload_config') + @patch('api.routes._save_yaml_config_file') + @patch('api.routes._get_config_path', return_value='/tmp/test.yaml') + @patch('api.routes.get_config') + def test_enable_server(self, mock_cfg, mock_path, mock_save, mock_reload): + mock_cfg.return_value = {'mcp_servers': {'myserver': {'command': 'run', 'enabled': False}}} + h = _make_handler() + h.command = 'PATCH' + _handle_mcp_server_toggle(h, 'myserver', {'enabled': True}) + saved = mock_save.call_args[0][1] + assert saved['mcp_servers']['myserver']['enabled'] is True + + @patch('api.routes.get_config') + def test_nonexistent_server_returns_404(self, mock_cfg): + mock_cfg.return_value = {'mcp_servers': {}} + h = _make_handler() + h.command = 'PATCH' + _handle_mcp_server_toggle(h, 'ghost', {'enabled': True}) + status = h.send_response.call_args[0][0] + assert status == 404 + + def test_empty_name_rejected(self): + h = _make_handler() + h.command = 'PATCH' + _handle_mcp_server_toggle(h, '', {'enabled': True}) + status = h.send_response.call_args[0][0] + assert status == 400 + + def test_missing_enabled_field_rejected(self): + h = _make_handler() + h.command = 'PATCH' + _handle_mcp_server_toggle(h, 'myserver', {}) + status = h.send_response.call_args[0][0] + assert status == 400 + + @patch('api.routes.reload_config') + @patch('api.routes._save_yaml_config_file') + @patch('api.routes._get_config_path', return_value='/tmp/test.yaml') + @patch('api.routes.get_config') + def test_response_payload(self, mock_cfg, mock_path, mock_save, mock_reload): + mock_cfg.return_value = {'mcp_servers': {'srv': {'url': 'http://localhost'}}} + h = _make_handler() + h.command = 'PATCH' + _handle_mcp_server_toggle(h, 'srv', {'enabled': False}) + body = h.wfile.write.call_args[0][0] + payload = json.loads(body.decode('utf-8')) + assert payload == {'ok': True, 'name': 'srv', 'enabled': False} + + @patch('api.routes.reload_config') + @patch('api.routes._save_yaml_config_file') + @patch('api.routes._get_config_path', return_value='/tmp/test.yaml') + @patch('api.routes.get_config') + def test_url_encoded_name(self, mock_cfg, mock_path, mock_save, mock_reload): + """Names with special characters must be URL-decoded.""" + mock_cfg.return_value = {'mcp_servers': {'my server': {'command': 'x'}}} + h = _make_handler() + h.command = 'PATCH' + _handle_mcp_server_toggle(h, 'my%20server', {'enabled': False}) + saved = mock_save.call_args[0][1] + assert 'my server' in saved['mcp_servers'] + assert saved['mcp_servers']['my server']['enabled'] is False diff --git a/tests/test_issue696_mcp_visibility_panel.py b/tests/test_issue696_mcp_visibility_panel.py index 999192e5..3fa1cf43 100644 --- a/tests/test_issue696_mcp_visibility_panel.py +++ b/tests/test_issue696_mcp_visibility_panel.py @@ -24,7 +24,8 @@ def test_mcp_panel_renders_status_badges_tool_counts_and_empty_error_states(): assert "mcp-tool-count" in js assert "mcp-empty-state" in js assert "mcp-error-state" in js - assert "mcp_toggle_followup" in js + assert "toggleMcpServer" in js + assert "mcp-toggle-btn" in js assert "api('/api/mcp/servers')" in js assert "mcp-delete-btn" not in js assert "showMcpAddForm" not in js From c77936ff81d5cc8b2549c4b18873a3e103ce109b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?U=C4=9Fur=20Murat=20Alt=C4=B1ntas?= <100377859+vaur94@users.noreply.github.com> Date: Sat, 23 May 2026 00:21:25 +0300 Subject: [PATCH 48/68] feat(i18n): add Turkish (tr) locale support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a complete Turkish locale to the WebUI and login page so users can select Türkçe in Settings, with speech recognition via tr-TR. Co-authored-by: Cursor --- api/routes.py | 9 + static/i18n.js | 1217 +++++++++++++++++ tests/test_1560_password_env_var_no_op.py | 2 +- .../test_issue1488_composer_voice_buttons.py | 4 +- tests/test_issue2419_cache_usage_display.py | 4 +- tests/test_issue2462_theme_i18n.py | 2 +- tests/test_issue2679_hide_suggestions.py | 4 +- tests/test_pr1721_rtl_salvage.py | 4 +- tests/test_quota_chip_settings_toggle.py | 4 +- tests/test_sidebar_tab_visibility.py | 4 +- tests/test_turkish_locale.py | 146 ++ 11 files changed, 1386 insertions(+), 14 deletions(-) create mode 100644 tests/test_turkish_locale.py diff --git a/api/routes.py b/api/routes.py index 63739194..d55df8fa 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2561,6 +2561,15 @@ _LOGIN_LOCALE = { "invalid_pw": "\ube44\ubc00\ubc88\ud638\uac00 \uc62c\ubc14\ub974\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", "conn_failed": "\uc5f0\uacb0 \uc2e4\ud328", }, + "tr": { + "lang": "tr-TR", + "title": "Oturum a\u00e7", + "subtitle": "Devam etmek i\u00e7in \u015fifrenizi girin", + "placeholder": "\u015eifre", + "btn": "Oturum a\u00e7", + "invalid_pw": "Ge\u00e7ersiz \u015fifre", + "conn_failed": "Ba\u011flant\u0131 ba\u015far\u0131s\u0131z", + }, } diff --git a/static/i18n.js b/static/i18n.js index 5e54d9d6..867b8d58 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -13110,6 +13110,1223 @@ const LOCALES = { usage_total: 'Total de tokens', usage_unknown: 'inconnu', workspace_auto_create_folder: 'Créer le dossier s\'il n\'existe pas', + }, + + tr: { + + offline_title: 'Bağlantı kesildi', + offline_browser_detail: 'Tarayıcınız bu cihazın çevrimdışı olduğunu bildiriyor.', + offline_network_detail: 'Hermes\'e şu anda bu tarayıcıdan ulaşılamıyor.', + offline_autorefresh: 'Hermes\'e tekrar ulaşılabilir olduğunda bu sayfayı otomatik olarak yenileyeceğim.', + offline_check_now: 'Şimdi kontrol et', + offline_checking: 'Kontrol ediliyor\\u2026', + offline_stream_waiting: 'Bağlantı kesildi. Yenilenmesi bekleniyor\\u2026', + _lang: 'tr', + _label: 'Türkçe', + _speech: 'tr-TR', + // boot.js + cancelling: 'İptal ediliyor\\u2026', + cancel_failed: 'İptal başarısız oldu:', + mic_denied: 'Mikrofon erişimi reddedildi. Tarayıcı izinlerini kontrol edin.', + mic_no_speech: 'Konuşma algılanmadı. Tekrar deneyin.', + mic_network: 'Konuşma tanıma kullanılamıyor.', + mic_error: 'Ses girişi hatası:', + session_imported: 'Oturum içe aktarıldı', + import_failed: 'İçe aktarma başarısız oldu:', + import_invalid_json: 'Geçersiz JSON', + image_pasted: 'Yapıştırılan resim:', + // messages.js + edit_message: 'Mesajı düzenle', + regenerate: 'Yanıtı yeniden oluştur', + copy: 'Kopyala', + copied: 'Kopyalandı!', + copy_failed: 'Kopyalama başarısız oldu', + selected_text_reply: 'Seçimle yanıtla', + selected_text_reply_title: 'Seçilen sohbet metnini alıntılanan bağlam olarak ekle', + selected_text_reply_appended: 'Seçilen metin besteciye eklendi', + + diff_loading: 'Fark yükleniyor', + diff_error: 'Yama dosyası yüklenemedi', + diff_too_large: 'Yama dosyası satır içi görüntülenemeyecek kadar büyük', + tree_view: 'Ağaç', + raw_view: 'Çiğ', + parse_failed_note: 'ayrıştırma başarısız oldu', + you: 'Sen', + mcp_servers_title: 'MCP Sunucuları', + mcp_servers_desc: 'config.yaml dosyasında yapılandırılmış MCP sunucularını görüntüleyin.', + mcp_no_servers: 'Hiçbir MCP sunucusu yapılandırılmadı.', + mcp_add_server: '+ Sunucu Ekle', + mcp_field_name: 'Sunucu Adı', + mcp_transport_label: 'Taşıma Türü', + mcp_field_command: 'Emretmek', + mcp_field_args: 'Bağımsız Değişkenler (virgülle ayrılmış)', + mcp_field_url: 'URL\'si', + mcp_field_timeout: 'Zaman aşımı (saniye)', + mcp_save: 'Kaydetmek', + mcp_cancel: 'İptal etmek', + mcp_name_required: 'Sunucu adı gerekli.', + mcp_url_required: 'HTTP aktarımı için URL gereklidir.', + mcp_command_required: 'Stdio aktarımı için komut gereklidir.', + mcp_saved: 'MCP sunucusu kaydedildi.', + mcp_save_failed: 'MCP sunucusu kaydedilemedi.', + mcp_delete_confirm_title: 'MCP Sunucusunu Sil', + mcp_delete_confirm_message: '"{0}" MCP sunucusu silinsin mi? Bu eylem geri alınamaz.', + mcp_deleted: 'MCP sunucusu silindi.', + mcp_delete_failed: 'MCP sunucusu silinemedi.', + mcp_load_failed: 'MCP sunucuları yüklenemedi.', + mcp_restart_hint: 'Sunucu değişiklikleri şimdilik burada salt okunurdur. Değişikliklerin etkili olması için config.yaml dosyasını düzenleyin ve Hermes\'i yeniden başlatın.', + mcp_toggle_followup: 'Etkinleştirme/devre dışı bırakma kontrolleri, MCP yeniden yükleme semantiği açıkça ortaya çıkana kadar kasıtlı olarak ertelenir.', + mcp_status_active: 'Aktif', + mcp_status_configured: 'Yapılandırılmış', + mcp_status_disabled: 'Engelli', + mcp_status_invalid_config: 'Geçersiz yapılandırma', + mcp_status_unknown: 'Bilinmiyor', + mcp_tool_count: '{0} araç', + mcp_enabled_yes: 'Etkinleştirilmiş', + mcp_enabled_no: 'Engelli', + mcp_tools_title: 'MCP Araçları', + mcp_tools_desc: 'Aktif MCP sunucularında bilinen araçları arayın.', + mcp_tools_search_placeholder: 'Araçlarda ada, sunucuya veya açıklamaya göre arama yapın\\u2026', + mcp_tools_no_tools: 'Etkin çalışma zamanı envanterinde hiçbir MCP aracı mevcut değil.', + mcp_tools_no_matches: 'Aramanızla eşleşen MCP aracı yok.', + mcp_tools_load_failed: 'MCP araçları yüklenemedi.', + mcp_tools_schema_empty: 'Şema parametresi yok.', + mcp_tools_runtime_note: 'Araç envanteri yalnızca önceden bilinen etkin MCP çalışma zamanı verilerini kullanır; WebUI sunucuları başlatmaz veya sorgulamaz.', + mcp_tools_summary_no_matches: (query,total) => `“${query}” ile eşleşen MCP aracı yok (toplam ${total} MCP araç).`, + mcp_tools_summary_none: 'Gösterilecek MCP aracı yok.', + mcp_tools_summary_matching: (query) => `"${query}" ile eşleşen`, + mcp_tools_summary_total_note: (total) => `(${total} toplam MCP araç)`, + mcp_tools_summary_showing: (start,end,filtered,searchNote,totalNote,page,pages) => `${start}-${end} / ${filtered} MCPtools${searchNote}${totalNote} gösteriliyor. Sayfa ${page} / ${pages}.`, + mcp_tools_page_size_prefix: 'Göstermek', + mcp_tools_page_size_suffix: 'sayfa başına', + mcp_tools_per_page_aria: 'Sayfa başına MCP araçları', + mcp_tools_inactive_configured_servers: (servers) => `Yapılandırılmış ancak bu WebUI çalışma zamanında etkin değil: ${servers}.`, + mcp_tools_pagination_label: 'MCP araçları sayfalandırması', + mcp_tools_previous_page: '‹ Önceki', + mcp_tools_previous_page_aria: 'Önceki MCP araçları sayfası', + mcp_tools_next_page: 'Sonraki >', + mcp_tools_next_page_aria: 'Sonraki MCP araçları sayfası', + thinking: 'Düşünme', + expand_all: 'Tümünü genişlet', + collapse_all: 'Tümünü daralt', + edit_failed: 'Düzenleme başarısız oldu:', + regen_failed: 'Yeniden oluşturma başarısız oldu:', + reconnect_active: 'Halen bir yanıt oluşturuluyor. Hazır olduğunuzda yeniden yüklensin mi?', + reconnect_finished: 'En son ayrıldığınızda bir yanıt sürüyordu. Mesajlar güncellenmiş olabilir.', + // approval card + approval_heading: 'Onay gerekli', + approval_desc_prefix: 'Tehlikeli komut algılandı', + approval_btn_once: 'Bir kez izin ver', + approval_btn_once_title: 'Bu tek komuta izin ver (Enter)', + approval_btn_session: 'Oturuma izin ver', + approval_btn_session_title: 'Bu konuşma oturumuna izin ver', + approval_btn_always: 'Her zaman izin ver', + approval_btn_always_title: 'Bu komut düzenine her zaman izin ver', + approval_btn_deny: 'Reddetmek', + approval_btn_deny_title: 'Reddet — bu komutu çalıştırma', + approval_responding: 'Yanıt veriliyor\\u2026', + clarify_heading: 'Açıklama gerekli', + clarify_hint: 'Bir seçim yapın veya kendi cevabınızı aşağıya yazın.', + clarify_other: 'Diğer', + clarify_send: 'Göndermek', + clarify_input_placeholder: 'Yanıtınızı yazın\\u2026', + clarify_responding: 'Yanıt veriliyor\\u2026', + untitled: 'İsimsiz', + n_messages: (n) => `${n}개 메시지`, + load_older_messages: '↑ Eski mesajları yüklemek için yukarı kaydırın veya tıklayın', + session_jump_start: 'Başlangıç', + session_jump_start_label: 'Oturumun başlangıcına atla', + session_jump_end: 'Son', + session_jump_end_label: 'Oturumun sonuna atla', + jump_to_question: 'sorgulamak', + jump_to_question_label: 'Bu yanıt için soruya geçin', + queued_label: 'Yanıttan sonra gönderilir', + queued_count: (n) => n === 1 ? '1 queued' : `${n} queued`, + queued_cancel: 'Sıraya alınmış mesajı iptal et', + model_unavailable: '(mevcut değil)', + model_unavailable_title: 'Bu model artık mevcut sağlayıcı listenizde değil', + provider_mismatch_warning: (m,p) => `"${m}" yapılandırılmış sağlayıcınızla (${p}) çalışmayabilir. Yine de gönderin veya geçiş yapmak için terminalinizde \`hermes model\` komutunu çalıştırın.`, + provider_mismatch_label: 'Sağlayıcı uyumsuzluğu', + model_not_found_label: 'Model bulunamadı', + model_custom_label: 'Özel model kimliği', + model_custom_placeholder: 'örneğin openai/gpt-5.4', + model_search_placeholder: 'Modelleri ara\\u2026', + session_toolsets: 'Session Toolsets', // TODO: translate + session_toolsets_desc: 'Restrict available tools for this session (blank = use global config)', // TODO: translate + session_toolsets_global: 'Global (default)', // TODO: translate + session_toolsets_custom: 'Custom', // TODO: translate + session_toolsets_placeholder: 'tool1, tool2, \u2026', // TODO: translate + session_toolsets_apply: 'Apply', // TODO: translate + session_toolsets_clear: 'Clear (use global)', // TODO: translate + session_toolsets_applied: 'Toolsets updated', // TODO: translate + session_toolsets_cleared: 'Toolsets cleared — using global config', // TODO: translate + session_toolsets_failed: 'Failed to update toolsets: ', // TODO: translate + model_search_no_results: 'Hiçbir model bulunamadı', + model_group_configured: 'Yapılandırılmış', + ws_search_placeholder: 'Çalışma alanlarını arayın\\u2026', + ws_no_results: 'Çalışma alanı bulunamadı', + workspace_new_worktree_conversation: 'Worktree\'de yeni konuşma', + workspace_new_worktree_conversation_meta: 'Bu çalışma alanı için yalıtılmış bir git çalışma ağacı oluşturun.', + workspace_worktree_created: 'Worktree görüşmesi oluşturuldu', + workspace_worktree_failed: 'Çalışma ağacı oluşturma işlemi başarısız oldu:', + session_worktree_badge: 'Çalışma Ağacı', + model_scope_advisory: 'Bir sonraki mesajınızdan itibaren bu görüşmeye uygulanır.', + model_scope_toast: 'Bir sonraki mesajınızdan itibaren bu görüşmeye uygulanır.', + // commands.js + cmd_clear: 'Konuşma mesajlarını temizle', + cmd_compress: 'Konuşma içeriğini manuel olarak sıkıştırın (kullanım: /compress [konuya odaklan])', + ctx_compress_hint: 'Yer açmak için bağlamı sıkıştırın →', + ctx_compress_action: '⚠ Şimdi serbest bağlama sıkıştırın', + cmd_compact_alias: '/compress için eski takma ad', + cmd_model: 'Modeli değiştir (ör. /model gpt-4o)', + cmd_workspace: 'Çalışma alanını ada göre değiştir', + cmd_terminal: 'Çalışma alanı terminalini açın', + cmd_new: 'Yeni bir sohbet oturumu başlatın', + cmd_usage: 'Token kullanımı ekranını aç/kapat', + cmd_theme: 'Görünümü değiştir (tema: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast)', + cmd_personality: 'Temsilci kişiliğini değiştir', + cmd_skills: 'Mevcut Hermes becerilerini listele', + available_commands: 'Mevcut komutlar:', + type_slash: 'Komutları görmek için / yazın', + conversation_cleared: 'Görüşme temizlendi', + command_label: 'Emretmek', + context_compaction_label: 'Bağlam sıkıştırma', + retrieval_context_label: 'Dizine alınmış bağlam', + retrieval_context_preview: 'Daha önceki mesajlar depolanır ve bağlam araçlarıyla alınabilir', + preserved_task_list_label: 'Korunmuş görev listesi', + reference_only_label: 'Yalnızca referans', + model_usage: 'Kullanım: /model ', + no_model_match: 'Eşleşen model yok"', + switched_to: 'Şuraya geçildi:', + workspace_usage: 'Kullanım: /workspace ', + no_workspace_match: 'Eşleşen çalışma alanı yok "', + switched_workspace: 'Çalışma alanına geçildi:', + workspace_switch_failed: 'Çalışma alanı anahtarı başarısız oldu:', + new_session: 'Yeni oturum oluşturuldu', + new_session_creating: 'Yeni görüşme oluşturuluyor\\u2026', + compressing: 'Bağlam sıkıştırması isteniyor...', + compress_running_label: 'Sıkıştırma', + compress_complete_label: 'Sıkıştırma tamamlandı', + auto_compress_label: 'Otomatik sıkıştırma', + compress_failed_label: 'Sıkıştırma başarısız oldu', + focus_label: 'Odak', + token_usage_on: 'Jeton kullanımı açık', + token_usage_off: 'Jeton kullanımı kapalı', + usage_cache_hit_detail: 'Önbellek: %{0} isabet ({1} okuma / {2} yazma)', + usage_cached_percent: '%{0} önbelleğe alındı', + theme_usage: 'Kullanım: /tema', + theme_set: 'Tema:', + no_active_session: 'Aktif oturum yok', + cmd_queue: 'Bir sonraki dönüş için bir mesajı sıraya koy', + cmd_goal: 'Kalıcı bir hedef belirleyin veya inceleyin', + goal_evaluating_progress: 'Hedef ilerlemesi değerlendiriliyor\\u2026', + goal_working_toward: 'Hedefe doğru çalışmak\\u2026', + goal_continuing_toast: 'Hedefe doğru devam\\u2026', + goal_status_none: 'Aktif hedef yok. /goal ile bir tane ayarlayın.', + goal_status_active: (turns, max_turns, goal) => `⊙ Hedef (aktif, ${turns}/${max_turns} tur): ${goal}`, + goal_status_paused: (turns, max_turns, reason, goal) => `⏸ Hedef (duraklatıldı, ${turns}/${max_turns}${reason ? `, ${reason}` : ''}): ${goal}`, + goal_status_done: (turns, max_turns, goal) => `✓ Hedef tamamlandı (${turns}/${max_turns}): ${goal}`, + goal_set: (turns, goal) => `⊙ Hedef belirlendi (${turns}-dönüş bütçesi): ${goal}`, + goal_paused: (goal) => `⏸ Hedef duraklatıldı: ${goal}`, + goal_resumed: (goal) => `▶ Hedef devam ettirildi: ${goal}`, + goal_cleared: 'Hedef temizlendi.', + goal_no_goal: 'Aktif hedef yok.', + goal_achieved: (reason) => `✓ Hedefe ulaşıldı: ${reason}`, + goal_paused_budget_exhausted: (turns, max_turns) => `⏸ Hedef duraklatıldı — ${turns}/${max_turns} tur kullanıldı. Devam etmek için /goal devamını, durdurmak için /goal clear komutunu kullanın.`, + goal_continuing: (turns, max_turns, reason) => `↻ Hedefe doğru devam ediyoruz (${turns}/${max_turns}): ${reason}`, + cmd_interrupt: 'Mevcut dönüşü iptal et ve yeni bir mesaj gönder', + cmd_steer: 'Temsilciyi kesintiye uğratmadan dönüş ortası düzeltmesi enjekte edin', + cmd_queue_no_msg: 'Kullanım: /queue ', + cmd_queue_not_busy: 'Etkin görev yok; yalnızca normal şekilde gönderin', + cmd_queue_confirm: 'İleti sıraya alındı', + cmd_interrupt_no_msg: 'Kullanım: /interrupt ', + cmd_interrupt_confirm: 'Kesintiye uğradı — yeni mesaj gönderilir', + cmd_steer_no_msg: 'Kullanım: /steer ', + cmd_steer_fallback: 'Yönlendirme kullanılamıyor - bunun yerine bir sonraki dönüş için sıraya alındı', + cmd_steer_delivered: 'Yönlendirme teslim edildi — temsilci bunu bir sonraki takım sonucunda görecek', + steer_leftover_queued: 'Direksiyon bir sonraki dönüş için kuyruğa alındı', + busy_steer_fallback: 'Yönlendirme kullanılamıyor — bir sonraki dönüş için kuyruğa alındı', + busy_interrupt_confirm: 'Kesintiye uğradı — yeni mesaj gönderilir', + settings_label_busy_input_mode: 'Meşgul giriş modu', + settings_desc_busy_input_mode: 'Aracı çalışırken bir mesaj gönderdiğinizde ne olacağını kontrol eder. Sıra bekler; Interrupt iptal eder ve yeniden başlar; Steer, dönüşün ortasında kesintiye uğramadan bir düzeltme enjekte eder (aracı veya akış mevcut olmadığında kuyruğa geri döner).', + settings_label_fade_text_effect: 'Metin efektini soldur', + settings_desc_fade_text_effect: 'Asistan yanıt verirken yeni aktarılan sözcüklerin geçişini sağlayın. OpenWebUI\'ye benzer; Maksimum performans için varsayılan olarak kapalıdır.', + settings_busy_input_mode_queue: 'Sıra takibi', + settings_busy_input_mode_interrupt: 'Mevcut dönüşü kes', + settings_busy_input_mode_steer: 'Yönlendirme (dönüş ortası düzeltme)', + + slash_skill_badge: 'Yetenek', + slash_skill_desc: 'Bu beceriyi çağır', + cmd_stop: 'Mevcut yanıtı durdur', + cmd_title: 'Oturum başlığını alın veya ayarlayın', + cmd_retry: 'Son mesajı tekrar gönder', + cmd_undo: 'Son değişimi kaldır', + cmd_btw: 'Bir yan soru sorun (geçici)', + cmd_btw_usage: '/btw — oturum bağlamını kullanarak bir yan soru sorun', + cmd_background: 'Arka planda bir istem çalıştır', + cmd_background_usage: '/background — engellemeden paralel olarak çalıştır', + btw_asking: 'Yan soruyu sormak...', + btw_label: 'Yan soru – tarihte değil', + btw_done: 'Yan soru cevaplandı', + btw_no_answer: 'Cevap alınmadı.', + btw_failed: 'Yan soru başarısız oldu:', + bg_running: 'Arka planda çalışıyor...', + bg_complete: 'Arka plan görevi tamamlandı', + bg_label: 'Arka plan sonucu:', + bg_no_answer: '(cevap yok)', + bg_failed: 'Arka plan görevi başarısız oldu:', + undo_exchange: 'Son değişimi geri al', + cmd_status: 'Oturum bilgilerini göster', + cmd_voice: 'Mikrofon girişini değiştir', + stream_stopped: 'Yanıt durduruldu.', + no_active_task: 'Durdurulacak etkin görev yok.', + cancel_unavailable: 'İptal kullanılamıyor.', + retry_failed: 'Yeniden deneme başarısız oldu:', + undo_failed: 'Geri alma başarısız oldu:', + undid_n_messages: 'Kaldırıldı', + undid_messages_suffix: 'mesaj(lar).', + status_heading: 'Oturum Durumu', + status_session_id: 'Oturum Kimliği', + status_title: 'Başlık', + status_model: 'Modeli', + status_provider: 'sağlayıcı', + status_workspace: 'Çalışma alanı', + status_personality: 'Kişilik', + status_messages: 'Mesajlar', + status_agent_running: 'Aracı çalışıyor', + status_profile: 'Profil', + status_hermes_home: 'Hermes\'in evi', + status_started: 'Başlatıldı', + status_tokens: 'Jetonlar', + status_updated: 'Güncellendi', + status_ephemeral: 'Geçici anlık görüntü — transkript geçmişine kaydedilmez.', + status_no_tokens: 'Hiç jeton kullanılmadı', + status_unknown: 'Bilinmiyor', + status_yes: 'Evet', + status_no: 'HAYIR', + status_load_failed: 'Durum yüklenemedi:', + title_current: 'Mevcut başlık', + title_change_hint: 'Yeniden adlandırmak için `/title ` kullanın.', + title_set: 'Başlık şu şekilde ayarlandı:', + cmd_webui_only_session: 'Bu komut CLI\'den içe aktarılan oturumlar için kullanılamaz.', + cmd_voice_use_mic: 'Bestecideki mikrofon düğmesine tıklayın.', + usage_heading: 'Jeton Kullanımı', + usage_default_model: 'varsayılan', + usage_unknown: 'bilinmiyor', + usage_input_tokens: 'Giriş jetonları', + usage_output_tokens: 'Çıkış jetonları', + usage_total: 'Toplam jeton', + usage_estimated_cost: 'Tahmini maliyet', + usage_settings_tip: 'Not: Maliyet tahminleri yaklaşık değerlerdir.', + usage_load_failed: 'Kullanım yüklenemedi:', + usage_personality_none: 'hiçbiri', + no_personalities: 'Hiç kişilik bulunamadı (onları ~/.hermes/personalities/ dosyasına ekleyin)', + available_personalities: 'Mevcut kişilikler:', + personality_switch_hint: 'Geçiş yapmak için `/kişilik ` kullanın veya temizlemek için `/kişilik yok` kullanın.', + personalities_load_failed: 'Kişilikler yüklenemedi', + personality_cleared: 'Kişilik temizlendi', + personality_set: 'Kişilik:', + failed_colon: 'Arızalı:', + // ui.js + no_workspace: 'Çalışma alanı yok', + terminal_open_title: 'Çalışma alanı terminalini aç', + terminal_no_workspace_title: 'Terminali açmak için bir çalışma alanı seçin', + terminal_title: 'terminal', + terminal_clear: 'Temizle', + terminal_copy_output: 'Çıktıyı kopyala', + terminal_restart: 'Tekrar başlat', + terminal_collapse: 'Yıkılmak', + terminal_expand: 'Genişletmek', + terminal_close: 'Kapalı', + terminal_input_placeholder: 'Bir komut çalıştır...', + terminal_start_failed: 'Terminal başlatma başarısız oldu:', + terminal_input_failed: 'Terminal girişi başarısız oldu:', + terminal_copy_failed: 'Kopyalama başarısız oldu:', + terminal_error: 'Terminal hatası', + workspace_empty_no_path: 'Çalışma alanı seçilmedi. Dosyalara göz atmak için Ayarlar \\u2192 Çalışma Alanı\'nda bir çalışma alanı ayarlayın.', + workspace_empty_dir: 'Bu çalışma alanı boş.', + workspace_show_hidden_files: 'Gizli dosyaları göster', + workspace_show_hidden_files_desc: 'Dosya ağacına .DS_Store, .git, node_modules ve diğer gizli / sistem dosyalarını ekleyin.', + workspace_hidden_files_visible: 'gizli görünür', + workspace_hidden_files_visible_title: 'Gizli dosyalar görünür — seçenekler için tıklayın', + workspace_options: 'Çalışma alanı seçenekleri', + dialog_confirm_title: 'İşlemi onayla', + dialog_prompt_title: 'Bir değer girin', + dialog_confirm_btn: 'Onaylamak', + // workspace.js + unsaved_confirm: 'Önizlemede kaydedilmemiş değişiklikleriniz var. Atılıp gezinilsin mi?', + discard: 'At', + save: 'Kaydetmek', + edit: 'Düzenlemek', + clear: 'Temizle', + create: 'Yaratmak', + remove: 'Kaldırmak', + save_title: 'Değişiklikleri kaydet', + edit_title: 'Bu dosyayı düzenle', + saved: 'Kaydedildi', + save_failed: 'Kaydetme başarısız oldu:', + image_load_failed: 'Resim yüklenemedi', + file_open_failed: 'Dosya açılamadı', + downloading: (name) => `${name} indiriliyor\u2026`, + double_click_rename: 'Yeniden adlandırmak için çift tıklayın', + renamed_to: 'Yeniden adlandırıldı', + rename_failed: 'Yeniden adlandırma başarısız oldu:', + delete_title: 'Silmek', + delete_confirm: (name) => `${name}을(를) nerelisin?`, + delete_dir_confirm: (name) => `"${name}" ne işe yaradı?`, + rename_title: 'Yeniden isimlendirmek', + rename_prompt: 'Yeni isim:', + deleted: 'Silindi', + delete_failed: 'Silinemedi:', + reveal_in_finder: 'Dosya Yöneticisinde Göster', + reveal_failed: 'Açıklanamadı:', + copy_file_path: 'Dosya yolunu kopyala', + download_folder: 'Download Folder', // TODO: translate + path_copied: 'Dosya yolu panoya kopyalandı', + path_copy_failed: 'Yol kopyalanamadı:', + session_rename: 'Konuşmayı yeniden adlandır', + session_rename_desc: 'Bu görüşmenin başlığını düzenleyin', + new_file_prompt: 'Yeni dosya adı (örn. Notes.md):', + project_name_prompt: 'Proje adı:', + created: 'Oluşturuldu', + create_failed: 'Oluşturma başarısız oldu:', + new_folder_prompt: 'Yeni klasör adı:', + folder_created: 'Oluşturulan klasör', + folder_create_failed: 'Klasör oluşturulamadı:', + workspace_auto_create_folder: 'Mevcut değilse klasör oluşturun', + folder_add_as_space_btn: 'Alan Olarak Ekle', + folder_add_as_space_msg: 'Bu klasör çalışma alanı listenize yeni bir alan olarak eklensin mi?', + archive_extracted: (n, c) => `${c}개 압축 파일에서 ${n}개 파일 압축 해제됨`, + folder_add_as_space_title: 'Alan olarak eklensin mi?', + remove_title: 'Kaldırmak', + empty_dir: '(boş)', + upload_failed: 'Yükleme başarısız oldu:', + upload_too_large: (maxMb, fileMb) => `Dosya çok büyük (${fileMb} MB). Maksimum yükleme boyutu ${maxMb} MB'tır.`, + all_uploads_failed: (n) => `${n} yüklemenin tümü başarısız oldu`, + session_pin: 'Görüşmeyi sabitle', + session_unpin: 'Görüşmenin sabitlemesini kaldır', + session_pin_desc: 'Bu sohbeti en üstte tut', + session_unpin_desc: 'Sabitlenenlerden kaldır', + session_pin_failed: 'Pin başarısız oldu:', + session_move_project: 'Projeye taşı', + session_move_project_desc_has: 'Bu görüşmenin projesini değiştirin', + session_move_project_desc_none: 'Bu görüşmeye bir proje atayın', + session_archive: 'Konuşmayı arşivle', + session_hide_external: 'Listeden gizle', + session_restore: 'Konuşmayı geri yükle', + session_archive_desc: 'Arşivlendi gösterilene kadar bu konuşmayı gizle', + session_archive_worktree_desc: 'Bu konuşmayı gizle; çalışma ağacını diskte tut', + session_hide_external_desc: 'Kaynak geçmişini silmeden içe aktarılan bu oturumu WebUI\'dan gizleyin.', + session_restore_desc: 'Bu görüşmeyi ana listeye geri getir', + session_archived: 'Oturum arşivlendi', + session_archived_worktree: 'Oturum arşivlendi. Worktree diskte kalır.', + session_hidden: 'Oturum listede gizlendi', + session_restored: 'Oturum geri yüklendi', + session_archive_failed: 'Arşivleme başarısız oldu:', + session_duplicate: 'Yinelenen görüşme', + session_duplicate_desc: 'Aynı çalışma alanı ve modelle bir kopya oluşturun', + session_duplicated: 'Oturum kopyalandı', + session_duplicate_failed: 'Kopyalama başarısız oldu:', + session_stop_response: 'Yanıtı durdur', + session_stop_response_desc: 'Bu görüşme için çalışan yanıtı iptal edin', + session_delete: 'Konuşmayı sil', + session_delete_desc: 'Bu görüşmeyi kalıcı olarak kaldır', + session_delete_confirm: 'Bu görüşme silinsin mi?', + session_delete_worktree_desc: 'Yalnızca WebUI görüşmesini silin; çalışma ağacını diskte tut', + session_delete_worktree_confirm: (path) => `Ne düşünüyorsunuz? ${path}의 worktree는 디스크에 남아 있습니다.`, + session_deleted: 'Görüşme silindi', + session_deleted_worktree: 'Görüşme silindi. Worktree diskte kalır.', + session_worktree_remove: 'Çalışma ağacını kaldır', + session_worktree_remove_desc: (path) => `${path}의 git worktree를 디스크에서 삭제합니다`, + session_worktree_remove_confirm: (path) => `git worktree를 디스크에서 삭제하시겠습니까?\n\n경로: ${path}\n\n전체 worktree 디렉토리가 삭제됩니다. WebUI에 보존됩니다.`, + session_worktree_remove_not_exists: (path) => `${path}의 worktree가 디스크에 더 이상 존재하지 않습니다.`, + session_worktree_remove_confirm_label: 'Kaldırmak', + session_worktree_removed: 'Worktree kaldırıldı.', + session_worktree_remove_failed: 'Çalışma ağacı kaldırılamadı:', + session_worktree_remove_status_failed: 'Çalışma ağacı durumu okunamadı:', + session_worktree_remove_locked_by_stream: 'Kaldırılamıyor — etkin bir akış oturumu bu çalışma ağacını kullanıyor.', + session_worktree_remove_locked_by_terminal: 'Kaldırılamıyor — etkin bir terminal oturumu bu çalışma ağacını kullanıyor.', + session_worktree_remove_unsafe_blocked: 'Bu çalışma ağacını kaldırmadan önce yerel değişiklikleri veya gönderilmemiş taahhütleri çözümleyin.', + session_worktree_remove_dirty_warning: 'UYARI: Bu çalışma ağacında kaybolacak kaydedilmemiş değişiklikler var.', + session_worktree_remove_untracked_warning: (count) => `${count}개의 추적되지 않은 파일이 영구적으로 삭제됩니다.`, + session_worktree_remove_ahead_warning: (ahead) => `${ahead} 푸시되지 않은 커밋이 손실됩니다.`, + session_select_mode: 'Seçme', + session_select_mode_desc: 'Toplu olarak yönetilecek konuşmaları seçin', + session_select_all: 'Tümünü seç', + session_deselect_all: 'Tümünün seçimini kaldır', + session_selected_count: '{0} seçildi', + session_batch_archive: 'Arşiv', + session_batch_delete: 'Silmek', + session_batch_move: 'Projeye taşı', + session_batch_delete_confirm: '{0} görüşme silinsin mi?', + session_batch_archive_confirm: '{0} ileti dizisi arşivlensin mi?', + session_batch_delete_worktree_confirm: '{0} görüşme silinsin mi? {1} çalışma ağacı destekli konuşma, çalışma ağacı dizinlerini diskte bırakacak.', + session_batch_archive_worktree_confirm: '{0} ileti dizisi arşivlensin mi? {1} çalışma ağacı destekli görüşme, çalışma ağacı dizinlerini diskte tutacak.', + session_no_selection: 'Hiçbir görüşme seçilmedi', + // settings panel + settings_heading_title: 'Kontrol Merkezi', + settings_heading_subtitle: 'Tercihler, konuşma araçları ve sistem kontrolleri.', + settings_section_conversation_title: 'Konuşma', + settings_section_appearance_title: 'Dış görünüş', + settings_section_appearance_meta: 'Tema, vurgu renkleri ve görsel stil.', + settings_section_preferences_title: 'Tercihler', + settings_section_preferences_meta: 'Hermes Web Kullanıcı Arayüzü için varsayılanlar ve Kullanıcı Arayüzü davranışı.', + settings_section_system_title: 'Sistem', + settings_section_system_meta: 'Örnek sürümü ve erişim kontrolleri.', + settings_check_now: 'Şimdi kontrol et', + settings_checking: 'Kontrol ediliyor\\u2026', + settings_up_to_date: 'Güncel \\u2713', + settings_updates_available: '{count} güncelleme mevcut', + settings_updates_disabled: 'Güncelleme kontrolleri devre dışı bırakıldı', + settings_update_check_failed: 'Güncelleme kontrolü başarısız oldu', + settings_label_workspace_panel_open: 'Çalışma alanı panelini varsayılan olarak açık tut', + settings_desc_workspace_panel_open: 'Etkinleştirildiğinde, çalışma alanı / dosya tarayıcı paneli her yeni oturumda otomatik olarak açılır. Yine de istediğiniz zaman manuel olarak kapatabilirsiniz.', + settings_label_session_jump_buttons: 'Oturum atlama düğmelerini göster', + settings_desc_session_jump_buttons: 'Uzun oturum geçmişlerini okurken kayan Başlat ve Bitir düğmelerini gösterin.', + + settings_label_session_endless_scroll: 'Yukarı kaydırırken eski mesajları yükle', + + settings_desc_session_endless_scroll: 'Etkinleştirildiğinde, yukarı doğru kaydırdığınızda eski mesajlar otomatik olarak yüklenir. Devre dışı bırakıldığında eski mesajlar düğmesini kullanın.', + + settings_label_tab_visibility: 'Kenar çubuğu sekmeleri', + settings_desc_tab_visibility: 'Kenar çubuğunda ve rayda hangi sekmelerin görüneceğini seçin. Sohbet ve Ayarlar her zaman görünür durumdadır.', + open_in_browser: 'Tarayıcıda aç', + settings_dropdown_conversation: 'Konuşma', + settings_dropdown_appearance: 'Dış görünüş', + settings_dropdown_preferences: 'Tercihler', + settings_dropdown_providers: 'Sağlayıcılar', + settings_dropdown_system: 'Sistem', + settings_tab_conversation: 'Konuşma', + settings_tab_appearance: 'Dış görünüş', + settings_tab_preferences: 'Tercihler', + settings_tab_plugins: 'Eklentiler', + settings_plugins_title: 'Plugins', // TODO: translate + settings_plugins_meta: 'View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.', // TODO: translate + settings_plugins_empty: 'No Hermes plugins are currently visible. Install or enable plugins from the Hermes CLI/config to see them here.', // TODO: translate + plugins_unnamed: 'Unnamed plugin', // TODO: translate + plugins_no_description: 'No description provided.', // TODO: translate + plugins_no_hooks: 'No registered lifecycle hooks', // TODO: translate + plugins_registered_hooks: 'Registered hooks', // TODO: translate + plugins_enabled: 'Enabled', // TODO: translate + plugins_disabled: 'Disabled', // TODO: translate + plugins_active_provider: 'Active (provider)', // TODO: translate + plugins_provider_no_hooks: 'Provider plugin — no agent-visibility hooks', // TODO: translate + plugins_load_failed: 'Failed to load plugins: ', // TODO: translate + settings_tab_system: 'Sistem', + settings_title: 'Ayarlar', + settings_save_btn: 'Ayarları Kaydet', + settings_label_model: 'Varsayılan Model', + settings_desc_model: 'Yeni konuşmalar için kullanılır. Mevcut konuşmalar seçilen modellerini korur.', + settings_label_send_key: 'Anahtar Gönder', + settings_label_theme: 'Tema', + settings_label_skin: 'Deri', + settings_label_font_size: 'Yazı tipi boyutu', + font_size_small: 'Küçük', + font_size_default: 'Varsayılan', + font_size_large: 'Büyük', + font_size_xlarge: 'Ekstra Büyük', + settings_autosave_saving: 'Kaydediliyor\\u2026', + settings_autosave_saved: 'Kaydedildi', + settings_autosave_failed: 'Kaydetme başarısız oldu', + settings_autosave_retry: 'Yeniden dene', + settings_label_language: 'Dil', + settings_label_quota_chip: 'Bestecide sağlayıcı kota çipini göster', + settings_desc_quota_chip: 'Besteci altbilgisinde bir ortam kalan kota göstergesini (örn. OpenRouter kredi bakiyesi) görüntüler. Varsayılan kapalı. Besteciyi dizüstü bilgisayar ve standart masaüstü genişliklerinde düzenli tutmak için yalnızca etkinleştirildiğinde geniş ekranlarda (≥1400 piksel) görünür.', + settings_label_hide_suggestions: 'Yeni sohbet önerilerini gizle', + settings_desc_hide_suggestions: 'Yanlışlıkla dokunmayı önlemek için boş yeni sohbet ekranındaki üç varsayılan öneri düğmesini gizleyin.', + settings_label_token_usage: 'Jeton kullanımını göster', + settings_label_sidebar_density: 'Kenar çubuğu yoğunluğu', + cmd_reasoning: 'Düşünme görünürlüğünü değiştirin (göster/gizle), çaba düzeyini ayarlayın veya mevcut durumu kontrol edin', + settings_label_external_sessions: 'WebUI olmayan oturumları göster', + settings_label_previous_messaging_sessions: 'Önceki mesajlaşma oturumlarını göster', + settings_label_sync_insights: 'Analizlerle senkronize edin', + settings_label_check_updates: 'Güncellemeleri kontrol edin', + settings_label_whats_new_summary: "Summarize What's New with AI", + settings_label_bot_name: 'Varsayılan asistanın adı', + settings_label_password: 'Erişim Şifresi', + settings_saved: 'Ayarlar kaydedildi', + settings_save_failed: 'Kaydetme başarısız oldu:', + settings_load_failed: 'Ayarlar yüklenemedi:', + settings_saved_pw: 'Ayarlar kaydedildi - şifre koruması etkin ve bu tarayıcıda oturum açık kalıyor', + settings_saved_pw_updated: 'Ayarlar kaydedildi — şifre güncellendi', + // login page (used server-side via /api/i18n/login endpoint) + login_title: 'Oturum aç', + login_subtitle: 'Devam etmek için şifrenizi girin', + login_placeholder: 'Şifre', + login_btn: 'Oturum aç', + login_invalid_pw: 'Geçersiz şifre', + login_conn_failed: 'Bağlantı başarısız oldu', + // Sidebar & Tabs + tab_chat: 'Sohbet', + tab_tasks: 'Görevler', + tab_skills: 'Yetenekler', + tab_memory: 'Hafıza', + tab_workspaces: 'Alanlar', + tab_profiles: 'Agent profilleri', + tab_kanban: 'Kanban', + kanban_board: 'Pano', + kanban_visible_tasks: '{0} görünür görev', + kanban_search_tasks: 'Görevleri ara', + kanban_all_assignees: 'Tüm atananlar', + kanban_all_tenants: 'Tüm kiracılar', + kanban_include_archived: 'Arşivlenenleri dahil et', + kanban_no_matching_tasks: 'Eşleşen görev yok', + kanban_no_data: 'Kanban verisi yok', + kanban_work_queue_hint: 'Bu Hermes Ajanının iş kuyruğudur. Bir görev oluşturun veya önceliklendirin, atayın, Hazır\'a taşıyın ve sevk görevlisinin görevi talep etmesine izin verin.', + kanban_unavailable: 'Kanban kullanılamıyor', + kanban_read_only: 'Salt okunur görünüm', + kanban_empty: 'Boş', + kanban_task: 'Görev', + kanban_no_description: 'Açıklama yok', + kanban_refresh: 'Yenile', + kanban_status_triage: 'Triyaj', + kanban_status_todo: 'Yapılacaklar', + kanban_status_ready: 'Hazır', + kanban_status_running: 'Koşma', + kanban_status_blocked: 'Engellendi', + kanban_status_done: 'Tamamlamak', + kanban_status_original_hint: 'Gerçek durum: {0}. Bu iletişim kutusu yalnızca Triyaj/Yapılacak/Hazır düzenlemelerini destekler.', + kanban_comments_count: 'Yorumlar ({0})', + kanban_events_count: 'Etkinlikler ({0})', + kanban_links: 'Bağlantılar', + kanban_parents: 'Ebeveynler', + kanban_children: 'Çocuklar', + kanban_runs_count: 'Çalıştırmalar ({0})', + kanban_no_comments: 'Yorum yok', + kanban_no_events: 'Etkinlik yok', + kanban_no_runs: 'Koşu yok', + kanban_title: 'Başlık', + kanban_description: 'Tanım', + kanban_description_placeholder: 'İsteğe bağlı - ne olması gerekiyor, kabul kriterleri, bağlantılar', + kanban_status: 'Durum', + kanban_assignee: 'Vekil', + kanban_assignee_placeholder: 'İsteğe bağlı — herhangi bir çalışan için boş bırakın', + kanban_tenant: 'Kiracı', + kanban_tenant_placeholder: 'İsteğe bağlı – proje veya ekip bilgisi', + kanban_priority: 'Öncelik', + kanban_priority_hint: 'Önce yüksek sayılar çalıştırılır. Varsayılan 0.', + kanban_title_required: 'Başlık gerekli.', + kanban_edit_task: 'Görevi düzenle', + kanban_run_dispatcher: 'Dağıtıcıyı çalıştır', + kanban_run_dispatcher_confirm: 'Bu, bu panodaki Hazır görevleri talep edecek ve çalışan alt süreçlerini oluşturacaktır (görev başına bir, tıklama başına en fazla 8). Devam etmek?', + kanban_assignee_profiles_label: 'Hermes profilleri', + kanban_assignee_other_label: 'Diğer (CLI hatları / kaldırılan profiller)', + kanban_assignee_unassigned: '— Atanmadı (otomatik olarak çalıştırılmayacak) —', + kanban_ready_needs_assignee: 'Atanmamış + Hazır\'ı seçtiniz. Gönderici bu görevi atlayacaktır. Onaylamak için tekrar gönderin veya bir profil seçin.', + kanban_dispatch_preview_prefix: 'Önizleme:', + kanban_dispatch_run_prefix: 'Sevk edilen:', + kanban_dispatch_spawned: 'yumurtladı', + kanban_dispatch_promoted: 'terfi ettirildi', + kanban_dispatch_reclaimed: 'geri kazanılmış', + kanban_dispatch_skipped_unassigned: 'atlandı (atanan yok)', + kanban_dispatch_skipped_nonspawnable: 'atlandı (bilinmeyen profil)', + kanban_dispatch_auto_blocked: 'otomatik olarak engellendi', + kanban_dispatch_timed_out: 'zaman aşımına uğradı', + kanban_dispatch_crashed: 'çöktü', + kanban_new_task: 'Yeni görev', + kanban_add_comment: 'Yorum ekle', + kanban_only_mine: 'Sadece benim', + kanban_bulk_action: 'Toplu işlem', + kanban_nudge_dispatcher: 'Göndericiyi önizleyin', + kanban_stats: 'İstatistikler', + kanban_worker_log: 'İşçi günlüğü', + kanban_block: 'Engellemek', + kanban_unblock: 'Engellemeyi kaldır', + kanban_back_to_board: 'Panoya geri dön', + kanban_lanes_by_profile: 'Profile göre şeritler', + kanban_new_board: 'Yeni yönetim kurulu\\u2026', + kanban_rename_board: 'Mevcut panoyu yeniden adlandır\\u2026', + kanban_archive_board: 'Mevcut panoyu arşivle\\u2026', + kanban_archive_board_confirm: 'Arşiv panosu "{name}"? Görevler diskte kalır ve pano kanban/boards/_archived/ adresinden geri yüklenebilir.', + kanban_board_archived: 'Pano arşivlendi', + kanban_board_name: 'İsim', + kanban_board_slug: 'Bilgi notu (küçük harf, kısa çizgi)', + kanban_board_description: 'Açıklama (isteğe bağlı)', + kanban_board_icon: 'Simge (emoji, isteğe bağlı)', + kanban_board_color: 'Renk (isteğe bağlı)', + kanban_board_name_required: 'Ad gerekli', + kanban_board_slug_required: 'Sümüklüböcek gerekli', + kanban_card_complete: 'tamamlamak', + kanban_card_archive: 'arşiv', + kanban_unassigned: 'atanmamış', + kanban_status_archived: 'Arşivlendi', + tab_todos: 'Yapılacaklar', + tab_insights: 'Analizler', + tab_dashboard: 'Hermes Kontrol Paneli', + dashboard_loopback_warning: 'Kontrol Paneli sunucuda yalnızca geri döngüye sahiptir. Ya sunucunun kendisinden göz atın ya da --host 0.0.0.0 (güvenli değil) ile yeniden başlatın.', + tab_logs: 'Günlükler', + tab_settings: 'Ayarlar', + + logs_title: 'Logs', // TODO: translate + logs_file: 'File', // TODO: translate + logs_tail: 'Tail', // TODO: translate + logs_auto_refresh: 'Auto-refresh (5s)', // TODO: translate + logs_wrap: 'Wrap lines', // TODO: translate + logs_copy_all: 'Copy all', // TODO: translate + logs_empty: 'No log lines yet.', // TODO: translate + logs_loading: 'Loading logs…', // TODO: translate + logs_load_failed: 'Logs failed to load', // TODO: translate + logs_status_idle: 'Choose a log file to view recent lines.', // TODO: translate + logs_no_mtime: 'not written yet', // TODO: translate + logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate + logs_copied: 'Logs copied', // TODO: translate + logs_severity: 'Şiddet', + logs_severity_all: 'Tüm', + logs_severity_errors: 'Hatalar', + logs_severity_warnings: 'Uyarılar+', + logs_filter_active: 'gösteriliyor (filtre etkin)', + new_conversation: 'Yeni görüşme', + filter_conversations: 'Konuşmaları filtrele...', + session_time_unknown: 'Bilinmiyor', + session_time_minutes_ago: (n) => `${n} milyon dolar`, + session_time_hours_ago: (n) => `${n}sa`, + session_time_days_ago: (n) => `${n}d`, + session_time_last_week: '1 saat', + session_time_bucket_today: 'Bugün', + session_time_bucket_yesterday: 'Dün', + session_time_bucket_this_week: 'Bu hafta', + session_time_bucket_last_week: 'Geçen hafta', + session_time_bucket_older: 'daha yaşlı', + scheduled_jobs: 'Planlanmış işler', + new_job: 'Yeni iş', + loading: 'Yükleniyor...', + search_skills: 'Arama becerileri...', + new_skill: 'Yeni beceri', + personal_memory: 'Kişisel hafıza', + current_task_list: 'Mevcut görev listesi', + workspace_desc: 'Oturumlarınız için çalışma alanları ekleyin ve değiştirin.', + session_meta_messages: (n) => `${n} mesaj${n === 1 ? '' : 'S'}`, + session_meta_children: (n) => `${n} çocuk${n === 1 ? '' : 'ren'}`, + session_meta_segments: (n) => `${n} segment${n === 1 ? '' : 'S'}`, + session_lineage_segment_untitled: 'Başlıksız segment', + session_lineage_segment_open: 'Soy segmentini aç', + new_profile: 'Yeni profil', + transcript: 'Deşifre metni', + download_transcript: 'Markdown olarak indir', + import: 'İçe aktarmak', + export_session_json: 'JSON', + export_session_json_tooltip: 'Tüm oturumu JSON olarak dışa aktar', + import_session_json_tooltip: 'JSON\'dan oturumu içe aktar', + clear_conversation_btn_tooltip: 'Bu görüşmedeki tüm mesajları temizle', + // Settings detail + settings_label_rtl: 'Sağdan sola sohbet düzeni', + settings_desc_rtl: 'Arapça veya İbranice gibi diller için sohbet mesajlarının ve besteci girişinin hizalamasını değiştirir. Yalnızca sohbet alanını etkiler; kenar çubuğu ve diğer paneller soldan sağa kalır.', + settings_label_sound: 'Bildirim sesi', + settings_desc_sound: 'Asistan bir yanıtı bitirdiğinde bir ses çalın.', + settings_label_notifications: 'Tarayıcı bildirimleri', + settings_desc_notifications: 'Uygulama arka plandayken bir yanıt tamamlandığında bir sistem bildirimi gösterin.', + settings_desc_token_usage: 'Her Asistan yanıtının altında giriş/çıkış jeton sayılarını gösterir. /usage ile de değiştirilebilir.', + settings_sidebar_density_compact: 'Kompakt', + settings_sidebar_density_detailed: 'Ayrıntılı', + settings_desc_sidebar_density: 'Oturum listesinin sol kenar çubuğunda ne kadar meta veri göstereceğini kontrol eder.', + settings_label_auto_title_refresh: 'Uyarlanabilir başlık yenileme', + settings_auto_title_refresh_off: 'Kapalı', + settings_auto_title_refresh_5: 'Her 5 değişimde bir', + settings_auto_title_refresh_10: 'Her 10 değişimde bir', + settings_auto_title_refresh_20: 'Her 20 değişimde bir', + settings_desc_auto_title_refresh: 'Oturum başlıklarını en son konuşmaya göre otomatik olarak yeniden oluşturarak konuşma ilerledikçe başlıkların alakalı kalmasını sağlar. LLM başlık oluşturma modeli yapılandırması gerektirir.', + settings_desc_external_sessions: 'Oturum listesinde CLI, Telegram, Discord, Slack ve diğer kanallardan gelen konuşmaları gösterin. İçe aktarmak ve devam etmek için tıklayın.', + settings_desc_previous_messaging_sessions: 'Sıfırlama veya sıkıştırmayla değiştirilen eski Discord, Telegram, Slack ve Weixin oturumlarını gösterin.', + settings_desc_sync_insights: 'WebUI belirteci kullanımını state.db\'ye yansıtır, böylece hermes /insights tarayıcı oturum verilerini içerir. Varsayılan olarak kapalıdır.', + settings_desc_check_updates: 'WebUI veya Agent\'ın daha yeni sürümleri mevcut olduğunda bir banner gösterin. Periyodik olarak bir arka plan git getirme işlemi çalıştırır.', + settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", + settings_desc_bot_name: 'Yalnızca varsayılan profil için kullanılır. Diğer profiller kendi profil adlarını kullanır.', + settings_desc_password: 'Ayarlamak veya değiştirmek için yeni bir şifre girin. Geçerli ayarı korumak için boş bırakın.', + password_placeholder: 'Yeni şifreyi girin\\u2026', + password_env_var_locked: 'HERMES_WEBUI_PASSWORD ortam değişkeni şu anda ayarlıdır ve önceliklidir. Şifreyi buradan yönetmek için ayarı kaldırın ve sunucuyu yeniden başlatın.', + password_env_var_locked_placeholder: 'Kilitli: HERMES_WEBUI_PASSWORD ortam değişkeni ayarlandı', + disable_auth: 'Kimlik Doğrulamasını Devre Dışı Bırak', + sign_out: 'Oturumu Kapat', + // Providers panel + providers_tab_title: 'Sağlayıcılar', + providers_section_title: 'Sağlayıcılar', + providers_section_meta: 'Yapay zeka sağlayıcıları için API anahtarlarını yönetin. Değişiklikler hemen yürürlüğe girer.', + providers_status_configured: 'API anahtarı yapılandırıldı', + providers_status_not_configured: 'API anahtarı yok', + providers_status_oauth: 'OAuth', + providers_status_api_key: 'API anahtarı', + providers_status_not_configured_label: 'Yapılandırılmadı', + providers_oauth_hint: 'OAuth aracılığıyla kimlik doğrulaması yapıldı. API anahtarına gerek yok.', + providers_oauth_config_yaml_hint: 'Belirteç config.yaml aracılığıyla yapılandırıldı. Güncellemek için config.yaml dosyanızdaki sağlayıcılar bölümünü düzenleyin veya hermes auth\'u çalıştırın.', + providers_oauth_not_configured_hint: 'Kimliği doğrulanmadı. Bu sağlayıcıyı yapılandırmak için terminalde Hermes Auth komutunu çalıştırın.', + providers_save: 'Kaydetmek', + providers_remove: 'Kaldırmak', + providers_saving: 'Kaydediliyor\\u2026', + providers_removing: 'Kaldırılıyor\\u2026', + providers_enter_key: 'Lütfen bir API anahtarı girin', + providers_empty: 'Yapılandırılabilir sağlayıcı bulunamadı.', + providers_key_updated: 'API anahtarı kaydedildi', + providers_key_removed: 'API anahtarı kaldırıldı', + providers_key_placeholder_new: 'sk-...', + providers_key_placeholder_replace: 'Değiştirilecek yeni anahtarı girin\\u2026', + provider_quota_title: 'Etkin sağlayıcı kotası', + provider_quota_active_provider: 'Aktif sağlayıcı', + provider_quota_last_checked_after_refresh: 'Yenilemeden sonra son kontrol edildi', + provider_quota_last_checked: 'Son kontrol edilen {0}', + provider_quota_refresh_usage: 'Kullanımı yenile', + provider_quota_refreshing: 'Yenileniyor...', + provider_quota_refresh_title: 'Sağlayıcı kullanım sınırlarını şimdi yenileyin', + provider_quota_refresh_succeeded: 'Sağlayıcı kullanımı yenilendi', + provider_quota_refresh_failed: 'Sağlayıcı kullanımı yenileme başarısız oldu', + provider_quota_session_limit: '5 saat sınırı', + provider_quota_weekly_limit: 'Haftalık limit', + provider_quota_window_fallback: 'Pencere', + provider_quota_metric_remaining: 'Geriye kalan', + provider_quota_metric_used: 'Kullanılmış', + provider_quota_metric_limit: 'Sınır', + provider_quota_status_available: 'mevcut', + provider_quota_status_exhausted: 'yorgun', + provider_quota_status_unavailable: 'müsait değil', + provider_quota_status_failed: 'arızalı', + provider_quota_status_checked: 'kontrol edildi', + provider_quota_status_no_key: 'anahtar yok', + provider_quota_status_invalid_key: 'geçersiz anahtar', + provider_quota_status_unsupported: 'desteklenmeyen', + provider_quota_used_meta: '{0} kullanıldı', + provider_quota_resets_meta: '{0} sıfırlanır', + provider_quota_credential_pool: 'Kimlik bilgisi havuzu', + provider_quota_credential_label: 'Kimlik bilgisi {0}', + provider_quota_pool_plans: 'Planlar: {0}', + provider_quota_pool_no_windows: 'Hesap sınırı aralığı bildirilmedi.', + provider_quota_account_limits_loaded: 'Hesap limitleri yüklendi.', + provider_quota_unavailable: 'Kota durumu mevcut değil', + provider_quota_retry_after: '{0} sonrasında yeniden deneyin', + provider_quota_pool_summary_available: '{0}/{1} mevcut', + provider_quota_pool_summary_exhausted: '{0} bitkin', + provider_quota_pool_summary_failed: '{0} başarısız oldu', + provider_quota_pool_summary_checked: '{0} işaretlendi', + cancel: 'İptal etmek', + create_job: 'İş oluştur', + save_skill: 'Beceriyi kaydet', + editing: 'Düzenleme', + // Empty state + empty_title: 'Hangi konuda yardımcı olabilirim?', + empty_subtitle: 'İstediğiniz şeyi sorun, komutları çalıştırın, dosyaları keşfedin veya planlanmış görevlerinizi yönetin.', + suggest_files: 'Bu çalışma alanında hangi dosyalar var?', + suggest_schedule: 'Nerelerdesiniz?', + suggest_plan: 'Küçük bir proje planlamama yardım et.', + // onboarding + onboarding_badge: 'İLK ÇALIŞMA', + onboarding_title: 'Hermes Web Kullanıcı Arayüzüne Hoş Geldiniz', + onboarding_lead: 'Hızlı yönlendirmeli kurulum, Hermes\'i doğrulayacak, gerçek bir sağlayıcı yapılandırmasını kaydedecek, bir çalışma alanı ve modeli seçecek ve isteğe bağlı olarak uygulamayı bir parola ile koruyacaktır.', + onboarding_back: 'Geri', + onboarding_continue: 'Devam etmek', + onboarding_skip: 'Kurulumu atla', + onboarding_skipped: 'Kurulum atlandı — mevcut yapılandırma kullanılıyor.', + onboarding_open: 'Hermes\'i aç', + onboarding_step_system_title: 'Sistem kontrolü', + onboarding_step_system_desc: 'Hermes Agent\'ı ve yapılandırma görünürlüğünü doğrulayın.', + onboarding_step_setup_title: 'Sağlayıcı kurulumu', + onboarding_step_setup_desc: 'Minimum Hermes sağlayıcı yapılandırmasını kaydedin.', + onboarding_step_workspace_title: 'Çalışma alanı + modeli', + onboarding_step_workspace_desc: 'Yeni oturumlar ve sohbet için varsayılanları seçin.', + onboarding_step_password_title: 'İsteğe bağlı şifre', + onboarding_step_password_desc: 'Web kullanıcı arayüzünü paylaşmadan önce koruyun.', + onboarding_step_finish_title: 'Sona ermek', + onboarding_step_finish_desc: 'Uygulamayı inceleyin ve girin.', + onboarding_notice_system_ready: 'Hermes Agent\'a Web kullanıcı arayüzünden ulaşılabilir görünüyor.', + onboarding_notice_system_unavailable: 'Hermes Agent henüz tam olarak mevcut değil. Bootstrap bunu kurabilir ancak sağlayıcı kurulumu yine de bir terminal gerektirebilir.', + onboarding_check_agent: 'Hermes Temsilcisi', + onboarding_check_agent_ready: 'Tespit edildi ve içe aktarılabilir', + onboarding_check_agent_missing: 'Eksik veya kısmen içe aktarılabilir', + onboarding_check_password: 'Şifre', + onboarding_check_password_enabled: 'Zaten etkin', + onboarding_check_password_disabled: 'Henüz etkinleştirilmedi', + onboarding_check_provider: 'Sağlayıcı yapılandırması', + onboarding_check_provider_ready: 'Sohbete hazır', + onboarding_check_provider_partial: 'Kaydedildi ancak eksik', + onboarding_check_provider_pending: 'Doğrulama gerekiyor', + onboarding_config_file: 'Yapılandırma dosyası:', + onboarding_env_file: '.env dosyası:', + onboarding_unknown: 'Bilinmiyor', + onboarding_current_provider: 'Mevcut kurulum:', + onboarding_missing_imports: 'Eksik içe aktarmalar:', + onboarding_notice_setup_required: 'Burada basit bir sağlayıcı yolu seçin. Gelişmiş OAuth akışları şimdilik Hermes CLI\'ye ait olmaya devam ediyor.', + onboarding_notice_setup_already_ready: 'Çalışan bir Hermes sağlayıcı kurulumu zaten algılandı. Burada saklayabilir veya değiştirebilirsiniz.', + onboarding_oauth_provider_ready_title: 'Sağlayıcının kimliği zaten doğrulandı', + onboarding_oauth_provider_ready_body: 'Bu örnek, Hermes CLI aracılığıyla ayarlanmış bir OAuth sağlayıcısını ({provider) kullanacak şekilde yapılandırılmıştır. Burada API anahtarına gerek yoktur; kurulumu tamamlamak için Devam\'a tıklayın.', + onboarding_oauth_provider_not_ready_title: 'OAuth sağlayıcısının kimliği henüz doğrulanmadı', + onboarding_oauth_provider_not_ready_body: 'Bu örnek, API anahtarı yerine OAuth kullanan {provider\'i kullanacak şekilde yapılandırılmıştır. Kimlik doğrulaması yapmak için bir terminalde hermes auth veya hermes model\'i çalıştırın, ardından Web kullanıcı arayüzünü yeniden yükleyin.', + onboarding_oauth_switch_hint: 'Veya API anahtarı kurulumuna geçmek için aşağıdan farklı bir sağlayıcı seçin:', + onboarding_notice_workspace: 'Bu değerler normal uygulamayla aynı ayar API\'lerini yeniden kullanır.', + onboarding_workspace_label: 'Çalışma alanı', + onboarding_workspace_or_path: 'Veya bir çalışma alanı yolu girin', + onboarding_workspace_placeholder: '/ev/siz/çalışma alanı', + onboarding_provider_label: 'Kurulum modu', + onboarding_quick_setup_badge: 'hızlı kurulum', + provider_category_easy_start: 'Kolay başlangıç', + provider_category_self_hosted: 'Açık / kendi kendine barındırılan', + provider_category_specialized: 'Uzmanlaşmış', + onboarding_api_key_label: 'API anahtarı', + oauth_login_codex: 'Login with Codex (ChatGPT)', // TODO: translate + oauth_codex_step1: 'Step 1: Visit this URL and enter the code', // TODO: translate + oauth_codex_step2: 'Step 2: Enter this code on the page', // TODO: translate + oauth_codex_polling: 'Waiting for authorization...', // TODO: translate + oauth_codex_success: 'Codex OAuth login successful!', // TODO: translate + oauth_codex_error: 'OAuth login failed', // TODO: translate + oauth_codex_expired: 'Code expired, please try again', // TODO: translate + onboarding_api_key_placeholder: 'Mevcut kayıtlı anahtarı korumak için boş bırakın', + onboarding_api_key_label_optional: 'API key (optional)', // TODO: translate + onboarding_api_key_placeholder_optional: 'Leave blank for keyless servers', // TODO: translate + onboarding_api_key_help_keyless: 'Most LM Studio / Ollama / vLLM installs run keyless — leave this blank if your server doesn\'t require authentication. Use the Test connection button to verify.', // TODO: translate + onboarding_api_key_help_prefix: 'Hermes .env dosyanızda gizli olarak kaydedildi', + onboarding_base_url_label: 'Temel URL', + onboarding_base_url_placeholder: 'https://uç noktanız.example/v1', + onboarding_base_url_help: 'Bunu OpenAI uyumlu yönlendiriciler, şirket içinde barındırılan sunucular, LiteLLM, Ollama, LM Studio, vLLM veya benzer uç noktalar için kullanın.', + onboarding_model_label: 'Varsayılan model', + onboarding_workspace_help: 'Kurulum tamamlandıktan sonra Hermes\'in yeni sohbetler için kullanması gereken modeli seçin.', + onboarding_custom_model_placeholder: 'modelinizin adı', + onboarding_custom_model_help: 'Özel uç noktalar için sunucunuzun beklediği tam model kimliğini girin.', + onboarding_notice_password_enabled: 'Bir parola zaten yapılandırılmıştır. Yalnızca değiştirmek istiyorsanız yeni bir tane girin.', + onboarding_notice_password_recommended: 'İsteğe bağlıdır ancak kullanıcı arayüzünü localhost\'un ötesinde gösterecekseniz önerilir.', + onboarding_password_label: 'Şifre (isteğe bağlı)', + onboarding_password_placeholder: 'Atlamak için boş bırakın', + onboarding_password_help: 'Parolalar, mevcut ayarlar API\'si aracılığıyla ve karma sunucu tarafında saklanır.', + onboarding_notice_finish: 'Bunlardan herhangi birini değiştirmek için Ayarları daha sonra yeniden açabilirsiniz.', + onboarding_not_set: 'Ayarlanmadı', + onboarding_password_will_enable: 'Etkinleştirilecek', + onboarding_password_will_replace: 'Değiştirilecek', + onboarding_password_keep_existing: 'Mevcut şifreyi koru', + onboarding_password_remains_disabled: 'Engelli kalacak', + onboarding_password_skipped: 'Şimdilik atlandı', + onboarding_finish_help: 'Bitirme, ayarlarda onboarding_completed kodunu saklar ve sizi normal uygulamaya bırakır.', + onboarding_error_choose_workspace: 'Devam etmeden önce bir çalışma alanı seçin.', + onboarding_error_choose_model: 'Devam etmeden önce bir model seçin.', + onboarding_error_provider_required: 'Devam etmeden önce bir kurulum modu seçin.', + onboarding_error_base_url_required: 'Özel uç noktalar için temel URL gereklidir.', + onboarding_probe_test_button: 'Test connection', // TODO: translate + onboarding_probe_probing: 'Testing connection…', // TODO: translate + onboarding_probe_ok: 'Connected. {n} model(s) available.', // TODO: translate + onboarding_probe_error_generic: 'Could not reach the configured base URL.', // TODO: translate + onboarding_probe_error_invalid_url: 'Base URL must start with http:// or https://.', // TODO: translate + onboarding_probe_error_dns: 'Could not resolve the host. Check the URL or use the host\'s IP address.', // TODO: translate + onboarding_probe_error_connect_refused: 'Connection refused — the server may not be running on that address. From inside Docker, try the host IP instead of localhost.', // TODO: translate + onboarding_probe_error_timeout: 'The endpoint did not respond in time. Check that the server is running and the URL is correct.', // TODO: translate + onboarding_probe_error_http_4xx: 'The endpoint returned a client error. Check authentication and the URL path (typically ends in /v1).', // TODO: translate + onboarding_probe_error_http_5xx: 'The endpoint returned a server error. Check the LM Studio / Ollama server logs.', // TODO: translate + onboarding_probe_error_parse: 'The endpoint did not return a model list in the expected shape. Verify the URL points to the OpenAI-compatible API root.', // TODO: translate + onboarding_probe_error_unreachable: 'Could not reach the configured base URL.', // TODO: translate + onboarding_error_probe_failed: 'Could not validate the configured base URL.', // TODO: translate + onboarding_error_workspace_required: 'Çalışma alanı gerekli.', + onboarding_error_model_required: 'Model gerekli.', + onboarding_complete: 'İlk katılım tamamlandı', + + // panel/runtime i18n + error_prefix: 'Hata:', + not_available: 'Yok', + never: 'Asla', + add: 'Eklemek', + add_failed: 'Ekleme başarısız oldu:', + remove_failed: 'Kaldırma başarısız oldu:', + switch_failed: 'Geçiş başarısız oldu:', + name_required: 'Ad gerekli', + content_required: 'İçerik gerekli', + view: 'Görüş', + dismiss: 'Azletmek', + disable: 'Devre dışı bırakmak', + cron_no_jobs: 'Planlanmış iş bulunamadı.', + cron_status_off: 'kapalı', + cron_status_paused: 'duraklatıldı', + cron_status_error: 'hata', + cron_status_active: 'aktif', + cron_status_running: 'koşuyor\\u2026', + cron_status_needs_attention: 'dikkat edilmesi gerekiyor', + cron_attention_desc: 'Bu yinelenen işin bir sonraki çalışma süresi yoktur. Zamanlayıcı bir sonraki çalıştırmayı hesaplamada başarısız olmuş olabilir.', + cron_attention_croniter_hint: 'Ağ Geçidi çalışma zamanında croniter paketi eksik olabilir. Ağ Geçidini cron desteğiyle yeniden başlatın, ardından bu işe devam edin.', + cron_attention_resume: 'Devam ettir ve yeniden hesapla', + cron_jobs_project: 'Cron İşleri', + cron_attention_run_once: 'Şimdi bir kez koş', + cron_attention_copy_diagnostics: 'Tanılamayı kopyala', + cron_diagnostics_copied: 'Cron teşhisi kopyalandı', + cron_next: 'Sonraki', + cron_last: 'Son', + cron_run_now: 'Şimdi koş', + cron_pause: 'Duraklat', + cron_resume: 'Sürdürmek', + cron_job_name_placeholder: 'İşin adı', + cron_schedule_placeholder: 'Takvim', + cron_prompt_placeholder: 'Çabuk', + cron_last_output: 'Son çıktı', + cron_expand_prompt: 'İstemi genişlet', + cron_collapse_prompt: 'İstemi daralt', + cron_expand_output: 'Çıktıyı genişlet', + cron_collapse_output: 'Çıktıyı daralt', + cron_all_runs: 'Tüm koşular', + cron_hide_runs: 'Çalıştırmaları gizle', + cron_no_runs_yet: '(henüz koşu yok)', + cron_schedule_required_example: 'Program gereklidir (ör. "0 9 * * *" veya "her 1 saatte bir")', + cron_schedule_required: 'Program gerekli', + cron_prompt_required: 'İstem gerekli', + cron_job_created: 'İş oluşturuldu', + cron_duplicate: 'Kopyalamak', + cron_duplicated: 'İş kopyalandı (duraklatıldı)', + cron_job_triggered: 'İş tetiklendi', + cron_job_paused: 'İş duraklatıldı', + cron_job_resumed: 'İş devam ettirildi', + cron_job_updated: 'İş güncellendi', + cron_delete_confirm_title: 'Cron işini sil', + cron_delete_confirm_message: 'Bu geri alınamaz.', + cron_job_deleted: 'İş silindi', + cron_completion_status: (name, status) => `Cron "${name}" ${status}`, + status_failed: 'arızalı', + status_completed: 'tamamlanmış', + todos_no_active: 'Bu oturumda etkin görev listesi yok.', + clear_conversation_title: 'Görüşmeyi temizle', + clear_conversation_message: 'Tüm mesajlar silinsin mi? Bu geri alınamaz.', + clear_failed: 'Temizleme başarısız oldu:', + skills_no_match: 'Beceri eşleşmesi yok.', + skill_enabled: 'Etkinleştirilmiş', + skill_disabled: 'Engelli', + skill_toggle_failed: 'Beceri değiştirilemedi:', + linked_files: 'Bağlantılı Dosyalar', + skill_load_failed: 'Beceri yüklenemedi:', + skill_file_load_failed: 'Dosya yüklenemedi:', + skill_name_required: 'Beceri adı gerekli', + skill_updated: 'Beceri güncellendi', + skill_created: 'Beceri oluşturuldu', + skill_deleted: 'Beceri silindi', + skill_delete_confirm: '"{0}" becerisi silinsin mi?', + skills_empty_title: 'Bir beceri seçin', + skills_empty_sub: 'İçeriğini görüntülemek için kenar çubuğundan bir beceri seçin veya yeni bir beceri oluşturun.', + skills_edit: 'Düzenlemek', + skills_delete: 'Silmek', + skills_back_to: '{0}\'a geri dön', + tasks_empty_title: 'Planlanmış bir iş seçin', + tasks_empty_sub: 'Ayrıntılarını ve çalışmalarını görüntülemek için kenar çubuğundan bir iş seçin veya yeni bir iş oluşturun.', + workspaces_empty_title: 'Bir alan seçin', + workspaces_empty_sub: 'Dosyalarını ve ayarlarını görüntülemek veya yeni bir tane eklemek için kenar çubuğundan bir alan seçin.', + profiles_empty_title: 'Bir profil seçin', + profiles_empty_sub: 'Ayarlarını görüntülemek ve düzenlemek için kenar çubuğundan bir temsilci profili seçin veya yeni bir tane oluşturun.', + memory_notes_label: 'hafıza (notlar)', + memory_saved: 'Bellek kaydedildi', + my_notes: 'Notlarım', + user_profile: 'Kullanıcı Profili', + no_notes_yet: 'Henüz not yok.', + no_profile_yet: 'Henüz profil yok.', + agent_soul: 'Ajan Ruhu', + no_soul_yet: 'Henüz tanımlanmış bir ruh yok.', + workspace_choose_path: 'Çalışma alanı yolunu seçin', + workspace_choose_path_meta: 'Doğrulanmış bir yol ekleyin ve bu görüşmeyi değiştirin', + workspace_manage: 'Çalışma alanlarını yönet', + workspace_manage_meta: 'Spaces panelini açın', + workspace_use_title: 'Geçerli oturumda kullan', + workspace_use: 'Kullanmak', + workspace_add_path_placeholder: 'Çalışma alanı yolu ekleyin (ör. /home/user/my-project)', + workspace_paths_validated_hint: 'Yollar, kaydedilmeden önce mevcut dizinler olarak doğrulanır.', + + workspace_drag_hint: 'Yeniden sıralamak için sürükleyin', + workspace_reorder_failed: 'Yeniden sıralama başarısız oldu', + workspace_added: 'Çalışma alanı eklendi', + workspace_renamed: 'Çalışma alanı yeniden adlandırıldı', + workspace_remove_confirm_title: 'Çalışma alanını kaldır', + workspace_remove_confirm_message: (path) => `"${path}" kaldırılsın mı?`, + workspace_removed: 'Çalışma alanı kaldırıldı', + workspace_switch_prompt_title: 'Çalışma alanını değiştir', + workspace_switch_prompt_message: 'Bu görüşmeyi eklemek ve bu görüşmeye geçiş yapmak için mutlak bir çalışma alanı yolu girin.', + workspace_switch_prompt_confirm: 'Anahtar', + workspace_switch_prompt_placeholder: '/Kullanıcılar/siz/proje', + workspace_not_added: 'Çalışma alanı eklenmedi', + workspace_already_saved: 'Çalışma alanı zaten kayıtlı; onu listeden seçin', + workspace_busy_switch: 'Aracı çalışırken çalışma alanı değiştirilemiyor', + discard_file_edits_title: 'Dosya düzenlemeleri silinsin mi?', + discard_file_edits_message: 'Çalışma alanlarının değiştirilmesi, önizlemedeki kaydedilmemiş dosya düzenlemelerinin silinmesine neden olur.', + workspace_switched_to: (name) => `${name}'a geçildi`, + profiles_no_profiles: 'Hiçbir profil bulunamadı.', + profile_api_keys_configured: 'API anahtarları yapılandırıldı', + profile_gateway_running: 'Ağ geçidi çalışıyor', + profile_gateway_stopped: 'Ağ geçidi durduruldu', + profile_active: 'AKTİF', + profile_no_configuration: 'Yapılandırma yok', + profile_skill_count: (count) => `${count} beceri${count === 1 ? '' : 'S'}`, + profile_use: 'Kullanmak', + profile_switch_title: 'Bu profile geç', + profile_delete_title: 'Bu profili sil', + profile_default_label: '(varsayılan)', + profile_name_placeholder: 'Profil adı (küçük harf, a-z 0-9 kısa çizgiler)', + profile_clone_label: 'Etkin profilden yapılandırmayı kopyala', + profile_model_label: 'Model / sağlayıcı', + profile_model_hint: 'Bu yeni profil için yapılandırılmış sağlayıcılar ve modeller arasından seçim yapın.', + profile_model_use_default: 'Etkin profil varsayılanını kullan', + profile_base_url_placeholder: 'Temel URL (isteğe bağlı, örneğin http://localhost:11434)', + profile_api_key_placeholder: 'API anahtarı (isteğe bağlı)', + manage_profiles: 'Profilleri yönet', + profiles_load_failed: 'Profiller yüklenemedi', + profile_switched_new_conversation: (name) => `Profile geçildi: ${name} — yeni görüşme başladı`, + profile_switched: (name) => `Profile geçildi: ${name}`, + profile_name_rule: 'Yalnızca küçük harfler, sayılar, kısa çizgiler ve alt çizgiler', + profile_base_url_rule: 'Temel URL http:// veya https:// ile başlamalıdır', + profile_created: (name) => `Profil oluşturuldu: ${name}`, + profile_delete_confirm_title: (name) => `"${name}" profili silinsin mi?`, + profile_delete_confirm_message: 'Bu profilin tüm oturumları, yapılandırmaları, becerileri ve belleği kalıcı olarak silinecek. Bu geri alınamaz.', + profile_deleted: (name) => `Profil silindi: ${name}`, + active_conversation_none: 'Aktif görüşme seçilmedi.', + active_conversation_meta: (title, count) => `${title} · ${count} mesaj${count === 1 ? '' : 'S'}`, + settings_unsaved_changes: 'Kaydedilmemiş değişiklikleriniz var.', + sign_out_failed: 'Oturumu kapatma başarısız oldu:', + disable_auth_confirm_title: 'Şifre korumasını devre dışı bırak', + disable_auth_confirm_message: 'Bu örneğe herkes erişebilecek.', + auth_disabled: 'Kimlik doğrulama devre dışı bırakıldı — şifre koruması kaldırıldı', + disable_auth_failed: 'Kimlik doğrulama devre dışı bırakılamadı:', + bg_error_single: (title) => `"${title}" bir hatayla karşılaştı`, + bg_error_multi: (count) => `${count} oturum bir hatayla karşılaştı`, + // skill form + skill_name: 'İsim', + skill_category: 'Kategori', + skill_category_placeholder: 'İsteğe bağlı, ör. devops', + skill_content: 'SKILL.md içeriği', + skill_content_placeholder: 'YAML ön maddesi + işaretleme gövdesi', + skill_rename_not_supported: 'Bir becerinin yeniden adlandırılması desteklenmiyor. Yeni bir beceri oluşturun ve yeniden adlandırmak için eskisini silin.', + skill_metadata: 'Meta veriler', + // cron form + cron_name_label: 'İsim', + cron_name_placeholder: 'İsteğe bağlı', + cron_schedule_label: 'Takvim', + cron_schedule_hint: "Use 'every 1h' or a cron expression for recurring jobs. Bare durations like '30m' run once.", + cron_schedule_once_warning: "Duration forms like '30m' run once and are removed after running. Use 'every 30m' to keep a recurring job.", + cron_prompt_label: 'Çabuk', + cron_deliver_label: 'Çıktıyı şuraya ilet:', + cron_deliver_local: 'Yerel (yalnızca çıktıyı kaydet)', + cron_profile_label: 'Profil', + cron_profile_server_default: 'sunucu varsayılanı', + cron_profile_server_default_hint: 'Çalışma zamanında WebUI sunucusunun varsayılan profilini kullanır. Profili olmayan mevcut işler bu eski davranışı sürdürüyor.', + cron_toast_notifications_label: 'Tamamlama kadehleri', + cron_toast_notifications_hint: 'Bu cron bittiğinde kadeh kaldır. Görevler rozeti ve yeni çalıştırma işaretçisi bu kapalıyken de güncellenir.', + cron_toast_notifications_enabled: 'Etkinleştirilmiş', + cron_toast_notifications_disabled: 'Engelli', + cron_skills_label: 'Yetenekler', + cron_skills_placeholder: 'Beceri ekleyin (isteğe bağlı)\\u2026', + cron_skills_edit_hint: 'Beceri listesi oluşturulduktan sonra düzenlenemez.', + // workspace form + workspace_name_label: 'İsim', + workspace_name_placeholder: 'İsteğe bağlı kolay ad', + workspace_path_label: 'Yol', + workspace_path_required: 'Yol gerekli', + workspace_path_readonly: 'Yol değiştirilemez. Yalnızca yeniden adlandırın.', + workspace_new_title: 'Yeni alan', + // profile form + profile_name_label: 'İsim', + profile_base_url_label: 'Temel URL', + profile_api_key_label: 'API anahtarı', + cmd_yolo: 'YOLO modunu değiştir (onayları atla)', + cmd_branch: 'Bu konuşmayı yeni bir oturuma aktarın', + cmd_branch_usage: '/branch [isim] - konuşmayı çatallayın (isteğe bağlı olarak bir adla)', + branch_forked: 'Yeni oturuma çatallandı', + branch_failed: 'Çatal başarısız oldu:', + fork_from_here: 'Buradan çatal', + forked_from: 'çatallı', + yolo_no_session: 'Aktif oturum yok', + yolo_enabled: '⚡ YOLO modu AÇIK — onaylar bu oturumu atladı', + yolo_disabled: 'YOLO modu KAPALI', + yolo_pill_label: 'YOLO', + yolo_pill_title_active: 'YOLO modu etkin — devre dışı bırakmak için tıklayın', + approval_skip_all: '⚡ Tüm bu oturumu atla', + approval_skip_all_title: 'Bu oturuma ilişkin tüm onay istemlerini atla', + // composer action tooltips + composer_send: 'Mesaj gönder', + composer_queue: 'Sıra mesajı', + composer_interrupt: 'Kes ve gönder', + composer_steer: 'Akım tepkisini yönlendir', + composer_stop: 'Üretimi durdur', + composer_disabled_clarify: 'Açıklama isteğine yanıt verin', + composer_disabled_compression: 'Sıkıştırmanın bitmesi bekleniyor', + composer_disabled_empty: 'Göndermek için bir mesaj yazın', + composer_mobile_workspace: 'Çalışma alanı', + composer_mobile_model: 'Modeli', + composer_mobile_reasoning: 'muhakeme', + composer_mobile_context: 'Bağlam', + + pdf_loading: 'PDF {0} yükleniyor\\u2026', + pdf_too_large: 'PDF satır içi önizleme için çok büyük', + pdf_no_pages: 'PDF\'de sayfa yok', + pdf_error: 'PDF önizlemesi oluşturulamadı', + pdf_download: 'PDF\'yi indir', + html_loading: 'HTML önizlemesi yükleniyor\\u2026', + html_too_large: 'HTML satır içi önizleme için çok büyük', + html_error: 'HTML önizlemesi oluşturulamadı', + html_open_full: 'Tam sayfayı aç', + html_sandbox_label: 'HTML Önizlemesi (korumalı alan)', + media_audio_label: 'Ses', + media_svg_label: 'Diyagram', + media_video_label: 'Video', + csv_loading: 'CSV yükleniyor', + csv_too_large: 'CSV dosyası satır içi oluşturma için çok büyük', + csv_no_data: 'CSV dosyasında tablo olarak oluşturmak için yeterli veri yok', + csv_error: 'CSV dosyası yüklenemedi', + csv_header_note: 'Tablo başlığı olarak gösterilen ilk satır', + excalidraw_loading: 'Yükleme diyagramı', + excalidraw_too_large: 'Excalidraw dosyası satır içi oluşturma için çok büyük', + excalidraw_invalid: 'Geçersiz Excalidraw dosya biçimi', + excalidraw_error: 'Excalidraw dosyası yüklenemedi', + excalidraw_label: 'Diyagram', + excalidraw_download: 'İndirmek', + excalidraw_empty: 'Boş diyagram', + excalidraw_render_error: 'Diyagram oluşturulamadı', + excalidraw_simplified: 'Basitleştirilmiş SVG önizlemesi — Excalidraw tuvaliyle piksel açısından aynı değil', + // TTS (#499) + tts_listen: 'Dinlemek', + tts_not_supported: 'Konuşma sentezi bu tarayıcıda desteklenmiyor.', + settings_label_tts: 'Yanıtlar için Metinden Konuşmaya', + settings_desc_tts: '도움말 메시지에 스피커 버튼 표시', + settings_label_tts_auto_read: 'Yanıtları sesli olarak otomatik oku', + settings_desc_tts_auto_read: 'Tamamlandığında her yeni asistanın yanıtını otomatik olarak söyleyin. Yazmaya başladığınızda duraklar.', + // Composer voice-mode pref (#1488) + settings_label_voice_mode: 'Hands-free voice mode button', // TODO: translate + settings_desc_voice_mode: 'Show the voice-mode button (audio waveform) next to the dictation mic. Lets you speak naturally — Hermes auto-sends after a pause and reads replies aloud. Requires a browser that supports both speech recognition and TTS.', // TODO: translate + settings_label_tts_voice: 'Ses', + settings_desc_tts_voice: '음성 합성 음성 선택', + settings_label_tts_rate: 'Konuşma hızı', + settings_label_tts_pitch: 'Konuşma perdesi', + checkpoint_date: 'Date', // TODO: translate + checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate + checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', // TODO: translate + checkpoint_diff_title: 'Changes in checkpoint', // TODO: translate + checkpoint_empty: 'No checkpoints found for this workspace.', // TODO: translate + checkpoint_error: 'Failed to load checkpoints', // TODO: translate + checkpoint_files: 'Files', // TODO: translate + checkpoint_loading: 'Loading checkpoints…', // TODO: translate + checkpoint_message: 'Message', // TODO: translate + checkpoint_restore: 'Restore', // TODO: translate + checkpoint_restore_confirm_message: (ckpt) => `Restore workspace to checkpoint "${ckpt}"? This will overwrite files with the saved versions. Files added after this checkpoint will not be deleted.`, // TODO: translate + checkpoint_restore_confirm_title: 'Restore checkpoint?', // TODO: translate + checkpoint_restored: 'Checkpoint restored', // TODO: translate + checkpoint_title: 'Checkpoints', // TODO: translate + checkpoint_view_diff: 'View diff', // TODO: translate + insights_activity_by_day: 'Activity by Day', // TODO: translate + insights_activity_by_hour: 'Activity by Hour', // TODO: translate + insights_cost: 'Estimated Cost', // TODO: translate + insights_daily_tokens: 'Günlük Jetonlar', + insights_model_name: 'Modeli', + insights_model_sessions: 'Oturumlar', + insights_model_tokens: 'Jetonlar', + insights_model_cost: 'Maliyet', + insights_model_share: 'Paylaşmak', + insights_no_usage_data: 'Henüz kullanım verisi yok', + insights_footer: 'Showing data from the last {days} days', // TODO: translate + insights_input_tokens: 'Input', // TODO: translate + insights_messages: 'Messages', // TODO: translate + insights_models: 'Models', // TODO: translate + insights_no_cost: 'N/A', // TODO: translate + insights_output_tokens: 'Output', // TODO: translate + insights_peak_hour: 'Peak: {hour}', // TODO: translate + insights_sessions: 'Sessions', // TODO: translate + insights_title: 'Usage Analytics', // TODO: translate + insights_token_breakdown: 'Token Breakdown', // TODO: translate + insights_tokens: 'Tokens', // TODO: translate + insights_total: 'Total', // TODO: translate + settings_desc_api_redact: 'Self-hosted users can disable for transparency (not recommended for shared instances).', // TODO: translate + settings_label_api_redact: 'Redact sensitive data in API responses', // TODO: translate + voice_error: 'Voice not supported in this browser', // TODO: translate + voice_listening: 'Listening…', // TODO: translate + voice_mode_active: 'Voice mode on', // TODO: translate + voice_mode_off: 'Voice mode off', // TODO: translate + voice_speaking: 'Speaking…', // TODO: translate + voice_thinking: 'Thinking…', // TODO: translate + // Composer voice buttons (#1488) + voice_dictate: 'Dictate', // TODO: translate + voice_dictate_active: 'Stop dictation', // TODO: translate + voice_mode_toggle: 'Voice mode', // TODO: translate + voice_mode_toggle_active: 'Exit voice mode', // TODO: translate + subagent_children: 'Subagent sessions', // TODO: translate } }; diff --git a/tests/test_1560_password_env_var_no_op.py b/tests/test_1560_password_env_var_no_op.py index bebc25c8..1e8723cb 100644 --- a/tests/test_1560_password_env_var_no_op.py +++ b/tests/test_1560_password_env_var_no_op.py @@ -333,7 +333,7 @@ def test_panels_js_uses_locked_placeholder_i18n_key(): # (en/es/de/zh/zh-Hant/ru/ja/fr/pt). The repo currently ships 9 locales but # substitutes 'ko' for 'fr' — we test what the repo actually has, not what the # issue body lists, so a future addition of fr won't fail the suite either. -EXPECTED_LOCALES = ("en", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko") +EXPECTED_LOCALES = ("en", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr") def _locale_block(locale_key: str) -> str: diff --git a/tests/test_issue1488_composer_voice_buttons.py b/tests/test_issue1488_composer_voice_buttons.py index f1e1a78e..72870e19 100644 --- a/tests/test_issue1488_composer_voice_buttons.py +++ b/tests/test_issue1488_composer_voice_buttons.py @@ -123,7 +123,7 @@ class TestComposerVoiceButtonI18n: "voice_mode_toggle_active", ) - LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko") + LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr") def test_legacy_voice_toggle_key_removed(self): """The old key whose string was 'Voice input' caused the duplicate- @@ -171,7 +171,7 @@ class TestComposerVoiceButtonI18n: class TestVoiceModePreferenceGate: """boot.js must hide btnVoiceMode by default, surface it via Preferences.""" - LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko") + LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr") def test_voice_mode_pref_is_localstorage_backed(self): """The pref reads from localStorage key 'hermes-voice-mode-button'.""" diff --git a/tests/test_issue2419_cache_usage_display.py b/tests/test_issue2419_cache_usage_display.py index e6cf94e2..e5cd8848 100644 --- a/tests/test_issue2419_cache_usage_display.py +++ b/tests/test_issue2419_cache_usage_display.py @@ -63,8 +63,8 @@ def test_context_indicator_surfaces_cache_hit_rate(): def test_cache_usage_labels_are_localized(): src = (ROOT / "static" / "i18n.js").read_text() - assert src.count("usage_cache_hit_detail:") == 11 - assert src.count("usage_cached_percent:") == 11 + assert src.count("usage_cache_hit_detail:") == 12 + assert src.count("usage_cached_percent:") == 12 assert "usage_cache_hit_detail: 'Cache: {0}% hit ({1} read / {2} write)'" in src assert "usage_cached_percent: '{0}% cached'" in src diff --git a/tests/test_issue2462_theme_i18n.py b/tests/test_issue2462_theme_i18n.py index 07e77302..0b83b3fb 100644 --- a/tests/test_issue2462_theme_i18n.py +++ b/tests/test_issue2462_theme_i18n.py @@ -31,7 +31,7 @@ def test_theme_command_help_mentions_current_theme_and_skin_values(): "system/dark/light", "default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast", ) - for locale in ("en", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "fr"): + for locale in ("en", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "fr", "tr"): value = _literal_value(_locale_block(locale), "cmd_theme") for fragment in required_fragments: assert fragment in value, f"{locale} cmd_theme missing {fragment!r}: {value!r}" diff --git a/tests/test_issue2679_hide_suggestions.py b/tests/test_issue2679_hide_suggestions.py index b00b047b..1b4f24e8 100644 --- a/tests/test_issue2679_hide_suggestions.py +++ b/tests/test_issue2679_hide_suggestions.py @@ -50,8 +50,8 @@ def test_panels_round_trip_and_hot_apply_hide_suggestions(): def test_hide_suggestions_i18n_all_locales_and_changelog(): js = I18N.read_text(encoding="utf-8") - assert js.count("settings_label_hide_suggestions:") == 11 - assert js.count("settings_desc_hide_suggestions:") == 11 + assert js.count("settings_label_hide_suggestions:") == 12 + assert js.count("settings_desc_hide_suggestions:") == 12 changelog = CHANGELOG.read_text(encoding="utf-8") assert "#2679" in changelog assert "hide_empty_state_suggestions" in changelog diff --git a/tests/test_pr1721_rtl_salvage.py b/tests/test_pr1721_rtl_salvage.py index f7cd1d7a..bc7c8445 100644 --- a/tests/test_pr1721_rtl_salvage.py +++ b/tests/test_pr1721_rtl_salvage.py @@ -112,5 +112,5 @@ def test_rtl_in_config_defaults_and_writable_keys(): def test_rtl_localized_in_all_locales(): js = I18N.read_text(encoding="utf-8") # Count occurrences — should match the 11 locale blocks - assert js.count("settings_label_rtl:") == 11 - assert js.count("settings_desc_rtl:") == 11 + assert js.count("settings_label_rtl:") == 12 + assert js.count("settings_desc_rtl:") == 12 diff --git a/tests/test_quota_chip_settings_toggle.py b/tests/test_quota_chip_settings_toggle.py index d5cda58e..91c90de7 100644 --- a/tests/test_quota_chip_settings_toggle.py +++ b/tests/test_quota_chip_settings_toggle.py @@ -88,5 +88,5 @@ def test_quota_chip_panels_round_trip(): def test_quota_chip_localized_in_all_locales(): js = I18N.read_text(encoding="utf-8") - assert js.count("settings_label_quota_chip:") == 11, "11 locales expected" - assert js.count("settings_desc_quota_chip:") == 11, "11 locales expected" + assert js.count("settings_label_quota_chip:") == 12, "12 locales expected" + assert js.count("settings_desc_quota_chip:") == 12, "12 locales expected" diff --git a/tests/test_sidebar_tab_visibility.py b/tests/test_sidebar_tab_visibility.py index 43ce2f66..2d80187b 100644 --- a/tests/test_sidebar_tab_visibility.py +++ b/tests/test_sidebar_tab_visibility.py @@ -99,8 +99,8 @@ def test_i18n_coverage(): """Label and description keys must exist in all locales with matching counts.""" label_count = I18N_JS.count("settings_label_tab_visibility") desc_count = I18N_JS.count("settings_desc_tab_visibility") - assert label_count >= 11, f"Expected ≥11 locales, found {label_count}" - assert desc_count >= 11, f"Expected ≥11 locales, found {desc_count}" + assert label_count >= 12, f"Expected ≥12 locales, found {label_count}" + assert desc_count >= 12, f"Expected ≥12 locales, found {desc_count}" assert label_count == desc_count, \ f"Label ({label_count}) and desc ({desc_count}) counts must match" diff --git a/tests/test_turkish_locale.py b/tests/test_turkish_locale.py new file mode 100644 index 00000000..254774af --- /dev/null +++ b/tests/test_turkish_locale.py @@ -0,0 +1,146 @@ +from collections import Counter +from pathlib import Path +import re + + +REPO = Path(__file__).resolve().parent.parent + + +def read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def extract_locale_block(src: str, locale_key: str) -> str: + start_match = re.search(rf"\b{re.escape(locale_key)}\s*:\s*\{{", src) + assert start_match, f"{locale_key} locale block not found" + + start = start_match.end() - 1 + depth = 0 + in_single = False + in_double = False + in_backtick = False + escape = False + + for i in range(start, len(src)): + ch = src[i] + + if escape: + escape = False + continue + + if in_single: + if ch == "\\": + escape = True + elif ch == "'": + in_single = False + continue + + if in_double: + if ch == "\\": + escape = True + elif ch == '"': + in_double = False + continue + + if in_backtick: + if ch == "\\": + escape = True + elif ch == "`": + in_backtick = False + continue + + if ch == "'": + in_single = True + continue + if ch == '"': + in_double = True + continue + if ch == "`": + in_backtick = True + continue + + if ch == "{": + depth += 1 + continue + if ch == "}": + depth -= 1 + if depth == 0: + return src[start + 1 : i] + + raise AssertionError(f"{locale_key} locale block braces are not balanced") + + +def locale_keys(src: str, locale_key: str) -> list[str]: + key_pattern = re.compile(r"^\s*([a-zA-Z0-9_]+)\s*:", re.MULTILINE) + return key_pattern.findall(extract_locale_block(src, locale_key)) + + +def test_turkish_locale_block_exists(): + src = read(REPO / "static" / "i18n.js") + assert "\n tr: {" in src + assert "_lang: 'tr'" in src + assert "_label: 'Türkçe'" in src + assert "_speech: 'tr-TR'" in src + + +def test_turkish_locale_includes_representative_translations(): + src = read(REPO / "static" / "i18n.js") + tr_block = extract_locale_block(src, "tr") + expected = [ + "settings_title: 'Ayarlar'", + "settings_label_language: 'Dil'", + "login_title: 'Oturum aç'", + "approval_heading: 'Onay gerekli'", + "tab_chat: 'Sohbet'", + "tab_tasks: 'Görevler'", + "tab_profiles: 'Agent profilleri'", + "empty_title: 'Hangi konuda yardımcı olabilirim?'", + "onboarding_title: 'Hermes Web Kullanıcı Arayüzüne Hoş Geldiniz'", + ] + for entry in expected: + assert entry in tr_block + + +def test_turkish_settings_detail_descriptions_are_translated(): + src = read(REPO / "static" / "i18n.js") + tr_block = extract_locale_block(src, "tr") + expected = [ + "settings_desc_workspace_panel_open: 'Etkinleştirildiğinde, çalışma alanı / dosya tarayıcı paneli her yeni oturumda otomatik olarak açılır. Yine de istediğiniz zaman manuel olarak kapatabilirsiniz.'", + "settings_desc_notifications: 'Uygulama arka plandayken bir yanıt tamamlandığında bir sistem bildirimi gösterin.'", + "settings_desc_token_usage: 'Her Asistan yanıtının altında giriş/çıkış jeton sayılarını gösterir. /usage ile de değiştirilebilir.'", + "settings_desc_sidebar_density: 'Oturum listesinin sol kenar çubuğunda ne kadar meta veri göstereceğini kontrol eder.'", + "settings_desc_auto_title_refresh: 'Oturum başlıklarını en son konuşmaya göre otomatik olarak yeniden oluşturarak konuşma ilerledikçe başlıkların alakalı kalmasını sağlar. LLM başlık oluşturma modeli yapılandırması gerektirir.'", + "settings_desc_external_sessions: 'Oturum listesinde CLI, Telegram, Discord, Slack ve diğer kanallardan gelen konuşmaları gösterin. İçe aktarmak ve devam etmek için tıklayın.'", + "settings_desc_sync_insights: 'WebUI belirteci kullanımını state.db\\'ye yansıtır, böylece hermes /insights tarayıcı oturum verilerini içerir. Varsayılan olarak kapalıdır.'", + "settings_desc_check_updates: 'WebUI veya Agent\\'ın daha yeni sürümleri mevcut olduğunda bir banner gösterin. Periyodik olarak bir arka plan git getirme işlemi çalıştırır.'", + "settings_desc_bot_name: 'Yalnızca varsayılan profil için kullanılır. Diğer profiller kendi profil adlarını kullanır.'", + "settings_desc_password: 'Ayarlamak veya değiştirmek için yeni bir şifre girin. Geçerli ayarı korumak için boş bırakın.'", + ] + for entry in expected: + assert entry in tr_block + + +def test_turkish_locale_matches_english_key_coverage(): + src = read(REPO / "static" / "i18n.js") + en_keys = set(locale_keys(src, "en")) + tr_keys = set(locale_keys(src, "tr")) + assert sorted(en_keys - tr_keys) == [] + assert sorted(tr_keys - en_keys) == [] + + +def test_turkish_locale_has_no_duplicate_keys(): + src = read(REPO / "static" / "i18n.js") + keys = locale_keys(src, "tr") + duplicates = sorted(k for k, count in Counter(keys).items() if count > 1) + assert not duplicates, f"Turkish locale has duplicate keys: {duplicates}" + + +def test_turkish_locale_keys_use_standard_indentation(): + src = read(REPO / "static" / "i18n.js") + tr_block = extract_locale_block(src, "tr") + badly_indented = [ + line.strip() + for line in tr_block.splitlines() + if re.match(r"^\s{1,3}[a-zA-Z0-9_]+\s*:", line) + ] + assert badly_indented == [] From 6c811dcef55aaa2ce8a80e0f5ec7d300c66da2d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?U=C4=9Fur=20Murat=20Alt=C4=B1ntas?= <100377859+vaur94@users.noreply.github.com> Date: Sat, 23 May 2026 00:39:52 +0300 Subject: [PATCH 49/68] fix(i18n): address Turkish locale review feedback Fix Copilot review issues in the tr locale: Korean string leaks, placeholder order, stray quotes, broken {provider} tags, duplicate English voice keys overriding translations, and remaining TODO strings. Co-authored-by: Cursor --- static/i18n.js | 267 ++++++++++++++++++----------------- tests/test_turkish_locale.py | 9 +- 2 files changed, 140 insertions(+), 136 deletions(-) diff --git a/static/i18n.js b/static/i18n.js index 867b8d58..8c445f70 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -13114,6 +13114,8 @@ const LOCALES = { tr: { + + offline_title: 'Bağlantı kesildi', offline_browser_detail: 'Tarayıcınız bu cihazın çevrimdışı olduğunu bildiriyor.', offline_network_detail: 'Hermes\'e şu anda bu tarayıcıdan ulaşılamıyor.', @@ -13131,6 +13133,16 @@ const LOCALES = { mic_no_speech: 'Konuşma algılanmadı. Tekrar deneyin.', mic_network: 'Konuşma tanıma kullanılamıyor.', mic_error: 'Ses girişi hatası:', + voice_dictate: 'Dikte', + voice_dictate_active: 'Dikteyi durdur', + voice_mode_toggle: 'Ses modu', + voice_mode_toggle_active: 'Ses modundan çık', + voice_listening: 'Dinleniyor\\u2026', + voice_speaking: 'Konuşuyor\\u2026', + voice_thinking: 'Düşünüyor\\u2026', + voice_error: 'Ses bu tarayıcıda desteklenmiyor', + voice_mode_active: 'Ses modu açık', + voice_mode_off: 'Ses modu kapalı', session_imported: 'Oturum içe aktarıldı', import_failed: 'İçe aktarma başarısız oldu:', import_invalid_json: 'Geçersiz JSON', @@ -13251,16 +13263,16 @@ const LOCALES = { model_custom_label: 'Özel model kimliği', model_custom_placeholder: 'örneğin openai/gpt-5.4', model_search_placeholder: 'Modelleri ara\\u2026', - session_toolsets: 'Session Toolsets', // TODO: translate - session_toolsets_desc: 'Restrict available tools for this session (blank = use global config)', // TODO: translate - session_toolsets_global: 'Global (default)', // TODO: translate - session_toolsets_custom: 'Custom', // TODO: translate - session_toolsets_placeholder: 'tool1, tool2, \u2026', // TODO: translate - session_toolsets_apply: 'Apply', // TODO: translate - session_toolsets_clear: 'Clear (use global)', // TODO: translate - session_toolsets_applied: 'Toolsets updated', // TODO: translate - session_toolsets_cleared: 'Toolsets cleared — using global config', // TODO: translate - session_toolsets_failed: 'Failed to update toolsets: ', // TODO: translate + session_toolsets: 'Oturum araç setleri', + session_toolsets_desc: 'Bu oturum için kullanılabilir araçları kısıtlayın (boş = genel yapılandırma)', + session_toolsets_global: 'Genel (varsayılan)', + session_toolsets_custom: 'Özel', + session_toolsets_placeholder: 'araç1, araç2, \u2026', + session_toolsets_apply: 'Uygula', + session_toolsets_clear: 'Temizle (geneli kullan)', + session_toolsets_applied: 'Araç setleri güncellendi', + session_toolsets_cleared: 'Araç setleri temizlendi — genel yapılandırma kullanılıyor', + session_toolsets_failed: 'Araç setleri güncellenemedi: ', model_search_no_results: 'Hiçbir model bulunamadı', model_group_configured: 'Yapılandırılmış', ws_search_placeholder: 'Çalışma alanlarını arayın\\u2026', @@ -13289,17 +13301,17 @@ const LOCALES = { available_commands: 'Mevcut komutlar:', type_slash: 'Komutları görmek için / yazın', conversation_cleared: 'Görüşme temizlendi', - command_label: 'Emretmek', + command_label: 'Komut', context_compaction_label: 'Bağlam sıkıştırma', retrieval_context_label: 'Dizine alınmış bağlam', retrieval_context_preview: 'Daha önceki mesajlar depolanır ve bağlam araçlarıyla alınabilir', preserved_task_list_label: 'Korunmuş görev listesi', reference_only_label: 'Yalnızca referans', model_usage: 'Kullanım: /model ', - no_model_match: 'Eşleşen model yok"', + no_model_match: 'Eşleşen model yok: "', switched_to: 'Şuraya geçildi:', workspace_usage: 'Kullanım: /workspace ', - no_workspace_match: 'Eşleşen çalışma alanı yok "', + no_workspace_match: 'Eşleşen çalışma alanı yok: "', switched_workspace: 'Çalışma alanına geçildi:', workspace_switch_failed: 'Çalışma alanı anahtarı başarısız oldu:', new_session: 'Yeni oturum oluşturuldu', @@ -13312,9 +13324,9 @@ const LOCALES = { focus_label: 'Odak', token_usage_on: 'Jeton kullanımı açık', token_usage_off: 'Jeton kullanımı kapalı', - usage_cache_hit_detail: 'Önbellek: %{0} isabet ({1} okuma / {2} yazma)', - usage_cached_percent: '%{0} önbelleğe alındı', - theme_usage: 'Kullanım: /tema', + usage_cache_hit_detail: 'Önbellek: {0}% isabet ({1} okuma / {2} yazma)', + usage_cached_percent: '{0}% önbelleğe alındı', + theme_usage: 'Kullanım: /theme ', theme_set: 'Tema:', no_active_session: 'Aktif oturum yok', cmd_queue: 'Bir sonraki dönüş için bir mesajı sıraya koy', @@ -13459,8 +13471,8 @@ const LOCALES = { save: 'Kaydetmek', edit: 'Düzenlemek', clear: 'Temizle', - create: 'Yaratmak', - remove: 'Kaldırmak', + create: 'Oluştur', + remove: 'Kaldır', save_title: 'Değişiklikleri kaydet', edit_title: 'Bu dosyayı düzenle', saved: 'Kaydedildi', @@ -13471,9 +13483,9 @@ const LOCALES = { double_click_rename: 'Yeniden adlandırmak için çift tıklayın', renamed_to: 'Yeniden adlandırıldı', rename_failed: 'Yeniden adlandırma başarısız oldu:', - delete_title: 'Silmek', - delete_confirm: (name) => `${name}을(를) nerelisin?`, - delete_dir_confirm: (name) => `"${name}" ne işe yaradı?`, + delete_title: 'Sil', + delete_confirm: (name) => `${name} silinsin mi?`, + delete_dir_confirm: (name) => `"${name}" klasörü ve tüm içeriği silinsin mi?`, rename_title: 'Yeniden isimlendirmek', rename_prompt: 'Yeni isim:', deleted: 'Silindi', @@ -13481,7 +13493,7 @@ const LOCALES = { reveal_in_finder: 'Dosya Yöneticisinde Göster', reveal_failed: 'Açıklanamadı:', copy_file_path: 'Dosya yolunu kopyala', - download_folder: 'Download Folder', // TODO: translate + download_folder: 'İndirme klasörü', path_copied: 'Dosya yolu panoya kopyalandı', path_copy_failed: 'Yol kopyalanamadı:', session_rename: 'Konuşmayı yeniden adlandır', @@ -13496,7 +13508,7 @@ const LOCALES = { workspace_auto_create_folder: 'Mevcut değilse klasör oluşturun', folder_add_as_space_btn: 'Alan Olarak Ekle', folder_add_as_space_msg: 'Bu klasör çalışma alanı listenize yeni bir alan olarak eklensin mi?', - archive_extracted: (n, c) => `${c}개 압축 파일에서 ${n}개 파일 압축 해제됨`, + archive_extracted: (n, c) => `${c} arşivden ${n} dosya çıkarıldı`, folder_add_as_space_title: 'Alan olarak eklensin mi?', remove_title: 'Kaldırmak', empty_dir: '(boş)', @@ -13533,13 +13545,13 @@ const LOCALES = { session_delete_desc: 'Bu görüşmeyi kalıcı olarak kaldır', session_delete_confirm: 'Bu görüşme silinsin mi?', session_delete_worktree_desc: 'Yalnızca WebUI görüşmesini silin; çalışma ağacını diskte tut', - session_delete_worktree_confirm: (path) => `Ne düşünüyorsunuz? ${path}의 worktree는 디스크에 남아 있습니다.`, + session_delete_worktree_confirm: (path) => `Bu görüşme silinsin mi? ${path} worktree diske kalır.`, session_deleted: 'Görüşme silindi', session_deleted_worktree: 'Görüşme silindi. Worktree diskte kalır.', session_worktree_remove: 'Çalışma ağacını kaldır', - session_worktree_remove_desc: (path) => `${path}의 git worktree를 디스크에서 삭제합니다`, - session_worktree_remove_confirm: (path) => `git worktree를 디스크에서 삭제하시겠습니까?\n\n경로: ${path}\n\n전체 worktree 디렉토리가 삭제됩니다. WebUI에 보존됩니다.`, - session_worktree_remove_not_exists: (path) => `${path}의 worktree가 디스크에 더 이상 존재하지 않습니다.`, + session_worktree_remove_desc: (path) => `${path} git worktree'sini diskten siler`, + session_worktree_remove_confirm: (path) => `Git worktree diskten silinsin mi?\n\nYol: ${path}\n\nTüm worktree dizini silinir. Oturum verileri WebUI'de kalır.`, + session_worktree_remove_not_exists: (path) => `${path} worktree'si artık diskte yok.`, session_worktree_remove_confirm_label: 'Kaldırmak', session_worktree_removed: 'Worktree kaldırıldı.', session_worktree_remove_failed: 'Çalışma ağacı kaldırılamadı:', @@ -13548,8 +13560,8 @@ const LOCALES = { session_worktree_remove_locked_by_terminal: 'Kaldırılamıyor — etkin bir terminal oturumu bu çalışma ağacını kullanıyor.', session_worktree_remove_unsafe_blocked: 'Bu çalışma ağacını kaldırmadan önce yerel değişiklikleri veya gönderilmemiş taahhütleri çözümleyin.', session_worktree_remove_dirty_warning: 'UYARI: Bu çalışma ağacında kaybolacak kaydedilmemiş değişiklikler var.', - session_worktree_remove_untracked_warning: (count) => `${count}개의 추적되지 않은 파일이 영구적으로 삭제됩니다.`, - session_worktree_remove_ahead_warning: (ahead) => `${ahead} 푸시되지 않은 커밋이 손실됩니다.`, + session_worktree_remove_untracked_warning: (count) => `${count} izlenmeyen dosya kalıcı olarak silinecek.`, + session_worktree_remove_ahead_warning: (ahead) => `${ahead} gönderilmemiş commit kaybolacak.`, session_select_mode: 'Seçme', session_select_mode_desc: 'Toplu olarak yönetilecek konuşmaları seçin', session_select_all: 'Tümünü seç', @@ -13600,18 +13612,18 @@ const LOCALES = { settings_tab_appearance: 'Dış görünüş', settings_tab_preferences: 'Tercihler', settings_tab_plugins: 'Eklentiler', - settings_plugins_title: 'Plugins', // TODO: translate - settings_plugins_meta: 'View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.', // TODO: translate - settings_plugins_empty: 'No Hermes plugins are currently visible. Install or enable plugins from the Hermes CLI/config to see them here.', // TODO: translate - plugins_unnamed: 'Unnamed plugin', // TODO: translate - plugins_no_description: 'No description provided.', // TODO: translate - plugins_no_hooks: 'No registered lifecycle hooks', // TODO: translate - plugins_registered_hooks: 'Registered hooks', // TODO: translate - plugins_enabled: 'Enabled', // TODO: translate - plugins_disabled: 'Disabled', // TODO: translate - plugins_active_provider: 'Active (provider)', // TODO: translate - plugins_provider_no_hooks: 'Provider plugin — no agent-visibility hooks', // TODO: translate - plugins_load_failed: 'Failed to load plugins: ', // TODO: translate + settings_plugins_title: 'Eklentiler', + settings_plugins_meta: 'Yüklü Hermes eklentilerini ve yaşam döngüsü kancalarını görüntüleyin. Bu panel salt okunurdur.', + settings_plugins_empty: 'Görünür Hermes eklentisi yok. Burada görmek için Hermes CLI/config üzerinden yükleyin veya etkinleştirin.', + plugins_unnamed: 'Adsız eklenti', + plugins_no_description: 'Açıklama yok.', + plugins_no_hooks: 'Kayıtlı yaşam döngüsü kancası yok', + plugins_registered_hooks: 'Kayıtlı kancalar', + plugins_enabled: 'Etkin', + plugins_disabled: 'Devre dışı', + plugins_active_provider: 'Etkin (sağlayıcı)', + plugins_provider_no_hooks: 'Sağlayıcı eklentisi — agent görünürlük kancası yok', + plugins_load_failed: 'Eklentiler yüklenemedi: ', settings_tab_system: 'Sistem', settings_title: 'Ayarlar', settings_save_btn: 'Ayarları Kaydet', @@ -13745,7 +13757,7 @@ const LOCALES = { kanban_board_icon: 'Simge (emoji, isteğe bağlı)', kanban_board_color: 'Renk (isteğe bağlı)', kanban_board_name_required: 'Ad gerekli', - kanban_board_slug_required: 'Sümüklüböcek gerekli', + kanban_board_slug_required: 'Slug gerekli', kanban_card_complete: 'tamamlamak', kanban_card_archive: 'arşiv', kanban_unassigned: 'atanmamış', @@ -13757,19 +13769,19 @@ const LOCALES = { tab_logs: 'Günlükler', tab_settings: 'Ayarlar', - logs_title: 'Logs', // TODO: translate - logs_file: 'File', // TODO: translate - logs_tail: 'Tail', // TODO: translate - logs_auto_refresh: 'Auto-refresh (5s)', // TODO: translate - logs_wrap: 'Wrap lines', // TODO: translate - logs_copy_all: 'Copy all', // TODO: translate - logs_empty: 'No log lines yet.', // TODO: translate - logs_loading: 'Loading logs…', // TODO: translate - logs_load_failed: 'Logs failed to load', // TODO: translate - logs_status_idle: 'Choose a log file to view recent lines.', // TODO: translate - logs_no_mtime: 'not written yet', // TODO: translate - logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate - logs_copied: 'Logs copied', // TODO: translate + logs_title: 'Günlükler', + logs_file: 'Dosya', + logs_tail: 'Son satırlar', + logs_auto_refresh: 'Otomatik yenile (5 sn)', + logs_wrap: 'Satırları kaydır', + logs_copy_all: 'Tümünü kopyala', + logs_empty: 'Henüz günlük satırı yok.', + logs_loading: 'Günlükler yükleniyor\u2026', + logs_load_failed: 'Günlükler yüklenemedi', + logs_status_idle: 'Son satırları görmek için bir günlük dosyası seçin.', + logs_no_mtime: 'henüz yazılmadı', + logs_truncated_hint: 'Büyük günlük dosyasının sonu gösteriliyor; bellek sınırı için eski veriler atlandı.', + logs_copied: 'Günlükler kopyalandı', logs_severity: 'Şiddet', logs_severity_all: 'Tüm', logs_severity_errors: 'Hatalar', @@ -13778,10 +13790,10 @@ const LOCALES = { new_conversation: 'Yeni görüşme', filter_conversations: 'Konuşmaları filtrele...', session_time_unknown: 'Bilinmiyor', - session_time_minutes_ago: (n) => `${n} milyon dolar`, - session_time_hours_ago: (n) => `${n}sa`, - session_time_days_ago: (n) => `${n}d`, - session_time_last_week: '1 saat', + session_time_minutes_ago: (n) => `${n} dk önce`, + session_time_hours_ago: (n) => `${n} sa önce`, + session_time_days_ago: (n) => `${n} gün önce`, + session_time_last_week: 'geçen hafta', session_time_bucket_today: 'Bugün', session_time_bucket_yesterday: 'Dün', session_time_bucket_this_week: 'Bu hafta', @@ -13944,9 +13956,9 @@ const LOCALES = { onboarding_notice_setup_required: 'Burada basit bir sağlayıcı yolu seçin. Gelişmiş OAuth akışları şimdilik Hermes CLI\'ye ait olmaya devam ediyor.', onboarding_notice_setup_already_ready: 'Çalışan bir Hermes sağlayıcı kurulumu zaten algılandı. Burada saklayabilir veya değiştirebilirsiniz.', onboarding_oauth_provider_ready_title: 'Sağlayıcının kimliği zaten doğrulandı', - onboarding_oauth_provider_ready_body: 'Bu örnek, Hermes CLI aracılığıyla ayarlanmış bir OAuth sağlayıcısını ({provider) kullanacak şekilde yapılandırılmıştır. Burada API anahtarına gerek yoktur; kurulumu tamamlamak için Devam\'a tıklayın.', + onboarding_oauth_provider_ready_body: 'Bu örnek, Hermes CLI aracılığıyla ayarlanmış bir OAuth sağlayıcısını ({provider}) kullanacak şekilde yapılandırılmıştır. Burada API anahtarına gerek yoktur; kurulumu tamamlamak için Devam\'a tıklayın.', onboarding_oauth_provider_not_ready_title: 'OAuth sağlayıcısının kimliği henüz doğrulanmadı', - onboarding_oauth_provider_not_ready_body: 'Bu örnek, API anahtarı yerine OAuth kullanan {provider\'i kullanacak şekilde yapılandırılmıştır. Kimlik doğrulaması yapmak için bir terminalde hermes auth veya hermes model\'i çalıştırın, ardından Web kullanıcı arayüzünü yeniden yükleyin.', + onboarding_oauth_provider_not_ready_body: 'Bu örnek, API anahtarı yerine OAuth kullanan {provider} sağlayıcısını kullanır. Kimlik doğrulamak için terminalde hermes auth veya hermes model çalıştırın, ardından WebUI\'yi yeniden yükleyin.', onboarding_oauth_switch_hint: 'Veya API anahtarı kurulumuna geçmek için aşağıdan farklı bir sağlayıcı seçin:', onboarding_notice_workspace: 'Bu değerler normal uygulamayla aynı ayar API\'lerini yeniden kullanır.', onboarding_workspace_label: 'Çalışma alanı', @@ -13958,17 +13970,17 @@ const LOCALES = { provider_category_self_hosted: 'Açık / kendi kendine barındırılan', provider_category_specialized: 'Uzmanlaşmış', onboarding_api_key_label: 'API anahtarı', - oauth_login_codex: 'Login with Codex (ChatGPT)', // TODO: translate - oauth_codex_step1: 'Step 1: Visit this URL and enter the code', // TODO: translate - oauth_codex_step2: 'Step 2: Enter this code on the page', // TODO: translate - oauth_codex_polling: 'Waiting for authorization...', // TODO: translate - oauth_codex_success: 'Codex OAuth login successful!', // TODO: translate - oauth_codex_error: 'OAuth login failed', // TODO: translate - oauth_codex_expired: 'Code expired, please try again', // TODO: translate + oauth_login_codex: 'Codex (ChatGPT) ile giriş yap', + oauth_codex_step1: 'Adım 1: Bu URL\'yi ziyaret edin ve kodu girin', + oauth_codex_step2: 'Adım 2: Bu kodu sayfaya girin', + oauth_codex_polling: 'Yetkilendirme bekleniyor...', + oauth_codex_success: 'Codex OAuth girişi başarılı!', + oauth_codex_error: 'OAuth girişi başarısız', + oauth_codex_expired: 'Kodun süresi doldu, lütfen tekrar deneyin', onboarding_api_key_placeholder: 'Mevcut kayıtlı anahtarı korumak için boş bırakın', - onboarding_api_key_label_optional: 'API key (optional)', // TODO: translate - onboarding_api_key_placeholder_optional: 'Leave blank for keyless servers', // TODO: translate - onboarding_api_key_help_keyless: 'Most LM Studio / Ollama / vLLM installs run keyless — leave this blank if your server doesn\'t require authentication. Use the Test connection button to verify.', // TODO: translate + onboarding_api_key_label_optional: 'API anahtarı (isteğe bağlı)', + onboarding_api_key_placeholder_optional: 'Anahtarsız sunucular için boş bırakın', + onboarding_api_key_help_keyless: 'Çoğu LM Studio / Ollama / vLLM kurulumu anahtarsız çalışır — sunucunuz kimlik doğrulama gerektirmiyorsa boş bırakın. Bağlantıyı test et düğmesini kullanın.', onboarding_api_key_help_prefix: 'Hermes .env dosyanızda gizli olarak kaydedildi', onboarding_base_url_label: 'Temel URL', onboarding_base_url_placeholder: 'https://uç noktanız.example/v1', @@ -13994,19 +14006,19 @@ const LOCALES = { onboarding_error_choose_model: 'Devam etmeden önce bir model seçin.', onboarding_error_provider_required: 'Devam etmeden önce bir kurulum modu seçin.', onboarding_error_base_url_required: 'Özel uç noktalar için temel URL gereklidir.', - onboarding_probe_test_button: 'Test connection', // TODO: translate - onboarding_probe_probing: 'Testing connection…', // TODO: translate - onboarding_probe_ok: 'Connected. {n} model(s) available.', // TODO: translate - onboarding_probe_error_generic: 'Could not reach the configured base URL.', // TODO: translate - onboarding_probe_error_invalid_url: 'Base URL must start with http:// or https://.', // TODO: translate - onboarding_probe_error_dns: 'Could not resolve the host. Check the URL or use the host\'s IP address.', // TODO: translate - onboarding_probe_error_connect_refused: 'Connection refused — the server may not be running on that address. From inside Docker, try the host IP instead of localhost.', // TODO: translate - onboarding_probe_error_timeout: 'The endpoint did not respond in time. Check that the server is running and the URL is correct.', // TODO: translate - onboarding_probe_error_http_4xx: 'The endpoint returned a client error. Check authentication and the URL path (typically ends in /v1).', // TODO: translate - onboarding_probe_error_http_5xx: 'The endpoint returned a server error. Check the LM Studio / Ollama server logs.', // TODO: translate - onboarding_probe_error_parse: 'The endpoint did not return a model list in the expected shape. Verify the URL points to the OpenAI-compatible API root.', // TODO: translate - onboarding_probe_error_unreachable: 'Could not reach the configured base URL.', // TODO: translate - onboarding_error_probe_failed: 'Could not validate the configured base URL.', // TODO: translate + onboarding_probe_test_button: 'Bağlantıyı test et', + onboarding_probe_probing: 'Bağlantı test ediliyor\u2026', + onboarding_probe_ok: 'Bağlandı. {n} model mevcut.', + onboarding_probe_error_generic: 'Yapılandırılmış temel URL\'ye ulaşılamadı.', + onboarding_probe_error_invalid_url: 'Temel URL http:// veya https:// ile başlamalı.', + onboarding_probe_error_dns: 'Ana bilgisayar çözülemedi. URL\'yi kontrol edin veya IP adresini kullanın.', + onboarding_probe_error_connect_refused: 'Bağlantı reddedildi — sunucu bu adreste çalışmıyor olabilir. Docker içinden localhost yerine ana bilgisayar IP\'sini deneyin.', + onboarding_probe_error_timeout: 'Uç nokta zamanında yanıt vermedi. Sunucunun çalıştığını ve URL\'nin doğru olduğunu kontrol edin.', + onboarding_probe_error_http_4xx: 'Uç nokta istemci hatası döndürdü. Kimlik doğrulama ve URL yolunu kontrol edin (genelde /v1 ile biter).', + onboarding_probe_error_http_5xx: 'Uç nokta sunucu hatası döndürdü. LM Studio / Ollama günlüklerini kontrol edin.', + onboarding_probe_error_parse: 'Uç nokta beklenen biçimde model listesi döndürmedi. URL\'nin OpenAI uyumlu API köküne işaret ettiğini doğrulayın.', + onboarding_probe_error_unreachable: 'Yapılandırılmış temel URL\'ye ulaşılamadı.', + onboarding_error_probe_failed: 'Yapılandırılmış temel URL doğrulanamadı.', onboarding_error_workspace_required: 'Çalışma alanı gerekli.', onboarding_error_model_required: 'Model gerekli.', onboarding_complete: 'İlk katılım tamamlandı', @@ -14266,34 +14278,34 @@ const LOCALES = { tts_listen: 'Dinlemek', tts_not_supported: 'Konuşma sentezi bu tarayıcıda desteklenmiyor.', settings_label_tts: 'Yanıtlar için Metinden Konuşmaya', - settings_desc_tts: '도움말 메시지에 스피커 버튼 표시', + settings_desc_tts: 'Yardım mesajlarında hoparlör düğmesini göster', settings_label_tts_auto_read: 'Yanıtları sesli olarak otomatik oku', settings_desc_tts_auto_read: 'Tamamlandığında her yeni asistanın yanıtını otomatik olarak söyleyin. Yazmaya başladığınızda duraklar.', // Composer voice-mode pref (#1488) - settings_label_voice_mode: 'Hands-free voice mode button', // TODO: translate - settings_desc_voice_mode: 'Show the voice-mode button (audio waveform) next to the dictation mic. Lets you speak naturally — Hermes auto-sends after a pause and reads replies aloud. Requires a browser that supports both speech recognition and TTS.', // TODO: translate + settings_label_voice_mode: 'Eller serbest ses modu düğmesi', + settings_desc_voice_mode: 'Dikte mikrofonunun yanında ses modu düğmesini gösterir. Duraklamadan sonra Hermes otomatik gönderir ve yanıtları sesli okur. Konuşma tanıma ve TTS destekleyen tarayıcı gerektirir.', settings_label_tts_voice: 'Ses', - settings_desc_tts_voice: '음성 합성 음성 선택', + settings_desc_tts_voice: 'Ses sentezi sesini seçin', settings_label_tts_rate: 'Konuşma hızı', settings_label_tts_pitch: 'Konuşma perdesi', - checkpoint_date: 'Date', // TODO: translate - checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate - checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', // TODO: translate - checkpoint_diff_title: 'Changes in checkpoint', // TODO: translate - checkpoint_empty: 'No checkpoints found for this workspace.', // TODO: translate - checkpoint_error: 'Failed to load checkpoints', // TODO: translate - checkpoint_files: 'Files', // TODO: translate - checkpoint_loading: 'Loading checkpoints…', // TODO: translate - checkpoint_message: 'Message', // TODO: translate - checkpoint_restore: 'Restore', // TODO: translate - checkpoint_restore_confirm_message: (ckpt) => `Restore workspace to checkpoint "${ckpt}"? This will overwrite files with the saved versions. Files added after this checkpoint will not be deleted.`, // TODO: translate - checkpoint_restore_confirm_title: 'Restore checkpoint?', // TODO: translate - checkpoint_restored: 'Checkpoint restored', // TODO: translate - checkpoint_title: 'Checkpoints', // TODO: translate - checkpoint_view_diff: 'View diff', // TODO: translate - insights_activity_by_day: 'Activity by Day', // TODO: translate - insights_activity_by_hour: 'Activity by Hour', // TODO: translate - insights_cost: 'Estimated Cost', // TODO: translate + checkpoint_date: 'Tarih', + checkpoint_diff_files_changed: (n) => `${n} dosya değişti`, + checkpoint_diff_no_changes: 'Bu kontrol noktası ile mevcut çalışma alanı arasında fark bulunamadı.', + checkpoint_diff_title: 'Kontrol noktasındaki değişiklikler', + checkpoint_empty: 'Bu çalışma alanı için kontrol noktası bulunamadı.', + checkpoint_error: 'Kontrol noktaları yüklenemedi', + checkpoint_files: 'Dosyalar', + checkpoint_loading: 'Kontrol noktaları yükleniyor\u2026', + checkpoint_message: 'Mesaj', + checkpoint_restore: 'Geri yükle', + checkpoint_restore_confirm_message: (ckpt) => `Çalışma alanı "${ckpt}" kontrol noktasına geri yüklensin mi? Kayıtlı sürümlerle dosyaların üzerine yazılır. Sonradan eklenen dosyalar silinmez.`, + checkpoint_restore_confirm_title: 'Kontrol noktası geri yüklensin mi?', + checkpoint_restored: 'Kontrol noktası geri yüklendi', + checkpoint_title: 'Kontrol noktaları', + checkpoint_view_diff: 'Farkı görüntüle', + insights_activity_by_day: 'Güne göre etkinlik', + insights_activity_by_hour: 'Saate göre etkinlik', + insights_cost: 'Tahmini maliyet', insights_daily_tokens: 'Günlük Jetonlar', insights_model_name: 'Modeli', insights_model_sessions: 'Oturumlar', @@ -14301,32 +14313,23 @@ const LOCALES = { insights_model_cost: 'Maliyet', insights_model_share: 'Paylaşmak', insights_no_usage_data: 'Henüz kullanım verisi yok', - insights_footer: 'Showing data from the last {days} days', // TODO: translate - insights_input_tokens: 'Input', // TODO: translate - insights_messages: 'Messages', // TODO: translate - insights_models: 'Models', // TODO: translate - insights_no_cost: 'N/A', // TODO: translate - insights_output_tokens: 'Output', // TODO: translate - insights_peak_hour: 'Peak: {hour}', // TODO: translate - insights_sessions: 'Sessions', // TODO: translate - insights_title: 'Usage Analytics', // TODO: translate - insights_token_breakdown: 'Token Breakdown', // TODO: translate - insights_tokens: 'Tokens', // TODO: translate - insights_total: 'Total', // TODO: translate - settings_desc_api_redact: 'Self-hosted users can disable for transparency (not recommended for shared instances).', // TODO: translate - settings_label_api_redact: 'Redact sensitive data in API responses', // TODO: translate - voice_error: 'Voice not supported in this browser', // TODO: translate - voice_listening: 'Listening…', // TODO: translate - voice_mode_active: 'Voice mode on', // TODO: translate - voice_mode_off: 'Voice mode off', // TODO: translate - voice_speaking: 'Speaking…', // TODO: translate - voice_thinking: 'Thinking…', // TODO: translate - // Composer voice buttons (#1488) - voice_dictate: 'Dictate', // TODO: translate - voice_dictate_active: 'Stop dictation', // TODO: translate - voice_mode_toggle: 'Voice mode', // TODO: translate - voice_mode_toggle_active: 'Exit voice mode', // TODO: translate - subagent_children: 'Subagent sessions', // TODO: translate + insights_footer: 'Son {days} günün verileri gösteriliyor', + insights_input_tokens: 'Giriş', + insights_messages: 'Mesajlar', + insights_models: 'Modeller', + insights_no_cost: 'Yok', + insights_output_tokens: 'Çıkış', + insights_peak_hour: 'Zirve: {hour}', + insights_sessions: 'Oturumlar', + insights_title: 'Kullanım analitiği', + insights_token_breakdown: 'Jeton dağılımı', + insights_tokens: 'Jetonlar', + insights_total: 'Toplam', + settings_desc_api_redact: 'Kendi sunucunuzda şeffaflık için devre dışı bırakılabilir (paylaşımlı örneklerde önerilmez).', + settings_label_api_redact: 'API yanıtlarında hassas verileri gizle', + subagent_children: 'Alt agent oturumları', + + } }; diff --git a/tests/test_turkish_locale.py b/tests/test_turkish_locale.py index 254774af..d6fa4d5d 100644 --- a/tests/test_turkish_locale.py +++ b/tests/test_turkish_locale.py @@ -77,10 +77,11 @@ def locale_keys(src: str, locale_key: str) -> list[str]: def test_turkish_locale_block_exists(): src = read(REPO / "static" / "i18n.js") - assert "\n tr: {" in src - assert "_lang: 'tr'" in src - assert "_label: 'Türkçe'" in src - assert "_speech: 'tr-TR'" in src + tr_block = extract_locale_block(src, "tr") + assert tr_block + assert "_lang: 'tr'" in tr_block + assert "_label: 'Türkçe'" in tr_block + assert "_speech: 'tr-TR'" in tr_block def test_turkish_locale_includes_representative_translations(): From d4603b096d238876772e813878d598b5f713b3cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?U=C4=9Fur=20Murat=20Alt=C4=B1ntas?= <100377859+vaur94@users.noreply.github.com> Date: Sat, 23 May 2026 09:16:34 +0300 Subject: [PATCH 50/68] fix(i18n): correct double-escaped ellipsis in Turkish locale Replace \\u2026 with \u2026 (and fix \\u2192/\\u2713) in the tr block so ellipsis renders as U+2026 instead of literal backslash-u text. Add a regression test guarding against double-escaped unicode sequences. Co-authored-by: Cursor --- static/i18n.js | 64 +++++++++++++++++++----------------- tests/test_turkish_locale.py | 8 +++++ 2 files changed, 41 insertions(+), 31 deletions(-) diff --git a/static/i18n.js b/static/i18n.js index 8c445f70..685d15c2 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -13116,18 +13116,19 @@ const LOCALES = { + offline_title: 'Bağlantı kesildi', offline_browser_detail: 'Tarayıcınız bu cihazın çevrimdışı olduğunu bildiriyor.', offline_network_detail: 'Hermes\'e şu anda bu tarayıcıdan ulaşılamıyor.', offline_autorefresh: 'Hermes\'e tekrar ulaşılabilir olduğunda bu sayfayı otomatik olarak yenileyeceğim.', offline_check_now: 'Şimdi kontrol et', - offline_checking: 'Kontrol ediliyor\\u2026', - offline_stream_waiting: 'Bağlantı kesildi. Yenilenmesi bekleniyor\\u2026', + offline_checking: 'Kontrol ediliyor\u2026', + offline_stream_waiting: 'Bağlantı kesildi. Yenilenmesi bekleniyor\u2026', _lang: 'tr', _label: 'Türkçe', _speech: 'tr-TR', // boot.js - cancelling: 'İptal ediliyor\\u2026', + cancelling: 'İptal ediliyor\u2026', cancel_failed: 'İptal başarısız oldu:', mic_denied: 'Mikrofon erişimi reddedildi. Tarayıcı izinlerini kontrol edin.', mic_no_speech: 'Konuşma algılanmadı. Tekrar deneyin.', @@ -13137,9 +13138,9 @@ const LOCALES = { voice_dictate_active: 'Dikteyi durdur', voice_mode_toggle: 'Ses modu', voice_mode_toggle_active: 'Ses modundan çık', - voice_listening: 'Dinleniyor\\u2026', - voice_speaking: 'Konuşuyor\\u2026', - voice_thinking: 'Düşünüyor\\u2026', + voice_listening: 'Dinleniyor\u2026', + voice_speaking: 'Konuşuyor\u2026', + voice_thinking: 'Düşünüyor\u2026', voice_error: 'Ses bu tarayıcıda desteklenmiyor', voice_mode_active: 'Ses modu açık', voice_mode_off: 'Ses modu kapalı', @@ -13198,7 +13199,7 @@ const LOCALES = { mcp_enabled_no: 'Engelli', mcp_tools_title: 'MCP Araçları', mcp_tools_desc: 'Aktif MCP sunucularında bilinen araçları arayın.', - mcp_tools_search_placeholder: 'Araçlarda ada, sunucuya veya açıklamaya göre arama yapın\\u2026', + mcp_tools_search_placeholder: 'Araçlarda ada, sunucuya veya açıklamaya göre arama yapın\u2026', mcp_tools_no_tools: 'Etkin çalışma zamanı envanterinde hiçbir MCP aracı mevcut değil.', mcp_tools_no_matches: 'Aramanızla eşleşen MCP aracı yok.', mcp_tools_load_failed: 'MCP araçları yüklenemedi.', @@ -13236,13 +13237,13 @@ const LOCALES = { approval_btn_always_title: 'Bu komut düzenine her zaman izin ver', approval_btn_deny: 'Reddetmek', approval_btn_deny_title: 'Reddet — bu komutu çalıştırma', - approval_responding: 'Yanıt veriliyor\\u2026', + approval_responding: 'Yanıt veriliyor\u2026', clarify_heading: 'Açıklama gerekli', clarify_hint: 'Bir seçim yapın veya kendi cevabınızı aşağıya yazın.', clarify_other: 'Diğer', clarify_send: 'Göndermek', - clarify_input_placeholder: 'Yanıtınızı yazın\\u2026', - clarify_responding: 'Yanıt veriliyor\\u2026', + clarify_input_placeholder: 'Yanıtınızı yazın\u2026', + clarify_responding: 'Yanıt veriliyor\u2026', untitled: 'İsimsiz', n_messages: (n) => `${n}개 메시지`, load_older_messages: '↑ Eski mesajları yüklemek için yukarı kaydırın veya tıklayın', @@ -13262,7 +13263,7 @@ const LOCALES = { model_not_found_label: 'Model bulunamadı', model_custom_label: 'Özel model kimliği', model_custom_placeholder: 'örneğin openai/gpt-5.4', - model_search_placeholder: 'Modelleri ara\\u2026', + model_search_placeholder: 'Modelleri ara\u2026', session_toolsets: 'Oturum araç setleri', session_toolsets_desc: 'Bu oturum için kullanılabilir araçları kısıtlayın (boş = genel yapılandırma)', session_toolsets_global: 'Genel (varsayılan)', @@ -13275,7 +13276,7 @@ const LOCALES = { session_toolsets_failed: 'Araç setleri güncellenemedi: ', model_search_no_results: 'Hiçbir model bulunamadı', model_group_configured: 'Yapılandırılmış', - ws_search_placeholder: 'Çalışma alanlarını arayın\\u2026', + ws_search_placeholder: 'Çalışma alanlarını arayın\u2026', ws_no_results: 'Çalışma alanı bulunamadı', workspace_new_worktree_conversation: 'Worktree\'de yeni konuşma', workspace_new_worktree_conversation_meta: 'Bu çalışma alanı için yalıtılmış bir git çalışma ağacı oluşturun.', @@ -13315,7 +13316,7 @@ const LOCALES = { switched_workspace: 'Çalışma alanına geçildi:', workspace_switch_failed: 'Çalışma alanı anahtarı başarısız oldu:', new_session: 'Yeni oturum oluşturuldu', - new_session_creating: 'Yeni görüşme oluşturuluyor\\u2026', + new_session_creating: 'Yeni görüşme oluşturuluyor\u2026', compressing: 'Bağlam sıkıştırması isteniyor...', compress_running_label: 'Sıkıştırma', compress_complete_label: 'Sıkıştırma tamamlandı', @@ -13331,9 +13332,9 @@ const LOCALES = { no_active_session: 'Aktif oturum yok', cmd_queue: 'Bir sonraki dönüş için bir mesajı sıraya koy', cmd_goal: 'Kalıcı bir hedef belirleyin veya inceleyin', - goal_evaluating_progress: 'Hedef ilerlemesi değerlendiriliyor\\u2026', - goal_working_toward: 'Hedefe doğru çalışmak\\u2026', - goal_continuing_toast: 'Hedefe doğru devam\\u2026', + goal_evaluating_progress: 'Hedef ilerlemesi değerlendiriliyor\u2026', + goal_working_toward: 'Hedefe doğru çalışmak\u2026', + goal_continuing_toast: 'Hedefe doğru devam\u2026', goal_status_none: 'Aktif hedef yok. /goal ile bir tane ayarlayın.', goal_status_active: (turns, max_turns, goal) => `⊙ Hedef (aktif, ${turns}/${max_turns} tur): ${goal}`, goal_status_paused: (turns, max_turns, reason, goal) => `⏸ Hedef (duraklatıldı, ${turns}/${max_turns}${reason ? `, ${reason}` : ''}): ${goal}`, @@ -13455,7 +13456,7 @@ const LOCALES = { terminal_input_failed: 'Terminal girişi başarısız oldu:', terminal_copy_failed: 'Kopyalama başarısız oldu:', terminal_error: 'Terminal hatası', - workspace_empty_no_path: 'Çalışma alanı seçilmedi. Dosyalara göz atmak için Ayarlar \\u2192 Çalışma Alanı\'nda bir çalışma alanı ayarlayın.', + workspace_empty_no_path: 'Çalışma alanı seçilmedi. Dosyalara göz atmak için Ayarlar \u2192 Çalışma Alanı\'nda bir çalışma alanı ayarlayın.', workspace_empty_dir: 'Bu çalışma alanı boş.', workspace_show_hidden_files: 'Gizli dosyaları göster', workspace_show_hidden_files_desc: 'Dosya ağacına .DS_Store, .git, node_modules ve diğer gizli / sistem dosyalarını ekleyin.', @@ -13586,8 +13587,8 @@ const LOCALES = { settings_section_system_title: 'Sistem', settings_section_system_meta: 'Örnek sürümü ve erişim kontrolleri.', settings_check_now: 'Şimdi kontrol et', - settings_checking: 'Kontrol ediliyor\\u2026', - settings_up_to_date: 'Güncel \\u2713', + settings_checking: 'Kontrol ediliyor\u2026', + settings_up_to_date: 'Güncel \u2713', settings_updates_available: '{count} güncelleme mevcut', settings_updates_disabled: 'Güncelleme kontrolleri devre dışı bırakıldı', settings_update_check_failed: 'Güncelleme kontrolü başarısız oldu', @@ -13637,7 +13638,7 @@ const LOCALES = { font_size_default: 'Varsayılan', font_size_large: 'Büyük', font_size_xlarge: 'Ekstra Büyük', - settings_autosave_saving: 'Kaydediliyor\\u2026', + settings_autosave_saving: 'Kaydediliyor\u2026', settings_autosave_saved: 'Kaydedildi', settings_autosave_failed: 'Kaydetme başarısız oldu', settings_autosave_retry: 'Yeniden dene', @@ -13746,9 +13747,9 @@ const LOCALES = { kanban_unblock: 'Engellemeyi kaldır', kanban_back_to_board: 'Panoya geri dön', kanban_lanes_by_profile: 'Profile göre şeritler', - kanban_new_board: 'Yeni yönetim kurulu\\u2026', - kanban_rename_board: 'Mevcut panoyu yeniden adlandır\\u2026', - kanban_archive_board: 'Mevcut panoyu arşivle\\u2026', + kanban_new_board: 'Yeni yönetim kurulu\u2026', + kanban_rename_board: 'Mevcut panoyu yeniden adlandır\u2026', + kanban_archive_board: 'Mevcut panoyu arşivle\u2026', kanban_archive_board_confirm: 'Arşiv panosu "{name}"? Görevler diskte kalır ve pano kanban/boards/_archived/ adresinden geri yüklenebilir.', kanban_board_archived: 'Pano arşivlendi', kanban_board_name: 'İsim', @@ -13844,7 +13845,7 @@ const LOCALES = { settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", settings_desc_bot_name: 'Yalnızca varsayılan profil için kullanılır. Diğer profiller kendi profil adlarını kullanır.', settings_desc_password: 'Ayarlamak veya değiştirmek için yeni bir şifre girin. Geçerli ayarı korumak için boş bırakın.', - password_placeholder: 'Yeni şifreyi girin\\u2026', + password_placeholder: 'Yeni şifreyi girin\u2026', password_env_var_locked: 'HERMES_WEBUI_PASSWORD ortam değişkeni şu anda ayarlıdır ve önceliklidir. Şifreyi buradan yönetmek için ayarı kaldırın ve sunucuyu yeniden başlatın.', password_env_var_locked_placeholder: 'Kilitli: HERMES_WEBUI_PASSWORD ortam değişkeni ayarlandı', disable_auth: 'Kimlik Doğrulamasını Devre Dışı Bırak', @@ -13863,14 +13864,14 @@ const LOCALES = { providers_oauth_not_configured_hint: 'Kimliği doğrulanmadı. Bu sağlayıcıyı yapılandırmak için terminalde Hermes Auth komutunu çalıştırın.', providers_save: 'Kaydetmek', providers_remove: 'Kaldırmak', - providers_saving: 'Kaydediliyor\\u2026', - providers_removing: 'Kaldırılıyor\\u2026', + providers_saving: 'Kaydediliyor\u2026', + providers_removing: 'Kaldırılıyor\u2026', providers_enter_key: 'Lütfen bir API anahtarı girin', providers_empty: 'Yapılandırılabilir sağlayıcı bulunamadı.', providers_key_updated: 'API anahtarı kaydedildi', providers_key_removed: 'API anahtarı kaldırıldı', providers_key_placeholder_new: 'sk-...', - providers_key_placeholder_replace: 'Değiştirilecek yeni anahtarı girin\\u2026', + providers_key_placeholder_replace: 'Değiştirilecek yeni anahtarı girin\u2026', provider_quota_title: 'Etkin sağlayıcı kotası', provider_quota_active_provider: 'Aktif sağlayıcı', provider_quota_last_checked_after_refresh: 'Yenilemeden sonra son kontrol edildi', @@ -14041,7 +14042,7 @@ const LOCALES = { cron_status_paused: 'duraklatıldı', cron_status_error: 'hata', cron_status_active: 'aktif', - cron_status_running: 'koşuyor\\u2026', + cron_status_running: 'koşuyor\u2026', cron_status_needs_attention: 'dikkat edilmesi gerekiyor', cron_attention_desc: 'Bu yinelenen işin bir sonraki çalışma süresi yoktur. Zamanlayıcı bir sonraki çalıştırmayı hesaplamada başarısız olmuş olabilir.', cron_attention_croniter_hint: 'Ağ Geçidi çalışma zamanında croniter paketi eksik olabilir. Ağ Geçidini cron desteğiyle yeniden başlatın, ardından bu işe devam edin.', @@ -14206,7 +14207,7 @@ const LOCALES = { cron_toast_notifications_enabled: 'Etkinleştirilmiş', cron_toast_notifications_disabled: 'Engelli', cron_skills_label: 'Yetenekler', - cron_skills_placeholder: 'Beceri ekleyin (isteğe bağlı)\\u2026', + cron_skills_placeholder: 'Beceri ekleyin (isteğe bağlı)\u2026', cron_skills_edit_hint: 'Beceri listesi oluşturulduktan sonra düzenlenemez.', // workspace form workspace_name_label: 'İsim', @@ -14247,12 +14248,12 @@ const LOCALES = { composer_mobile_reasoning: 'muhakeme', composer_mobile_context: 'Bağlam', - pdf_loading: 'PDF {0} yükleniyor\\u2026', + pdf_loading: 'PDF {0} yükleniyor\u2026', pdf_too_large: 'PDF satır içi önizleme için çok büyük', pdf_no_pages: 'PDF\'de sayfa yok', pdf_error: 'PDF önizlemesi oluşturulamadı', pdf_download: 'PDF\'yi indir', - html_loading: 'HTML önizlemesi yükleniyor\\u2026', + html_loading: 'HTML önizlemesi yükleniyor\u2026', html_too_large: 'HTML satır içi önizleme için çok büyük', html_error: 'HTML önizlemesi oluşturulamadı', html_open_full: 'Tam sayfayı aç', @@ -14330,6 +14331,7 @@ const LOCALES = { subagent_children: 'Alt agent oturumları', + } }; diff --git a/tests/test_turkish_locale.py b/tests/test_turkish_locale.py index d6fa4d5d..5fa0d789 100644 --- a/tests/test_turkish_locale.py +++ b/tests/test_turkish_locale.py @@ -145,3 +145,11 @@ def test_turkish_locale_keys_use_standard_indentation(): if re.match(r"^\s{1,3}[a-zA-Z0-9_]+\s*:", line) ] assert badly_indented == [] + + +def test_turkish_locale_has_no_double_escaped_unicode_sequences(): + """JSON-style double escapes (\\\\u2026) render literal backslash-u in the UI.""" + src = read(REPO / "static" / "i18n.js") + tr_block = extract_locale_block(src, "tr") + for bad in ("\\\\u2026", "\\\\u2192", "\\\\u2713"): + assert bad not in tr_block, f"Turkish locale must not contain {bad!r}" From f92eff573a7751d854abb021c4c1c7f9c61af632 Mon Sep 17 00:00:00 2001 From: hermes-agent Date: Sun, 24 May 2026 17:15:45 +0000 Subject: [PATCH 51/68] =?UTF-8?q?Stage=20403:=20i18n=20parity=20=E2=80=94?= =?UTF-8?q?=20Turkish=20translations=20for=209=20MCP/VS-Code/ignore-agent-?= =?UTF-8?q?updates=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling-PR collision between #2772 (Turkish locale baseline) and #2776 (MCP enable/disable toggle) plus already-shipped master additions for open_in_vscode and ignore_agent_updates. Add Turkish translations for the 9 missing keys to restore locale-parity invariant: mcp_enable_server, mcp_disable_server, mcp_enabled_toast, mcp_disabled_toast, mcp_toggle_failed, open_in_vscode, open_in_vscode_failed, settings_label_ignore_agent_updates, settings_desc_ignore_agent_updates --- static/i18n.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/static/i18n.js b/static/i18n.js index 685d15c2..c1ba196f 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -13188,6 +13188,15 @@ const LOCALES = { mcp_delete_failed: 'MCP sunucusu silinemedi.', mcp_load_failed: 'MCP sunucuları yüklenemedi.', mcp_restart_hint: 'Sunucu değişiklikleri şimdilik burada salt okunurdur. Değişikliklerin etkili olması için config.yaml dosyasını düzenleyin ve Hermes\'i yeniden başlatın.', + mcp_enable_server: 'Bu MCP sunucusunu etkinleştir', + mcp_disable_server: 'Bu MCP sunucusunu devre dışı bırak', + mcp_enabled_toast: (name) => `"${name}" MCP sunucusu etkinleştirildi.`, + mcp_disabled_toast: (name) => `"${name}" MCP sunucusu devre dışı bırakıldı.`, + mcp_toggle_failed: 'MCP sunucusu güncellenemedi.', + open_in_vscode: 'VS Code\'da aç', + open_in_vscode_failed: 'VS Code\'da açılamadı: ', + settings_label_ignore_agent_updates: 'Agent güncellemelerini yoksay', + settings_desc_ignore_agent_updates: 'WebUI güncelleme denetimlerini açık tutun, ancak Agent güncelleme bildirimlerini gizleyin ve Agent güncelleme getirme işlemlerini atlayın.', mcp_toggle_followup: 'Etkinleştirme/devre dışı bırakma kontrolleri, MCP yeniden yükleme semantiği açıkça ortaya çıkana kadar kasıtlı olarak ertelenir.', mcp_status_active: 'Aktif', mcp_status_configured: 'Yapılandırılmış', From 130be3db1dd33341107d8eb4b4a30ceda8a96330 Mon Sep 17 00:00:00 2001 From: hermes-agent Date: Sun, 24 May 2026 17:42:06 +0000 Subject: [PATCH 52/68] Stage 403: Opus pre-release fixes (1 MUST-FIX + 3 SHOULD-FIX) MUST-FIX: - tests/test_2735_open_in_vscode.py: bump expected open_in_vscode locale counter from 10 to 11 (Turkish locale added in #2772). The bump fell out of an in-rebase test edit but never got committed; tagging without this would have shipped a failing test in the release commit. SHOULD-FIX inline: - api/updates.py: case-D drift in _select_apply_compare_ref. The original #2855 fix used latest_tag in the past-tag predicate; the check side uses current_tag (HEAD's nearest reachable tag) plus a 'behind == 0' gate. They drift when HEAD is on an OLDER release tag with commits on top AND a NEWER tag exists ('case D'): check correctly suggests advancing to the newer tag, but apply fell through to origin/. Mirror the check-side predicate exactly. Adds regression test test_select_apply_compare_ref_case_d_older_tag_with_commits_and_newer_tag_exists. - static/messages.js: post-await race guard in _restoreSettledSession. stream_end without preceding 'done' enters the settlement path, awaits /api/session, then sets _streamFinalized=true. If a late 'done' event arrives during that await, it sees _streamFinalized still false and double-runs the finalize. The guard returns early when done won the race, avoiding double renderMessages() + double notification. - server.py: CORS preflight Access-Control-Allow-Methods now includes PUT. #2776 wired PUT into the router for /api/mcp/servers/{name} but didn't update the OPTIONS response. Same-origin only in practice, but cosmetic completeness for CORS-aware deployments. Opus advisor verdict: all 5 risk areas reviewed, 1 MUST-FIX + 3 SHOULD-FIX all addressed inline. Net: +69/-9, no new architecture, no behavior risk. --- api/updates.py | 15 ++++++++-- server.py | 2 +- static/messages.js | 3 ++ tests/test_2735_open_in_vscode.py | 12 ++++---- tests/test_updates.py | 46 +++++++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 9 deletions(-) diff --git a/api/updates.py b/api/updates.py index cc1fb53c..d7793dc3 100644 --- a/api/updates.py +++ b/api/updates.py @@ -384,8 +384,19 @@ def _select_apply_compare_ref(path): tags = _release_tags(path) if tags: latest_tag = tags[0] - # Mirror the predicate _check_repo_release uses to fall through. - if not _head_is_past_latest_tag(path, latest_tag): + current_tag = _current_release_tag(path) + behind = _release_gap(tags, current_tag, latest_tag) + # Mirror the check side exactly: only fall through when behind == 0 + # AND HEAD has moved past its nearest tag (case A: bench between + # tagged releases). Otherwise the tag is correct — including the + # case where HEAD is on an older release tag with commits on top + # AND a newer tag exists (case D), where `behind > 0` means the + # user is genuinely behind the latest release and should advance + # to it. Pre-#2855 the apply path only consulted `latest_tag` + # without the `behind`/`current_tag` predicate, so case D fell + # through to `origin/` and the pull landed past the + # advertised tag. See #2846 + Opus pre-release review for #2855. + if not (behind == 0 and _head_is_past_latest_tag(path, current_tag)): return latest_tag upstream, ok = _run_git(['rev-parse', '--abbrev-ref', '@{upstream}'], path) diff --git a/server.py b/server.py index 7405d073..e6636209 100644 --- a/server.py +++ b/server.py @@ -297,7 +297,7 @@ class Handler(BaseHTTPRequestHandler): self._req_t0 = time.time() self.send_response(200) self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") + self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization") self.end_headers() diff --git a/static/messages.js b/static/messages.js index 57133a01..bfebb94d 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2152,6 +2152,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ async function _restoreSettledSession(){ try{ const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`); + // Opus #2852 race-fix: if a late `done` event ran the finalize path while + // we were awaiting the network roundtrip, bail out — done already settled. + if(_streamFinalized) return true; const session=data&&data.session; if(!session) return false; if(session.active_stream_id||session.pending_user_message) return false; diff --git a/tests/test_2735_open_in_vscode.py b/tests/test_2735_open_in_vscode.py index dbf2fb8c..207d358d 100644 --- a/tests/test_2735_open_in_vscode.py +++ b/tests/test_2735_open_in_vscode.py @@ -226,19 +226,19 @@ class TestOpenInVsCodeI18n: ] def test_open_in_vscode_key_count(self): - """open_in_vscode key must appear exactly once per locale (10 total).""" + """open_in_vscode key must appear exactly once per locale (11 total).""" src = I18N.read_text(encoding="utf-8") count = src.count("open_in_vscode:") - assert count == 10, ( - f"Expected 10 open_in_vscode: entries (one per locale), found {count}" + assert count == 11, ( + f"Expected 11 open_in_vscode: entries (one per locale), found {count}" ) def test_open_in_vscode_failed_key_count(self): - """open_in_vscode_failed key must appear exactly once per locale (10 total).""" + """open_in_vscode_failed key must appear exactly once per locale (11 total).""" src = I18N.read_text(encoding="utf-8") count = src.count("open_in_vscode_failed:") - assert count == 10, ( - f"Expected 10 open_in_vscode_failed: entries (one per locale), found {count}" + assert count == 11, ( + f"Expected 11 open_in_vscode_failed: entries (one per locale), found {count}" ) def test_english_translation_not_a_placeholder(self): diff --git a/tests/test_updates.py b/tests/test_updates.py index 76da7593..71792c44 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -438,6 +438,8 @@ def test_select_apply_compare_ref_uses_tag_when_head_is_on_tag(tmp_path): def fake_git(args, cwd, timeout=10): if args == ['tag', '--list', 'v*', '--sort=-v:refname']: return 'v2026.5.16\nv2026.5.10', True + if args == ['describe', '--tags', '--abbrev=0']: + return 'v2026.5.16', True if args == ['describe', '--tags', '--always']: return 'v2026.5.16', True raise AssertionError(f'unexpected git args: {args!r}') @@ -461,6 +463,9 @@ def test_select_apply_compare_ref_falls_through_when_head_is_past_tag(tmp_path): def fake_git(args, cwd, timeout=10): if args == ['tag', '--list', 'v*', '--sort=-v:refname']: return 'v2026.5.16', True + if args == ['describe', '--tags', '--abbrev=0']: + # HEAD's nearest tag is v2026.5.16; HEAD is 608 commits past it. + return 'v2026.5.16', True if args == ['describe', '--tags', '--always']: return 'v2026.5.16-608-g1d22b9c2d', True if args == ['rev-parse', '--abbrev-ref', '@{upstream}']: @@ -547,3 +552,44 @@ def test_check_and_apply_paths_agree_when_head_is_past_tag(tmp_path): 'when HEAD is past the latest tag (#2846)' ) + +def test_select_apply_compare_ref_case_d_older_tag_with_commits_and_newer_tag_exists(tmp_path): + """Case D — HEAD on older tag + commits + newer tag exists → advance to newer tag. + + Pre-Opus-#2855-fix: the check side correctly reported "behind by N" and + suggested `latest_tag`, but the apply side's predicate consulted + `_head_is_past_latest_tag(path, latest_tag)` which returned True (because + `git describe --tags --always` returns `v.older-N-g...` ≠ `latest_tag`). + So the apply side fell through to `origin/` and the pull landed + PAST the advertised tag — silent drift between check ("advance to + v2026.5.16") and apply ("pulled to whatever origin/main is now"). + + Fix: the apply-side predicate now uses `current_tag` (HEAD's nearest tag) + AND requires `behind == 0`, exactly mirroring the check-side rule. + """ + (tmp_path / '.git').mkdir() + + def fake_git(args, cwd, timeout=10): + if args == ['tag', '--list', 'v*', '--sort=-v:refname']: + return 'v2026.5.16\nv2026.5.10', True + if args == ['describe', '--tags', '--abbrev=0']: + # HEAD's nearest reachable tag (older one) + return 'v2026.5.10', True + if args == ['describe', '--tags', '--always']: + # HEAD has 3 commits past v2026.5.10 + return 'v2026.5.10-3-gabcdef12', True + if args == ['rev-parse', '--abbrev-ref', '@{upstream}']: + return 'origin/main', True + return '', True + + with patch.object(updates, '_run_git', side_effect=fake_git): + apply_ref = updates._select_apply_compare_ref(tmp_path) + + # User is genuinely behind v2026.5.16 (the newer published tag) — apply + # MUST advance to the tag, NOT fall through to origin/. + assert apply_ref == 'v2026.5.16', ( + 'case D: HEAD on older tag with commits + newer tag exists. Apply ' + 'should advance to the newer tag, not silently fall through to ' + 'origin/. Regression for Opus-flagged drift in #2855.' + ) + From d84f8b29cb2fd3ba783b7feacd20d5481d52ea3c Mon Sep 17 00:00:00 2001 From: hermes-agent Date: Sun, 24 May 2026 17:51:54 +0000 Subject: [PATCH 53/68] Stamp CHANGELOG for v0.51.127 (Release CY / stage-batch9 / 7-PR low-risk batch) --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1a18913..bdf8f056 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,36 @@ ## [Unreleased] +## [v0.51.127] — 2026-05-24 — Release CY (stage-batch9 — 7-PR low-risk batch — brick-class Linux + brick-class update apply + composer wide-screen + Turkish locale + MCP toggle + SSE settlement + Windows CI) + +### Fixed + +- **PR #2854** by @nesquena-hermes — Embedded terminal opens then immediately closes with `[terminal closed]` on every Linux install past `71d8a8fb`. Root cause: `_terminal_shell_preexec_fn` set `PR_SET_PDEATHSIG=SIGTERM` on the PTY shell so orphans would die when WebUI crashed, but `PR_SET_PDEATHSIG` is **per-thread**, not per-process. WebUI uses `ThreadingHTTPServer`, so each HTTP request runs in its own short-lived worker thread; when the request handler returns and the worker thread exits, the kernel sees the pdeathsig-parent thread has died and SIGTERMs the PTY shell within ~10ms. macOS users were unaffected because `libc.prctl` doesn't exist there. Fix: drop the `preexec_fn` entirely; rely on `atexit.register(close_all_terminals)` for graceful shutdown and explicit `close_terminal` for user-driven close. Adds `tests/test_terminal_process_cleanup.py::test_pty_shell_survives_when_spawning_thread_exits` (real PTY shell spawned via worker thread, asserts shell alive after 500ms grace) plus static-check that `preexec_fn` cannot be re-introduced. Closes #2853. + +- **PR #2855** by @nesquena-hermes — "Update Now" loops for every user past the latest tag (#2846). After #2758 the update check correctly fell through to branch comparison when `HEAD` had moved past the latest `v*` tag, but `_select_apply_compare_ref` still returned `tags[0]` — so `git pull --ff-only v2026.5.16` no-op'd, the server bounced, and the banner reappeared unchanged. `apply_force_update` had the same bug except worse (would `git reset --hard v2026.5.16` and rewind the checkout 254 commits). Fix: extract `_head_is_past_latest_tag(path, current_tag)` and have both check and apply paths consult it. Opus pre-release review caught a "case D" parameter-asymmetry drift (HEAD on older tag + commits + newer tag exists → predicate flipped between the two callsites) and patched the apply-side predicate to use `current_tag` + a `behind == 0` gate, exactly mirroring the check-side rule. Adds `test_select_apply_compare_ref_case_d_older_tag_with_commits_and_newer_tag_exists`. Closes #2846. + +- **PR #2852** by @ai-ag2026 — Chat `stream_end` handler now settles from the persisted session when `done` was not received or replayed, instead of leaving the active pane with live `Thinking` / assistant DOM and inflight state projected indefinitely. Reconnect / journal / replay paths can deliver `stream_end` without preceding `done`; the prior code treated `stream_end` as transport-only close. Duplicate / replayed `done` events are also made idempotent before completion sound / final render side effects. Opus pre-release review added a post-await race guard inside `_restoreSettledSession` to catch the case where a late `done` event runs the finalize path while the settlement is awaiting the `/api/session` roundtrip. Adds 4 new regression tests across `tests/test_1694_terminal_cleanup_ownership.py` covering both `stream_end`-without-`done` and duplicate-`done` paths. + +- **PR #2811** by @Koraji95-coder — Native-Windows startup E2E workflow now self-tests on PR push (closes the post-#2783 gap where Windows-only regressions like the WOW64 ProgramFiles redirect could only be caught after release). Reworked per maintainer feedback to use a stub `hermes_cli/__init__.py` next to a sibling `hermes-agent/` folder rather than `pip install hermes-agent` (which is not on PyPI). Workflow runs `start.ps1` for 8s and asserts none of its `Write-Error` guards fired (no Python, no agent dir, bad port, missing `hermes_cli`, missing `server.py`). PowerShell syntax + path discovery is the testable surface; the server can't actually boot on a stub. `taskkill` exit-128 swallowed when the stub process is already gone. + +### Changed + +- **PR #2812** by @Koraji95-coder — Composer max-width is now responsive on wide displays. Pre-change `.composer-box` had a fixed `max-width: 780px` that pinched footer chips (workspace name, model picker, reasoning chip, context ring) against each other on 1440p+ monitors. Switched to `max-width: clamp(780px, 60vw, 1100px)` — the 780px floor preserves byte-identical layout at 1280px (Aron's laptop reference width); 1440px viewports gain ~84px (864px composer); 1920px viewports gain ~320px (1100px composer cap). Mobile responsive logic untouched. Single-line CSS change in `static/style.css`. + +### Added + +- **PR #2772** by @vaur94 — Complete Turkish (`tr`) locale across `static/i18n.js` (~1,182 keys matching existing locale coverage). Adds Turkish login page strings in `api/routes.py` `_LOGIN_LOCALE`. Settings → Language now offers **Türkçe**; speech recognition uses `tr-TR`. Stage build absorbed a sibling-PR i18n collision with #2776 below (9 missing keys: `mcp_enable_server`, `mcp_disable_server`, `mcp_enabled_toast`, `mcp_disabled_toast`, `mcp_toggle_failed`, `open_in_vscode`, `open_in_vscode_failed`, `settings_label_ignore_agent_updates`, `settings_desc_ignore_agent_updates`) — Turkish translations added in-stage so locale-parity test passes. Closes #2537 as superseded (byzuzayli's earlier Turkish PR with narrower scope). + +- **PR #2776** by @roryford — New `PATCH /api/mcp/servers/{name}` endpoint accepts `{"enabled": bool}`, writes `mcp_servers..enabled` to `config.yaml`, calls `reload_config()`, returns `{"ok": true, "name": "", "enabled": }`. Each MCP server row in the panel now shows a clickable Enabled/Disabled toggle. Also fixes a pre-existing bug: `_handle_mcp_server_delete` and `_handle_mcp_server_update` were defined at line ~11656 but never wired into the HTTP router — DELETE wired into `handle_delete`, PUT wired via new `handle_put` / `do_PUT` in `server.py`. CORS preflight `Access-Control-Allow-Methods` updated to include `PUT` (Opus pre-release review nit). Adds 5 i18n keys to all 11 locales (en, it, ja, ru, es, de, zh, zh-Hant, pt, ko, fr, tr via in-stage parity fix). 7 new tests covering enable, disable, 404, empty-name, missing-field, response payload, URL-decoded names. + +### Notes + +- Two PRs (#2854, #2855) are brick-class fixes — every Linux install was unable to use the embedded terminal, and every install past the latest agent tag was stuck in an Update Now loop. They land in the same low-risk batch as cosmetic / locale / CI changes because both fixes are mechanical, well-tested, and the brick-class severity made deferring impossible. +- Opus pre-release advisor reviewed all 5 risk areas (PR_SET_PDEATHSIG removal, update apply path symmetry, MCP toggle wiring, composer clamp, stream_end settlement). 1 MUST-FIX + 3 SHOULD-FIX all addressed inline before tag. Net: +69/-9 across 5 files for the Opus fixes. +- Full pytest: 6,424 passed / 6 skipped / 3 xpassed / 8 subtests passed. +- UX evidence for #2812 captured at 1280/1440/1920/mobile (iPhone 14 emulation); Telegram-approved. +- File a follow-up issue for pdeathsig-on-supervisor-thread hardening (#2854 deferred Option B) and French-locale `open_in_vscode` parity gap (predates this batch, Opus advisor flagged). + ## [v0.51.126] — 2026-05-24 — Release CX (stage-batch8 — 2-PR low-risk batch — kanban markdown + live activity timeline) ### Added From 9d95ba0b926a24174147bbe379eb375703b57640 Mon Sep 17 00:00:00 2001 From: hermes-agent Date: Sun, 24 May 2026 18:08:41 +0000 Subject: [PATCH 54/68] =?UTF-8?q?Stage=20404:=20PR=20#2716=20=E2=80=94=20P?= =?UTF-8?q?erformance=20optimizations=20by=20@dobby-d-elf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nesquena APPROVED 2026-05-22. Cherry-picked onto post-v0.51.127 master via 3-way apply. Resolved api/routes.py conflict: master had the inline correctness fix from the deep-review iteration; PR refactors it into _metadata_only_message_summary() helper. Took the helper AND added profile= threading (post-#2827 master adds profile-aware state.db reads). Kept master's pre-existing test_api_session_reload_drops_stale_cached_user_tail_after_saved_assistant alongside the PR's new test_metadata_fast_path_matches_reconciliation_for_restamped_replays. Co-authored-by: dobby-d-elf --- api/models.py | 69 +++++------------- api/routes.py | 71 ++++++++++++------- static/boot.js | 8 ++- static/panels.js | 11 +-- static/sessions.js | 5 +- static/ui.js | 29 ++++++-- static/workspace.js | 16 +++-- ..._provider_removal_dropdown_invalidation.py | 9 +++ ..._issue1785_workspace_preview_breadcrumb.py | 7 ++ tests/test_parallel_session_switch.py | 6 +- tests/test_session_index.py | 20 +++--- tests/test_session_metadata_fast_path.py | 36 ++++++++++ tests/test_webui_state_db_reconciliation.py | 34 +++++++++ 13 files changed, 217 insertions(+), 104 deletions(-) diff --git a/api/models.py b/api/models.py index f6416650..3c8d895d 100644 --- a/api/models.py +++ b/api/models.py @@ -2153,9 +2153,27 @@ def all_sessions(diag=None): _diag_stage(diag, "all_sessions.prune_index") with LOCK: in_memory_ids = set(SESSIONS.keys()) + try: + persisted_ids = { + p.stem + for p in SESSION_DIR.glob('*.json') + if not p.name.startswith('_') + } + except Exception: + persisted_ids = None index = [ s for s in index - if _index_entry_exists(s.get('session_id'), in_memory_ids=in_memory_ids) + if ( + str(s.get('session_id') or '') in in_memory_ids + or ( + persisted_ids is not None + and str(s.get('session_id') or '') in persisted_ids + ) + or ( + persisted_ids is None + and _index_entry_exists(s.get('session_id'), in_memory_ids=in_memory_ids) + ) + ) ] backfilled = [] for i, s in enumerate(index): @@ -3032,55 +3050,6 @@ def get_state_db_session_messages(sid, *, stitch_continuations: bool = False, pr return msgs -def get_state_db_session_summary(sid) -> dict: - """Return cheap message count/max timestamp for one state.db session. - - This is intentionally narrower than ``get_state_db_session_messages`` for - metadata-only WebUI polling: callers only need a staleness signal, not a - fully materialized transcript with tool/reasoning metadata. - """ - import os - try: - import sqlite3 - except ImportError: - return {} - - db_path = _active_state_db_path() - if not sid or not db_path.exists(): - return {} - - try: - with closing(sqlite3.connect(str(db_path))) as conn: - conn.row_factory = sqlite3.Row - cur = conn.cursor() - cur.execute("PRAGMA table_info(messages)") - available = {str(row['name']) for row in cur.fetchall()} - if not {'session_id', 'timestamp'}.issubset(available): - return {} - cur.execute( - """ - SELECT COUNT(*) AS message_count, MAX(timestamp) AS last_message_at - FROM messages - WHERE session_id = ? - """, - (str(sid),), - ) - row = cur.fetchone() - if not row: - return {} - count = int(row['message_count'] or 0) - last_message_at = row['last_message_at'] - result = {'message_count': count} - if last_message_at not in (None, ''): - try: - result['last_message_at'] = float(last_message_at) - except (TypeError, ValueError): - pass - return result - except Exception: - return {} - - def _normalized_message_timestamp_for_key(value): if value is None or value == "": return "" diff --git a/api/routes.py b/api/routes.py index d55df8fa..7a36e632 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2036,6 +2036,37 @@ def _merged_session_messages_for_display(session, cli_messages=None) -> list: return sidecar_messages +def _message_summary(messages) -> dict: + messages = list(messages or []) + last_message_at = 0.0 + for msg in messages: + if not isinstance(msg, dict): + continue + try: + last_message_at = max(last_message_at, float(msg.get("timestamp") or 0)) + except (TypeError, ValueError): + pass + return {"message_count": len(messages), "last_message_at": last_message_at} + + +def _metadata_only_message_summary(sid: str, profile: str | None = None) -> dict: + """Return the reconciled message summary used by metadata-only session loads. + + Threads ``profile=`` through to ``get_state_db_session_messages`` so + background-thread reads land on the correct profile's state.db (per the + cookie-bound profile selector — fixes the same TLS-vs-thread race the + #2762 fix addressed for write paths). + """ + sidecar_session = Session.load(sid) + sidecar_messages = [] + if sidecar_session: + sidecar_messages = getattr(sidecar_session, "messages", []) or [] + state_db_messages = get_state_db_session_messages(sid, profile=profile) + return _message_summary( + merge_session_messages_append_only(sidecar_messages, state_db_messages) + ) + + def _session_requires_cli_metadata_lookup(session) -> bool: """Return True when a sidecar/session row still needs CLI metadata. @@ -3792,7 +3823,7 @@ def handle_get(handler, parsed) -> bool: is_messaging_session = _is_messaging_session_record(s) or _is_messaging_session_record(cli_meta) cli_messages = [] state_db_messages = [] - sidecar_metadata_messages = None + metadata_summary = None _session_profile = getattr(s, 'profile', None) or None if is_messaging_session: cli_messages = get_cli_session_messages(sid) @@ -3800,17 +3831,11 @@ def handle_get(handler, parsed) -> bool: state_db_messages = get_state_db_session_messages(sid, profile=_session_profile) elif not is_messaging_session: # Metadata-only callers still need the same append-only - # reconciliation contract as full loads. A raw state.db summary - # can count stale rows that the merge intentionally filters out, - # which makes sidebar polling think the transcript is always - # newer than the loaded conversation. - state_db_messages = get_state_db_session_messages(sid, profile=_session_profile) - sidecar_metadata_session = Session.load(sid) - sidecar_metadata_messages = ( - getattr(sidecar_metadata_session, "messages", []) or [] - if sidecar_metadata_session - else [] - ) + # reconciliation contract as full loads so stale/replayed + # state.db rows do not make sidebar polling think the + # transcript is always newer. Helper threads profile= to + # honor #2827's TLS-vs-thread fix. + metadata_summary = _metadata_only_message_summary(sid, profile=_session_profile) _t2 = _time.monotonic() effective_model = ( _resolve_effective_session_model_for_display(s) @@ -3840,12 +3865,16 @@ def handle_get(handler, parsed) -> bool: sidecar_messages = getattr(s, "messages", []) or [] _all_msgs = merge_session_messages_append_only(cli_messages, sidecar_messages) else: - _metadata_sidecar = sidecar_metadata_messages - if _metadata_sidecar is None: - _metadata_sidecar = getattr(s, "messages", []) or [] - _all_msgs = merge_session_messages_append_only(_metadata_sidecar, state_db_messages) + if metadata_summary is None: + metadata_summary = _message_summary(getattr(s, "messages", []) or []) + _summary_message_count = metadata_summary["message_count"] + _summary_last_message_at = metadata_summary["last_message_at"] + _all_msgs = [] if not load_messages: - _summary_message_count = len(_all_msgs) + if metadata_summary is None: + metadata_summary = _message_summary(_all_msgs) + _summary_message_count = metadata_summary["message_count"] + _summary_last_message_at = metadata_summary["last_message_at"] if _summary_message_count == 0: # Legacy session with no loaded sidecar and no state.db summary — # fall back to the persisted metadata count from session JSON. @@ -3858,14 +3887,6 @@ def handle_get(handler, parsed) -> bool: _summary_message_count = max(0, int(metadata_count)) except (TypeError, ValueError): pass - try: - _summary_last_message_at = max( - float((m or {}).get("timestamp") or 0) - for m in _all_msgs - if isinstance(m, dict) - ) if _all_msgs else 0 - except (TypeError, ValueError): - _summary_last_message_at = 0 else: _summary_message_count = None _summary_last_message_at = None diff --git a/static/boot.js b/static/boot.js index 1ff1a72d..2b6d8347 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1624,7 +1624,10 @@ function applyBotName(){ else if(typeof syncModelChip==='function') syncModelChip(); } if(S.session) syncTopbar(); - }).catch(()=>{}); + }).catch(e=>{ + window._modelDropdownReady=null; + throw e; + }); const _startBootModelDropdown=()=>{ const ready=window._modelDropdownReady; if(ready&&typeof ready.then==='function') return ready; @@ -1634,6 +1637,9 @@ function applyBotName(){ }; window._modelDropdownReady=null; window._ensureModelDropdownReady=_startBootModelDropdown; + setTimeout(()=>{ + try{Promise.resolve(_startBootModelDropdown()).catch(()=>{});}catch(_){} + },0); // Start independent boot fetches without holding the conversation list behind // them. The sidebar can render from /api/sessions while workspace/onboarding // metadata settles in parallel. diff --git a/static/panels.js b/static/panels.js index 89ad0d20..e1519cf4 100644 --- a/static/panels.js +++ b/static/panels.js @@ -6736,10 +6736,13 @@ function _refreshModelDropdownsAfterProviderChange(){ if(typeof window._invalidateSlashModelCache==='function'){ window._invalidateSlashModelCache(); } - if(typeof populateModelDropdown==='function'){ - // Fire-and-forget: don't block the providers panel refresh on a - // dropdown rebuild. The composer/Settings dropdowns will catch up - // on the very next paint frame. + // Fire-and-forget: don't block the providers panel refresh on a + // dropdown rebuild. The composer/Settings dropdowns will catch up + // on the very next paint frame. + if(typeof window._ensureModelDropdownReady==='function'){ + window._modelDropdownReady=null; + Promise.resolve(window._ensureModelDropdownReady()).catch(()=>{}); + }else if(typeof populateModelDropdown==='function'){ Promise.resolve(populateModelDropdown()).catch(()=>{}); } }catch(_e){ diff --git a/static/sessions.js b/static/sessions.js index 8a55fef1..262cedcf 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -789,7 +789,10 @@ async function loadSession(sid){ syncTopbar();renderMessages(); if(typeof resumeManualCompressionForSession==='function') resumeManualCompressionForSession(sid); const _dirP=loadDir('.'); - await _dirP; + // Workspace refresh is guarded by session id inside loadDir(); do not + // block session-load completion, draft restore, or model resolution on + // file-tree IO for users focused on the chat. + if(_dirP&&typeof _dirP.catch==='function') _dirP.catch(()=>{}); } } diff --git a/static/ui.js b/static/ui.js index 0d4c8e4e..b7c581f6 100644 --- a/static/ui.js +++ b/static/ui.js @@ -5992,7 +5992,7 @@ function renderMessages(options){ const msgCount=S.messages.length; if(sid!==_messageRenderWindowSid) _resetMessageRenderWindow(sid); const renderWindowSize=_currentMessageRenderWindowSize(); - const renderSignature=_messageRenderCacheSignature(); + let cachedRenderSignature=null; const hasTransientTranscriptUi=!!( (window._compressionUi&&(!window._compressionUi.sessionId||window._compressionUi.sessionId===sid)) || (window._handoffUi&&(!window._handoffUi.sessionId||window._handoffUi.sessionId===sid)) @@ -6007,6 +6007,8 @@ function renderMessages(options){ // cross-channel handoff summaries; otherwise the cached transcript returns // before those cards can be inserted. if(sid&&sid!==_sessionHtmlCacheSid&&!INFLIGHT[sid]&&!hasTransientTranscriptUi){ + const renderSignature=_messageRenderCacheSignature(); + cachedRenderSignature=renderSignature; const cached=_sessionHtmlCache.get(sid); if(cached&&cached.msgCount===msgCount&&cached.renderWindowSize===renderWindowSize&&cached.signature===renderSignature){ inner.innerHTML=cached.html; @@ -6128,6 +6130,13 @@ function renderMessages(options){ const assistantSegments=new Map(); const assistantThinking=new Map(); const userRows=new Map(); + const toolCallAssistantIdxs=new Set(); + if(Array.isArray(S.toolCalls)){ + for(const tc of S.toolCalls){ + if(!tc) continue; + toolCallAssistantIdxs.add(tc.assistant_msg_idx!==undefined?tc.assistant_msg_idx:-1); + } + } // Windowed render loop replaces the legacy full loop: // for(let vi=0;vi{ + const fallbackToolSources=[]; + S.messages.forEach((m,rawIdx)=>{ if(!m) return; // OpenAI / Hermes CLI format: role=tool with tool_call_id if(m.role==='tool'){ @@ -6398,10 +6408,14 @@ function renderMessages(options){ resultsByTid[tid]=_cliToolResultSnippet(raw); }); } + if(m.role==='assistant'){ + const hasTopLevelToolCalls=Array.isArray(m.tool_calls)&&m.tool_calls.length>0; + const hasContentToolUse=Array.isArray(m.content)&&m.content.some(p=>p&&typeof p==='object'&&p.type==='tool_use'); + if(hasTopLevelToolCalls||hasContentToolUse) fallbackToolSources.push({m,rawIdx}); + } }); const derived=[]; - S.messages.forEach((m,rawIdx)=>{ - if(m.role!=='assistant') return; + fallbackToolSources.forEach(({m,rawIdx})=>{ // OpenAI format: top-level tool_calls field on the assistant message (m.tool_calls||[]).forEach(tc=>{ if(!tc||typeof tc!=='object') return; @@ -6548,7 +6562,7 @@ function renderMessages(options){ const hasTurnUsage=!!msg._turnUsage; const compactActivityForMessage=isSimplifiedToolCalling()&&( assistantThinking.has(mi)|| - (S.toolCalls||[]).some(tc=>tc&&(tc.assistant_msg_idx!==undefined?tc.assistant_msg_idx:-1)===mi) + toolCallAssistantIdxs.has(mi) ); const durationText=compactActivityForMessage?'':_formatTurnDuration(msg._turnDuration); if(!hasTurnUsage&&!durationText&&!gatewayText&&!failoverText&&!modelWarningText) continue; @@ -6615,10 +6629,11 @@ function renderMessages(options){ if(typeof _applyMediaPlaybackPreferences==='function') _applyMediaPlaybackPreferences(inner); // Populate session cache so switching back here skips a full rebuild. _sessionHtmlCacheSid=sid; - if(sid&&!hasTransientTranscriptUi){ + if(sid&&!INFLIGHT[sid]&&!hasTransientTranscriptUi){ const _html=inner.innerHTML; // Only cache sessions with <300KB rendered HTML; evict oldest beyond 8 sessions. if(_html.length<300_000){ + const renderSignature=cachedRenderSignature===null?_messageRenderCacheSignature():cachedRenderSignature; _sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowSize,signature:renderSignature}); if(_sessionHtmlCache.size>8){_sessionHtmlCache.delete(_sessionHtmlCache.keys().next().value);} } diff --git a/static/workspace.js b/static/workspace.js index b4c37ae1..03320f9a 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -105,13 +105,15 @@ function _restoreExpandedDirs(){ async function loadDir(path){ if(!S.session)return; + const sessionId=S.session.session_id; try{ if(!path||path==='.'){ S._dirCache={}; _restoreExpandedDirs(); // restore per-workspace expanded state on root load } S.currentDir=path||'.'; - const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`); + const data=await api(`/api/list?session_id=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(path)}`); + if(!S.session||S.session.session_id!==sessionId)return; S.entries=data.entries||[];renderBreadcrumb();renderFileTree(); // Pre-fetch contents of restored expanded dirs so they render without a second click // (parallelized — avoids serial waterfall when multiple dirs are expanded) @@ -120,10 +122,11 @@ async function loadDir(path){ const pending=[...expanded].filter(dirPath=>!S._dirCache[dirPath]); if(pending.length){ const results=await Promise.all(pending.map(dirPath=> - api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(dirPath)}`) + api(`/api/list?session_id=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(dirPath)}`) .then(dc=>({dirPath,entries:dc.entries||[]})) .catch(()=>({dirPath,entries:[]})) )); + if(!S.session||S.session.session_id!==sessionId)return; for(const {dirPath,entries} of results) S._dirCache[dirPath]=entries; } if(expanded.size>0)renderFileTree(); @@ -143,8 +146,10 @@ async function loadDir(path){ async function _refreshGitBadge(){ const badge=$('gitBadge'); if(!badge||!S.session)return; + const sessionId=S.session.session_id; try{ - const data=await api(`/api/git-info?session_id=${encodeURIComponent(S.session.session_id)}`); + const data=await api(`/api/git-info?session_id=${encodeURIComponent(sessionId)}`); + if(!S.session||S.session.session_id!==sessionId)return; if(data.git&&data.git.is_git){ const g=data.git; let text=g.branch||'git'; @@ -158,7 +163,10 @@ async function _refreshGitBadge(){ badge.style.display='none'; badge.textContent=''; } - }catch(e){badge.style.display='none';} + }catch(e){ + if(!S.session||S.session.session_id!==sessionId)return; + badge.style.display='none'; + } } function navigateUp(){ diff --git a/tests/test_issue1539_provider_removal_dropdown_invalidation.py b/tests/test_issue1539_provider_removal_dropdown_invalidation.py index 59ada50d..bc51c0d4 100644 --- a/tests/test_issue1539_provider_removal_dropdown_invalidation.py +++ b/tests/test_issue1539_provider_removal_dropdown_invalidation.py @@ -164,6 +164,15 @@ class TestProviderRemoveInvalidatesDropdowns: "response (covers the dropdown + badge surfaces from #1539)." ) + def test_dropdown_flush_reuses_shared_model_ready_promise(self): + src = _read_static("panels.js") + body = _extract_function_body(src, "function _refreshModelDropdownsAfterProviderChange(") + ensure_pos = body.index("typeof window._ensureModelDropdownReady") + reset_pos = body.index("window._modelDropdownReady=null", ensure_pos) + call_pos = body.index("window._ensureModelDropdownReady()", reset_pos) + + assert ensure_pos < reset_pos < call_pos + def test_dropdown_flush_is_resilient_to_missing_modules(self): """If commands.js or ui.js failed to load, the providers panel must still update — the dropdown flush is best-effort (#1539).""" diff --git a/tests/test_issue1785_workspace_preview_breadcrumb.py b/tests/test_issue1785_workspace_preview_breadcrumb.py index 8c75b254..f76fa920 100644 --- a/tests/test_issue1785_workspace_preview_breadcrumb.py +++ b/tests/test_issue1785_workspace_preview_breadcrumb.py @@ -50,6 +50,13 @@ def test_load_dir_keeps_workspace_panel_open_when_clearing_preview(): ) +def test_load_dir_ignores_stale_session_results(): + block = _function_block(WORKSPACE_JS, "loadDir") + assert "const sessionId=S.session.session_id" in block + assert "encodeURIComponent(sessionId)" in block + assert "if(!S.session||S.session.session_id!==sessionId)return;" in block + + def test_file_preview_breadcrumb_uses_directory_navigation_for_root(): block = _function_block(WORKSPACE_JS, "renderFileBreadcrumb") assert "loadDir('.')" in block, "The preview root breadcrumb should navigate to the workspace root." diff --git a/tests/test_parallel_session_switch.py b/tests/test_parallel_session_switch.py index 75496ec8..97fb5b35 100644 --- a/tests/test_parallel_session_switch.py +++ b/tests/test_parallel_session_switch.py @@ -88,9 +88,11 @@ class TestLoadSessionIdleOverlap: "The idle path should rely on renderMessages()'s consolidated " "post-render pass instead of running a second highlight pass." ) - assert "await" in block and "_dirP" in block, ( - "loadDir() result should still be stored and awaited." + assert "_dirP" in block and "await _dirP" not in block, ( + "loadDir() should refresh the workspace without blocking " + "session-load completion." ) + assert "_dirP.catch" in block break assert found, ( diff --git a/tests/test_session_index.py b/tests/test_session_index.py index 6944da84..62ec9508 100644 --- a/tests/test_session_index.py +++ b/tests/test_session_index.py @@ -123,8 +123,8 @@ def test_all_sessions_backfills_last_message_at_for_legacy_index_rows(): assert persisted[0].get("last_message_at") == 100.0 -def test_all_sessions_prune_reuses_in_memory_id_snapshot(monkeypatch): - """Index pruning should not reacquire the session lock for every row.""" +def test_all_sessions_prune_batches_persisted_id_snapshot(monkeypatch): + """Index pruning should not probe each backing file through the helper.""" index_file = models.SESSION_INDEX_FILE entries = [ { @@ -152,22 +152,22 @@ def test_all_sessions_prune_reuses_in_memory_id_snapshot(monkeypatch): "archived": False, }, ] + for entry in entries: + (models.SESSION_DIR / f"{entry['session_id']}.json").write_text( + "{}", + encoding="utf-8", + ) _write_index_file(index_file, entries) - seen = [] + def _assert_not_called(session_id, in_memory_ids=None): + raise AssertionError("all_sessions should batch persisted ids before pruning") - def _assert_snapshot_used(session_id, in_memory_ids=None): - assert in_memory_ids is not None, "all_sessions should snapshot SESSIONS once before pruning" - seen.append(session_id) - return True - - monkeypatch.setattr(models, "_index_entry_exists", _assert_snapshot_used) + monkeypatch.setattr(models, "_index_entry_exists", _assert_not_called) monkeypatch.setattr(models, "_enrich_sidebar_lineage_metadata", lambda _sessions: None) rows = models.all_sessions() assert [row["session_id"] for row in rows] == ["sess_a", "sess_b"] - assert seen == ["sess_a", "sess_b"] # ── 6. test_incremental_patch_correctness ───────────────────────────────── diff --git a/tests/test_session_metadata_fast_path.py b/tests/test_session_metadata_fast_path.py index 645e89eb..477b42de 100644 --- a/tests/test_session_metadata_fast_path.py +++ b/tests/test_session_metadata_fast_path.py @@ -68,6 +68,42 @@ def test_boot_does_not_block_session_restore_on_model_catalog(): assert "await populateModelDropdown()" not in src +def test_boot_primes_model_catalog_without_awaiting_it(): + """The boot-time prime must NOT await the model-catalog hydration before + rendering the session list. A later awaited hydration inside the saved- + session restore path at ``if(S.session) await _startBootModelDropdown();`` + is intentional — that one re-applies the saved session's model after the + live catalog hydrates so the chip never shows a stale static default + (see comment in static/boot.js next to the saved-session restore). + """ + src = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") + + ensure_pos = src.index("window._ensureModelDropdownReady=_startBootModelDropdown;") + prime_pos = src.index("Promise.resolve(_startBootModelDropdown()).catch(()=>{});", ensure_pos) + session_restore_pos = src.index("await renderSessionList();", prime_pos) + + assert ensure_pos < prime_pos < session_restore_pos + + # No await on the boot-prime path itself: between ensure_pos and the first + # session_restore await, the dropdown is fired-and-forgotten. + boot_prelude = src[ensure_pos:session_restore_pos] + assert "await _startBootModelDropdown()" not in boot_prelude, ( + "Boot prelude must not await _startBootModelDropdown — the prime is " + "fire-and-forget so the sidebar can render before /api/models returns." + ) + assert "await populateModelDropdown()" not in boot_prelude + + +def test_failed_boot_model_catalog_prime_is_retryable(): + src = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") + start = src.index("const _hydrateBootModelDropdown=()=>populateModelDropdown().then") + end = src.index("const _startBootModelDropdown=()=>", start) + block = src[start:end] + + assert "window._modelDropdownReady=null;" in block + assert "throw e;" in block + + def test_boot_primes_visible_default_model_without_catalog_fetch(): src = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") default_block_start = src.index("if(s.default_model){") diff --git a/tests/test_webui_state_db_reconciliation.py b/tests/test_webui_state_db_reconciliation.py index f977b6c2..ad6bd83b 100644 --- a/tests/test_webui_state_db_reconciliation.py +++ b/tests/test_webui_state_db_reconciliation.py @@ -512,6 +512,40 @@ def test_api_session_reload_drops_stale_cached_user_tail_after_saved_assistant(m assert handler.response_json["session"]["message_count"] == 2 +def test_metadata_fast_path_matches_reconciliation_for_restamped_replays(monkeypatch, tmp_path): + """#2716 invariant: metadata-only /api/session uses merge_session_messages_append_only + (not a raw state.db COUNT) so restamped replay rows don't make sidebar polling think + the transcript is always newer than the loaded conversation.""" + import api.routes as routes + + sid = "webui_reconcile_metadata_replay" + _install_test_session( + monkeypatch, + tmp_path, + sid, + [ + {"role": "user", "content": "old user", "timestamp": 1000.0}, + {"role": "assistant", "content": "old assistant", "timestamp": 1001.0}, + ], + ) + _make_state_db( + tmp_path / "state.db", + sid, + [ + {"role": "user", "content": "old user", "timestamp": 1002.0}, + ], + ) + + handler = _GetHandler(f"/api/session?session_id={sid}&messages=0&resolve_model=0") + routes.handle_get(handler, urlparse(handler.path)) + + assert handler.status == 200 + session = handler.response_json["session"] + assert session["messages"] == [] + assert session["message_count"] == 2 + assert session["last_message_at"] == 1001.0 + + def test_state_db_reconciliation_preserves_tool_metadata(monkeypatch, tmp_path): import api.routes as routes From 2419b3a0a20583a62dbe8eed67ece99d7f90ac56 Mon Sep 17 00:00:00 2001 From: hermes-agent Date: Sun, 24 May 2026 18:08:42 +0000 Subject: [PATCH 55/68] =?UTF-8?q?Stage=20404:=20PR=20#2830=20=E2=80=94=20f?= =?UTF-8?q?ix(sessions):=20keep=20pin=20state=20authoritative=20by=20@fran?= =?UTF-8?q?ksong2702=20(closes=20#2821)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent reviewer 'LGTM. Ship it.' - Bug A fix: _session_field helper handles dict-vs-object snapshot in pin-limit check - Bug B fix: removed stale client-side pinLimitReached short-circuit - Bug C recovery: renderSessionList() on pin/unpin failure refreshes from server Co-authored-by: franksong2702 <146128127+franksong2702@users.noreply.github.com> --- api/routes.py | 10 ++- static/sessions.js | 14 ++-- ...test_configurable_pinned_sessions_limit.py | 3 +- tests/test_issue2508_session_pin_cap.py | 4 +- .../test_issue2821_session_pin_state_sync.py | 70 +++++++++++++++++++ 5 files changed, 89 insertions(+), 12 deletions(-) create mode 100644 tests/test_issue2821_session_pin_state_sync.py diff --git a/api/routes.py b/api/routes.py index 7a36e632..b6ee042c 100644 --- a/api/routes.py +++ b/api/routes.py @@ -77,6 +77,12 @@ _CSP_REPORT_RATE_LIMIT_MAX = 100 _CSP_REPORT_MAX_BODY_BYTES = 64 * 1024 +def _session_field(session, field, default=None): + if isinstance(session, dict): + return session.get(field, default) + return getattr(session, field, default) + + # ── Profile-scoped session/project filtering (#1611, #1614) ──────────────── # # Sessions and projects are stored in the WebUI sidecar without per-row @@ -5837,8 +5843,8 @@ def handle_post(handler, parsed) -> bool: # Pre-snapshot from persisted index (acquires LOCK internally, # so must run outside our own LOCK acquire below). persisted_pinned_ids = { - getattr(existing, "session_id", None) for existing in all_sessions() - if getattr(existing, "pinned", False) and not getattr(existing, "archived", False) + _session_field(existing, "session_id", None) for existing in all_sessions() + if _session_field(existing, "pinned", False) and not _session_field(existing, "archived", False) } with LOCK: # Final authoritative count: merge persisted-pinned with the diff --git a/static/sessions.js b/static/sessions.js index 262cedcf..2a4e199b 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1828,26 +1828,24 @@ function _openSessionActionMenu(session, anchorEl){ } )); } - const pinLimitReached=!session.pinned&&_pinnedSessionCount()>=_getPinnedSessionsLimit(); menu.appendChild(_buildSessionAction( session.pinned?t('session_unpin'):t('session_pin'), - pinLimitReached?_pinnedSessionsLimitMessage():(session.pinned?t('session_unpin_desc'):t('session_pin_desc')), + session.pinned?t('session_unpin_desc'):t('session_pin_desc'), session.pinned?ICONS.pin:ICONS.unpin, async()=>{ closeSessionActionMenu(); - if(pinLimitReached){ - if(typeof showToast==='function') showToast(_pinnedSessionsLimitMessage(),3000,'error'); - return; - } const newPinned=!session.pinned; try{ await api('/api/session/pin',{method:'POST',body:JSON.stringify({session_id:session.session_id,pinned:newPinned})}); session.pinned=newPinned; if(S.session&&S.session.session_id===session.session_id) S.session.pinned=newPinned; renderSessionList(); - }catch(err){showToast(t('session_pin_failed')+err.message);} + }catch(err){ + showToast(t('session_pin_failed')+err.message); + await renderSessionList(); + } }, - (session.pinned?'is-active':'')+(pinLimitReached?' is-disabled':'') + session.pinned?'is-active':'' )); menu.appendChild(_buildSessionAction( t('session_move_project'), diff --git a/tests/test_configurable_pinned_sessions_limit.py b/tests/test_configurable_pinned_sessions_limit.py index 3dba102c..3b98ed05 100644 --- a/tests/test_configurable_pinned_sessions_limit.py +++ b/tests/test_configurable_pinned_sessions_limit.py @@ -54,7 +54,8 @@ def test_pin_limit_setting_is_exposed_and_wired_through_ui(): assert "window._pinnedSessionsLimit=parseInt(s.pinned_sessions_limit||3,10)||3" in BOOT_JS assert "function _getPinnedSessionsLimit()" in SESSIONS_JS assert "function _pinnedSessionsLimit()" not in SESSIONS_JS - assert "_pinnedSessionCount()>=_getPinnedSessionsLimit()" in SESSIONS_JS + assert "_pinnedSessionCount()>=_getPinnedSessionsLimit()" not in SESSIONS_JS + assert "await api('/api/session/pin'" in SESSIONS_JS def test_settings_api_persists_integer_pin_limit_and_rejects_invalid_values(): diff --git a/tests/test_issue2508_session_pin_cap.py b/tests/test_issue2508_session_pin_cap.py index 2799d32a..f4164024 100644 --- a/tests/test_issue2508_session_pin_cap.py +++ b/tests/test_issue2508_session_pin_cap.py @@ -77,7 +77,9 @@ def test_session_pin_cap_has_backend_and_frontend_guards(): assert 'function _pinnedSessionCount()' in SESSIONS_JS assert 'function _getPinnedSessionsLimit()' in SESSIONS_JS assert 'function _pinnedSessionsLimit()' not in SESSIONS_JS - assert 'const pinLimitReached=!session.pinned&&_pinnedSessionCount()>=_getPinnedSessionsLimit();' in SESSIONS_JS + assert 'const pinLimitReached=!session.pinned&&_pinnedSessionCount()>=_getPinnedSessionsLimit();' not in SESSIONS_JS + assert 'if(pinLimitReached)' not in SESSIONS_JS + assert "await api('/api/session/pin'" in SESSIONS_JS assert 'Only ${limit} conversations can be pinned' in SESSIONS_JS assert ".session-action-opt.is-disabled{opacity:.55;cursor:not-allowed;}" in STYLE_CSS diff --git a/tests/test_issue2821_session_pin_state_sync.py b/tests/test_issue2821_session_pin_state_sync.py new file mode 100644 index 00000000..b08b95ef --- /dev/null +++ b/tests/test_issue2821_session_pin_state_sync.py @@ -0,0 +1,70 @@ +"""Regression checks for #2821 session pin/unpin state sync.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +ROUTES_PY = (ROOT / "api" / "routes.py").read_text(encoding="utf-8") +SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + + +def _function_block(src: str, name: str) -> str: + marker = f"function {name}" + start = src.find(marker) + assert start != -1, f"{name} not found" + brace = src.find("{", start) + assert brace != -1, f"{name} body not found" + depth = 1 + i = brace + 1 + while i < len(src) and depth: + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + i += 1 + assert depth == 0, f"{name} body did not close" + return src[start:i] + + +def test_session_field_helper_reads_dicts_and_objects(): + from api.routes import _session_field + + class SessionLike: + session_id = "obj-1" + pinned = True + archived = False + + assert _session_field({"session_id": "dict-1", "pinned": True}, "pinned", False) is True + assert _session_field({"session_id": "dict-1"}, "archived", False) is False + assert _session_field(SessionLike(), "session_id", None) == "obj-1" + assert _session_field(SessionLike(), "missing", "fallback") == "fallback" + + +def test_pin_limit_snapshot_counts_index_dict_entries(): + assert "_session_field(existing, \"session_id\", None)" in ROUTES_PY + assert "_session_field(existing, \"pinned\", False)" in ROUTES_PY + assert "_session_field(existing, \"archived\", False)" in ROUTES_PY + start = ROUTES_PY.find("persisted_pinned_ids = {") + assert start != -1, "persisted pin snapshot not found" + end = ROUTES_PY.find("with LOCK:", start) + assert end != -1, "persisted pin snapshot should be computed before LOCK" + persisted_snapshot = ROUTES_PY[start:end] + assert 'getattr(existing, "pinned", False)' not in persisted_snapshot + assert 'getattr(existing, "archived", False)' not in persisted_snapshot + + +def test_pin_action_does_not_short_circuit_on_stale_client_count(): + body = _function_block(SESSIONS_JS, "_openSessionActionMenu") + assert "const pinLimitReached=" not in body + assert "if(pinLimitReached)" not in body + assert "_pinnedSessionCount()>=_getPinnedSessionsLimit()" not in body + assert "await api('/api/session/pin'" in body + + +def test_pin_action_refreshes_session_list_after_pin_failure(): + body = _function_block(SESSIONS_JS, "_openSessionActionMenu") + catch_idx = body.find("}catch(err){") + assert catch_idx != -1, "Pin/unpin action must have an error path" + catch_block = body[catch_idx:body.find("}", catch_idx + len("}catch(err){")) + 1] + assert "showToast(t('session_pin_failed')+err.message)" in catch_block + assert "await renderSessionList()" in catch_block From 324df1c4afe218064b6f17ce6657428d80186ce0 Mon Sep 17 00:00:00 2001 From: hermes-agent Date: Sun, 24 May 2026 18:18:49 +0000 Subject: [PATCH 56/68] Stamp CHANGELOG for v0.51.128 (Release CZ / stage-batch10 / 2-PR perf + correctness batch) --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdf8f056..ff0e742b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,39 @@ ## [Unreleased] +## [v0.51.128] — 2026-05-24 — Release CZ (stage-batch10 — 2-PR perf + correctness batch) + +### Fixed + +- **PR #2830** by @franksong2702 — Pin state synchronization between persisted index and in-memory sessions (closes #2821). Three coupled bugs: + - **Bug A (load-bearing):** `/api/session/pin` pre-snapshot used `getattr(session, "pinned", False)` which always returned `False` for dict-backed index rows from `all_sessions()`. With ~55-session profiles and LRU eviction churn, pinned counts routinely under-counted because the persisted snapshot was effectively empty. New `_session_field(session, field, default)` helper resolves both dict-backed and Session-object snapshots correctly. + - **Bug B:** Removed stale client-side `pinLimitReached` short-circuit in the sidebar action menu that could block pin clicks before the server saw them, based on `_allSessions` data that was stale mid-render. Server now enforces the cap; the toast surfaces the 400 response. + - **Bug C recovery:** Pin/unpin failure path (4xx response from `/api/session/pin`) now triggers `renderSessionList()` to refresh `_allSessions` from the server, so the sidebar never gets stuck on stale optimistic state. + + Adds `tests/test_issue2821_session_pin_state_sync.py` (70 LOC) covering the `_session_field` helper, the persisted-pinned snapshot, the removed `pinLimitReached` reference, and the failure-catch refresh path. Companion fix to #2782 (server-side 404→200 transition for missing CLI-synced sessions) which remains out of scope. + +### Performance + +- **PR #2716** by @dobby-d-elf — Six independent perf nudges plus one correctness fix. nesquena-APPROVED on 2026-05-22 after a deep-review iteration; cherry-picked onto post-v0.51.127 master via 3-way apply with sibling-PR composition resolution. + + - **Metadata-only `/api/session` correctness fix.** Refactors the prior inline reconciliation into `_metadata_only_message_summary(sid, profile=None)` helper that runs the full `merge_session_messages_append_only()` path. Pre-fix shortcut could over-count stale state.db replay rows that the merge intentionally filters out, producing false "transcript newer than loaded conversation" signals (same bug class as #2705 / #2686). The new helper threads `profile=` through to `get_state_db_session_messages` to preserve #2827's TLS-vs-thread profile fix on background-thread reads. + - **Batched persisted-session checks in sidebar indexing.** One `SESSION_DIR.glob('*.json')` snapshot per call replaces per-row `_index_entry_exists()` filesystem lookups during `all_sessions()` pruning. Fallback to the per-row helper preserved when the glob raises. + - **Deferred render-cache signature.** `cachedRenderSignature` closes over the lookup-time signature so the cache STORE path reuses it without recomputing. `_messageRenderCacheSignature()` continues to include the content hash per #2692, preserving the cache-invalidation invariant. + - **Hoisted assistant tool-activity index.** Footer-rendering loop now uses an `O(1)` Set lookup instead of `S.toolCalls.some(...)` per message — ~30× fewer comparisons for a 100-message conversation with 30 tool calls. + - **Workspace stale-session guards.** `loadDir` and `_refreshGitBadge` in `static/workspace.js` capture `sessionId` at call time and check it after each `await` (including the catch path of `_refreshGitBadge` — without it, a late 404 from the previous session would hide the git badge on the current session). + - **Background model-catalog prime.** `_startBootModelDropdown` fires fire-and-forget on boot via `setTimeout(0)` so the live catalog hydrates without blocking. The existing `await` on the saved-session restore path is preserved (re-applies the saved session's model after hydration so the chip never shows the stale static default). + - **Failed hydration retryable.** `window._modelDropdownReady = null; throw e;` lets the next caller refetch instead of being stuck on a permanent failure. + + Adds 76 LOC of new tests across `test_session_metadata_fast_path.py`, `test_webui_state_db_reconciliation.py`, `test_session_index.py`, `test_issue1539_provider_removal_dropdown_invalidation.py`, `test_issue1785_workspace_preview_breadcrumb.py`, `test_parallel_session_switch.py`. + +### Notes + +- PR #2716 had been pending merge since 2026-05-22 due to a rebase blocker against the rapidly-advancing master (10+ intervening releases). Cherry-picked via `git apply --3way` of the PR's net delta vs its original merge-base (`f9302601`); 12 of 14 files applied cleanly. Two files had genuine conflicts requiring resolution: `api/routes.py` (took the PR's helper extraction AND added `profile=` threading to preserve #2827's fix), and `tests/test_webui_state_db_reconciliation.py` (kept BOTH master's pre-existing `test_api_session_reload_drops_stale_cached_user_tail_after_saved_assistant` AND the PR's new `test_metadata_fast_path_matches_reconciliation_for_restamped_replays` — they pin different invariants). +- Opus pre-release advisor reviewed all 6 risk areas (helper extraction correctness, sibling-PR composition, `Session.load` profile-safety, test coverage, deferred Bug D, stale-line-number cleanup nit). Verdict: **SHIP AS-IS** — no MUST-FIX, no inline SHOULD-FIX. Two follow-up issues to file post-tag (Bug D startup index rebuild perf; multi-profile state.db test for the `profile=` threading invariant). +- Full pytest: **6,434 passed / 6 skipped / 3 xpassed / 8 subtests passed** in 2m43s. +- Agent self-verified the producer→consumer channel for `_metadata_only_message_summary` with unmocked invocation against a real session-load path (per skill rule Trigger A + E for mocked-consumer test patterns). +- Closes: #2821 (pin state sync), and `get_state_db_session_summary` dead-code removed (#2716). + ## [v0.51.127] — 2026-05-24 — Release CY (stage-batch9 — 7-PR low-risk batch — brick-class Linux + brick-class update apply + composer wide-screen + Turkish locale + MCP toggle + SSE settlement + Windows CI) ### Fixed From dd7648d56c52d7519878f9ccbfcc5867221b5fcf Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Sat, 23 May 2026 04:08:13 -0700 Subject: [PATCH 57/68] feat(runtime): wire runner route selection harness --- api/routes.py | 80 ++++++++++++++++----- docs/rfcs/hermes-run-adapter-contract.md | 66 +++++++++++++++-- tests/test_runtime_adapter_seam.py | 90 +++++++++++++++++++++--- 3 files changed, 202 insertions(+), 34 deletions(-) diff --git a/api/routes.py b/api/routes.py index b6ee042c..64fa3d9e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8463,6 +8463,41 @@ def _start_chat_stream_for_session( return response +def _runtime_runner_client_factory(): + """Return the runner-local client when a supervised backend exists. + + Slice 4d wires the `/api/chat/start` selection point without silently falling + back to the legacy in-process runtime when `runner-local` is explicitly + requested. The supervised runner backend itself is intentionally not created + in this helper yet; a later slice can replace this factory with the concrete + client while keeping the route contract stable. + """ + raise NotImplementedError("runner-local chat backend is not configured") + + +def _chat_start_response_from_run_start(result): + """Expose only the legacy browser-facing chat-start response fields.""" + payload = dict(getattr(result, "payload", {}) or {}) + response = {} + for key in ( + "stream_id", + "session_id", + "pending_started_at", + "turn_id", + "title", + "effective_model", + "effective_model_provider", + "error", + "active_stream_id", + "_status", + ): + if key in payload: + response[key] = payload[key] + response.setdefault("stream_id", result.stream_id) + response.setdefault("session_id", result.session_id) + return response + + def _runtime_adapter_goal_action(goal_args: str) -> str: """Return the bounded RuntimeAdapter goal action for WebUI /goal args.""" action = str(goal_args or "").strip().lower() @@ -8672,10 +8707,12 @@ def _handle_chat_start(handler, body, diag=None): from api.runtime_adapter import ( LegacyJournalRuntimeAdapter, StartRunRequest, + build_runtime_adapter, runtime_adapter_enabled, + runtime_adapter_runner_enabled, ) - if runtime_adapter_enabled(): + if runtime_adapter_enabled() or runtime_adapter_runner_enabled(): def _legacy_start_run(request: StartRunRequest) -> dict: return _start_chat_stream_for_session( s, @@ -8688,23 +8725,32 @@ def _handle_chat_start(handler, body, diag=None): diag=diag, ) - adapter = LegacyJournalRuntimeAdapter(start_run_delegate=_legacy_start_run) - result = adapter.start_run( - StartRunRequest( - session_id=s.session_id, - message=msg, - attachments=attachments, - workspace=workspace, - profile=getattr(s, "profile", None), - provider=model_provider, - model=model, - source="webui", - metadata={"route": "/api/chat/start"}, + def _legacy_adapter_factory(): + return LegacyJournalRuntimeAdapter(start_run_delegate=_legacy_start_run) + + try: + adapter = build_runtime_adapter( + legacy_adapter_factory=_legacy_adapter_factory, + runner_client_factory=_runtime_runner_client_factory, ) - ) - response = dict(result.payload) - response.setdefault("stream_id", result.stream_id) - response.setdefault("session_id", result.session_id) + if adapter is None: + raise NotImplementedError("runtime adapter selection returned no adapter") + result = adapter.start_run( + StartRunRequest( + session_id=s.session_id, + message=msg, + attachments=attachments, + workspace=workspace, + profile=getattr(s, "profile", None), + provider=model_provider, + model=model, + source="webui", + metadata={"route": "/api/chat/start"}, + ) + ) + except NotImplementedError as exc: + return j(handler, {"error": str(exc)}, status=501) + response = _chat_start_response_from_run_start(result) else: response = _start_chat_stream_for_session( s, diff --git a/docs/rfcs/hermes-run-adapter-contract.md b/docs/rfcs/hermes-run-adapter-contract.md index 9732044e..71ef4da2 100644 --- a/docs/rfcs/hermes-run-adapter-contract.md +++ b/docs/rfcs/hermes-run-adapter-contract.md @@ -4,7 +4,7 @@ - **Author:** @Michaelyklam - **Updated by:** @franksong2702 - **Created:** 2026-05-11 -- **Revised:** 2026-05-21 +- **Revised:** 2026-05-23 - **Tracking issue:** [#1925](https://github.com/nesquena/hermes-webui/issues/1925) ## Credit and Scope @@ -52,7 +52,7 @@ The immediate goal is not to build a sidecar. The immediate goal is to define th browser contract, classify current runtime state, and gate the first reversible journal slice. -## Current Gate State — 2026-05-21 +## Current Gate State — 2026-05-23 Slice 1 is now past the first active validation gate: @@ -104,11 +104,14 @@ adapter-seam work: `runner-local` adapter selection point and `build_runtime_adapter(...)` factory wiring around an injected runner client. Live browser chat routes still stay on the legacy backend, and no supervised runner process exists yet. -- The next implementation gate is a supervised/local runner backend proposal and - route-selection harness. It must stay default-off, keep legacy fallback intact, - pass explicit profile/workspace/model payloads instead of mutating WebUI - process globals, and avoid recreating `STREAMS` / `CANCEL_FLAGS` / approval - queues / clarify queues under new names. +- #2744 shipped the Slice 4d supervised runner route gate in v0.51.108. +- The next implementation slice is a default-off runner route-selection harness + for `/api/chat/start`. It should only engage when `runner-local` is explicitly + selected, return a bounded not-configured error until a supervised runner + client exists, keep `legacy-direct` / `legacy-journal` fallback intact, pass + explicit profile/workspace/model payloads instead of mutating WebUI process + globals, and avoid recreating `STREAMS` / `CANCEL_FLAGS` / approval queues / + clarify queues under new names. The next gate is runner-backend plumbing, not queue implementation by default. Queue / continue routing should only move before Slice 4 if a future @@ -843,6 +846,10 @@ Non-goals for Slice 4c: #### Slice 4d: Supervised runner backend route gate +Status as of 2026-05-23: shipped in v0.51.108 via #2744. The gate remains a +docs/test contract: it defines the default-off route-selection requirements but +does not itself route live chat to a runner backend. + After `runner-local` selection exists, the next reviewable gate should define the first supervised/local runner backend and the route-selection harness before live browser chat can use it. This is still a contract/test slice first: no default-on @@ -896,6 +903,51 @@ Non-goals for Slice 4d: - no broad UI/product surface migration; WebUI remains the rich workbench while only execution ownership moves. +#### Slice 4e: Default-off runner chat-start route-selection harness + +The first implementation after the Slice 4d gate should wire the +`/api/chat/start` selection point to the existing `RuntimeAdapter` factory +without adding a supervised runner process yet. The harness must make the +selection behavior explicit: `legacy-direct` stays default, `legacy-journal` +continues to delegate to the legacy in-process stream path, and `runner-local` +does not silently fall back to legacy when no runner client is configured. + +Scope: + +- route `/api/chat/start` through `build_runtime_adapter(...)` when an adapter + mode is explicitly selected; +- keep the successful browser response whitelisted to legacy-compatible fields + such as `stream_id`, `session_id`, `pending_started_at`, `turn_id`, `title`, + and effective model/provider metadata; +- return a bounded not-configured error for `runner-local` until a supervised + runner client/backend lands; +- pass the existing explicit `StartRunRequest` payload fields across the seam. + +Acceptance tests for Slice 4e: + +1. **Default remains legacy-direct.** With no adapter env var, `/api/chat/start` + keeps using `_start_chat_stream_for_session(...)` directly. +2. **Legacy-journal remains behavior-preserving.** The flagged legacy adapter + still delegates to the same stream-start helper and preserves the public + response shape. +3. **Runner-local does not fallback silently.** If `runner-local` is selected but + no runner client exists, the route returns a bounded error instead of starting + a WebUI-owned legacy run behind the operator's back. +4. **No adapter-internal response drift.** `run_id`, `status`, and + `active_controls` remain internal until a later contract explicitly exposes + them. +5. **No runtime-surrogate globals.** The harness does not add runner-owned stream, + cancel, approval, clarify, cached-agent, goal, or queue maps to the main WebUI + process. + +Non-goals for Slice 4e: + +- no supervised runner process yet; +- no default-on runner mode; +- no execution-survives-WebUI-restart claim for production chat turns; +- no removal of `legacy-direct` or `legacy-journal`; +- no server-side queue endpoint or queue scheduler just for adapter symmetry. + ## First Meaningful Success Criteria The first meaningful milestones are deliberately split. diff --git a/tests/test_runtime_adapter_seam.py b/tests/test_runtime_adapter_seam.py index 980a7b67..99de8d98 100644 --- a/tests/test_runtime_adapter_seam.py +++ b/tests/test_runtime_adapter_seam.py @@ -409,11 +409,31 @@ def test_chat_start_route_selects_adapter_only_when_flag_enabled(): start_body = src[start_idx:src.index("def _resolve_chat_workspace_with_recovery", start_idx)] assert "runtime_adapter_enabled()" in start_body + assert "runtime_adapter_runner_enabled()" in start_body + assert "build_runtime_adapter(" in start_body + assert "legacy_adapter_factory=_legacy_adapter_factory" in start_body + assert "runner_client_factory=_runtime_runner_client_factory" in start_body assert "LegacyJournalRuntimeAdapter" in start_body assert "_start_chat_stream_for_session(" in start_body assert "HERMES_WEBUI_RUNTIME_ADAPTER" not in start_body, "route should use runtime_adapter_enabled(), not inline env checks" +def test_runner_local_chat_start_selection_does_not_fallback_to_legacy(): + routes = importlib.import_module("api.routes") + src = (routes.Path(__file__).parent.parent / "api" / "routes.py").read_text(encoding="utf-8") + start_idx = src.index("def _handle_chat_start") + start_body = src[start_idx:src.index("def _resolve_chat_workspace_with_recovery", start_idx)] + + flag_branch = "if runtime_adapter_enabled() or runtime_adapter_runner_enabled():" + assert flag_branch in start_body + assert "except NotImplementedError as exc:" in start_body + assert 'return j(handler, {"error": str(exc)}, status=501)' in start_body + assert "runner-local chat backend is not configured" in src + adapter_branch = start_body[start_body.index(flag_branch):start_body.index("else:", start_body.index(flag_branch))] + assert "_start_chat_stream_for_session(" in adapter_branch, "legacy-journal delegate should still call the legacy path" + assert "runtime_adapter_runner_enabled()" in adapter_branch + + def test_chat_start_adapter_path_preserves_legacy_response_shape(): """The RuntimeAdapter seam must be invisible to /api/chat/start callers. @@ -422,17 +442,53 @@ def test_chat_start_adapter_path_preserves_legacy_response_shape(): """ routes = importlib.import_module("api.routes") src = (routes.Path(__file__).parent.parent / "api" / "routes.py").read_text(encoding="utf-8") - start_idx = src.index("def _handle_chat_start") - start_body = src[start_idx:src.index("def _resolve_chat_workspace_with_recovery", start_idx)] - branch_start = start_body.index("if runtime_adapter_enabled():") - branch_end = start_body.index("else:", branch_start) - adapter_branch = start_body[branch_start:branch_end] + helper_idx = src.index("def _chat_start_response_from_run_start") + helper_body = src[helper_idx:src.index("def _runtime_adapter_goal_action", helper_idx)] - assert 'response.setdefault("stream_id", result.stream_id)' in adapter_branch - assert 'response.setdefault("session_id", result.session_id)' in adapter_branch - assert 'response.setdefault("run_id", result.run_id)' not in adapter_branch - assert 'response.setdefault("status", result.status)' not in adapter_branch - assert 'response.setdefault("active_controls", result.active_controls)' not in adapter_branch + assert '"stream_id",' in helper_body + assert '"session_id",' in helper_body + assert 'response.setdefault("stream_id", result.stream_id)' in helper_body + assert 'response.setdefault("session_id", result.session_id)' in helper_body + assert '"run_id",' not in helper_body + assert '"status",' not in helper_body + assert '"active_controls",' not in helper_body + + +def test_chat_start_response_from_run_start_filters_adapter_internal_fields(): + routes = importlib.import_module("api.routes") + runtime = importlib.import_module("api.runtime_adapter") + + response = routes._chat_start_response_from_run_start( + runtime.RunStartResult( + run_id="runner-internal-1", + session_id="s1", + stream_id="runner-stream-1", + status="running", + active_controls=["cancel"], + payload={ + "stream_id": "runner-stream-1", + "session_id": "s1", + "pending_started_at": 123.0, + "turn_id": "turn-1", + "title": "Demo", + "effective_model": "gpt-5.5", + "effective_model_provider": "openai-codex", + "run_id": "runner-internal-1", + "status": "running", + "active_controls": ["cancel"], + }, + ) + ) + + assert response == { + "stream_id": "runner-stream-1", + "session_id": "s1", + "pending_started_at": 123.0, + "turn_id": "turn-1", + "title": "Demo", + "effective_model": "gpt-5.5", + "effective_model_provider": "openai-codex", + } def test_rfc_distinguishes_goal_routing_from_queue_route_staging(): @@ -485,6 +541,7 @@ def test_rfc_defines_slice4d_supervised_runner_route_gate(): rfc = (routes.Path(__file__).parent.parent / "docs" / "rfcs" / "hermes-run-adapter-contract.md").read_text(encoding="utf-8") assert "#### Slice 4d: Supervised runner backend route gate" in rfc + assert "Status as of 2026-05-23: shipped in v0.51.108 via #2744" in rfc assert "After `runner-local` selection exists" in rfc assert "route-selection harness before live\nbrowser chat can use it" in rfc assert "Route remains default-off" in rfc @@ -496,6 +553,19 @@ def test_rfc_defines_slice4d_supervised_runner_route_gate(): assert "WebUI remains the rich workbench while\n only execution ownership moves" in rfc +def test_rfc_defines_slice4e_runner_chat_start_route_selection_harness(): + routes = importlib.import_module("api.routes") + rfc = (routes.Path(__file__).parent.parent / "docs" / "rfcs" / "hermes-run-adapter-contract.md").read_text(encoding="utf-8") + + assert "#### Slice 4e: Default-off runner chat-start route-selection harness" in rfc + assert "route `/api/chat/start` through `build_runtime_adapter(...)`" in rfc + assert "`legacy-direct` stays default" in rfc + assert "`legacy-journal`\ncontinues to delegate to the legacy in-process stream path" in rfc + assert "`runner-local`\ndoes not silently fall back to legacy" in rfc + assert "return a bounded not-configured error for `runner-local`" in rfc + assert "`run_id`, `status`, and\n `active_controls` remain internal" in rfc + assert "no supervised runner process yet" in rfc + def test_runner_runtime_adapter_passes_explicit_start_payload_without_env_mutation(monkeypatch): runtime = importlib.import_module("api.runtime_adapter") captured = [] From 598fd4ff83a7c8dde920e7710878a0c5eb6d493f Mon Sep 17 00:00:00 2001 From: Qi Date: Sun, 24 May 2026 05:03:35 +0000 Subject: [PATCH 58/68] perf(http): enable HTTP/1.1 keep-alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable HTTP/1.1 on the WebUI server so browsers can reuse TCP connections across normal API/static requests. Tighten response framing by adding Content-Length to short manual responses and marking SSE/streaming responses as Connection: close, keeping HTTP/1.1 message boundaries unambiguous. Verified: - python3 -m py_compile server.py api/auth.py api/routes.py api/kanban_bridge.py - pytest tests/test_auth_*.py tests/test_*sse*.py tests/test_pr1350_*.py tests/test_pr1355_sse_handler_no_deadlock.py tests/test_kanban_bridge.py tests/test_logs_ui_static.py tests/test_onboarding_static.py tests/test_regressions.py tests/test_1038_pwa_auth_redirect.py tests/test_issue1623_sse_heartbeat_alignment.py → 239 passed, 1 skipped --- api/auth.py | 5 ++++- api/kanban_bridge.py | 2 +- api/routes.py | 27 +++++++++++++++++---------- server.py | 7 +++++++ 4 files changed, 29 insertions(+), 12 deletions(-) diff --git a/api/auth.py b/api/auth.py index df5f0e4c..5a49516f 100644 --- a/api/auth.py +++ b/api/auth.py @@ -435,10 +435,12 @@ def check_auth(handler, parsed) -> bool: return True # Not authorized if parsed.path.startswith('/api/'): + body = b'{"error":"Authentication required"}' handler.send_response(401) handler.send_header('Content-Type', 'application/json') + handler.send_header('Content-Length', str(len(body))) handler.end_headers() - handler.wfile.write(b'{"error":"Authentication required"}') + handler.wfile.write(body) else: handler.send_response(302) # Pass the original path as ?next= so login.js redirects back after auth. @@ -468,6 +470,7 @@ def check_auth(handler, parsed) -> bool: # `?`, `&`, `=`) gets percent-encoded. _next = _urlparse.quote(_path_with_query, safe='/') handler.send_header('Location', 'login?next=' + _next) + handler.send_header('Content-Length', '0') handler.end_headers() return False diff --git a/api/kanban_bridge.py b/api/kanban_bridge.py index 63bef9cd..f0d5d261 100644 --- a/api/kanban_bridge.py +++ b/api/kanban_bridge.py @@ -1022,7 +1022,7 @@ def _handle_events_sse_stream(handler, parsed): handler.send_header("Content-Type", "text/event-stream; charset=utf-8") handler.send_header("Cache-Control", "no-cache") handler.send_header("X-Accel-Buffering", "no") - handler.send_header("Connection", "keep-alive") + handler.send_header("Connection", "close") handler.end_headers() # Send an initial frame so the client knows the connection is open diff --git a/api/routes.py b/api/routes.py index 64fa3d9e..fe0ddd0e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6216,13 +6216,15 @@ def handle_post(handler, parsed) -> bool: _record_login_attempt(client_ip) return bad(handler, "Invalid password", 401) cookie_val = create_session() + body = json.dumps({"ok": True}).encode() handler.send_response(200) handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(body))) handler.send_header("Cache-Control", "no-store") _security_headers(handler) set_auth_cookie(handler, cookie_val) handler.end_headers() - handler.wfile.write(json.dumps({"ok": True}).encode()) + handler.wfile.write(body) return True if parsed.path == "/api/auth/logout": @@ -6231,13 +6233,15 @@ def handle_post(handler, parsed) -> bool: cookie_val = parse_cookie(handler) if cookie_val: invalidate_session(cookie_val) + body = json.dumps({"ok": True}).encode() handler.send_response(200) handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(body))) handler.send_header("Cache-Control", "no-store") _security_headers(handler) clear_auth_cookie(handler) handler.end_headers() - handler.wfile.write(json.dumps({"ok": True}).encode()) + handler.wfile.write(body) return True # ── Checkpoints / Rollback (POST) ── @@ -6577,7 +6581,7 @@ def _handle_sse_stream(handler, parsed): handler.send_header("Content-Type", "text/event-stream; charset=utf-8") handler.send_header("Cache-Control", "no-cache") handler.send_header("X-Accel-Buffering", "no") - handler.send_header("Connection", "keep-alive") + handler.send_header("Connection", "close") handler.end_headers() try: _replay_run_journal(handler, stream_id, _parse_run_journal_after_seq(qs)) @@ -6589,7 +6593,7 @@ def _handle_sse_stream(handler, parsed): handler.send_header("Content-Type", "text/event-stream; charset=utf-8") handler.send_header("Cache-Control", "no-cache") handler.send_header("X-Accel-Buffering", "no") - handler.send_header("Connection", "keep-alive") + handler.send_header("Connection", "close") handler.end_headers() try: while True: @@ -6720,7 +6724,7 @@ def _handle_terminal_output(handler, parsed): handler.send_header("Content-Type", "text/event-stream; charset=utf-8") handler.send_header("Cache-Control", "no-cache") handler.send_header("X-Accel-Buffering", "no") - handler.send_header("Connection", "keep-alive") + handler.send_header("Connection", "close") handler.end_headers() try: while True: @@ -6798,7 +6802,7 @@ def _handle_gateway_sse_stream(handler, parsed): handler.send_header('Content-Type', 'text/event-stream; charset=utf-8') handler.send_header('Cache-Control', 'no-cache') handler.send_header('X-Accel-Buffering', 'no') - handler.send_header('Connection', 'keep-alive') + handler.send_header('Connection', 'close') handler.end_headers() q = watcher.subscribe() @@ -6831,7 +6835,7 @@ def _handle_session_events_stream(handler): handler.send_header('Content-Type', 'text/event-stream; charset=utf-8') handler.send_header('Cache-Control', 'no-cache') handler.send_header('X-Accel-Buffering', 'no') - handler.send_header('Connection', 'keep-alive') + handler.send_header('Connection', 'close') handler.end_headers() q = subscribe_session_events() @@ -6917,6 +6921,7 @@ def _serve_file_bytes(handler, target: Path, mime: str, disposition: str, cache_ handler.send_response(416) handler.send_header("Content-Range", f"bytes */{file_size}") handler.send_header("Accept-Ranges", "bytes") + handler.send_header("Content-Length", "0") _security_headers(handler) handler.end_headers() return True @@ -7027,10 +7032,12 @@ def _handle_media(handler, parsed): if is_auth_enabled(): cv = parse_cookie(handler) if not (cv and verify_session(cv)): + body = b'{"error":"Authentication required"}' handler.send_response(401) handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(body))) handler.end_headers() - handler.wfile.write(b'{"error":"Authentication required"}') + handler.wfile.write(body) return qs = parse_qs(parsed.query) @@ -7389,7 +7396,7 @@ def _handle_approval_sse_stream(handler, parsed): handler.send_header('Content-Type', 'text/event-stream; charset=utf-8') handler.send_header('Cache-Control', 'no-cache') handler.send_header('X-Accel-Buffering', 'no') - handler.send_header('Connection', 'keep-alive') + handler.send_header('Connection', 'close') handler.end_headers() from api.streaming import _sse @@ -7490,7 +7497,7 @@ def _handle_clarify_sse_stream(handler, parsed): handler.send_header('Content-Type', 'text/event-stream; charset=utf-8') handler.send_header('Cache-Control', 'no-cache') handler.send_header('X-Accel-Buffering', 'no') - handler.send_header('Connection', 'keep-alive') + handler.send_header('Connection', 'close') handler.end_headers() from api.streaming import _sse diff --git a/server.py b/server.py index e6636209..e6a2c65a 100644 --- a/server.py +++ b/server.py @@ -170,6 +170,13 @@ class QuietHTTPServer(ThreadingHTTPServer): class Handler(BaseHTTPRequestHandler): + # HTTP/1.1 enables keep-alive connection reuse — major latency win on + # high-RTT links where every saved TCP handshake is 2×RTT. Each response + # MUST declare framing (Content-Length, Transfer-Encoding: chunked, or + # Connection: close) so the client knows where the message ends. Helpers + # j()/t() emit Content-Length; SSE/streaming endpoints emit + # Connection: close because the body has no terminator. See PR notes. + protocol_version = "HTTP/1.1" timeout = 30 # seconds — kills idle/incomplete connections to prevent thread exhaustion def setup(self): From a86b378036192fa77bf4afd6766153593cd54ff5 Mon Sep 17 00:00:00 2001 From: hermes-agent Date: Sun, 24 May 2026 18:28:26 +0000 Subject: [PATCH 59/68] =?UTF-8?q?Stage=20405:=20PR=20#2680=20=E2=80=94=20f?= =?UTF-8?q?eat:=20add=20Auxiliary=20Models=20settings=20card=20by=20@mccxj?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked via 3-way apply (rebase had failed on static/index.html conflict when applied via rebase commit chain; 3-way of the net delta against stage HEAD applied cleanly). Co-authored-by: mccxj --- api/config.py | 106 ++++++++++++ api/routes.py | 24 +++ static/i18n.js | 176 +++++++++++++++++++ static/index.html | 9 + static/panels.js | 219 ++++++++++++++++++++++++ tests/test_auxiliary_models_settings.py | 217 +++++++++++++++++++++++ 6 files changed, 751 insertions(+) create mode 100644 tests/test_auxiliary_models_settings.py diff --git a/api/config.py b/api/config.py index 293dc953..66bcb570 100644 --- a/api/config.py +++ b/api/config.py @@ -2185,6 +2185,112 @@ def set_hermes_default_model(model_id: str) -> dict: return {"ok": True, "model": persisted_model} +# ── Auxiliary model configuration ────────────────────────────────────────── + +# Canonical auxiliary task slots. Keep in sync with hermes_cli/config.py +# DEFAULT_CONFIG["auxiliary"] and hermes_cli/web_server.py _AUX_TASK_SLOTS. +AUX_TASK_SLOTS: tuple[str, ...] = ( + "vision", + "web_extract", + "compression", + "session_search", + "skills_hub", + "approval", + "mcp", + "title_generation", + "curator", +) + + +def get_auxiliary_models() -> dict: + """Return current auxiliary task assignments from config.yaml. + + Shape: + { + "tasks": [ + {"task": "vision", "provider": "auto", "model": "", "base_url": ""}, + ... + ], + "main": {"provider": "openrouter", "model": "anthropic/claude-opus-4.7"}, + } + """ + reload_config() + model_cfg = cfg.get("model", {}) + if not isinstance(model_cfg, dict): + model_cfg = {} + main_provider = str(model_cfg.get("provider") or "").strip() + main_model = str(model_cfg.get("default") or model_cfg.get("name") or "").strip() + + aux_cfg = cfg.get("auxiliary", {}) + if not isinstance(aux_cfg, dict): + aux_cfg = {} + + tasks = [] + for slot in AUX_TASK_SLOTS: + entry = aux_cfg.get(slot, {}) + if not isinstance(entry, dict): + entry = {} + tasks.append({ + "task": slot, + "provider": str(entry.get("provider") or "auto").strip(), + "model": str(entry.get("model") or "").strip(), + "base_url": str(entry.get("base_url") or "").strip(), + }) + + return { + "tasks": tasks, + "main": {"provider": main_provider, "model": main_model}, + } + + +def set_auxiliary_model(task: str, provider: str, model: str) -> dict: + """Persist an auxiliary model assignment in config.yaml. + + Special case: task='__reset__' clears all auxiliary slots. + """ + config_path = _get_config_path() + with _cfg_lock: + config_data = _load_yaml_config_file(config_path) + + if task == "__reset__": + # Per-slot reset: set each slot to auto, preserving extra fields + # (timeout, extra_body, api_key, base_url, download_timeout, etc.) + aux_cfg = config_data.get("auxiliary", {}) + if not isinstance(aux_cfg, dict): + aux_cfg = {} + for slot in AUX_TASK_SLOTS: + slot_cfg = aux_cfg.get(slot, {}) + if not isinstance(slot_cfg, dict): + slot_cfg = {} + slot_cfg["provider"] = "auto" + slot_cfg["model"] = "" + aux_cfg[slot] = slot_cfg + config_data["auxiliary"] = aux_cfg + else: + aux_cfg = config_data.get("auxiliary", {}) + if not isinstance(aux_cfg, dict): + aux_cfg = {} + slot_cfg = aux_cfg.get(task, {}) + if not isinstance(slot_cfg, dict): + slot_cfg = {} + slot_cfg["provider"] = provider or "auto" + slot_cfg["model"] = model or "" + if provider and (provider.startswith("custom:") or provider == "custom"): + try: + _, _, resolved_base_url = resolve_model_provider(model) + if resolved_base_url: + slot_cfg["base_url"] = str(resolved_base_url).strip().rstrip("/") + except Exception: + pass + aux_cfg[task] = slot_cfg + config_data["auxiliary"] = aux_cfg + + _save_yaml_config_file(config_path, config_data) + + reload_config() + return {"ok": True, "task": task, "provider": provider, "model": model} + + # ── TTL cache for get_available_models() ───────────────────────────────────── _available_models_cache: dict | None = None _available_models_cache_ts: float = 0.0 diff --git a/api/routes.py b/api/routes.py index fe0ddd0e..da861a7d 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3687,6 +3687,11 @@ def handle_get(handler, parsed) -> bool: if parsed.path == "/api/models/live": return _handle_live_models(handler, parsed) + # ── Auxiliary models (GET/POST) ── + if parsed.path == "/api/model/auxiliary": + from api.config import get_auxiliary_models + return j(handler, get_auxiliary_models()) + if parsed.path == "/api/dashboard/status": from api import dashboard_probe @@ -4857,6 +4862,25 @@ def handle_post(handler, parsed) -> bool: except RuntimeError as e: return bad(handler, str(e), 500) + # ── Auxiliary model set (POST) ── + if parsed.path == "/api/model/set": + scope = str(body.get("scope") or "").strip() + task = str(body.get("task") or "").strip() + provider = str(body.get("provider") or "auto").strip() + model = str(body.get("model") or "").strip() + if scope == "auxiliary": + from api.config import set_auxiliary_model + try: + return j(handler, set_auxiliary_model(task, provider, model)) + except Exception as exc: + return bad(handler, str(exc), status=400) + if scope == "main": + try: + return j(handler, set_hermes_default_model(model)) + except ValueError as exc: + return bad(handler, str(exc), status=400) + return bad(handler, f"unknown scope: {scope}", status=400) + # ── Providers (POST) ── if parsed.path == "/api/providers": provider_id = (body.get("provider") or "").strip().lower() diff --git a/static/i18n.js b/static/i18n.js index c1ba196f..51aa5543 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -548,6 +548,22 @@ const LOCALES = { settings_save_btn: 'Save Settings', settings_label_model: 'Default Model', settings_desc_model: 'Used for new conversations. Existing conversations keep their selected model.', + settings_label_auxiliary_models: 'Auxiliary Models', + settings_desc_auxiliary_models: 'Side-task routing for vision, compression, title generation, etc. "Auto" uses your main chat model.', + settings_btn_reset_aux_models: 'Reset all to auto', + settings_btn_apply_aux_models: 'Apply changes', + settings_aux_provider_auto: 'use main model', + settings_aux_model_auto: 'auto (use provider default)', + settings_aux_model_custom: 'Custom model…', + settings_aux_model_custom_prompt: 'Enter model ID:', + settings_aux_loading: 'Loading auxiliary models…', + settings_aux_load_failed: 'Could not load auxiliary model settings. Make sure the agent API is available.', + settings_aux_reset_confirm_title: 'Reset auxiliary models?', + settings_aux_reset_confirm_msg: 'This will set all auxiliary tasks to auto (use main model).', + settings_aux_reset_done: 'Auxiliary models reset to auto', + settings_aux_save_failed: 'Failed to save auxiliary model', + settings_aux_saved: 'Auxiliary models updated', + settings_aux_no_changes: 'No changes to apply', settings_label_send_key: 'Send Key', settings_label_theme: 'Theme', settings_label_skin: 'Skin', @@ -1791,6 +1807,22 @@ const LOCALES = { settings_save_btn: 'Salva Impostazioni', settings_label_model: 'Modello Predefinito', settings_desc_model: 'Usato per le nuove conversazioni. Le conversazioni esistenti mantengono il modello selezionato.', + settings_label_auxiliary_models: 'Modelli Ausiliari', + settings_desc_auxiliary_models: 'Routing per attività secondarie come visione, compressione, generazione titoli, ecc. "Auto" utilizza il modello chat principale.', + settings_btn_reset_aux_models: 'Ripristina tutto ad auto', + settings_btn_apply_aux_models: 'Applica modifiche', + settings_aux_provider_auto: 'usa modello principale', + settings_aux_model_auto: 'auto (usa predefinito del provider)', + settings_aux_model_custom: 'Modello personalizzato…', + settings_aux_model_custom_prompt: 'Inserisci ID modello:', + settings_aux_loading: 'Caricamento modelli ausiliari…', + settings_aux_load_failed: 'Impossibile caricare le impostazioni dei modelli ausiliari. Assicurati che l’API dell’agent sia disponibile.', + settings_aux_reset_confirm_title: 'Ripristinare modelli ausiliari?', + settings_aux_reset_confirm_msg: 'Questo imposterà tutte le attività ausiliarie ad auto (usa modello principale).', + settings_aux_reset_done: 'Modelli ausiliari ripristinati ad auto', + settings_aux_save_failed: 'Salvataggio del modello ausiliario non riuscito', + settings_aux_saved: 'Modelli ausiliari aggiornati', + settings_aux_no_changes: 'Nessuna modifica da applicare', settings_label_send_key: 'Tasto Invio', settings_label_theme: 'Tema', settings_label_skin: 'Skin', @@ -3026,6 +3058,22 @@ const LOCALES = { settings_save_btn: '設定を保存', settings_label_model: 'デフォルトモデル', settings_desc_model: '新しい会話で使用されます。既存の会話は選択済みモデルを保持します。', + settings_label_auxiliary_models: '補助モデル', + settings_desc_auxiliary_models: 'ビジョン、圧縮、タイトル生成などの補助タスクのルーティング。「自動」はメインチャットモデルを使用します。', + settings_btn_reset_aux_models: 'すべて自動にリセット', + settings_btn_apply_aux_models: '変更を適用', + settings_aux_provider_auto: 'メインモデルを使用', + settings_aux_model_auto: '自動(プロバイダーのデフォルトを使用)', + settings_aux_model_custom: 'カスタムモデル…', + settings_aux_model_custom_prompt: 'モデルIDを入力:', + settings_aux_loading: '補助モデルを読み込み中…', + settings_aux_load_failed: '補助モデル設定を読み込めませんでした。エージェントAPIが利用可能であることを確認してください。', + settings_aux_reset_confirm_title: '補助モデルをリセットしますか?', + settings_aux_reset_confirm_msg: 'すべての補助タスクを自動(メインモデルを使用)に設定します。', + settings_aux_reset_done: '補助モデルを自動にリセットしました', + settings_aux_save_failed: '補助モデルの保存に失敗しました', + settings_aux_saved: '補助モデルを更新しました', + settings_aux_no_changes: '適用する変更はありません', settings_label_send_key: '送信キー', settings_label_theme: 'テーマ', settings_label_skin: 'スキン', @@ -4077,6 +4125,22 @@ const LOCALES = { settings_save_btn: 'Сохранить настройки', settings_label_model: 'Модель по умолчанию', settings_desc_model: 'Используется для новых бесед. Существующие беседы сохраняют выбранную модель.', + settings_label_auxiliary_models: 'Вспомогательные модели', + settings_desc_auxiliary_models: 'Маршрутизация побочных задач: зрение, сжатие, генерация заголовков и т.д. «Авто» использует основную модель чата.', + settings_btn_reset_aux_models: 'Сбросить всё на авто', + settings_btn_apply_aux_models: 'Применить изменения', + settings_aux_provider_auto: 'использовать основную модель', + settings_aux_model_auto: 'авто (по умолчанию провайдера)', + settings_aux_model_custom: 'Пользовательская модель…', + settings_aux_model_custom_prompt: 'Введите ID модели:', + settings_aux_loading: 'Загрузка вспомогательных моделей…', + settings_aux_load_failed: 'Не удалось загрузить настройки вспомогательных моделей. Убедитесь, что API агента доступен.', + settings_aux_reset_confirm_title: 'Сбросить вспомогательные модели?', + settings_aux_reset_confirm_msg: 'Все вспомогательные задачи будут установлены на «авто» (использовать основную модель).', + settings_aux_reset_done: 'Вспомогательные модели сброшены на авто', + settings_aux_save_failed: 'Не удалось сохранить вспомогательную модель', + settings_aux_saved: 'Вспомогательные модели обновлены', + settings_aux_no_changes: 'Нет изменений для применения', settings_label_send_key: 'Клавиша отправки', settings_label_theme: 'Тема', settings_label_language: 'Язык', @@ -5231,6 +5295,22 @@ const LOCALES = { settings_save_btn: 'Guardar configuración', settings_label_model: 'Modelo predeterminado', settings_desc_model: 'Se usa para conversaciones nuevas. Las conversaciones existentes conservan su modelo seleccionado.', + settings_label_auxiliary_models: 'Modelos auxiliares', + settings_desc_auxiliary_models: 'Enrutamiento para tareas secundarias como visión, compresión, generación de títulos, etc. «Auto» usa el modelo de chat principal.', + settings_btn_reset_aux_models: 'Restablecer todo a auto', + settings_btn_apply_aux_models: 'Aplicar cambios', + settings_aux_provider_auto: 'usar modelo principal', + settings_aux_model_auto: 'auto (usar predeterminado del proveedor)', + settings_aux_model_custom: 'Modelo personalizado…', + settings_aux_model_custom_prompt: 'Ingrese ID del modelo:', + settings_aux_loading: 'Cargando modelos auxiliares…', + settings_aux_load_failed: 'No se pudieron cargar las configuraciones de modelos auxiliares. Asegúrese de que la API del agente esté disponible.', + settings_aux_reset_confirm_title: '¿Restablecer modelos auxiliares?', + settings_aux_reset_confirm_msg: 'Esto establecerá todas las tareas auxiliares en auto (usar modelo principal).', + settings_aux_reset_done: 'Modelos auxiliares restablecidos a auto', + settings_aux_save_failed: 'Error al guardar el modelo auxiliar', + settings_aux_saved: 'Modelos auxiliares actualizados', + settings_aux_no_changes: 'Sin cambios para aplicar', settings_label_send_key: 'Tecla de envío', settings_label_theme: 'Tema', settings_label_skin: 'Piel', @@ -6388,6 +6468,22 @@ const LOCALES = { settings_save_btn: 'Einstellungen speichern', settings_label_model: 'Standard-Modell', settings_desc_model: 'Wird für neue Chats verwendet. Bestehende Chats behalten ihr ausgewähltes Modell.', + settings_label_auxiliary_models: 'Hilfsmodelle', + settings_desc_auxiliary_models: 'Routing für Nebenaufgaben wie Vision, Komprimierung, Titelgenerierung usw. „Auto" verwendet das Haupt-Chat-Modell.', + settings_btn_reset_aux_models: 'Alle auf auto zurücksetzen', + settings_btn_apply_aux_models: 'Änderungen anwenden', + settings_aux_provider_auto: 'Hauptmodell verwenden', + settings_aux_model_auto: 'auto (Provider-Standard verwenden)', + settings_aux_model_custom: 'Benutzerdefiniertes Modell…', + settings_aux_model_custom_prompt: 'Modell-ID eingeben:', + settings_aux_loading: 'Hilfsmodelle werden geladen…', + settings_aux_load_failed: 'Hilfsmodelle-Einstellungen konnten nicht geladen werden. Stellen Sie sicher, dass die Agent-API verfügbar ist.', + settings_aux_reset_confirm_title: 'Hilfsmodelle zurücksetzen?', + settings_aux_reset_confirm_msg: 'Dies setzt alle Hilfsaufgaben auf auto (Hauptmodell verwenden).', + settings_aux_reset_done: 'Hilfsmodelle auf auto zurückgesetzt', + settings_aux_save_failed: 'Hilfsmodell konnte nicht gespeichert werden', + settings_aux_saved: 'Hilfsmodelle aktualisiert', + settings_aux_no_changes: 'Keine Änderungen anzuwenden', settings_label_send_key: 'Sende-Taste', settings_label_theme: 'Theme', settings_label_skin: 'Skin', @@ -7597,6 +7693,22 @@ const LOCALES = { settings_save_btn: '保存设置', settings_label_model: '默认模型', settings_desc_model: '用于新对话。现有对话保持各自选定的模型。', + settings_label_auxiliary_models: '辅助模型', + settings_desc_auxiliary_models: '视觉分析、上下文压缩、标题生成等辅助任务的路由。"自动"表示使用主聊天模型。', + settings_btn_reset_aux_models: '全部重置为自动', + settings_btn_apply_aux_models: '应用更改', + settings_aux_provider_auto: '使用主模型', + settings_aux_model_auto: '自动(使用提供商默认)', + settings_aux_model_custom: '自定义模型…', + settings_aux_model_custom_prompt: '输入模型 ID:', + settings_aux_loading: '正在加载辅助模型…', + settings_aux_load_failed: '无法加载辅助模型设置,请确保 Agent API 可用。', + settings_aux_reset_confirm_title: '重置辅助模型?', + settings_aux_reset_confirm_msg: '这将把所有辅助任务设置为自动(使用主模型)。', + settings_aux_reset_done: '辅助模型已重置为自动', + settings_aux_save_failed: '辅助模型保存失败', + settings_aux_saved: '辅助模型已更新', + settings_aux_no_changes: '没有需要应用的更改', settings_label_send_key: '发送快捷键', settings_label_theme: '主题', settings_label_skin: '皮肤', @@ -8783,6 +8895,22 @@ const LOCALES = { settings_save_btn: '\u5132\u5b58\u8a2d\u5b9a', settings_label_model: '\u9ed8\u8a8d\u6a21\u578b', settings_desc_model: '\u7528\u65bc\u65b0\u6703\u8a71\u3002\u73fe\u6709\u6703\u8a71\u6703\u4fdd\u7559\u5404\u81ea\u9078\u5b9a\u7684\u6a21\u578b\u3002', + settings_label_auxiliary_models: '\u8f14\u52a9\u6a21\u578b', + settings_desc_auxiliary_models: '\u8996\u89ba\u5206\u6790\u3001\u4e0a\u4e0b\u6587\u58d3\u7e2e\u3001\u6a19\u984c\u7522\u751f\u7b49\u8f14\u52a9\u4efb\u52d9\u7684\u8def\u7531\u3002\u300c\u81ea\u52d5\u300d\u8868\u793a\u4f7f\u7528\u4e3b\u804a\u5929\u6a21\u578b\u3002', + settings_btn_reset_aux_models: '\u5168\u90e8\u91cd\u7f6e\u70ba\u81ea\u52d5', + settings_btn_apply_aux_models: '\u61c9\u7528\u8b8a\u66f4', + settings_aux_provider_auto: '\u4f7f\u7528\u4e3b\u6a21\u578b', + settings_aux_model_auto: '\u81ea\u52d5\uff08\u4f7f\u7528\u63d0\u4f9b\u5546\u9810\u8a2d\uff09', + settings_aux_model_custom: '\u81ea\u8a02\u6a21\u578b\u2026', + settings_aux_model_custom_prompt: '\u8f38\u5165\u6a21\u578b ID\uff1a', + settings_aux_loading: '\u6b63\u5728\u8f09\u5165\u8f14\u52a9\u6a21\u578b\u2026', + settings_aux_load_failed: '\u7121\u6cd5\u8f09\u5165\u8f14\u52a9\u6a21\u578b\u8a2d\u5b9a\uff0c\u8acb\u78ba\u4fdd Agent API \u53ef\u7528\u3002', + settings_aux_reset_confirm_title: '\u91cd\u7f6e\u8f14\u52a9\u6a21\u578b\uff1f', + settings_aux_reset_confirm_msg: '\u9019\u5c07\u628a\u6240\u6709\u8f14\u52a9\u4efb\u52d9\u8a2d\u5b9a\u70ba\u81ea\u52d5\uff08\u4f7f\u7528\u4e3b\u6a21\u578b\uff09\u3002', + settings_aux_reset_done: '\u8f14\u52a9\u6a21\u578b\u5df2\u91cd\u7f6e\u70ba\u81ea\u52d5', + settings_aux_save_failed: '\u8f14\u52a9\u6a21\u578b\u5132\u5b58\u5931\u6557', + settings_aux_saved: '\u8f14\u52a9\u6a21\u578b\u5df2\u66f4\u65b0', + settings_aux_no_changes: '\u6c92\u6709\u9700\u8981\u61c9\u7528\u7684\u8b8a\u66f4', settings_label_send_key: '\u767c\u9001\u5feb\u6377\u9375', settings_label_theme: '\u4e3b\u984c', settings_label_skin: '佈景', @@ -10096,6 +10224,22 @@ const LOCALES = { settings_save_btn: 'Salvar Configurações', settings_label_model: 'Modelo Padrão', settings_desc_model: 'Usado para novas conversas. Conversas existentes mantêm o modelo selecionado.', + settings_label_auxiliary_models: 'Modelos auxiliares', + settings_desc_auxiliary_models: 'Roteamento para tarefas secundárias como visão, compressão, geração de títulos, etc. "Auto" usa o modelo de chat principal.', + settings_btn_reset_aux_models: 'Restaurar tudo para auto', + settings_btn_apply_aux_models: 'Aplicar alterações', + settings_aux_provider_auto: 'usar modelo principal', + settings_aux_model_auto: 'auto (usar padrão do provedor)', + settings_aux_model_custom: 'Modelo personalizado…', + settings_aux_model_custom_prompt: 'Insira o ID do modelo:', + settings_aux_loading: 'Carregando modelos auxiliares…', + settings_aux_load_failed: 'Não foi possível carregar as configurações de modelos auxiliares. Certifique-se de que a API do agente esteja disponível.', + settings_aux_reset_confirm_title: 'Restaurar modelos auxiliares?', + settings_aux_reset_confirm_msg: 'Isso definirá todas as tarefas auxiliares para auto (usar modelo principal).', + settings_aux_reset_done: 'Modelos auxiliares restaurados para auto', + settings_aux_save_failed: 'Falha ao salvar o modelo auxiliar', + settings_aux_saved: 'Modelos auxiliares atualizados', + settings_aux_no_changes: 'Nenhuma alteração para aplicar', settings_label_send_key: 'Tecla de Envio', settings_label_theme: 'Tema', settings_label_skin: 'Skin', @@ -11234,6 +11378,22 @@ const LOCALES = { settings_save_btn: '설정 저장', settings_label_model: '기본 모델', settings_desc_model: '새 대화에 사용됩니다. 기존 대화는 선택된 모델을 유지합니다.', + settings_label_auxiliary_models: '보조 모델', + settings_desc_auxiliary_models: '비전, 압축, 제목 생성 등 보조 작업 라우팅. "자동"은 기본 채팅 모델을 사용합니다.', + settings_btn_reset_aux_models: '모두 자동으로 재설정', + settings_btn_apply_aux_models: '변경 사항 적용', + settings_aux_provider_auto: '기본 모델 사용', + settings_aux_model_auto: '자동 (제공자 기본값 사용)', + settings_aux_model_custom: '사용자 정의 모델…', + settings_aux_model_custom_prompt: '모델 ID 입력:', + settings_aux_loading: '보조 모델 로딩 중…', + settings_aux_load_failed: '보조 모델 설정을 로드할 수 없습니다. 에이전트 API가 사용 가능한지 확인하세요.', + settings_aux_reset_confirm_title: '보조 모델을 재설정하시겠습니까?', + settings_aux_reset_confirm_msg: '모든 보조 작업이 자동(기본 모델 사용)으로 설정됩니다.', + settings_aux_reset_done: '보조 모델이 자동으로 재설정됨', + settings_aux_save_failed: '보조 모델 저장 실패', + settings_aux_saved: '보조 모델 업데이트됨', + settings_aux_no_changes: '적용할 변경 사항 없음', settings_label_send_key: '전송 키', settings_label_theme: '테마', settings_label_skin: '스킨', @@ -12385,6 +12545,22 @@ const LOCALES = { settings_save_btn: 'Enregistrer les paramètres', settings_label_model: 'Modèle par défaut', settings_desc_model: 'Utilisé pour les nouvelles conversations. Les conversations existantes conservent leur modèle sélectionné.', + settings_label_auxiliary_models: 'Modèles auxiliaires', + settings_desc_auxiliary_models: 'Routage des tâches secondaires : vision, compression, génération de titres, etc. « Auto » utilise le modèle de chat principal.', + settings_btn_reset_aux_models: 'Tout réinitialiser à auto', + settings_btn_apply_aux_models: 'Appliquer les modifications', + settings_aux_provider_auto: 'utiliser le modèle principal', + settings_aux_model_auto: 'auto (utiliser la valeur par défaut du fournisseur)', + settings_aux_model_custom: 'Modèle personnalisé…', + settings_aux_model_custom_prompt: 'Entrez l\u2019ID du modèle :', + settings_aux_loading: 'Chargement des modèles auxiliaires…', + settings_aux_load_failed: 'Impossible de charger les paramètres des modèles auxiliaires. Vérifiez que l\u2019API de l\u2019agent est disponible.', + settings_aux_reset_confirm_title: 'Réinitialiser les modèles auxiliaires ?', + settings_aux_reset_confirm_msg: 'Cela définira toutes les tâches auxiliaires sur auto (utiliser le modèle principal).', + settings_aux_reset_done: 'Modèles auxiliaires réinitialisés à auto', + settings_aux_save_failed: 'Échec de la sauvegarde du modèle auxiliaire', + settings_aux_saved: 'Modèles auxiliaires mis à jour', + settings_aux_no_changes: 'Aucune modification à appliquer', settings_label_send_key: 'Envoyer la clé', settings_label_theme: 'Thème', settings_label_skin: 'Peau', diff --git a/static/index.html b/static/index.html index e5518943..8b4009c0 100644 --- a/static/index.html +++ b/static/index.html @@ -997,6 +997,15 @@
    Used for new conversations. Existing conversations keep their selected model.
    +
    + +
    Side-task routing for vision, compression, title generation, etc. "Auto" uses your main chat model.
    +
    +
    + + +
    +