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 <cursoragent@cursor.com>
This commit is contained in:
Roberto Villegas
2026-05-23 03:59:45 -06:00
parent ef5eafcceb
commit a9ce2889af
8 changed files with 224 additions and 24 deletions
+3
View File
@@ -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.
+113 -1
View File
@@ -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),
}
+12 -1
View File
@@ -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())
+3 -2
View File
@@ -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));
+49 -7
View File
@@ -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');});
@@ -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():
+6 -9
View File
@@ -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):
@@ -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