Skip to content

release: v0.50.253 — /branch command + local-provider heal + mobile composer + Opus follow-ups#1391

Merged
nesquena-hermes merged 8 commits intomasterfrom
stage-may2
May 1, 2026
Merged

release: v0.50.253 — /branch command + local-provider heal + mobile composer + Opus follow-ups#1391
nesquena-hermes merged 8 commits intomasterfrom
stage-may2

Conversation

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Release v0.50.253 — May 1 2026

3 PRs absorbed + 4 in-stage fixes + 2 Opus pre-release follow-ups + 1 contributor follow-up commit.

What's in this release

PR Author Summary
#1342 @bergeouss /branch command + "Fork from here" hover action (closes #465)
#1388 self-built Heal provider: "local" mid-conversation crash (closes #1384)
#1381 @starship-s Mobile composer layout — progressive-disclosure config panel + scoped titlebar safe-area

In-stage fixes I applied during pre-release

Fix Reason
Reverted #1342's test rewrite of test_issue1195_session_profile_routing.py Introduced del sys.modules anti-pattern that broke v0.50.252's test_live_models_cache_is_profile_scoped (cross-test module-state corruption)
Gated compact() parent_session_id on truthiness #1342 unconditionally emitted parent_session_id: None for non-fork sessions, breaking v0.50.251's lineage end_reason gating in agent_sessions.py
CSS specificity bump on .icon-btn.composer-mobile-config-btn{display:none} The new mobile config button was leaking into desktop view at 1440×900 because .icon-btn{display:flex} (later in source, equal specificity) was winning the cascade
Hide kicker labels in mobile config panel Uppercase WORKSPACE/REASONING labels at 700-weight overflowed the 60px copy column on iPhone 14; icons already convey meaning

Opus pre-release follow-ups (also in this PR)

Follow-up Reason
Strip 9 orphan wiki_* i18n keys (72 lines) PR #1342 leaked them from a different branch; zero references outside i18n.js
/branch rejects non-string session_id with 400 Was raising TypeError → confusing 500 from get_session()
/branch rejects negative keep_count with 400 Python slice semantics on negative produces "all but last N" — confusing fork behavior

Contributor follow-up (also in this PR)

starship-s pushed cddd175 mid-review — @media (max-width: 340px) block that tightens composer-wrap padding + composer-footer/composer-left gap for 320px legacy phones without shrinking 44×44 touch targets. Plus a regression test.

What was NOT in this release

PR Held until Reason
#1390 (provider context preservation, +782 LOC, @starship-s) v0.50.254 Cross-cutting provider routing (streaming + session persistence + 6 frontend files); deserves a focused standalone batch rather than bundling on top of an already-large diff

Verification

Gate Result
pytest (full suite) 3558 passing (up from 3507 baseline)
~/WebUI/scripts/run-browser-tests.sh 20/20 QA + 11/11 browser API
~/WebUI/scripts/webui_qa_agent.sh 23/23 (desktop + mobile + SSE liveness)
Comprehensive E2E browser walk (desktop + mobile) All interactive surfaces verified — workspace switch, model selector, reasoning options, profile dropdown, mobile config panel toggle, mobile workspace from panel, mobile model from panel, hamburger sidebar
Telegram screenshot approval Sent 5 screenshots, approved by Nathan
Opus Advisor pre-release review APPROVED with 1 NEEDS-FIX (orphan wiki_* keys — resolved) and 2 optional follow-ups (both applied)

Diff stats

  • 14 files changed
  • +1,898 / -206 lines (net +1,692)
  • 4 new test files (33 new tests on top of v0.50.252's baseline)

Release process

  1. Independent review by Nathan with a different agent — starting after this PR is opened
  2. After approval: gh pr merge --merge (preserves attribution commits)
  3. Tag v0.50.253 from origin/master
  4. Restart 8787, close all 3 underlying PRs (feat(sessions): add /branch command to fork conversations from any message (#465) #1342, fix(#1384): heal 'provider: local' mid-conversation crash for local-model users #1388, fix(ui): mobile composer layout #1381) with credit comments
  5. GitHub release notes auto-created on tag push, then edited

Contributors

@bergeouss, @starship-s, plus the self-built #1388.

Hermes Agent added 8 commits May 1, 2026 05:29
 #465)

Fix: gate parent_session_id emission in compact() on truthiness so
sessions without a fork link don't leak parent_session_id: None and
break the v0.50.251 lineage end_reason gating in agent_sessions.py.
The /branch endpoint sets the field on saved forks; everything else
keeps the v0.50.251 sidebar lineage path as the canonical source.
…hared module state

PR #1342's rewrite introduced `del sys.modules['api.config']`, 'api.profiles']`
anti-pattern that breaks tests/test_live_models_ttl_cache.py::test_live_models_cache_is_profile_scoped
(v0.50.252) when run after test_issue1195_*. The pattern is explicitly banned per
~/WebUI/docs/agent-memory/pytest-isolation.md — sibling tests that import api.profiles
later see the wrong (re-imported) module.

Master's version of this test passes 5/5 and uses no del sys.modules calls. The PR's
core /branch feature does NOT depend on this test rewrite — reverting it loses no
coverage of the branching feature.
…w (CSS specificity)

The .composer-mobile-config-btn{display:none} base rule was at line 896 but
.icon-btn{display:flex} (the button's other class) was at line 941 — equal
specificity, but later in source wins. Result: the button was visible at
desktop widths, sandwiched between the workspace and model chips.

Bumping the base rule's selector to .icon-btn.composer-mobile-config-btn
gives it specificity 0,0,2,0 (vs .icon-btn at 0,0,1,0), so it always wins
the cascade. The two narrow-viewport rules already use !important and remain
unaffected — desktop hides cleanly, mobile shows correctly.

Verified via Agent Browser CDP: 1440x900 desktop now shows the standard
chips only (no extra config button); iPhone 14 mobile shows the new compact
config btn at 44x44 with the panel toggling correctly. Screenshots:
/tmp/may2-shots/desktop-final.png, mobile-{closed,open}-final.png
…egacy phones

Pulls in the extra commit pushed to PR #1381 after our initial absorb. Adds a
@media (max-width: 340px) block that compacts gutters (composer-wrap padding,
composer-footer gap, composer-left gap) without shrinking the 44px touch
targets. Plus its regression test.

Verified with apply --check failed but actual apply succeeded — the failure
was due to context drift from our earlier CSS specificity fix; the new lines
landed at the correct location. test_mobile_layout.py: 47 tests passing.
Three small fixes from Opus review of the merged stage diff:

1. Strip 9 orphan wiki_* i18n keys (72 lines) from PR #1342 — leaked
   from a different branch, zero references outside i18n.js.

2. /branch endpoint: reject non-string session_id with explicit 400
   (was raising TypeError → generic 500 from get_session()).

3. /branch endpoint: reject negative keep_count with explicit 400
   (Python slice semantics on negative produces 'all but last N',
   confusing fork behavior).

Plus tests/test_v050253_opus_followups.py — 3 regression tests pinning
all three fixes.

Verified: 3558 pytest passing.
Copy link
Copy Markdown
Owner

@nesquena nesquena left a comment

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, batch release of 3 PRs + 4 in-stage fixes + 3 Opus follow-ups)

Release v0.50.253 — 3-PR batch with extensive in-stage cleanup. The bot's pre-release fixes addressed real concerns (test isolation regression, parent_session_id leak, CSS specificity), and the Opus follow-ups closed the remaining nits I would have flagged.

Squash audit

fea47bd  #1388  fix(#1384): heal 'provider: local' mid-conversation crash       (already approved standalone)
52bfcea  #1342  feat(#465): /branch command + Fork from here action               @bergeouss
                + parent_session_id gating in compact() (in-stage fix)
18d960e         revert PR #1342's del sys.modules test rewrite (in-stage fix)
1a76e87  #1381  feat: mobile composer progressive-disclosure config panel        @starship-s
1c356bf         CSS specificity fix on .composer-mobile-config-btn (in-stage)
8cd3680  #1381  absorb starship-s cddd175: 320px legacy phone tightening
67193fa         Opus follow-ups: wiki_* strip + /branch input validation
2a0757f         CHANGELOG entry

Contributor commits preserve Author: trailers via the squash workflow. ✅

Traced against upstream hermes-agent

  • #1388: already verified in my standalone approval — "custom" IS in the safe-pass list at all three LOCAL_API_KEY raise sites in upstream agent/auxiliary_client.py:3337,3647 and run_agent.py:1466.
  • #1342: webui-only change. parent_session_id is a webui-only Session attribute; the upstream agent CLI doesn't read it. Sessions created via /api/session/branch only mutate webui's SESSIONS dict and on-disk session JSON.
  • #1381: pure CSS/JS frontend; no agent interaction.

End-to-end trace — /branch endpoint

Verified via behavioral harness (8 scenarios, all pass):

Test 1: full fork                     ✅ status=200, title='Original Conversation (fork)', msgs=4, parent=src001
Test 2: fork at keep_count=2          ✅ msgs=2
Test 3: custom title                  ✅ title='Alt Path'
Test 4: keep_count=0                  ✅ msgs=0 (empty fork)
Test 5: negative keep_count           ✅ status=400, msg='keep_count must be non-negative'
Test 6: non-string session_id         ✅ status=400, msg='session_id must be a string'
Test 7: title 100 chars               ✅ truncated to 80
Test 8: shared dict mutation          ⚠️ source and branch share dict references (pre-existing pattern)

The flow at api/routes.py:1705-1778:

  1. Validates body["session_id"] is a string at line 1717-1718.
  2. Loads source via get_session(...) at line 1720.
  3. Validates keep_count is non-negative integer at line 1726-1735.
  4. Truncates title to 80 chars at line 1739.
  5. Slices forked_messages = source_messages[:keep_count] at line 1744 (or full copy at 1746).
  6. Creates new Session(parent_session_id=source.session_id, ...) inheriting workspace/model/profile.
  7. Adds to SESSIONS dict under LOCK at line 1764.
  8. Persists via branch.save() if messages exist.

The parent_session_id field on Session is gated in compact() at api/models.py:472-474:

**({'parent_session_id': self.parent_session_id} if self.parent_session_id else {}),

This was the pre-release fix the bot applied — without it, EVERY session would have leaked parent_session_id: None into /api/sessions payloads, breaking the v0.50.251 lineage end_reason gating logic at agent_sessions.py:336-352 (which exposes parent_session_id only when parent.end_reason in {compression, cli_close}).

End-to-end trace — mobile composer (#1381)

Verified by inspecting the static HTML + CSS:

  1. Mobile button composerMobileConfigBtn exists in static/index.html:400 with onclick="toggleMobileComposerConfig()".
  2. CSS rule at static/style.css:899 .icon-btn.composer-mobile-config-btn{display:none} (specificity 0,0,2,0) beats .icon-btn{display:flex} (0,0,1,0) at desktop widths.
  3. Mobile media queries at static/style.css:1157, 1231 use display:inline-flex !important to override.
  4. Kicker labels hidden via .composer-mobile-config-action:not(.composer-mobile-context-action) .composer-mobile-config-kicker{display:none} at style.css:914 — context action keeps its kicker because it stretches full panel width.

CSS specificity hierarchy verified: ✅

Selector Specificity Source order
.icon-btn{display:flex} 0,0,1,0 line 941
.icon-btn.composer-mobile-config-btn{display:none} 0,0,2,0 line 899
@media (max-width: ...) .composer-mobile-config-btn{display:inline-flex !important} 0,0,1,0 + !important line 1157, 1231

Higher specificity rule wins at desktop widths; !important in media queries wins at mobile widths. Correct cascade. ✅

Race / lock analysis

  • /branch under LOCK: same global lock as other session ops at api/routes.py:1764. Concurrent /branch calls on the same source session each get their own forked_messages = list(source_messages) (separate list). The dicts inside are SHARED references — but that's a pre-existing pattern, not a regression.
  • Two concurrent /branch POSTs from the same client: both succeed; each creates a different branch.session_id (uuid4 hex). No clash.
  • Source session being modified mid-fork: list slice at line 1744 (or list copy at 1746) is atomic in CPython (GIL-protected). No torn list.
  • Mobile composer JS is single-threaded — no concurrency concerns.

Cross-tool consistency

  • parent_session_id is webui-only; CLI doesn't read it. Verified.
  • provider: custom (#1388) is honored by both webui and agent CLI as the canonical OpenAI-compat fall-through.
  • No config.yaml writes from #1342 / #1381.
  • No new env vars.

Security audit

  • /branch input validation: session_id type-checked, keep_count range-checked, title length-bounded.
  • /branch workspace inheritance: branch.workspace = source.workspace — re-uses the source's already-resolved path. No new traversal surface.
  • No XSS in sidebar parent indicator: at static/sessions.js:1450, branchInd.title = ... (title attribute) and branchInd.textContent = '⒂' — both safe property/text assignments.
  • No XSS in fork button: at static/ui.js:3307, template literal with ${t('fork_from_here')} (developer-controlled i18n) and ${rawIdx+1} (numeric).
  • No new file-serving endpoints.
  • Auth gate: /api/session/branch is a POST route; gated by check_auth in server.py:94 like all POSTs.
  • forked_messages are deep-copied for the new session: actually a shallow copy of the list (dicts are shared). Pre-existing pattern across s.messages operations. No security implication — both sessions are owned by the same authenticated user.

Edge-case matrix

Scenario Pre-PR / Pre-fix Post-PR
/branch with valid session_id n/a (endpoint didn't exist) Returns new session, parent_session_id set ✅
/branch with non-string session_id n/a 400 "must be a string" ✅
/branch with missing session_id n/a 400 from require()
/branch with keep_count=0 n/a Empty fork (zero messages) ✅
/branch with negative keep_count n/a 400 "must be non-negative" ✅
/branch with non-int keep_count n/a 400 "must be an integer" ✅
/branch with title > 80 chars n/a Truncated to 80 ✅
/branch with title=null n/a Auto-titled "{src} (fork)" ✅
compact() on session without fork (pre-PR didn't have field) parent_session_id NOT emitted ✅
compact() on forked session n/a parent_session_id: <src> emitted ✅
Lineage end_reason gating after PR (would have broken pre-fix) Still works — no false fork keys leak ✅
Mobile config button at desktop width Visible (CSS bug) Hidden via specificity bump ✅
Mobile config button at iPhone 14 Visible Visible with proper sizing ✅
Kicker labels at iPhone 14 (390px) Overflowed Hidden inside open panel ✅
Composer at 320px legacy phone Cramped Tightened gutters via @media (max-width: 340px)
del sys.modules test isolation Broke v0.50.252 cache test Reverted; full suite passes ✅
provider: local (already approved) Crash mid-conversation Healed at all three layers ✅

Tests

  • #1342 branching: test_465_session_branching.py — 21/21 pass (source-level invariants + JS structural checks).
  • #1388 local provider: test_issue1384_local_provider.py — 9/9 pass.
  • #1381 mobile layout: test_mobile_layout.py — 47/47 pass (includes the new 320px breakpoint test).
  • Opus follow-ups: test_v050253_opus_followups.py — 3/3 pass (input validation + wiki orphan removal).
  • Behavioral harness: 8/8 happy-path + edge-case scenarios for /branch endpoint pass.
  • Full suite: 3506 passed, 54 skipped, 3 xpassed, 0 failed in 16.70s on 2a0757f.
  • CI: 3.11/3.12/3.13 all green.

(PR description claims 3558; my count 3506 — counting drift consistent with prior batches. Both runs have all new tests passing.)

Other audit — confirmed correct

  • del sys.modules removed from test_issue1195_session_profile_routing.py — verified via grep, file is back to the master/no-del pattern.
  • 9 orphan wiki_* keys stripped from static/i18n.jsgrep -c "wiki_" static/i18n.js returns 0.
  • branch_* and fork_* i18n keys are consumedt('forked_from') at sessions.js:1450 and t('fork_from_here') at ui.js:3307.
  • parent_session_id test (test_session_lineage_metadata_api.py) still passes — the v0.50.251 lineage gating logic is preserved because compact() doesn't leak None values.
  • profile-scoped live-models cache test still passes (the test isolation revert did its job).

Minor observations (non-blocking)

  • Shared dict references between source and branch sessions: forked_messages = source_messages[:keep_count] is a shallow copy. The dict objects inside are shared. If any downstream code mutates a message dict in-place (e.g. branch.messages[0]['content'] = "new"), the change would also appear in the source session's in-memory representation. In practice this rarely happens — message lists are typically append-only — but a future refactor that introduces in-place edits would need to be aware. Pre-existing pattern across s.messages.append() flows; not introduced by this PR. Could be addressed by forked_messages = [dict(m) for m in source_messages[:keep_count]] or copy.deepcopy(source_messages[:keep_count]).
  • No behavioral integration test for /branch: the 21 tests in test_465_session_branching.py are all source-level regex checks. My behavioral harness above covers the actual endpoint behavior. A real integration test that POSTs to /api/session/branch and asserts the new session is correctly stored would lock the contract end-to-end. Out of scope for this batch.
  • CSS specificity comment at style.css:896-898 explains the rationale clearly. Future maintainers will understand why the .icon-btn prefix is required. ✅
  • /branch endpoint doesn't dedupe concurrent forks: spam-clicking "Fork from here" 5x within a second creates 5 different sessions, each with the same parent. Acceptable — each gets a unique session_id. The user can delete unwanted forks.
  • Test count drift: PR claims 3558, my run shows 3506. Same pattern as prior batches. Not a defect.

Recommendation

Approved. Three-PR batch with clean separation of concerns and well-documented in-stage fixes. The bot caught real regressions (test isolation, parent_session_id leak, CSS specificity) before they shipped, and the Opus follow-ups closed the input-validation and i18n-orphan nits I would have flagged. Behavioral harness confirms the /branch endpoint works correctly across 8 happy-path and edge-case scenarios.

Parked at approval — ready for the release agent's merge/tag pipeline.

@nesquena-hermes nesquena-hermes merged commit 219f5d6 into master May 1, 2026
3 checks passed
@nesquena-hermes nesquena-hermes deleted the stage-may2 branch May 1, 2026 07:02
joaompfp added a commit to joaompfp/hermes-webui that referenced this pull request May 2, 2026
When the webui auth session expires (e.g., after a server restart),
api() returns undefined after redirecting to /login. Previously,
loadSession() and _ensureMessagesLoaded() would dereference the
undefined response and throw, surfacing a confusing 'Failed to load
session' toast while the browser was already navigating away.

Add guards after api() calls that may trigger 401 redirects:
- loadSession(): bail early if data is undefined
- _ensureMessagesLoaded(): return silently if data is missing
- _loadOlderMessages(): return silently if data is missing

This prevents the stuck loading state and unnecessary error toasts
when the user is already being redirected to re-authenticate.

Fixes nesquena#1391 (reported as 'Failed to load session' after restart)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants