From 0669279fd75852b1aac14053edc15e4245c7db65 Mon Sep 17 00:00:00 2001 From: witbrock Date: Thu, 17 Sep 2026 12:58:54 -0400 Subject: [PATCH] Fix scoped turn model selection and registry reasoning capabilities --- src/backend/server/routes/settings_routes.py | 45 +++++++++++++ .../services/model_parameter_service.py | 28 ++++++-- .../web/von_interface/static/js/chatTab.js | 13 +++- .../static/js/components/turnModelPicker.js | 65 ++++++++++++++++--- .../web/von_interface/templates/chat_tab.html | 4 ++ tests/backend/test_model_parameter_service.py | 42 ++++++++++++ tests/backend/test_settings_enabled_models.py | 59 +++++++++++++++++ tests/browser/turnModelPicker.cjs | 63 ++++++++++++++++++ tests/frontend/chatTaskQueue.test.js | 16 +++-- tests/frontend/turnModelPicker.test.js | 52 +++++++++++++++ 10 files changed, 366 insertions(+), 21 deletions(-) create mode 100644 tests/backend/test_settings_enabled_models.py create mode 100644 tests/browser/turnModelPicker.cjs diff --git a/src/backend/server/routes/settings_routes.py b/src/backend/server/routes/settings_routes.py index 0c9f10646..46a999097 100644 --- a/src/backend/server/routes/settings_routes.py +++ b/src/backend/server/routes/settings_routes.py @@ -2255,6 +2255,51 @@ def get_meta_models(): ) +@settings_bp.route("/models/enabled/", methods=["GET"]) +def get_enabled_models(provider): + """Catalogue intersection for execution, scoped to the trusted request actor.""" + from ...languagemodels.llm_interface import _normalise_model_execution_key + + loaders = { + "openai": get_openai_models, + "openrouter": get_openrouter_models, + "gemini": get_gemini_models, + "meta": get_meta_models, + "ollama": get_ollama_models, + } + if provider not in loaders: + return _jsonify_no_store({"error": "Unsupported model provider."}, 400) + allowed = None + if provider != "ollama": + user = get_effective_user_concept_id() + org = get_effective_organisation_concept_id() + if not user and not org: + return _jsonify_no_store([], 200) + try: + entries = resolve_enabled_llm_settings( + user_concept_id=user, org_concept_id=org + ) + allowed = { + _normalise_model_execution_key(entry.get("provider"), entry.get("model")) + for entry in entries if isinstance(entry, Mapping) + } + except Exception: + return _jsonify_no_store({"error": "Scoped model eligibility unavailable."}, 503) + if not any(key[0] == provider for key in allowed): + return _jsonify_no_store([], 200) + response = current_app.make_response(loaders[provider]()) + if response.status_code != 200: + return response + models = response.get_json() + if not isinstance(models, list): + return _jsonify_no_store({"error": "Model catalogue unavailable."}, 503) + return _jsonify_no_store([ + model for model in models + if isinstance(model, str) and model.strip() + and (allowed is None or _normalise_model_execution_key(provider, model) in allowed) + ], 200) + + @settings_bp.route("/models/inventory/", methods=["GET"]) def get_model_inventory(provider): """Project discovery and transport metadata without enabling any model.""" diff --git a/src/backend/services/model_parameter_service.py b/src/backend/services/model_parameter_service.py index 01f2a9c0d..13bea807e 100644 --- a/src/backend/services/model_parameter_service.py +++ b/src/backend/services/model_parameter_service.py @@ -289,11 +289,21 @@ def build_model_parameter_capabilities( fixed_value = _clean_text(reasoning_policy.get("fixed_value")) source = str(reasoning_policy.get("source") or "model_registry") supported = action != "omit" - merged_allowed_values = ( - allowed_values - if allowed_values + merged_allowed_values = allowed_values or ( + [fixed_value] + if fixed_value else _normalise_string_list(reasoning_capability.get("allowed_values")) ) + if supported and not merged_allowed_values: + # Explicit registry support can introduce a model outside the + # static family list without losing the provider's value domain. + merged_allowed_values = list( + { + "openai": OPENAI_REASONING_EFFORT_VALUES, + "gemini": GEMINI_REASONING_EFFORT_VALUES, + "meta": META_MUSE_REASONING_EFFORT_VALUES, + }.get(provider_name, ()) + ) reasoning_capability = _parameter_capability( parameter_id=MODEL_PARAMETER_REASONING_EFFORT, supported=supported, @@ -305,12 +315,18 @@ def build_model_parameter_capabilities( *[ item for item in reasoning_capability.get("sources", []) - if isinstance(item, str) and item + if isinstance(item, str) and item and item != "unsupported" ], source, ], - provider_api_mapping=_extract_mapping( - reasoning_capability.get("provider_api_mapping") + # Registry-only models still use the provider's transport. + provider_api_mapping={ + "openai": _OPENAI_REASONING_EFFORT_API_MAPPINGS, + "gemini": _GEMINI_REASONING_EFFORT_API_MAPPINGS, + "meta": _META_MUSE_REASONING_EFFORT_API_MAPPINGS, + }.get( + provider_name, + _extract_mapping(reasoning_capability.get("provider_api_mapping")), ), registry_policy=reasoning_policy, ) diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index 0f98fe170..ec4e1bfc3 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -33546,6 +33546,7 @@ function detachChatWorkForOrganisationSwitch() { } function startOrganisationSwitchForChatTab(detail = {}) { + turnModelPicker?.invalidate(); resetConversationCatalogue(); stopDictation(); const requestedSwitchId = normaliseOrganisationSwitchId(detail); @@ -33742,6 +33743,7 @@ try { function handleAuthStatusChangeForChatTab(detail) { resetConversationCatalogue(); + turnModelPicker?.invalidate(); synchroniseLlmExecutionContext({ force: true, reason: 'authenticated_actor_changed' }); loadHiddenChatSessionIds(); loadPinnedChatSessionIds(); @@ -33828,8 +33830,16 @@ export function initializeChatTab() { select: turnModelSelect, status: document.getElementById('turnModelStatus'), clearButton: document.getElementById('clearTurnModelButton'), + reasoningSelect: document.getElementById('turnReasoningSelect'), + loadCapabilities: async (provider, model) => { + const api_surface = { gemini: 'interactions', openrouter: 'chat_completions' }[provider] || 'responses'; + const query = new URLSearchParams({ provider, model, api_surface }); + const response = await fetch(`/api/settings/model_parameters/capabilities?${query}`, { headers: buildChatFetchHeaders() }); + if (!response.ok) throw new Error('Reasoning options unavailable'); + return response.json(); + }, loadModels: async provider => { - const response = await fetch(`/api/settings/models/${provider}`, { headers: buildChatFetchHeaders() }); + const response = await fetch(`/api/settings/models/enabled/${provider}`, { headers: buildChatFetchHeaders() }); if (!response.ok) throw new Error('Model catalogue unavailable'); return response.json(); } @@ -33885,6 +33895,7 @@ export function initializeChatTab() { // Model and actor controls share this event. Invalidate the display // snapshot immediately; retained debug data remains available for // diagnostics and will be rebound only when current history loads. + turnModelPicker?.invalidate(); synchroniseLlmExecutionContext({ force: true, reason: 'settings_context_changed' }); const newHiddenKey = getHiddenSessionsStorageKey(); const newPinnedKey = getPinnedSessionsStorageKey(); diff --git a/src/frontend/web/von_interface/static/js/components/turnModelPicker.js b/src/frontend/web/von_interface/static/js/components/turnModelPicker.js index 2e3722e07..fa07b0dbc 100644 --- a/src/frontend/web/von_interface/static/js/components/turnModelPicker.js +++ b/src/frontend/web/von_interface/static/js/components/turnModelPicker.js @@ -1,29 +1,71 @@ // In-memory composer choice. Only a submitted turn receives these request fields. -export function createTurnModelPicker({ select, status, clearButton, loadModels }) { +export function createTurnModelPicker({ select, status, clearButton, loadModels, reasoningSelect, loadCapabilities }) { let choice = null; + let parameters = {}; let loaded = false; let loading = false; + let generation = 0; + let capabilityGeneration = 0; + const resetReasoning = (label = 'Choose a model first') => { + parameters = {}; + if (!reasoningSelect) return; + reasoningSelect.replaceChildren(new Option(label, '')); + reasoningSelect.disabled = true; + }; const render = () => { status.textContent = choice - ? `Next turn only: ${choice.model} (${choice.model_provider}). Default resumes after sending.` + ? `Next turn only: ${choice.model} (${choice.model_provider})${parameters.reasoning_effort ? ` · reasoning: ${parameters.reasoning_effort}` : ''}. Default resumes after sending.` : ''; status.hidden = !choice; clearButton.hidden = !choice; select.value = choice ? JSON.stringify(choice) : ''; }; - const clear = () => { choice = null; render(); }; + const clear = () => { capabilityGeneration++; choice = null; resetReasoning(); render(); }; + const updateReasoning = async () => { + const requestGeneration = ++capabilityGeneration; + resetReasoning(choice ? 'Loading reasoning options…' : undefined); + if (!choice || !reasoningSelect || !loadCapabilities) return; + try { + const result = await loadCapabilities(choice.model_provider, choice.model); + if (requestGeneration !== capabilityGeneration) return; + const capability = result?.parameters?.reasoning_effort; + if (!capability?.supported) { + resetReasoning('Reasoning level unavailable for this model'); + return; + } + const values = [...new Set([...(capability.allowed_values || []), ...(capability.fixed_value ? [capability.fixed_value] : [])])]; + resetReasoning('Provider default'); + values.forEach(value => reasoningSelect.add(new Option(value, value))); + reasoningSelect.disabled = !!capability.read_only || !!capability.fixed_value || !values.length; + if (capability.fixed_value) { + reasoningSelect.value = capability.fixed_value; + parameters = { reasoning_effort: capability.fixed_value }; + } + render(); + } catch (_) { + if (requestGeneration === capabilityGeneration) resetReasoning('Reasoning options unavailable; reselect model to retry'); + } + }; select.addEventListener('change', () => { choice = select.value ? JSON.parse(select.value) : null; + void updateReasoning(); + render(); + }); + reasoningSelect?.addEventListener('change', () => { + parameters = reasoningSelect.value ? { reasoning_effort: reasoningSelect.value } : {}; render(); }); clearButton.addEventListener('click', clear); const load = async () => { if (loaded || loading) return; + const requestGeneration = generation; loading = true; select.disabled = true; const failures = []; const providers = ['openai', 'gemini', 'openrouter', 'meta', 'ollama']; const results = await Promise.allSettled(providers.map(provider => loadModels(provider))); + if (requestGeneration !== generation) return; + select.querySelectorAll('optgroup').forEach(group => group.remove()); results.forEach((result, index) => { const provider = providers[index]; if (result.status !== 'fulfilled' || !Array.isArray(result.value)) { @@ -45,16 +87,21 @@ export function createTurnModelPicker({ select, status, clearButton, loadModels loaded = failures.length === 0; select.disabled = false; select.title = failures.length ? `Unavailable: ${failures.join(', ')}. Reopen to retry.` : ''; + if (choice && ![...select.options].some(option => option.value === JSON.stringify(choice))) clear(); render(); }; return { - load: async () => { - // Retry unavailable catalogues without duplicating successful options. - if (!loaded && !loading) select.querySelectorAll('optgroup').forEach(group => group.remove()); - await load(); + load, + invalidate: () => { + generation++; + loading = false; + loaded = false; + select.disabled = false; + select.querySelectorAll('optgroup').forEach(group => group.remove()); + clear(); }, - peek: () => choice ? { ...choice } : null, - take: () => { const fields = choice ? { ...choice, model_parameters: {} } : null; clear(); return fields; }, + peek: () => choice ? { ...choice, model_parameters: { ...parameters } } : null, + take: () => { const fields = choice ? { ...choice, model_parameters: { ...parameters } } : null; clear(); return fields; }, clear }; } diff --git a/src/frontend/web/von_interface/templates/chat_tab.html b/src/frontend/web/von_interface/templates/chat_tab.html index f0c087f3d..3c97e7e9d 100644 --- a/src/frontend/web/von_interface/templates/chat_tab.html +++ b/src/frontend/web/von_interface/templates/chat_tab.html @@ -254,6 +254,10 @@

Model for next turn

+ +

Choose a stronger or faster model for your next message only. Sending or queueing it restores the default for future turns.

diff --git a/tests/backend/test_model_parameter_service.py b/tests/backend/test_model_parameter_service.py index 86606a232..81fa78869 100644 --- a/tests/backend/test_model_parameter_service.py +++ b/tests/backend/test_model_parameter_service.py @@ -159,3 +159,45 @@ def test_gemini_37_reasoning_effort_maps_to_supported_thinking_levels( ) == {} ) + + +def test_registry_only_astra_capability_reaches_both_openai_transports(monkeypatch): + import src.backend.services.model_parameter_service as mod + + policy = {'action': 'allow', 'allowed_values': ['low', 'high'], 'source': 'configured_registry'} + monkeypatch.setattr(mod, '_registry_parameter_policy', lambda **kw: policy) + capability = mod.build_model_parameter_capabilities( + provider='openai', model='gpt-6-astra', include_registry=True, + )['parameters']['reasoning_effort'] + assert capability['supported'] is True + assert capability['allowed_values'] == ['low', 'high'] + assert mod.openai_responses_kwargs_from_model_parameters( + {'reasoning_effort': 'high'}, model='gpt-6-astra' + ) == {'reasoning': {'effort': 'high'}} + assert mod.openai_chat_completions_kwargs_from_model_parameters( + {'reasoning_effort': 'high'}, model='gpt-6-astra' + ) == {'reasoning_effort': 'high'} + policy['action'] = 'omit' + assert mod.openai_responses_kwargs_from_model_parameters( + {'reasoning_effort': 'high'}, model='gpt-6-astra' + ) == {} + monkeypatch.setattr(mod, '_registry_parameter_policy', lambda **kw: None) + assert not mod.build_model_parameter_capabilities( + provider='openai', model='gpt-6-astra', include_registry=True, + )['parameters']['reasoning_effort']['supported'] + + +def test_registry_only_model_without_value_constraint_gets_provider_domain(monkeypatch): + import src.backend.services.model_parameter_service as mod + + policy = {'action': 'allow', 'source': 'configured_registry'} + monkeypatch.setattr(mod, '_registry_parameter_policy', lambda **kw: policy) + capability = mod.build_model_parameter_capabilities( + provider='openai', model='gpt-6-astra', include_registry=True, + )['parameters']['reasoning_effort'] + assert capability['allowed_values'] == list(mod.OPENAI_REASONING_EFFORT_VALUES) + assert capability['sources'] == ['configured_registry'] + policy['fixed_value'] = 'registry-defined-effort' + assert mod.openai_responses_kwargs_from_model_parameters( + {'reasoning_effort': 'registry-defined-effort'}, model='gpt-6-astra' + ) == {'reasoning': {'effort': 'registry-defined-effort'}} diff --git a/tests/backend/test_settings_enabled_models.py b/tests/backend/test_settings_enabled_models.py new file mode 100644 index 000000000..c3df76a44 --- /dev/null +++ b/tests/backend/test_settings_enabled_models.py @@ -0,0 +1,59 @@ +from types import SimpleNamespace +from flask import Flask, jsonify +import pytest +from src.backend.server.routes import settings_routes as routes +from src.backend.languagemodels import llm_interface + + +@pytest.fixture +def client(monkeypatch): + app = Flask(__name__) + app.config['TESTING'] = True + app.register_blueprint(routes.settings_bp, url_prefix='/api/settings') + monkeypatch.setattr(routes, 'get_effective_user_concept_id', lambda: '#V#actor') + monkeypatch.setattr(routes, 'get_effective_organisation_concept_id', lambda: '#V#org') + monkeypatch.setattr(routes, 'OpenAIClient', lambda **kw: SimpleNamespace( + list_models=lambda: ['gpt-6-astra', 'gpt-6-astra-preview', 'other-model'] + )) + return app.test_client() + + +def test_picker_uses_execution_normalisation_and_trusted_scope(client, monkeypatch): + def pool(**scope): + assert scope == dict(user_concept_id='#V#actor', org_concept_id='#V#org') + return [dict(provider=' OpenAI ', model='openai:gpt-6-astra'), + dict(provider='gemini', model='other-model')] + monkeypatch.setattr(routes, 'resolve_enabled_llm_settings', pool) + monkeypatch.setattr(llm_interface, 'resolve_enabled_llm_settings', pool) + response = client.get('/api/settings/models/enabled/openai?user_concept_id=other') + assert response.status_code == 200 + assert response.get_json() == ['gpt-6-astra'] + assert 'no-store' in response.headers['Cache-Control'] + assert llm_interface.assert_model_execution_allowed( + provider='openai', model=response.get_json()[0], + user_concept_id='#V#actor', org_concept_id='#V#org' + )['allowed'] + # Settings discovery still exposes models which can be enabled later. + assert client.get('/api/settings/models/openai').get_json() == [ + 'gpt-6-astra', 'gpt-6-astra-preview', 'other-model' + ] + + +def test_missing_scope_does_not_discover_or_read_pool(client, monkeypatch): + monkeypatch.setattr(routes, 'get_effective_user_concept_id', lambda: None) + monkeypatch.setattr(routes, 'get_effective_organisation_concept_id', lambda: None) + monkeypatch.setattr(routes, 'resolve_enabled_llm_settings', lambda **kw: pytest.fail('no actor')) + assert client.get('/api/settings/models/enabled/openai').get_json() == [] + + +def test_unavailable_scope_is_not_reported_as_empty_pool(client, monkeypatch): + def unavailable(**kw): + raise RuntimeError('offline') + monkeypatch.setattr(routes, 'resolve_enabled_llm_settings', unavailable) + assert client.get('/api/settings/models/enabled/openai').status_code == 503 + + +def test_ollama_retains_local_execution_exception(client, monkeypatch): + monkeypatch.setattr(routes, 'resolve_enabled_llm_settings', lambda **kw: pytest.fail('local')) + monkeypatch.setattr(routes, 'get_ollama_models', lambda: (jsonify(['local-model']), 200)) + assert client.get('/api/settings/models/enabled/ollama').get_json() == ['local-model'] diff --git a/tests/browser/turnModelPicker.cjs b/tests/browser/turnModelPicker.cjs new file mode 100644 index 000000000..19c0df55f --- /dev/null +++ b/tests/browser/turnModelPicker.cjs @@ -0,0 +1,63 @@ +// Candidate markup/module with synthetic model capabilities; no model calls or live writes. +// PLAYWRIGHT_BROWSERS_PATH=/tmp/von-playwright node tests/browser/turnModelPicker.cjs +const fs = require('fs'); +const http = require('http'); +const path = require('path'); +const assert = require('assert/strict'); +const { chromium } = require('playwright'); +const root = process.cwd(); +const evidence = process.env.VON_PICKER_EVIDENCE_DIR || '/tmp/von-turn-picker'; +const staticRoot = path.join(root, 'src/frontend/web/von_interface/static'); +const template = fs.readFileSync(path.join(root, 'src/frontend/web/von_interface/templates/chat_tab.html'), 'utf8'); +const section = template.match(/
[\s\S]*?<\/section>/)[0]; +const server = http.createServer((req, res) => { + if (req.url.startsWith('/static/')) { + const file = path.join(staticRoot, req.url.slice('/static/'.length)); + res.setHeader('Content-Type', file.endsWith('.js') ? 'text/javascript' : 'text/css'); + return res.end(fs.readFileSync(file)); + } + res.setHeader('Content-Type', 'text/html'); + res.end(`${section}

+ `); +}); +(async () => { + let browser; + try { + fs.mkdirSync(evidence, { recursive: true }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + await page.goto(`http://127.0.0.1:${server.address().port}`); + await page.waitForFunction(() => window.ready); + await page.locator('#turnModelSelect').selectOption({ label: 'gpt-6-astra' }); + await page.locator('#turnReasoningSelect').selectOption('high'); + await page.screenshot({ path: path.join(evidence, 'reasoning-selected.png') }); + await page.locator('#sendFixture').click(); + assert.deepEqual(await page.evaluate(() => window.captured), { + model: 'gpt-6-astra', model_provider: 'openai', model_parameters: { reasoning_effort: 'high' } + }); + assert.equal(await page.locator('#turnModelSelect').inputValue(), ''); + assert.equal(await page.locator('#turnReasoningSelect').isDisabled(), true); + await page.locator('#turnModelSelect').selectOption({ label: 'plain-model' }); + await page.waitForFunction(() => document.getElementById('turnReasoningSelect').textContent.includes('unavailable')); + assert.equal(await page.locator('#turnReasoningSelect').isDisabled(), true); + await page.evaluate(() => window.picker.invalidate()); + assert.equal(await page.locator('#turnModelSelect option').count(), 1); + console.log(JSON.stringify({ passed: true, scope: 'candidate template and picker module; synthetic catalogue and registry; no live authentication or provider calls', evidence })); + } finally { + if (browser) await browser.close(); + server.close(); + } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/frontend/chatTaskQueue.test.js b/tests/frontend/chatTaskQueue.test.js index a67c3f287..8eb6df05c 100644 --- a/tests/frontend/chatTaskQueue.test.js +++ b/tests/frontend/chatTaskQueue.test.js @@ -179,9 +179,11 @@ describe('chat task queue', () => { }); expect(generateBody.model).not.toContain('gemini:'); const { createTurnModelPicker } = require('../../src/frontend/web/von_interface/static/js/components/turnModelPicker.js'); - document.body.insertAdjacentHTML('beforeend', '

'); + document.body.insertAdjacentHTML('beforeend', '

'); const select = document.getElementById('temporaryModel'); const picker = createTurnModelPicker({ select, + reasoningSelect: document.getElementById('temporaryReasoning'), + loadCapabilities: async () => ({ parameters: { reasoning_effort: { supported: true, allowed_values: ['low', 'high'] } } }), status: document.getElementById('temporaryStatus'), clearButton: document.getElementById('temporaryClear'), loadModels: async provider => provider === 'openai' ? ['gpt-6-astra'] : [] }); @@ -189,17 +191,21 @@ describe('chat task queue', () => { require(chatTabModulePath).__testOnly_setTurnModelPicker(picker); select.value = JSON.stringify({ model: 'gpt-6-astra', model_provider: 'openai' }); select.dispatchEvent(new Event('change')); + await Promise.resolve(); + const reasoning = document.getElementById('temporaryReasoning'); + reasoning.value = 'high'; + reasoning.dispatchEvent(new Event('change')); document.getElementById('promptInput').value = 'Use stronger model once'; await sendMessage(); - expect(generateBody).toMatchObject({ model: 'gpt-6-astra', model_provider: 'openai', model_parameters: {} }); + expect(generateBody).toMatchObject({ model: 'gpt-6-astra', model_provider: 'openai', model_parameters: { reasoning_effort: 'high' } }); expect(picker.peek()).toBeNull(); document.getElementById('promptInput').value = 'Use default again'; await sendMessage(); expect(generateBody).toMatchObject({ model: 'gemini-3.7-flash', model_provider: 'gemini', model_parameters: { reasoning_effort: 'high' } }); // A retained queue choice must survive later default changes. await sendMessage({ fromQueue: true, promptOverride: 'Queued stronger turn', sessionId: 'session-1', - executionEnvelope: { model: 'gpt-6-astra', model_provider: 'openai', model_parameters: {} } }); - expect(generateBody).toMatchObject({ model: 'gpt-6-astra', model_provider: 'openai', model_parameters: {} }); + executionEnvelope: { model: 'gpt-6-astra', model_provider: 'openai', model_parameters: { reasoning_effort: 'high' } } }); + expect(generateBody).toMatchObject({ model: 'gpt-6-astra', model_provider: 'openai', model_parameters: { reasoning_effort: 'high' } }); require(chatTabModulePath).__testOnly_setTurnModelPicker(null); } finally { localStorage.removeItem('von:localModelPreference'); @@ -299,7 +305,7 @@ describe('chat task queue', () => { expect(generateBodies).toHaveLength(1); expect(document.getElementById('sendButton').textContent).toBe('Queue Prompt'); - const temporaryChoice = { model: 'gpt-6-astra', model_provider: 'openai', model_parameters: {} }; + const temporaryChoice = { model: 'gpt-6-astra', model_provider: 'openai', model_parameters: { reasoning_effort: 'high' } }; const picker = { peek: () => temporaryChoice, take: jest.fn(() => temporaryChoice), clear: jest.fn() }; require(chatTabModulePath).__testOnly_setTurnModelPicker(picker); promptInput.value = 'Second draft'; diff --git a/tests/frontend/turnModelPicker.test.js b/tests/frontend/turnModelPicker.test.js index 2652e233b..434836c96 100644 --- a/tests/frontend/turnModelPicker.test.js +++ b/tests/frontend/turnModelPicker.test.js @@ -52,3 +52,55 @@ describe('temporary turn model picker', () => { expect(select.title).toBe(''); }); }); + +describe('temporary reasoning control', () => { + let picker, select, reasoningSelect, resolveCapabilities; + beforeEach(async () => { + document.body.innerHTML = '

'; + select = document.getElementById('model'); + reasoningSelect = document.getElementById('reasoning'); + picker = createTurnModelPicker({ select, reasoningSelect, + status: document.querySelector('p'), clearButton: document.querySelector('button'), + loadModels: async provider => provider === 'openai' ? ['gpt-6-astra', 'other'] : [], + loadCapabilities: () => new Promise(resolve => { resolveCapabilities = resolve; }) + }); + await picker.load(); + }); + function choose(model = 'gpt-6-astra') { + select.value = JSON.stringify({ model, model_provider: 'openai' }); + select.dispatchEvent(new Event('change')); + } + async function capability(value = {}) { + resolveCapabilities({ parameters: { reasoning_effort: { supported: true, allowed_values: ['low', 'high'], ...value } } }); + await Promise.resolve(); + } + test('passes selected effort once and resets on send', async () => { + choose(); await capability(); + reasoningSelect.value = 'high'; reasoningSelect.dispatchEvent(new Event('change')); + expect(picker.take().model_parameters).toEqual({ reasoning_effort: 'high' }); + expect(picker.take()).toBeNull(); + expect(reasoningSelect.disabled).toBe(true); + }); + test('changing model discards effort and ignores stale capability reads', async () => { + choose(); const stale = resolveCapabilities; + choose('other'); await capability({ supported: false }); + stale({ parameters: { reasoning_effort: { supported: true, allowed_values: ['high'] } } }); + await Promise.resolve(); + expect(reasoningSelect.disabled).toBe(true); + expect(picker.take().model_parameters).toEqual({}); + }); + test('fixed registry value is visible, read-only and propagated', async () => { + choose(); await capability({ fixed_value: 'high', read_only: true }); + expect(reasoningSelect.disabled).toBe(true); + expect(reasoningSelect.value).toBe('high'); + expect(picker.take().model_parameters).toEqual({ reasoning_effort: 'high' }); + }); + test('actor/settings invalidation clears choice and stale asynchronous metadata', async () => { + choose(); picker.invalidate(); await capability(); + expect(picker.peek()).toBeNull(); + expect(select.options.length).toBe(1); + expect(reasoningSelect.disabled).toBe(true); + await picker.load(); + expect(select.options.length).toBe(3); + }); +});