2026-05-21 17:14:34 +00:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
SESSIONS_JS = Path("static/sessions.js").read_text(encoding="utf-8")
|
|
|
|
|
MESSAGES_JS = Path("static/messages.js").read_text(encoding="utf-8")
|
|
|
|
|
CHANGELOG = Path("CHANGELOG.md").read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_function(source: str, signature: str) -> str:
|
|
|
|
|
start = source.index(signature)
|
|
|
|
|
# Look for the function body's opening brace, not an object literal inside
|
|
|
|
|
# a default argument such as `options={}`.
|
|
|
|
|
brace = source.index("{\n", start)
|
|
|
|
|
depth = 0
|
|
|
|
|
for idx in range(brace, len(source)):
|
|
|
|
|
ch = source[idx]
|
|
|
|
|
if ch == "{":
|
|
|
|
|
depth += 1
|
|
|
|
|
elif ch == "}":
|
|
|
|
|
depth -= 1
|
|
|
|
|
if depth == 0:
|
|
|
|
|
return source[start : idx + 1]
|
|
|
|
|
raise AssertionError(f"Function body not closed for {signature}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _new_session_function() -> str:
|
|
|
|
|
return _extract_function(SESSIONS_JS, "async function newSession")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_new_chat_syncs_model_picker_when_default_provider_changes_but_model_id_matches():
|
|
|
|
|
fn = _new_session_function()
|
|
|
|
|
assert "currentModelState" in fn
|
|
|
|
|
assert "currentProvider" in fn
|
|
|
|
|
assert "sessionProvider" in fn
|
|
|
|
|
assert "sessionProvider !== currentProvider" in fn
|
|
|
|
|
assert "_applyModelToDropdown(S.session.model,modelSel,sessionProvider)" in fn
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 06:10:50 +00:00
|
|
|
def test_new_chat_inserts_session_model_when_static_picker_lacks_default():
|
|
|
|
|
fn = _new_session_function()
|
|
|
|
|
assert "sessionModelApplied" in fn
|
|
|
|
|
assert "document.createElement('option')" in fn
|
|
|
|
|
assert "opt.value=S.session.model" in fn
|
|
|
|
|
assert "opt.dataset.provider=sessionProvider||''" in fn
|
|
|
|
|
assert "modelSel.appendChild(opt)" in fn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_boot_model_hydration_prefers_active_session_over_persisted_model():
|
|
|
|
|
boot_js = Path("static/boot.js").read_text(encoding="utf-8")
|
|
|
|
|
marker = "const sessionModelState=S.session&&S.session.model"
|
|
|
|
|
assert marker in boot_js
|
|
|
|
|
session_branch = boot_js[boot_js.index(marker) : boot_js.index("if(S.session) syncTopbar();", boot_js.index(marker))]
|
|
|
|
|
assert "_applyModelToDropdown(sessionModelState.model,$('modelSelect'),sessionModelState.model_provider||null)" in session_branch
|
|
|
|
|
assert "savedState" in session_branch
|
|
|
|
|
assert session_branch.index("sessionModelState") < session_branch.index("savedState"), (
|
|
|
|
|
"active session model must be considered before localStorage so stale saved model preferences cannot override new chats"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 06:29:34 +00:00
|
|
|
def test_hard_refresh_hydrates_saved_session_model_before_revealing_model_chip():
|
|
|
|
|
boot_js = Path("static/boot.js").read_text(encoding="utf-8")
|
|
|
|
|
load_marker = "await loadSession(saved);"
|
|
|
|
|
assert load_marker in boot_js
|
|
|
|
|
saved_restore = boot_js[boot_js.index(load_marker) : boot_js.index("await checkInflightOnBoot(saved);return;", boot_js.index(load_marker))]
|
|
|
|
|
assert "await _startBootModelDropdown();" in saved_restore
|
|
|
|
|
assert saved_restore.index("await _startBootModelDropdown();") > saved_restore.index(load_marker)
|
|
|
|
|
assert saved_restore.index("await _startBootModelDropdown();") < saved_restore.index("S._bootReady=true;"), (
|
|
|
|
|
"hard refresh must hydrate/re-apply the active session model before S._bootReady lets syncModelChip display stale static HTML defaults"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 07:23:33 +00:00
|
|
|
def test_hard_refresh_injects_missing_active_session_model_option():
|
|
|
|
|
boot_js = Path("static/boot.js").read_text(encoding="utf-8")
|
|
|
|
|
marker = "if(!applied&&sessionModelState&&typeof _ensureModelOptionInDropdown==='function')"
|
|
|
|
|
assert marker in boot_js
|
|
|
|
|
branch = boot_js[boot_js.index(marker) : boot_js.index("else if(!applied&&!sessionModelState", boot_js.index(marker))]
|
|
|
|
|
assert "_ensureModelOptionInDropdown(sessionModelState.model,$('modelSelect'),sessionModelState.model_provider||null)" in branch
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sync_topbar_preserves_missing_session_model_as_dropdown_option():
|
|
|
|
|
ui_js = Path("static/ui.js").read_text(encoding="utf-8")
|
|
|
|
|
assert "function _ensureModelOptionInDropdown" in ui_js
|
|
|
|
|
sync_topbar = _extract_function(ui_js, "function syncTopbar")
|
|
|
|
|
branch_start = sync_topbar.index("const applied=_applyModelToDropdown(currentModel,modelSel,S.session.model_provider||null);")
|
|
|
|
|
session_model_branch = sync_topbar[branch_start:]
|
|
|
|
|
assert "_ensureModelOptionInDropdown(currentModel,modelSel,S.session.model_provider||null)" in session_model_branch
|
|
|
|
|
assert "const fallback=_applySessionModelFallback(modelSel);" in session_model_branch
|
|
|
|
|
assert session_model_branch.index("_ensureModelOptionInDropdown(currentModel,modelSel,S.session.model_provider||null)") < session_model_branch.index("const fallback=_applySessionModelFallback(modelSel);"), (
|
|
|
|
|
"active session models missing from the current catalog must be injected before fallback can select the static/default model"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 17:14:34 +00:00
|
|
|
def test_new_chat_does_not_send_stale_dropdown_model_when_session_has_default_model():
|
|
|
|
|
assert "model:S.session.model||$('modelSelect').value" in MESSAGES_JS
|
|
|
|
|
assert "model_provider:S.session.model_provider||null" in MESSAGES_JS
|
|
|
|
|
|
|
|
|
|
|
2026-05-23 09:59:03 +00:00
|
|
|
def test_new_session_posts_picker_model_before_server_default():
|
|
|
|
|
fn = _new_session_function()
|
|
|
|
|
assert "reqBody.model=newModelState.model" in fn
|
2026-06-24 18:23:33 +00:00
|
|
|
assert "explicitModelOverride" in fn
|
|
|
|
|
assert "hasLoadedSession&&window._defaultModel" in fn
|
|
|
|
|
assert "modelSelForNew&&modelSelForNew.value&&typeof _modelStateForSelect==='function'" in fn
|
Release v0.51.238 — Release HF (stage-q9) (#3493)
## Release v0.51.238 — Release HF (stage-q9)
Phase 3 MEDIUM-ring pick (3-factor: contributor×impact×mitigated-risk) — high-impact perf fix to the most-clicked affordance from a regular contributor (@franksong2702 ★★★), small code surface, CI-green.
### Fixed
| PR | Author | Fix |
|----|--------|-----|
| #2518 follow-up | @franksong2702 | Clicking **New Conversation** on a cold start no longer hangs 3–4s on a catalog rebuild. `newSession()` fills `model_provider` from `window._activeProvider` (then prev-session) when the dropdown carries none, so `POST /api/session/new` takes the fast path on the first click too. |
### Pre-release dual gate caught a wrong-backend routing bug (fixed + regression-tested)
The server fast path passes `(model, provider)` through **without validating the pair**, so naively attaching the active provider to *any* bare model could silently route to the wrong backend (e.g. bare `claude-opus-4.8` + active `openrouter`). **Codex** flagged this; **Opus** had judged it acceptable ("respect the selection over silent swap"). I took the stricter, empirically-grounded path and added a **family-mismatch guard** mirroring the server's own bare-prefix→provider map (`gpt`→openai, `claude`→anthropic, `gemini`→google): when the model's known family differs from the fallback provider, `model_provider` stays `null` so the server slow-path's family repair runs. This keeps the perf win for the common matching case while closing the mis-route. Backend behavioral tests confirm fast-path-on-match + slow-path-on-mismatch. (Also re-anchored the source-shape test assertions on the real `reqBody.model_provider=` assignment per Codex's 2nd note.)
### Gate results
- **Full pytest suite**: 7495 passed, 9 skipped, 3 xpassed, **0 failed**
- **ESLint runtime gate**: CLEAN · **ruff**: CLEAN · **browser-smoke**: CLEAN
- **Codex (regression)**: SHIP ONLY WITH FIXES → guard + test-anchor applied → re-reviewed **SAFE TO SHIP**
- **Opus (correctness)**: reviewed the original (judged acceptable); the shipped version is strictly safer (adds the family guard)
Note: `docs/pr-media/2518/{PR_BODY.md,bench.py}` are the contributor's review aids, included per the tracked `docs/pr-media/` convention (157 files already tracked) — not app code.
Closes #2518.
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-03 19:23:46 +00:00
|
|
|
provider_assignment = fn[fn.index("reqBody.model_provider="):].split(";", 1)[0]
|
|
|
|
|
assert "newModelState.model_provider" in provider_assignment
|
|
|
|
|
assert "_fallbackProvider" in provider_assignment
|
|
|
|
|
assert "window._activeProvider" in fn
|
|
|
|
|
assert "S.session&&S.session.model_provider" in fn
|
2026-06-24 18:23:33 +00:00
|
|
|
pos_override = fn.index("explicitModelOverride")
|
|
|
|
|
pos_default = fn.index("hasLoadedSession&&window._defaultModel")
|
|
|
|
|
pos_legacy = fn.index("modelSelForNew&&modelSelForNew.value&&typeof _modelStateForSelect==='function'")
|
|
|
|
|
assert pos_override < pos_default < pos_legacy, (
|
|
|
|
|
"newSession() must prefer the empty-composer override first, then the configured default for loaded-session flows, then legacy picker state"
|
Release v0.51.238 — Release HF (stage-q9) (#3493)
## Release v0.51.238 — Release HF (stage-q9)
Phase 3 MEDIUM-ring pick (3-factor: contributor×impact×mitigated-risk) — high-impact perf fix to the most-clicked affordance from a regular contributor (@franksong2702 ★★★), small code surface, CI-green.
### Fixed
| PR | Author | Fix |
|----|--------|-----|
| #2518 follow-up | @franksong2702 | Clicking **New Conversation** on a cold start no longer hangs 3–4s on a catalog rebuild. `newSession()` fills `model_provider` from `window._activeProvider` (then prev-session) when the dropdown carries none, so `POST /api/session/new` takes the fast path on the first click too. |
### Pre-release dual gate caught a wrong-backend routing bug (fixed + regression-tested)
The server fast path passes `(model, provider)` through **without validating the pair**, so naively attaching the active provider to *any* bare model could silently route to the wrong backend (e.g. bare `claude-opus-4.8` + active `openrouter`). **Codex** flagged this; **Opus** had judged it acceptable ("respect the selection over silent swap"). I took the stricter, empirically-grounded path and added a **family-mismatch guard** mirroring the server's own bare-prefix→provider map (`gpt`→openai, `claude`→anthropic, `gemini`→google): when the model's known family differs from the fallback provider, `model_provider` stays `null` so the server slow-path's family repair runs. This keeps the perf win for the common matching case while closing the mis-route. Backend behavioral tests confirm fast-path-on-match + slow-path-on-mismatch. (Also re-anchored the source-shape test assertions on the real `reqBody.model_provider=` assignment per Codex's 2nd note.)
### Gate results
- **Full pytest suite**: 7495 passed, 9 skipped, 3 xpassed, **0 failed**
- **ESLint runtime gate**: CLEAN · **ruff**: CLEAN · **browser-smoke**: CLEAN
- **Codex (regression)**: SHIP ONLY WITH FIXES → guard + test-anchor applied → re-reviewed **SAFE TO SHIP**
- **Opus (correctness)**: reviewed the original (judged acceptable); the shipped version is strictly safer (adds the family guard)
Note: `docs/pr-media/2518/{PR_BODY.md,bench.py}` are the contributor's review aids, included per the tracked `docs/pr-media/` convention (157 files already tracked) — not app code.
Closes #2518.
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-03 19:23:46 +00:00
|
|
|
)
|
|
|
|
|
assert "_familyMismatch" in fn
|
2026-05-23 09:59:03 +00:00
|
|
|
assert "_readPersistedModelState" in fn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_model_picker_persists_without_active_session():
|
|
|
|
|
boot_js = Path("static/boot.js").read_text(encoding="utf-8")
|
|
|
|
|
body = boot_js[boot_js.index("$('modelSelect').onchange=async()=>") : boot_js.index("$('msg').addEventListener", boot_js.index("$('modelSelect').onchange=async()=>"))]
|
|
|
|
|
assert "_writePersistedModelState(modelState.model,modelState.model_provider)" in body
|
2026-06-24 18:23:33 +00:00
|
|
|
assert "_rememberEmptyComposerModelOverride(modelState.model,modelState.model_provider)" in body
|
2026-05-23 09:59:03 +00:00
|
|
|
assert "if(!S.session){" in body
|
|
|
|
|
assert body.index("if(!S.session){") < body.index("await api('/api/session/update'")
|
|
|
|
|
|
|
|
|
|
|
2026-06-24 18:23:33 +00:00
|
|
|
def test_session_model_changes_do_not_write_empty_composer_override():
|
|
|
|
|
boot_js = Path("static/boot.js").read_text(encoding="utf-8")
|
|
|
|
|
body = boot_js[boot_js.index("$('modelSelect').onchange=async()=>") : boot_js.index("$('msg').addEventListener", boot_js.index("$('modelSelect').onchange=async()=>"))]
|
|
|
|
|
session_branch = body[body.index("if(typeof _rememberPendingSessionModel==='function')"):]
|
|
|
|
|
assert "_rememberPendingSessionModel(S.session.session_id,modelState.model,modelState.model_provider)" in body
|
|
|
|
|
assert "_rememberEmptyComposerModelOverride(modelState.model,modelState.model_provider)" not in session_branch
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_new_chat_prefers_explicit_empty_composer_override_before_configured_default():
|
|
|
|
|
fn = _new_session_function()
|
|
|
|
|
assert "explicitModelOverride" in fn
|
|
|
|
|
assert "hasLoadedSession" in fn
|
|
|
|
|
assert "consumedExplicitModelOverride" in fn
|
2026-06-24 18:39:57 +00:00
|
|
|
assert "usingConfiguredDefault" in fn
|
2026-06-24 18:23:33 +00:00
|
|
|
assert "_clearEmptyComposerModelOverride" in fn
|
2026-06-24 18:57:29 +00:00
|
|
|
assert "newModelState={model:window._defaultModel,model_provider:null};" in fn
|
2026-06-24 18:23:33 +00:00
|
|
|
assert fn.index("explicitModelOverride") < fn.index("hasLoadedSession&&window._defaultModel") < fn.index("_modelStateForSelect"), (
|
|
|
|
|
"newSession() must prefer the empty-composer override first, then the configured default for loaded-session flows, then legacy picker state"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_new_session_keeps_provider_fallback_guards_after_model_precedence():
|
|
|
|
|
fn = _new_session_function()
|
|
|
|
|
provider_assignment = fn[fn.index("reqBody.model_provider="):].split(";", 1)[0]
|
|
|
|
|
assert "newModelState.model_provider" in provider_assignment
|
|
|
|
|
assert "_fallbackProvider" in provider_assignment
|
|
|
|
|
assert "_familyMismatch" in provider_assignment
|
|
|
|
|
assert "_fallbackIsNamedCustom" in provider_assignment
|
2026-06-24 18:39:57 +00:00
|
|
|
assert "usingConfiguredDefault?window._activeProvider" in fn
|
2026-06-24 18:23:33 +00:00
|
|
|
|
|
|
|
|
|
2026-06-24 18:49:27 +00:00
|
|
|
def test_save_settings_syncs_default_model_provider_with_saved_model():
|
|
|
|
|
panels_js = Path("static/panels.js").read_text(encoding="utf-8")
|
|
|
|
|
save_block = _extract_function(panels_js, "async function saveSettings")
|
|
|
|
|
apply_saved_block = _extract_function(panels_js, "function _applySavedSettingsUi")
|
2026-06-24 19:03:55 +00:00
|
|
|
autosave_block = panels_js[panels_js.index("const pwField=$('settingsPassword');"):panels_js.index("if(!pwDirty&&!modelDirty){", panels_js.index("const pwField=$('settingsPassword');")) + 24]
|
2026-06-24 18:49:27 +00:00
|
|
|
|
|
|
|
|
assert "_captureModelDropdownSelection($('settingsModel'))" in save_block
|
|
|
|
|
assert "body.default_model_provider=(modelState&&modelState.model===model)?(modelState.model_provider||null):null;" in save_block
|
2026-06-24 19:03:55 +00:00
|
|
|
assert "const modelChanged=(model||'')!==(_settingsHermesDefaultModelOnOpen||'')||((modelState.model_provider||null)!==(_settingsHermesDefaultModelProviderOnOpen||null));" in save_block
|
2026-06-24 18:49:27 +00:00
|
|
|
assert "if(Object.prototype.hasOwnProperty.call(body,'default_model_provider')) window._activeProvider=body.default_model_provider||null;" in apply_saved_block
|
2026-06-24 19:03:55 +00:00
|
|
|
assert "_settingsHermesDefaultModelProviderOnOpen=(models&&models.active_provider)||null;" in panels_js
|
|
|
|
|
assert "if(Object.prototype.hasOwnProperty.call(body,'default_model_provider')) _settingsHermesDefaultModelProviderOnOpen=body.default_model_provider||null;" in apply_saved_block
|
|
|
|
|
assert "(modelState.model_provider||null)!==(_settingsHermesDefaultModelProviderOnOpen||null)" in autosave_block
|
2026-06-24 18:49:27 +00:00
|
|
|
|
|
|
|
|
|
2026-05-21 17:14:34 +00:00
|
|
|
def test_changelog_mentions_new_chat_default_model_provider_sync():
|
|
|
|
|
unreleased = CHANGELOG.split("## [v0.51.103]", 1)[0]
|
|
|
|
|
assert "New conversations now resync" in unreleased
|
|
|
|
|
assert "default model provider" in unreleased
|