Skip to content

release: v0.50.245 — 10-PR batch - #1334

Merged
nesquena-hermes merged 12 commits into
masterfrom
release/v0.50.245
Apr 30, 2026
Merged

nesquena-hermes merged 12 commits into
masterfrom
release/v0.50.245

Conversation

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

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

# Author Subject Lines
#1317 @fxd-jason fix(cron): import run_job inside _run_cron_tracked to fix NameError (#1310) +93 −1
#1316 @qxxaa fix(streaming): compare compression_count against per-turn snapshot to stop repeated banner +5 −1
#1322 @renatomott fix(ui): show configured fallback models missing from catalog +67 −11
#1323 @dso2ng fix(i18n): restore zh-Hant locale labels +51 −25
#1324 @dso2ng fix(ui): avoid duplicate header copy buttons (#1096) +19 −3
#1326 @Hacker2005 fix(models): default model rehydration when providers share slash-qualified IDs (#1313) +181 −54
#1328 @franksong2702 fix(mobile): workspace panel sliver + composer footer collapse (#1300) +258 −13
#1330 @franksong2702 fix(models): exempt streaming sessions from Untitled+0-msg sidebar filter (#1327) +177 −1
#1331 @zichen0116 fix: align .env.example state dir default with bootstrap.py +1 −1
#1332 @zichen0116 fix: add Docker HEALTHCHECK to Dockerfile +3 −0

Closed as duplicate of #1317

Held with feedback (not in this release)

Pre-release gate

  • ✅ pytest: 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)
  • ⏳ Opus diff review: queued in parallel
  • ⏳ Browser sanity: pending (touches static/ui.js, static/boot.js, static/style.css)

Diff stats

.env.example                                       |   2 +-
CHANGELOG.md                                       |  14 +-
Dockerfile                                         |   3 +
api/config.py                                      |  67 ++++----
api/models.py                                      |  11 +-
api/routes.py                                      |   2 +-
api/streaming.py                                   |   6 +-
static/boot.js                                     |  26 ++-
static/i18n.js                                     |  50 +++---
static/panels.js                                   |   4 +-
static/style.css                                   |  68 ++++++--
static/ui.js                                       | 128 +++++++++++----
tests/test_chinese_locale.py                       |  26 +++
tests/test_cron_run_job_import.py                  |  92 +++++++++++
tests/test_issue1096_copy_buttons.py               |  18 ++-
tests/test_issue1228_model_picker_duplicate_ids.py |  56 ++++++-
tests/test_mobile_layout.py                        | 176 ++++++++++++++++++-
tests/test_model_picker_badges.py                  |  62 +++++++-
tests/test_streaming_session_sidebar.py            | 167 +++++++++++++++++++
19 files changed, 867 insertions(+), 111 deletions(-)

Cross-PR interactions reviewed

Three PRs touched static/ui.js:

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.

nesquena-hermes and others added 12 commits April 30, 2026 15:24
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>
…1310)

From PR #1317.

Co-authored-by: fxd-jason <wujiachen7@gmail.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>
…r filter (#1327)

From PR #1330.

Co-authored-by: Frank Song <franksong2702@gmail.com>
From PR #1322.

Co-authored-by: renatomott <renato.mott@gmail.com>
…lified IDs (#1313)

From PR #1326.

Co-authored-by: hacker2005 <chen20057275@outlook.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 nesquena left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. /api/models builds configured_model_badges at 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).
  2. Frontend populateModelDropdown populates _modelData from <select> options (catalog only).
  3. NEW loop at static/ui.js:500-510: iterates _badgeMap entries, dedups against existing _modelData via _normalizeConfiguredModelKey (strips @provider: and slash-prefix, lowercases, -→.), and synthesizes entries for badged models missing from the catalog.
  4. _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:

  1. 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 same google/gemma-4-27b therefore 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).
  2. Backend badge map — _build_configured_model_badges() at api/config.py:1471-1523 now uses option_provider_lookup to disambiguate normalize-equal candidates by provider instead of the old "first match wins" path. Skips registering badges for catalog candidates whose option_provider_lookup differs from the configured provider (line 1517-1520). ✅
  3. Frontend match — _findModelInDropdown at static/ui.js:200-220 now takes preferredProviderId, reads optgroup.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:
  4. 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.default gets the bare/slash form (verified at api/config.py:1186). The CLI never sees the @provider: prefix. ✅
  5. 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:

  1. Workspace sliver — _syncWorkspacePanelInlineWidth() at static/boot.js:23-37 removes the inline panel.style.width when _isCompactWorkspaceViewport() is true (≤900px), and reapplies the localStorage value otherwise. Wired to resize at 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.
  2. Composer footer collapse — uses CSS @container queries (container-type:inline-size; container-name:composer-footer at 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 the Untitled+0-msg exclusion.
  • 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 where pending_user_message is 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 inside renderModelDropdown.
  • #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 threads preferredProviderId through _applyModelToDropdown and populateModelDropdown.

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.label escaped 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 both active_stream_id and pending_user_message. Both fields are persisted in compact(), so the index path could also check pending_user_message for 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=10s means 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_id alphabetical. 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-written config.yaml.model.default = gemma-4-27b (no model.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.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator Author

Shipped — v0.50.245 ✅

Merge: 52e1567 (2026-04-30 15:48 UTC)
Tag: v0.50.245
Production restarted, health check OK.

Constituent PRs closed with credit

# Author Subject
#1316 @qxxaa per-turn compression-count snapshot
#1317 @fxd-jason cron run_job worker-thread import scope
#1322 @renatomott configured-fallback dropdown synthesis
#1323 @dso2ng zh-Hant locale labels
#1324 @dso2ng duplicate copy-button guard
#1326 @Hacker2005 slash-ID dedup + provider-aware rehydration
#1328 @franksong2702 mobile workspace sliver + composer collapse
#1330 @franksong2702 streaming-session sidebar exemption
#1331 @zichen0116 .env.example state-dir alignment
#1332 @zichen0116 Docker HEALTHCHECK

Issues closed

Closed as duplicates of #1317 (with credit)

Held with detailed feedback

Thanks to everyone — 8 contributor names in this batch alone. 🙏

SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Manual cron run crashes with NameError: run_job is not defined

2 participants