Files
hermes-webui/tests/test_glm_5_3_catalog.py
T
92c790ffe5 Batch release exp-v0.52.235 — cancelling-run reattach (#7096) + GPT-5.6 max reasoning (#7083) + test order-independence (#7101) (#7102)
* fix(tests): make mtime_invalidation and glm_5_3 tests order-independent (#7100)

* fix: expose max reasoning for GPT-5.6 models

* fix(recovery): do not reattach cancelling runs

ACTIVE_RUNS tracks worker lifecycle, which is deliberately broader than
"a turn a browser may attach to". cancel_stream() keeps the row as
phase="cancelling" while the worker unwinds so a successor turn cannot
start on top of it, but the client has already reached a terminal state
for that stream: its run journal ends in a terminal event.

The recovery lookups treated every same-session ACTIVE_RUNS row as
attachable. An idle session holding a cancelling row therefore received a
recovered server_turn_started on every /api/session/stream subscription:
the client attached, replayed the terminal event, tore the renderer down,
resubscribed, and the server replayed the same frame again. The result is
an endless attach/replay loop that rebuilds the transcript repeatedly.

Separate the two meanings instead of narrowing one call site:

- api/config.py gains active_run_is_attachable() and
  active_run_cancel_is_stale() as the shared predicates.
- active_stream_id_for_session() (browser recovery) returns attachable
  rows only; _session_has_active_turn() (busy check) keeps counting a
  fresh cancellation so a successor cannot overlap the unwinding worker.
- _live_active_stream_id() applies the same rule to the hidden-tab
  status poller, on both the STREAMS and ACTIVE_RUNS paths.
- routes._cancelled_run_is_stale() now delegates to the shared predicate
  rather than keeping a parallel copy of the staleness rule.
- A cancelling row past a bounded unwind window with no live STREAMS
  channel is reclaimed from ACTIVE_RUNS and its stream owner released, so
  a wedged worker cannot suppress background wakeups forever. Age alone
  does not reclaim a row that still owns a live channel.

Staleness anchors on cancelled_at, falling back to started_at, so a
long-running turn cancelled moments ago is never treated as an orphan.

tests/test_cancelling_run_not_attachable.py covers both directions of
each rule. The six behavioral tests fail on the unpatched tree and pass
with the fix; two targeted mutations (forcing the attachability predicate
true, and disabling the staleness reaper) each turn the suite red.

* docs(rfc): document cancellation attach/admission split

Records the runtime contract introduced by the recovery fix in the WebUI
run-state consistency RFC, so the distinction is discoverable instead of
living only in code comments.

- Adds ACTIVE_RUNS to the State Layers table as the worker-lifecycle
  registry, explicitly not the set of runs a browser may attach to.
- Adds invariant 9: lifecycle-busy is not client-attachable. Cancellation
  splits the two meanings, recovery paths must exclude cancelling rows,
  and admission checks must keep counting them.
- Documents the bounded cancellation-unwind window: reclamation needs
  both age and the absence of a live STREAMS channel, and staleness is
  anchored on the cancellation timestamp.
- Extends the review checklist with the admission-vs-attachment question
  and the evidence required when changing a reclamation window.

* docs(changelog): note #7096 cancelling-run reattach, #7083 GPT-5.6 max reasoning, #7101 test order-independence

---------

Co-authored-by: webtecnica <webtecnica@gmail.com>
Co-authored-by: Abdulrahman Elkenany <boudy.elkenany123@gmail.com>
Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
Co-authored-by: n <a@n>
2026-08-17 05:37:00 +00:00

235 lines
9.1 KiB
Python

"""Regression tests: GLM-5.3 catalog and onboarding integration.
GLM-5.3 is Z.ai's current flagship model (successor to GLM-5.2). These tests
pin catalog presence (opt-in selection), fallback entry, reasoning ladder,
and payload propagation — ensuring the model users see matches what's declared
in api/config.py. The onboarding default deliberately REMAINS glm-5.1 until
Z.ai's direct API serves GLM-5.3 (Coding-Plan-only today; see #7017 review).
"""
import unittest.mock as mock
import pytest
import api.config as cfg
import api.onboarding as onboarding
@pytest.fixture(autouse=True)
def _isolate_models_cache():
"""Invalidate the TTL model cache before AND after every test.
``get_available_models()`` caches its result keyed on config.yaml mtime.
Tests in this file repoint ``_get_config_path`` to a tmp_path, populate
the cache there, then let monkeypatch restore the original path. The
cache, keyed on the tmp_path's mtime, then poisons downstream tests
(e.g. test_model_resolver) which see stale data and never hit their
mocks. Clearing the cache around each test breaks that linkage.
Also snapshots/restores ``c.cfg``, ``c._cfg_mtime``, ``c._cfg_path``,
and ``c._cfg_fingerprint`` to prevent tests from leaking global config
state to neighboring test files.
"""
import api.config as c
import api.providers as p
import api.profiles as profiles
old_cfg = dict(c.cfg)
old_mtime = c._cfg_mtime
old_path = c._cfg_path
old_fingerprint = c._cfg_fingerprint
try:
c.invalidate_models_cache()
p.invalidate_providers_cache()
profiles._invalidate_root_profile_cache()
from api.plugin_providers import invalidate_plugin_model_provider_cache
invalidate_plugin_model_provider_cache()
except Exception:
pass
yield
c.cfg.clear()
c.cfg.update(old_cfg)
c._cfg_mtime = old_mtime
c._cfg_path = old_path
c._cfg_fingerprint = old_fingerprint
try:
c.invalidate_models_cache()
p.invalidate_providers_cache()
profiles._invalidate_root_profile_cache()
from api.plugin_providers import invalidate_plugin_model_provider_cache
invalidate_plugin_model_provider_cache()
except Exception:
pass
def test_glm_5_3_in_provider_models():
"""GLM-5.3 must appear in the zai provider catalog with the correct label."""
zai_models = cfg._PROVIDER_MODELS.get("zai", [])
model_ids = [m["id"] for m in zai_models]
assert "glm-5.3" in model_ids, (
f"glm-5.3 missing from zai provider models; got {model_ids}"
)
# Verify the exact label
glm_5_3_entry = [m for m in zai_models if m["id"] == "glm-5.3"]
assert len(glm_5_3_entry) == 1
assert glm_5_3_entry[0]["label"] == "GLM-5.3", (
f'Expected label "GLM-5.3", got {glm_5_3_entry[0]["label"]!r}'
)
def test_glm_5_3_positioned_before_glm_5_2():
"""GLM-5.3 must appear BEFORE GLM-5.2 (lists are newest-first)."""
zai_models = cfg._PROVIDER_MODELS.get("zai", [])
glm_5_3_index = None
glm_5_2_index = None
for i, model in enumerate(zai_models):
if model["id"] == "glm-5.3":
glm_5_3_index = i
elif model["id"] == "glm-5.2":
glm_5_2_index = i
assert glm_5_3_index is not None, "glm-5.3 not found in zai models"
assert glm_5_2_index is not None, "glm-5.2 not found in zai models"
assert glm_5_3_index < glm_5_2_index, (
f"glm-5.3 (index {glm_5_3_index}) must appear before glm-5.2 (index {glm_5_2_index})"
)
def test_glm_5_3_in_fallback_models():
"""GLM-5.3 must appear in _FALLBACK_MODELS with correct provider and label."""
fallback_entries = [m for m in cfg._FALLBACK_MODELS if m["id"] == "zai/glm-5.3"]
assert len(fallback_entries) == 1, (
f"Expected exactly one zai/glm-5.3 entry in _FALLBACK_MODELS; "
f"found {len(fallback_entries)}"
)
entry = fallback_entries[0]
assert entry["provider"] == "Z.AI", (
f'Expected provider "Z.AI", got {entry["provider"]!r}'
)
assert entry["label"] == "GLM-5.3", (
f'Expected label "GLM-5.3", got {entry["label"]!r}'
)
def test_glm_5_3_positioned_first_in_zai_fallback_block():
"""GLM-5.3 must appear as the FIRST Z.AI entry in _FALLBACK_MODELS."""
zai_entries = [m for m in cfg._FALLBACK_MODELS if m["provider"] == "Z.AI"]
assert len(zai_entries) > 0, "No Z.AI entries found in _FALLBACK_MODELS"
first_zai_entry = zai_entries[0]
assert first_zai_entry["id"] == "zai/glm-5.3", (
f'Expected first Z.AI entry to be "zai/glm-5.3", got {first_zai_entry["id"]!r}'
)
def test_zai_onboarding_default_stays_glm_5_1_until_direct_api_serves_glm_5_3():
"""Z.AI onboarding default must remain glm-5.1 until the direct endpoint serves GLM-5.3.
The zai provider calls Z.ai's direct endpoint (api.z.ai/api/paas/v4), where the GLM-5.3
API is not live yet. Z.ai's GLM-5.3 guide marks it "coming soon"; GLM-5.3 is
Coding-Plan-only today. Defaulting direct-API users onto glm-5.3 would fail their first
message with model-not-found, so the default stays glm-5.1 — bump it only after the
direct endpoint serves GLM-5.3 (per the #7017 review gate).
"""
zai_setup = onboarding._SUPPORTED_PROVIDER_SETUPS.get("zai", {})
assert zai_setup, "zai setup not found in _SUPPORTED_PROVIDER_SETUPS"
default_model = zai_setup.get("default_model")
assert default_model == "glm-5.1", (
f'Expected default_model "glm-5.1" (not glm-5.3), got {default_model!r}'
)
def test_glm_5_3_in_zai_onboarding_models_list():
"""GLM-5.3 must appear in the zai onboarding setup's models list."""
zai_setup = onboarding._SUPPORTED_PROVIDER_SETUPS.get("zai", {})
assert zai_setup, "zai setup not found in _SUPPORTED_PROVIDER_SETUPS"
models = zai_setup.get("models", [])
model_ids = [m["id"] for m in models]
assert "glm-5.3" in model_ids, (
f"glm-5.3 missing from zai onboarding models list; got {model_ids}"
)
# Verify the exact label in the onboarding list
glm_5_3_entry = [m for m in models if m["id"] == "glm-5.3"]
assert len(glm_5_3_entry) == 1
assert glm_5_3_entry[0]["label"] == "GLM-5.3", (
f'Expected label "GLM-5.3" in onboarding, got {glm_5_3_entry[0]["label"]!r}'
)
def test_glm_5_3_reasoning_efforts():
"""GLM-5.3 must support the full reasoning_effort ladder (GLM-5.2+ tier)."""
efforts = cfg.resolve_model_reasoning_efforts("glm-5.3", provider_id="zai")
assert set(efforts) == {"minimal", "low", "medium", "high", "xhigh", "max"}, (
f"glm-5.3 must support the full reasoning_effort ladder; got {efforts!r}"
)
def test_glm_5_3_in_models_payload_for_zai_provider(tmp_path, monkeypatch):
"""GLM-5.3 must appear in the /api/models payload when zai is active.
This tests the actual observable behavior — what the WebUI model dropdown
sees — not just module-level structures. It verifies that the catalog
entries flow through get_available_models() to the frontend payload.
The configured default is deliberately glm-5.2 (a different model), so
presence of glm-5.3 in the zai group's models list proves catalog
propagation from _PROVIDER_MODELS, not config echo.
"""
import api.config as c
cfgfile = tmp_path / "config.yaml"
cfgfile.write_text(
"model:\n provider: zai\n default: glm-5.2\n",
encoding="utf-8",
)
monkeypatch.setattr(c, "_get_config_path", lambda: cfgfile)
c.reload_config()
# Mock list_available_providers to avoid real network calls
fake_prov = mock.MagicMock()
fake_prov.return_value = []
try:
import hermes_cli.models as hm
monkeypatch.setattr(hm, "list_available_providers", fake_prov)
except Exception:
pass
# Pin the agent-core catalog: get_available_models() sources the zai
# model list from the INSTALLED hermes-cli core (via
# _read_live_provider_model_ids -> provider_model_ids), not from the
# repo's static _PROVIDER_MODELS. On a box whose installed core predates
# glm-5.3 this test previously failed spuriously ("glm-5.3 missing from
# zai group models"). Stub the core catalog to a deterministic empty
# list so the repo's own _PROVIDER_MODELS fallback is exercised — this
# tests WebUI catalog propagation, not the installed core version.
try:
import hermes_cli.models as hm
monkeypatch.setattr(hm, "provider_model_ids", lambda _pid: [])
except Exception:
pass
result = c.get_available_models()
c.reload_config()
# Find the zai group
zai_group = None
for group in result.get("groups", []):
if group.get("provider_id") == "zai" or group.get("provider") == "zai":
zai_group = group
break
assert zai_group is not None, (
"No zai provider group found in get_available_models() output; "
f"got providers: {[g.get('provider_id') or g.get('provider') for g in result.get('groups', [])]}"
)
# Check that glm-5.3 appears in the models list
model_ids = [m["id"] for m in zai_group.get("models", [])]
assert "glm-5.3" in model_ids, (
f"glm-5.3 missing from zai group models; got {model_ids}"
)