release: v0.50.253 — /branch command + local-provider heal + mobile composer + Opus follow-ups#1391
release: v0.50.253 — /branch command + local-provider heal + mobile composer + Opus follow-ups#1391nesquena-hermes merged 8 commits intomasterfrom
Conversation
#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.
…titlebar safe-area (#1381)
…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.
nesquena
left a comment
There was a problem hiding this comment.
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 threeLOCAL_API_KEYraise sites in upstreamagent/auxiliary_client.py:3337,3647andrun_agent.py:1466. - #1342: webui-only change.
parent_session_idis a webui-only Session attribute; the upstream agent CLI doesn't read it. Sessions created via/api/session/branchonly mutate webui'sSESSIONSdict 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:
- Validates
body["session_id"]is a string at line 1717-1718. - Loads source via
get_session(...)at line 1720. - Validates
keep_countis non-negative integer at line 1726-1735. - Truncates
titleto 80 chars at line 1739. - Slices
forked_messages = source_messages[:keep_count]at line 1744 (or full copy at 1746). - Creates new
Session(parent_session_id=source.session_id, ...)inheriting workspace/model/profile. - Adds to
SESSIONSdict underLOCKat line 1764. - 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:
- Mobile button
composerMobileConfigBtnexists in static/index.html:400 withonclick="toggleMobileComposerConfig()". - 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. - Mobile media queries at static/style.css:1157, 1231 use
display:inline-flex !importantto override. - 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
/branchunderLOCK: same global lock as other session ops at api/routes.py:1764. Concurrent/branchcalls on the same source session each get their ownforked_messages = list(source_messages)(separate list). The dicts inside are SHARED references — but that's a pre-existing pattern, not a regression.- Two concurrent
/branchPOSTs from the same client: both succeed; each creates a differentbranch.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_idis 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.yamlwrites from #1342 / #1381. - ✅ No new env vars.
Security audit
- ✅
/branchinput validation: session_id type-checked, keep_count range-checked, title length-bounded. - ✅
/branchworkspace 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) andbranchInd.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/branchis a POST route; gated bycheck_authinserver.py:94like all POSTs. - ✅
forked_messagesare deep-copied for the new session: actually a shallow copy of the list (dicts are shared). Pre-existing pattern acrosss.messagesoperations. 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
/branchendpoint 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.modulesremoved fromtest_issue1195_session_profile_routing.py— verified via grep, file is back to the master/no-delpattern. - ✅ 9 orphan
wiki_*keys stripped fromstatic/i18n.js—grep -c "wiki_" static/i18n.jsreturns 0. - ✅
branch_*andfork_*i18n keys are consumed —t('forked_from')at sessions.js:1450 andt('fork_from_here')at ui.js:3307. - ✅
parent_session_idtest (test_session_lineage_metadata_api.py) still passes — the v0.50.251 lineage gating logic is preserved becausecompact()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 acrosss.messages.append()flows; not introduced by this PR. Could be addressed byforked_messages = [dict(m) for m in source_messages[:keep_count]]orcopy.deepcopy(source_messages[:keep_count]). - No behavioral integration test for
/branch: the 21 tests intest_465_session_branching.pyare all source-level regex checks. My behavioral harness above covers the actual endpoint behavior. A real integration test that POSTs to/api/session/branchand 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-btnprefix 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.
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)
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
/branchcommand + "Fork from here" hover action (closes #465)provider: "local"mid-conversation crash (closes #1384)In-stage fixes I applied during pre-release
test_issue1195_session_profile_routing.pydel sys.modulesanti-pattern that broke v0.50.252'stest_live_models_cache_is_profile_scoped(cross-test module-state corruption)compact()parent_session_id on truthinessparent_session_id: Nonefor non-fork sessions, breaking v0.50.251's lineage end_reason gating inagent_sessions.py.icon-btn.composer-mobile-config-btn{display:none}.icon-btn{display:flex}(later in source, equal specificity) was winning the cascadeOpus pre-release follow-ups (also in this PR)
wiki_*i18n keys (72 lines)i18n.js/branchrejects non-stringsession_idwith 400get_session()/branchrejects negativekeep_countwith 400Contributor follow-up (also in this PR)
starship-s pushed
cddd175mid-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
Verification
~/WebUI/scripts/run-browser-tests.sh~/WebUI/scripts/webui_qa_agent.shDiff stats
Release process
gh pr merge --merge(preserves attribution commits)v0.50.253from origin/masterContributors
@bergeouss, @starship-s, plus the self-built #1388.