Skip to content

fix(reasoning): gate reasoning_effort ladder to GLM-5.2+ on native zai provider - #6219

Closed
rh-id wants to merge 4 commits into
nesquena:masterfrom
rh-id:fix/zai-reasoning-effort-gating
Closed

rh-id wants to merge 4 commits into
nesquena:masterfrom
rh-id:fix/zai-reasoning-effort-gating

Conversation

@rh-id

@rh-id rh-id commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Problem

Z.AI's official API (docs.z.ai) defines two distinct parameters:

  • thinking: {"type": "enabled"|"disabled"} — the reasoning on/off toggle, supported by GLM-4.5 and above (with GLM-4.7 using forced thinking that cannot be disabled).
  • reasoning_effort — the effort intensity ladder (max/xhigh/high/medium/low/minimal), supported by GLM-5.2 and above ONLY.

hermes-webui advertised the full 6-level reasoning_effort ladder (plus the none sentinel) for all 7 GLM models, because _candidate_supports_reasoning has an unconditional glm token match (api/config.py:3303, no version gate — unlike the GPT/Claude/Qwen branches above it which check major version) and _filter_reasoning_efforts_for_provider had no ZAI branch. Six of seven catalog models therefore showed a selector whose values Z.AI documents as GLM-5.2-exclusive, and the chosen value was forwarded toward an endpoint that silently ignores it.

Two bugs result:

  • Bug 1reasoning_effort advertised for glm-5.1, glm-5, glm-5-turbo, glm-4.5, glm-4.5-flash (none support it; only glm-5.2 does).
  • Bug 3glm-4.7 uses forced thinking (cannot be disabled per Z.AI docs), yet none and all effort levels were shown for it.

Fix (one targeted branch in the existing chokepoint)

Add a ZAI branch to _filter_reasoning_efforts_for_provider (api/config.py), mirroring the existing OpenAI/Gemini/Anthropic ceiling pattern:

if provider == "zai" and "glm" in bare:
    # GLM-4.7 family: forced thinking — reasoning is not configurable at all.
    if bare.startswith("glm-4.7"):
        return []
    m = re.search(r"glm-(\d+)(?:\D+(\d+))?", bare)
    if m:
        major = int(m.group(1))
        minor = int(m.group(2)) if m.group(2) else 0
        if (major, minor) >= (5, 2):
            return normalized  # GLM-5.2+ keeps the full ladder
    return []  # Everything else (4.5, 4.5-flash, 5, 5.1, 5-turbo)

Why this is the right scope (per guideline #1 — fix the class, not the instance)

  • resolve_model_reasoning_efforts is the single source feeding both the UI dropdown options AND coerce_reasoning_effort_for_model clamping (both call through _filter_reasoning_efforts_for_provider). Fixing it here makes the dropdown and coercion agree automatically — no stored max will be degraded incorrectly, because it won't be offered in the first place.
  • Mirrors the existing per-provider ceiling branches exactly (OpenAI GPT-5→xhigh, Gemini drop-max, pre-adaptive Claude drop-max).
  • The glm family-detection heuristic at line 3303 is deliberately left unchanged — GLM models DO support the thinking on/off toggle at the family level (that flag drives the thinking-toggle UI too). The bug is specifically about the reasoning_effort intensity ladder, which is what the filter narrows.
  • Scoped to the native zai provider only (aliases glm/z-ai/z.ai/zhipu all funnel to zai via _resolve_provider_alias, which the function already calls). Aggregator providers (openrouter/kilocode/custom) are untouched because they route through their own routers, not Z.AI's native endpoint.

Contract Routing

State layer touched: agent.reasoning_effort config (config.yaml) + UI dropdown options derived from resolve_model_reasoning_efforts.

Invariant proof: UI options and coercion now agree and match Z.AI's per-model docs — max/xhigh/high/medium/low/minimal are offered ONLY for GLM-5.2+ (whose accepted values match VALID_REASONING_EFFORTS exactly, max being the Z.AI default), and none is never offered for forced-thinking GLM-4.7. The downgrade ladder in coerce_reasoning_effort_for_model is unaffected because it only walks down from levels that ARE in the offered list.

Verification

Regression gate satisfied (per guideline #6): 12 of the 24 new tests fail before the fix (the full ladder was returned for every GLM model), all 24 pass after. Verified via stash/unstash on a clean tree.

24 new tests in tests/test_zai_reasoning_effort_gating.py:

  • GLM-5.2 keeps the full 6-level ladder + none
  • Future GLM ≥ 5.2 (5.3, 5.2-air, 6-pro, 6, 5.2.1) keep the full ladder
  • Bug 1: pre-5.2 GLM (5.1, 5, 5-turbo, 4.5, 4.5-flash) → []
  • Bug 3: GLM-4.7 + glm-4.7-air (forced thinking) → [] (no none either)
  • All 4 aliases (glm/z-ai/z.ai/zhipu) resolve through the same gate
  • Aggregators (kilocode/openrouter/custom:newapi) keep family-level reasoning — keeps existing test_generalized_model_families_and_suffixed_ids green
  • Non-GLM models on zai provider are untouched by the GLM-specific gate
  • Coercion agrees with advertising: stored max for glm-5.1 downgrades, max for glm-5.2 preserves

Broader suite: 129/129 pass across test_zai_reasoning_effort_gating + test_custom_provider_bare_model_reasoning + test_reasoning_effort_model_capabilities + test_reasoning_show_hide + test_catalog_has_provider_compound_ids + test_4413_seed_provider_models. No new lint errors (the 4 pre-existing ruff errors in api/config.py reproduce identically on clean master).

Pre-existing failure noted (NOT caused by this PR): test_custom_providers_in_panel.py::test_custom_provider_with_models fails under multi-file pytest ordering due to a provider-cache state leak across files — confirmed to reproduce identically on clean master. Passes in isolation.

Manual verification I could not do here

  • Browser: confirm the reasoning chip dropdown shows all levels for glm-5.2 and shows nothing for glm-4.7/glm-4.5/glm-5.1 (requires UI + running backend).
  • End-to-end: confirm a request to Z.AI for glm-5.2 with reasoning_effort: max succeeds, and for glm-4.5 the field is now omitted rather than silently ignored (requires GLM_API_KEY + network).

Out of scope — Bug 2 (separate issue)

There is a related gap I deliberately did not address here: no code path in this repo emits the thinking: {"type": "enabled"|"disabled"} request field for ZAI. That translation lives in the external run_agent/agent package (_build_api_kwargs()) or the Hermes Gateway server — neither of which is present in this repository (verified: import run_agent / import agent both raise ModuleNotFoundError; no sibling hermes-agent repo on disk). Investigating/implementing that belongs in a separate change against the agent tree, not here. Filing a separate issue to track it.

Sources

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.
@greptile-apps

greptile-apps Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR gates the reasoning_effort intensity ladder to GLM-5.2+ on the native ZAI provider, fixing two bugs: pre-5.2 GLM models (5.1, 5, 5-turbo, 4.5, 4.5-flash) were incorrectly advertising the full ladder, and GLM-4.7 (forced thinking) was showing a none option that has no effect. It also addresses the previously-flagged coercion gaps — stored "none" for forced-thinking GLM-4.7 is now intercepted before the generic early-return, and non-max stored efforts for pre-5.2 GLM now coerce to "" rather than being forwarded to Z.AI unchanged.

  • A new _zai_glm_classification helper introduces a clean three-tier model ("effort" / "thinking" / "forced") that is shared across the filter, coercion, and status paths, keeping them in sync.
  • The thinking-toggle-only tier (GLM-4.5–5.1) is handled end-to-end: a new supports_thinking_toggle field flows from get_reasoning_status through the JS chip logic, keeping the on/off control visible (with a new "Default" option in the dropdown) even when the effort ladder is empty.
  • set_reasoning_effort now accepts an empty string as "clear the override" to complete the two-way re-enable path.

Confidence Score: 4/5

The core fix is well-scoped and the previously-flagged coercion gaps are closed. One misleading comment in ui.js about default toggle behavior is the only remaining finding.

Both previously-flagged coercion issues are properly addressed: the 'none' bypass for GLM-4.7 is now intercepted before the generic early-return, and all non-max stored effort levels for pre-5.2 GLM now coerce to ''. The three-tier classification helper is clean and shared across the filter, coercion, and status surfaces. The one remaining finding is a documentation inaccuracy in ui.js — the undefined→true default for _currentReasoningToggleSupported is labeled 'prior behavior' but is actually a new policy that differs from old behavior (empty supported_efforts previously hid the chip).

static/ui.js — the _currentReasoningToggleSupported initialization comment should be updated to accurately describe the new default-to-visible policy.

Important Files Changed

Filename Overview
api/config.py Adds three new helper functions (_zai_glm_classification, _zai_glm_reasoning_efforts_supported, _zai_glm_thinking_toggle_supported), wires ZAI gate into _filter_reasoning_efforts_for_provider and coerce_reasoning_effort_for_model, adds forced-thinking early exit in resolve_model_reasoning_efforts, and extends get_reasoning_status with supports_thinking_toggle. The two previously-flagged coercion gaps are both addressed.
static/ui.js Adds _currentReasoningToggleSupported state variable, updates chip visibility logic to OR effort ladder with thinking toggle, always surfaces Default (effort='') and None in the dropdown, and fixes the click handler to use opt presence rather than effort truthiness. The undefined→true default introduces a subtle behavioral change from the old code.
static/index.html Adds a 'Default' option (data-effort='') as the first item in the reasoning dropdown, enabling the two-way thinking toggle for GLM-4.5–5.1 models.
tests/test_zai_reasoning_effort_gating.py New test file with 24 parametrized tests covering the three-tier classification, alias resolution, aggregator passthrough, coercion agreement, and set_reasoning_effort empty-accept path.
tests/test_reasoning_chip_js_behaviour.py Adds two new test classes (TestSupportsThinkingToggleVisibility, TestTwoStateToggleControl) that drive the actual ui.js functions through Node.js sub-processes, verifying chip visibility and two-way toggle option rendering across all three GLM tiers.
tests/test_reasoning_show_hide.py Updates test_set_reasoning_effort_rejects_invalid to reflect the new accepted-empty contract; removes the assertion that set_reasoning_effort('') raises ValueError and adds a positive test that it completes without error.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["Model + Provider ID"] --> B["_resolve_provider_alias()"]
    B --> C{"provider == 'zai'?"}
    C -- No --> D["Other provider rules\n(OpenAI ceiling, Gemini, Anthropic)"]
    C -- Yes --> E{"'glm' in bare id?"}
    E -- No --> D
    E -- Yes --> F{"bare.startswith('glm-4.7')?"}
    F -- Yes --> G["'forced'\nNeither toggle nor ladder"]
    F -- No --> H["re.search version\nmajor, minor"]
    H --> I{"(major,minor) >= (5,2)?"}
    I -- Yes --> J["'effort'\nFull ladder + toggle\nGLM-5.2+"]
    I -- No --> K{"(major,minor) >= (4,5)?"}
    K -- Yes --> L["'thinking'\nToggle only, no ladder\nGLM-4.5-5.1"]
    K -- No --> M["None\nNo thinking support\nGLM-4 and below"]
    J --> N["resolve_model_reasoning_efforts\n-> [minimal..max]"]
    L --> O["resolve_model_reasoning_efforts\n-> []"]
    G --> P["resolve_model_reasoning_efforts\n-> [] (forced exit)"]
    N --> Q["get_reasoning_status\nsupports_thinking_toggle: true"]
    O --> R["get_reasoning_status\nsupports_thinking_toggle: true"]
    P --> S["get_reasoning_status\nsupports_thinking_toggle: false"]
    Q --> T["UI: Full effort dropdown"]
    R --> U["UI: Default + None only"]
    S --> V["UI: Chip hidden"]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A["Model + Provider ID"] --> B["_resolve_provider_alias()"]
    B --> C{"provider == 'zai'?"}
    C -- No --> D["Other provider rules\n(OpenAI ceiling, Gemini, Anthropic)"]
    C -- Yes --> E{"'glm' in bare id?"}
    E -- No --> D
    E -- Yes --> F{"bare.startswith('glm-4.7')?"}
    F -- Yes --> G["'forced'\nNeither toggle nor ladder"]
    F -- No --> H["re.search version\nmajor, minor"]
    H --> I{"(major,minor) >= (5,2)?"}
    I -- Yes --> J["'effort'\nFull ladder + toggle\nGLM-5.2+"]
    I -- No --> K{"(major,minor) >= (4,5)?"}
    K -- Yes --> L["'thinking'\nToggle only, no ladder\nGLM-4.5-5.1"]
    K -- No --> M["None\nNo thinking support\nGLM-4 and below"]
    J --> N["resolve_model_reasoning_efforts\n-> [minimal..max]"]
    L --> O["resolve_model_reasoning_efforts\n-> []"]
    G --> P["resolve_model_reasoning_efforts\n-> [] (forced exit)"]
    N --> Q["get_reasoning_status\nsupports_thinking_toggle: true"]
    O --> R["get_reasoning_status\nsupports_thinking_toggle: true"]
    P --> S["get_reasoning_status\nsupports_thinking_toggle: false"]
    Q --> T["UI: Full effort dropdown"]
    R --> U["UI: Default + None only"]
    S --> V["UI: Chip hidden"]
Loading

Reviews (4): Last reviewed commit: "fix(reasoning): make ZAI thinking toggle..." | Re-trigger Greptile

Comment thread tests/test_zai_reasoning_effort_gating.py Outdated
Address Greptile review on nesquena#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 nesquena#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 nesquena#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.
@rh-id

rh-id commented Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @greptile-apps[bot] for the review — both P2 findings were real and are addressed in 48fdac7.

1. Vacuous test assertion — fixed

test_glm_5_2_preserves_none_sentinel had an or fallback (set(efforts) == {6 levels}) that was always true given the preceding test, so the assertion could never fail. You were right that the intent ("none must survive filtering") was never actually enforced.

Rewrote to inject none via the raw source by mocking _resolve_model_reasoning_efforts_impl to return ["none", "minimal", ..., "max"], then asserting "none" in efforts. This genuinely exercises the preservation branch in resolve_model_reasoning_efforts (which strips none before calling _filter_reasoning_efforts_for_provider, then re-attaches it from its original position). The test now fails if that re-attachment ever breaks for GLM-5.2.

2. Coercion gap for non-max stored levels — fixed

This was the more important catch. My original PR claimed "UI options and coercion now agree," but that only held for max. A stored high/medium/low/xhigh/minimal for glm-5.1/glm-4.5/glm-4.5-flash/glm-5/glm-5-turbo/glm-4.7 on native zai was forwarded to Z.AI unchanged (silently ignored there) — because the existing if ceiling and raw not in ceiling guard at line 3935 treats an empty ceiling as "no rule," preserving the configured effort verbatim per #3505.

Root cause: my ZAI gate returns [] to mean "known-empty" (this model is documented as not supporting reasoning_effort at all), but the coercion path treated every [] as "ambiguous/unknown, preserve verbatim." The two semantics collided.

Fix: extracted the ZAI decision into a shared helper _zai_glm_reasoning_efforts_supported(model_id, provider_id) -> bool | None — returns True (GLM-5.2+), False (pre-5.2 / forced-thinking GLM-4.7), or None (not a native-zai GLM case, defer to other rules). Both _filter_reasoning_efforts_for_provider and coerce_reasoning_effort_for_model consume it. In coercion, a known-False result now returns "" (send no reasoning_effort field) before the #3505 preserve-verbatim path runs. This is the "fix the class, not the instance" approach — one decision function, two consumers, can't drift.

#3505 behavior preserved: a genuinely-unknown model on a non-zai provider (e.g. some-brand-new-model-9999 on custom:myrouter) still preserves the configured effort verbatim — the ZAI gate returns None for it and the original ambiguous-empty path runs unchanged. New regression test test_coerce_unchanged_for_unknown_non_zai_models pins this.

Regression gate (per guideline #6)

10 new coercion tests fail before the coercion fix, all pass after (verified via stash):

Suite total: 34 tests (was 24), all green. 132/132 pass across the ZAI + neighboring reasoning/catalog suites. No new lint errors.

The invariant now fully holds end-to-end: the UI offers no reasoning_effort options for pre-5.2 GLM models, AND the coercion sends no reasoning_effort field for any stored level on those models.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Great catch on the real problem — the WebUI genuinely does over-advertise the reasoning_effort ladder for sub-5.2 GLM models, and your version parsing is solid (the gate verified canonical 4.5–6.x, casing, namespaces, provider prefixes, and GLM-5.2 none all resolve correctly). But the gate reproduced one SILENT regression the current shape introduces, so I'm bouncing with the fix — it's a small scoping change.

SILENT — sub-5.2 GLM users lose the working thinking on/off control — api/config.py:3471static/ui.js:4932-4938

Per your own premise: thinking: {enabled|disabled} is supported by GLM-4.5+, only the reasoning_effort ladder is 5.2+. But gating by returning [] for the effort list makes the frontend hide the entire reasoning chip — including the none/off action — because static/ui.js:4932-4938 treats an empty supported_efforts as "no reasoning control at all." So GLM-4.5/4.6/5.0/5.1 users (who today CAN toggle thinking on/off) would lose that control entirely. That's a functional regression for 4 GLM versions, not the intended "hide only the effort levels."

Fix: separate thinking-toggle capability from supported_efforts.

  • Keep the effort ladder empty below GLM-5.2 (your gate is correct there).
  • But retain a binary-thinking capability for GLM-4.5+ except forced-thinking GLM-4.7 (4.7 forces thinking on — no toggle), so the composer still renders an operable On/Default + None control when the effort ladder is empty.
  • In static/ui.js:4932, render the explicit On/None actions when supported_efforts is empty but binary-thinking is supported, instead of hiding the whole chip.

GLM-4.7 (forced thinking) correctly shows neither an off toggle nor an effort ladder. GLM-5.2+ keeps the full ladder + none. Sub-5.2-but-4.5+ keeps On/None only.

Add a test asserting: GLM-4.6 → no effort ladder BUT thinking On/None still available; GLM-4.7 → forced (no off); GLM-5.2 → full ladder + none. Everything else (non-GLM gating, custom/aggregator routing, 127 focused tests) verified unchanged — this is the only blocker. Re-push and I'll re-gate.

@nesquena-hermes nesquena-hermes added the changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address label Jul 18, 2026

@nesquena-hermes nesquena-hermes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Version-parsing solid, but returning [] efforts for sub-5.2 GLM hides the whole reasoning chip incl the thinking on/off toggle that GLM-4.5+ DOES support. Fix: separate thinking-toggle capability from supported_efforts (keep ladder empty <5.2 but retain On/None for 4.5+ except forced-thinking 4.7). Fix-spec posted. Re-gate on re-push.

…ladder empty

Address nesquena-hermes round-2 review on nesquena#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.
@rh-id

rh-id commented Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

Great catch — this was a real silent regression I had missed. The round-1 gate returned [] for the effort ladder on sub-5.2 GLM, which static/ui.js:4932 interprets as "no reasoning control at all," so GLM-4.5/4.6/5.0/5.1 users lost the working thinking on/off toggle they had before. Fixed in a30b907 exactly along the scoping you prescribed.

Fix — decoupled thinking-toggle capability from the effort ladder

Backend (api/config.py)

Refactored the ZAI gate into a single three-tier classifier _zai_glm_classification(model_id, provider_id) -> "effort" | "thinking" | "forced" | None:

  • "effort" — GLM-5.2+ (full ladder + thinking toggle)
  • "thinking" — GLM-4.5 up to but not including 5.2 (toggle only, no ladder)
  • "forced" — GLM-4.7 family (forced thinking, neither)
  • None — non-zai provider, non-GLM model, or pre-4.5 GLM (defer)

This is now the single source of truth. _zai_glm_reasoning_efforts_supported (used by coercion) becomes a thin wrapper returning cls == "effort". A new _zai_glm_thinking_toggle_supported returns cls in {"effort", "thinking"}.

get_reasoning_status gains a supports_thinking_toggle field:

supports_thinking_toggle = bool(supported_efforts) or (zai_thinking is True)

For non-zai providers, zai_thinking is None, so this collapses to bool(supported_efforts) — their chip-visibility behavior is byte-identical to before. The new field only diverges for the native-zai middle tier.

Frontend (static/ui.js)

  • New _currentReasoningToggleSupported state var (default undefined, treated as true so legacy responses without the field do not newly hide the chip).
  • _applyReasoningChip: const supports = hasEffortLadder || toggleSupported. Empty efforts + toggle=true keeps the chip visible (the existing _applyReasoningOptions already shows only the none option when the ladder is empty, so the user gets the operable On/None control you described). Empty efforts + toggle=false (GLM-4.7 forced) hides the chip.
  • 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 (matches the prior reset contract).

Result by tier

Model effort ladder thinking toggle chip
glm-5.2 / glm-5.3 / glm-6 full 6 levels yes full dropdown
glm-5.1 / glm-5 / glm-5-turbo / glm-4.6 / glm-4.5 / glm-4.5-flash / glm-4.5-air empty yes On/None only (the previously-working control, preserved)
glm-4.7 / glm-4.7-air empty no hidden (forced thinking)

Tests (30 new)

  • _zai_glm_classification parametrized across all three tiers + aliases (glm/z-ai/z.ai/zhipu resolve through the same gate) + non-zai/non-GLM defer cases.
  • get_reasoning_status supports_thinking_toggle per tier — including the exact scenarios you asked for: GLM-4.6 → no ladder BUT thinking On/None available; GLM-4.7 → forced (no off); GLM-5.2 → full ladder + none. Plus GLM-4.5-flash/5/5.1/5-turbo, all four aliases, and a regression guard that non-zai providers default to bool(supported_efforts) (no spurious toggle when ZAI gate doesn't fire).
  • Frontend _applyReasoningChip behavior via the existing node driver (extended to pass meta): empty efforts + toggle=true stays visible, toggle=false hides, effort ladder alone is sufficient regardless of toggle flag, absent field keeps prior behavior.

Regression gate

The three-tier classification is structurally new in this round (the helper, the field, and the frontend flag did not exist before), so the per-tier status assertions cannot pass without the fix. The pre-existing chip suite (test_reasoning_chip_js_behaviour, test_reasoning_chip_btw_fixes, test_issue1103_reasoning_chip_visibility, test_issue4650_reasoning_chip_no_storm) all still pass — 190 passed, 1 skipped across the full reasoning + chip surface.

Re-push is in; ready for re-gate.

@rh-id
rh-id requested a review from nesquena-hermes July 18, 2026 08:10
@nesquena-hermes nesquena-hermes added the size:L Large PR (>10 files or >250 LOC) label Jul 18, 2026
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Round-2 re-gate — visibility regression fixed, but the toggle is now one-way

Re-reviewing against my round-1 ask (separate the thinking on/off capability from the effort ladder so returning [] efforts doesn't hide the whole chip for GLM-4.5–5.1). Reading api/config.py at _zai_glm_classification / _zai_glm_thinking_toggle_supported and the static/ui.js chip path at HEAD a30b9079:

The three-tier split is right and the visibility fix lands. get_reasoning_status now emits supports_thinking_toggle (config.py ~4106-4133), and _applyReasoningChip keeps the wrap visible when the ladder is empty but the toggle is live:

const hasEffortLadder=Array.isArray(supportedEfforts)?supportedEfforts.length>0:true;
const supports=hasEffortLadder||toggleSupported;   // ui.js:4941-4943

The node-driven TestSupportsThinkingToggleVisibility cases (empty+toggle → visible, forced-thinking → hidden, absent flag → visible legacy default) pin exactly the contract I asked for. The off-path is also sound end to end: coerce_reasoning_effort_for_model preserves the none sentinel via the early if raw == "none": return "none" (config.py:3985-3986), so thinking-OFF still reaches the wire while effort levels collapse to "".

The residual gap — you can turn thinking OFF but not back ON

For a thinking-toggle model the chip is now visible, but the dropdown is populated by _applyReasoningOptions(supportedEfforts) with supportedEfforts=[] (ui.js:4951). That function only ever un-hides none when the set is empty:

dd.querySelectorAll('.reasoning-option').forEach(function(opt){
  const effort=opt.dataset.effort;
  if(effort==='none'){ opt.style.display=''; return; }   // ui.js:4896-4899
  if(!supported.size){ opt.style.display='none'; return; } // everything else hidden
  ...
});

The dropdown HTML has no selectable "Default"/"On" entry — only none/minimal/low/medium/high/xhigh/max (index.html:757-763). So for GLM-4.6 the dropdown renders a single item, "None". A user can click it to disable thinking, but once off there's no option to re-enable (the "on" state is empty-effort "", which isn't a clickable option). The "On/None control" is effectively None-only.

Recommendation

Give thinking-toggle models a real two-state control. Minimal approach: when supports_thinking_toggle && supported_efforts is empty, have _applyReasoningOptions also surface an explicit "On"/"Default" option (data-effort "") so the dropdown offers On + None. Sketch at ui.js:4892:

function _applyReasoningOptions(supportedEfforts, toggleOnly){
  // toggleOnly (empty ladder + thinking toggle): show only Default(on) + None
  ...
}

Plus a data-effort="" "Default (on)" .reasoning-option in index.html (POST /api/reasoning with effort:'' already round-trips to provider-default). A behavioural test asserting the dropdown exposes both an "on" and a "none" option for {supported_efforts:[], supports_thinking_toggle:true} would lock it in — the current TestSupportsThinkingToggleVisibility only checks wrap visibility, not that the toggle is operable in both directions.

Everything else here is ready to merge once the re-enable path exists. Nice work isolating the three tiers cleanly.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Good progress — the whole-chip-hidden regression is closed (the tri-state forced/thinking/effort now correctly gives GLM-5.2+ the full ladder, and version parsing across 4.5–6.x / casing / namespaces / provider aliases / non-GLM all re-verified clean). But the round-2 reproduction against the actual renderer found two SILENT gaps in the toggle-only path — bouncing with both, then this ships.

SILENT — the thinking toggle is ONE-WAY for GLM-4.5/4.6/5.0/5.1 — static/ui.js:4902

Verified with the real renderer: both before AND after selecting None, the only visible dropdown option is none — there's no On/Default item (static/index.html:757 has no such option, and api/config.py:4247 rejects an empty effort). So a toggle-only GLM user can turn thinking OFF but can never turn it back ON. That's worse than the original bug in a sense (a stuck-off control).

Fix: add a nonempty On/Default action that clears agent.reasoning_effort (backend must accept the clear, not reject empty), render On + None for toggle-only models, and test the off→on→off round trip.

SILENT — GLM-4.7 not consistently forced when none is already stored — api/config.py:3984 (+ :3857)

Verified: when GLM-4.7 has reasoning_effort: none already configured, status + coercion preserve none while reporting supports_thinking_toggle:false → streaming then constructs disabled reasoning, even though 4.7 forces thinking ON and isn't configurable. Separately, api/config.py:3857 reattaches an explicit nonesupported_efforts=['none'] for GLM-4.7 (should be empty/forced).

Fix: check the forced classification BEFORE preserving none (coerce a stored none to default for forced GLM-4.7), and never reattach none to a forced model's supported options.

Everything else re-verified clean (canonical tiers, casing, namespaces, aliases, custom-provider deferral, non-GLM matched master; 79 changed + 62 neighboring tests + node --check pass). Re-push these two + the off→on round-trip test + a forced-4.7-with-stored-none test and I'll re-gate — you're close, this is the last mile on the toggle semantics.

@nesquena-hermes nesquena-hermes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 2: whole-chip-hidden regression closed + version-parse re-verified clean, but 2 SILENT gaps remain: (1) ui.js:4902 thinking is ONE-WAY for 4.5-5.1 (only 'none' option, no On/Default — can't re-enable; backend rejects empty effort); (2) config.py:3984/3857 GLM-4.7 not forced when 'none' already stored (streaming builds disabled reasoning + supported_efforts=['none']). Fix-spec posted.

…ed none

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

Gap nesquena#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 nesquena#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 nesquena#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 nesquena#1 backend: set('') clears the key (no raise), set('garbage') still
  raises, all 7 valid levels still save.
- Gap nesquena#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).
@rh-id

rh-id commented Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

Both SILENT gaps closed in 0855e22, plus a click-handler sibling I found while auditing the toggle path end-to-end.

Gap #1 — One-way toggle fixed (the thinking tier can now re-enable)

Three coordinated changes so GLM-4.5/4.6/5.0/5.1 get a real two-state Default+None control:

Backend (set_reasoning_effort): empty effort is now accepted as "clear the override" — it removes agent.reasoning_effort from config.yaml so the provider default (thinking on) takes effect. Invalid values still raise ValueError. The /api/reasoning POST handler already passed "" through (if effort is not None:), so no route change was needed.

Frontend (static/index.html + _applyReasoningOptions): added a Default option (data-effort="") to the dropdown, and _applyReasoningOptions now always shows both none and "" (alongside the effort ladder). A thinking-tier model (empty ladder + toggle=true) therefore renders Default + None — the operable two-state control you described.

Click handler sibling (ui.js:5106): the existing click guard was if(effort) — falsy for the empty string, so the Default button would have been silently dead even after the HTML/backend changes. Changed to if(opt) (option presence) so data-effort="" clicks actually POST. Without this the round trip would still be one-way off-only despite the other two fixes.

Gap #2 — GLM-4.7 forced + stored none fixed

Two coordinated changes so a forced-thinking model never offers or sends none:

Coercion (coerce_reasoning_effort_for_model): added a forced-tier check BEFORE the generic if raw == "none": return "none" early-return. A stored none now coerces to "" (default = thinking on) for GLM-4.7, so streaming no longer builds disabled reasoning for a forced-thinking model.

Resolver (resolve_model_reasoning_efforts): returns [] early for forced-tier models, skipping the none reattachment block. So even when the raw capability source (provider config / models.dev) lists none, GLM-4.7's supported_efforts stays [] — no none leak to the UI.

End-to-end trace (verified empirically, all models with stored none)

Model coerce(none) supported_efforts status reasoning_effort toggle
glm-5.2 none full ladder none true
glm-4.6 none [] (or ["none"] if raw source lists it) none true
glm-4.7 "" [] "" (default = on) false
glm-4.7-air "" [] "" false

Tests (17 new + 1 updated)

  • Gap Hermes Web UI — Sprints 11-14: multi-provider models, settings, sessi… #2 (6 new): coerce('none', glm-4.7)'' × glm-4.7 + glm-4.7-air; regression guard that glm-5.2/4.6/5.1 still accept none; resolver does NOT reattach none for forced but DOES for thinking-tier; end-to-end get_reasoning_status for forced + stored none.
  • Gap Portability #1 backend (3 new + 1 updated): set('') clears the key without raising; set('garbage') still raises; all 7 valid levels still save. Updated test_reasoning_show_hide.test_set_reasoning_effort_rejects_invalid to reflect the new contract (empty accepted, garbage rejected).
  • Gap Portability #1 frontend (3 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 options visible at every step.

Regression gate (per guideline #6)

  • Gap Hermes Web UI — Sprints 11-14: multi-provider models, settings, sessi… #2 coerce: 3 tests fail without the forced-tier check (coerce_stored_none_to_empty_for_forced_glm[glm-4.7/-air] + get_reasoning_status_forced_glm_with_stored_none_reports_default).
  • Gap Portability #1 backend: test_set_reasoning_effort_accepts_empty_as_clear fails with ValueError: effort is required without the empty-acceptance change.
  • Gap Portability #1 frontend: the Default option and the if(opt) click guard are structurally new — the two-state dropdown tests cannot pass without them.

219 passed, 1 pre-existing skip across the full reasoning + chip + config-cache surface (test_zai_reasoning_effort_gating, test_reasoning_chip_js_behaviour, test_reasoning_chip_btw_fixes, test_issue1103_reasoning_chip_visibility, test_issue4650_reasoning_chip_no_storm, test_reasoning_effort_model_capabilities, test_reasoning_show_hide, test_models_dev_reasoning, test_issue3750_lmstudio_probe_auth, test_issue4650_yaml_config_cache, test_issue3958_reasoning_post_session_context). ui.js syntax valid (node --check). No new lint errors.

Out of scope (noted, not changed)

The CLI /reasoning slash command (static/commands.js) does not expose a default alias — its EFFORTS list is none/minimal/low/medium/high/xhigh/max. The status display already maps empty effort to 'default', so the concept exists CLI-side. Adding a /reasoning default alias would give the CLI parity with the new WebUI Default button, but it's a separate enhancement and outside this bug fix's scope.

Re-push is in; ready for re-gate.

@rh-id
rh-id requested a review from nesquena-hermes July 18, 2026 11:38
nesquena-hermes added a commit that referenced this pull request Jul 18, 2026
* 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>
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Shipped in exp-v0.52.105. Thanks @rh-id — great persistence through 4 gate rounds! 🎉 The final gate verified the full off→on→off toggle round trip for GLM-4.5/4.6/5.0/5.1, GLM-4.7 forced-thinking even with a stored none, GLM-5.2+ full ladder intact, and all 3 surfaces agreeing across tiers/aliases/namespaces.

@rh-id
rh-id deleted the fix/zai-reasoning-effort-gating branch July 20, 2026 12:24
maksym-mishchenko added a commit to maksym-mishchenko/hermes-webui that referenced this pull request Aug 5, 2026
… permissions (#3)

* docs: fold in Rod's PR-review feedback into GUIDELINES + CONTRIBUTING (#6211)

* docs: fold in Rod's PR-review feedback into GUIDELINES + CONTRIBUTING

Based on rodboev's feedback distilled from 50+ recent PRs. Four accepted
points plus one clause, folded into existing rules rather than adding new ones:

- Rule 6: load the reporter's shipped reproduction, don't rebuild a fixture
  from your reading of it (the one genuine hole — a fix and test from the same
  wrong model agree with each other and certify a no-op).
- Rule 2: confirm a value is authoritative (declared at the point of intent),
  not inferred from id prefix / content shape / emptiness / DOM state.
- Rule 4: read prior PRs and review threads to find a subsystem's real variants
  instead of inventing axes from the single case handed to you.
- Rule 1: the chokepoint is the smallest boundary that contains the fault, not
  the widest you can reach (don't disable a whole pipeline to suppress one output).
- "Show your work": name who owns the truth for any claim the repo doesn't own.

CONTRIBUTING.md carries the two contributor-facing points (repro-loading,
proof-ownership) in the PR-description section, deferring detail to GUIDELINES.md.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* docs: tighten Rule 6 escape hatch per Rod — gate on shape under-specified, not file absent

Rod's review: the #5749 no-op came from a fully prose-specified repro (fenced JSON
+ field-level conditions + steps), not a missing file. The old hatch ('why the issue
gave you nothing to load') reads as 'no downloadable attachment', letting someone walk
past a binding JSON block. Gate the hatch on the SHAPE being under-specified instead:
a fenced JSON structure / field conditions / step list pin the shape as bindingly as a
file; if pinned, satisfy every condition and don't add a property the shape never had
to make a guard fire; only say 'constructed, assumed X' when the shape is truly unpinned.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* docs: distill Rule 6 repro-shape guidance into a principle (Rod style note)

Rod: 'distill into durable principles, don't enumerate lists; strip negative
conditions that read like narration.' Reworked the addition to lead with the
principle ('a reproduction is whatever pins the bug's shape'), collapse the
capture/JSON/conditions/steps enumeration into flowing prose, and convert the
'don't add a property...' negative into a positive imperative ('bind your fixture
to that shape: satisfy every condition... instead of granting...'). Same for the
CONTRIBUTING bullet.

---------

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* Release: msg_limit ceiling metadata decoupling (#6214, @webtecnica) (#6216)

* feat: expose msg_limit ceiling via /api/session metadata, frontend reads dynamically (#6177)

Backend exposes _MAX_MSG_LIMIT as _msg_limit_max in every /api/session
response. Frontend reads it dynamically, falling back to _MSG_LIMIT_MAX
for older servers. This removes the hand-mirrored coupling between the
two layers.

Removed test_msg_limit_ceiling_drift.py since the mirror pattern that
required the drift guard is replaced by dynamic metadata.

This is the standalone metadata-decoupling piece from #6206,
without the clamp/paging that already shipped in exp-v0.52.98 via #6152/#6154.

* fix(session): declare _msgLimitMax at module scope + CHANGELOG + gate fixes (#6214 follow-up)

The submitted PR used _msgLimitMax at two read sites (boundedReloadLimit in
_ensureMessagesLoaded, useBeforePaging in _loadOlderMessages) but never declared
it and read it before assignment -> undefined on cold load -> full-transcript
fetch every load (regression) + implicit global. Declared `let _msgLimitMax =
_MSG_LIMIT_MAX;` at module scope so the reload-width paths always read a defined
value (the static fallback) until the server's _msg_limit_max lands.

Also: defined the ceiling globals in the two inline node-harness tests that copy
_ensureMessagesLoaded's body (test_cross_session_message_load_isolation,
test_session_unread_dot_on_visit), updated the source-string assertion in
test_webui_external_refresh_frontend, and added 3 decoupling tests (backend field
present, module-scope declaration + fallback, both paths read the live ceiling)
replacing the deleted drift-guard.

Gate: Codex adversarial SAFE TO SHIP (executable probes: cold-load fallback,
mixed-version omission, live update, cross-session isolation, over-ceiling bare
refresh, msg_before row preservation). Full sharded suite green.

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>

---------

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* Release: Transparent Stream multi-segment prefix dedupe (#6189, @ai-ag2026) (#6217)

* fix(transparent-stream): drop stale final-answer prefix row in multi-segment settle (#5749 follow-up)

A turn with interim assistant messages (prose interleaved with tool calls)
could show the beginning of the final answer TWICE after watching it stream:
once as a settled anchor-scene prose row (the live-token accumulator's last
throttled snapshot) and once as the real assistant segment. The duplicate
persisted until reload.

Root cause: #5758 suppresses the accumulator row only when it sits "after the
last tool row", but _completeSettledAnchorSceneForTurn appends the settled
per-message tool rows AFTER the projected live rows — those re-list tools that
ran EARLIER in the turn, pushing the boundary past the final segment's
accumulator so the guard never fired. The stale prefix snapshot then survived
into the persisted scene and rendered above the settled answer.

Fix: judge final-segment eligibility against the LIVE projection's own
chronology — a live-prose row belongs to the final segment iff no PROJECTED
tool row follows it. Pre-tool narration that happens to prefix the final
answer stays protected (existing #5758 regression tests still pass), and a
new regression test pins the multi-segment shape.

Verified end-to-end by replaying the captured run journal of an affected
session through the real SSE live handlers in headless Chromium: duplicate
before (answer prefix visible twice after settle), gone after; fresh-reload
rendering unchanged.

Rollback: revert this commit; behavior returns to pre-fix (duplicate prefix
row after live-settle of multi-segment turns).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* CHANGELOG: transparent-stream multi-segment prefix dedupe (#6189, @ai-ag2026)

---------

Co-authored-by: ai-ag2026 <m.fuechtenkoetter@posteo.de>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* Live Stream: hydrate ID-linked historical tool turns

* Release: stop false Compressing-context card (#6184, @carlotestor) (#6223)

* fix: stop false "Compressing context" on non-compress turns

Narrow the agent status → SSE compressing bridge to real Hermes
compaction start notices, and stop snapshot hydration inventing a
running compress divider from terminal/lifecycle rows without cues.

Brand-new low-token chats (and skip/cooldown notices) no longer paint
the live auto-compression worklog row.

* CHANGELOG: false compressing-context card fix (#6184)

---------

Co-authored-by: carlotestor <89560945+carlotestor@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* Release: extension session-open handler + renderTranscript API (#5508, @ChonSong) (#6226)

* feat(core): add registerHermesSessionOpenHandler + renderTranscript extension hooks

- registerHermesSessionOpenHandler(fn): register a handler that fires on
  session open. Return {cancel:true} to prevent navigation.
- renderTranscript(container, messages, opts): render messages into any
  DOM container using core's renderMd pipeline. Skip tool messages.
- Wire _hermesNotifySessionOpen into loadSession: pre-load guard at top,
  post-load notification at end for extensions to hook into.
- Follows existing registerHermesTtsEngine extension registration pattern.

This gives extensions like chat-tiling a sanctioned API instead of DOM
hacking to intercept and render session transcripts.

* fix: address 3 gate-fail blockers from PR #5508 review

Fixes the three core issues identified by nesquena-hermes code review:

1. XSS sink in renderTranscript (boot.js)
   - Fallback to textContent when window.renderMd is unavailable
   - innerHTML exclusively for successful renderMd output

2. Stale _loadingSessionId reset in cancel branch (sessions.js)
   - Remove premature nulling of _loadingSessionId
   - Cancel path simply returns without touching loading-guard state

3. Pre-open veto bypasses profile/import side-effects (sessions.js)
   - Move cancellable preload hook to start of _openSidebarSession,
     before external session import and profile switching
   - Pass internal _preloadNotified flag to skip duplicate preload
     in loadSession while retaining post-load notification

Closes #5508 gate-fail items.

* fix: use module-level flag instead of call argument to avoid test regression

The _preloadNotified approach broke test_static_sessions_js_switches_profile
before_opening_all_profiles_row because it changed the loadSession call
signature from loadSession(sid, loadOpts) to loadSession(sid, Object.assign(...)).

Switch to a module-scoped boolean _hermesSessionOpenAlreadyFired set by
_openSidebarSession before calling loadSession, checked by loadSession's
pre-hook guard. The call signature stays unchanged.

Test 2 (test_load_session_rearms_stream_on_every_early_return) also passes.

* fix: compact pre-hook comment to keep loadSession within test window limits

* fix: replace global _hermesSessionOpenAlreadyFired flag with per-call opts._preloadNotified

The module-level boolean introduced in 381fa0ef had two problems:
1. ReferenceError on direct loadSession() calls — the flag was undeclared
   when called outside _openSidebarSession, breaking saved-session restore.
2. Never reset after first sidebar open — all subsequent direct calls
   silently skipped the cancellable preload handler.

Per the maintainer's review (PR #5508), replace the global with a per-call
option _preloadNotified passed by _openSidebarSession via Object.assign.
This keeps the call signature stable for existing tests and eliminates
the stale global state leak.

Test adjustments:
- test_issue1611: widened loadSession call literal assertion
- test_session_channel_option_x: body slice 14000→15000 to accommodate
  the slightly longer function body

* chore: remove unrelated files from commit

* fix: address 3 review issues — preload-only cancel, drop inner wrapper, pass _preloadNotified on retry

- Only honor {cancel:true} when opts.preload===true (boot.js)
- Drop .msg-body-inner wrapper, render directly into .msg-body (boot.js)
- Carry _preloadNotified:true through cross-profile 409 retry (sessions.js)

* recommit

* Update static/boot.js

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix(extensions): resolve canonical sid before preload hook + pass _preloadNotified on continuation retry

PR #5508 review follow-up (review 4690636760):
1. Move _resolveSessionIdFromSidebarLineage() before the preload hook so
   extensions always see the canonical sid, not the raw sidebar click id.
2. Pass _preloadNotified:true on the continuation-session retry path to
   prevent duplicate preload events to extensions.
3. Add functional test_extension_session_hooks.py — actually drives the
   new hook registration, preload-veto, transcript rendering, and
   _preloadNotified bridge in Node (13 tests, all green).

* Remove unused pytest import

Removed unused import of pytest from test file.

* fix: address 2 gate-blocking veto-ordering defects (PR #5508)

Blocker 1 (CORE): cross-profile retry now passes _preloadNotified:true
so the pre-hook doesn't re-fire after destructive side-effects already
ran (stream teardown, message clear, profile switch). A {cancel:true}
on that second fire was stranding the UI profile-switched with a
cleared transcript.

Blocker 2 (SILENT): closeMobileSidebar() was called synchronously
BEFORE _openSidebarSession()'s veto guard in three places (tap-to-open,
child-session, lineage-segment). A {cancel:true} still closed the
sidebar out from under it. Removed the three premature calls; moved
a single closeMobileSidebar() inside _openSidebarSession AFTER the
veto guard so it only runs when the open actually proceeds.

Added 3 regression tests asserting {cancel:true} leaves NO side-effect.

* CHANGELOG: extension session-open handler + renderTranscript API (#5508)

---------

Co-authored-by: Sean <seanos1a@gmail.com>
Co-authored-by: ChonSong <85378550+ChonSong@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* test: cover ordered multi-tool anchor hydration

* Release: durable run-journal recovery + full tool args (#6197, @franksong2702) (#6236)

* fix(streaming): prefer durable run journal recovery

* fix(streaming): reject stale recovery stream scenes

* test(streaming): pin todo recovery metadata guard

* fix(streaming): preserve recovery snapshot tool args

* test(streaming): align journal snapshot args contract

* fix(streaming): bound recovery snapshot tool args

* CHANGELOG: durable run-journal recovery + full tool args (#6197)

---------

Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* Release: folder-download subpath baseURI fix (#6227, @steezypunk) (#6237)

* fix(ui): resolve folder download URL against document.baseURI for subpath support

When Hermes WebUI is served behind a reverse proxy with a path prefix
(e.g. /hermes/), the right-click → "Download Folder" context menu option
navigates to a root-absolute URL (/api/folder/download?...), which resolves
to the server origin instead of the proxy mount point, causing a 404.

This matches the pattern already used by the workspace.js route helper
refactored in v0.52.41 (commit 1a64d7d3).

* CHANGELOG: folder-download subpath baseURI fix (#6227)

---------

Co-authored-by: Steezy <21984836+steezypunk@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* Atomic config.yaml writes to survive mid-write crashes

config.yaml and profile config.yaml were persisted with a plain
Path.write_text(), which truncates the target before writing.  A crash
(or exception) after the truncate but before the full payload was
flushed left the live config truncated/corrupt, so the next agent/WebUI
start failed to parse it (availability regression).

Extract a shared api.paths._atomic_write_text() helper (tempfile in the
same dir -> write -> flush + os.fsync -> os.replace; unlink tmp on
error), mirroring the existing .env / cost-snapshot atomic pattern in
api.providers, and apply it to _save_yaml_config_file (api.config) and
the two profile model-config writers (api.profiles).  On any mid-write
failure os.replace never runs, so the original file stays byte-for-byte
intact.

Preserve the target's permissions: tempfile.mkstemp() hard-codes 0600
and os.replace carries the temp file's mode onto the target, so without
an explicit chmod every save would silently tighten a group/other-
readable config.yaml (the homelab install ships 0644, profiles 0664)
down to owner-only.  Copy the existing file's mode before the replace,
falling back to the umask-adjusted 0666 for a new file.  config.yaml
holds no secrets, so that tightening would be a regression, not
hardening (unlike .env, which stays 0600 in api.providers).

settings.json is intentionally left untouched here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Cover onboarding with atomic config writes

* Handle unsupported ownership transfer in atomic writes

* Preserve hard-linked configs during atomic writes

* fix: preserve config extended attributes

* fix: avoid racy umask probe for config writes

* fix(ctl): detect foreign/supervised WebUI instances instead of double-starting

ctl.sh start only guarded against launchd (macOS). On Linux, with a
systemd-supervised WebUI serving the port and a stale PID file, stop
reported 'stopped', start spawned a bootstrap that died ~2s later on
server.py's 'already responding' check — after the 0.15s aliveness gate
had already printed 'Started' and recorded the doomed PID. Killing the
foreign server by hand then put its supervisor's auto-restart into a
race with ctl.sh's start, ending in a permanent RestartSec crash loop.

- start: refuse when anything answers HTTP(S) on the target port (any
  response bytes, matching server.py's abort semantics — a 404 squatter
  still dooms our server), and when the hermes-webui systemd unit is
  active on our port or mid-auto-restart (activating). Port scoping
  mirrors the launchd #3291 over-block fix; overrides:
  HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT / _ALLOW_PORT_CONFLICT,
  unit name via HERMES_WEBUI_SYSTEMD_UNIT.
- start: watch the child through a startup grace window
  (HERMES_WEBUI_START_GRACE, default 3s) — report failure and clean the
  PID file when it dies during startup; break early once /health answers.
- status/stop: when ctl.sh owns no PID but the port answers, say
  'running (not managed by ctl.sh)' with listener diagnostics instead of
  'stopped', and never touch the foreign process.
- _pid_listens_on_port: ss fallback for Linux hosts without lsof.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ctl): harden the foreign-instance guards per review

Four fixes from the Greptile review on #5944:

- Bracket IPv6 literals in the probe target ('::1' -> '[::1]') so the
  URL-based responder checks don't silently miss a running instance.
- Force direct connections in the local ownership probes: --noproxy '*'
  (curl) / --no-proxy (wget) in _port_answers_http, and neutralized
  proxy env around the startup-grace health probe — a configured
  http(s)_proxy would report the proxy instead of the port.
- Clamp HERMES_WEBUI_START_GRACE=0 to the default: a zero window would
  skip startup monitoring entirely and restore the stale-PID behavior
  the window exists to prevent.
- stop: warn about an unmanaged instance BEFORE deleting the state
  file — it carries the saved host/port binding the probe needs when
  the instance was started off-default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ctl): keep listener diagnostics best-effort

* fix: bypass proxies in ctl startup health probe

* test: allow startup monitor cleanup in dotenv test

* test: give ctl start fixtures startup grace

* 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>

* Release: intercept /sessions and /resume slash commands (#6245, @webtecnica) (#6253)

* fix: intercept /sessions and /resume slash commands in WebUI (#6224)

Add a native-intercept branch in the  block of
send() alongside the /pet special-case. When the user types /sessions
or /resume, expand the sidebar and refresh the session list instead of
sending the raw slash text to the agent.

Root cause: the agent command registry exposes sessions/resume as
non-CLI-only commands, so the autocomplete popup shows them, but the
WebUI send-time dispatch had no branch to catch them, causing the
literal text to be sent as a prompt.

* fix(commands): use mobile-aware session-browser opener for /sessions /resume (gate follow-up)

The intercept called expandSidebar() directly, which is a no-op on phone-width
layouts, so /sessions and /resume silently did nothing on mobile (composer
cleared, nothing shown). Use the mobile-aware _openProfileSwitchSessionBrowser()
first, falling back to expandSidebar(). Reproduced + specified by the pre-release
Codex gate.

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>

* CHANGELOG: intercept /sessions /resume slash commands (#6245)

---------

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* Release: OIDC allowlist whitespace fix (#6244, @webtecnica) (#6259)

* fix: split OIDC allowlist on commas only, preserve scope whitespace-split (#6244)

_normalize_text_list is shared with _normalize_scopes — OAuth scopes
are space-delimited per RFC 6749 §3.3. Created _normalize_allow_values
that splits on commas/newlines only, keeping multi-word group names
like 'Hermes Users' intact.

* fix(oidc): filter blank allow_values list elements + add parser-split test (gate follow-up)

The new comma/newline-only _normalize_allow_values() list-path retained empty
strings that the shared _normalize_text_list() had filtered, so a YAML
allow_values: [""] would brick an OIDC-only deployment (every callback 403s).
Filter stripped-empty collection elements. Adds a regression test asserting
allowlist multi-word preservation, comma/newline splitting, blank filtering,
and that scopes stay space-delimited (RFC 6749 §3.3).

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>

* CHANGELOG: OIDC allowlist whitespace fix (#6244)

---------

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* Release: remove dead rowIndex param from settled-scene pushRow (#6258, @webtecnica) (#6262)

* fix(transparent-stream): remove dead rowIndex param from pushRow (#6189 follow-up)

In #6189 / #6217 the final-segment eligibility was migrated from an
index comparison (rowIndex > lastNonTerminalWorkRowIndex) to a WeakSet
lookup (finalSegmentLiveProseRows.has(row)). The rowIndex parameter on
pushRow became dead code — it's declared but never read, and the
call-site still passes idx from forEach. Remove the unused parameter
and simplify the call-site.

This addresses the greptile review feedback on the original PR.

Closes #6189

* CHANGELOG: dead rowIndex param removal (#6258)

---------

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* Release: byte-size threshold for reconnect tail optimization (#6260, @webtecnica) (#6263)

* perf: optimize large session reconnect by adding file-size threshold to tail optimization (#6241)

When a sidecar JSON file exceeds 500 KB, the display-path tail optimization
now fires even if the message count is within the raw_budget. This prevents
sessions with few messages but large tool outputs (multi-MB JSON) from
forcing a full-scan merge of all messages on reconnect.

Changes:
- Added _sidecar_file_exceeds_threshold() helper
- Added _SIDECAR_BYTE_TAIL_THRESHOLD = 500_000 constant
- Fall-through to truncation in _state_db_since_timestamp_for_limited_display
  when the sidecar file exceeds the threshold, regardless of message count

* CHANGELOG: byte-size reconnect tail optimization (#6260)

---------

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* fix(config): restore read-only-target protection for atomic writes

Review finding 2 (10.07 gate): a deliberately locked config (0444) in a
writable directory was silently overwritten — atomic replace creates a
fresh temp inode and renames over the read-only file, defeating the old
in-place Path.write_text PermissionError contract.

Probe the existing target with a non-truncating O_WRONLY open before any
replacement work and let the PermissionError propagate. The probe fstat()s
the fd it actually opened and hands that stat to the rest of the write, so
a concurrent writer replacing the inode between stat and probe refreshes
the metadata instead of failing (keeps concurrent-writer semantics).

Regression: writable parent + 0444 target now raises and keeps the
original bytes and mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ctl): close all three 12.07 re-gate findings on the systemd guard

1. inherit_errexit: the listener-diagnostic assignments in
   _port_listener_diag abort status/stop when errexit is inherited into
   command substitutions (shopt inherit_errexit or a BASHOPTS env from the
   invoking shell). Guard both assignments with || true; regression test
   runs status/stop under BASHOPTS=inherit_errexit.

2. PID/port AND: _pid_listens_on_port called lsof -p PID -iTCP:PORT
   without -a, which OR-combines the selectors — any socket of the PID or
   any listener on the port matched, so an active unit could be blamed for
   a port its MainPID does not listen on. Add -a; the new fake lsof
   mimics real OR/AND semantics so a missing -a fails the test.

3. Binding-aware collision: an active/activating unit whose ownership
   could not be attributed via MainPID was assumed to own port 8787
   unconditionally. Resolve the unit's configured binding first
   (HERMES_WEBUI_PORT from Environment=, then --port from ExecStart=) and
   refuse only on actual overlap; the default-port guard remains solely
   for undeterminable bindings (#3291 semantics). Tests cover the reverse
   alternate-port case (unit on 9999, start on 8787 proceeds) and the
   overlap case (unit on the requested port refuses).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: deduplicate configured model badges (#6221)

Deduplicate configured model badges so one configured model shows a single picker entry, with provider-collision + colon-bearing-id routing correctness. Thanks @happy5318.

Co-authored-by: happy5318 <happy5318@users.noreply.github.com>

* Release: deduplicate configured model badges (#6221, @happy5318) (#6268)

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>

* fix(renderer): render data:image URIs as images instead of raw base64 text (#6209)

Render data:image URIs as inline images (raster + base64 SVG) instead of raw base64 text, route file:// images through the media pipeline, with a strict allowlist + 2MB cap and img-only data: sanitizer. Thanks @ai-ag2026.

Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>

* Release: render data:image URIs as images (#6209, @ai-ag2026) (#6270)

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>

* Fail closed on historical anchor hydration throws

* docs(changelog): stamp v0.52.76 stable section (promotion) (#6269)

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>

* ci: docs-only fast-path + minimal docs CI (#6279)

* ci: docs-only fast path + minimal docs CI

Skip the full pytest matrix + browser smoke on docs/CHANGELOG/README-only PRs
(required checks still report green fast via a fail-safe 'changes' gate job), and
add a lightweight Docs CI: critical_markdown_check.py (rendering-breaks only, not
style) + lychee broken-link check. Detection fails safe (any uncertainty or any
non-doc path -> full suite runs).

* ci: tighten docs-only detection — extension/type wins over name

A code file whose NAME contains README/CHANGELOG (scripts/CHANGELOG_stamp.py,
static/README_renderer.js) was wrongly classified docs-only, which would SKIP the
test suite on a real code change. Now a path is docs only by doc extension, exact
doc basename, or non-code file under docs/. Verified against 16 cases incl. every
code-with-docs-name trap.

* ci: fix all Fable+Codex gate findings on docs-only fast path

- BLOCKER: drop *.txt from is_docs (requirements.txt is a dep manifest — a bump
  would have skipped the whole test matrix). docs = *.md/.markdown/.rst + bare
  doc basenames only; strict allowlist, no docs/** denylist.
- Rename hole: use 'git diff --name-only --no-renames' so a rename src/app.py ->
  docs.md reveals the code-side deletion instead of collapsing to the doc dest.
- SECURITY: docs-ci.yml no longer interpolates untrusted fork-PR filenames into
  run: via ${{ }} (which executes $() in a crafted name). File list flows through
  a file + mapfile-as-args; lychee gets a fixed glob, not the attacker list.
  Added permissions: contents: read.
- Wedge belt-and-suspenders: 'if: ${{ always() }}' on the required test +
  browser-smoke jobs so a failed 'changes' job can't skip them; step guards treat
  missing/empty docs_only as run-full.
- critical_markdown_check.py: corrected the core rule — a newline in the whitespace
  AROUND a link destination is valid CommonMark (was a false positive); only a
  newline INSIDE the destination token, or an unclosed inline link, breaks
  rendering. Verified full agreement with the markdown-it-py reference parser +
  0 false positives on all 43 repo docs. Also blank 4-space indented code + multi
  backtick spans. Reworded 'block the merge' -> 'break rendering' (non-required).

* ci: address Codex re-gate — lint always() + markdown title/unclosed cases

- Add if: always() to the lint job too (not currently required, but future-proof
  against the wedge class if it's ever promoted).
- critical_markdown_check.py: handle two more CommonMark cases Codex found —
  a newline inside a "title" string is legal (skip), and a newline-terminated
  unclosed dest ([x](url\n at EOF/EOL with no close) is broken (flag). After the
  destination token ends, valid continuations are ')' or a title opener (" ' ();
  bare text after the newline is the real break. Verified full agreement with
  markdown-it-py across 13 cases + 0 FP on all 43 repo docs.

* ci: model the CommonMark inline-dest grammar (root-cause fix for markdown checker)

Round-3 gate found the title heuristic caused sibling regressions: a parenthesized
multi-line title (url (a\nb)) was falsely flagged, and a quote glued into the URL
(exa"part) was wrongly treated as a title-start and skipped. Rather than patch more
heuristics, replace the ad-hoc newline logic with _scan_inline_dest(), which walks
the actual grammar: skip leading ws -> bare dest (balanced parens, ends at ws or the
depth-0 ')') or <angle> dest -> after ws the next char must be ')' or a real title
opener (" ' () -> else the destination is split across the line (broken). Verified
FULL agreement with markdown-it-py across 20 adversarial cases (incl. both round-3
regressions, balanced parens, angle dests, multiline titles) + 0 FP on all 43 docs.

* test: pytest suite for critical_markdown_check (42 cases)

Durable, repeatable verification for the docs-CI markdown checker: 19 verdict cases
+ 19 cross-checked against the markdown-it-py CommonMark reference (skips cleanly if
the lib is absent) + 3 code-span-safety cases + empty/no-link inputs. Covers both
round-3 regressions (parenthesized multi-line title valid; quote-glued-in-URL broken),
balanced parens, angle destinations, and multiline titles. 42 passed.

* ci: fix 2 grammar edges from Codex round-4 (escaped-> in angle dest, unbalanced bare-dest parens)

- Angle dest <...> now honors backslash escapes: [x](<foo\>bar>) renders (the \> is
  escaped), was a false positive.
- Bare dest must have BALANCED parens: [x](foo(\n)) does not render (a '(' stays open
  when whitespace ends the token) — now returns split, was a false negative.
Both verified against markdown-it-py + added as pytest cases. 46 passed, 0 FP on 43 docs.

* ci: escaped-newline in angle dest is still a raw newline (Codex round-5)

<...> escape handling skipped the char after backslash including a newline, so
[x](<foo\<nl>>) returned ok but doesn't render (blockquote on line 2). An escaped
newline inside an angle destination is still a raw newline -> split. Preserves
[x](<foo\>bar>). Added regression case. 48 pytest cases pass, 0 FP on 43 docs.

* ci: mirror escaped-newline guard to bare dest (Codex round-6, GFM-correct)

The angle branch already treated backslash-newline as split; the bare branch skipped
it, so [x](foo\<nl>bar) returned ok. GFM/CommonMark forbid line endings in bare
destinations (GitHub's cmark-gfm won't render it), though markdown-it-py permissively
does. Since these docs are GitHub-rendered we follow GFM: flag it. Added as a
SPEC_DIVERGENT test case (verdict-asserted, excluded from the permissive parser
cross-check). 49 pytest pass, escaped paren/space still valid, 0 FP on 43 docs.

* ci: delimiter-aware title scan (Codex round-7 unclosed-link false negatives)

Naive text.find(')') matched the TITLE's own ')' not the link's outer ')', so
[x](foo (title), [x](<foo> (title), [x](foo "title" all returned ok despite being
unclosed links. Now parse the title to its actual closing delimiter ("..", '..',
or a (..) that forbids nested unescaped '(' per CommonMark), then require the link's
own ')' after optional whitespace. Also catches the nested-paren-title break
[x](foo (a (b) c)). Verified vs markdown-it-py; escaped parens + single-quoted
titles containing parens still valid. 61 pytest pass, 0 FP on 43 docs.

* ci: remove unused variable (ruff F841 in critical_markdown_check)

The CI lint gate (ruff forward E9+F+B on new lines) caught a dead 'stripped =
line.lstrip()' leftover from an earlier refactor in _blank_code — the fence
detection matches on the raw line. Removed. No behavior change (61 pytest pass).

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>

* fix(models): prevent bare-id picker revert when provider hint is empty (#6195) (#6199)

Prevent bare-id model picker revert when the provider hint is empty: an ambiguous bare id that collides across provider groups no longer snaps to the default group on re-render. Adds a revert-sensitive regression test and fixes three cross-file test-isolation leaks found while gating. Thanks @webtecnica.

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>

* Release: prevent bare-id picker revert on empty provider hint (#6199, @webtecnica) (#6280)

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>

* Release: Artifacts filename-first + session-own-streaming + reduced-motion msg-row (#6161, #6165, #6166, @webtecnica) (#6282)

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>

* fix(wakeup): route async-delegation completions by origin + durable-claim delivery (#6283)

Route async-delegation completions by the immutable origin_ui_session_id (exact origin tab) and deliver them through a durable claim/complete/release lifecycle so they arrive exactly once, restart-safe, on both the background wakeup and next-turn drain paths. Combines #6185 (@carlotestor) + #6159 (@sysophelper-droid); supersedes #6002/#6225.

Co-authored-by: carlotestor <carlotestor@users.noreply.github.com>
Co-authored-by: sysophelper-droid <sysophelper-droid@users.noreply.github.com>

* fix(#6240): fall back when test skills symlink is unavailable (#6276)

Fall back to a copytree (with read-only handling) when the test-server fixture can't create the skills symlink on native Windows without SeCreateSymbolicLinkPrivilege (WinError 1314). Test-infra only. Thanks @rodboev. Closes #6240.

* fix(wakeup): recover terminal process completions after restart (#6287)

Recover checkpointed core background processes and rebuild PROCESS_SESSION_INDEX on WebUI startup, so an ordinary terminal(background=True, notify_on_complete=True) proc_* completion that outlives a WebUI restart can still wake its original session. Complements #6283 (which covered async_delegation completions). Thanks @allenliang2022.

* Release: bg-process restart recovery (#6287) + Windows test-fixture symlink fallback (#6276) (#6294)

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>

* fix(#6099): make transparent stream activity timestamps optional (#6130)

Add an opt-in setting to hide Transparent Stream's per-event timestamp chips while keeping the response footer time visible, for users who found the per-event chips noisy. Thanks @rodboev. Closes #6099.

* Release: optional Transparent Stream event timestamp chips (#6130, @rodboev) (#6300)

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>

* Live Stream: add public conversation lifecycle browser gate (#6251)

* test: add conversation lifecycle browser gate

* test: wait for durable lifecycle settlement

* test: harden lifecycle gate cleanup and startup

* test: normalize gateway fixture request paths

* test: harden conversation lifecycle gate

* test: fix lifecycle request failure capture

* test: align lifecycle CI dependencies

* test: harden lifecycle gate waits

* docs: align lifecycle gate setup command

* test: harden lifecycle gate persistence wait

* ci: scope conversation-lifecycle gate to relevant code paths

Only run the playwright browser gate when the chat render/streaming surface
it exercises actually changes (static/**, api/**.py, server.py, the test,
deps, the workflow). Docs-only and unrelated PRs skip it entirely, keeping
CI lean per the docs-only fast-path philosophy.

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>

---------

Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>

* Release: gateway approval_id fallback (#6168) + update-check guard (#6180) + state-dir test isolation (#6305) (#6332)

* fix: catch unhandled exception in POST /api/updates/check (defensive hardening) (#6180)

* fix: generate non-empty approval_id when gateway approval.request omits it (#6008) (#6168)

* chore: mark update-check try/except as defensive-only guard, drop #6086 linkage

Per maintainer review, the try/except wrapper is defense-in-depth only —
it does NOT fix #6086 (root cause is signal/process-group reaping).
Updated log message and added inline comment to make this explicit.
Leave #6086 open.

* test: isolate state-dir probes from user state

* docs(changelog): stamp #6168 approval_id, #6180 update-check guard, #6305 test isolation

---------

Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: pxxD1998 <214340659+pxxD1998@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* Release: broadcast terminal output to every viewer (#5836, @ai-ag2026) (#6340)

* feat(terminal): broadcast output to every viewer instead of one shared queue

TerminalSession.output was a single queue.Queue read destructively by the SSE
handler. Two tabs/windows viewing the SAME session each open their own
EventSource, so two _handle_terminal_output handlers competed on that one queue:
every PTY chunk was delivered to exactly one of them. Each tab saw a disjoint
half of the byte stream, and only one ever received terminal_closed.

Output now fans out, mirroring StreamChannel/SessionChannel: each SSE consumer
subscribe()s its own queue (seeded with a bounded backlog so a first/late attach
still replays the recent scrollback, preserving the old buffer-until-first-
consumer behaviour), and put_output broadcasts to all subscribers. A slow
viewer's queue drops its own oldest chunk (drop-oldest, isolated per subscriber)
so one lagging tab can't starve another. The handler unsubscribes in a finally
so the subscriber list can't grow.

Stacked on the terminal fd-leak fix (same file).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: cover terminal broadcast lifecycle

* fix: serialize terminal subscriber fanout

* test: cover terminal unsubscribe publication race

* restore timing-flaky pytest.skip on test_terminal_survives_short_lived_request_thread (keep API-updated body); changelog #5836

---------

Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* Release: terminal-error settlement timing/seal (#6323) + Docker experimental builds (#6329) (#6341)

* fix: preserve timing and seal tool rows on terminal error (#6309)

* fix: publish Docker experimental builds to ghcr (#6298)

- Add exp-v* trigger to release workflow so experimental tags build
  and push Docker images
- Add :experimental floating tag for experimental channel, keeping
  :latest scoped to stable v* tags only
- Mark GitHub Releases from exp-v* tags as pre-releases
- Document available Docker tags (:latest, :experimental, version
  pins) in docs/docker.md

Closes #6298

* docs(changelog): stamp #6323 terminal-error timing/seal + #6329 Docker exp builds

---------

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* Release: content search no longer evicts the working-set cache (#6084, @ai-ag2026) (#6343)

* fix(webui): keep the content search from evicting the user's working set

/api/sessions/search?content=1 walks EVERY session and pulls each one through
get_session(), which inserts it into the SESSIONS LRU and marks it
recently-used. On any install with more sessions than sessions_cache_max
(default 300), a single search therefore flushes the whole cache and refills it
with sessions the user is not looking at — the classic buffer-pool
scan-pollution problem. The sessions actually open in the UI are exactly the
ones evicted, and the search is keystroke-debounced, so it repeats while typing.

A scan reads each session exactly once, so nothing it touches has earned
"recently used". get_session_for_scan() reuses a resident session without
promoting it, and reads a cold one straight from disk without caching it. It
returns None rather than raising, since a scan skips what it cannot open.

This is a correctness fix for cache behaviour, not a latency fix. The
multi-second searches that led here were contention, not scan cost: a trivial
/api/profiles took 9.2s in the same window, and a full read+parse of ~1700 real
sessions measures ~4s total.

test_sessions_search_depth_validation patched api.routes.get_session. With the
search reading through the scan accessor it now patches get_session_for_scan —
left unfixed, two of its cases fail and the third passes vacuously against an
empty result set.

Validation:
  pytest tests/test_issue4765_sessions_lru_eviction.py
         tests/test_sessions_search_depth_validation.py   ->  13 passed
  Both added eviction tests fail on the pre-fix accessor (verified by revert):
  the working set drops from 4/4 to 0/4 resident after one scan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(changelog): stamp #6084 content-search working-set preservation

---------

Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* Release: pip-installable packaging metadata (#6337, @rodboev) (#6344)

* build(#2695): add packaging metadata for the current runtime layout

* docs(changelog): stamp #6337 pip-installable packaging metadata

---------

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* Release: Live Stream stable Anchor run identity (#6201, @franksong2702) (#6346)

* fix: preserve live anchor run identity

* Validate envelope run ids before snapshot cursor use

* docs(changelog): stamp #6201 stable Anchor run identity

---------

Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* Release: fix Kanban column scrolling on mobile (#6306, @jpalazz2) (#6347)

* fix: Fix kanban scrolling in mobile viewports

Scrolling vertically (particularly in expanded view) in kanban on
a mobile viewport is difficult. The columns have overscroll
disabled, meaning tap and drag will only scroll within the column
and will not continue to the next section. On desktop it's much
easier to get the mouse outside the column div, on mobile you have
to deliberately try to tap very close to the edge of the viewport.

Disabling that behavior makes the experience much better

Author: Joe Palazzolo <joe@joepalazzolo.net>

* docs(changelog): stamp #6306 mobile Kanban column scroll fix

---------

Co-authored-by: Joe Palazzolo <joe@joepalazzolo.net>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* a11y: avoid no-op composer height resets

* Release: Live Stream Anchor side-effects projection (#6204, @franksong2702) (#6348)

* fix: preserve anchor-owned side effects

* test: prove invisible anchor outcomes do not repaint

* docs(changelog): stamp #6204 Anchor side-effects projection

---------

Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* Release: run-journal summary cache (#6291, @sjungwon03) (#6355)

* perf(run-journal): cache unchanged run summaries

* fix(run-journal): reject cache after missing-file race

* run-journal cache: add st_ctime_ns to signature (close same-size mtime-preserving rewrite window) + regression test [maintainer fix on @sjungwon03 #6291]

* docs(changelog): stamp #6291 run-journal summary cache

---------

Co-authored-by: sjungwon03 <sjungwon03@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* test: cover composer resize boundaries

* Harden historical anchor hydration edges

* Release: terminal-error lifecycle gate row (#6354, @franksong2702) (#6358)

* test: add terminal-error lifecycle matrix row

* test: reject empty terminal process rows

---------

Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* Release: gateway-default MoA send (#5869, @rodboev) (#6365)

* fix(#5853): allow gateway-default MoA sends

* fix(#5853): freeze gateway auth to one locked snapshot

* docs(changelog): stamp #5869 gateway-default MoA send

---------

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* Release: raster-data-URI redaction fast-path (#6311, @inch772) (#6367)

* fix(redaction): skip native raster data URIs

* fix(redaction): accept mixed-case raster MIME types

* fix(redaction): validate complete raster payloads

* docs(changelog): stamp #6311 raster-data-URI redaction fast-path

---------

Co-authored-by: Su Ahn Lee <11433303+inch772@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* feat(extensions): token-v1 proxy→sidecar authentication boundary (#6331)

* feat(extensions): token-v1 proxy->sidecar auth boundary

Mint a per-extension secret core injects (X-Hermes-Sidecar-Token) on every
proxied request; sidecars validate it. Closes the hole where a loopback sidecar
port is reachable by any local process and cannot distinguish a proxied request
from a direct one.

- api/extension_sidecar_auth.py: per-extension token lifecycle (atomic mint,
  re-read-verified so an unpersisted token is never injected, per-request
  mtime-cached read for live rotation, path-escape-safe)
- manifest proxy_auth negotiation: absent=legacy, token-v1=enforce, unknown=fail-closed
- proxy: inject token, strip inbound + response x-hermes-*, auth-off posture
  (loopback-only local_unprotected, else 503), fail-closed when token unavailable
- consent-time auth_required in status payload; mint-on-consent
- docs/EXTENSIONS.md proxy_auth section
- 7 new tests (26/26 green)

* fix(extensions): address Codex+Fable gate on token-v1 (6 findings)

- align token-module extension-id grammar with core _EXTENSION_ID_RE (was
  narrower -> legally-named ext consented then 503'd forever)
- resolve token dir dynamically (mirror _extension_state_dir) -> real test
  isolation, no import-time STATE_DIR cache
- cross-process mint: O_CREAT|O_EXCL no-clobber claim (was os.replace clobber)
- rotation cache keyed on full fingerprint (ino/dev/mtime/ctime/size) +
  re-fingerprint after read -> same-size/mtime replacement no longer stale
- validate token format on read (url-safe, 16-256) -> malformed file can't
  leak via a ValueError echoed in a 502
- consent fails 503 when token can't be provisioned (was silent-swallow ->
  persisted consent then 503 forever)
- rename status auth_required -> posture enum (protected|local_unprotected):
  nothing is blocked for loopback, so 'required' was misleading
- panels.js: render local_unprotected warning on the consent row (+ CSS)
- docs: token-path resolution order, 401-vs-503, explicit 'legacy' acceptance
- tests: route-level token-injection+response-strip test; fix illusory isolation
  in token-module test; 27/27 green

* fix(extensions): close 2 token-mint races (Codex re-gate round 2)

- mint via temp-file + atomic os.replace (not O_CREAT|O_EXCL) so the final
  path is never observed empty/half-written — a concurrent loser can no longer
  read an empty token file and 503
- single _stable_read helper (fingerprint-read-refingerprint, bounded retry on
  mid-read change) used by BOTH ensure_token and current_token — a token that
  changes during the read is never returned or cached stale
- stress-verified: 20 concurrent first-mints converge on 1 persisted token; 27/27

* fix(extensions): atomic no-clobber token publish via os.link (Codex re-gate round 3)

os.replace fixed empty-file exposure but still clobbered cross-process: two
processes could both write+replace and a reader between them got a token no
longer on disk -> 401. Switch to the repo's TOCTOU-safe os.link create-or-fail
idiom (session_recovery.py:627): write temp -> link into place (fails if a
winner already published) -> loser drops its temp and reads the winner via
_stable_read. Proven: 16 concurrent PROCESSES converge on 1 persisted token,
all matching disk. 27/27 tests green.

* fix(extensions): resolve Frank+Greptile #6331 review — token-v1 fail-closed when auth off (consent+resolution), token-v1-only proxy_auth/posture status fields, fullmatch ext-id validator

* fix(extensions): finish Frank and Greptile sidecar review

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* Release: compact completed image tool-results (#6315, @sjungwon03) (#6371)

* fix(session): compact completed native vision results

* streaming: guard image-part compaction against unhashable part.type (isinstance str) + regression test [maintainer fix on @sjungwon03 #6315]

* docs(changelog): stamp #6315 completed-image tool-result compaction

---------

Co-authored-by: sjungwon03 <sjungwon03@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* Preserve historical anchor final text

* Release p1-batch: session cache cap 300→100 (#6362) + virtualization comment (#6318) (#6375)

* perf(transcript): enable DOM virtualization by default (re-enable #4346 fix) (#6151) (#6155)

* fix: revert DOM virtualization default to opt-in, fix gate RED (#6155)

* fix(#6351): lower default session cache cap

* Release p1-batch: session cache cap 300->100 (#6362) + virtualization comment (#6318)

---------

Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* Release: consolidated Kanban board fills vertical space (#6308) (#6376)

* fix: Fix height of consolidated kanban board

There was a lot of empty space below the kanban board columns
in the condolidated view (particularly on desktop). Modify the
CSS such that the consolidated view always fills the viewport
vertical space.

* Release: consolidated Kanban board fills vertical space (#6308)

---------

Co-authored-by: Joe Palazzolo <joe@joepalazzolo.net>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* Release: isolate Hermes home per streaming turn (#5877, @starship-s) (#6379)

* fix(profiles): isolate Hermes home per streaming turn

Assisted-by: OpenCode:gpt-5.3-codex-spark
Assisted-by: Hermes Agent:gpt-5.6-sol
Assisted-by: Codex:gpt-5.3-codex-spark

* fix(profiles): gate skill isolation by capability

Assisted-by: OpenCode:gpt-5.3-codex-spark
Assisted-by: Hermes Agent:gpt-5.6-sol
Assisted-by: Codex:gpt-5.3-codex-spark

* fix(profiles): harden fallback lock boundaries

Assisted-by: OpenCode:gpt-5.3-codex-spark
Assisted-by: Hermes Agent:gpt-5.6-sol

* test(profiles): adapt streaming isolation harness

Assisted-by: Codex:gpt-5.3-codex-spark
Assisted-by: Hermes Agent:gpt-5.6-sol

* Release: isolate Hermes home per streaming turn (#5877, @starship-s)

---------

Co-authored-by: starship-s <45587122+starship-s@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* Release: Kanban New-Task modal reachable on mobile (#6301, @jpalazz2) (#6384)

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* Release: Artifacts pane long-filename readability + long-parent containment (#6078, @rodboev) (#6386)

* fix(#6067): keep artifact file names visible

# Conflicts:
#	static/style.css
#	static/workspace.js

* fix(#6067): bound long parent artifact tails inside the drawer

* Release: Artifacts pane long-filename readability + long-parent containment (#6078, @rodboev)

---------

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* Release: add preference to hide new-chat welcome panel (#6183, @vaidu-ai) (#6387)

* feat: add option to hide new-chat welcome panel

* Release: add preference to hide new-chat welcome panel (#6183, @vaidu-ai)

---------

Co-authored-by: vaidu-ai <im@vaidu.net>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>

* fix(stream): preserve reader viewport anchor through live-to-settlement collapse

Issue #6385: when a streaming turn settles, the two-render sequence
(keep-open expanded worklog → collapsed worklog) could displace the
reader's viewport because the second render captured its scroll snapshot
from the intermediate expanded state, not from the original live DOM.

Root cause
----------
The STREAM_DONE handler in messages.js:

1. Arms keep-settled-worklog-open token → renderMessages({preserveScroll:true})
   → worklog rendered EXPANDED (height-stable swap preventing shrink jump)

2. Disarms token → _renderMessagesWithScrollSnapshot()
   → This function called _captureMessageScrollSnapshot() which captured
     the scroll anchor from the expanded-worklog DOM (step 1 output),
     then called renderMessages with the worklog COLLAPSED (keep-open gone),
     then tried to restore from the expanded-state snapshot.

   The snapshot's semantic anchor (row key, session idx, top offset) was
   captured from a DOM where the worklog was expanded. After the collapse
   render the worklog is no longer at that position — anchor keys don't
   match, the semantic restore fails, and the viewport jumps to a
   unrelated scrollTop.

Fix
---
- Capture the scroll snapshot from the LIVE DOM (before any settlement
  renders) and pass it as  to the second render.
- Modify _renderMessagesWithScrollSnapshot() to accept a pre-captured
  snapshot via options._prescro…
samfoy pushed a commit to samfoy/hermes-webui that referenced this pull request Aug 26, 2026
Setting the reasoning-effort chip in one WebUI session changed it in
every session. All read and write paths shared a single global key,
agent.reasoning_effort in config.yaml, so the per-session chip was a
global setting with a per-session appearance.

Add Session.reasoning_effort and prefer it wherever the effort is
resolved:

- api/models.py       persist the field in the metadata prefix
- static/ui.js        send session_id with the chip GET and POST
- api/routes.py       GET reads the session value; POST writes it and
                      evicts the cached agent so the next turn rebuilds
- api/config.py       get_reasoning_status() override parameter
- api/streaming.py    local agent path prefers the session value
- api/gateway_chat.py gateway path prefers the session value

A session value of None keeps the previous behaviour and falls back to
profile config, so existing sessions, the CLI, and cron are unchanged.
An explicit empty string means "provider default" for that session
only, preserving the nesquena#6219 thinking-toggle re-enable path.

Both request paths are updated because the gateway path reads the same
key; fixing only the local path would leave gateway-routed WebUI chats
globally scoped.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address size:L Large PR (>10 files or >250 LOC)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants