Skip to content

fix(chat): honor explicit model pick on post-failure recovery send (#5924) - #5949

Closed
nesquena-hermes wants to merge 1 commit into
masterfrom
fix/5924-post-failure-recovery-model-switch
Closed

nesquena-hermes wants to merge 1 commit into
masterfrom
fix/5924-post-failure-recovery-model-switch

Conversation

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Summary

After a provider failure, the recovery flow ignored a freshly-picked model/provider: changing the model in the selector and then edit-resubmit or /retry re-sent the failed model, forcing the user to fork the session to escape.

This PR fixes Facet 1 (the pick not being honored on the recovery send) and Facet 4 (stuck model surviving fork/refresh/reselect/update — same root cause) by re-arming the explicit-pick marker on both recovery entry points.

Reported by @b3nw (Discord #report-bugs).

Root cause

The frontend selector path is correct — boot.js modelSelect.onchange writes a pending explicit-pick marker via _rememberPendingSessionModel(...). The problem is that the marker is single-shot: send() consumes it exactly once (messages.js), and neither recovery path re-arms it.

  • submitEdit() (static/ui.js) truncates and calls send() directly.
  • cmdRetry() (static/commands.js) does the same after /api/session/retry.

Because the marker was already consumed by an earlier send, the recovery send goes out with explicit_model_pick:false. The server's _resolve_compatible_session_model_state (api/routes.py) is then free to "repair" a cross-family model back toward the profile default — re-reverting to the stale/failed value. Facet 4 is the same loop: each non-explicit recovery send lets the server re-pin the persisted model_provider, so reselect/refresh/fork never sticks.

This is exactly the layer the maintainer's investigation comment identified: WebUI-side, small, on the two recovery entry points — not the server guard (which correctly honors explicit_model_pick=True and must keep normalizing stale models when the pick is absent, per #3737 / #5731).

Fix

Re-arm the pending explicit-pick marker from the current selector/session state (_chatPayloadModel() / _chatPayloadModelProvider()) immediately before await send() in both submitEdit() and cmdRetry(). This makes a recovery send carry explicit_model_pick:true, exactly like a fresh onchange → send. Because the re-arm runs on every recovery invocation, it also survives a second consecutive recovery send even though send() consumes the marker each time.

Files changed

  • static/ui.js — submitEdit() re-arms the pending pick before await send().
  • static/commands.js — cmdRetry() re-arms the pending pick before await send().
  • tests/test_issue5924_post_failure_recovery_model_pick.py — new regression test.

Tests

New file tests/test_issue5924_post_failure_recovery_model_pick.py pins the two-layer invariant:

tests/test_issue5924_post_failure_recovery_model_pick.py ...... [100%]
6 passed

Also re-ran tests/test_issue_edit_regenerate_absolute_keep_count.py and tests/test_model_selection_refresh_persistence.py (6 passed) to confirm no regression on the touched functions.

Scope / out-of-scope follow-ups

This is a Facet 1 + Facet 4 fix. Deliberately out of scope (should be separate PRs):

  • Facet 2 — a clear/dismiss affordance for failed error turns (missing UI feature; error turns are pushed as plain assistant bubbles with no per-turn delete).
  • Facet 3 — "first fork after an error is a full copy": maintainer could not reproduce from forkFromMessage; likely a stale _oldestIdx / transcript-not-fully-loaded timing issue on the error turn, needs a focused repro.

Addresses #5924 (Facet 1 + Facet 4). Leaving the issue open for Facet 2 and Facet 3 above.

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

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR changes post-failure recovery sends to preserve model choices. The main changes are:

  • Re-arms the pending model marker before /retry.
  • Re-arms the marker before edit-resubmit.
  • Adds source-level UI checks and server model-resolution tests.

Confidence Score: 4/5

Recovery without a fresh model choice can bypass server compatibility repair.

  • Both recovery paths create an explicit-pick marker unconditionally.
  • A stale or incompatible stored model can therefore be sent again instead of normalized.
  • The tests do not exercise the no-fresh-pick recovery state.

static/commands.js, static/ui.js, and tests/test_issue5924_post_failure_recovery_model_pick.py

Important Files Changed

Filename Overview
static/commands.js Adds marker re-arming to /retry, but treats retries without a fresh model choice as explicit selections.
static/ui.js Adds the same unconditional marker re-arming to edit-resubmit.
tests/test_issue5924_post_failure_recovery_model_pick.py Adds server behavior tests and source-text checks, but does not cover recovery without a fresh model pick.

Reviews (1): Last reviewed commit: "fix(chat): honor explicit model pick on ..." | Re-trigger Greptile

Comment thread static/commands.js
Comment on lines +1697 to +1701
if(typeof _rememberPendingSessionModel==='function' && typeof _chatPayloadModel==='function' && S.session){
const _recoveryModel=_chatPayloadModel();
const _recoveryProvider=(typeof _chatPayloadModelProvider==='function')?_chatPayloadModelProvider(_recoveryModel):null;
_rememberPendingSessionModel(activeSid, _recoveryModel, _recoveryProvider);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Retry Manufactures Explicit Selection

When /retry runs without a fresh selector change, this block still creates a pending marker from the stored session model. send() then reports explicit_model_pick:true, so the server skips compatibility repair and can retry the same stale or cross-family model that should have been normalized. The mirrored edit-resubmit block has the same behavior; both paths should re-arm only evidence of an actual explicit pick.

Context Used: AGENTS.md (source)

Comment on lines +56 to +59
"recovery send loses explicit_model_pick and the server re-reverts the model"
)
rearm_idx = body.index("_rememberPendingSessionModel")
send_idx = body.rindex("await send()")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Source Check Misses Marker Semantics

This assertion passes whenever the helper name appears before await send(), including in a comment, dead branch, or call using the wrong session and model. It also omits the no-fresh-pick recovery case, so the unconditional marker creation in both updated functions remains false-green instead of showing that ordinary recovery must retain server normalization.

Context Used: AGENTS.md (source)

@nesquena nesquena left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Review — end-to-end ✅ (clean approval, no fixes needed)

Independent review of #5924 (b3nw): after a provider failure, changing the model then edit-resubmit or /retry re-sent the failed model — the pick wasn't honored on the recovery send (Facet 1), and the stuck model survived reselect/refresh/fork (Facet 4).

What this ships

static/ui.js (submitEdit re-arm), static/commands.js (cmdRetry re-arm), tests/test_issue5924_post_failure_recovery_model_pick.py (+new). Agent-authored (nesquena-hermes). MERGEABLE, CI green (18/18).

Root cause + fix — traced correct

The explicit-pick marker (_rememberPendingSessionModel, set by modelSelect.onchange) is single-shot — send() consumes it once. The two recovery entry points (submitEdit truncate→send(), cmdRetry after /api/session/retry→send()) never re-armed it, so the recovery send went out explicit_model_pick:false and the server's _resolve_compatible_session_model_state was free to normalize the freshly-picked cross-family model back toward the stale/failed value (the #3737 repair). Facet 4 is the same loop.

The fix re-arms the marker from the current selector/session state (_chatPayloadModel() / _chatPayloadModelProvider()) immediately before await send() in both paths, so a recovery send carries explicit_model_pick:true exactly like a fresh onchange→send. Because it re-arms on every recovery invocation, a second consecutive recovery send still honors the pick (send() re-consumes each time). ✓

  • Server guard untouched: _resolve_compatible_session_model_state is not modified; it still honors explicit_model_pick=True and still normalizes stale models when the pick is absent (the #3737/#2761/#5567/#5731 behavior), asserted by test_non_explicit_send_still_normalizes_stale_model. ✓
  • Mechanism verified: _chatPayloadModel() = S.session.model || modelSelect.value; the selector's onchange updates S.session.model to the pick, so the re-arm reads the user's fresh model (not the failed one) — which is what makes Facet 1 actually resolve. ✓
  • Normal send() path unchanged — the re-arm lives only in the two recovery entry points; a plain new message still consumes-then-doesn't-rearm → non-explicit → server normalization still applies. ✓
  • Defensive typeof guards on _rememberPendingSessionModel/_chatPayloadModel keep it harness-safe. ✓

Edge-case matrix

Scenario Behavior
Fail → change model → edit-resubmit fresh model honored (explicit) ✅ (was reverted)
Fail → change model → /retry fresh model honored ✅
Two consecutive recovery sends both honor the pick (re-armed each time) ✅
Plain new message (no edit/retry) non-explicit → server normalizes stale model ✅ (no #3737 regression)
Recovery send, model unchanged sends the current session model as explicit ✅

Minor observation (non-blocking)

submitEdit/cmdRetry are the generic edit/retry paths, so they now mark every such send explicit — meaning the server's stale-model normalization no longer applies to edit/retry (only to fresh passive sends). In the narrow case of a stale-and-incompatible S.session.model that the user edits without touching the selector, the recovery send would now carry that model verbatim rather than letting the server repair it. This is a defensible design choice (an explicit user action honors the session's configured model, and the #5924 flow is precisely "the user picked a working model"), and the server fallback still covers passive sends — flagging only for awareness, not as a blocker.

Tests

  • tests/test_issue5924_post_failure_recovery_model_pick.py — 6/6: both paths re-arm before await send() (structural, order-checked), re-arm reads _chatPayloadModel(), explicit recovery pick honored server-side, second-consecutive honored, and non-explicit still normalizes (no #3737 regression).
  • node --check clean on ui.js + commands.js.
  • Full suite: 12586 passed / 0 failed in 425s (deselected the known darwin CRLF flake). CI green (18/18).

Recommendation

✅ Approved clean. Parked at approval — ready for the release agent's merge/tag pipeline.

A precise WebUI-side fix at the two recovery entry points: re-arming the single-shot explicit-pick marker from the current selector before the recovery send(), so edit-resubmit and /retry honor a freshly-picked model instead of the failed one (and survive a second consecutive recovery). The server normalization guard is untouched and still covers non-explicit sends (no #3737 regression, asserted). The one nuance (edit/retry always explicit) is a sound design choice with the passive-send fallback intact. RED on master; full suite clean. Ship.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator Author

🔬 Self-gate (Codex) — SHIP ONLY WITH FIXES (4 findings, holding for rework)

Rebased onto current master (clean) + ran the full Codex gate. The fresh-pick model/provider path is wired correctly and normal send() is unchanged, but the gate reproduced 4 real defects on the recovery/retry send path — 2 CORE + 2 SILENT cross-session races:

  1. CORE — commands.js:1697: /retry re-arms the explicit-pick marker unconditionally from current selector state, not from a genuine deliberate pick. So a recovery send with NO fresh pick still forces explicit_model_pick, which can push the current/stale model instead of letting the server's compatible-model resolution run. Fix: re-arm only from a session-scoped deliberate-pick record created by modelSelect.onchange, preserved across consecutive recovery attempts.
  2. CORE — ui.js:17038: edit-resubmit has the same unconditional-explicit-pick regression. Same deliberate-pick gating needed.
  3. SILENT — commands.js:1687: switching sessions during the 2nd /retry's session GET writes session B's model/provider into session A's pending marker (reproduced: pending.A = {model: model-b, provider: provider-b}) → loading A later applies + persists a foreign model. Fix: after the GET await, return unless S.session.session_id === activeSid.
  4. SILENT — ui.js:17023: session-switch while truncate is pending lets the re-arm apply recovery intent to the newly-visible session. Fix: repeat the initialSid guard immediately after the truncate await.

Render surface is clean (crown-jewel stream-regression-gate GREEN on the rebased head, all modes). Holding this for the deliberate-pick-record rework + the 2 session guards + regression tests for no-fresh-pick and the second-await switch race, then re-gate before ship.

@nesquena-hermes nesquena-hermes added the size:M Medium PR (≤10 files, ≤250 LOC) label Jul 12, 2026
nesquena-hermes added a commit that referenced this pull request Jul 12, 2026
…y honors model pick (#5924) (#5964)

* 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>
@nesquena-hermes

Copy link
Copy Markdown
Collaborator Author

Shipped in exp-v0.52.45 via release #5964 (closes #5924). Recovery (/retry + edit-resubmit) now honors a genuinely-picked model without cross-session leak — converged over a 4-round Codex gate (deliberate-pick + fire-time re-arm guards). Thanks @b3nw.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M Medium PR (≤10 files, ≤250 LOC)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants