From ee0b476f62b2d39067bb401c811fdc4f017e85dd Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 21 Jun 2026 11:36:26 -0700 Subject: [PATCH] =?UTF-8?q?Release=20v0.51.561=20=E2=80=94=20context-windo?= =?UTF-8?q?w=20indicator=20stays=20correct=20after=20model=20switch=20(#46?= =?UTF-8?q?18)=20(#4628)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * stage #4618 (allenliang2022): broaden streaming stale-compressor guard to any model-window mismatch + #4618 regression tests + CHANGELOG Broadens #3256's default-only live-usage guard: the streaming SSE snapshot now always resolves the real per-model window via the same helper GET /api/session hydration uses (_context_length_lookup_inputs_for_model + get_model_context_length) and corrects whenever it differs from the compressor's cached value, with a TypeError fallback to the legacy 2-arg form. Fixes 'refresh shows 1M, send reverts to stale 168k + early auto-compress' on model-switched sessions. Per-stream cache preserved (one lookup/stream). Code byte-identical to PR head 3beb18e92d73. Adds 4 source-structure regression tests (RED-proven on master). Co-authored-by: allenliang2022 * fix #4618 gate findings: profile-scoped config (Codex cross-profile) + #4248 256k acceptance gate (Opus downward-clobber) Codex SHIP-WITH-FIXES: live-snapshot used ambient get_config() which in the detached streaming worker resolves the process-global/default profile (#3294) -> for a non-default profile pinning a different per-model context_length it would surface the WRONG profile's window. Now resolves via get_config_for_profile_home on the session's own profile home (mirrors the worker's _cfg resolution). Opus SHIP-WITH-FIXES: broadened guard aligned resolution w/ hydration but not its #4248 acceptance gate -> a transient low-confidence 256k metadata probe could clobber a LARGER cached window mid-stream. Now reuses the exact hydration helper _should_accept_session_context_length_refresh on both modern + legacy paths. + regression tests for both. Co-authored-by: allenliang2022 * fix #4618 Codex re-gate findings: broaden save + SSE-done stale-compressor guards too Codex re-gate found the broadened live-snapshot guard fixed metering but the two SIBLING paths still used the old default-only exact-cap test: - api/streaming.py final session-save: persisted stale other-model window (168k) to s.context_length -> wrong window on reload. - api/streaming.py terminal SSE: emitted stale window -> indicator REVERTS on stream end (messages.js overwrites S.lastUsage) = the exact 'send reverts to 168k' symptom. Both now resolve the real per-model window via the same hydration helper and honor the #4248 acceptance gate (no 256k downward-clobber), with legacy 2-arg fallback. This is the root-cause completion across all 3 paths (live/save/SSE-done). + 2 regression tests. Co-authored-by: allenliang2022 * docs(#4618): note model_changed-omission rationale (Opus) + broaden CHANGELOG to all-3-paths * Release v0.51.561 — Release TT (context-window indicator stays correct after model switch; #4618) --------- Co-authored-by: nesquena-hermes Co-authored-by: allenliang2022 --- CHANGELOG.md | 3 + api/streaming.py | 266 +++++++++++++----- ...e3256_context_length_default_only_guard.py | 122 ++++++++ 3 files changed, 315 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9273548..73e624e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,11 @@ ## [Unreleased] +## [v0.51.561] — 2026-06-21 — Release TT (context-window indicator stays correct after model switch) + ### Fixed +- **The live context-window indicator no longer reverts to a stale value mid-conversation after a model switch.** On a session whose model was switched in place (e.g. to `claude-opus-4.8` with a 1M window), the streaming context snapshot could surface a *previous* model's cached context window (e.g. `claude-opus-4.5`'s 168k) — so a refresh showed the correct 1M but sending a message reverted the indicator to 168k and tripped auto-compress early. The correction now applies consistently across all three paths that surface the window (the live metering snapshot, the final session save, and the terminal `done` SSE payload): each resolves the real per-model window through the same helper `GET /api/session` hydration uses (honoring nested per-model config overrides and custom providers) and corrects whenever it differs from the compressor's cached value, while never letting a low-confidence 256k metadata fallback clobber a larger cached window. The lookup still runs at most once per stream (no per-tick cost). Thanks @allenliang2022. (#4618) - **`bootstrap.py` now finds the Hermes Agent when it was installed as root (FHS layout) or behind the current shell-wrapper `hermes` launcher (#root-install).** A fresh root install on Linux places the agent at `/usr/local/lib/hermes-agent` (CLI linked into `/usr/local/bin`), and the modern `hermes` launcher is a `#!/usr/bin/env bash` wrapper that `exec`s the venv entrypoint. The old discovery only checked `~/.hermes/hermes-agent`-style paths and parsed the launcher's shebang as a Python interpreter, so on a root VM it found nothing, built a WebUI-only `.venv`, and aborted with "Python environment cannot import both WebUI dependencies and Hermes Agent." Discovery now also probes `/usr/local/lib/hermes-agent` and follows the launcher's `exec` target (any absolute path inside the script) up to `run_agent.py`, so `python3 bootstrap.py` works out of the box for root/Proxmox/container installs with no `HERMES_WEBUI_PYTHON` override needed. ## [v0.51.560] — 2026-06-21 — Release TS (recovered run-journal output reaches the model) diff --git a/api/streaming.py b/api/streaming.py index adddf30a..b46ca813 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -5721,50 +5721,126 @@ def _run_agent_streaming( _cc = getattr(_agent, 'context_compressor', None) if _cc: _cc_cl_u = getattr(_cc, 'context_length', 0) or 0 - # Default-only guard (#3256): the agent-side compressor is - # built in agent_init with the global model.context_length - # applied unconditionally — for non-default models that - # value is the stale global cap (e.g. 232K). Drop it here - # so the live usage payload doesn't surface the wrong cap. - # PERF: resolve the real per-model cap at most once per - # stream (cached in _real_ctx_cache). This snapshot runs on - # every metering tick; doing the config read + metadata - # lookup per tick froze non-default-model streams. + # Stale-compressor self-heal (#3256, broadened): the + # agent-side compressor caches a context_length from the + # model it was *built/last-updated* with. After an in-place + # model switch (or when agent_init seeded it with the global + # model.context_length cap), that cached value can be the + # WRONG model's window — e.g. a session on claude-opus-4.8 + # (1M / 936k prompt on Copilot) whose compressor still holds + # claude-opus-4.5's 168k. The original guard only corrected + # the narrow case where the cached value equalled the config + # cap exactly; a leftover *other-model* value (168k) slipped + # straight through to the live usage payload. Broaden it: + # ALWAYS resolve the real per-model window for the agent's + # CURRENT model and, when that differs from the cached value, + # surface the real one. Frontend hydration (GET /api/session) + # already does this; this aligns the streaming path with it + # so "refresh shows 1M, send-a-message drops to 168k" can't + # happen. + # PERF: resolve at most once per stream (cached in + # _real_ctx_cache). This snapshot runs on every metering + # tick; doing the config read + metadata lookup per tick + # froze non-default-model streams. if _real_ctx_cache[0] is None: - _resolved_real = 0 # 0 = guard not applicable / failed + _resolved_real = 0 # 0 = no correction / lookup failed try: - from api.config import get_config as _gc_u - _cfg_u = _gc_u() - _mcfg_u = _cfg_u.get('model', {}) if isinstance(_cfg_u, dict) else {} - if isinstance(_mcfg_u, dict): - _def_u = str(_mcfg_u.get('default') or '').strip() - _raw_u = _mcfg_u.get('context_length') + _sm_u = str(getattr(_agent, 'model', '') or '').strip() + _prov_u = str(getattr(_agent, 'provider', '') or '').strip() + _base_u = str(getattr(_agent, 'base_url', '') or '').strip() + _key_u = getattr(_agent, 'api_key', '') or '' + if _sm_u: + # Resolve the real window through the SAME helper + # hydration uses (routes._context_length_lookup_inputs_for_model + # + get_model_context_length). This honors the + # nested per-model config override + # (model..models..context_length, + # e.g. claude-opus-4.8 -> 1,000,000) and custom- + # provider keys, so the streaming/SSE path and the + # GET /api/session path land on the IDENTICAL value. + # Reusing the helper (instead of hand-reading the + # flat top-level model.context_length, which is + # None here) is what prevents a new mismatch like + # "refresh shows 1M, send-a-message shows 936k". try: - _cl_u = int(_raw_u) if _raw_u is not None else 0 - except (TypeError, ValueError): - _cl_u = 0 - _sm_u = str(getattr(_agent, 'model', '') or '').strip() - from api.routes import _model_matches_configured_default as _mmcd_u - if ( - _cl_u > 0 - and _cc_cl_u == _cl_u - and _def_u - and _sm_u - and not _mmcd_u(_sm_u, _def_u, getattr(_agent, 'provider', '') or '') - ): - # Recompute from real per-model metadata. + from api.routes import ( + _context_length_lookup_inputs_for_model as _cli_u, + _should_accept_session_context_length_refresh as _accept_u, + ) + from agent.model_metadata import get_model_context_length as _g_u + # Resolve the SESSION's own profile config, not + # the ambient one. This worker is a detached + # thread that does NOT inherit the per-request + # thread-local profile context, so a bare + # get_config() resolves the process-global + # (default) profile (#3294) — for a non-default + # profile that pins a different per-model + # context_length, that would surface the WRONG + # profile's window in the live payload. Read the + # session's profile home explicitly, mirroring + # the worker's own _cfg resolution below. try: - from agent.model_metadata import get_model_context_length as _g_u - _real_u = _g_u( - _sm_u, - getattr(_agent, 'base_url', '') or '', - config_context_length=None, - provider=getattr(_agent, 'provider', '') or '', - ) or 0 - if _real_u: + from api.config import get_config_for_profile_home as _gch_u + from api.profiles import get_hermes_home_for_profile as _ghp_u + _ph_u = _ghp_u(getattr(_session_obj, 'profile', None)) + _cfg_u = _gch_u(_ph_u) + except Exception: + from api.config import get_config as _gc_u + _cfg_u = _gc_u() + _lk_u = _cli_u( + _sm_u, + _prov_u, + base_url=_base_u, + api_key=_key_u, + cfg=_cfg_u if isinstance(_cfg_u, dict) else {}, + ) + _real_u = _g_u( + _sm_u, + _lk_u.base_url, + api_key=_lk_u.api_key, + config_context_length=_lk_u.config_context_length, + provider=_lk_u.provider or _prov_u or '', + custom_providers=_lk_u.custom_providers, + ) or 0 + # Only treat it as a correction when the real + # window is valid AND disagrees with the + # compressor's cached value. Equal => nothing + # to fix, leave the fast path untouched. + # #4248: never let a low-confidence 256k metadata + # fallback clobber a LARGER cached window — that + # would reintroduce the very "drops to a smaller + # window mid-stream" regression this guard fixes. + # Reuse the exact acceptance gate hydration uses. + # NOTE: we deliberately omit model_changed (=False + # default) here, unlike hydration. The streaming + # path can't cheaply know if the model changed + # since the compressor was seeded, so we err + # toward the LARGER window (auto-compress fires + # late, not early — the safe direction), and the + # next GET /api/session hydration self-heals any + # genuine downward 256k case via model_changed. + if ( + _real_u and _real_u != _cc_cl_u + and _accept_u(_cc_cl_u, _real_u) + ): + _resolved_real = _real_u + except TypeError: + # Older hermes-agent: legacy 2-arg form. + try: + from api.routes import ( + _should_accept_session_context_length_refresh as _accept2_u, + ) + from agent.model_metadata import get_model_context_length as _g2_u + _real_u = _g2_u(_sm_u, _base_u) or 0 + if ( + _real_u and _real_u != _cc_cl_u + and _accept2_u(_cc_cl_u, _real_u) + ): _resolved_real = _real_u except Exception: pass + except Exception: + pass except Exception: _resolved_real = 0 _real_ctx_cache[0] = _resolved_real @@ -7910,25 +7986,48 @@ def _run_agent_streaming( # cap (e.g. 232K) that would clobber the real 1M metadata # on every stream end. In that case skip the compressor # value and let the fallback resolver below recompute. + # #4618: broaden the stale-compressor guard the same way the + # live-usage snapshot does. The OLD test only skipped the + # compressor value when it equalled the config cap EXACTLY + # (a non-default model carrying the global cap). But a + # compressor can hold a DIFFERENT model's window after an + # in-place model switch (e.g. opus-4.5's 168k lingering on an + # opus-4.8 1M session) — that value != the config cap, so the + # old guard let it persist to s.context_length and the SSE + # payload, snapping the indicator back to 168k at turn-end. + # Resolve the real per-model window via the SAME helper the + # live path + hydration use and skip the compressor value + # whenever the real window differs, honoring the #4248 + # acceptance gate (never let a low-confidence 256k fallback + # clobber a larger cached window). _skip_cc_cl = False try: - _model_cfg_cc = _cfg.get('model', {}) if isinstance(_cfg, dict) else {} - if isinstance(_model_cfg_cc, dict): - _cfg_default_cc = str(_model_cfg_cc.get('default') or '').strip() - _raw_cfg_cl_cc = _model_cfg_cc.get('context_length') + from api.routes import ( + _context_length_lookup_inputs_for_model as _cli_cc, + _should_accept_session_context_length_refresh as _accept_cc, + ) + from agent.model_metadata import get_model_context_length as _g_cc + _sess_model_cc = str(getattr(agent, 'model', resolved_model or '') or '').strip() + if _sess_model_cc and _cc_cl > 0: + _lk_cc = _cli_cc( + _sess_model_cc, + resolved_provider or '', + base_url=getattr(agent, 'base_url', '') or resolved_base_url or '', + api_key=getattr(agent, 'api_key', '') or resolved_api_key or '', + cfg=_cfg if isinstance(_cfg, dict) else {}, + ) try: - _cfg_cl_cc = int(_raw_cfg_cl_cc) if _raw_cfg_cl_cc is not None else 0 - except (TypeError, ValueError): - _cfg_cl_cc = 0 - _sess_model_cc = str(getattr(agent, 'model', resolved_model or '') or '').strip() - from api.routes import _model_matches_configured_default as _mmcd_cc - if ( - _cfg_cl_cc > 0 - and _cc_cl == _cfg_cl_cc - and _cfg_default_cc - and _sess_model_cc - and not _mmcd_cc(_sess_model_cc, _cfg_default_cc, resolved_provider or '') - ): + _real_cc = _g_cc( + _sess_model_cc, + _lk_cc.base_url, + api_key=_lk_cc.api_key, + config_context_length=_lk_cc.config_context_length, + provider=_lk_cc.provider or resolved_provider or '', + custom_providers=_lk_cc.custom_providers, + ) or 0 + except TypeError: + _real_cc = _g_cc(_sess_model_cc, _lk_cc.base_url) or 0 + if _real_cc and _real_cc != _cc_cl and _accept_cc(_cc_cl, _real_cc): _skip_cc_cl = True except Exception: pass @@ -8176,31 +8275,46 @@ def _run_agent_streaming( _orig_cc_cl_sse = _cc_cl_sse _orig_cc_thresh_sse = getattr(_cc, 'threshold_tokens', 0) or 0 _dropped_stale_cap_sse = False - # Default-only guard (#3256): the agent-side context_compressor - # is constructed in agent_init with the global model.context_length - # applied unconditionally, so for non-default models its - # context_length is the stale global cap (e.g. 232K) — surfacing - # it via SSE makes the indicator show the wrong window even - # after the session was correctly resized. Drop the compressor - # value in that case and let the fallback resolver below recompute. + # Default-only guard (#3256), broadened (#4618): the agent-side + # context_compressor caches a context_length from the model it + # was built/last-updated with. For a non-default model it may be + # the stale global cap (e.g. 232K); after an in-place model switch + # it may be a DIFFERENT model's window (e.g. opus-4.5's 168k on an + # opus-4.8 1M session). Either way, surfacing it via the terminal + # `done` SSE makes the indicator REVERT to the wrong window on + # stream end (messages.js overwrites S.lastUsage with this payload) + # — the exact "send a message reverts to 168k" symptom. Resolve + # the real per-model window via the SAME helper the live path + + # hydration use; drop the compressor value whenever the real + # window differs, honoring the #4248 acceptance gate (never let a + # low-confidence 256k fallback clobber a larger cached window). try: - _model_cfg_sse = _cfg.get('model', {}) if isinstance(_cfg, dict) else {} - if isinstance(_model_cfg_sse, dict): - _cfg_default_sse = str(_model_cfg_sse.get('default') or '').strip() - _raw_cfg_cl_sse = _model_cfg_sse.get('context_length') + from api.routes import ( + _context_length_lookup_inputs_for_model as _cli_sse, + _should_accept_session_context_length_refresh as _accept_sse, + ) + from agent.model_metadata import get_model_context_length as _g_sse + _sess_model_sse = str(getattr(agent, 'model', resolved_model or '') or '').strip() + if _sess_model_sse and _cc_cl_sse > 0: + _lk_sse = _cli_sse( + _sess_model_sse, + resolved_provider or '', + base_url=getattr(agent, 'base_url', '') or resolved_base_url or '', + api_key=getattr(agent, 'api_key', '') or resolved_api_key or '', + cfg=_cfg if isinstance(_cfg, dict) else {}, + ) try: - _cfg_cl_sse = int(_raw_cfg_cl_sse) if _raw_cfg_cl_sse is not None else 0 - except (TypeError, ValueError): - _cfg_cl_sse = 0 - _sess_model_sse = str(getattr(agent, 'model', resolved_model or '') or '').strip() - from api.routes import _model_matches_configured_default as _mmcd_sse - if ( - _cfg_cl_sse > 0 - and _cc_cl_sse == _cfg_cl_sse - and _cfg_default_sse - and _sess_model_sse - and not _mmcd_sse(_sess_model_sse, _cfg_default_sse, resolved_provider or '') - ): + _real_sse = _g_sse( + _sess_model_sse, + _lk_sse.base_url, + api_key=_lk_sse.api_key, + config_context_length=_lk_sse.config_context_length, + provider=_lk_sse.provider or resolved_provider or '', + custom_providers=_lk_sse.custom_providers, + ) or 0 + except TypeError: + _real_sse = _g_sse(_sess_model_sse, _lk_sse.base_url) or 0 + if _real_sse and _real_sse != _cc_cl_sse and _accept_sse(_cc_cl_sse, _real_sse): _cc_cl_sse = 0 _dropped_stale_cap_sse = True except Exception: diff --git a/tests/test_issue3256_context_length_default_only_guard.py b/tests/test_issue3256_context_length_default_only_guard.py index ef4902c1..ea9ac9da 100644 --- a/tests/test_issue3256_context_length_default_only_guard.py +++ b/tests/test_issue3256_context_length_default_only_guard.py @@ -509,3 +509,125 @@ def test_sse_done_payload_rescales_threshold_when_cap_dropped(): assert "usage['threshold_tokens'] = int(_orig_cc_thresh_sse * _fb_cl / _orig_cc_cl_sse)" in _STREAMING_SRC, ( "SSE done payload must rescale threshold_tokens to the resolved window (#3263 MUST-FIX 3)" ) + + +def test_live_snapshot_guard_broadened_to_any_model_window_mismatch(): + """#4618: the live-usage snapshot stale-compressor guard must correct the + cached window whenever the resolved real per-model window DIFFERS from the + compressor's cached value — not only when it exactly equals the global cap. + + A leftover *other-model* cached value (e.g. opus-4.5's 168k lingering on an + opus-4.8 1M session after an in-place model switch) is != the config cap, so + the old `_cc_cl_u == _cl_u` condition let it slip through to the live payload + ('refresh shows 1M, send-a-message reverts to 168k'). Pin the broadened + condition so it cannot silently revert to the narrow default-only form.""" + assert "_real_u and _real_u != _cc_cl_u" in _STREAMING_SRC, ( + "live-snapshot guard must correct on ANY real-window != cached-window " + "mismatch (#4618), not only the default-model exact-cap case" + ) + # The narrow default-only signals must be GONE from the live-snapshot block. + assert "_model_matches_configured_default as _mmcd_u" not in _STREAMING_SRC, ( + "the old default-only guard (_mmcd_u) must be removed from the " + "live-snapshot path (#4618 broadened it)" + ) + + +def test_live_snapshot_reuses_hydration_context_length_helper(): + """#4618: the broadened guard must resolve the real window through the SAME + helper GET /api/session hydration uses (_context_length_lookup_inputs_for_model + + get_model_context_length) so the streaming/SSE path and the hydration path + land on an IDENTICAL value (honors nested per-model config overrides + custom + providers). Pin the helper reuse against a hand-rolled flat-cap regression.""" + assert "_context_length_lookup_inputs_for_model as _cli_u" in _STREAMING_SRC, ( + "live-snapshot guard must reuse the hydration lookup-inputs helper (#4618)" + ) + assert "from agent.model_metadata import get_model_context_length as _g_u" in _STREAMING_SRC + + +def test_live_snapshot_guard_has_legacy_two_arg_fallback(): + """#4618: a TypeError from the modern 6-arg get_model_context_length (older + hermes-agent builds) must fall back to the legacy 2-arg form rather than + raising, and the fallback must apply the SAME mismatch condition.""" + assert "from agent.model_metadata import get_model_context_length as _g2_u" in _STREAMING_SRC + assert "_real_u = _g2_u(_sm_u, _base_u) or 0" in _STREAMING_SRC, ( + "live-snapshot guard must keep a legacy 2-arg get_model_context_length " + "fallback for older hermes-agent builds (#4618)" + ) + + +def test_live_snapshot_resolves_session_profile_config_not_ambient(): + """#4618 gate fix (Codex): the streaming worker is a detached thread that + does NOT inherit the per-request thread-local profile context, so a bare + get_config() resolves the process-global (default) profile (#3294). For a + non-default profile pinning a different per-model context_length, that would + surface the WRONG profile's window in the live payload. The guard must + resolve the SESSION's own profile config via get_config_for_profile_home.""" + assert "from api.config import get_config_for_profile_home as _gch_u" in _STREAMING_SRC, ( + "live-snapshot guard must read the session's profile config, not ambient " + "get_config() (#3294 cross-profile leak class)" + ) + assert "get_hermes_home_for_profile as _ghp_u" in _STREAMING_SRC + assert "_ghp_u(getattr(_session_obj, 'profile', None))" in _STREAMING_SRC, ( + "profile home must derive from the live session object's profile" + ) + + +def test_live_snapshot_guard_honors_256k_clobber_acceptance_gate(): + """#4618 gate fix (Opus): the broadened guard aligned resolution with + hydration but must ALSO honor hydration's #4248 acceptance gate — a transient + low-confidence 256k metadata fallback must NOT clobber a LARGER cached window + mid-stream (which would reintroduce the 'drops to a smaller window' regression + this guard exists to fix). Reuse the exact hydration acceptance helper on both + the modern and legacy paths.""" + assert "_should_accept_session_context_length_refresh as _accept_u" in _STREAMING_SRC, ( + "live-snapshot guard must reuse the #4248 acceptance gate " + "(_should_accept_session_context_length_refresh)" + ) + assert "and _accept_u(_cc_cl_u, _real_u)" in _STREAMING_SRC, ( + "the correction must be gated by the 256k-clobber acceptance check (#4248)" + ) + # Legacy 2-arg fallback must apply the same gate. + assert "_should_accept_session_context_length_refresh as _accept2_u" in _STREAMING_SRC + assert "and _accept2_u(_cc_cl_u, _real_u)" in _STREAMING_SRC, ( + "the legacy 2-arg fallback must also honor the 256k acceptance gate" + ) + + +def test_session_save_path_broadened_to_any_model_window_mismatch(): + """#4618 (Codex re-gate finding 1): the FINAL session-save stale-compressor + guard must also broaden beyond the default-only exact-cap test — otherwise a + leftover other-model window (168k) is PERSISTED to s.context_length and a + reload shows the wrong window. It must resolve the real window via the same + helper and honor the #4248 acceptance gate before skipping the compressor + value.""" + assert "_context_length_lookup_inputs_for_model as _cli_cc" in _STREAMING_SRC, ( + "save path must resolve the real per-model window via the hydration helper (#4618)" + ) + assert "_should_accept_session_context_length_refresh as _accept_cc" in _STREAMING_SRC + assert "if _real_cc and _real_cc != _cc_cl and _accept_cc(_cc_cl, _real_cc):" in _STREAMING_SRC, ( + "save path must skip the compressor value on ANY accepted real-window " + "mismatch, not only the default-model exact-cap case (#4618)" + ) + + +def test_sse_done_path_broadened_to_any_model_window_mismatch(): + """#4618 (Codex re-gate finding 2): the terminal `done` SSE usage path must + also broaden — otherwise the indicator REVERTS to the stale window on stream + end (messages.js overwrites S.lastUsage with the done payload). It must + resolve the real window via the same helper and honor the #4248 gate before + dropping the compressor value.""" + assert "_context_length_lookup_inputs_for_model as _cli_sse" in _STREAMING_SRC, ( + "SSE done path must resolve the real per-model window via the hydration helper (#4618)" + ) + assert "_should_accept_session_context_length_refresh as _accept_sse" in _STREAMING_SRC + assert "if _real_sse and _real_sse != _cc_cl_sse and _accept_sse(_cc_cl_sse, _real_sse):" in _STREAMING_SRC, ( + "SSE done path must drop the compressor value on ANY accepted real-window " + "mismatch so the indicator can't snap back to a stale window at turn-end (#4618)" + ) + # The old narrow default-only matcher must be gone from BOTH sibling paths. + assert "_model_matches_configured_default as _mmcd_sse" not in _STREAMING_SRC, ( + "the old default-only SSE matcher (_mmcd_sse) must be removed (#4618)" + ) + assert "_model_matches_configured_default as _mmcd_cc" not in _STREAMING_SRC, ( + "the old default-only save matcher (_mmcd_cc) must be removed (#4618)" + )