Files
hermes-webui/tests/test_reasoning_chip_js_behaviour.py
d195b094bb Release: GLM per-version reasoning controls (#6219, @rh-id) (#6243)
* fix(reasoning): gate reasoning_effort ladder to GLM-5.2+ on native zai

Z.AI's API (docs.z.ai) defines reasoning_effort as GLM-5.2+ exclusive, but
hemes-webui advertised the full 6-level ladder for all 7 GLM models because
_candidate_supports_reasoning has an unconditional glm token match and
_filter_reasoning_efforts_for_provider had no ZAI branch. Six of seven catalog
models (glm-5.1, glm-5, glm-5-turbo, glm-4.7, glm-4.5, glm-4.5-flash) showed a
selector whose values the endpoint silently ignores, and GLM-4.7 (forced thinking
that cannot be disabled per Z.AI docs) showed a 'none' option with no effect.

Add a ZAI branch to _filter_reasoning_efforts_for_provider mirroring the existing
OpenAI/Gemini/Anthropic ceiling pattern: strip the whole ladder for pre-5.2 GLM
models and for the forced-thinking GLM-4.7 family; preserve the full ladder for
GLM-5.2+ (whose accepted values match VALID_REASONING_EFFORTS exactly). The gate
is scoped to the native zai provider only (aliases glm/z-ai/z.ai/zhipu all
resolve to zai); aggregator providers are untouched because they route through
their own routers, not Z.AI's native endpoint.

The glm family-detection heuristic in _candidate_supports_reasoning is unchanged
— GLM models DO support the thinking on/off toggle at the family level; this fix
is specifically about the reasoning_effort intensity ladder.

State layer: agent.reasoning_effort config + UI dropdown options derived from
resolve_model_reasoning_efforts. Invariant: UI options and coercion now agree
and match Z.AI's per-model docs (max offered only for GLM-5.2+, none never
offered for forced-thinking models). Out of scope: the thinking:{type:...}
request-field translation lives in the external agent/gateway layer.

* fix(reasoning): close ZAI coercion gap + harden test assertions

Address Greptile review on #6219 (round 1):

1. Vacuous test assertion (test_glm_5_2_preserves_none_sentinel): the 'or'
   fallback made the assertion always-true, so a regression stripping 'none'
   for GLM-5.2 would go undetected. Rewrote to inject 'none' via the raw source
   (mocking _resolve_model_reasoning_efforts_impl) and assert it survives — the
   test now genuinely exercises the preservation branch.

2. Coercion gap for non-max stored levels on pre-5.2 GLM: the existing
   'if ceiling and raw not in ceiling' guard treats an empty ceiling as 'no
   rule' (preserving the configured effort verbatim per #3505), so a stored
   'high'/'medium'/'low' for glm-5.1/glm-4.5/glm-4.7 on native zai was forwarded
   to Z.AI unchanged and silently ignored — contradicting the PR's stated
   UI/coercion agreement invariant.

   Root cause: the ZAI gate returns [] to mean 'known-empty' (no
   reasoning_effort at all), but the coercion path treated all [] as
   'ambiguous/unknown, preserve verbatim'. Fixed by extracting the ZAI decision
   into _zai_glm_reasoning_efforts_supported (True/False/None sentinel) shared by
   both the filter and coercion, then special-casing the known-False result in
   coerce_reasoning_effort_for_model to return '' (send no field). The #3505
   preserve-verbatim behavior for genuinely-unknown models on non-zai providers
   is unchanged.

Added 10 regression tests (all fail before the coercion fix, pass after):
- All 6 levels (max..minimal) coerce to '' for each pre-5.2 GLM + glm-4.7
- All 4 aliases (glm/z-ai/z.ai/zhipu) resolve through the same coercion gate
- GLM-5.2 preserves all 6 levels verbatim
- Regression guard: unknown model on custom: provider STILL preserves verbatim

State layer: agent.reasoning_effort config + the value forwarded to Z.AI.
Invariant now fully holds: UI offers no options for pre-5.2 GLM AND coercion
sends no reasoning_effort field for any stored level on those models.

* fix(reasoning): preserve ZAI GLM 4.5-5.1 thinking toggle when effort ladder empty

Address nesquena-hermes round-2 review on #6219: returning [] for the effort
ladder on sub-5.2 GLM models hid the entire reasoning chip in the composer
(static/ui.js:4932 treats empty supported_efforts as 'no reasoning control at
all'), silently regressing the working thinking on/off toggle for GLM-4.5/4.6/
5.0/5.1 users. Per Z.AI's own docs, those models accept the thinking
{type:enabled|disabled} toggle even though they do not accept the
reasoning_effort intensity ladder.

Fix decouples thinking-toggle capability from the effort ladder:

Backend (api/config.py):
- Refactor _zai_glm_classification returns one of 'effort' (GLM-5.2+), 'thinking'
  (GLM-4.5 up to but not including 5.2), 'forced' (GLM-4.7 family), or None
  (non-zai / non-GLM). Single source of truth shared by all three consumers.
- _zai_glm_reasoning_efforts_supported now wraps classification for the coercion
  contract (unchanged behavior).
- _zai_glm_thinking_toggle_supported returns True for 'effort' or 'thinking',
  False for 'forced', None otherwise.
- get_reasoning_status gains a supports_thinking_toggle field = bool(supported)
  OR (zai_thinking is True). Non-zai providers default to bool(supported_efforts)
  so their chip-visibility behavior is unchanged.

Frontend (static/ui.js):
- New _currentReasoningToggleSupported state var (default undefined = treat as
  true so legacy responses without the field do not newly hide the chip).
- _applyReasoningChip shows the chip when hasEffortLadder OR toggleSupported.
  Empty efforts + toggle=True keeps the chip visible with just the None/On
  control (the existing _applyReasoningOptions already shows 'none' when the
  ladder is empty). Empty efforts + toggle=False (GLM-4.7 forced) hides it.
- Profile-transition and fetch-failure resets now pass
  supports_thinking_toggle:false alongside the empty efforts so the chip hides
  during the unknown-state window, matching the prior reset contract.

Tests (30 new):
- _zai_glm_classification parametrized across all three tiers + aliases + defer
- get_reasoning_status supports_thinking_toggle per tier (GLM-5.2 both, GLM-4.6
  toggle-only, GLM-4.7 neither, non-zai defaults to effort capability)
- Frontend _applyReasoningChip behavior via node driver: empty efforts +
  toggle=True stays visible, toggle=False hides, effort ladder alone is
  sufficient, absent field keeps prior behavior

State layer: agent.reasoning_effort config + supports_thinking_toggle field in
/api/reasoning + composer chip visibility. Invariant: the chip is hidden ONLY
when the model supports neither the effort ladder nor the thinking toggle
(GLM-4.7 forced, or genuinely non-reasoning models); GLM-4.5-5.1 retain the
working On/None control they had before the round-1 effort gate.

* fix(reasoning): make ZAI thinking toggle two-way + force GLM-4.7 stored none

Address nesquena-hermes round-3 review on #6219 — two SILENT gaps in the
thinking-toggle path, plus a click-handler sibling I found while auditing.

Gap #1 — ONE-WAY toggle for GLM-4.5/4.6/5.0/5.1 (api/config.py:4247,
static/ui.js:4902, static/index.html:757):
The round-2 fix kept the chip visible for thinking-tier models but the only
rendered dropdown option was 'None' (the HTML had no Default option, and
set_reasoning_effort rejected empty effort with 400). So a GLM-4.6 user could
turn thinking OFF but never back ON — worse than the original bug.

Fix:
- set_reasoning_effort now accepts empty effort as 'clear the override' (removes
  agent.reasoning_effort so the provider default takes effect). Invalid values
  still raise ValueError.
- static/index.html gains a <div data-effort=''>Default</div> option.
- _applyReasoningOptions always shows both Default ('') and None alongside the
  effort ladder, so a thinking-tier model (empty ladder + toggle=true) renders
  an operable Default+None two-state control.
- Click handler (ui.js:5106) checks option presence (if(opt)) not truthiness
  (if(effort)) — the old check silently ignored data-effort='' clicks, which
  would have left the Default button dead even after the HTML/backend changes.

Gap #2 — GLM-4.7 not forced when 'none' stored (api/config.py:3984, :3857):
When GLM-4.7 had agent.reasoning_effort=none configured, coercion preserved
'none' via the early return at line 3985, so streaming built disabled reasoning
for a model that forces thinking on regardless. Separately, when the raw
capability source listed 'none', resolve_model_reasoning_efforts reattached it
to GLM-4.7's supported_efforts (['none']), leaking an 'off' option to the UI
for a forced-thinking model.

Fix:
- coerce_reasoning_effort_for_model checks _zai_glm_classification == 'forced'
  BEFORE the generic 'none' early-return, coercing stored 'none' to '' (default
  = thinking on) for forced models.
- resolve_model_reasoning_efforts returns [] early for forced-tier models,
  skipping the 'none' reattachment entirely.

Tests (17 new):
- Gap #2: coerce('none', glm-4.7) -> '', regression guard that non-forced GLM
  still accepts 'none'; resolve does not reattach 'none' for forced but DOES
  for thinking-tier; end-to-end get_reasoning_status for forced+stored-none.
- Gap #1 backend: set('') clears the key (no raise), set('garbage') still
  raises, all 7 valid levels still save.
- Gap #1 frontend: three new node-driver tests asserting the dropdown exposes
  both Default and None for thinking-tier (two-state), Default+None+ladder for
  effort-tier, and the off->on->off round trip keeps both visible throughout.
- Updated test_reasoning_show_hide.test_set_reasoning_effort_rejects_invalid to
  reflect the new contract (empty accepted, garbage rejected).

Regression gate: gap#2 coerce tests fail without the forced-tier check (3
failures); gap#1 backend test fails with 'ValueError: effort is required'
without the empty-acceptance change. 219 passed, 1 pre-existing skip across
the full reasoning + chip + config-cache surface.

State layer: agent.reasoning_effort config + supports_thinking_toggle field +
composer dropdown options. Invariant: the thinking toggle is now genuinely
two-way for GLM-4.5-5.1 (Default=on, None=off, both always visible) and
GLM-4.7 forced-thinking never offers 'none' in any path (coerce, resolve,
status, or UI).

* CHANGELOG: GLM per-version reasoning controls (#6219)

---------

Co-authored-by: Ruby Hartono <58564005+rh-id@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-18 05:44:33 -07:00

544 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Behavioural tests that drive the actual `_applyReasoningChip()` from
static/ui.js via node, not just a regex over the source.
The static checks in test_reasoning_chip_btw_fixes.py confirm the *shape*
of the function (no `display='none'`, the right toggle call exists, etc.)
but they pass even if a runtime detail is wrong — e.g. if `inactive` were
inverted, or `_normalizeReasoningEffort` mishandled whitespace, or the
label fell through to a wrong value for an unknown input.
This file pins the actual rendered output for every effort state so the
chip's None/Default visibility cannot silently regress.
"""
import os
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).parent.parent.resolve()
UI_JS_PATH = REPO_ROOT / "static" / "ui.js"
NODE = shutil.which("node")
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
_DRIVER_SRC = r"""
const fs = require('fs');
const src = fs.readFileSync(process.argv[2], 'utf8');
function makeEl() {
return {
style: {},
_attrs: {},
setAttribute(k, v){this._attrs[k] = String(v)},
getAttribute(k){return this._attrs[k]},
classList: {
_set: new Set(),
add(c){this._set.add(c)},
remove(c){this._set.delete(c)},
toggle(c, on){
const want = on === undefined ? !this._set.has(c) : Boolean(on);
if (want) this._set.add(c); else this._set.delete(c);
},
contains(c){return this._set.has(c)},
},
dataset: {},
title: '',
textContent: '',
querySelectorAll(){return []},
};
}
const els = {
composerReasoningWrap: makeEl(),
composerReasoningLabel: makeEl(),
composerReasoningChip: makeEl(),
composerReasoningDropdown: makeEl(),
};
els.composerReasoningWrap.style.display = 'none'; // mirrors the HTML default
global.window = {};
global.document = {
createElement: () => makeEl(),
addEventListener: () => {},
querySelectorAll: () => [],
querySelector: () => null,
};
global.$ = id => els[id] || null;
global.api = () => ({ then: () => ({ catch: () => {} }), catch: () => {} });
var _profileTransitionReasoningContext = null;
function extractFunc(name) {
const re = new RegExp('function\\s+' + name + '\\s*\\(');
const start = src.search(re);
if (start < 0) throw new Error(name + ' not found');
let i = src.indexOf('{', start);
let depth = 1; i++;
while (depth > 0 && i < src.length) {
if (src[i] === '{') depth++;
else if (src[i] === '}') depth--;
i++;
}
return src.slice(start, i);
}
eval(extractFunc('_normalizeReasoningEffort'));
eval(extractFunc('_formatReasoningEffortLabel'));
eval(extractFunc('_highlightReasoningOption'));
eval(extractFunc('_applyReasoningChip'));
const input = JSON.parse(process.argv[3]);
_applyReasoningChip(input);
const result = {
display: els.composerReasoningWrap.style.display,
label: els.composerReasoningLabel.textContent,
inactive: els.composerReasoningChip.classList.contains('inactive'),
title: els.composerReasoningChip.title,
};
process.stdout.write(JSON.stringify(result));
"""
@pytest.fixture(scope="module")
def driver_path(tmp_path_factory):
p = tmp_path_factory.mktemp("reasoning_driver") / "driver.js"
p.write_text(_DRIVER_SRC, encoding="utf-8")
return str(p)
def _apply(driver_path, value):
"""Run _applyReasoningChip(value) against the actual ui.js."""
import json as _json
result = subprocess.run(
[NODE, driver_path, str(UI_JS_PATH), _json.dumps(value)],
capture_output=True, text=True, timeout=30,
)
if result.returncode != 0:
raise RuntimeError(f"node driver failed: {result.stderr}")
return _json.loads(result.stdout)
# ─────────────────────────────────────────────────────────────────────────────
# The chip MUST stay visible for every effort state (issue #1068). This used
# to be hidden for !eff and 'none', and the source-regex tests in
# test_reasoning_chip_btw_fixes.py verify the literal `display='none'` is gone
# — but only a behavioural check confirms the wrap actually receives `''`.
# ─────────────────────────────────────────────────────────────────────────────
class TestChipAlwaysVisible:
def test_empty_string_shows_chip_with_default_label(self, driver_path):
out = _apply(driver_path, "")
assert out["display"] == "", f"empty effort must show the chip: {out}"
assert out["label"] == "Default"
assert out["inactive"] is True
def test_null_shows_chip_with_default_label(self, driver_path):
out = _apply(driver_path, None)
assert out["display"] == ""
assert out["label"] == "Default"
assert out["inactive"] is True
def test_none_shows_chip_with_none_label(self, driver_path):
"""The bug from #1068 — 'none' must NOT hide the chip."""
out = _apply(driver_path, "none")
assert out["display"] == "", (
f"'none' must show the chip (the regression that started #1068): {out}"
)
assert out["label"] == "None"
assert out["inactive"] is True
def test_low_shows_chip_active(self, driver_path):
out = _apply(driver_path, "low")
assert out["display"] == ""
assert out["label"] == "Low"
assert out["inactive"] is False
def test_high_shows_chip_active(self, driver_path):
out = _apply(driver_path, "high")
assert out["display"] == ""
assert out["label"] == "High"
assert out["inactive"] is False
class TestNormalizationEdgeCases:
"""Pin the input-normalisation contract so it can't silently shift."""
def test_uppercase_normalises(self, driver_path):
# Even though the API and slash command use lowercase, defensive
# normalisation matters — copy/paste of an uppercase value or a
# mis-cased server response shouldn't break the chip.
out = _apply(driver_path, "NONE")
assert out["label"] == "None"
assert out["inactive"] is True
def test_whitespace_trimmed(self, driver_path):
out = _apply(driver_path, " none ")
assert out["label"] == "None"
assert out["inactive"] is True
def test_unknown_value_falls_through_visible(self, driver_path):
# Defensive: unknown effort still shows the chip rather than hiding.
out = _apply(driver_path, "banana")
assert out["display"] == ""
assert out["label"] == "Banana"
assert out["inactive"] is False
class TestTitleAttributeAccessibility:
"""The chip's `title` is the hover tooltip and a screen-reader hint —
confirm it always carries the current state in human-readable form."""
def test_title_has_default_label_for_unset(self, driver_path):
out = _apply(driver_path, "")
assert out["title"] == "Reasoning effort: Default"
def test_title_has_none_label_for_none(self, driver_path):
out = _apply(driver_path, "none")
assert out["title"] == "Reasoning effort: None"
def test_title_has_active_label_for_high(self, driver_path):
out = _apply(driver_path, "high")
assert out["title"] == "Reasoning effort: High"
# ─────────────────────────────────────────────────────────────────────────────
# supports_thinking_toggle (ZAI GLM-4.5–5.1): the chip must STAY visible when
# supported_efforts is empty but the model still accepts the thinking on/off
# toggle. Without this, the gate regresses GLM-4.5/4.6/5.0/5.1 users who today
# can toggle thinking on/off (#6219 round-2 review). GLM-4.7 (forced thinking)
# passes supports_thinking_toggle:false and the chip hides as intended.
# ─────────────────────────────────────────────────────────────────────────────
_DRIVER_META_SRC = r"""
const fs = require('fs');
const src = fs.readFileSync(process.argv[2], 'utf8');
function makeEl() {
return {
style: {},
_attrs: {},
setAttribute(k, v){this._attrs[k] = String(v)},
getAttribute(k){return this._attrs[k]},
classList: {
_set: new Set(),
add(c){this._set.add(c)},
remove(c){this._set.delete(c)},
toggle(c, on){
const want = on === undefined ? !this._set.has(c) : Boolean(on);
if (want) this._set.add(c); else this._set.delete(c);
},
contains(c){return this._set.has(c)},
},
dataset: {},
title: '',
textContent: '',
querySelectorAll(){return []},
};
}
const els = {
composerReasoningWrap: makeEl(),
composerReasoningLabel: makeEl(),
composerReasoningChip: makeEl(),
composerReasoningDropdown: makeEl(),
};
els.composerReasoningWrap.style.display = 'none';
global.window = {};
global.document = {
createElement: () => makeEl(),
addEventListener: () => {},
querySelectorAll: () => [],
querySelector: () => null,
};
global.$ = id => els[id] || null;
global.api = () => ({ then: () => ({ catch: () => {} }), catch: () => {} });
var _profileTransitionReasoningContext = null;
function extractFunc(name) {
const re = new RegExp('function\\s+' + name + '\\s*\\(');
const start = src.search(re);
if (start < 0) throw new Error(name + ' not found');
let i = src.indexOf('{', start);
let depth = 1; i++;
while (depth > 0 && i < src.length) {
if (src[i] === '{') depth++;
else if (src[i] === '}') depth--;
i++;
}
return src.slice(start, i);
}
eval(extractFunc('_normalizeReasoningEffort'));
eval(extractFunc('_formatReasoningEffortLabel'));
eval(extractFunc('_highlightReasoningOption'));
eval(extractFunc('_applyReasoningOptions'));
eval(extractFunc('_applyReasoningChip'));
const input = JSON.parse(process.argv[3]);
_applyReasoningChip(input.effort, input.meta);
const result = {
display: els.composerReasoningWrap.style.display,
label: els.composerReasoningLabel.textContent,
};
process.stdout.write(JSON.stringify(result));
"""
@pytest.fixture(scope="module")
def driver_meta_path(tmp_path_factory):
p = tmp_path_factory.mktemp("reasoning_meta_driver") / "driver_meta.js"
p.write_text(_DRIVER_META_SRC, encoding="utf-8")
return str(p)
def _apply_meta(driver_meta_path, effort, meta):
"""Run _applyReasoningChip(effort, meta) against the actual ui.js."""
import json as _json
payload = _json.dumps({"effort": effort, "meta": meta})
result = subprocess.run(
[NODE, driver_meta_path, str(UI_JS_PATH), payload],
capture_output=True, text=True, timeout=30,
)
if result.returncode != 0:
raise RuntimeError(f"node meta driver failed: {result.stderr}")
return _json.loads(result.stdout)
class TestSupportsThinkingToggleVisibility:
"""#6219 round-2: empty effort ladder must NOT hide the chip when the model
still accepts the thinking on/off toggle (GLM-4.5–5.1 on native zai)."""
def test_empty_efforts_with_toggle_stays_visible(self, driver_meta_path):
"""GLM-4.6 (thinking-only tier): empty ladder + toggle=True → chip stays."""
out = _apply_meta(
driver_meta_path,
"",
{"supported_efforts": [], "supports_thinking_toggle": True},
)
assert out["display"] == "", (
"GLM-4.5–5.1 accept thinking:{type:enabled|disabled} per Z.AI docs; "
f"the chip must stay visible for the On/None control: {out}"
)
def test_empty_efforts_forced_thinking_hides_chip(self, driver_meta_path):
"""GLM-4.7 (forced thinking): empty ladder + toggle=False → chip hides."""
out = _apply_meta(
driver_meta_path,
"",
{"supported_efforts": [], "supports_thinking_toggle": False},
)
assert out["display"] == "none", (
"glm-4.7 forces thinking on — neither toggle nor ladder should be "
f"offered; chip must hide: {out}"
)
def test_effort_ladder_stays_visible_regardless_of_toggle(self, driver_meta_path):
"""GLM-5.2 (full ladder): effort ladder present → chip stays visible
whether or not the toggle flag is set (effort implies toggle)."""
out = _apply_meta(
driver_meta_path,
"max",
{"supported_efforts": ["minimal", "low", "medium", "high", "xhigh", "max"],
"supports_thinking_toggle": True},
)
assert out["display"] == ""
out2 = _apply_meta(
driver_meta_path,
"max",
{"supported_efforts": ["minimal", "low", "medium", "high", "xhigh", "max"],
"supports_thinking_toggle": False},
)
assert out2["display"] == "", (
"effort ladder alone is sufficient to show the chip regardless of toggle"
)
def test_default_toggle_undefined_keeps_prior_behavior(self, driver_meta_path):
"""When supports_thinking_toggle is absent (legacy responses), the chip
visibility must follow the prior contract: empty efforts → hide."""
out = _apply_meta(driver_meta_path, "", {"supported_efforts": []})
# No supports_thinking_toggle in meta → undefined → treated as true →
# chip stays visible. This is the conservative default: a missing flag
# must not newly hide the chip for older response shapes.
assert out["display"] == "", (
"absent supports_thinking_toggle defaults to visible (prior behavior "
f"for legacy responses without the field): {out}"
)
# ─────────────────────────────────────────────────────────────────────────────
# Round-3 #6219: the thinking toggle must be TWO-WAY for GLM-4.5–5.1 (thinking
# tier). The dropdown must expose both Default (on = clear override) and None
# (off). The earlier round-2 fix kept the chip visible but the only rendered
# option was "None" — so a user could turn thinking OFF but never back ON.
# These tests drive the actual _applyReasoningOptions against a simulated
# dropdown containing all 8 options from static/index.html (Default/none/
# minimal/low/medium/high/xhigh/max) and pin which are visible per tier.
# ─────────────────────────────────────────────────────────────────────────────
_DRIVER_OPTIONS_SRC = r"""
const fs = require('fs');
const src = fs.readFileSync(process.argv[2], 'utf8');
function makeOption(effort) {
return {
style: {display: ''},
dataset: {effort: effort},
classList: {
_set: new Set(),
add(c){this._set.add(c)},
remove(c){this._set.delete(c)},
toggle(c, on){ const w = on === undefined ? !this._set.has(c) : Boolean(on); if(w) this._set.add(c); else this._set.delete(c); },
contains(c){return this._set.has(c)},
},
_attrs: {},
setAttribute(k, v){this._attrs[k] = String(v)},
getAttribute(k){return this._attrs[k]},
};
}
function makeEl() {
return {
style: {},
_attrs: {},
setAttribute(k, v){this._attrs[k] = String(v)},
getAttribute(k){return this._attrs[k]},
classList: {
_set: new Set(),
add(c){this._set.add(c)}, remove(c){this._set.delete(c)},
toggle(c, on){ const w = on === undefined ? !this._set.has(c) : Boolean(on); if(w) this._set.add(c); else this._set.delete(c); },
contains(c){return this._set.has(c)},
},
dataset: {},
title: '', textContent: '',
};
}
// Options mirror static/index.html's composerReasoningDropdown exactly.
const options = ['', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].map(makeOption);
const optionEls = options;
const els = {
composerReasoningWrap: makeEl(),
composerReasoningLabel: makeEl(),
composerReasoningChip: makeEl(),
composerReasoningDropdown: { querySelectorAll(sel){ return sel === '.reasoning-option' ? optionEls : []; } },
};
els.composerReasoningWrap.style.display = 'none';
global.window = {};
global.document = {
createElement: () => makeEl(),
addEventListener: () => {},
querySelectorAll: () => [],
querySelector: () => null,
};
global.$ = id => els[id] || null;
global.api = () => ({ then: () => ({ catch: () => {} }), catch: () => {} });
var _profileTransitionReasoningContext = null;
function extractFunc(name) {
const re = new RegExp('function\\s+' + name + '\\s*\\(');
const start = src.search(re);
if (start < 0) throw new Error(name + ' not found');
let i = src.indexOf('{', start);
let depth = 1; i++;
while (depth > 0 && i < src.length) {
if (src[i] === '{') depth++;
else if (src[i] === '}') depth--;
i++;
}
return src.slice(start, i);
}
eval(extractFunc('_normalizeReasoningEffort'));
eval(extractFunc('_formatReasoningEffortLabel'));
eval(extractFunc('_highlightReasoningOption'));
eval(extractFunc('_applyReasoningOptions'));
eval(extractFunc('_applyReasoningChip'));
const input = JSON.parse(process.argv[3]);
_applyReasoningChip(input.effort, input.meta);
const visible = optionEls
.filter(o => o.style.display !== 'none')
.map(o => o.dataset.effort === '' ? 'Default' : o.dataset.effort);
process.stdout.write(JSON.stringify({visible: visible}));
"""
@pytest.fixture(scope="module")
def driver_options_path(tmp_path_factory):
p = tmp_path_factory.mktemp("reasoning_options_driver") / "driver_options.js"
p.write_text(_DRIVER_OPTIONS_SRC, encoding="utf-8")
return str(p)
def _visible_options(driver_options_path, effort, meta):
"""Return the list of visible dropdown labels after _applyReasoningChip."""
import json as _json
payload = _json.dumps({"effort": effort, "meta": meta})
result = subprocess.run(
[NODE, driver_options_path, str(UI_JS_PATH), payload],
capture_output=True, text=True, timeout=30,
)
if result.returncode != 0:
raise RuntimeError(f"node options driver failed: {result.stderr}")
return _json.loads(result.stdout)["visible"]
class TestTwoStateToggleControl:
"""#6219 round-3: GLM-4.5–5.1 (thinking tier) must have BOTH Default (on)
and None (off) — the toggle is two-way, not off-only."""
def test_thinking_tier_offers_both_default_and_none(self, driver_options_path):
"""GLM-4.6: empty ladder + toggle=True → Default + None both visible."""
visible = _visible_options(
driver_options_path,
"",
{"supported_efforts": [], "supports_thinking_toggle": True},
)
assert "Default" in visible, (
f"Default (re-enable thinking) must be shown for thinking-tier models; "
f"got {visible}"
)
assert "none" in visible, (
f"None (disable thinking) must be shown; got {visible}"
)
# No effort levels for the thinking-only tier.
for level in ["minimal", "low", "medium", "high", "xhigh", "max"]:
assert level not in visible, (
f"thinking-tier must not show effort level {level}; got {visible}"
)
def test_effort_tier_offers_default_none_and_ladder(self, driver_options_path):
"""GLM-5.2: full ladder + toggle → Default, None, AND all 6 levels."""
visible = _visible_options(
driver_options_path,
"high",
{"supported_efforts": ["minimal", "low", "medium", "high", "xhigh", "max"],
"supports_thinking_toggle": True},
)
assert "Default" in visible
assert "none" in visible
for level in ["minimal", "low", "medium", "high", "xhigh", "max"]:
assert level in visible, f"effort tier must show {level}; got {visible}"
def test_off_then_on_round_trip_options(self, driver_options_path):
"""Simulate the user flow: Default → click None → click Default.
At every step, BOTH Default and None must remain visible."""
meta_toggle = {"supported_efforts": [], "supports_thinking_toggle": True}
# Start: Default selected (effort=''), chip shows Default+None
v1 = _visible_options(driver_options_path, "", meta_toggle)
# User clicks None (effort='none')
v2 = _visible_options(driver_options_path, "none", meta_toggle)
# User clicks Default again (effort='')
v3 = _visible_options(driver_options_path, "", meta_toggle)
for i, v in enumerate([v1, v2, v3], 1):
assert "Default" in v and "none" in v, (
f"step {i}: both Default and None must remain visible throughout "
f"the round trip; got {v}"
)