Picking a model in the dialog works, yet switching from
gpt-5.6-sol to gpt-5.6-luna can leave the persisted slug
untouched. The picker is not the problem. The problem is that “the model” is written by
four uncoordinated call sites and read back from two disagreeing authorities — a
frontend-optimistic selectedModel and the host session snapshot.
Four writers, two authoritiesPlan only · no implementationRepro: gpt-5.6-sol → gpt-5.6-luna
1. Symptom
What you see
Open the picker, choose gpt-5.6-luna, continue chatting.
Picker highlightluna
Persisted session slugsol
Sidebar / session row modelsol
The optimistic picker state says luna. The host session — and every
surface that reads the session — still says sol.
What “works”
A brand-new session created after the pick.
New-session modelluna
Prompt-time contextluna
Transcript footerluna
Creation paths read the live selectedModel. Everything that reads the
stored session — or gets re-synced by refreshModels — can disagree.
2. Two mental models never merged
The codebase carries two incompatible definitions of “a model”, and the switch between
them is only half-finished. The legacy model — a base model plus variants,
agents, and provider defaults — came from the pre-Host harness bridge. The current model
— offerings that route to a backend + upstream id — came from
ADR 0014. The
frontend keeps the legacy shape while the Host speaks the new one.
Legacy shape (kept, stubbed)
SelectedModel { providerID, modelID }
variantSelections keyed per model
cycleVariant() / revertVariant()
agents, providerDefaults
Host truth (ADR 0014)
Offering slug gpt-5.6-luna selected by user
Route backendId + upstreamModelId resolved on Host
Session stores { connectionId, modelId }
Entitlements attach to the slug
Frontend reality
Fabricates opengui-offering provider from offerings list
Maps offering slug → model option in the picker
Writes selectedModel from four places
Never joins it back with the stored session
Cmd+T is the tell. The shortcut the UI wires to
cycleVariant() is a () => {} no-op in
HostProvider, while variantSelections and
currentVariant are read by no live component. A user who “switches model”
with the shortcut that used to cycle variants now gets silence.
3. Root causes
1
The offering provider is fabricated client-side
refreshModels in HostProvider folds
listModelOfferings() into a synthetic
opengui-offering host connection: modelIds = offering
slugs, modelCapabilities = display names. Selection identity is
therefore derived twice: the slug the user picked and the
backend/upstream route the Host resolves live on opposite sides of the wire with
no shared contract.
If the offerings list is empty, unauthorized, or not yet loaded, the offering
provider disappears entirely and any slug selection fails the “still visible”
check.
2
refreshModels rewrites the selection asynchronously
Every boot and refreshProviders() call runs
setSelectedModel with a fallback that snaps to
connections[0].defaultModelId when the current selection is “not
visible” in the freshly built provider list. Combined with cause 1, a slug whose
offering list shifted disappears and the picker snaps back — the user’s pick is
silently undone, which reads as “the slug did not update”.
3
Two storage authorities are never joined
The live selectedModel (frontend-optimistic) and the stored session
model (session.model / activeSnapshotRef) are written
and read independently. setModel updates the optimistic state and the
snapshot ref, but never refreshes the sessions list. Any surface
rendered from sessions — sidebar rows, session list — keeps the old
slug indefinitely.
4
Four writers, no reducer, no race handling
setSelectedModel is called from refreshModels fallback,
hydrateTranscript, the optimistic setModel, and
setPromptBoxSelection. In-flight host.setModel calls can
interleave with refreshModels; whichever runs last wins, and the two
authorities can end up holding different slugs with no reconciliation.
5
setModel never re-hydrates the session
After a successful switch the action sets activeSnapshotRef and
returns. It does not call refreshSessions() or
hydrateTranscript(), so the canonical stored slug and the surfaces
that display it are never reconciled with the optimistic pick.
4. Repro flow
What the code does today
User picks gpt-5.6-luna in the dialog.
selectModel → setModel({providerID, modelID}).
Action optimistically sets selectedModel = luna.
host.setModel persists luna into the session.
Returned snapshot lands in activeSnapshotRef — only.
sessions list, sidebar rows keep sol.
Next refreshModels re-derives providers and may re-snap selection.
Where it diverges
Picker reads selectedModel → shows luna.
Session surfaces read sessions / snapshot → show sol.
If offerings reload is empty, refreshModels snaps to default
sol.
Result: the slug that “sticks” depends on which writer ran last.
The bug is not “the picker forgets the model”. It is that the persisted model
slug and the displayed model slug are produced by different state, and nothing
reconciles them after the optimistic write.
5. Target: one selection store, host-resolved
Make the Host the single authority for the model catalog and the resolved
selection. The frontend keeps exactly one SelectedModel and derives all
display from it — never from a re-derived provider join.
Single source
One selectedModel in one reducer
Written by one action: setModel
Read by picker, prompt box, context info
Reconciled after every host round-trip
Host owns catalog
Offerings + entitled connections returned as one list
Display name, slug, capabilities all from Host
No client-side opengui-offering fabrication
Host returns resolution
setModel response carries the canonical slug
Frontend writes it back into the single store
Sessions refresh after every switch
6. Contracts
Make the model identity explicit instead of two-string guessing:
Decide Cmd+T’s future: cycle entitled offerings, or remove the shortcut.
Remove unused agents / providerDefaults from the model
context.
Exit: the model context exposes exactly the fields live components read.
8. Acceptance
Verdict
The fix is not another guard in the selector. It is consolidating model selection to
one store, making the Host return the canonical resolution, and deleting the
half-migrated legacy layer. That is the difference between a picker that works and a
model system that can be reasoned about.