release: v0.51.31 — Release H (12-PR contributor batch: image-mode + race fixes + composer drafts + locale parity)#1967
Conversation
…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>
…nv var and settings.json by @hermes-gimmethebeans
…m host:port slugs by @lucky-yonug
…iders instead of deduplicating by @happy5318
…onditionally embedding native images by @sbe27
# Conflicts: # CHANGELOG.md
…art-jump race with generation-token + mutex by @Sanjays2402 # Conflicts: # CHANGELOG.md
…cross-client, survives refresh by @JKJameson
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.
Opus advisor pass #2 — VERDICT: SHIP-AS-ISRe-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)
Final pre-ship state
Merging. |
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-byattribution preserved.What landed
Added (2 PRs)
Fixed (10 PRs)
image_input_mode(closes Bug: WebUI unconditionally embeds images as native image_url, bypassing image_input_mode config #1959)Closed in favor of canonical PRs (with Co-authored-by trailers)
Pre-release verification
node -con modifiedstatic/*.jspy_compileon modifiedapi/*.pyOpus advisor verdict: SHIP-WITH-FIXES (all applied)
Opus flagged one critical + two recommended fixes; all applied in stage commits
404e24ac+8782fd26:CRITICAL fix: only evaluate goal hook on goal-related turns (#1932) #1951 —
PENDING_GOAL_CONTINUATION.discard(session_id)in the streaming worker'sfinallyblock race-erased the marker before the frontend's SSE-receive →POST /api/chat/startround-trip could consume it. Removed the discard; consumer inroutes.pydiscards atomically on read. 5 new regression guards pin the corrected ordering.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.
feat(auth): make session TTL configurable via env var and settings.json #1957 — preserved
SESSION_TTLmodule 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 newTestSessionTtlResolutionclass to use unittest setUp/tearDown env snapshotting (instead of the pytestmonkeypatchfixture, incompatible withunittest.TestCasesubclasses) and aligned clamp tests with the actual fall-through-to-default behavior.Stage diff
stage-326(12 PR merges + 4 stage-326 hotfix commits + 1 release CHANGELOG/ROADMAP commit)What's NOT in this release
hold,uxper maintainer callCloses: #1932 #1937 #1938 #1841 #1944 #1954 #1959
🤖 Generated with Claude Code