Release v0.51.295 — stage-3739/3742 (model-pick revert fix #3739 + session-status revert #3742) (#3743)
* fix: honor explicit model pick, suppress silent revert on cross-family selection (#3737) When a user changes the model in the composer dropdown and sends, _resolve_compatible_session_model_state previously had no way to distinguish an explicit user pick from stale session state. The profile-aware branch (v0.51.290, PR #3448) and the legacy block both rewrote bare cross-family models to the profile default, and the client unconditionally applied effective_model — silently discarding the user's choice. Backend: accept explicit_model_pick flag (default False) on _resolve_compatible_session_model_state. Guard both the profile-aware branch (routes.py:2024) and the legacy block (routes.py:2124) to skip cross-provider normalization when set. _handle_chat_start extracts the flag and passes it through. Frontend: consult _readPendingSessionModel (sessionStorage, 10-min window) to detect explicit picks and include the flag. Add a toast as defense-in-depth when the server still returns effective_model. Closes #3737 * fix: tighten explicit-pick detection and add regression tests (#3737) Greptile P2-1: compare model_provider in pending pick detection, not just model name, to avoid false-positive flag when the session provider changes between pick and send. Greptile P2-2: only show the defense-in-depth toast when an explicit pick was actually overridden — stale-session normalizations are expected behavior and should be silent. Add two regression tests for the profile-branch guard: - explicit_model_pick=True → cross-family model survives - explicit_model_pick=False → existing normalization preserved * revert(sidebar): remove manual session status labels (#3570) The manual per-session status labels (Todo / In Progress / Done) added in v0.51.284 (#3570) stored state only in browser localStorage keyed by session id, with no server-side backing — so labels did not persist across browsers or devices (a user who labeled sessions on one machine saw none after moving to a laptop). They also rendered as three flat top-level entries in the session context menu, crowding the root menu. Per maintainer decision, remove the feature entirely for now. It can be reintroduced later with proper server-side persistence and a less intrusive menu treatment. Removes: - JS state/cycle helpers + SESSION_MANUAL_STATUS_KEY (static/sessions.js) - context-menu status entries + sidebar status badge render - .session-manual-status* CSS (static/style.css) - session_status_* locale strings across all locales (static/i18n.js) Full suite: 8084 passed, 0 failed. ESLint runtime gate: clean. reverts #3570 * fix(#3737): keep explicit-pick marker until send consumes it (Codex catch) Codex found the explicit_model_pick flag never engaged in the normal flow: boot.js modelSelect.onchange cleared the pending-pick marker right after /api/session/update, so by the time send() ran _readPendingSessionModel returned null, _explicitPick was false, and the server's profile-provider branch still reverted the cross-family pick (the exact #3737 bug). The flag only worked in the rare race where send beat the session-update round-trip. Fix (Codex prescription): do NOT clear the marker in onchange; clear it in send() immediately after reading a matching pending pick, so it's consumed for that send only. onchange still RECORDS the pick (_rememberPendingSessionModel) — only the premature clear is removed. * test(#3737): lock client clear-timing wiring (onchange records, send consumes) Static source guards for the Codex clear-timing fix: onchange must record the pending pick and NOT clear it post-session-update; send() must consume (clear) it only after reading a matching _explicitPick, and send the flag only when truthy. Complements the author's resolver-level tests in test_provider_mismatch.py. * test(#3737): realign refresh-persistence test to the moved pending-pick clear The Codex clear-timing fix moved the pending-pick clear out of modelSelect.onchange into send() (consume-on-send). test_model_selection_records_pending_state_before_async_session_update asserted the OLD onchange-clears behavior (assert _clearPendingSessionModel in body). Updated to assert the NEW correct behavior (onchange must NOT clear it — it survives to send). The test's core refresh-survives invariant (marker recorded before the async session-update; reapplied on load) is unchanged and still passes; only the stale clear-location assertion is flipped. Not a regression-blessing: the refresh-survives feature is intact, the marker lifecycle is more correct. --------- Co-authored-by: John Doe <johndoe@example.com> Co-authored-by: nesquena-hermes <[email protected]>
This commit is contained in:
parent
e3a7c93dc6
commit
65c4bc9fa2
10 changed files with 155 additions and 125 deletions
|
|
@ -3,6 +3,10 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Removed
|
||||
- **Reverted the manual per-session status labels (Todo / In Progress / Done).** The feature added in v0.51.284 (#3570) stored the chosen status only in browser `localStorage`, keyed by session id, with no server-side backing — so labels silently did not persist across browsers or devices (a user who labeled sessions on one machine saw none of them after switching to a laptop). It also rendered the three statuses as flat top-level entries in the session context menu alongside Copy/Rename/Pin/etc., which crowded the root menu. Removed entirely for now (JS state + cycle logic, context-menu entries, sidebar badge render, CSS, and all locale strings); the feature can be reintroduced later with proper server-side persistence and a less intrusive menu treatment. (reverts #3570)
|
||||
|
||||
|
||||
## [v0.51.294] — 2026-06-06 — Release JJ (stage-3401 — live-to-final assistant reply redesign)
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
|
|
@ -1955,6 +1955,7 @@ def _resolve_compatible_session_model_state(
|
|||
*,
|
||||
profile_provider: str | None = None,
|
||||
profile_default_model: str | None = None,
|
||||
explicit_model_pick: bool = False,
|
||||
) -> tuple[str, str | None, bool]:
|
||||
"""Return (effective_model, effective_provider, model_was_normalized).
|
||||
|
||||
|
|
@ -2020,6 +2021,9 @@ def _resolve_compatible_session_model_state(
|
|||
break
|
||||
|
||||
if model_family and model_family != _profile_provider_normalized:
|
||||
if explicit_model_pick:
|
||||
# User explicitly chose a cross-family model; honor it (#3737)
|
||||
return model, profile_provider, False
|
||||
_target = _profile_default or default_model
|
||||
return _target, profile_provider, True
|
||||
|
||||
|
|
@ -2117,6 +2121,9 @@ def _resolve_compatible_session_model_state(
|
|||
|
||||
slash = model.find("/")
|
||||
if slash < 0:
|
||||
if explicit_model_pick:
|
||||
# User explicitly chose this model; don't second-guess (#3737)
|
||||
return model, requested_provider, False
|
||||
model_lower = model.lower()
|
||||
for bare_prefix in ("gpt", "claude", "gemini"):
|
||||
if model_lower.startswith(bare_prefix):
|
||||
|
|
@ -11408,12 +11415,14 @@ def _handle_chat_start(handler, body, diag=None):
|
|||
else getattr(s, "model_provider", None)
|
||||
)
|
||||
_pp_provider, _pp_default = _read_profile_model_config(s, requested_provider)
|
||||
explicit_model_pick = bool(body.get("explicit_model_pick"))
|
||||
diag.stage("resolve_model_provider") if diag else None
|
||||
model, model_provider, normalized_model = _resolve_compatible_session_model_state(
|
||||
requested_model,
|
||||
requested_provider,
|
||||
profile_provider=_pp_provider,
|
||||
profile_default_model=_pp_default,
|
||||
explicit_model_pick=explicit_model_pick,
|
||||
)
|
||||
from api.runtime_adapter import (
|
||||
LegacyJournalRuntimeAdapter,
|
||||
|
|
|
|||
|
|
@ -1202,12 +1202,11 @@ $('modelSelect').onchange=async()=>{
|
|||
model:modelState.model,
|
||||
model_provider:modelState.model_provider||null,
|
||||
})});
|
||||
if(typeof _readPendingSessionModel==='function'&&typeof _clearPendingSessionModel==='function'){
|
||||
const pending=_readPendingSessionModel(S.session.session_id);
|
||||
if(!pending||(pending.model===modelState.model&&String(pending.model_provider||'')===String(modelState.model_provider||''))){
|
||||
_clearPendingSessionModel(S.session.session_id);
|
||||
}
|
||||
}
|
||||
// NOTE: do NOT clear the pending explicit-pick marker here. It must survive until
|
||||
// the NEXT send() consumes it, otherwise the normal "pick → session-update → send"
|
||||
// flow loses the explicit-pick signal before /api/chat/start runs and the server
|
||||
// re-reverts a cross-family pick (the #3737 bug, Codex catch). send() clears it
|
||||
// after reading a matching pending pick. (#3739/#3737)
|
||||
_applySessionContextMetadataUpdate(data);
|
||||
// Warn if selected model belongs to a different provider than what Hermes is configured for
|
||||
if(typeof _checkProviderMismatch==='function'){
|
||||
|
|
|
|||
|
|
@ -670,10 +670,6 @@ const LOCALES = {
|
|||
kanban_status_running: 'Running',
|
||||
kanban_status_blocked: 'Blocked',
|
||||
kanban_status_done: 'Done',
|
||||
session_status_todo: 'Todo',
|
||||
session_status_in_progress: 'In Progress',
|
||||
session_status_done: 'Done',
|
||||
session_status_click_to_change: 'Click to cycle status',
|
||||
kanban_comments_count: 'Comments ({0})',
|
||||
kanban_events_count: 'Events ({0})',
|
||||
kanban_links: 'Links',
|
||||
|
|
@ -2014,10 +2010,6 @@ const LOCALES = {
|
|||
kanban_status_running: 'In esecuzione',
|
||||
kanban_status_blocked: 'Bloccato',
|
||||
kanban_status_done: 'Completato',
|
||||
session_status_todo: 'Todo',
|
||||
session_status_in_progress: 'In Progress',
|
||||
session_status_done: 'Done',
|
||||
session_status_click_to_change: 'Click to cycle status',
|
||||
kanban_comments_count: 'Commenti ({0})',
|
||||
kanban_events_count: 'Eventi ({0})',
|
||||
kanban_links: 'Link',
|
||||
|
|
@ -3350,10 +3342,6 @@ const LOCALES = {
|
|||
kanban_status_running: '実行中',
|
||||
kanban_status_blocked: 'ブロック中',
|
||||
kanban_status_done: '完了',
|
||||
session_status_todo: 'Todo',
|
||||
session_status_in_progress: 'In Progress',
|
||||
session_status_done: 'Done',
|
||||
session_status_click_to_change: 'Click to cycle status',
|
||||
kanban_status_original_hint: '実際のステータス: {0}。このダイアログはトリアージ/ToDo/準備完了の編集のみ対応しています。',
|
||||
kanban_comments_count: 'コメント ({0})',
|
||||
kanban_events_count: 'イベント ({0})',
|
||||
|
|
@ -4478,10 +4466,6 @@ const LOCALES = {
|
|||
kanban_status_running: 'Running',
|
||||
kanban_status_blocked: 'Blocked',
|
||||
kanban_status_done: 'Done',
|
||||
session_status_todo: 'Todo',
|
||||
session_status_in_progress: 'In Progress',
|
||||
session_status_done: 'Done',
|
||||
session_status_click_to_change: 'Click to cycle status',
|
||||
kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.',
|
||||
kanban_comments_count: 'Comments ({0})',
|
||||
kanban_events_count: 'Events ({0})',
|
||||
|
|
@ -5751,10 +5735,6 @@ const LOCALES = {
|
|||
kanban_status_running: 'Running',
|
||||
kanban_status_blocked: 'Blocked',
|
||||
kanban_status_done: 'Done',
|
||||
session_status_todo: 'Todo',
|
||||
session_status_in_progress: 'In Progress',
|
||||
session_status_done: 'Done',
|
||||
session_status_click_to_change: 'Click to cycle status',
|
||||
kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.',
|
||||
kanban_comments_count: 'Comments ({0})',
|
||||
kanban_events_count: 'Events ({0})',
|
||||
|
|
@ -7008,10 +6988,6 @@ const LOCALES = {
|
|||
kanban_status_running: 'Running',
|
||||
kanban_status_blocked: 'Blocked',
|
||||
kanban_status_done: 'Done',
|
||||
session_status_todo: 'Todo',
|
||||
session_status_in_progress: 'In Progress',
|
||||
session_status_done: 'Done',
|
||||
session_status_click_to_change: 'Click to cycle status',
|
||||
kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.',
|
||||
kanban_comments_count: 'Comments ({0})',
|
||||
kanban_events_count: 'Events ({0})',
|
||||
|
|
@ -8308,10 +8284,6 @@ const LOCALES = {
|
|||
kanban_status_running: '进行中',
|
||||
kanban_status_blocked: '阻塞',
|
||||
kanban_status_done: '完成',
|
||||
session_status_todo: 'Todo',
|
||||
session_status_in_progress: 'In Progress',
|
||||
session_status_done: 'Done',
|
||||
session_status_click_to_change: 'Click to cycle status',
|
||||
kanban_status_original_hint: '实际状态:{0}。此对话框仅支持编辑 Triage/Todo/Ready。',
|
||||
kanban_comments_count: '评论 ({0})',
|
||||
kanban_events_count: '事件 ({0})',
|
||||
|
|
@ -9779,10 +9751,6 @@ const LOCALES = {
|
|||
kanban_status_running: '執行中',
|
||||
kanban_status_blocked: '已封鎖',
|
||||
kanban_status_done: '完成',
|
||||
session_status_todo: 'Todo',
|
||||
session_status_in_progress: 'In Progress',
|
||||
session_status_done: 'Done',
|
||||
session_status_click_to_change: 'Click to cycle status',
|
||||
kanban_comments_count: '留言 ({0})',
|
||||
kanban_events_count: '事件 ({0})',
|
||||
kanban_links: '連結',
|
||||
|
|
@ -10999,10 +10967,6 @@ const LOCALES = {
|
|||
kanban_status_running: 'Running',
|
||||
kanban_status_blocked: 'Blocked',
|
||||
kanban_status_done: 'Done',
|
||||
session_status_todo: 'Todo',
|
||||
session_status_in_progress: 'In Progress',
|
||||
session_status_done: 'Done',
|
||||
session_status_click_to_change: 'Click to cycle status',
|
||||
kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.',
|
||||
kanban_comments_count: 'Comments ({0})',
|
||||
kanban_events_count: 'Events ({0})',
|
||||
|
|
@ -12235,10 +12199,6 @@ const LOCALES = {
|
|||
kanban_status_running: 'Running',
|
||||
kanban_status_blocked: 'Blocked',
|
||||
kanban_status_done: 'Done',
|
||||
session_status_todo: 'Todo',
|
||||
session_status_in_progress: 'In Progress',
|
||||
session_status_done: 'Done',
|
||||
session_status_click_to_change: 'Click to cycle status',
|
||||
kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.',
|
||||
kanban_comments_count: 'Comments ({0})',
|
||||
kanban_events_count: 'Events ({0})',
|
||||
|
|
@ -13495,10 +13455,6 @@ const LOCALES = {
|
|||
kanban_status_running: 'En cours d\'exécution',
|
||||
kanban_status_blocked: 'Bloqué',
|
||||
kanban_status_done: 'Fait',
|
||||
session_status_todo: 'Todo',
|
||||
session_status_in_progress: 'In Progress',
|
||||
session_status_done: 'Done',
|
||||
session_status_click_to_change: 'Click to cycle status',
|
||||
kanban_comments_count: 'Commentaires ({0})',
|
||||
kanban_events_count: 'Événements ({0})',
|
||||
kanban_links: 'Links',
|
||||
|
|
@ -14843,10 +14799,6 @@ const LOCALES = {
|
|||
kanban_status_running: 'Koşma',
|
||||
kanban_status_blocked: 'Engellendi',
|
||||
kanban_status_done: 'Tamamlamak',
|
||||
session_status_todo: 'Todo',
|
||||
session_status_in_progress: 'In Progress',
|
||||
session_status_done: 'Done',
|
||||
session_status_click_to_change: 'Click to cycle status',
|
||||
kanban_status_original_hint: 'Gerçek durum: {0}. Bu iletişim kutusu yalnızca Triyaj/Yapılacak/Hazır düzenlemelerini destekler.',
|
||||
kanban_comments_count: 'Yorumlar ({0})',
|
||||
kanban_events_count: 'Etkinlikler ({0})',
|
||||
|
|
|
|||
|
|
@ -655,6 +655,18 @@ async function send(){
|
|||
let streamId;
|
||||
try{
|
||||
const _modelState=_chatPayloadModelState();
|
||||
const _pendingPick=(typeof _readPendingSessionModel==='function')
|
||||
? _readPendingSessionModel(activeSid)
|
||||
: null;
|
||||
const _explicitPick=_pendingPick
|
||||
&& _pendingPick.model===_modelState.model
|
||||
&& String(_pendingPick.model_provider||'')===String(_modelState.model_provider||'');
|
||||
// Consume the pending explicit-pick marker for THIS send only. The marker is
|
||||
// recorded on modelSelect.onchange and intentionally kept (not cleared on
|
||||
// session-update) so it survives the normal pick→update→send flow; clear it here
|
||||
// once read so a later send of an unchanged dropdown isn't treated as an explicit
|
||||
// pick. (#3739/#3737, Codex catch)
|
||||
if(_explicitPick && typeof _clearPendingSessionModel==='function') _clearPendingSessionModel(activeSid);
|
||||
const startData=await api('/api/chat/start',{method:'POST',body:JSON.stringify({
|
||||
session_id:activeSid,message:msgText,
|
||||
// S.session.model remains authoritative; the helper only resolves a
|
||||
|
|
@ -662,12 +674,17 @@ async function send(){
|
|||
model:_modelState.model,workspace:S.session.workspace,
|
||||
model_provider:_modelState.model_provider,
|
||||
profile:S.activeProfile||S.session.profile||'default',
|
||||
explicit_model_pick:_explicitPick||undefined,
|
||||
attachments:uploaded.length?uploaded:undefined
|
||||
})});
|
||||
|
||||
if(startData.title) applySessionTitleUpdate(activeSid, startData.title, {provisionalText:displayText.slice(0,64), rememberProvisional:true});
|
||||
|
||||
if(startData.effective_model && S.session){
|
||||
const _sentModel=_modelState.model;
|
||||
if(_explicitPick && _sentModel && startData.effective_model!==_sentModel && typeof showToast==='function'){
|
||||
showToast('Model '+_sentModel+' changed to '+startData.effective_model+' — profile provider mismatch', 5000);
|
||||
}
|
||||
S.session.model=startData.effective_model;
|
||||
S.session.model_provider=startData.effective_model_provider||S.session.model_provider||null;
|
||||
localStorage.setItem('hermes-webui-model', startData.effective_model);
|
||||
|
|
|
|||
|
|
@ -170,7 +170,6 @@ function _clearComposerDraft(sid) {
|
|||
const SESSION_VIEWED_COUNTS_KEY = 'hermes-session-viewed-counts';
|
||||
const SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread';
|
||||
const SESSION_OBSERVED_STREAMING_KEY = 'hermes-session-observed-streaming';
|
||||
const SESSION_MANUAL_STATUS_KEY = 'hermes-session-manual-status';
|
||||
let _sessionViewedCounts = null;
|
||||
let _sessionCompletionUnread = null;
|
||||
let _sessionObservedStreaming = null;
|
||||
|
|
@ -354,43 +353,6 @@ function _isServerIdleSessionRow(s) {
|
|||
return Boolean(s && s.session_id && !s.is_streaming && !s.active_stream_id && !s.pending_user_message);
|
||||
}
|
||||
|
||||
// ── Manual session status (Todo / In Progress / Done) ─────────────────────
|
||||
const _SESSION_STATUS_VALUES = ['todo', 'in-progress', 'done'];
|
||||
|
||||
function _getSessionManualStatuses() {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(SESSION_MANUAL_STATUS_KEY) || '{}');
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch (_e) { return {}; }
|
||||
}
|
||||
|
||||
function getSessionManualStatus(sid) {
|
||||
if (!sid) return null;
|
||||
const val = _getSessionManualStatuses()[sid];
|
||||
return _SESSION_STATUS_VALUES.includes(val) ? val : null;
|
||||
}
|
||||
|
||||
function setSessionManualStatus(sid, status) {
|
||||
if (!sid) return;
|
||||
const map = _getSessionManualStatuses();
|
||||
if (status && _SESSION_STATUS_VALUES.includes(status)) {
|
||||
map[sid] = status;
|
||||
} else {
|
||||
delete map[sid];
|
||||
}
|
||||
try { localStorage.setItem(SESSION_MANUAL_STATUS_KEY, JSON.stringify(map)); } catch (_e) {}
|
||||
renderSessionListFromCache();
|
||||
}
|
||||
|
||||
function _cycleSessionManualStatus(session) {
|
||||
const current = getSessionManualStatus(session.session_id);
|
||||
const idx = _SESSION_STATUS_VALUES.indexOf(current);
|
||||
const next = idx === -1 ? _SESSION_STATUS_VALUES[0]
|
||||
: idx === _SESSION_STATUS_VALUES.length - 1 ? null
|
||||
: _SESSION_STATUS_VALUES[idx + 1];
|
||||
setSessionManualStatus(session.session_id, next);
|
||||
}
|
||||
|
||||
function _reconcileActiveSessionIdleStateFromList(serverRows) {
|
||||
if (!S || !S.session || !S.session.session_id) return false;
|
||||
if (typeof _sendInProgress !== 'undefined' && _sendInProgress) return false;
|
||||
|
|
@ -3028,22 +2990,6 @@ function _openSessionActionMenu(session, anchorEl){
|
|||
}
|
||||
));
|
||||
}
|
||||
// Manual status picker (before danger actions)
|
||||
if (!isExternalSession) {
|
||||
const currentStatus = getSessionManualStatus(session.session_id);
|
||||
for (const status of _SESSION_STATUS_VALUES) {
|
||||
menu.appendChild(_buildSessionAction(
|
||||
t('session_status_' + status.replace(/-/g,'_')) || status,
|
||||
'',
|
||||
'',
|
||||
() => {
|
||||
closeSessionActionMenu();
|
||||
setSessionManualStatus(session.session_id, currentStatus === status ? null : status);
|
||||
},
|
||||
currentStatus === status ? 'is-active' : ''
|
||||
));
|
||||
}
|
||||
}
|
||||
if(!isExternalSession){
|
||||
if(session.worktree_path){
|
||||
menu.appendChild(_buildSessionAction(
|
||||
|
|
@ -5026,15 +4972,6 @@ function renderSessionListFromCache(){
|
|||
titleRow.appendChild(dot);
|
||||
}
|
||||
}
|
||||
const manualStatus = getSessionManualStatus(s.session_id);
|
||||
if (manualStatus) {
|
||||
const statusBadge = document.createElement('span');
|
||||
statusBadge.className = 'session-manual-status session-manual-status--' + manualStatus;
|
||||
statusBadge.textContent = t('session_status_' + manualStatus.replace(/-/g,'_')) || manualStatus;
|
||||
statusBadge.title = t('session_status_click_to_change') || 'Click to change status';
|
||||
statusBadge.onclick = (e) => { e.stopPropagation(); _cycleSessionManualStatus(s); };
|
||||
titleRow.appendChild(statusBadge);
|
||||
}
|
||||
const density=(window._sidebarDensity==='detailed'?'detailed':'compact');
|
||||
const showLineageMetadata=density==='detailed';
|
||||
const lineageKey=_sidebarLineageKeyForRow(s);
|
||||
|
|
|
|||
|
|
@ -4109,13 +4109,6 @@ main.main > #mainPlugin{display:none;}
|
|||
the row's gap:6px handles spacing, no margin/vertical-align needed. */
|
||||
.session-project-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;display:inline-block;}
|
||||
|
||||
/* Manual session status badge */
|
||||
.session-manual-status{display:inline-flex;align-items:center;padding:1px 6px;border-radius:999px;font-size:9px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;flex-shrink:0;cursor:pointer;line-height:1.6;border:1px solid transparent;transition:opacity .12s;}
|
||||
.session-manual-status:hover{opacity:.75;}
|
||||
.session-manual-status--todo{background:rgba(99,179,237,.18);border-color:rgba(99,179,237,.35);color:#63b3ed;}
|
||||
.session-manual-status--in-progress{background:rgba(246,173,85,.18);border-color:rgba(246,173,85,.35);color:#f6ad55;}
|
||||
.session-manual-status--done{background:rgba(72,187,120,.18);border-color:rgba(72,187,120,.35);color:#48bb78;}
|
||||
|
||||
/* ── Code copy button ── */
|
||||
.code-copy-btn{background:var(--hover-bg);border:1px solid var(--border2);border-radius:4px;color:var(--muted);font-size:11px;cursor:pointer;padding:2px 6px;transition:all .15s;line-height:1.3;}
|
||||
.code-copy-btn:hover{background:rgba(255,255,255,.12);color:var(--text);}
|
||||
|
|
|
|||
75
tests/test_issue3737_explicit_pick_client_wiring.py
Normal file
75
tests/test_issue3737_explicit_pick_client_wiring.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Regression guard for the #3737 explicit-pick CLIENT clear-timing fix (PR #3739 + Codex catch).
|
||||
|
||||
The server-side resolver coverage lives in test_provider_mismatch.py
|
||||
(test_explicit_pick_survives_profile_family_mismatch / _false_allows_normalization).
|
||||
|
||||
This file locks the CLIENT-side wiring that makes the flag actually engage in the
|
||||
normal flow. Codex found that boot.js modelSelect.onchange cleared the pending
|
||||
explicit-pick marker right after /api/session/update, so by the time send() ran the
|
||||
marker was gone, _explicitPick was false, and the server reverted the cross-family
|
||||
pick anyway — the flag only worked in a rare race. The fix:
|
||||
|
||||
* onchange RECORDS the pick (_rememberPendingSessionModel) and must NOT clear it;
|
||||
* send() consumes it (reads, then _clearPendingSessionModel) for that send only.
|
||||
|
||||
These are static source-structure assertions (the flow is DOM/network-driven and
|
||||
exercised live); they keep the clear-timing from silently regressing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _model_onchange_region() -> str:
|
||||
"""The modelSelect.onchange handler body where the pending pick is recorded and
|
||||
the /api/session/update round-trip happens."""
|
||||
idx = BOOT_JS.find("_rememberPendingSessionModel")
|
||||
assert idx != -1, "_rememberPendingSessionModel call not found in boot.js"
|
||||
# Window from the remember call through the session-update POST + its aftermath.
|
||||
return BOOT_JS[idx: idx + 1200]
|
||||
|
||||
|
||||
def test_onchange_records_pending_pick():
|
||||
region = _model_onchange_region()
|
||||
assert "_rememberPendingSessionModel(" in region, (
|
||||
"modelSelect.onchange must record the explicit pick so send() can detect it"
|
||||
)
|
||||
|
||||
|
||||
def test_onchange_does_not_clear_pending_pick_after_session_update():
|
||||
"""The premature clear (the #3737 bug) must be gone: onchange must NOT call
|
||||
_clearPendingSessionModel after the /api/session/update POST."""
|
||||
region = _model_onchange_region()
|
||||
assert "_clearPendingSessionModel" not in region, (
|
||||
"modelSelect.onchange must NOT clear the pending explicit-pick marker — it has "
|
||||
"to survive until send() consumes it, else the normal pick→update→send flow "
|
||||
"loses the explicit-pick signal and the server re-reverts the pick (#3737)"
|
||||
)
|
||||
|
||||
|
||||
def test_send_consumes_pending_pick_after_reading_it():
|
||||
"""send() must clear the marker once it has read a matching pending pick, so a
|
||||
later send of an unchanged dropdown is not treated as a fresh explicit pick."""
|
||||
idx = MESSAGES_JS.find("_explicitPick")
|
||||
assert idx != -1, "_explicitPick not computed in send()"
|
||||
region = MESSAGES_JS[idx: idx + 1100]
|
||||
assert "_clearPendingSessionModel(activeSid)" in region, (
|
||||
"send() must consume (clear) the pending explicit-pick marker after reading it"
|
||||
)
|
||||
# The clear must be gated on _explicitPick (only consume a genuine, matching pick).
|
||||
assert re.search(r"_explicitPick\s*&&[^\n]*_clearPendingSessionModel", region), (
|
||||
"the consume-clear must be gated on _explicitPick"
|
||||
)
|
||||
|
||||
|
||||
def test_send_sends_flag_only_when_explicit():
|
||||
idx = MESSAGES_JS.find("_explicitPick")
|
||||
region = MESSAGES_JS[idx: idx + 1300]
|
||||
assert "explicit_model_pick:_explicitPick||undefined" in region.replace(" ", ""), (
|
||||
"the chat/start payload must send explicit_model_pick only when truthy"
|
||||
)
|
||||
|
|
@ -30,7 +30,14 @@ def test_model_selection_records_pending_state_before_async_session_update():
|
|||
|
||||
assert pending_idx < update_idx
|
||||
assert local_model_idx < update_idx
|
||||
assert "_clearPendingSessionModel" in body
|
||||
# onchange must NOT clear the pending marker after the session-update round-trip.
|
||||
# The marker has to survive until the next send() consumes it, otherwise the normal
|
||||
# pick→update→send flow loses the explicit-pick signal and the server re-reverts a
|
||||
# cross-family pick (#3737). The consume-clear now lives in send(), not here.
|
||||
assert "_clearPendingSessionModel" not in body, (
|
||||
"modelSelect.onchange must not clear the pending explicit-pick marker (#3737); "
|
||||
"send() consumes it instead"
|
||||
)
|
||||
|
||||
|
||||
def test_load_session_applies_pending_model_before_first_topbar_sync():
|
||||
|
|
|
|||
|
|
@ -1438,6 +1438,43 @@ def test_custom_namespace_model_always_preserved_on_custom_provider(monkeypatch)
|
|||
assert effective == "custom/my-local-llm"
|
||||
|
||||
|
||||
def test_explicit_pick_survives_profile_family_mismatch():
|
||||
"""When explicit_model_pick=True, a cross-family bare model survives
|
||||
the profile-aware normalization instead of being rewritten to the
|
||||
profile default (#3737)."""
|
||||
import api.routes as routes
|
||||
|
||||
effective, provider, changed = routes._resolve_compatible_session_model_state(
|
||||
"gpt-5.4-mini",
|
||||
None,
|
||||
profile_provider="anthropic",
|
||||
profile_default_model="claude-sonnet-4",
|
||||
explicit_model_pick=True,
|
||||
)
|
||||
|
||||
assert changed is False, "explicit pick must not be normalized"
|
||||
assert effective == "gpt-5.4-mini", "user's model must survive"
|
||||
assert provider == "anthropic", "profile provider context preserved"
|
||||
|
||||
|
||||
def test_explicit_pick_false_allows_profile_family_normalization():
|
||||
"""Without explicit_model_pick, the same cross-family model IS rewritten
|
||||
to the profile default (existing behavior, must not regress)."""
|
||||
import api.routes as routes
|
||||
|
||||
effective, provider, changed = routes._resolve_compatible_session_model_state(
|
||||
"gpt-5.4-mini",
|
||||
None,
|
||||
profile_provider="anthropic",
|
||||
profile_default_model="claude-sonnet-4",
|
||||
explicit_model_pick=False,
|
||||
)
|
||||
|
||||
assert changed is True, "stale model must be normalized"
|
||||
assert effective == "claude-sonnet-4", "rewritten to profile default"
|
||||
assert provider == "anthropic", "profile provider context preserved"
|
||||
|
||||
|
||||
def test_stale_ui_js_does_not_inject_unavailable_option():
|
||||
"""renderSession() must no longer inject a bare (unavailable) option into
|
||||
modelSelect when the session model is not in the provider list (#829).
|
||||
|
|
|
|||
Loading…
Reference in a new issue