* 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>
544 lines
21 KiB
Python
544 lines
21 KiB
Python
"""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}"
|
||
)
|