From 2d430c290971122f789d027c63e3483bb31daf2e Mon Sep 17 00:00:00 2001 From: witbrock Date: Sat, 12 Sep 2026 20:44:52 -0400 Subject: [PATCH] Preserve browser model choice across settings edits and reloads --- .../von_interface/static/js/settingsPage.js | 85 +++++++---- .../von_interface/templates/settings_tab.html | 6 +- tests/browser/settingsModelPreference.cjs | 141 ++++++++++++++++++ .../settingsOpenAiModelChange.test.js | 51 ++++++- 4 files changed, 244 insertions(+), 39 deletions(-) create mode 100644 tests/browser/settingsModelPreference.cjs diff --git a/src/frontend/web/von_interface/static/js/settingsPage.js b/src/frontend/web/von_interface/static/js/settingsPage.js index 9aa366691..2166857b0 100644 --- a/src/frontend/web/von_interface/static/js/settingsPage.js +++ b/src/frontend/web/von_interface/static/js/settingsPage.js @@ -548,12 +548,11 @@ function selectedPremiumModelName(provider = selectedPremiumProvider()) { function getSettingsConcernModelSnapshot() { const localModelPreference = getEffectiveLocalModelPreference(); - const premiumToggle = document.getElementById('enableOpenAiPremiumToggle'); - const premiumEnabled = premiumToggle - ? !!premiumToggle.checked - : ['openai', 'openrouter', 'gemini', 'meta'].includes(localModelPreference.activeSource); - const premiumProvider = selectedPremiumProvider(); - const premiumModel = selectedPremiumModelName(premiumProvider); + const premiumEnabled = Boolean(normalisePremiumProvider(localModelPreference.activeSource)); + const premiumProvider = premiumEnabled ? localModelPreference.activeSource : selectedPremiumProvider(); + const premiumModel = premiumEnabled + ? localModelPreference.requestedLlm?.model + : selectedPremiumModelName(premiumProvider); const ollamaSelection = resolveOllamaSelection(false) || localModelPreference.ollamaSelection || null; const ollamaModel = String(ollamaSelection?.model || ollamaSelection?.value || '').trim(); @@ -637,6 +636,18 @@ function buildTemporarySettingsConcernRecommendation(concernId) { ollamaModel, } = getSettingsConcernModelSnapshot(); const providerLabel = PREMIUM_PROVIDER_CONFIG[premiumProvider]?.label || 'premium'; + const browserPrimary = browserChatPrimaryEntry(); + if (scopedModelStateReady && browserPrimary && !isPersistedEffectiveModelAllowed(browserPrimary)) { + return { + kicker: 'Browser model needs attention', + title: 'Choose an allowed browser model or restore its allowance', + description: `${formatLlmEntry(browserPrimary)} is no longer allowed for the current scope. Your browser choice is retained; choose a replacement explicitly or restore its scoped allowance.`, + actionLabel: 'Review browser model', + actionTarget: 'premium-model-settings', + focusSelector: '#premiumProviderSelect, #globalModelSelect', + source: 'temporary-placeholder', + }; + } if (premiumEnabled && !premiumModel) { return { @@ -1310,6 +1321,9 @@ function getStoredPremiumModelParameters(provider) { } function setStoredPremiumModelParameters(provider, parameters) { + // An uncommitted model in the editor must not change the active model's parameters. + if (getEffectiveLocalModelPreference().activeSource === provider + && selectedPremiumModelName(provider) !== getStoredPremiumSelectedModel(provider)) return; if (provider === 'gemini') { setStoredGeminiModelParameters(parameters); } else if (provider === 'openrouter') { @@ -1403,6 +1417,8 @@ async function refreshPremiumReasoningEffortControls(provider = selectedPremiumP { cache: 'no-store' }, ); const capability = await response.json(); + if (selectedPremiumModelName(provider) !== selectedModel + || selectedPremiumProvider() !== provider) return null; if (response.ok && capability?.success !== false) { setLatestPremiumModelParameterCapability(provider, capability); applyPremiumReasoningEffortControls(provider, capability); @@ -1411,6 +1427,8 @@ async function refreshPremiumReasoningEffortControls(provider = selectedPremiumP } catch (error) { console.warn(`Failed to refresh ${config.label} model parameter controls`, error); } + if (selectedPremiumModelName(provider) !== selectedModel + || selectedPremiumProvider() !== provider) return null; setLatestPremiumModelParameterCapability(provider, null); applyPremiumReasoningEffortControls( provider, @@ -1470,8 +1488,10 @@ function updatePremiumModelStatusMessageForProvider(provider) { if (!allowed) { setInlineStatusMessage( statusEl, - `Testing and browser use remain unavailable until ${config.label} ${selectedModel} is allowed for this scope and saved.`, - 'error', + scopedModelStateReady + ? `Testing and browser use remain unavailable until ${config.label} ${selectedModel} is allowed for this scope and saved. The browser choice is retained; select an allowed replacement explicitly if needed.` + : 'Checking persisted allowed models. The browser choice is retained.', + scopedModelStateReady ? 'error' : null, ); return; } @@ -1498,7 +1518,7 @@ function updatePremiumModelStatusMessageForProvider(provider) { : `${config.label} ${selectedModel} failed its last test.`; setInlineStatusMessage( statusEl, - probe.reason ? `${failurePrefix} ${probe.reason}` : failurePrefix, + `${probe.reason ? `${failurePrefix} ${probe.reason}` : failurePrefix} Restore provider/model availability or select an allowed replacement explicitly.`, 'error', ); return; @@ -1604,7 +1624,12 @@ async function handlePremiumModelSelectionChange(provider) { const model = normaliseLocalModelName( document.getElementById(config?.modelSelectId)?.value, ) || ''; - setStoredPremiumSelectedModel(provider, model); + // Keep an ineligible draft in the form. Only an allowed selection may replace + // the active browser model; editing must never select an Ollama fallback. + if (getEffectiveLocalModelPreference().activeSource !== provider + || isPersistedEffectiveModelAllowed({ provider, model })) { + setStoredPremiumSelectedModel(provider, model); + } invalidateSelectedOpenAiCostSummary(); if (selectedPremiumProvider() === provider) { await refreshPremiumReasoningEffortControls(provider); @@ -1621,16 +1646,12 @@ async function handlePremiumProviderSelectionChange(value) { const provider = normalisePremiumProvider(value) || selectedPremiumProvider(); const select = document.getElementById('premiumProviderSelect'); if (select) select.value = provider; - setStoredPremiumModelProvider(provider); + const wasPremiumActive = Boolean(normalisePremiumProvider(getEffectiveLocalModelPreference().activeSource)); + const allowed = isPersistedEffectiveModelAllowed(selectedPremiumPoolEntry(provider)); + if (!wasPremiumActive || allowed) setStoredPremiumModelProvider(provider); syncPremiumProviderPanels(); await refreshPremiumReasoningEffortControls(provider); - const toggle = document.getElementById('enableOpenAiPremiumToggle'); - if (toggle?.checked) { - const allowed = isPersistedEffectiveModelAllowed(selectedPremiumPoolEntry(provider)); - toggle.checked = allowed; - setLocalPremiumModelUseEnabled(allowed, provider); - } updatePremiumModelStatusMessages(); updateOllamaModelStatusMessage(); invalidateSelectedOpenAiCostSummary(); @@ -1670,8 +1691,6 @@ async function testSelectedPremiumModel(provider) { }; } - setStoredPremiumSelectedModel(provider, selectedModel); - setStoredPremiumModelParameters(provider, modelParameters); setInlineStatusMessage(statusEl, `Testing ${config.label} ${selectedModel}...`, null); try { @@ -2278,14 +2297,10 @@ function updateSelectedPremiumEligibilityControls() { } } if (toggleEl) { + toggleEl.checked = sameLlmSlot(browserChatPrimaryEntry(), selected); toggleEl.disabled = !selectedAllowed; toggleEl.setAttribute('aria-disabled', String(!selectedAllowed)); } - if (!selectedAllowed && toggleEl?.checked) { - toggleEl.checked = false; - setLocalPremiumModelUseEnabled(false); - notifyLocalModelPreferenceChanged(); - } return selectedAllowed; } @@ -2524,9 +2539,14 @@ function renderWorkflowModelPool() { function renderModelScopeOverview() { const browserPrimary = browserChatPrimaryEntry(); + const browserStatus = !scopedModelStateReady + ? 'checking allowed models; browser preference retained' + : !isPersistedEffectiveModelAllowed(browserPrimary) + ? 'not allowed for the current scope; select an allowed replacement explicitly or restore its scoped allowance' + : 'browser preference'; setModelScopeSummaryText( 'browserChatModelSummary', - browserPrimary ? `${formatLlmEntry(browserPrimary)} (browser preference)` : 'No usable browser chat model selected', + browserPrimary ? `${formatLlmEntry(browserPrimary)} (${browserStatus})` : 'No usable browser chat model selected', ); setModelScopeSummaryText( 'effectiveScopedModelSummary', @@ -5131,7 +5151,6 @@ document.addEventListener('DOMContentLoaded', async () => { const provider = selectedPremiumProvider(); if (!isPersistedEffectiveModelAllowed(selectedPremiumPoolEntry(provider))) { premiumToggle.checked = false; - setLocalPremiumModelUseEnabled(false); updatePremiumModelStatusMessages(); refreshActiveSettingsConcernGuidance(); notifyLocalModelPreferenceChanged(); @@ -5139,6 +5158,8 @@ document.addEventListener('DOMContentLoaded', async () => { return; } + setStoredPremiumSelectedModel(provider, selectedPremiumModelName(provider)); + setStoredPremiumModelParameters(provider, readPremiumModelParametersFromUi(provider)); setLocalPremiumModelUseEnabled(true, provider); updatePremiumModelStatusMessages(); refreshActiveSettingsConcernGuidance(); @@ -5570,8 +5591,8 @@ async function loadAndDisplaySettings() { syncGmailOutboundRateLimitWriteAccess(); } - // The selector should reflect the resolved active model for the provider in scope, - // not a stale enabled_llms entry from an older or broader context. + // Restore the browser choice independently of the server primary. Eligibility + // is checked against the current effective scope after loading. const { effectiveLlm: displayedEffectiveLlm, currentOpenAIModel, @@ -5587,10 +5608,10 @@ async function loadAndDisplaySettings() { currentServerDefaultLlm = buildCanonicalLlmEntry(settings.server_default_llm); const localModelPreference = getEffectiveLocalModelPreference(); const currentOllamaModel = resolveOllamaDropdownSelectionValue(localModelPreference); - const preferredOpenAiModel = currentOpenAIModel || localModelPreference.openaiModel || null; - const preferredOpenRouterModel = currentOpenRouterModel || localModelPreference.openrouterModel || null; - const preferredGeminiModel = currentGeminiModel || localModelPreference.geminiModel || null; - const preferredMetaModel = currentMetaModel || localModelPreference.metaModel || null; + const preferredOpenAiModel = localModelPreference.openaiModel || currentOpenAIModel || null; + const preferredOpenRouterModel = localModelPreference.openrouterModel || currentOpenRouterModel || null; + const preferredGeminiModel = localModelPreference.geminiModel || currentGeminiModel || null; + const preferredMetaModel = localModelPreference.metaModel || currentMetaModel || null; const premiumEnabled = ['openai', 'openrouter', 'gemini', 'meta'].includes(localModelPreference.activeSource); const preferredPremiumProvider = normalisePremiumProvider( localModelPreference.premiumProvider || localModelPreference.activeSource, diff --git a/src/frontend/web/von_interface/templates/settings_tab.html b/src/frontend/web/von_interface/templates/settings_tab.html index 8e50acafc..f8f149753 100644 --- a/src/frontend/web/von_interface/templates/settings_tab.html +++ b/src/frontend/web/von_interface/templates/settings_tab.html @@ -340,8 +340,10 @@

Premium Models

title="When enabled, this browser requests the selected premium provider and model for chat, provided the exact pair is allowed for the selected scope. When disabled, it requests the selected Ollama model.">Use selected allowed premium model for chat in this browser - First allow the exact provider and model for this scope and save. Turn this off - to use the selected Ollama model for browser chat. Editing or testing a model does not allow it. + Already allowed models need no additional save to use in this browser. + Selecting another allowed model on the active provider updates the browser choice. An unallowed draft + leaves that choice unchanged. Turn this off to explicitly use the selected Ollama model. + Editing or testing a model does not add it to the scoped allowed models.
diff --git a/tests/browser/settingsModelPreference.cjs b/tests/browser/settingsModelPreference.cjs new file mode 100644 index 000000000..e9cede437 --- /dev/null +++ b/tests/browser/settingsModelPreference.cjs @@ -0,0 +1,141 @@ +// Actual Settings template/modules with synthetic API state; no live service or model calls. +// PLAYWRIGHT_BROWSERS_PATH=/tmp/von-playwright node tests/browser/settingsModelPreference.cjs EVIDENCE_DIR +const fs = require('node:fs'); +const path = require('node:path'); +const http = require('node:http'); +const crypto = require('node:crypto'); +const assert = require('node:assert/strict'); +const { chromium } = require('playwright'); +const root = path.resolve(__dirname, '../../src/frontend/web/von_interface'); +const evidence = process.argv[2] || '/tmp/settings-model-preference'; +const html = fs.readFileSync(path.join(root, 'templates/settings_tab.html'), 'utf8') + .replace(/\{\{ url_for\('static', filename='([^']+)'\) \}\}/g, '/static/$1'); +const luna = { provider: 'openai', model: 'gpt-5.6-luna' }; +const astra = { provider: 'openai', model: 'gpt-6-astra' }; +const ollama = { provider: 'ollama', model: 'gemma4:latest', host: 'http://127.0.0.1:11434' }; +let state = { openai_api_key_env_var: 'OPENAI_API_KEY', enabled_llms: [luna, astra, ollama], + resolved_llm: { ...luna, scope: 'user' }, active_llm: luna }; +const writes = []; +let probeUsable = true; +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + if (url.pathname === '/') { res.setHeader('Content-Type', 'text/html'); res.end(html); return; } + if (url.pathname.startsWith('/static/')) { + const file = path.resolve(root, '.' + url.pathname); + if (!file.startsWith(path.join(root, 'static') + path.sep) || !fs.existsSync(file)) { + res.writeHead(404); res.end(); return; + } + res.setHeader('Content-Type', file.endsWith('.js') ? 'text/javascript' : 'text/css'); + res.end(fs.readFileSync(file)); return; + } + let body = ''; + for await (const chunk of req) body += chunk; + const payload = body ? JSON.parse(body) : {}; + let result = {}; + const target = url.pathname; + if (target === '/api/settings/') { + if (req.method === 'POST') { + writes.push(payload); + state = { ...state, ...payload, resolved_llm: { ...(payload.active_llm || state.resolved_llm), scope: 'user' } }; + } + result = { ...state, success: true }; + } else if (target === '/von/api/auth/status') { + result = { authenticated: true, user_concept_id: '#V#model-fixture-user', name: 'Model Fixture', email: 'fixture@example.invalid' }; + } else if (target === '/von/api/session/context' || target === '/von/api/session/set_organisation') { + result = { status: 'updated', role: 'user', organisation_id: null, namespace: '#V#model-fixture-user' }; + } else if (target.includes('/model_parameters/capabilities')) { + result = { success: true, parameters: { reasoning_effort: { supported: true, allowed_values: ['low', 'medium', 'high'] } } }; + } else if (target === '/api/settings/models/openai') { + result = ['gpt-5.6-luna', 'gpt-6-astra', 'unallowed-draft']; + } else if (target === '/api/settings/ollama/models') { + result = { models: ['gemma4:latest'] }; + } else if (target === '/api/settings/ollama/hosts') { + result = { hosts: [], active_host: null }; + } else if (target === '/api/settings/organisations') { + result = []; + } else if (target === '/api/settings/env_var/check') { + result = { exists: false }; + } else if (target === '/api/settings/openai/test_model') { + result = { usable: probeUsable, model: payload.model, reason: probeUsable ? 'Synthetic probe succeeded' : 'Provider disabled or model unavailable (fixture)' }; + } + res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(result)); +}); +(async () => { + fs.mkdirSync(evidence, { recursive: true }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const browser = await chromium.launch({ headless: true }); + const results = []; + try { + const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } }); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.addInitScript(() => { + if (localStorage.getItem('fixture-initialised')) return; + localStorage.setItem('fixture-initialised', 'true'); + localStorage.setItem('von_current_user', JSON.stringify({ concept_id: '#V#model-fixture-user', name: 'Model Fixture' })); + localStorage.setItem('von:localModelPreference', JSON.stringify({ schemaVersion: 'localModelPreference.v1', activeSource: 'openai', openaiModel: 'gpt-6-astra', + ollamaSelection: { value: 'http://127.0.0.1:11434:gemma4:latest', ...{ model: 'gemma4:latest', host: 'http://127.0.0.1:11434' } } })); + }); + const preference = () => page.evaluate(() => JSON.parse(localStorage.getItem('von:localModelPreference'))); + const ready = async () => { + await page.waitForFunction(() => !document.getElementById('saveModelPoolButton')?.disabled); + await page.locator('[data-settings-concern-tab="models"]').click(); + await page.waitForFunction(() => document.getElementById('openaiModelSelect')?.value === JSON.parse(localStorage.getItem('von:localModelPreference')).openaiModel); + }; + const check = async (name, model, source = 'openai') => { + const stored = await preference(); + assert.equal(stored.activeSource, source, name); + assert.equal(stored.openaiModel, model, name); + results.push({ name, model, source }); + }; + await page.goto(`http://127.0.0.1:${server.address().port}/`); + await ready(); + assert.equal(await page.locator('#enableOpenAiPremiumToggle').isChecked(), true); + await check('reload prefers browser Astra over server Luna', 'gpt-6-astra'); + await page.selectOption('#openaiModelSelect', 'gpt-5.6-luna'); + await page.waitForFunction(() => document.getElementById('enableOpenAiPremiumToggle').checked); + await check('select already allowed Luna', 'gpt-5.6-luna'); + assert.equal(writes.length, 0, 'selection must not write allow list'); + await page.selectOption('#openaiReasoningEffortSelect', 'high'); + await check('edit reasoning retains browser choice', 'gpt-5.6-luna'); + assert.equal(writes.length, 0); + await page.locator('#useBrowserModelAsScopedPrimaryButton').click(); + await page.locator('#saveModelPoolButton').click(); + await page.waitForFunction(() => document.getElementById('modelPoolStatusMessage').textContent.includes('saved')); + assert.equal(writes.length, 1, 'only explicit scoped save writes allowance'); + await check('explicit edit and scoped save retain browser choice', 'gpt-5.6-luna'); + await page.reload(); await ready(); + await check('reload retains model and parameters', 'gpt-5.6-luna'); + assert.equal((await preference()).openaiModelParameters.reasoning_effort, 'high'); + await page.locator('#enableOpenAiPremiumToggle').uncheck(); + await check('explicit toggle off selects Ollama', 'gpt-5.6-luna', 'ollama'); + await page.locator('#enableOpenAiPremiumToggle').check(); + await check('explicit toggle on restores Luna', 'gpt-5.6-luna'); + await page.selectOption('#openaiModelSelect', 'unallowed-draft'); + await page.waitForFunction(() => document.getElementById('enableOpenAiPremiumToggle').disabled); + await check('unallowed draft preserves active Luna', 'gpt-5.6-luna'); + assert.equal(await page.locator('#enableOpenAiPremiumToggle').isChecked(), false); + await page.selectOption('#openaiModelSelect', 'gpt-6-astra'); + await check('another allowed selection switches to Astra', 'gpt-6-astra'); + await page.reload(); await ready(); + await check('second allowed choice survives reload', 'gpt-6-astra'); + probeUsable = false; + await page.locator('#testOpenAiModelButton').click(); + await page.waitForFunction(() => document.getElementById('openaiModelStatusMessage').textContent.includes('Restore provider/model availability')); + await check('failed availability probe retains explicit choice', 'gpt-6-astra'); + state.enabled_llms = [luna, ollama]; + await page.reload(); await ready(); + await check('scope removal retains choice without fallback', 'gpt-6-astra'); + assert.match(await page.locator('#browserChatModelSummary').textContent(), /select an allowed replacement explicitly/); + assert.match(await page.locator('#settingsConcernSummary').textContent(), /Choose an allowed browser model or restore its allowance/); + assert.equal(await page.locator('#enableOpenAiPremiumToggle').isDisabled(), true); + await page.screenshot({ animations: 'disabled', path: path.join(evidence, 'invalidated-choice.png') }); + await page.selectOption('#openaiModelSelect', 'gpt-5.6-luna'); + await check('explicit replacement recovers', 'gpt-5.6-luna'); + assert.equal(writes.length, 1); + await page.screenshot({ animations: 'disabled', path: path.join(evidence, 'recovered-choice.png') }); + assert.deepEqual(errors, []); + fs.writeFileSync(path.join(evidence, 'results.json'), JSON.stringify({ fixture: 'actual candidate template/modules; synthetic API; no authentication or live writes', sourceSha256: crypto.createHash('sha256').update(fs.readFileSync(path.join(root, 'static/js/settingsPage.js'))).digest('hex'), results, writes }, null, 2)); + console.log(`${results.length} Settings browser fixture checks passed; ${evidence}`); + } finally { await browser.close(); server.close(); } +})().catch(error => { console.error(error); server.close(); process.exitCode = 1; }); diff --git a/tests/frontend/settingsOpenAiModelChange.test.js b/tests/frontend/settingsOpenAiModelChange.test.js index a490009df..65d771922 100644 --- a/tests/frontend/settingsOpenAiModelChange.test.js +++ b/tests/frontend/settingsOpenAiModelChange.test.js @@ -65,7 +65,7 @@ describe('settingsPage OpenAI model change handling', () => { `; }); - test('editing GPT reasoning while Gemma is active neither probes, saves, enrols, nor switches provider', async () => { + test.each(['ollama', 'openai'])('editing an allowed model while %s is active preserves browser choice without enrolment', async (activeSource) => { const { getJsonDetailed, postJson } = await import(apiServicePath); const savePayloads = []; const testPayloads = []; @@ -174,7 +174,7 @@ describe('settingsPage OpenAI model change handling', () => { localStorage.setItem('von:localModelPreference', JSON.stringify({ schemaVersion: 'localModelPreference.v1', - activeSource: 'ollama', + activeSource, openaiModel: 'gpt-5.6-luna', openaiModelParameters: { reasoning_effort: 'low' }, ollamaSelection: { @@ -211,7 +211,7 @@ describe('settingsPage OpenAI model change handling', () => { expect(localStorage.getItem('von:openaiSelectedModel')).toBe('gpt-5.6-luna'); expect(JSON.parse(localStorage.getItem('von:localModelPreference'))).toMatchObject({ - activeSource: 'ollama', + activeSource, openaiModel: 'gpt-5.6-luna', openaiModelParameters: { reasoning_effort: 'high' }, ollamaSelection: { @@ -219,8 +219,8 @@ describe('settingsPage OpenAI model change handling', () => { host: 'http://127.0.0.1:11434' } }); - expect(document.getElementById('enableOpenAiPremiumToggle').checked).toBe(false); - expect(document.getElementById('browserChatModelSummary').textContent).toContain('gemma4:latest'); + expect(document.getElementById('enableOpenAiPremiumToggle').checked).toBe(activeSource === 'openai'); + expect(document.getElementById('browserChatModelSummary').textContent).toContain(activeSource === 'openai' ? 'gpt-5.6-luna' : 'gemma4:latest'); expect(document.getElementById('workflowModelPoolList').textContent).toContain('low effort'); expect(document.getElementById('addOpenAiToWorkflowPoolButton').textContent).toBe( 'Update allowed model entry' @@ -238,5 +238,46 @@ describe('settingsPage OpenAI model change handling', () => { model_parameters: { reasoning_effort: 'high' } }); expect(savePayloads).toEqual([]); + + if (activeSource === 'openai') { + const settings = await import(settingsPagePath); + const before = localStorage.getItem('von:localModelPreference'); + settings.__testOnly_setModelScopeState({ ready: false }); + settings.__testOnly_renderModelScopeOverview(); + expect(localStorage.getItem('von:localModelPreference')).toBe(before); + expect(document.getElementById('browserChatModelSummary').textContent).toContain('checking allowed models'); + const enabledLlms = [ + { provider: 'openai', model: 'gpt-5.6-luna' }, + { provider: 'openai', model: 'gpt-6-astra' }, + ]; + settings.__testOnly_setModelScopeState({ enabledLlms }); + select.add(new Option('Unallowed draft', 'draft-model')); + select.value = 'draft-model'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await waitUntil(() => document.getElementById('openaiModelEligibilityStatus').textContent.includes('draft-model')); + expect(localStorage.getItem('von:localModelPreference')).toBe(before); + expect(document.getElementById('enableOpenAiPremiumToggle').checked).toBe(false); + expect(document.getElementById('browserChatModelSummary').textContent).toContain('gpt-5.6-luna'); + + // Saving an allowance makes the draft eligible, but testing it is + // still a probe, not an explicit browser replacement. + settings.__testOnly_setModelScopeState({ enabledLlms: [...enabledLlms, { provider: 'openai', model: 'draft-model' }] }); + await settings.__testOnly_testSelectedPremiumModel('openai'); + expect(localStorage.getItem('von:localModelPreference')).toBe(before); + + select.add(new Option('gpt-6-astra', 'gpt-6-astra')); + select.value = 'gpt-6-astra'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await waitUntil(() => document.getElementById('enableOpenAiPremiumToggle').checked); + expect(JSON.parse(localStorage.getItem('von:localModelPreference'))).toMatchObject({ activeSource: 'openai', openaiModel: 'gpt-6-astra' }); + expect(savePayloads).toEqual([]); + + // A canonical scope read removing the active model preserves the + // request and explains replacement; it must never select Ollama. + settings.__testOnly_setModelScopeState({ enabledLlms: [] }); + settings.__testOnly_renderModelScopeOverview(); + expect(document.getElementById('browserChatModelSummary').textContent).toContain('select an allowed replacement explicitly'); + expect(JSON.parse(localStorage.getItem('von:localModelPreference'))).toMatchObject({ activeSource: 'openai', openaiModel: 'gpt-6-astra' }); + } }); });