Repository navigation
fix: move a Claude model you chose to the default before a message fails once Claude stops offering it - #26459
brennanb2025 wants to merge 18 commits into
Conversation
…atalog row When model discovery is empty or fails and the current model is missing from the saved catalog, it was added with no effort choices, so the picker refused efforts the provider accepts. Give it the same unknown-capability choices as saved rows; a real catalog still constrains.
… chats Idle and restored chats treated a saved model catalog as current truth, so an effort-only change could save an unchosen model and valid effort choices could disappear. Live and resting reads now use the same projection: saved rows give labels only, unknown capabilities stay permissive, and a real child listing still restricts choices.
Reuse the registered Claude saved-choice policy when serving modelCatalog, so remembered capability claims cannot reject effort picks before startup. Keep explicit launch choices and live-child authority, with local/paired picker and runtime composition regressions.
…-model-discovery-retry # Conflicts: # src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts
A model selected before the host knew it (launch seed, held pick) could lose its effort choices when a remembered catalog without it arrived. The shared option snapshot now gives any selected Claude model missing from a non-live list the permissive unknown-model effort choices, so every picker input gets the same rule; real live listings still restrict. Updates two tests whose setup predated main's initialize-time startup report.
Review summaryProblem: when Claude couldn't list its models on an SSH host (the list failed or came back empty), that failure stuck: Retry and new chats kept failing until the server restarted. Fixing that surfaced a family of related picker bugs. A saved, older model list was being treated as the truth about what the current model supports. So valid effort choices (like Extra high) could disappear or be refused, and changing only the effort could quietly save a model you never picked. User-facing change:
How: one rule for saved model lists, applied at the single place that builds every picker's choices: a running chat, a stopped or restored chat, a new chat before its server-side record exists, and the phone. The SSH server also applies it to the model list it sends, so older desktops and phones get the corrected list too. Review rounds: each of the earlier rounds found the same bug at a different picker input. Round 5 moved the rule to the shared place that builds the picker's choices, and checked every input against it.
Not verified: a live SSH chat hitting a failed model list and pressing Retry, the rendered desktop and phone pickers, and Linux, Windows or WSL. Live SSH QA across all the SSH chat fixes is running now, and screenshots will go in the PR body before this is marked ready. Claude chats over SSH also need #26450 (the server build fix) to start at all. Merge-order note: if #26116 (the chat permission picker) merges first, this PR's model-choice code for a stopped chat has to be combined with #26116's permission handling there. The combined test build found and fixed that interaction, and the same fix goes into whichever PR merges second. |
A saved model list that lacks the chat's selected model no longer gets every effort level offered for it. An aged list is re-listed once before it may say the model is gone; a current list that lacks it moves the chat to the listed default, at rest, at the next start, and in the picker before the chat starts. While the list is loading or failing, the selection stays and no effort levels are invented for it.
… a Claude start - A model Claude's own settings or env chose (one Orca never sent, which no listed row names) is no longer saved as the chat's pick, so a later list can't call it gone; the next start lets the CLI choose again. - Host model rows keep the id each alias runs, and a pick saved as that id counts as listed. - A running child's list keeps the age of its start and never replaces a newer listing, so it can't make a newer pick look gone. - A start waits at most 3 s for the one re-listing, then launches as saved; at-rest reads never wait. - The host names the replacement (the account's listed default) so a floating chat's picker shows what the start runs; effort moves to the nearest level the replacement offers. - Remove the unused effortDefaultsToModel and fallbackModels rules.
…-model-discovery-retry # Conflicts: # src/main/claude/claude-structured-effort-default-at-rest.test.ts # src/main/claude/claude-structured-session-options.ts # src/main/claude/claude-structured-session-startup.ts # src/main/claude/claude-structured-session-state.ts # src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts # src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts # src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts # src/main/runtime/rpc/methods/structured-agent-session-options-read.ts # src/main/runtime/structured-agent-model-catalog-wiring.ts # src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts # src/shared/agent-session-wire.ts # src/shared/structured-agent-session-options.ts
…o listed row names Two runtime tests pinned the scripted CLI's unlisted init model as the saved pick.
…n list - Record who chose a chat's model (`modelSource`: picker or caller). Only a picker's selection is checked against the catalog or replaced; a model a caller named (orchestration or agent-launch `--model`) runs as given, and records from before this field read as a caller's. - Name a replacement only while the merged list is the current account-level listing, never a running chat's (possibly project-narrowed) list. - Desktop and phone re-read a chat's options when the listing the host said was running lands, so a stopped chat shows the replacement before a send. - A start reads the catalog only for a picker's model on an agent that replaces one, and the whole read is capped at the start's 3 s wait. - One "is this model listed" rule; a live child reporting no model names the listed default only where the agent's seed proves it. - The scripted CLI lists real `resolvedModel` rows; the unanswered-control test waits for the start's own write.
…-model-discovery-retry # Conflicts: # src/main/claude/claude-structured-session-startup.ts # src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts # src/main/runtime/rpc/methods/structured-agent-session-create.ts
- Rename the persisted modelSource to modelChosenBy: 'caller' | 'picker' | 'new-chat-default'. Picks and the remembered new-chat selection are replaceable; a caller's or an absent value never is. Unknown stored values read as absent instead of setting the chat aside. - Re-read a stopped chat's options once when a catalog answer that did not wait names a replacement those options still lack (desktop and phone). - The picker view uses the shared listing rule for a seed. - Move the child listing clock to its own file; a start's read relies on its caller's 3 s cap instead of a second inner wait. - Revert the unrelated @pnpm/exe lockfile churn.
…-model-discovery-retry # Conflicts: # src/main/codex/codex-background-terminals.ts # src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts # src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts # src/shared/agent-session-wire.ts # src/shared/structured-agent-session-options.ts
A new-chat seed matched only by a row's resolved model id now commits that row's id, so the picker selects it instead of adding a raw-id duplicate. Reword the setOption comment (any client of the public RPC counts as a picker) and pin the predicate's live-source guard with a unit test.
Review summary (final head
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
📝 Walkthrough
Priority: ➖ Normal Merge Risk: 🔵 Low · up to The change looks mergeable with minor awareness. Two earlier low-severity concerns remain open: a test that does not set up the aged-listing scenario it describes, and a model replacement that may overwrite the effort a user saved for the target model. Pre-merge checks |
|
There was a problem hiding this comment.
Actionable comments posted: 2
ℹ️ Review info
⚙️ Run configuration
- Configuration used: Repository UI
- Review profile: CHILL
- Plan: Advanced
- Run ID:
f0d9f240-8538-42ca-b9de-aa80934a1083
📒 Files selected for processing (86)
mobile/src/session/use-mobile-host-model-catalog-upgrade.tsmobile/src/session/use-mobile-structured-agent-options.stopped-replacement.test.tsmobile/src/session/use-mobile-structured-agent-options.tssrc/main/claude/claude-model-catalog-probe.tssrc/main/claude/claude-sdk-model-discovery-snapshot.test.tssrc/main/claude/claude-stream-json-connection.test.tssrc/main/claude/claude-structured-agent-definition.tssrc/main/claude/claude-structured-applied-options.test.tssrc/main/claude/claude-structured-cached-model-capabilities.test.tssrc/main/claude/claude-structured-catalog-listing.tssrc/main/claude/claude-structured-effort-default-at-rest.test.tssrc/main/claude/claude-structured-effort-reporting.test.tssrc/main/claude/claude-structured-live-catalog-age.test.tssrc/main/claude/claude-structured-model-catalog.tssrc/main/claude/claude-structured-model-discovery-retry.test.tssrc/main/claude/claude-structured-session-adapter-turns.test.tssrc/main/claude/claude-structured-session-options.tssrc/main/claude/claude-structured-session-startup.test.tssrc/main/claude/claude-structured-session-startup.tssrc/main/claude/claude-structured-unknown-model-persistence.test.tssrc/main/native-chat/agent-model-catalog/agent-model-catalog-child-listing-clock.tssrc/main/native-chat/agent-model-catalog/agent-model-catalog-entry.tssrc/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.tssrc/main/native-chat/agent-model-catalog/agent-model-catalog-required-model.test.tssrc/main/native-chat/agent-model-catalog/agent-model-catalog-retry.test.tssrc/main/native-chat/agent-model-catalog/agent-model-catalog-selected-model.tssrc/main/native-chat/agent-model-catalog/agent-model-catalog-selection.tssrc/main/native-chat/agent-model-catalog/agent-model-catalog-service.tssrc/main/native-chat/agent-model-catalog/agent-model-catalog-store.tssrc/main/native-chat/agent-model-catalog/agent-model-catalog-workspace-default.tssrc/main/native-chat/agent-session-wire/structured-agent-definition.tssrc/main/native-chat/agent-session-wire/structured-agent-session-acquisition.tssrc/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.tssrc/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.tssrc/main/native-chat/agent-session-wire/structured-agent-session-attach.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.tssrc/main/native-chat/agent-session-wire/structured-agent-session-option-models.tssrc/main/native-chat/agent-session-wire/structured-agent-session-options-read.tssrc/main/native-chat/agent-session-wire/structured-agent-session-options-resting-catalog.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-start-model.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.tssrc/main/native-chat/agent-session-wire/structured-agent-session-turns-options.tssrc/main/native-chat/agent-session-wire/structured-agent-session-turns.tssrc/main/runtime/agent-session-record-founding.tssrc/main/runtime/agent-session-record-options.test.tssrc/main/runtime/agent-session-record-options.tssrc/main/runtime/agent-session-record-store.tssrc/main/runtime/agent-session-reservation-admission.tssrc/main/runtime/claude-structured-startup-unanswered-control-request.test.tssrc/main/runtime/orca-runtime-get-structured-agent-session-create-support.tssrc/main/runtime/orca-runtime-structured-agent-session-create-intent.test.tssrc/main/runtime/rpc/methods/orchestration/worker/structured-worker-launch-seed-options.test.tssrc/main/runtime/rpc/methods/structured-agent-session-create-handler.tssrc/main/runtime/rpc/methods/structured-agent-session-create-message.test.tssrc/main/runtime/rpc/methods/structured-agent-session-create.tssrc/main/runtime/rpc/methods/structured-agent-session-options-read.test.tssrc/main/runtime/rpc/methods/structured-agent-session-options-read.tssrc/main/runtime/structured-agent-model-catalog-wiring.test.tssrc/main/runtime/structured-agent-model-catalog-wiring.tssrc/main/runtime/structured-agent-session-runtime-provider-started.test.tssrc/main/runtime/structured-claude-scripted-runtime-test-support.tssrc/renderer/src/components/native-chat/use-host-model-catalog-upgrade.tssrc/renderer/src/components/native-chat/use-structured-agent-session-option-state.tssrc/renderer/src/components/native-chat/use-structured-agent-session-options.stopped-replacement.test.tsxsrc/renderer/src/components/native-chat/use-structured-agent-session-options.tssrc/shared/agent-session-legacy-handoff-lease.tssrc/shared/agent-session-model-fallback.test.tssrc/shared/agent-session-model-fallback.tssrc/shared/agent-session-option-catalog-types.tssrc/shared/agent-session-options-replacement.tssrc/shared/agent-session-record-stored-form.tssrc/shared/agent-session-record.tssrc/shared/agent-session-wire.tssrc/shared/claude-model-list-probe.test.tssrc/shared/claude-model-list-probe.tssrc/shared/commit-message-agent-spec.tssrc/shared/commit-message-model-parsers.tssrc/shared/structured-agent-session-discovered-model.tssrc/shared/structured-agent-session-option-view.tssrc/shared/structured-agent-session-options.test.tssrc/shared/structured-agent-session-options.tstests/e2e/structured-claude-starting-model-catalog.unit.test.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review.
| const effort = next.valuesByModel[selected]?.effort | ||
| const effortOption = catalog.models | ||
| .find((model) => model.id === target) | ||
| ?.options.find((option) => option.id === 'effort') | ||
| const carried = nearestAgentEffort( | ||
| typeof effort?.value === 'string' ? effort.value : undefined, | ||
| effortOption?.kind.type === 'select' | ||
| ? effortOption.kind.choices.map((choice) => choice.value) | ||
| : [] | ||
| ) | ||
| if (effort && carried) { | ||
| next.valuesByModel[target] = { | ||
| ...next.valuesByModel[target], | ||
| effort: { ...effort, value: carried } | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
set -eu
printf '%s\n' '--- target file ---'
nl -ba src/shared/agent-session-model-fallback.ts | sed -n '1,180p'
printf '%s\n' '--- references ---'
rg -n -F --glob '*.{ts,tsx}' -- 'verifiedListReplacementRecord' src tests 2>/dev/null || true
rg -n -F --glob '*.{ts,tsx}' -- 'nearestAgentEffort' src tests 2>/dev/null || true
rg -n -F --glob '*.{ts,tsx}' -- 'valuesByModel[target]' src tests 2>/dev/null || true
printf '%s\n' '--- replacement-related symbols ---'
rg -n -i --glob '*.{ts,tsx}' -- 'replacement|modelChosenBy|effort' src | grep -E 'agent|claude|session|model' | head -240 || trueRepository: stablyai/orca
Length of output: 42011
🏁 Script executed:
set -eu
printf '%s\n' '--- host catalog selection ---'
nl -ba src/main/native-chat/agent-model-catalog/agent-model-catalog-selection.ts | sed -n '1,180p'
printf '%s\n' '--- structured option view ---'
nl -ba src/shared/structured-agent-session-option-view.ts | sed -n '1,160p'
printf '%s\n' '--- replacement tests and catalog wiring ---'
nl -ba src/main/runtime/structured-agent-model-catalog-wiring.test.ts | sed -n '1,180p'
nl -ba src/shared/agent-session-model-fallback.test.ts | sed -n '1,220p'
printf '%s\n' '--- option state types and clone behavior ---'
nl -ba src/shared/native-chat-session-option-state.ts | sed -n '1,180p'Repository: stablyai/orca
Length of output: 23657
Preserve an existing effort for the replacement model.
valuesByModel retains effort values per model. When the replacement model already has an effort, do not overwrite it with the source model's carried effort.
Suggested fix
- if (effort && carried) {
+ if (effort && carried && next.valuesByModel[target]?.effort === undefined) {The host start path already applies the same nearest-effort carry rule for its flat saved options.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const effort = next.valuesByModel[selected]?.effort | |
| const effortOption = catalog.models | |
| .find((model) => model.id === target) | |
| ?.options.find((option) => option.id === 'effort') | |
| const carried = nearestAgentEffort( | |
| typeof effort?.value === 'string' ? effort.value : undefined, | |
| effortOption?.kind.type === 'select' | |
| ? effortOption.kind.choices.map((choice) => choice.value) | |
| : [] | |
| ) | |
| if (effort && carried) { | |
| next.valuesByModel[target] = { | |
| ...next.valuesByModel[target], | |
| effort: { ...effort, value: carried } | |
| } | |
| } | |
| const effort = next.valuesByModel[selected]?.effort | |
| const effortOption = catalog.models | |
| .find((model) => model.id === target) | |
| ?.options.find((option) => option.id === 'effort') | |
| const carried = nearestAgentEffort( | |
| typeof effort?.value === 'string' ? effort.value : undefined, | |
| effortOption?.kind.type === 'select' | |
| ? effortOption.kind.choices.map((choice) => choice.value) | |
| : [] | |
| ) | |
| if (effort && carried && next.valuesByModel[target]?.effort === undefined) { | |
| next.valuesByModel[target] = { | |
| ...next.valuesByModel[target], | |
| effort: { ...effort, value: carried } | |
| } | |
| } |
| store.recordSuccess(fingerprint, 'claude', { | ||
| models: [SONNET], | ||
| origin: 'probe', | ||
| fastModeTierByModel: new Map() | ||
| }) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Pass the 'discovery' source to recordSuccess. The test does not set up the list the way it says.
AgentModelCatalogStore.recordSuccess takes four arguments: (fingerprint, agent, success, source). This call passes only three, so source is undefined. agentModelCatalogEntryWithSuccess then stores the Sonnet list as the live listing, and entry.discovered stays null. As a result, isCurrent is always false, and the store skips the discovery bookkeeping (dueEntries, failures). The test says it sets up "a saved Sonnet-only list a minute old". What it actually sets up is a running-child listing with no discovery, so the age advance at Line 63 has no effect. The fastModeTierByModel field is also not part of AgentModelCatalogSuccess, both here and at Line 69. Both mismatches fail type checking if this file is part of a typechecked project.
Proposed fix
--- "a/tests/e2e/structured-claude-starting-model-catalog.unit.test.ts"
+++ "b/tests/e2e/structured-claude-starting-model-catalog.unit.test.ts"
@@ -55,11 +55,7 @@
})
let now = 1_000
const store = new AgentModelCatalogStore({ now: () => now })
- store.recordSuccess(fingerprint, 'claude', {
- models: [SONNET],
- origin: 'probe',
- fastModeTierByModel: new Map()
- })
+ store.recordSuccess(fingerprint, 'claude', { models: [SONNET], origin: 'probe' }, 'discovery')
now += AGENT_MODEL_CATALOG_CURRENT_MS
const relists = input.relists
const probe = vi.fn(async () => {At Line 69, return { models: relists, origin: 'probe' as const }.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| store.recordSuccess(fingerprint, 'claude', { | |
| models: [SONNET], | |
| origin: 'probe', | |
| fastModeTierByModel: new Map() | |
| }) | |
| store.recordSuccess(fingerprint, 'claude', { models: [SONNET], origin: 'probe' }, 'discovery') |
|
Synced with Conflicts
Fixed without conflict markers
One unrelated formatting change: the commit hook reformatted one long line from Checks
Live proof rerun on this head (the body's screenshots are replaced). A stopped chat whose picked Fake Opus / Extra high was dropped from a fresh listing showed Fake Sonnet / High before any send. Its next start launched |
|
Replaced by #27826, a much smaller version of this fix that decides the model at send time. |
The problem
Orca saves each account's list of Claude models. A chat's selected model can be missing from that saved list, either because the list is out of date or because Claude really stopped offering the model. On
main, Orca launched the missing model anyway. The first message failed with Claude's "model not found" reply, and only then did the chat fall back to the default model.Earlier rounds of this PR fixed that, and review turned up four more problems:
~/.claude/settings.json, a project's.claude/settings.json,ANTHROPIC_MODEL, a Bedrock id). Orca saved that model as if you had picked it, so a later list that didn't name it could move the chat to another model.orca orchestration worker-start --model claude-sonnet-4-5, ororca agent launch --model, saves that model on the chat just like a picker choice. Claude's model list is the picker's set, not every model Claude can run, so an older or dated id that Claude still runs could be swapped for the account's default. Nobody watches a background worker's picker, so nobody would notice.What you see now
agentSession.setOption, or in theoptionsofagentSession.create(the picks a new chat's picker held before the chat existed), from any client. Today only the desktop and phone pickers call these, but they are public calls, so any client that uses them counts as a picker.orca orchestration worker-startandorca agent launchruns without--model. So a background worker started without--modelcan have its model replaced when Claude stops offering it, even though nobody is watching that worker's picker.orca orchestration worker-start --modelandorca agent launch --model(calls through agent launch with an explicit model). If Claude can't run it, Claude's own error shows, as onmain.main's behaviour until you pick a model in them. Their model is never replaced. Once you pick a model in one, that pick counts as yours and can be replaced like any other.maxbecomesxhighwhen the default stops atxhigh).claude-sonnet-5for Sonnet) counts as listed.mainalready does.How it works
Each chat records who chose its model. A new optional field on the chat's saved record,
modelChosenBy, has one of three values:picker: a model picked for this chat (agentSession.setOptionwith a model, running or stopped, or a model inagentSession.create'soptions).new-chat-default: a new chat started from the remembered new-chat selection, including orchestration and agent launches without--model.caller: orchestration or an agent launch passed its own--model.Keeping an explicit pick apart from the remembered selection means a later question, such as "was this the user's own choice for this chat?", can be answered without guessing. Only
pickerandnew-chat-defaultcan be replaced.Only the user's own selection is checked. The catalog service looks at a chat's saved model only when
modelChosenByispickerornew-chat-default. A start reads the catalog only for such a chat, and only for agents that replace a gone model (Claude). The whole start read, including the workspace-config checks it may wait on, is capped at 3 seconds in one place (the start's own cap; the read has no second, inner wait).Only the account-level listing can call a model gone. The "account-level listing" is the list Orca asks Claude for outside any chat, with no project settings applied. Orca names a replacement only while that listing is newer than any running chat's list and less than a minute old. A running chat's list, which its project's settings may narrow, never decides that a model is gone. Each save of a running chat's list is dated by when that chat first saved it, so an old chat's list never counts as newer than a fresh one.
One fresh listing per decision. Orca lists once if all of these hold: the account-level listing is at least a minute old, the list doesn't name the picked model by id or by the id an alias runs, and no listing failed in the last 30 seconds. A start waits up to 3 seconds for it. A picker's follow-up read waits up to the existing 30-second picker limit. Every other read answers "listing in progress" right away.
The host names the replacement. A catalog answer carries a new optional
unlistedModelReplacementfield, sent only for a picked model while the list is current. The host's start step, the host's answer for a stopped chat, and the desktop picker all use that one value with one shared rule (agent-session-model-fallback.ts). The same file now holds the one "is this model listed" check, which Claude's option code uses too, so the copies can't drift apart.Stopped chats re-read their options. On desktop and phone, the picker's waiting catalog read already landed when the listing finished, but its answer was ignored once the chat's own options had arrived. Now the picker also reads the chat's options again at that point, and they come back decided against the fresh list. It does the same once for a catalog answer that didn't wait, if that answer names a replacement and the chat's options still show a model the answer's list lacks. A re-read never triggers another one: the catalog read doesn't run again because the options changed. There is no wire change.
Resolved ids are kept. Claude's listing names the model id each alias runs. That id now stays on the host's saved rows: through the listing, the saved file, and the optional wire field
resolvedModel. Matching uses it.What a start saves. When a Claude chat started with no model from Orca, its reported model is saved only if a listed row names it. Otherwise the record stays unpicked.
An unknown model is left out, not sent as
''. When nothing identifies a running chat's model, its options leavecurrent.modelout, followingmain's optional-model rule.Merged with
mainmain's new shared model catalog landed while this PR was open: one saved catalog for every agent, account-level listings kept apart from running-chat listings, a learned configured default, and saved-only reads. This PR builds on it and drops its own copies of whatmainalready does:main. At rest, a Claude chat names the model and effort Claude's config resolves to, oncemainhas learned them.main. A running Claude chat doesn't read the host's saved list. With no list of its own, it shows the built-in list, as onmain.Why
The aim is what you would expect: a model you picked is never silently dropped because a list is old, a model Claude really removed is replaced before a message fails, a model you or a script named on purpose keeps running, and checking never holds up a start.
Alternatives considered:
--modelat start. Rejected for now:mainruns it as asked. Claude's own error already says when it can't run.options.How this differs from the common pattern:
main's behaviour.maindoes for--modeltoday. The common pattern refuses such a change with a plain message.mainlets them add which models exist.main's catalog rules.main. Such a model has nomodelChosenBy, so it is never replaced. It does, however, stop the chat from following a latersettings.jsonchange. Follow-up: stop saving a model Orca never sent.One change for every agent, disclosed: a running chat whose agent reports no model now shows the quiet "Model" placeholder instead of naming its list's default as what it runs. The exception is an agent whose built-in list proves its default is what a launch with no model runs (Grok).
mainnamed the listed default for every agent. A child that reports no model has said nothing about what it runs, and the catalog's own rule is to name a default only where it is provable. In practice this is the moment before a chat's first report, or an agent that never reports one.Known gap (from
main, not changed here): at rest and in a running chat, a no-pick Claude chat names the configured defaultmainlearned. If you then change only its effort, the picker saves that effort together with that model, so new chats launch it by name. Not addressed here; worth a follow-up decision.Compatibility (compared with
main)Clients and servers update independently.
unlistedModelReplacementon the catalog answer andresolvedModelon model rows. Older clients ignore both. Neither side has a strict schema for them.modelChosenBy. It never crosses the wire. A record without it behaves as onmain(never replaced). An unknown value reads as absent instead of setting the chat aside.main, a running Claude chat that reported no model was given the list's default.main's existing "client capability missing" refusal (fix(acp): start chats for agents that report no model #26587) for that read.Linked Issue
Internal SSH structured-chat remediation plan, model-discovery failure (U28). No new issue opened.
Visual Proof
A live run on the desktop app with a local folder workspace. It used a stand-in Claude CLI whose model list can drop "Fake Opus" on demand; the real
claudenever ran. The run was on the final head59adf2864e3, after the last merge withmain.1. Pick "Fake Opus (listed)" at Extra high and send a first message. The start launched
--model claude-opus-4-6 --effort xhigh.2. Claude stops offering Fake Opus. The stand-in's fresh listing drops
claude-opus-4-6. Reopening the stopped chat, with nothing sent, shows the account's default "Fake Sonnet (listed)", and the effort moves from Extra high to High. The picker no longer lists Fake Opus.3. The next message runs on the replacement with no failed turn. The start launched
--model claude-sonnet-4-6 --effort high --resume=….4. When Claude offers Fake Opus again, a new chat's picker lists it again, with all its effort levels.
What the run also showed:
Testing
Run on macOS with
env -u ORCA_USER_DATA_PATH, at most two workers:Tests this PR adds or changes: 32 files, 287 passing, 1 skipped (on the head merged with current
main; with the rest of the catalog folder, 42 files and 427 passing).Phone: the 6 option test files under
mobile/src/session/, including both stopped-chat tests, 50 passing.Proof the new tests catch the bugs: removing each piece one at a time makes its own test fail:
pickernew-chat-defaultnew-chat-defaultas replaceable (catalog answer and start)Earlier rounds' ablations (the account-level-only check, the caller check in the catalog and at start, the 3-second cap, the waiting re-reads, the start's save rule) still apply.
Real Claude CLI:
claude-structured-real-cli.test.tsandclaude-structured-real-cli-fold.test.tspass (13 tests, with the user's own login).Typecheck:
tsconfig.node.json,tsconfig.tc.web.jsonand mobile (with its tests-typecheck ratchet) are all clean.Repo checks:
orca-ci-checks --no-typecheckpasses 22/23. The one red is 5no-cyclewarnings in mobile transport and terminal files this PR doesn't touch, a known local-only false red from generated mobile files. After the merge withmain,oxlint,oxfmt --checkandcheck:code-quality:changedpass on every changed file.