fix: apply and display mid-session model changes (#981) - #1047
Conversation
Resolve the Claude active model against the provider-native session id (not the internal session id), and normalize raw JSONL model ids to catalog aliases before returning them.
…viderModel Consolidate the four per-provider reconcile effects into one loop keyed by a storage-key lookup table, and make it prefer the current in-memory model over the stored default (previously reversed, so a freshly-applied session-scoped model got immediately overwritten by the last persisted default). Track session-scoped changes via setProviderModelState so selectProviderModel can apply the resolved model to local state right away instead of waiting on a future reconcile.
…vider-native id resolveResumeModel looked up a session's stored model override keyed by the provider-native session id, but overrides are written keyed by the app-facing session id — so a picked model would only actually apply because the client also resends it on every message, not because the override lookup succeeded. A reconnect or a second client on the same session would silently miss it. Credit to PR 996 (Kennems) for identifying this mismatch. This applies the same fix but centralizes the appSessionId-or-sessionId fallback inside resolveResumeModel itself, so the four provider runtimes (claude-sdk, cursor-cli, openai-codex, opencode-cli) just pass both ids through instead of each repeating the fallback logic.
1M context is now default/automatic for Sonnet 5 and Opus 4.8, not a separate beta-gated mode, so sonnet[1m]/opus[1m] no longer represent a real choice and were never translated into a valid SDK model id when selected. Also updates the stale "Sonnet 4.6" wording now that the sonnet alias resolves to Sonnet 5.
setProviderModelState (added for mid-session model switching) claimed to apply the resolved model "in memory only," but the reconcile effect's pickStoredOrCurrent change (preferring `current` over `stored`) caused the very next reconcile pass to persist that session-scoped value into the provider's localStorage default key — seeding it into future new chats and contradicting PR 762's intent that model selection not act as a provider-level preference. sessionScopedModelRef tracks which model was applied session-scoped per provider. The reconcile effect skips persisting to localStorage while the current value still matches the recorded session-scoped value, and only clears the flag (resuming normal persistence) once the value genuinely diverges — e.g. a real default change via the pre-session picker, or the model falling out of the catalog on refresh. This is sticky until divergence, not just for the single pass immediately after the switch — an initial delete-on-match version was found to reopen the leak on the very next unrelated reconcile trigger. The reconcile effect's per-provider body (the pickStoredOrCurrent resolution plus the ref-check/clear and localStorage persist) is extracted into a standalone, hook-free reconcileProviderModel module. It takes a minimal storage interface (localStorage in the app, an in-memory stand-in in tests), so the sticky-until-divergence logic can be unit-tested with the repo's existing node:test convention instead of introducing a jsdom/@testing-library DOM-rendering test framework. The effect's timing, dependency array, and per-provider loop are otherwise unchanged; it still owns the React state setter and passes the live sessionScopedModelRef.current in by reference. Includes a parametrized regression test (all four providers) driving reconcileProviderModel directly: the initial leak, the delayed- reoccurrence case (repeated calls with the session-scoped ref still set, verifying suppression is sticky rather than one-shot), and correct persistence on genuine divergence (real default change and model falling out of the catalog). Note this exercises sticky-until-divergence at the pure-function level, not by re-firing the effect through a rendered multi-provider React tree. Does not affect /models Active-model, /status, or /cost, which remain driven by the backend JSONL/provider-native read and correctly wait for a turn to complete.
…ire up npm test matchClaudeModelOptionFromRawId kept its own alias/regex list alongside the catalog, so a new Claude model would need recording twice: a catalog entry to appear in /models, and a pattern entry to be recognized in raw transcript ids. Left to drift, a catalog-only model would list and select fine but resolve to 'default' on display. Family aliases are now derived from CLAUDE_FALLBACK_MODELS.OPTIONS itself (every plain-word value other than the 'default' sentinel), so a new model matches as soon as it has a catalog entry, the same source of truth /models is populated from. Concept credit to PR 998 (Sunjaieks); implemented independently: matching is on the alias as a whole word anywhere in the id rather than only as a 'claude-<alias>' prefix, so Bedrock-style ids (us.anthropic.claude-haiku-...-v1:0) still resolve, with the longest alias winning on multiple hits. Adds a regression test for the Bedrock-style id; behaviour is otherwise unchanged and pinned by the existing alias-mapping and fallback tests. Also adds an npm test script, which main lacked. 'test' runs both suites; 'test:client' and 'test:server' split them by tsconfig (the server tests need server/tsconfig.json for the @/ path aliases), mirroring the existing build:client/build:server naming. Both are node:test unit suites, the only kind of test in the repo. Without a runner the new server-side provider tests here could not be exercised by npm.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThe PR adds session-aware resume model resolution, maps Claude transcript identifiers to catalog options, separates session-scoped client selections from persisted defaults, adds regression coverage, and introduces npm commands for client and server tests. ChangesModel resolution and persistence
Sequence Diagram(s)sequenceDiagram
participant ChatWebSocket
participant ProviderRuntime
participant ProviderModelsService
participant ClaudeModelsProvider
ChatWebSocket->>ProviderRuntime: Pass sessionId and appSessionId
ProviderRuntime->>ProviderModelsService: Resolve resume model
ProviderModelsService->>ClaudeModelsProvider: Read changed active model
ClaudeModelsProvider-->>ProviderModelsService: Return catalog model
ProviderModelsService-->>ProviderRuntime: Return resolved model
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/components/chat/hooks/useChatProviderState.ts`:
- Around line 297-302: Reset or rehydrate sessionScopedModelRef whenever
selectedSession?.id changes, so it reflects the newly active session’s provider
models rather than retaining values from the previous session. Update the
session-change handling alongside the reconciliation logic near
sessionScopedModelRef and preserve suppression of persistence only for models
belonging to the current session.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 9bacfa3c-9f50-4481-b9f0-c3ae051c595c
📒 Files selected for processing (14)
package.jsonserver/claude-sdk.jsserver/cursor-cli.jsserver/modules/providers/list/claude/claude-models.provider.tsserver/modules/providers/services/provider-models.service.tsserver/modules/providers/tests/claude-models.provider.test.tsserver/modules/providers/tests/provider-models.service.test.tsserver/modules/websocket/services/chat-websocket.service.tsserver/openai-codex.jsserver/opencode-cli.jsserver/routes/agent.jssrc/components/chat/hooks/__tests__/useChatProviderState.test.tssrc/components/chat/hooks/reconcileProviderModel.tssrc/components/chat/hooks/useChatProviderState.ts
|
I hit this same bug and arrived at the same root cause independently, so first: confirming your diagnosis. The pin is written under the app-facing session id and read with the provider-native one, and it only appears to work for sessions discovered on disk where the two happen to be equal — 1 of 6 sessions in my install, which is why it reads as intermittent. Two things from running an equivalent fix on my own instance that might be worth folding in. 1. The
|
Hey @nico-stever, so this is a misconception that I also fell for and has taken me like an hour or so to work out why our two outputs are different. My guess would be you are using a Max plan or similar? At the very least you are not using a Pro plan. Currently the Max plan reports the default as The CLI=~/.local/share/claude/versions/<your-version>
grep -a -o -E 'function KO\(\)\{[^}]{0,400}' "$CLI"
grep -a -o -E 'function W6e\(\)\{[^}]{0,100}' "$CLI"
grep -a -o -E '.{80}"\[1m\]".{80}' "$CLI" | headYou'll find the The important part: that gate only decorates the alias label. It isn't a capability gate. Both strings are in the CLI's static catalog and both resolve. I checked My knee-jerk reaction is that this is now a bug on Anthropic's side, and a hold-over from when 1M context genuinely was gated to Max accounts. Judging by issues like anthropics/claude-code#39841 even that gating is fairly littered with bugs, with Max users being told 1M is "included with subscription" while the CLI demands extra usage credits to actually select it. I hit the same thing testing All this being said, CloudCLI doesn't currently use TL;DR: plain That said, I don't have a Max account, so I would love it if you could confirm that If on the other hand it doesn't work, or you are on a Pro account, please let me know so I can cry some more. For a real "supported models" list you can trawl the binary. Here's what I came up with on 2.1.220, every model entry the CLI code knows about, complete and unfiltered:
Worth noting In regards to your second point:
Agreed, though I'd say the practical risk is low: sessions are throwaway working context, not something a user expects to stay pinned to a model weeks later. It's also worth noting |
Opus was updated to 5; update the flavor text so it doesn't fall out of date.
|
Confirmed on Max — and you were right on all three points. No crying required. 1. Plain 2. The gate is exactly where you said it is. Same helper on my install: 3. The 1M window belongs to the plain model id, not to the suffix. Rather than read it off grep -a -o -E '.{0,120}"claude-opus-5".{0,200}' "$CLI"
# → ... context:{window:1e6} ...
So dropping the On the stored pins: fair, I'll withdraw it. Your point that sessions are throwaway context is the right frame, and a release note is proportionate. One aside that supports your Measurements run on my own instance; the binary greps and the write-up were done with Claude Code. |
Summary
Fixes #981. Changing the model mid-session from the /models modal showed "applies next response," but the model never actually changed, and reopening the modal showed the old model.
This PR is deliberately scoped to that issue. It fixes the runtime bug, fixes the display bug that made it hard to diagnose, and closes the persistence regression that fixing the runtime bug would otherwise introduce.
Reproducing
It doesn't change. Run /models again and it still reports the old model; the transcript for the new turn also still records Sonnet. Same failure as #981's repro, without needing an account that lacks Opus access.
Root cause
Two bugs, the second of which made the first hard to diagnose:
Runtime: the change never applied.
resolveResumeModellooked up the stored per-session override keyed by the provider-native session id, but overrides are written keyed by the app session id. The lookup never matched, so the override was ignored on every subsequent turn and the session carried on with its original model.Display: the modal showed the wrong model.
getCurrentActiveModelmatched transcript events against the app session id, but the JSONL records the provider-native id. For sessions created through the app the two ids differ, so every event was rejected and the lookup fell back todefault. For sessions imported from the CLI the two ids coincide (the sessions table keys both columns on the provider-native id), so matching succeeded, but the transcript stores the raw model id (claude-opus-4-8), which was never mapped back to the catalog alias (opus) the modal highlights, so nothing was highlighted. Both populations saw a wrong display, through two different halves of the same resolver.Changes
Backend: make the change take effect and read the real model back
resolveResumeModelresolves the override with the app session id, falling back to the provider-native id. The fallback is centralized insideresolveResumeModel, so the four provider runtimes (claude-sdk, cursor-cli, openai-codex, opencode-cli) just pass both ids through instead of each repeating it.getCurrentActiveModelmatches transcript events by provider-native session id and normalizes raw JSONL model ids to catalog aliases before returning them, so every reader that goes through it (/models, /cost, /status) is correct once a turn has run.CLAUDE_FALLBACK_MODELS.OPTIONSother than thedefaultsentinel is a family alias, so a newly released model is recognized as soon as it has a catalog entry. Matching accepts the alias as a whole word anywhere in the id, so Bedrock-style ids (us.anthropic.claude-haiku-4-5-...-v1:0) also resolve.sonnet[1m]andopus[1m]catalog entries are removed as part of this fix, not as a drive-by cleanup. The display fix maps every raw transcript id to its bare family alias, so decorated variants would need dedicated resolution code to ever be highlighted, and any such code would be wrong: on Sonnet 5 and Opus 4.8 the 1M context window is standard, not a separate beta-gated mode, and the[1m]suffix is unhandled everywhere in the codebase, so those cards duplicated the entries above them and on main legitimately show models that do not exist as distinct choices. Their removal also fixes the stale "Sonnet 4.6" descriptions (the label symptom reported in Model picker never shows new Claude models — getSupportedModels() is disabled, returns a hardcoded stale list #1043; the dynamic-discovery request in that issue is out of scope here).Frontend: apply the pick locally without introducing a persistence regression
sessionScopedModelRefrecords the session-scoped value and suppresses the write until it genuinely diverges (a real default change, or the model falling out of the catalog). The per-provider reconcile body is extracted into a hook-freereconcileProviderModelmodule so this is unit-testable.Behaviour, per the design in #762
The session-override design landed in #762 (commit
9aa92700, "feat: support session-scoped model overrides"), and its commit message states the two principles this PR follows:"Active model" means the model the session is currently running on. That commit says the modal "shows when a selected model will apply on the next response": a pending pick is displayed as pending, not reported as active. Accordingly, after a mid-session pick, /models and /status keep reporting the model the session is actually on until a turn has run on the new one, and the modal marks the pick beneath the current model, e.g.:
One consequence: that marker is currently modal-local, so reopening the modal before the next message shows the running model with no pending indicator. Persisting the marker across modal opens is a worthwhile follow-up, but it is UI work beyond #981. Unlike the original bug, the next message genuinely runs on the picked model.
A session pick must not become the provider default. The same commit describes the prior behaviour, "model selection was acting like a provider-level preference," as the bug it fixed, moving session choices into app-owned state precisely so they stop mutating the default. This PR keeps that boundary: a new chat's selection prefills from React state with the model just used, but the persisted provider default is never written and a reload returns to it.
Known limitation, pre-existing on main: interrupted or errored turns record placeholder model values like
<synthetic>in the transcript, and the backwards scan stops at the first model it finds, so a trailing placeholder masks the session's real model. On main it surfaces as the raw sentinel string; here the normalization renders it asdefault. It is a distinct pre-existing defect, left out of scope (#998 fixes it by skipping placeholders).Tests
Of the 520 additions in this diff, 326 are two new test files (165 and 161 lines); the code change itself is about 194 additions and 109 deletions across the other 12 files.
Under the repo's existing node:test convention (no new test framework):
claude-models.provider.test.ts: active-model resolution against the provider-native id, raw-id to catalog-alias normalization, Bedrock-style ids, and unrecognized-id fallback.useChatProviderState.test.ts: the session-scoped model being applied but not leaked to the provider default across reconcile re-runs, and persisting correctly on genuine divergence, across all four providers.npm run build,npm run typecheck, andnpm run lintpass. This PR also adds annpm testscript, which main lacked:test:clientcoverssrc/, andtest:serverruns the server suites underserver/tsconfig.json(they need it for the@/path aliases), mirroring the existingbuild:clientandbuild:servernaming. Both are node:test unit suites. All 19 client and 123 server tests pass.Relationship to #996 and #998
All three PRs target #981; this one was developed independently and converges with the others where there is one correct answer.
Credit to #996 (@Kennems) for identifying root cause (1), the app-session-id vs provider-native-id override mismatch; their work is what pointed me at it. This PR fixes the same mismatch, centralized inside
resolveResumeModelrather than repeated per runtime. Where #996 stops short is the display path: it surfaces the pending override for /models, /cost and /status but does not touchgetCurrentActiveModelitself, so app-created sessions whose model was not set through the UI still resolve todefault, and raw transcript ids are never normalized to catalog aliases, so CLI-started sessions report an id no card can highlight. It also does not apply the pick to frontend state, so the composer does not reflect it. #996 additionally stripsANTHROPIC_MODELandANTHROPIC_DEFAULT_*_MODELfrom the SDK subprocess env, a genuinely useful fix, but a distinct root cause affecting hosts that set those variables, deliberately not duplicated here.Credit to #998 (@Sunjaieks) for the catalog-derived family matcher concept, which this PR adopts with an independent implementation (whole-word matching anywhere in the id, covering Bedrock-style ids that a
claude-<alias>prefix match misses). #998 has the most complete backend of the three: provider-native id matching, normalization, pending-override surfacing, and<synthetic>handling. The differences are scope and the frontend. It bundles two unrelated fixes (env-var stripping and override-cache cleanup on delete; both reasonable, neither #981). On display semantics, it reports a pending pick as active before any turn has used it, which #762's design explicitly distinguishes from the active model. On the frontend, it routes a mid-session pick throughsetStoredProviderModel, so picking a model for one session rewrites the user's persisted provider default; that reintroduces exactly the behaviour #762 describes as the bug it fixed ("model selection was acting like a provider-level preference"). It has no frontend tests and does not address the reconcile-revert problem, which only stays hidden there because the pick overwrites the stored default it would otherwise be reverted to.Why this PR: it is the only one of the three that fixes the display for both session populations, reflects the pick in the composer, and preserves the boundary #762 drew between a session pick and the user's default, while staying scoped to #981. The pieces of #996 and #998 that are genuinely valuable and out of scope here (env stripping, cache cleanup on delete, placeholder skipping, persisting the pending marker) would make good follow-ups regardless of which PR lands.
Summary by CodeRabbit
New Features
Bug Fixes
*[1m]variants).Tests