* fix(streaming): keep errored-turn assistant response visible (#5941) An errored turn that produced assistant content (tool calls + reasoning) folded that content into a collapsed worklog above the error card, so the user read a lone error bubble as "nothing came back". The settled-scene renderer collapsed the worklog unconditionally, never consulting the turn terminal_state. Now an errored/failure terminal_state keeps the worklog expanded by default (unless the user explicitly collapsed it), while completed turns and genuinely-empty errored turns are unchanged. Reported by @b3nw. * fix(review): update #4970 brittle collapsed-expr assertion for #5941 test_issue4970_stream_done_shrink_regression asserted the literal `collapsed:!keepSettledWorklogOpen` in _renderSettledAnchorSceneForMessage. #5941 OR'd an errored-turn keep-open term into that expression (`collapsed:!(keepSettledWorklogOpen||erroredWorklogKeepOpen)`), so the substring no longer matched and the test failed in the full suite (the PR updated its own touched tests but missed this pre-existing one). Re-anchor to `collapsed:!(keepSettledWorklogOpen` — still guards #4970's invariant (keepSettledWorklogOpen negates into the settled-render collapsed flag) without pinning the exact term list. #4970 + #5941 suites green. * fix(chat): honor explicit model pick on post-failure recovery send (#5924) The onchange explicit-pick marker is single-shot: send() consumes it once. submitEdit() and cmdRetry() truncated and called send() directly without re-arming it, so the recovery send went out with explicit_model_pick=false and the server's compatible-model resolution re-reverted a freshly-picked cross-family model back to the failed/stale value (Facet 1). Facet 4 is the same loop keeping the persisted model_provider pinned across fork/refresh. Re-arm the pending explicit-pick marker from the current selector state immediately before await send() in both recovery paths. Survives a second consecutive recovery send. Normal send path and the #3737/#5731 server repair guard are unchanged. Reported by @b3nw. * fix(#5924): gate recovery re-arm on genuine pick + session-race guards Codex gate found 4 defects on the recovery send path; fixed all: - /retry + edit-resubmit re-armed the explicit-pick marker UNCONDITIONALLY, forcing explicit_model_pick even with no fresh pick (suppressed server compatible-model resolution). Now gated on _recoveryPick (selector model differs from session's own stored model), captured pre-await. - session-switch races: added active-session guards after the retry GET await and the edit truncate await so session A's recovery intent can't apply to session B. - 3 new regression tests (gated re-arm, pre-await capture, post-await re-guard). * fix(#5924) round-2: derive recovery pick from non-default session model, not inference Codex round-2 CORE: the state-comparison predicate false-negatived an already-applied pick (consumed marker → looks unchanged) and false-positived on provider inference (@removed:mistral-large + null stored provider → inferred 'removed' → fake change). Replace with _deliberateSessionModelPick(sid): reports {model,provider} only when the session's OWN model is genuinely non-default vs window._defaultModel/_activeProvider — inference-free + survives marker consumption. Both recovery paths + tests updated. * fix(#5924) round-3: require known-default+owned-provider evidence + fire-time re-arm guard Codex round-3: (1) _deliberateSessionModelPick still false-positived when the profile default was unknown or the provider was only inferred — now requires a session-OWNED provider AND a known window._defaultModel/_activeProvider, else fails closed. (2) new same-session race: a model change DURING the recovery awaits made the pre-await pick stale and clobbered a newer marker — new _reArmRecoveryPick helper re-arms only if the current session model/provider still equals the captured pick AND no different pending marker exists. Both recovery paths route through it. * Release exp-v0.52.45: errored-turn response stays visible (#5950/#5941) + recovery honors model pick (#5949/#5924) * fix: keep absoluteKeepCount capture before _recoveryPick in submitEdit The #5924 _recoveryPick comment contained the word 'await', tripping the pre-existing test_issue_edit_regenerate_absolute_keep_count regex (first \bawait\b must come after the absoluteKeepCount capture). Reorder both synchronous pre-network captures (absoluteKeepCount first) + reword the comment. No behavior change. --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
257 lines
12 KiB
Python
257 lines
12 KiB
Python
"""Regression coverage for #5924 — post-failure recovery must honor a fresh pick.
|
|
|
|
After a provider failure the reporter (@b3nw) could not switch models: changing
|
|
the model in the selector and then **edit-resubmit** or **/retry** re-sent the
|
|
*failed* model, forcing a session fork to escape.
|
|
|
|
Root cause (Facet 1 + Facet 4): the onchange explicit-pick marker
|
|
(``_rememberPendingSessionModel``) is single-shot — ``send()`` consumes it once
|
|
(``messages.js``). The two recovery paths (``submitEdit`` in ``ui.js`` and
|
|
``cmdRetry`` in ``commands.js``) truncate and call ``send()`` directly WITHOUT
|
|
re-arming the marker, so ``explicit_model_pick`` goes out ``false`` and the
|
|
server's ``_resolve_compatible_session_model_state`` re-reverts a freshly-picked
|
|
cross-family model back to the profile default.
|
|
|
|
Two-layer invariant pinned here:
|
|
* WebUI: both recovery paths re-arm the pending explicit-pick marker from the
|
|
CURRENT selector state *before* ``await send()`` (so a recovery send —
|
|
including a SECOND consecutive one — carries ``explicit_model_pick:true``).
|
|
* Server: with ``explicit_model_pick=True`` the fresh cross-family pick is
|
|
honored (NOT reverted), and without it the stale value is still normalized
|
|
(the #3737/#5731 repair path must not regress).
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
import api.routes as routes
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
|
COMMANDS_JS = (ROOT / "static" / "commands.js").read_text(encoding="utf-8")
|
|
|
|
|
|
def _function_body(src: str, name: str) -> str:
|
|
# Match either "async function NAME" or plain "function NAME".
|
|
start = src.find(f"async function {name}")
|
|
if start == -1:
|
|
start = src.find(f"function {name}")
|
|
if start == -1:
|
|
raise AssertionError(f"function {name!r} not found")
|
|
brace = src.index("{", start)
|
|
depth = 0
|
|
for i in range(brace, len(src)):
|
|
if src[i] == "{":
|
|
depth += 1
|
|
elif src[i] == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return src[start : i + 1]
|
|
raise AssertionError(f"function {name!r} body not found")
|
|
|
|
|
|
# ── WebUI layer: recovery paths re-arm the marker before send() ──────────────
|
|
|
|
|
|
def test_submit_edit_rearms_pending_pick_before_send():
|
|
"""Edit-resubmit must re-arm the explicit-pick marker before ``await send()``."""
|
|
body = _function_body(UI_JS, "submitEdit")
|
|
assert "_reArmRecoveryPick(" in body, (
|
|
"submitEdit must re-arm via _reArmRecoveryPick (#5924); otherwise the "
|
|
"recovery send loses explicit_model_pick and the server re-reverts the model"
|
|
)
|
|
rearm_idx = body.index("_reArmRecoveryPick(")
|
|
send_idx = body.rindex("await send()")
|
|
assert rearm_idx < send_idx, "the re-arm must happen BEFORE await send()"
|
|
|
|
|
|
def test_cmd_retry_rearms_pending_pick_before_send():
|
|
"""/retry must re-arm the explicit-pick marker before ``await send()``."""
|
|
body = _function_body(COMMANDS_JS, "cmdRetry")
|
|
assert "_reArmRecoveryPick(" in body, (
|
|
"cmdRetry must re-arm via _reArmRecoveryPick (#5924); otherwise /retry "
|
|
"re-sends the failed model instead of the freshly-picked one"
|
|
)
|
|
rearm_idx = body.index("_reArmRecoveryPick(")
|
|
send_idx = body.rindex("await send()")
|
|
assert rearm_idx < send_idx, "the re-arm must happen BEFORE await send()"
|
|
|
|
|
|
def test_recovery_rearm_sources_deliberate_pick_helper():
|
|
"""The re-arm must source the deliberate-pick signal from the shared helper.
|
|
|
|
Both recovery paths capture ``_deliberateSessionModelPick(<sid>)`` (a non-default
|
|
session-model signal that is inference-free and survives the failed send's marker
|
|
consumption) BEFORE any await, rather than comparing ``_chatPayloadModel()`` to
|
|
itself (which false-negatives an already-applied pick and false-positives on
|
|
provider inference — the round-2 gate finding).
|
|
"""
|
|
for body in (_function_body(UI_JS, "submitEdit"), _function_body(COMMANDS_JS, "cmdRetry")):
|
|
assert "_deliberateSessionModelPick(" in body, (
|
|
"recovery paths must derive the pick via _deliberateSessionModelPick"
|
|
)
|
|
|
|
|
|
def test_deliberate_pick_helper_ignores_default_and_inference():
|
|
"""The helper only reports a pick for a genuine NON-DEFAULT session model.
|
|
|
|
It must key off the session's own model vs the profile default (window._defaultModel
|
|
/ _activeProvider) — NOT provider inference on an unchanged model, and NOT a
|
|
self-comparison. A session on the profile default returns null (no re-arm), so the
|
|
server's compatible-model resolution runs for a no-real-pick recovery.
|
|
"""
|
|
body = _function_body(UI_JS, "_deliberateSessionModelPick")
|
|
assert "window._defaultModel" in body and "window._activeProvider" in body, (
|
|
"the pick signal must compare against the profile default, not infer a provider"
|
|
)
|
|
assert "return null" in body, "a default-model session must return null (no re-arm)"
|
|
# must NOT resurrect the round-2 false-positive: no provider inference in the signal
|
|
assert "_providerFromModelValue" not in body and "_chatPayloadModelProvider" not in body, (
|
|
"the deliberate-pick signal must not use provider inference (round-2 false-positive)"
|
|
)
|
|
|
|
|
|
# ── Server layer: explicit pick is honored; repair path preserved (#3737) ────
|
|
|
|
|
|
def test_explicit_pick_honors_fresh_cross_family_model_on_recovery():
|
|
"""The freshly-picked cross-family model survives when explicit_model_pick=True.
|
|
|
|
This is the value the re-armed marker carries into /api/chat/start on the
|
|
recovery send. It must NOT be reverted to the failed/profile-default model.
|
|
"""
|
|
effective, provider, changed = routes._resolve_compatible_session_model_state(
|
|
"gpt-5.4-mini", # freshly picked, cross-family vs anthropic profile
|
|
None,
|
|
profile_provider="anthropic",
|
|
profile_default_model="claude-sonnet-4",
|
|
explicit_model_pick=True,
|
|
)
|
|
assert changed is False, "an explicit recovery pick must not be reverted"
|
|
assert effective == "gpt-5.4-mini", "the freshly-picked model must survive"
|
|
assert provider == "anthropic"
|
|
|
|
|
|
def test_second_consecutive_recovery_send_still_honors_pick():
|
|
"""A SECOND consecutive recovery send re-arms the marker, so it stays explicit.
|
|
|
|
send() consumes the marker each time, but both recovery paths re-arm it from
|
|
the current selector state on every invocation — so two retries/edits in a
|
|
row both carry explicit_model_pick=True and both honor the pick.
|
|
"""
|
|
for _ in range(2):
|
|
effective, provider, changed = routes._resolve_compatible_session_model_state(
|
|
"gpt-5.4-mini",
|
|
None,
|
|
profile_provider="anthropic",
|
|
profile_default_model="claude-sonnet-4",
|
|
explicit_model_pick=True,
|
|
)
|
|
assert changed is False
|
|
assert effective == "gpt-5.4-mini"
|
|
assert provider == "anthropic"
|
|
|
|
|
|
def test_non_explicit_send_still_normalizes_stale_model():
|
|
"""Guard against regressing the #3737/#5731 repair path.
|
|
|
|
Without an explicit pick (the normal 2nd+-turn continuation), a stale
|
|
cross-family model is still normalized to the profile default. The #5924 fix
|
|
only re-arms on the recovery entry points, so this path is unchanged.
|
|
"""
|
|
effective, provider, changed = routes._resolve_compatible_session_model_state(
|
|
"gpt-5.4-mini",
|
|
None,
|
|
profile_provider="anthropic",
|
|
profile_default_model="claude-sonnet-4",
|
|
explicit_model_pick=False,
|
|
)
|
|
assert changed is True, "stale model must still be normalized on a plain send"
|
|
assert effective == "claude-sonnet-4"
|
|
assert provider == "anthropic"
|
|
|
|
|
|
# ── #5924 gate re-fixes: gated re-arm + session-race guards ──────────────────
|
|
|
|
|
|
def test_recovery_rearm_is_gated_on_a_genuine_deliberate_pick():
|
|
"""The re-arm must be CONDITIONAL on a real pick, not unconditional.
|
|
|
|
Codex round-1 CORE: re-arming on EVERY recovery send (even with no fresh pick)
|
|
forced explicit_model_pick=true and suppressed the server's compatible-model
|
|
resolution. Both recovery paths now derive `_recoveryPick` from the shared
|
|
_deliberateSessionModelPick helper and re-arm only via _reArmRecoveryPick,
|
|
which no-ops on a null/absent pick.
|
|
"""
|
|
for body in (_function_body(UI_JS, "submitEdit"), _function_body(COMMANDS_JS, "cmdRetry")):
|
|
assert "_recoveryPick" in body, "recovery re-arm must be gated on _recoveryPick"
|
|
assert "_deliberateSessionModelPick(" in body, (
|
|
"the pick must come from _deliberateSessionModelPick, not an inline comparison"
|
|
)
|
|
assert "_reArmRecoveryPick(" in body, (
|
|
"the re-arm must go through _reArmRecoveryPick (fire-time safety guards)"
|
|
)
|
|
|
|
|
|
def test_rearm_helper_guards_stale_pick_and_newer_marker():
|
|
"""_reArmRecoveryPick must not re-arm a stale pick or clobber a newer marker.
|
|
|
|
Codex round-3 SILENT: a same-session model change DURING the recovery awaits
|
|
made the pre-await captured pick stale; re-arming it overwrote the newer
|
|
pending marker and restored the old model on a later session load. The helper
|
|
must (1) require the current session model/provider to still equal the pick,
|
|
and (2) not overwrite a different existing pending marker.
|
|
"""
|
|
body = _function_body(UI_JS, "_reArmRecoveryPick")
|
|
# still-matches-current-state guard
|
|
assert "S.session.model" in body and "pick.model" in body, (
|
|
"must confirm the current session model still equals the captured pick"
|
|
)
|
|
# newer-marker guard
|
|
assert "_readPendingSessionModel(" in body, (
|
|
"must read the existing pending marker and refuse to clobber a newer one"
|
|
)
|
|
# session-scope guard
|
|
assert "S.session.session_id!==sessionId" in body.replace(" ", ""), (
|
|
"must confirm the session is still the captured one before re-arming"
|
|
)
|
|
|
|
|
|
def test_recovery_pick_is_captured_before_the_first_await():
|
|
"""_recoveryPick must be captured BEFORE any awaited network call so a session
|
|
|
|
switch during the recovery's round-trips can't make it read the wrong
|
|
session's selector state. The `_recoveryPick` assignment must appear before
|
|
the first awaited call expression (``await api(`` / ``await _ensure`` / ``await send``).
|
|
"""
|
|
import re as _re
|
|
for body in (_function_body(UI_JS, "submitEdit"), _function_body(COMMANDS_JS, "cmdRetry")):
|
|
pick_idx = body.index("const _recoveryPick")
|
|
# match a real awaited call, not the word "await" inside a comment
|
|
m = _re.search(r"await\s+\w", body)
|
|
assert m is not None, "expected an awaited call in the recovery path"
|
|
assert pick_idx < m.start(), (
|
|
"_recoveryPick must be captured before the first awaited call (pre-await snapshot)"
|
|
)
|
|
|
|
|
|
def test_recovery_reguards_active_session_after_each_await():
|
|
"""After each await, a session-switch guard must re-check the captured sid.
|
|
|
|
Codex gate SILENT findings: switching sessions during the retry GET await (or
|
|
the edit truncate await) let session A's recovery intent apply to session B —
|
|
in /retry it wrote B's model into A's pending marker. Each recovery path must
|
|
re-assert its captured session id AFTER its post-network await, before
|
|
mutating messages / re-arming / calling send().
|
|
"""
|
|
retry = _function_body(COMMANDS_JS, "cmdRetry")
|
|
# cmdRetry: guard after the session GET await, before the render/re-arm/send
|
|
assert retry.count("S.session.session_id!==activeSid") >= 2, (
|
|
"cmdRetry must re-guard activeSid after the session GET await (>=2 guards total)"
|
|
)
|
|
edit = _function_body(UI_JS, "submitEdit")
|
|
# submitEdit: guard after the truncate await, before slice/re-arm/send
|
|
assert edit.count("S.session.session_id !== initialSid") >= 2, (
|
|
"submitEdit must re-guard initialSid after the truncate await (>=2 guards total)"
|
|
)
|
|
|