release: v0.50.245 — 10-PR batch - #1334
Conversation
From PR #1331. Co-authored-by: Leon.C <160379708+zichen0116@users.noreply.github.com>
From PR #1332. Co-authored-by: Leon.C <160379708+zichen0116@users.noreply.github.com>
…o stop repeated banner From PR #1316. Co-authored-by: qxxaa <mrhanoi@outlook.com>
From PR #1323. Co-authored-by: Dennis Soong <dso2ng@gmail.com>
From PR #1324. Co-authored-by: Dennis Soong <dso2ng@gmail.com>
From PR #1322. Co-authored-by: renatomott <renato.mott@gmail.com>
From PR #1328. Co-authored-by: Frank Song <franksong2702@gmail.com>
10 contributor fixes — cron worker scope, compression banner, mobile workspace sliver, streaming session sidebar exemption, slash-qualified model dedup, configured-fallback dropdown synthesis, copy-button idempotency, zh-Hant locale restore, Docker HEALTHCHECK, .env.example state-dir alignment. See CHANGELOG.md for the full list with author credit.
Per Opus pre-release review (SHOULD-FIX #1): the CHANGELOG claimed both filter sites exempt 'active_stream_id OR pending_user_message', but the index path operates on compact() output which doesn't include pending_user_message. The behavior is correct in both paths because both fields are set/cleared in lockstep during streaming, but the wording was stronger than what the code does. Tightened to describe what each path actually checks.
nesquena
left a comment
There was a problem hiding this comment.
Review — end-to-end ✅ (clean approve)
Release v0.50.245 integrating 10 contributor PRs. None of the constituents had a prior formal event=APPROVE review on file (only triage acknowledgement comments from the agent bot), so this review traced each constituent end-to-end against current master and the upstream hermes-agent tarball. Squash sizes match every open PR byte-for-byte (b2fbacf/9a6caa1/d0f6ee2/5bde48b/eb95c6a/1ccd958/92121324/e86de0a/4683a4a/aa2b9d5 — see Step 1 below). Full suite is green; CHANGELOG is comprehensive with author credit per PR.
What this ships
| # | Author | Subject | Files |
|---|---|---|---|
| #1317 | @fxd-jason | cron run_job import scope |
api/routes.py + tests |
| #1316 | @qxxaa | per-turn compression-count snapshot | api/streaming.py |
| #1322 | @renatomott | synthesize missing configured fallbacks in dropdown | static/ui.js + tests |
| #1323 | @dso2ng | restore Traditional Chinese labels in zh-Hant block | static/i18n.js + tests |
| #1324 | @dso2ng | duplicate-button guard checks .pre-header sibling |
static/ui.js + tests |
| #1326 | @Hacker2005 | dedup slash-qualified IDs + provider-aware rehydrate | api/config.py, static/ui.js, static/panels.js + tests |
| #1328 | @franksong2702 | mobile workspace sliver + container-query composer collapse | static/boot.js, static/style.css + tests |
| #1330 | @franksong2702 | exempt streaming sessions from Untitled+0-msg sidebar filter | api/models.py + tests |
| #1331 | @zichen0116 | .env.example state-dir alignment |
.env.example |
| #1332 | @zichen0116 | Docker HEALTHCHECK |
Dockerfile |
Squash audit (byte-identical to open PRs)
#1317 b2→d0f6ee2 93+/1- across 2 files ✅ matches open PR
#1316 5bde48b 5+/1- across 1 file ✅ matches open PR
#1322 e86de0a 67+/11- across 2 files ✅ matches open PR
#1323 eb95c6a 51+/25- across 2 files ✅ matches open PR
#1324 1ccd958 19+/3- across 2 files ✅ matches open PR
#1326 4683a4a 181+/54- across 5 files ✅ matches open PR
#1328 aa2b9d5 258+/13- across 4 files ✅ matches open PR
#1330 92121324 177+/1- across 2 files ✅ matches open PR
#1331 b2fbacf 1+/1- across 1 file ✅ matches open PR
#1332 9a6caa1 3+/0- across 1 file ✅ matches open PR
End-to-end traces
#1317 cron run_job import scope. Pre-fix, _handle_cron_run imported run_job (from cron.scheduler import run_job at the old api/routes.py:3501) into its local scope, then spawned threading.Thread(target=_run_cron_tracked, ...) at api/routes.py:3514. The worker target _run_cron_tracked at api/routes.py:95 has no run_job in scope — function namespaces don't share locals across calls — so the thread raised NameError the moment manual cron-run hit it. Fix moves the import inside _run_cron_tracked itself (api/routes.py:97). Verified the import is gone from _handle_cron_run (only from cron.jobs import get_job remains). Regression test (test_cron_run_job_import.py) is AST-based and asserts the import exists in the worker function — locks the invariant. ✅
#1316 compression banner per-turn. The agent's context_compressor lives on the cached agent stored in SESSION_AGENT_CACHE (verified at api/streaming.py:1788-1832), which means compression_count is a cumulative counter across all turns within a session lifetime. The pre-fix detector at line 2114 checked compression_count > 0, which stays true forever after the first compression event, so the banner re-fired on every subsequent turn. Fix snapshots the count before run_conversation() at api/streaming.py:1886-1889 and compares > _pre_compression_count at api/streaming.py:2114. The cumulative agent-cache lifetime is the actual root cause and the snapshot pattern is the right fix. ✅
#1322 fallback model dropdown synthesis. Trace:
/api/modelsbuildsconfigured_model_badgesat api/config.py:1442-1524 — for each fallback entry it registers all candidate forms (model,provider/model,@provider:model) into the badge map even when the model isn't in the live catalog (line 1521).- Frontend
populateModelDropdownpopulates_modelDatafrom<select>options (catalog only). - NEW loop at static/ui.js:500-510: iterates
_badgeMapentries, dedups against existing_modelDatavia_normalizeConfiguredModelKey(strips@provider:and slash-prefix, lowercases,-→.), and synthesizes entries for badged models missing from the catalog. _configuredRank()at static/ui.js:502-510 sorts: primary=0, fallback N=N, configured=500, none=infinity.
Behavioral harness on the normalize+rank path (12 inputs, all three candidate forms collapse to the same key, ranks come out as expected):
{"input":["llama-3","local-ollama/llama-3","@local-ollama:llama-3"],
"normalized":["llama.3","llama.3","llama.3"], "allEqual":true}
{"input":["gpt-4","@openai:gpt-4"], "normalized":["gpt.4","gpt.4"], "allEqual":true}
ranks: 0 1 2 500 Infinity
Names and ids are escaped via esc() (static/ui.js:506); badge label/role are escaped at render time. No XSS surface. ✅
#1323 zh-Hant locale. Diff replaces 18 Russian/Cyrillic labels and one Cyrillic-tagged tree_view: 'Дерево' / raw_view: 'Исходный' pair in the zh-Hant block with proper Traditional Chinese (樹狀, 原始, 儲存, 伺服器, etc.). Sanity-loaded static/i18n.js in node and confirmed LOCALES['zh-Hant'].tree_view === '樹狀', parse_failed_note === '解析失敗' (Traditional ㄒ敗), distinct from LOCALES.zh.parse_failed_note === '解析失败' (Simplified 败). Companion test asserts no Cyrillic codepoints in the zh-Hant block — locks the regression. ✅
#1324 duplicate copy button. For code blocks with a language fence, addCopyButtons appends the button to the .pre-header sibling of <pre>, not inside <pre> (the original idempotency guard only inspected pre.querySelector('.code-copy-btn')). Repeated render passes (cache replay, streaming completion) re-entered and stacked duplicate buttons in the header. Fix at static/ui.js:3715 widens the guard to also check header && header.classList.contains('pre-header') && header.querySelector('.code-copy-btn'). Test (test_issue1096_copy_buttons.py) asserts only one copy button after two passes. ✅
#1326 slash-qualified model dedup + provider-aware rehydration. Trace through both repos:
- Backend dedup —
_deduplicate_model_ids()at api/config.py:880-940 previously skipped IDs containing/("Skip IDs that are already provider-qualified"). Two providers exposing the samegoogle/gemma-4-27btherefore both kept the bare slash-qualified ID and the<option value>collided. Now the skip only excludes IDs that already start with@, so slash-qualified collisions get the second occurrence prefixed to@<provider_id>:google/gemma-4-27b(line 938). - Backend badge map —
_build_configured_model_badges()at api/config.py:1471-1523 now usesoption_provider_lookupto disambiguate normalize-equal candidates by provider instead of the old "first match wins" path. Skips registering badges for catalog candidates whoseoption_provider_lookupdiffers from the configured provider (line 1517-1520). ✅ - Frontend match —
_findModelInDropdownat static/ui.js:200-220 now takespreferredProviderId, readsoptgroup.dataset.provider(set when populating: static/ui.js:269), and prefers the option whose parent optgroup matches the active provider. Both call sites pass the hint:populateModelDropdownat static/ui.js:281 →data.active_providerpanels.jsswitchToProfileat static/panels.js:2247 →window._activeProviderpanels.jsloadSettingsPanelat static/panels.js:2756 →models.active_provider || window._activeProvider
- Cross-tool persistence — verified by harness:
'@deepinfra:google/gemma-4-27b' → ('google/gemma-4-27b', 'deepinfra', None) '@nous:anthropic/claude-opus-4.6' → ('anthropic/claude-opus-4.6', 'nous', None)resolve_model_provider()at api/config.py:997-999 strips the@provider:prefix, so when WebUI persists,config.yaml.model.defaultgets the bare/slash form (verified at api/config.py:1186). The CLI never sees the@provider:prefix. ✅ - Agent invariant — searched
/tmp/hermes-agent-fresh/hermes_cli/for@provider:handling: none, because the prefix never reaches the CLI in the first place. Confirmed.
#1328 mobile workspace sliver + composer collapse. Two related fixes:
- Workspace sliver —
_syncWorkspacePanelInlineWidth()at static/boot.js:23-37 removes the inlinepanel.style.widthwhen_isCompactWorkspaceViewport()is true (≤900px), and reapplies the localStorage value otherwise. Wired toresizeat static/boot.js:597 and the panel-width restore branch at static/boot.js:613. The CSS at static/style.css:1138 layers--mobile-rightpanel-width:min(300px,100vw)so the right panel always fits the viewport. - Composer footer collapse — uses CSS
@containerqueries (container-type:inline-size; container-name:composer-footerat static/style.css:843) to collapse labels at 700px and 520px thresholds. Container queries have full evergreen browser support (Chrome 105+, Safari 16+, Firefox 110+); the existing@media(max-width:640px)block stays as the fallback for older user-agents and as the icon-only mobile mode.
176 new lines of mobile layout tests cover both behaviours. ✅
#1330 streaming session sidebar exemption. Two filter sites in all_sessions():
- Index path (api/models.py:771-779): adds
and not s.get('active_stream_id')to theUntitled+0-msgexclusion. - In-memory path (api/models.py:806-812): adds
and not s.active_stream_id and not s.pending_user_message(more lenient — catches the brief window wherepending_user_messageis set before the stream is allocated).
Justification (PR #1184 deferred first save() until first message landed) is correct against current master: compact() at api/models.py:430-457 returns these fields, and pending_user_message is set at the start of the streaming flow (api/models.py:557-572). 167 new test lines lock both filter paths and the asymmetry. ✅
#1331 .env.example. bootstrap.py:229 defaults HERMES_WEBUI_STATE_DIR to ~/.hermes/webui. The pre-fix .env.example showed ~/.hermes/webui-mvp. One-line alignment. Trivially correct. ✅
#1332 Dockerfile HEALTHCHECK. New HEALTHCHECK at Dockerfile:96 hits /health. Verified /health route exists at api/routes.py:810 and is in the auth allowlist at api/auth.py:22. curl is installed at Dockerfile:25. --start-period=10s covers the bootstrap/uv warmup. ✅
Cross-PR interactions on static/ui.js
Three PRs touch static/ui.js. Verified no overlap:
- #1322 adds the configured-fallback synthesis loop at lines 500-510 and the
_configuredRank()sorting at 502-510 insiderenderModelDropdown. - #1324 widens the duplicate-button guard at line 3715 inside
addCopyButtons. - #1326 adds
_getOptionProviderId()(lines 196-205), updates_findModelInDropdown(lines 196-225) and_getConfiguredModelBadge(lines 431-449), and threadspreferredProviderIdthrough_applyModelToDropdownandpopulateModelDropdown.
Disjoint regions. The dropdown rendering chain (#1322 → #1326) integrates cleanly because #1326's provider-aware lookups match the synthesis pattern #1322 adds — _getConfiguredModelBadge(opt.value, _badgeMap, providerId) is called with the optgroup's provider on the catalog pass, and _modelData synthesized from the badge map carries its own badge directly (no second lookup).
Security audit
- ✅ No XSS surface — synthesized dropdown entries:
name=esc(getModelLabel(modelId)),id=esc(modelId),badge.role/badge.labelescaped at render. zh-Hant labels are static literals. - ✅ No new endpoints — none of the 10 PRs add a route. HEALTHCHECK uses an existing in-allowlist endpoint.
- ✅ No file-serving changes — none.
- ✅ No SSRF/path traversal — none of the diff touches network or filesystem boundaries beyond the existing surface.
- ✅ No
eval/exec/Function()— confirmed via grep on the diff. - ✅ No secrets/tokens — confirmed.
- ✅ No undisclosed scope creep — every change matches its CHANGELOG entry; nothing snuck in.
Edge-case matrix
| PR | Scenario | Expected | Actual |
|---|---|---|---|
| #1317 | Manual cron Run Now | Worker thread executes run_job without NameError |
✅ trace + AST test |
| #1316 | First compression in turn | Banner fires once | ✅ snapshot diff |
| #1316 | Second turn after compression | Banner does NOT fire | ✅ snapshot equals |
| #1322 | Fallback local-ollama/llama-3 not in live catalog |
Appears in dropdown under Configured | ✅ synthesis path |
| #1322 | Catalog already has same fallback | No duplicate row | ✅ normalized dedup |
| #1323 | Render zh-Hant tree_view |
Shows 樹狀 not Cyrillic |
✅ harness |
| #1324 | Render same code block twice | One copy button only | ✅ guard |
| #1326 | Two providers expose google/gemma-4-27b |
Second prefixed @provider:slash |
✅ dedup |
| #1326 | Saved default + active provider hint | Match prefers same-provider option | ✅ _findModelInDropdown |
| #1326 | Persisted to config.yaml |
Strips @provider: to bare/slash |
✅ harness |
| #1328 | Resize desktop→mobile with saved panel width | Sliver gone | ✅ _syncWorkspacePanelInlineWidth |
| #1328 | Composer at 600px width | Workspace label hidden, chips icon-only | ✅ container query |
| #1330 | Session streaming first turn | Visible in sidebar | ✅ active_stream_id exempt |
| #1330 | Truly empty Untitled session | Hidden | ✅ filter still applies |
| #1331 | Fresh user copies .env.example |
State dir matches bootstrap.py |
✅ trivial |
| #1332 | docker ps after start |
Shows (healthy) once /health returns 200 |
✅ uses existing route |
Behavioural harnesses
# PR #1322 normalize/rank
{"input":["llama-3","local-ollama/llama-3","@local-ollama:llama-3"], "allEqual":true}
{"input":["gpt-4","@openai:gpt-4"], "allEqual":true}
ranks: 0 1 2 500 Infinity
# PR #1323 zh-Hant locale values
zh-Hant.tree_view = '樹狀' (was 'Дерево')
zh-Hant.mcp_save = '儲存' (was 'Сохранить')
zh.parse_failed_note = '解析失败' (Simplified)
zh-Hant.parse_failed_note = '解析失敗' (Traditional, distinct codepoint)
# PR #1326 cross-tool persistence
'@google:gemma-4-27b' → ('gemma-4-27b', 'google', None)
'@deepinfra:google/gemma-4-27b' → ('google/gemma-4-27b', 'deepinfra', None)
'@nous:anthropic/claude-opus-4.6' → ('anthropic/claude-opus-4.6', 'nous', None)
Tests
- PR-specific tests across 7 files: 76/76 pass (cron import, chinese locale, copy buttons, model picker dup IDs, mobile layout, model picker badges, streaming session sidebar)
- Full suite: 3257 passed, 54 skipped, 3 xpassed, 0 failed in 15.62s (3314 collected — every test runs)
- CI: 3.11/3.12/3.13 all green (per
gh pr checks 1334)
Minor observations (non-blocking)
- PR description's test count (
3309 passed) doesn't match my local run (3257 passed). Probably a stale baseline number from before some skipped/xpassed buckets stabilised — both add up consistently when you include skipped/xpassed/subtests, and 0 failures is the right invariant. Not a blocker. - #1330 asymmetry between filter sites. The index-path filter only checks
active_stream_id; the in-memory path checks bothactive_stream_idandpending_user_message. Both fields are persisted incompact(), so the index path could also checkpending_user_messagefor symmetry. In practice both fields are set together once the stream starts, so the asymmetry doesn't lose sessions. Could tighten in a follow-up but not worth blocking. - #1332 HEALTHCHECK is conservative.
--interval=30s --retries=3 --timeout=5s --start-period=10smeans worst-case 100s before the container is reported unhealthy. Fine for production; some teams prefer 10s/2 retries for faster failover. Tunable in compose; default is reasonable. - #1326 dedup ordering is
provider_idalphabetical. Confirmed at api/config.py:912-915. Means adding/removing a provider can cause a saved bare ID to suddenly become@provider:prefixed (or vice versa) on the next cache rebuild. The provider-aware rehydration neutralises this for the saved default model, but a user with a hand-writtenconfig.yaml.model.default = gemma-4-27b(nomodel.provider) could see the dropdown snap to a different provider's option after adding a new colliding custom provider. Acceptable trade-off given the dedup is necessary and the rehydration prefers the configured provider when present.
Recommendation
✅ Approved. Every constituent PR traces cleanly: bugs are real, fixes are minimal, no XSS/SSRF/path-traversal surface, no cross-tool regression (@provider: prefix is stripped before reaching config.yaml), squash matches each open PR byte-for-byte, full test suite green, CHANGELOG comprehensive with author credit. Parked at approval — ready for the release agent's merge/tag pipeline.
release: v0.50.245 — 10-PR batch
Release v0.50.245 — 10-PR batch
Combines 10 contributor fixes from the open-PR queue. All constituent PRs were independently reviewed during triage; this is the integration release branch.
Constituent PRs
run_jobinside_run_cron_trackedto fix NameError (#1310)compression_countagainst per-turn snapshot to stop repeated banner.env.examplestate dir default withbootstrap.pyClosed as duplicate of #1317
Held with feedback (not in this release)
Pre-release gate
3309 passed, 2 skipped, 3 xpassed, 1 warning, 8 subtests passed in 79.89s(up from 3290 baseline, +19 new tests added by this batch)static/ui.js,static/boot.js,static/style.css)Diff stats
Cross-PR interactions reviewed
Three PRs touched
static/ui.js:configured_model_badgesfor models missing from the dropdown catalog and adds a_configuredRank()priority sort.addCopyButtons()idempotency guard to also check the.pre-headersibling._getOptionProviderId(), extends_findModelInDropdown()and_getConfiguredModelBadge()to take a preferred provider, and propagates that throughpopulateModelDropdown()andrenderModelDropdown().These touch disjoint regions and helper signatures (no overlap), and tests for all three pass on the integration branch.
Closes #1310, #1313, #1327, #1300, #1096.