Skip to content

release: v0.51.31 — Release H (12-PR contributor batch: image-mode + race fixes + composer drafts + locale parity)#1967

Merged
nesquena-hermes merged 29 commits into
masterfrom
stage-326
May 9, 2026
Merged

release: v0.51.31 — Release H (12-PR contributor batch: image-mode + race fixes + composer drafts + locale parity)#1967
nesquena-hermes merged 29 commits into
masterfrom
stage-326

Conversation

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Release H (v0.51.31) — 12-PR contributor batch

Stage-326 absorbs 12 contributor PRs covering bug fixes, locale parity, gateway heartbeat hardening, race-condition fixes, composer-draft persistence, and configurable session TTL. Three duplicate-PR clusters resolved by selecting the strongest variant and closing the others with Co-authored-by attribution preserved.

What landed

Added (2 PRs)

Fixed (10 PRs)

Closed in favor of canonical PRs (with Co-authored-by trailers)

Closed In favor of Reason
#1942 (franksong2702) #1949 synchronous mutex doesn't reach into resolved callback
#1962 (Michaelyklam) #1949 same approach as #1942; browser evidence preserved
#1946 (franksong2702) #1951 same fix shape, weaker test coverage
#1874 (hacker1e7) #1947 broader scope incl. behavior change; #1947's 4-LOC narrower
#1311 (lost9999) (master) superseded by master-side fix

Pre-release verification

Gate Result
Full pytest suite (HERMES_HOME isolated) 5028 passed, 8 skipped, 1 xfailed, 2 xpassed (142.61s)
Net new tests +51 across 12 PRs + 10 stage-326 hardening tests
Browser API harness (port 8789) 11/11 endpoints + 20/20 QA tests PASS (111s)
node -c on modified static/*.js Clean (5 files)
py_compile on modified api/*.py Clean (6 files)
Merge-conflict marker scan Clean
Live verification (port 8789, stage-326) composer-draft validation + TTL resolution + custom-provider groups all behave as expected
Pre-stamp re-fetch of all 12 PR heads No contributor force-push during build window
Silent-revert check All 12 PRs ≥ original additions on stage

Opus advisor verdict: SHIP-WITH-FIXES (all applied)

Opus flagged one critical + two recommended fixes; all applied in stage commits 404e24ac + 8782fd26:

  1. CRITICAL fix: only evaluate goal hook on goal-related turns (#1932) #1951PENDING_GOAL_CONTINUATION.discard(session_id) in the streaming worker's finally block race-erased the marker before the frontend's SSE-receive → POST /api/chat/start round-trip could consume it. Removed the discard; consumer in routes.py discards atomically on read. 5 new regression guards pin the corrected ordering.

  2. feat: persistent composer draft — server-side, cross-client, survives refresh #1956 — composer-draft input validation: text clamped to 50 KB, files clamped to 50 entries, types coerced to str/list. Without this, a misbehaving client could persist multi-MB strings into the session JSON via the 400 ms debounced auto-save. 5 new validation tests.

  3. feat(auth): make session TTL configurable via env var and settings.json #1957 — preserved SESSION_TTL module constant as the named fallback (existing regression tests pin it as a guard against the daily-kick-out regression from fix(login): session TTL, redirect-back, and connectivity retry #1419). Reconciled the new TestSessionTtlResolution class to use unittest setUp/tearDown env snapshotting (instead of the pytest monkeypatch fixture, incompatible with unittest.TestCase subclasses) and aligned clamp tests with the actual fall-through-to-default behavior.

Stage diff

  • 28 files, +1,609 / −116 LOC
  • 27 commits on stage-326 (12 PR merges + 4 stage-326 hotfix commits + 1 release CHANGELOG/ROADMAP commit)

What's NOT in this release

Closes: #1932 #1937 #1938 #1841 #1944 #1954 #1959

🤖 Generated with Claude Code

ai-ag2026 and others added 29 commits May 8, 2026 23:51
…eration-token + mutex

The originally-proposed fix (gate _ensureAllMessagesLoaded on the existing
_loadingOlder flag) does not actually close the race. By the time the
prefetch reaches its post-await body, it has already cleared the entry-
gate that reads _loadingOlder, so a same-flag check inside the resolved
callback would be a no-op for an in-flight request.

The actual fix is two-pronged:

1. New module-scoped _messagesGeneration counter, bumped every time
   S.messages is wholesale-replaced. _loadOlderMessages snapshots it
   BEFORE its await and re-checks after — if it changed, the prepend
   is aborted. This is the canonical async-invalidation pattern.

2. _ensureAllMessagesLoaded now claims the _loadingOlder mutex around
   its body so a new prefetch cannot start mid-replace and concurrent
   ensure-all calls (rapid double-click on Start) serialize cleanly.
   It bumps the generation token before mutating S.messages, yields
   until any in-flight prefetch finishes, and resets _oldestIdx so a
   subsequent prefetch cannot request stale older messages.

Also adds the same-session / _loadingSessionId guards that the original
ensure-all body was missing post-await — if the user switched sessions
mid-flight, the old code would happily overwrite the new session's
messages with the previous session's full history.

12 new regression tests in tests/test_issue1937_endless_scroll_jumpstart_race.py
lock in: generation token declaration, bump-helper presence, snapshot-
before-await ordering, post-await-abort behaviour, mutex acquisition and
finally-release, yield-then-claim ordering when a prefetch is in flight,
generation bump during the wait phase, _oldestIdx reset, and the new
session-switch guard.

Closes #1937.
The goal evaluation hook was firing on every completed assistant turn
when a goal was active, even for unrelated messages like "what time is
it". This burned the goal budget, triggered continuation prompts that
interrupted unrelated conversations, and made /goal status numbers
misleading.

Add STREAM_GOAL_RELATED and PENDING_GOAL_CONTINUATION flags to gate
the evaluate_goal_after_turn() call in the streaming loop. Only streams
started from goal kickoff (/goal <text>) or goal continuation are
marked as goal-related. Normal user messages skip the hook entirely.
model_with_provider_context can emit @Custom:<host>:<port>:<model> when
model_provider is derived from an OpenAI base_url authority (e.g.
custom:10.8.0.1:8080). The colon-count heuristic meant for @Custom:slug:model:free
mistook those extra colons for an over-split model ID and prepended the port
segment onto the bare model (8080:Qwen3-235B), breaking WebUI while CLI/curl
stayed correct.

Detect endpoint-style slugs (IPv4/localhost/hostname + numeric port) and skip
the peel in that case. Add regression tests for IPv4, dotted hostname,
localhost, and model_with_provider_context round-trip.
…licating

When multiple custom providers expose the same model ID (e.g. baidu,
huoshan, and liantong all offering glm-5.1), only the first provider's
entry was shown in the model dropdown.

Root cause (backend):  used the bare model ID as the
dedup key, so the second and subsequent providers with the same model
were silently skipped.

Root cause (frontend):  stripped the @Provider: prefix before
comparing, so @Custom:baidu:glm-5.1 and @Custom:huoshan:glm-5.1 were
treated as duplicates.

Fix:
- Backend: change _seen_custom_ids key to '{slug}:{model_id}' so each
  provider's models are tracked independently.
- Frontend: add _providerOf() helper and deduplicate on the composite
  (normId, provider) key instead of normId alone. Bare model IDs
  (without @Provider: prefix) still deduplicate on normId for backward
  compatibility.
… refresh

- Session.composer_draft field: {text, files} stored in session JSON
- POST+GET /api/session/draft endpoint for save/load
- loadSession: save draft before switch, restore from S.session.composer_draft
- textarea input: debounced 400ms auto-save to server
- send(): clear draft after message is sent
- lockComposerForClarify(): save draft before card locks composer
- _restoreComposerDraft: clears textarea when target has no draft, guards
  against stale responses racing new session loads, exact text comparison
- Session.compact(): includes composer_draft in response
- Fix: use handler.command instead of parsed.method (ParseResult has no .method)

Co-authored-by: Minimax <noreply@minimax.io>
Add _resolve_session_ttl() with three-layer precedence:
  1. HERMES_WEBUI_SESSION_TTL env var (highest priority)
  2. session_ttl_seconds in settings.json
  3. Default: 86400 * 30 (30 days)

Clamped to [60s, 1 year] for safety. Settings changes take effect
immediately since the function is called dynamically at each login/cookie-write.

Closes #1954
… native images

_build_native_multimodal_message() unconditionally embedded images as
native image_url parts, bypassing the agent's image_input_mode config.

Add _resolve_image_input_mode(cfg) helper mirroring the agent's
decide_image_input_mode logic, and wire it into
_build_native_multimodal_message with a new cfg parameter.

When mode resolves to 'text' (explicit aux vision config, or
image_input_mode: text), returns plain string so the agent's
existing text-mode pipeline (vision_analyze) handles images.

Closes #1959
…-providers

Adds tests/test_pr1947_same_model_multiple_custom_providers.py covering:

1. Two named custom providers exposing the same model id — both must
   surface in the rendered groups (one bare, one @Custom:slug:model)
2. Three named providers all exposing the same model — none dropped
3. Distinct-model-per-provider sanity check (still grouped correctly)

Verified the regression-detecting tests (1 + 2) FAIL against master's
api/config.py (where _seen_custom_ids was seeded from auto_detected_models
and used as a global bare-id bucket — the second provider's entry was
silently dropped) and PASS against the contributor fix on this branch.

Test 3 (distinct-models sanity) passes either way as expected.

Co-authored-by: happy5318 <happy5318@users.noreply.github.com>
Co-authored-by: hacker1e7 <hacker1e7@users.noreply.github.com>
…art-jump race with generation-token + mutex by @Sanjays2402

# Conflicts:
#	CHANGELOG.md
PR #1957 deleted the SESSION_TTL = 86400 * 30 module-level constant in
favor of the new _resolve_session_ttl() helper. Two existing regression
tests pin the constant: test_auth_sessions.TestSessionPruning.test_session_ttl_is_24_hours
imports SESSION_TTL directly, and test_v050258_opus_followups.test_redirect_session_ttl_30_days
asserts the literal "SESSION_TTL = 86400 * 30" line is present in source
(guarding against the daily-kick-out regression from #1419).

Restore SESSION_TTL as the named fallback for _resolve_session_ttl(); the
new env-var/settings.json path is unchanged. Backwards-compatible.

Also fix the new TestSessionTtlResolution suite:
- Switch from pytest's `monkeypatch` fixture (incompatible with
  unittest.TestCase subclasses) to setUp/tearDown env snapshotting
- Reconcile clamp tests with actual implementation: out-of-range env
  values fall through to settings/default, not snap to bounds
- test_session_uses_dynamic_ttl now sets the env var so the dynamic
  resolved value (3600s) is exercised rather than expecting the default

Verified: tests/test_auth_sessions.py + tests/test_v050258_opus_followups.py
21/21 pass.
CRITICAL: #1951 PENDING_GOAL_CONTINUATION race
  Removes `PENDING_GOAL_CONTINUATION.discard(session_id)` from the
  streaming worker's `finally` cleanup block. The marker is set inside
  the SAME function call (line ~3328 on `goal_continue`) and the discard
  in the `finally` (line ~3553) almost always raced ahead of the
  frontend's SSE-receive → POST /api/chat/start round-trip, erasing
  the marker before the consumer in routes.py could read it. The
  consumer (`_start_chat_stream_for_session` in routes.py:6522) already
  discards atomically when consuming, so removing the streaming-side
  discard preserves single-use semantics and unblocks the
  goal-continuation chain.

  Adds tests/test_stage326_pending_goal_continuation_race.py with 5
  regression guards:
  1. streaming.py's finally must NOT discard PENDING_GOAL_CONTINUATION
  2. routes.py consumer must check + set + discard atomically
  3. PENDING_GOAL_CONTINUATION must be a set (GIL-safe single-op)
  4. STREAM_GOAL_RELATED.pop must be keyed by stream_id, not session_id
  5. PENDING_GOAL_CONTINUATION.add must precede the goal_continue SSE
     emission in source ordering

HARDENING: #1956 composer-draft input validation
  Per Opus, the POST /api/session/draft handler accepted unbounded /
  arbitrary-typed text and files inputs. With the 400ms debounced
  auto-save firing on every keystroke, a misbehaving client could
  persist multi-MB strings into the session JSON. Adds:
  - text: coerced to str if not already; clamped to 50_000 chars
  - files: coerced to list if not already; clamped to 50 entries
  Validation runs BEFORE the session lock acquire / save.

  Adds tests/test_stage326_composer_draft_validation.py with 5 guards.

Verdict from Opus advisor on stage-326: SHIP-WITH-FIXES.
This commit applies the required + recommended fixes; #1957 hardening
fixed in a prior stage commit.
CHANGELOG, ROADMAP, TESTING refresh for v0.51.31 stage release covering
12 contributor PRs:

Added (2 PRs):
- #1956 JKJameson — persistent composer draft (server-side, cross-client)
- #1957 hermes-gimmethebeans — configurable session TTL via env + settings

Fixed (10 PRs):
- #1939 ai-ag2026 — theme-color + sw cache regression coverage
- #1941 ai-ag2026 — preserve chat scroll across final render
- #1945 franksong2702 — localize session jump controls (#1938)
- #1947 happy5318 — show same model from different custom providers
  (Co-authored-by hacker1e7 for #1874 close)
- #1949 Sanjays2402 — close #1937 endless-scroll vs Start-jump race
  with generation-token + mutex
  (Co-authored-by franksong2702 + Michaelyklam)
- #1950 franksong2702 — mute stale stopped gateway heartbeat (#1944)
- #1951 amlyczz — gate goal hook on goal-related turns (#1932)
  (Co-authored-by franksong2702 for #1946 close)
- #1953 lucky-yonug — skip provider peel for custom host:port slugs
- #1960 Michaelyklam — translate hidden-files workspace label (#1841)
- #1961 sbe27 — respect image_input_mode (#1959)

Closed in favor of canonical: #1942, #1962, #1946, #1874, #1311.

Stage-326 hotfixes (per Opus advisor):
- CRITICAL #1951 PENDING_GOAL_CONTINUATION race fix (removed finally
  discard that race-erased the marker before consumer could read it)
- #1956 composer-draft input validation (50 KB text / 50 file clamp +
  type coercion to prevent unbounded session-JSON bloat)
- #1957 SESSION_TTL constant preserved as named fallback (existing
  regression tests pin it; #1957 originally deleted it)

Tests: 5006 → 5028 (+51 net new) — 0 regressions, 142.61s runtime.
@nesquena-hermes
Copy link
Copy Markdown
Collaborator Author

Opus advisor pass #2 — VERDICT: SHIP-AS-IS

Re-ran Opus on the patched stage HEAD after applying the critical + recommended fixes from pass #1. All three patches verified clean.

Pass #2 findings (verbatim)

Ask 1 — #1951 race fix completeness ✓ VERIFIED
Searched all api/*.py for PENDING_GOAL_CONTINUATION. Only three files touch it: api/config.py:3654 (definition, set()), api/streaming.py (import + line 3328 add + line 3553 now a comment-only block, no code mutating the set), api/routes.py (line 783 import + line 6534 check + line 6536 discard). No other discard path in the streaming worker or anywhere else. Per-stream cleanup uses STREAM_GOAL_RELATED.pop(stream_id, ...) (different set, keyed by stream_id, correct). The race is closed. Add-before-put ordering at streaming.py:3328 → 3329 confirmed.

Ask 2 — #1956 validation order vs lock ✓ SAFE
routes.py:4027–4040 clamps text/files (local-variable mutation only) before with _get_session_agent_lock(sid):. Inside the lock: read existing composer_draft, merge, assign, save. The only writer of s.composer_draft in the entire codebase is routes.py:4051 (verified). Validation outside the lock is correct — avoids holding the per-session lock during CPU-bound length checks; persisted state is still serialized.

Ask 3 — SESSION_TTL fallback in all exit paths ✓ VERIFIED
auth.py:28–44 has exactly one return-default site (line 44, return SESSION_TTL). All three failure paths converge there: env-out-of-range → falls past inner return val, past settings block, hits line 44. settings-out-of-range / wrong type / missing → hits line 44. no-config (empty env + no settings key) → hits line 44. Constant restored at auth.py:25. The two existing regression tests will both pass.

Ask 4 — Spot-checks Reviewed patch commits, diff scope, surrounding code. CI is green on 3.11/3.12/3.13, PR is mergeable, no outstanding reviews. Minor non-blocking observations: TestSessionTtlResolution is declared after unittest.main() block (cosmetic; pytest discovers it fine); routes.py:6534–6536 check+discard is non-atomic but the earlier 409 short-circuit at routes.py:6520 prevents concurrent /chat/start for same session; PENDING_GOAL_CONTINUATION leak if user closes browser between goal_continue and next /chat/start is bounded + negligible; files validation caps list count (50) but not per-entry payload (file references only today).

Verdict: SHIP-AS-IS. All three patches close their gaps cleanly, no new issues introduced, CI green across supported Python versions.

Final pre-ship state

Merging.

@nesquena-hermes nesquena-hermes merged commit 8a653ba into master May 9, 2026
3 checks passed
@nesquena-hermes nesquena-hermes deleted the stage-326 branch May 9, 2026 18:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment