Skip to content

Models: a fallback model is for that run only, never the saved choice - #2465

Merged
AbirAbbas merged 6 commits into
workstreams/mainfrom
workstreams/l-fix9-e1
Nov 6, 2026
Merged

AbirAbbas merged 6 commits into
workstreams/mainfrom
workstreams/l-fix9-e1

Conversation

@AbirAbbas

@AbirAbbas AbirAbbas commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

On a local terminal launch whose configured model is not in the listed catalog (for example no key for its provider while a local Ollama is running), the terminal picks the first listed model so the conversation can run. It did not write the profile's talk row, but it switched through the ordinary model setter, so the fallback became the conversation's saved model. The agent then reopened on it (in the desktop app too, where the configured provider worked), and errand routes that fall to the conversation's own model reported it. The chat manual already said "Automatic replacements do not overwrite your saved preference."

Change: a fallback is for that run only.

  • session.Agent.UseModelForNow / ConfiguredModel (new, additive): requests use the fallback while the saved model, provider, ModelIdentity.Selected/Models and any main/fallback pair keep naming the configured choice. Execution receipts (requested/active/last) still record what actually ran. An explicit SetModel / SetConversationModels clears the run-only model and saves as before, including when the person chooses the fallback that is already running.
  • The linked-local wire carries the new door (internal/remote); an older engine without it keeps the old setter behaviour, and no other wire error falls back to a saving write.
  • Terminal (internal/tui3): the availability reconcile and the move after disconnecting the running model's service use the run-only door, asked through the ordered off-loop door line like every engine door. The surface shows the replacement at once and holds nothing; every engine call that starts a turn (plain, attachment, follow-up and errand sends, held and replayed ones included) waits inside its own command for that agent's switch still on the line, so the engine always sees the switch before the message. A refused switch still lets the message go and the next reconcile retries; an explicit pick queued behind it supersedes it. A hidden chat reconciles when it comes back to the front, as before. Renaming a service relabels the saved choice and a run-only model separately, so a rename never saves the fallback; a deferred rename stays with its own chat and a later explicit pick retires it. Every later reconcile (catalog arrival, connection, turn end) prefers the configured model and returns to it as soon as it is listed. A fresh service connect still moves the conversation onto that service and saves it; that is the person's intent, and TestConnectingAServiceMovesTheConversationOntoItsPreferredModel / TestTheMovedModelIsTheOneTheNextLaunchOpensOn are unchanged.
  • The linked-local provider-source refresh no longer re-applies the live (fallback) model through the saving setter.
  • An explicit choice also clears a run-only model word still queued for the next request.
  • start_chat without a model gives the new chat its parent's configured model; the new chat applies its own availability reconcile.
  • The implicit credit-default follow (an untouched chat following the build's free/paid default) is unchanged: it is the default moving, not an availability fallback.
  • The app reports the configured model for an agent (agents.list / agents.open).
  • Unchanged: provider fallback-chain rescue (never persisted), remote --host doors, and the continued-chat replacement for a chat opened on another computer where its model is not set up (that one is persisted by design; later local opens rely on it).

No profile row, tier, role, task-model or app setup write was fed by an availability decision; the trace is in the PR's tests (profile bytes unchanged).

Validation contract → tests

Contract Tests
C1 the fallback serves requests; after save/close/reopen the saved model, provider, identity and pair are the configured ones TestRunOnlyModelSurvivesSavingAsTheConfiguredChoice, TestRunOnlyModelKeepsConfiguredProviderWhenDisconnected, TestRunOnlyModelCrossesTheWireWithoutReplacingTheSavedPair, TestCatalogRecoveryUsesConfiguredModelOnTheNextRequest, TestDisconnectMoveStaysTemporaryUntilExplicitlyChosen, TestLinkedLocalSourceRefreshKeepsRunOnlyModelTemporary, TestAppReopensConfiguredModelAfterRunOnlyReplacement
C2 no profile or app-state write (file bytes compared) TestListedModelReplacementKeepsProfileAndReturnsToConfiguredModel, TestSetupCanExplicitlyChooseTheRunOnlyModel, TestServiceRenamePreservesConfiguredAndRunOnlySelections, TestDeferredRenameStaysWithItsConversationAcrossASwitch, TestLaterExplicitChoiceRetiresTheDeferredRename, TestRunOnlyModelFailureDoesNotCallThePersistentSetter, TestAppReopensConfiguredModelAfterRunOnlyReplacement
C3 the app reports the configured model; the reopened title route follows it TestAppReopensConfiguredModelAfterRunOnlyReplacement, TestRunOnlyModelSurvivesSavingAsTheConfiguredChoice
C4 the configured model returns as soon as it is listed, with no person action TestCatalogRecoveryUsesConfiguredModelOnTheNextRequest, TestListedModelReplacementKeepsProfileAndReturnsToConfiguredModel, TestExplicitModelChoiceClearsRunOnlyModel
C5 explicit choices persist as before, including choosing the running fallback; a fresh connect persists TestExplicitChoiceSupersedesQueuedRunOnlyModel, TestExplicitPickSupersedesAnUnacknowledgedRunOnlySelection, TestPendingExplicitPickSavesAfterLeavingTheConversation, TestStartChatInheritsConfiguredModelFromTemporaryParent, TestRequestedChatSavesConfiguredModelBeforeItsOwnAvailabilityReconcile, TestExplicitModelChoiceClearsRunOnlyModel, TestExplicitPairSelectionClearsTheRunOnlyMain, TestSetupCanExplicitlyChooseTheRunOnlyModel, TestServiceConnectionPersistsIdleAndDeferredMoves
C6 remote doors, older engines, credit follow and rescue unchanged; no engine door asked on the update loop; no send lost or sent ahead of its switch TestRemoteCatalogDoesNotSelectARunOnlyModel, TestRunOnlyModelFallsBackToTheOlderEngineSetter, TestLegacyRunOnlySelectionUsesTheOffLoopDoor, TestRunOnlySelectionOrdersTheSendOffTheLoop, TestParkedMessageFollowsItsOrderedModelAtStreamClose, TestReplayDeferredSendFollowsItsOrderedModel, TestHeldSendFollowsItsOwnOrderedModel, TestIdleFollowUpFollowsTheOrderedSelection, TestAlternateTurnStartFollowsTheOrderedSelection, TestRefusedRunOnlySelectionStillSendsOnce, TestCancelingSendEndsTheModelWait, TestHiddenChatReconcilesAvailabilityOnlyWhenItReturns, TestCreditDefaultSurvivesListedModelSubmission, existing TestNoEngineDoorIsAskedFromTheUpdateLoop (unchanged, same debt)

Existing tests changed

No assertion changed. Because the run-only switch now lands asynchronously, a few existing tests that drive a replacement add one step that lets it land before their unchanged assertions (modeldefault_test.go, modelservices_test.go disconnect, provideraccess_test.go). TestConnectingAServiceMovesTheConversationOntoItsPreferredModel and TestTheMovedModelIsTheOneTheNextLaunchOpensOn keep their assertions: a fresh connect still saves.

Review round

A fresh review found one blocker (the new door was asked from the update loop, which TestNoEngineDoorIsAskedFromTheUpdateLoop forbids) and four majors (a service rename saved the fallback; a queued run-only word outlived an explicit pick; the credit-default follow fought the reconcile; start_chat inherited the live fallback). All fixed as above; the credit follow is restored to its old behaviour rather than made run-only. Review 2 found two majors in a send gate the fix had added (parked sends, deferred renames across chats); review 3 found two more send roads that gate missed (replay-deferred sends, hidden chats). The gate was replaced by per-agent send ordering, which needs no surface state.

🤖 Generated with Claude Code

AbirAbbas added a commit that referenced this pull request Nov 6, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AbirAbbas added a commit that referenced this pull request Nov 6, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AbirAbbas
AbirAbbas force-pushed the workstreams/l-fix9-e1 branch from f0825c6 to 3ecb8bb Compare November 6, 2026 14:44
AbirAbbas and others added 4 commits November 6, 2026 10:13
On a local launch whose configured model is not listed (no key for its
provider, say, with only a local Ollama running), the terminal picks the first
listed model so the conversation can run. It skipped the profile's talk row,
but it switched through the ordinary model setter, so the fallback became the
conversation's saved model: the agent reopened on it (also in the app, where the
configured provider worked), and errand routes that fall to the conversation's
model followed it. The chat manual already said automatic replacements do not
overwrite a saved preference.

The conversation now has a run-only model door (session UseModelForNow, carried
over the linked-local wire, older engines keep the old setter). Requests run on
the fallback while the saved model, provider, identity and main/fallback pair
keep naming the configured choice; the app reports the configured model. The
terminal's availability reconcile, the implicit credit-default follow, the move
after disconnecting the running model's service, and the linked-local source
refresh use it, and every later reconcile returns to the configured model as
soon as it is listed again. An explicit choice, including choosing the fallback
that is already running, and a fresh service connect still save as before. A
chat continued from another computer keeps its existing replacement rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eued words and new chats keep the saved choice

Review round:
- The terminal asks the engine for the run-only model through its ordered
  off-loop command, as every engine door must; sends wait for the acknowledged
  switch, and an explicit pick queued behind it supersedes it.
- Renaming a service relabels the saved choice and a run-only model
  separately, so a rename never saves the fallback.
- An explicit choice clears a run-only model word still queued for the next
  request.
- start_chat with no model gives the new chat its parent's configured model;
  the new chat applies its own availability reconcile.
- The implicit credit-default follow is restored as it was: it is the build's
  default moving, not an availability fallback.

Existing tests gain only the acknowledgment step the asynchronous switch needs;
no assertion changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…odel switch

Review round 2:
- A parked message (text and attachments) stays queued while its chat's
  run-only switch is pending or refused, and is sent once on the acknowledged
  model; pending switches travel with the chat when the window moves to another.
- A deferred service rename is bound to the chat it was made for and is retired
  by a later explicit pick, so it never saves into another chat or over a newer
  choice.
- Two new test fixtures gain the provider base URL their sessions require.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AbirAbbas
AbirAbbas force-pushed the workstreams/l-fix9-e1 branch from 3ecb8bb to ed52c29 Compare November 6, 2026 15:13
AbirAbbas and others added 2 commits November 6, 2026 10:35
…he window holding it

Review round 3: holding sends at the surface while a run-only switch was
unacknowledged kept missing send roads (a send deferred behind a replay, a
hidden chat's queued send). The surface no longer holds anything: it shows the
replacement at once, and every engine call that starts a turn (a plain send,
an attachment send, a follow-up, an errand send) waits inside its own command
for that agent's switch still on the ordered door line, so the engine always
sees the switch before the message. A refused switch still lets the message go;
the next reconcile retries. A hidden chat reconciles when it comes back to the
front, as before. Existing tests keep only the acknowledgment steps the
asynchronous switch still needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 4: resuming a turn stopped by a takeover started the turn without
waiting for the chat's run-only switch still on the door line. It now waits
inside its command like every other turn start; a cancelled wait leaves the
resume for later, a refused switch still allows one resume.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AbirAbbas
AbirAbbas merged commit 631c78a into workstreams/main Nov 6, 2026
9 checks passed
@AbirAbbas
AbirAbbas deleted the workstreams/l-fix9-e1 branch November 6, 2026 16:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant