From 0ce97f7b87d2f587bf2de642a42ff68fe35b0bd4 Mon Sep 17 00:00:00 2001 From: Mantas Kantautas Date: Fri, 23 Oct 2026 09:29:48 +0300 Subject: [PATCH 1/3] fix(web): composer keeps the effort when it falls back to another account --- apps/web/src/components/ChatView.logic.ts | 1 + .../chat/useComposerProviderModel.ts | 1 + apps/web/src/composerDraftStore.ts | 49 ++++++- apps/web/src/modelSelection.test.ts | 132 +++++++++++++++++- 4 files changed, 181 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 856a2b4e548c3..18038308a1ffb 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -612,6 +612,7 @@ export function resolveComposerProviderSelection(input: { : requestedInstanceId; return { selectedProviderEntry, + requestedInstanceId, requestedDriverKind, lockedContinuationGroupKey, unavailableProviderInstanceId, diff --git a/apps/web/src/components/chat/useComposerProviderModel.ts b/apps/web/src/components/chat/useComposerProviderModel.ts index 24920d1593b45..f97fdb0b318c4 100644 --- a/apps/web/src/components/chat/useComposerProviderModel.ts +++ b/apps/web/src/components/chat/useComposerProviderModel.ts @@ -74,6 +74,7 @@ export function useComposerProviderModel( providers: providerStatuses, selectedProvider, selectedInstanceId, + requestedInstanceId: selection.requestedInstanceId, threadModelSelection, projectModelSelection, settings, diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 57ffde65949ff..952d3cd7debf6 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1097,6 +1097,8 @@ export function deriveEffectiveComposerModelState(input: { providers: ReadonlyArray; selectedProvider: ProviderDriverKind; selectedInstanceId?: ProviderInstanceId | null | undefined; + /** The instance the draft, thread or project asked for; differs from the selection on fallback. */ + requestedInstanceId?: ProviderInstanceId | null | undefined; threadModelSelection: ModelSelection | null | undefined; projectModelSelection: ModelSelection | null | undefined; settings: UnifiedSettings; @@ -1164,10 +1166,55 @@ export function deriveEffectiveComposerModelState(input: { return { selectedModel, - modelOptions, + modelOptions: withFallbackInstanceOptions({ ...input, modelOptions, selectedModel }), }; } +/** + * When the requested instance is disabled or unavailable, the composer shows another instance of + * the same driver with the same model. That instance has no options of its own, so it takes the + * requested instance's options instead of the model's defaults. + */ +function withFallbackInstanceOptions(input: { + readonly modelOptions: ProviderOptionSelectionsByProvider | null; + readonly selectedModel: string; + readonly providers: ReadonlyArray; + readonly selectedProvider: ProviderDriverKind; + readonly selectedInstanceId?: ProviderInstanceId | null | undefined; + readonly requestedInstanceId?: ProviderInstanceId | null | undefined; + readonly draft: Pick | null | undefined; + readonly threadModelSelection: ModelSelection | null | undefined; + readonly projectModelSelection: ModelSelection | null | undefined; +}): ProviderOptionSelectionsByProvider | null { + const { modelOptions, selectedInstanceId, requestedInstanceId } = input; + if ( + !modelOptions || + !selectedInstanceId || + !requestedInstanceId || + selectedInstanceId === requestedInstanceId || + modelOptions[selectedInstanceId] !== undefined + ) { + return modelOptions; + } + const requestedOptions = modelOptions[requestedInstanceId]; + const requestedDriver = input.providers.find( + (provider) => provider.instanceId === requestedInstanceId, + )?.driver; + const requestedModel = + input.draft?.modelSelectionByProvider?.[requestedInstanceId]?.model ?? + [input.threadModelSelection, input.projectModelSelection].find( + (selection) => selection?.instanceId === requestedInstanceId, + )?.model; + if ( + !requestedOptions || + requestedDriver !== input.selectedProvider || + requestedModel !== input.selectedModel + ) { + return modelOptions; + } + return { ...modelOptions, [selectedInstanceId]: requestedOptions }; +} + function revokeObjectPreviewUrl(previewUrl: string): void { if (typeof URL === "undefined") { return; diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 05a7e76b6b25b..c93718a8eaff1 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -10,7 +10,12 @@ import { describe, expect, it } from "vite-plus/test"; import { createModelSelection } from "@t3tools/shared/model"; import { deriveEffectiveComposerModelState } from "./composerDraftStore"; import { getComposerProviderState } from "./components/chat/composerProviderState"; -import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "./providerInstances"; +import { resolveComposerProviderSelection } from "./components/ChatView.logic"; +import { + deriveProviderInstanceEntries, + NO_PROVIDER_MODEL_SELECTION, + sortProviderInstanceEntries, +} from "./providerInstances"; import { getAppModelOptionsForInstance, resolveAppModelSelectionForInstance, @@ -780,3 +785,128 @@ describe("default model settings and new-thread composer parity", () => { }, ); }); + +describe("composer fallback to another instance of the same driver", () => { + const claude = ProviderDriverKind.make("claudeAgent"); + const codex = ProviderDriverKind.make("codex"); + const disabled = ProviderInstanceId.make("claudeAgent"); + const fallback = ProviderInstanceId.make("claude_team"); + const effortModels: ServerProvider["models"] = [ + { + slug: "claude-opus-5-5", + name: "Claude Opus 5.5", + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Effort", + type: "select", + options: ["medium", "high", "xhigh"].map((id) => ({ + id, + label: id, + isDefault: id === "medium", + })), + }, + ], + }, + }, + ]; + const providers: ReadonlyArray = [ + { + ...provider({ provider: claude, instanceId: disabled }), + enabled: false, + models: effortModels, + }, + { ...provider({ provider: claude, instanceId: fallback }), models: effortModels }, + ]; + const picked = createModelSelection(disabled, "claude-opus-5-5", [ + { id: "effort", value: "xhigh" }, + ]); + + // The same steps useComposerProviderModel takes, without the draft store. + function composerFor(input: { + readonly providers: ReadonlyArray; + readonly draft?: Parameters[0]["draft"]; + readonly threadModelSelection: ReturnType; + }) { + const selection = resolveComposerProviderSelection({ + entries: sortProviderInstanceEntries(deriveProviderInstanceEntries(input.providers)), + candidateInstanceIds: [input.threadModelSelection.instanceId], + lockedProvider: null, + lockedInstanceId: undefined, + }); + const selectedInstanceId = selection.selectedProviderEntry!.instanceId; + const selectedProvider = selection.selectedProviderEntry!.driverKind; + const state = deriveEffectiveComposerModelState({ + draft: input.draft ?? null, + providers: input.providers, + selectedProvider, + selectedInstanceId, + requestedInstanceId: selection.requestedInstanceId, + threadModelSelection: input.threadModelSelection, + projectModelSelection: null, + settings: DEFAULT_UNIFIED_SETTINGS, + }); + const traits = getComposerProviderState({ + provider: selectedProvider, + model: state.selectedModel, + models: selection.selectedProviderEntry!.models, + modelOptions: state.modelOptions?.[selectedInstanceId], + planModeEnabled: false, + }); + return { selectedInstanceId, state, traits }; + } + + it("keeps the requested instance's effort on the fallback instance", () => { + const { selectedInstanceId, state, traits } = composerFor({ + providers, + threadModelSelection: picked, + }); + + expect(selectedInstanceId).toBe(fallback); + expect(state.selectedModel).toBe("claude-opus-5-5"); + expect(traits.promptEffort).toBe("xhigh"); + expect( + createModelSelection(fallback, state.selectedModel, traits.modelOptionsForDispatch), + ).toEqual( + createModelSelection(fallback, "claude-opus-5-5", [{ id: "effort", value: "xhigh" }]), + ); + }); + + it("keeps options the fallback instance already has", () => { + const { traits } = composerFor({ + providers, + threadModelSelection: picked, + draft: { + activeProvider: disabled, + modelSelectionByProvider: { + [disabled]: picked, + [fallback]: createModelSelection(fallback, "claude-opus-5-5", [ + { id: "effort", value: "high" }, + ]), + }, + }, + }); + + expect(traits.promptEffort).toBe("high"); + }); + + it("does not carry options to another driver", () => { + const codexOnly: ReadonlyArray = [ + { + ...provider({ provider: claude, instanceId: disabled }), + enabled: false, + models: effortModels, + }, + provider({ provider: codex, instanceId: "codex", models: ["gpt-6-astra"] }), + ]; + const { selectedInstanceId, state } = composerFor({ + providers: codexOnly, + threadModelSelection: picked, + }); + + expect(selectedInstanceId).toBe(ProviderInstanceId.make("codex")); + expect(state.modelOptions?.[selectedInstanceId]).toBeUndefined(); + }); +}); From b4547bd775674aaaea6e3635ced00f37bf8c544d Mon Sep 17 00:00:00 2001 From: Mantas Kantautas Date: Fri, 23 Oct 2026 09:43:57 +0300 Subject: [PATCH 2/3] fix(web): match the fallback options to the model they were picked with --- apps/web/src/composerDraftStore.ts | 49 ++++++++++++++--------------- apps/web/src/modelSelection.test.ts | 18 +++++++++++ 2 files changed, 41 insertions(+), 26 deletions(-) diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 952d3cd7debf6..aecf8fc4e1430 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -640,17 +640,10 @@ interface ComposerDraftModelState { modelSelectionByProvider: Partial>; } -function providerSelectionsFromModelSelection( +function selectionsByInstance( modelSelection: ModelSelection | null | undefined, -): ProviderOptionSelectionsByProvider | null { - if (!modelSelection) { - return null; - } - const options = modelSelection.options; - if (!options || options.length === 0) { - return null; - } - return { [modelSelection.instanceId]: options }; +): Partial> | null { + return modelSelection ? { [modelSelection.instanceId]: modelSelection } : null; } function modelSelectionByProviderToOptions( @@ -1158,33 +1151,40 @@ export function deriveEffectiveComposerModelState(input: { activeSelection.model, )) : baseModel; - const modelOptions = - modelSelectionByProviderToOptions(input.draft?.modelSelectionByProvider) ?? - providerSelectionsFromModelSelection(input.threadModelSelection) ?? - providerSelectionsFromModelSelection(input.projectModelSelection) ?? - null; + // Options come from the first of the draft, thread and project that has any. Their selections + // stay with them, so a fallback instance can check which model the options belong to. + const optionSelections = + [ + input.draft?.modelSelectionByProvider, + selectionsByInstance(input.threadModelSelection), + selectionsByInstance(input.projectModelSelection), + ].find((selections) => modelSelectionByProviderToOptions(selections) !== null) ?? null; + const modelOptions = modelSelectionByProviderToOptions(optionSelections); return { selectedModel, - modelOptions: withFallbackInstanceOptions({ ...input, modelOptions, selectedModel }), + modelOptions: withFallbackInstanceOptions({ + ...input, + modelOptions, + optionSelections, + selectedModel, + }), }; } /** * When the requested instance is disabled or unavailable, the composer shows another instance of - * the same driver with the same model. That instance has no options of its own, so it takes the - * requested instance's options instead of the model's defaults. + * the same driver. When that instance has no options of its own and shows the model the requested + * instance's options were picked with, it takes those options instead of the model's defaults. */ function withFallbackInstanceOptions(input: { readonly modelOptions: ProviderOptionSelectionsByProvider | null; + readonly optionSelections: Partial> | null; readonly selectedModel: string; readonly providers: ReadonlyArray; readonly selectedProvider: ProviderDriverKind; readonly selectedInstanceId?: ProviderInstanceId | null | undefined; readonly requestedInstanceId?: ProviderInstanceId | null | undefined; - readonly draft: Pick | null | undefined; - readonly threadModelSelection: ModelSelection | null | undefined; - readonly projectModelSelection: ModelSelection | null | undefined; }): ProviderOptionSelectionsByProvider | null { const { modelOptions, selectedInstanceId, requestedInstanceId } = input; if ( @@ -1200,11 +1200,8 @@ function withFallbackInstanceOptions(input: { const requestedDriver = input.providers.find( (provider) => provider.instanceId === requestedInstanceId, )?.driver; - const requestedModel = - input.draft?.modelSelectionByProvider?.[requestedInstanceId]?.model ?? - [input.threadModelSelection, input.projectModelSelection].find( - (selection) => selection?.instanceId === requestedInstanceId, - )?.model; + // The model of the selection the options came from, which can differ from the shown model's source. + const requestedModel = input.optionSelections?.[requestedInstanceId]?.model; if ( !requestedOptions || requestedDriver !== input.selectedProvider || diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index c93718a8eaff1..01a06e71c1906 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -892,6 +892,24 @@ describe("composer fallback to another instance of the same driver", () => { expect(traits.promptEffort).toBe("high"); }); + it("does not carry options picked with another model", () => { + // An optionless draft pick takes its options from the thread, which holds another model. + const { state, traits } = composerFor({ + providers, + threadModelSelection: createModelSelection(disabled, "claude-sonnet-4-6", [ + { id: "effort", value: "high" }, + ]), + draft: { + activeProvider: disabled, + modelSelectionByProvider: { [disabled]: createModelSelection(disabled, "claude-opus-5-5") }, + }, + }); + + expect(state.selectedModel).toBe("claude-opus-5-5"); + expect(traits.promptEffort).toBe("medium"); + expect(traits.modelOptionsForDispatch).toBeUndefined(); + }); + it("does not carry options to another driver", () => { const codexOnly: ReadonlyArray = [ { From 5731b843bea3092e63f3c25b9bed81003b3057cd Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:21:16 +0000 Subject: [PATCH 3/3] fix(web): build fallback provider fixtures with current settings --- apps/web/src/modelSelection.test.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 01a06e71c1906..b5e9be7d9e011 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -14,7 +14,7 @@ import { resolveComposerProviderSelection } from "./components/ChatView.logic"; import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, - sortProviderInstanceEntries, + providerInstanceEntriesFor, } from "./providerInstances"; import { getAppModelOptionsForInstance, @@ -830,8 +830,21 @@ describe("composer fallback to another instance of the same driver", () => { readonly draft?: Parameters[0]["draft"]; readonly threadModelSelection: ReturnType; }) { + const settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: Object.fromEntries( + input.providers.map((provider) => [ + provider.instanceId, + { + driver: provider.driver, + enabled: provider.enabled, + config: {}, + }, + ]), + ), + }; const selection = resolveComposerProviderSelection({ - entries: sortProviderInstanceEntries(deriveProviderInstanceEntries(input.providers)), + entries: providerInstanceEntriesFor(input.providers, settings, null), candidateInstanceIds: [input.threadModelSelection.instanceId], lockedProvider: null, lockedInstanceId: undefined, @@ -846,7 +859,7 @@ describe("composer fallback to another instance of the same driver", () => { requestedInstanceId: selection.requestedInstanceId, threadModelSelection: input.threadModelSelection, projectModelSelection: null, - settings: DEFAULT_UNIFIED_SETTINGS, + settings, }); const traits = getComposerProviderState({ provider: selectedProvider,