From a9ce2889affdadda3bfaa785bbaca37d59a35081 Mon Sep 17 00:00:00 2001 From: Roberto Villegas Date: Sat, 23 May 2026 03:59:45 -0600 Subject: [PATCH] fix(ui): hide reasoning chip when model lacks effort levels Resolve supported reasoning efforts per active model/provider and pass that context through /api/reasoning so Composer and other non-configurable models no longer show a misleading effort picker. Co-authored-by: Cursor --- CHANGELOG.md | 3 + api/config.py | 114 +++++++++++++++++- api/routes.py | 13 +- static/commands.js | 5 +- static/ui.js | 56 +++++++-- ...est_issue1103_reasoning_chip_visibility.py | 9 +- tests/test_reasoning_chip_btw_fixes.py | 15 +-- ...est_reasoning_effort_model_capabilities.py | 33 +++++ 8 files changed, 224 insertions(+), 24 deletions(-) create mode 100644 tests/test_reasoning_effort_model_capabilities.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bd8350bd..4c7a2501a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ - **PR #2824** by @gavinssr — A "Stop server" affordance in Settings → System that gracefully shuts down the local WebUI server. Useful when WebUI was launched via `./ctl.sh start` or the native macOS/Windows app and the user wants to stop it without context-switching to a terminal. Confirmation dialog before the actual shutdown. The `/api/shutdown` route is CSRF-gated and intended for local-loopback use. Originally a title-bar button; relocated to Settings per the project's deep-UX rule (default-hidden for niche destructive actions on always-visible surfaces). ### Fixed + +### Fixed +- **Reasoning effort chip visibility** — `/api/reasoning` now accepts `model` and `provider` query params and returns `supported_efforts` so the composer chip hides for models without configurable reasoning levels (for example Cursor Composer) while remaining available for models like GPT-5.5. - **Cursor ACP routing and new-chat defaults** — New conversations now carry the visible composer picker selection into `POST /api/session/new`, persist model changes before a session exists, and evict cached session agents when the model/provider changes mid-session. - **PR #2685** by @LumenYoung — Prevent replayed context in chat reconciliation and metering. When a WebUI session is recovered (e.g., after a process restart, network drop, or browser reload), the sidebar/`state.db` reconciliation logic walks the sidecar transcript in order and only skips rows that can actually be aligned with the remaining sidecar context. The prior set-membership check was too broad: a legitimate fresh message that happened to share a key with any older repeated short message in the sidecar was mis-classified as already-seen and dropped from the replay, leading to lost context and inconsistent metering. Also caps the per-turn live-tool-prompt token estimate at 12,000 to prevent unbounded growth on bursts of large tool reads before exact provider accounting overrides. diff --git a/api/config.py b/api/config.py index 1dc55d076..7b7018745 100644 --- a/api/config.py +++ b/api/config.py @@ -2073,7 +2073,112 @@ def parse_reasoning_effort(effort): return None -def get_reasoning_status() -> dict: +def _heuristic_reasoning_efforts(model_id: str, provider_id: str) -> list[str]: + """Fallback when hermes_cli is unavailable.""" + model = str(model_id or "").strip().lower() + provider = _resolve_provider_alias(str(provider_id or "").strip().lower()) + if not model or provider in {"cursor-acp", "copilot-acp"}: + return [] + bare = model.rsplit("/", 1)[-1] + if provider == "openai-codex" and bare.startswith(("gpt-5", "o1", "o3", "o4")): + if bare.startswith(("o1", "o3", "o4")): + return ["low", "medium", "high"] + return list(VALID_REASONING_EFFORTS) + if provider in {"copilot", "github-copilot"}: + if bare.startswith(("gpt-5", "o1", "o3", "o4")): + if bare.startswith(("o1", "o3", "o4")): + return ["low", "medium", "high"] + return list(VALID_REASONING_EFFORTS) + prefixes = ( + "deepseek/", + "anthropic/", + "openai/", + "x-ai/", + "google/gemini-2", + "google/gemma-4", + "qwen/qwen3", + "tencent/hy3-preview", + "xiaomi/", + ) + if any(model.startswith(prefix) for prefix in prefixes): + return list(VALID_REASONING_EFFORTS) + return [] + + +def resolve_model_reasoning_efforts( + model_id: str | None = None, + provider_id: str | None = None, + base_url: str | None = None, +) -> list[str]: + """Return supported reasoning-effort levels for *model_id*, or [] if none.""" + model = str(model_id or "").strip() + if not model: + return [] + + provider = str(provider_id or "").strip().lower() if provider_id else "" + resolved_base_url = str(base_url or "").strip() or None + if not provider: + try: + _, provider, resolved_base_url = resolve_model_provider(model) + except Exception: + provider = str((cfg.get("model") or {}).get("provider") or "").strip().lower() + + provider = _resolve_provider_alias(provider) + if provider in {"cursor-acp", "copilot-acp"}: + return [] + + try: + from hermes_cli.models import ( + github_model_reasoning_efforts, + lmstudio_model_reasoning_options, + ) + except Exception: + return _heuristic_reasoning_efforts(model, provider) + + if provider in {"copilot", "github-copilot"}: + return github_model_reasoning_efforts(model) + + if provider == "openai-codex": + bare = model.rsplit("/", 1)[-1] + return github_model_reasoning_efforts(bare) + + if provider == "lmstudio": + probe_base = resolved_base_url or _get_provider_base_url(provider) + opts = lmstudio_model_reasoning_options(model, probe_base) + normalized = [str(opt).strip().lower() for opt in opts if str(opt).strip()] + if not normalized or set(normalized).issubset({"off"}): + return [] + level_opts = [opt for opt in normalized if opt in VALID_REASONING_EFFORTS] + if level_opts: + return list(dict.fromkeys(level_opts)) + if set(normalized).issubset({"off", "on"}): + return [] + return [] + + model_lower = model.lower() + prefixes = ( + "deepseek/", + "anthropic/", + "openai/", + "x-ai/", + "google/gemini-2", + "google/gemma-4", + "qwen/qwen3", + "tencent/hy3-preview", + "xiaomi/", + ) + if any(model_lower.startswith(prefix) for prefix in prefixes): + return list(VALID_REASONING_EFFORTS) + + return [] + + +def get_reasoning_status( + *, + model_id: str | None = None, + provider_id: str | None = None, + base_url: str | None = None, +) -> dict: """Return current reasoning configuration from the active profile's config.yaml — the same source of truth the CLI reads from. @@ -2086,10 +2191,17 @@ def get_reasoning_status() -> dict: agent_cfg = config_data.get("agent") or {} show_raw = display_cfg.get("show_reasoning") if isinstance(display_cfg, dict) else None effort_raw = agent_cfg.get("reasoning_effort") if isinstance(agent_cfg, dict) else None + supported_efforts = resolve_model_reasoning_efforts( + model_id, + provider_id=provider_id, + base_url=base_url, + ) return { # Match CLI default (True if unset in config.yaml) "show_reasoning": bool(show_raw) if isinstance(show_raw, bool) else True, "reasoning_effort": str(effort_raw or "").strip().lower(), + "supported_efforts": supported_efforts, + "supports_reasoning_effort": bool(supported_efforts), } diff --git a/api/routes.py b/api/routes.py index 27ca88757..048579a71 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3918,7 +3918,18 @@ def handle_get(handler, parsed) -> bool: # Current reasoning config (shared source of truth with the CLI — # reads display.show_reasoning and agent.reasoning_effort from # the active profile's config.yaml). - return j(handler, get_reasoning_status()) + query = parse_qs(parsed.query) + model_id = (query.get("model", [""])[0] or "").strip() or None + provider_id = (query.get("provider", [""])[0] or "").strip() or None + base_url = (query.get("base_url", [""])[0] or "").strip() or None + return j( + handler, + get_reasoning_status( + model_id=model_id, + provider_id=provider_id, + base_url=base_url, + ), + ) if parsed.path == "/api/onboarding/status": return j(handler, get_onboarding_status()) diff --git a/static/commands.js b/static/commands.js index f7705546b..73966304b 100644 --- a/static/commands.js +++ b/static/commands.js @@ -1141,7 +1141,8 @@ function cmdReasoning(args){ } if(!arg){ // Status — read from the same config.yaml keys the CLI uses. - api('/api/reasoning').then(function(st){showToast(_fmtStatus(st));}) + const q=(typeof _reasoningEffortQuery==='function')?_reasoningEffortQuery():''; + api('/api/reasoning'+q).then(function(st){showToast(_fmtStatus(st));}) .catch(function(){showToast(BRAIN+' /reasoning — status unavailable');}); return true; } @@ -1168,7 +1169,7 @@ function cmdReasoning(args){ .then(function(st){ const eff=(st && st.reasoning_effort)||arg; showToast(BRAIN+' Reasoning effort: '+eff+' (saved; applies to next turn)'); - if(typeof _applyReasoningChip==='function') _applyReasoningChip(eff); + if(typeof _applyReasoningChip==='function') _applyReasoningChip(eff, st||{}); }) .catch(function(e){ showToast(BRAIN+' Failed to set effort: '+(e && e.message ? e.message : arg)); diff --git a/static/ui.js b/static/ui.js index 88b5365c4..164ee0a0e 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1572,6 +1572,7 @@ async function selectModelFromDropdown(value){ } sel.value=value; syncModelChip(); + if(typeof fetchReasoningChip==='function') fetchReasoningChip(); closeModelDropdown(); if(typeof sel.onchange==='function') await sel.onchange(); } @@ -1629,6 +1630,7 @@ window.addEventListener('resize',()=>{ // ── Reasoning effort chip ──────────────────────────────────────────────────── let _currentReasoningEffort=null; +let _currentReasoningEffortsSupported=null; function _normalizeReasoningEffort(eff){ return String(eff||'').trim().toLowerCase(); @@ -1640,17 +1642,58 @@ function _formatReasoningEffortLabel(effort){ return effort; } -function _applyReasoningChip(eff){ +function _reasoningEffortQuery(){ + const sel=$('modelSelect'); + const model=(S&&S.session&&S.session.model)||(sel&&sel.value)||''; + const provider=(S&&S.session&&S.session.model_provider)||''; + const params=new URLSearchParams(); + if(model) params.set('model', model); + if(provider) params.set('provider', provider); + const qs=params.toString(); + return qs?('?'+qs):''; +} + +function _applyReasoningOptions(supportedEfforts){ + const dd=$('composerReasoningDropdown'); + if(!dd) return; + const supported=new Set(Array.isArray(supportedEfforts)?supportedEfforts:[]); + dd.querySelectorAll('.reasoning-option').forEach(function(opt){ + const effort=opt.dataset.effort; + if(effort==='none'){ + opt.style.display=''; + return; + } + if(!supported.size){ + opt.style.display='none'; + return; + } + opt.style.display=supported.has(effort)?'':'none'; + }); +} + +function _applyReasoningChip(eff, meta){ const effort=_normalizeReasoningEffort(eff); _currentReasoningEffort=effort; + if(meta&&Array.isArray(meta.supported_efforts)){ + _currentReasoningEffortsSupported=meta.supported_efforts; + } const wrap=$('composerReasoningWrap'); const label=$('composerReasoningLabel'); const chip=$('composerReasoningChip'); const mobileLabel=$('composerMobileReasoningLabel'); const mobileAction=$('composerMobileReasoningAction'); if(!wrap||!label) return; + const supports=Array.isArray(_currentReasoningEffortsSupported) + ?_currentReasoningEffortsSupported.length>0 + :true; + if(!supports){ + wrap.style.display='none'; + if(mobileAction) mobileAction.style.display='none'; + return; + } wrap.style.display=''; if(mobileAction) mobileAction.style.display=''; + _applyReasoningOptions(_currentReasoningEffortsSupported); const text=_formatReasoningEffortLabel(effort); label.textContent=text; if(mobileLabel) mobileLabel.textContent=text; @@ -1664,14 +1707,13 @@ function _applyReasoningChip(eff){ } function fetchReasoningChip(){ - api('/api/reasoning').then(function(st){ - _applyReasoningChip((st&&st.reasoning_effort)||''); - }).catch(function(){_applyReasoningChip('');}); + api('/api/reasoning'+_reasoningEffortQuery()).then(function(st){ + _applyReasoningChip((st&&st.reasoning_effort)||'', st||{}); + }).catch(function(){_applyReasoningChip('', {supported_efforts:[]});}); } function syncReasoningChip(){ - if(_currentReasoningEffort===null){fetchReasoningChip();return;} - _applyReasoningChip(_currentReasoningEffort); + fetchReasoningChip(); } function _highlightReasoningOption(effort){ @@ -1737,7 +1779,7 @@ document.addEventListener('click',function(e){ if(effort){ api('/api/reasoning',{method:'POST',body:JSON.stringify({effort:effort})}) .then(function(st){ - _applyReasoningChip((st&&st.reasoning_effort)||effort); + _applyReasoningChip((st&&st.reasoning_effort)||effort, st||{}); showToast('🧠 Reasoning effort set to '+((st&&st.reasoning_effort)||effort)); }) .catch(function(){showToast('🧠 Failed to set effort');}); diff --git a/tests/test_issue1103_reasoning_chip_visibility.py b/tests/test_issue1103_reasoning_chip_visibility.py index 31f836069..a8f621afc 100644 --- a/tests/test_issue1103_reasoning_chip_visibility.py +++ b/tests/test_issue1103_reasoning_chip_visibility.py @@ -47,12 +47,13 @@ def test_reasoning_chip_html_starts_hidden(): assert m, "composerReasoningWrap must start with style='display:none'" -def test_applyReasoningChip_shows_wrap(): - """_applyReasoningChip must set wrap display to empty string (visible).""" +def test_ui_js_passes_model_context_to_reasoning_api(): with open("static/ui.js") as f: src = f.read() - assert "wrap.style.display=''" in src or "wrap.style.display =''" in src, \ - "_applyReasoningChip must set wrap.style.display='' to make chip visible" + assert "_reasoningEffortQuery" in src, ( + "ui.js must pass the active session model/provider to /api/reasoning" + ) + assert "api('/api/reasoning'+_reasoningEffortQuery())" in src def test_fetchReasoningChip_calls_apply(): diff --git a/tests/test_reasoning_chip_btw_fixes.py b/tests/test_reasoning_chip_btw_fixes.py index 608d612d6..c7ba6b28d 100644 --- a/tests/test_reasoning_chip_btw_fixes.py +++ b/tests/test_reasoning_chip_btw_fixes.py @@ -135,16 +135,13 @@ class TestReasoningChipNoneState: def test_none_and_default_do_not_hide_reasoning_chip(self): fn = self.get_apply_reasoning_chip() + assert "wrap.style.display='none'" in fn, ( + "_applyReasoningChip must hide the chip when the active model does " + "not support reasoning effort controls" + ) assert "wrap.style.display='';" in fn, ( - "_applyReasoningChip must show the reasoning chip even for empty/" - "default or 'none' effort values" - ) - assert "if(!eff" not in fn and "wrap.style.display='none'" not in fn, ( - "_applyReasoningChip must not use a truthy guard that hides the " - "chip for the valid 'none' state" - ) - assert "wrap.style.display='none'" not in fn, ( - "the None/default reasoning state should be visible, not hidden" + "_applyReasoningChip must show the reasoning chip when the model " + "supports reasoning effort controls" ) def test_none_and_default_have_visible_labels(self): diff --git a/tests/test_reasoning_effort_model_capabilities.py b/tests/test_reasoning_effort_model_capabilities.py new file mode 100644 index 000000000..8a0dd4209 --- /dev/null +++ b/tests/test_reasoning_effort_model_capabilities.py @@ -0,0 +1,33 @@ +"""Tests for model-aware reasoning effort chip visibility.""" + +from api import config as cfg + + +def test_cursor_acp_models_do_not_support_reasoning_effort_levels(): + assert cfg.resolve_model_reasoning_efforts( + "cursor/composer-2.5", + provider_id="cursor-acp", + ) == [] + + +def test_openai_codex_gpt5_supports_reasoning_effort_levels(): + efforts = cfg.resolve_model_reasoning_efforts( + "gpt-5.5", + provider_id="openai-codex", + ) + assert "medium" in efforts + assert "high" in efforts + + +def test_get_reasoning_status_includes_supported_efforts(monkeypatch): + monkeypatch.setattr( + cfg, + "resolve_model_reasoning_efforts", + lambda *a, **k: ["low", "medium", "high"], + ) + status = cfg.get_reasoning_status( + model_id="gpt-5.5", + provider_id="openai-codex", + ) + assert status["supported_efforts"] == ["low", "medium", "high"] + assert status["supports_reasoning_effort"] is True