From 518a46ba08a86b967bae94f3b8b13c807ef698d9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 06:50:22 +0000 Subject: [PATCH 1/2] test(web): all-environments model picker across matching accounts with different instance ids Repro for #30270: two environments signed in to the same Codex account under their own provider instance ids. The All environments picker reports the model as unavailable on the second environment. AI-assisted; verified with the tests and tools listed above. --- ...seScopedModelAvailability.browser.test.tsx | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 apps/web/src/components/settings/useScopedModelAvailability.browser.test.tsx diff --git a/apps/web/src/components/settings/useScopedModelAvailability.browser.test.tsx b/apps/web/src/components/settings/useScopedModelAvailability.browser.test.tsx new file mode 100644 index 000000000000..22252371866f --- /dev/null +++ b/apps/web/src/components/settings/useScopedModelAvailability.browser.test.tsx @@ -0,0 +1,91 @@ +import { render } from "../../../test/render"; +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { deriveProviderInstanceEntries } from "../../providerInstances"; + +const laptop = EnvironmentId.make("laptop"); +const server = EnvironmentId.make("server"); + +function codexAccount(instanceId: string): ServerProvider { + return { + instanceId: ProviderInstanceId.make(instanceId), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { + status: "authenticated", + type: "chatgpt", + email: "gmail@example.test", + workspaceId: "personal", + }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [{ slug: "gpt-6-luna", name: "GPT-6 Luna", isCustom: false, capabilities: {} }], + slashCommands: [], + skills: [], + }; +} + +const providers = new Map([ + [laptop, [codexAccount("codex_laptop_gmail")]], + [server, [codexAccount("codex_server_gmail")]], +]); + +function settingsFor(environmentId: EnvironmentId) { + return { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: Object.fromEntries( + (providers.get(environmentId) ?? []).map((provider) => [ + provider.instanceId, + { driver: provider.driver }, + ]), + ), + }; +} + +vi.mock(import("./SettingsScopeContext"), async (original) => ({ + ...(await original()), + useSettingsScope: (() => ({ + targets: [laptop, server].map((environmentId) => ({ + environmentId, + projectId: null, + settings: settingsFor(environmentId), + })), + })) as never, +})); +vi.mock(import("../../state/environments"), async (original) => ({ + ...(await original()), + useEnvironments: (() => ({ + environments: [ + { environmentId: laptop, label: "Laptop", serverConfig: {} }, + { environmentId: server, label: "Server", serverConfig: {} }, + ], + })) as never, + useEnvironmentProviders: (() => providers) as never, +})); + +import { useScopedModelDisabledReason } from "./useScopedModelAvailability"; + +function DisabledReason() { + const reason = useScopedModelDisabledReason( + settingsFor(laptop), + deriveProviderInstanceEntries(providers.get(laptop) ?? []), + )(ProviderInstanceId.make("codex_laptop_gmail"), "gpt-6-luna"); + return {reason ?? "available"}; +} + +describe("useScopedModelDisabledReason", () => { + // Both environments are signed in to the same account under their own instance id. + it("offers a model every selected environment reaches through the same account", async () => { + await using screen = await render(, { router: false }); // hook only, no routes + await expect.element(screen.getByRole("status")).toHaveTextContent("available"); + }); +}); From ad8ba8e08813f946d23323de1fc703d1e5bac5fc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:02:17 +0000 Subject: [PATCH 2/2] fix(web): all-environments model settings match the same account across instance ids Instance ids are machine-local, so two environments signed in to the same provider account usually name it differently. At All environments the model pickers looked for the shown environment's id on every other environment and reported the model as unavailable there. An instance now matches the same id, or else the single instance of the same driver signed in to the same email and workspace. The availability check, the save (each environment stores its own instance id), and the mixed comparison all use that match. Fixes pingdotgg/t3code#30270 AI-assisted; verified with the tests and tools listed above. --- .../settings/ProjectDefaultsSettings.tsx | 18 +++-- .../src/components/settings/scopedSettings.ts | 29 +++++--- .../settings/useScopedModelAvailability.ts | 11 ++- .../components/settings/useScopedSettings.ts | 67 ++++++++++++++++++- apps/web/src/modelSelection.ts | 29 ++++---- apps/web/src/providerInstances.test.ts | 28 ++++++++ apps/web/src/providerInstances.ts | 36 ++++++++++ 7 files changed, 185 insertions(+), 33 deletions(-) diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 182b8bc5b1fd..fc7de2974865 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -13,7 +13,7 @@ import { defaultModelSelectionsAreMixed, getCustomModelOptionsByInstance, } from "../../modelSelection"; -import { providerInstanceEntriesFor } from "../../providerInstances"; +import { localizeModelSelection, providerInstanceEntriesFor } from "../../providerInstances"; import { useEnvironmentProviders, useEnvironments } from "../../state/environments"; import { firstManagedDenial } from "../../state/managedPolicy"; import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; @@ -80,11 +80,17 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId); const mixedModel = defaultModelSelectionsAreMixed( clientSettings, - targets.map((candidate) => ({ - projectId: candidate.projectId, - settings: candidate.settings, - providers: providersByEnvironment.get(candidate.environmentId) ?? EMPTY_SERVER_PROVIDERS, - })), + targets.map((candidate) => { + const candidateProviders = + providersByEnvironment.get(candidate.environmentId) ?? EMPTY_SERVER_PROVIDERS; + return { + projectId: candidate.projectId, + settings: candidate.settings, + providers: candidateProviders, + toShown: (selection: ModelSelection) => + localizeModelSelection(selection, candidateProviders, providers), + }; + }), ); const automaticModel = targets.length > 0 && diff --git a/apps/web/src/components/settings/scopedSettings.ts b/apps/web/src/components/settings/scopedSettings.ts index 2c771db70f15..85ceb72c981c 100644 --- a/apps/web/src/components/settings/scopedSettings.ts +++ b/apps/web/src/components/settings/scopedSettings.ts @@ -183,6 +183,7 @@ function projectOverrideWrites( current: ProjectSettingsOverrides, settings: ServerSettings, projectId: ProjectId, + environmentId: EnvironmentId, ) => ProjectSettingsOverrides | null, ): ScopedServerWrite[] { const byId = new Map(environments.map((environment) => [environment.environmentId, environment])); @@ -193,7 +194,8 @@ function projectOverrideWrites( continue; } const settings = environment.serverConfig.settings; - const entry = update(settings.projectSettingsOverrides[member.id] ?? {}, settings, member.id); + const current = settings.projectSettingsOverrides[member.id] ?? {}; + const entry = update(current, settings, member.id, member.environmentId); const existing = writes.get(member.environmentId); writes.set(member.environmentId, { environmentId: member.environmentId, @@ -221,6 +223,11 @@ export function planScopedSettingsPatch( scope: ResolvedSettingsScope, environments: readonly ScopedSettingsEnvironment[], patch: ScopedSettingsPatch, + /** Rewrites the server patch for one destination, such as its own provider instance ids. */ + localize: (environmentId: EnvironmentId, patch: ServerSettingsPatch) => ServerSettingsPatch = ( + _environmentId, + serverPatch, + ) => serverPatch, ) { const clientPatch = Object.fromEntries( Object.entries(patch).filter(([key]) => CLIENT_KEYS.has(key) && !SERVER_KEYS.has(key)), @@ -240,18 +247,19 @@ export function planScopedSettingsPatch( : isProjectScope ? unscopableKeys.length > 0 ? [] - : projectOverrideWrites(scope, environments, (current, settings, projectId) => { + : projectOverrideWrites(scope, environments, (current, settings, projectId, envId) => { // Override entries store whole objects; complete partial patches from each target's effective // value. const effective = resolveProjectSettings(settings, projectId).settings; const next: Record = { ...current }; - for (const [key, value] of Object.entries(serverPatch)) { - if (key === "worktreeCleanup" && serverPatch.worktreeCleanup?.mode === "custom") { + const localPatch = localize(envId, serverPatch); + for (const [key, value] of Object.entries(localPatch)) { + if (key === "worktreeCleanup" && localPatch.worktreeCleanup?.mode === "custom") { next[key] = { mode: "custom", rules: { ...resolveWorktreeCleanup(settings, projectId), - ...serverPatch.worktreeCleanup.rules, + ...localPatch.worktreeCleanup.rules, }, }; continue; @@ -287,6 +295,7 @@ export function planScopedSettingsPatch( ? connectedEnvironments.flatMap((environment) => { const previous = environment.serverConfig?.settings; if (!previous) return []; + const localPatch = localize(environment.environmentId, serverPatch); return [ { environmentId: environment.environmentId, @@ -295,25 +304,25 @@ export function planScopedSettingsPatch( patch: forDestination( previous, previous.worktreeCleanup != null && - serverPatch.storageCleanup && - Object.keys(serverPatch.storageCleanup).some((key) => + localPatch.storageCleanup && + Object.keys(localPatch.storageCleanup).some((key) => key.startsWith("worktree"), ) ? { - ...serverPatch, + ...localPatch, worktreeCleanup: { mode: "custom", rules: { ...resolveWorktreeCleanup(previous, null), ...Object.fromEntries( - Object.entries(serverPatch.storageCleanup).filter(([key]) => + Object.entries(localPatch.storageCleanup).filter(([key]) => key.startsWith("worktree"), ), ), }, }, } - : serverPatch, + : localPatch, ), }, ]; diff --git a/apps/web/src/components/settings/useScopedModelAvailability.ts b/apps/web/src/components/settings/useScopedModelAvailability.ts index 2471825cb3d1..d5237c05b3d1 100644 --- a/apps/web/src/components/settings/useScopedModelAvailability.ts +++ b/apps/web/src/components/settings/useScopedModelAvailability.ts @@ -9,6 +9,7 @@ import { getCustomModelOptionsByInstance } from "../../modelSelection"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, + matchingProviderInstanceId, type ProviderInstanceEntry, } from "../../providerInstances"; import { useEnvironmentProviders, useEnvironments } from "../../state/environments"; @@ -43,15 +44,21 @@ export function useScopedModelDisabledReason( } const providers = providersByEnvironment.get(candidate.environmentId); if (!environment?.serverConfig || !providers) continue; + const localInstanceId = + matchingProviderInstanceId( + instanceId, + sourceEntry ? [sourceEntry.snapshot] : [], + providers, + ) ?? instanceId; const entry = applyProviderInstanceSettings( deriveProviderInstanceEntries(providers), candidate.settings, candidate.projectId, - ).find((option) => option.instanceId === instanceId); + ).find((option) => option.instanceId === localInstanceId); const options = getCustomModelOptionsByInstance( mergeEnvironmentSettings(candidate.settings, settings), providers, - ).get(instanceId); + ).get(localInstanceId); if ( !entry?.enabled || !entry.isAvailable || diff --git a/apps/web/src/components/settings/useScopedSettings.ts b/apps/web/src/components/settings/useScopedSettings.ts index 5fb9d048a9e3..440b4261960b 100644 --- a/apps/web/src/components/settings/useScopedSettings.ts +++ b/apps/web/src/components/settings/useScopedSettings.ts @@ -9,12 +9,17 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { DEFAULT_SERVER_SETTINGS, + type EnvironmentId, type ProjectScopedServerSettingKey, + type ProviderInstanceId, + type ServerProvider, type ServerSettings, type UnifiedSettings, } from "@t3tools/contracts"; import { persistClientSettingsPatch, useMergedSettings } from "../../hooks/useSettings"; +import { localizeModelSelection } from "../../providerInstances"; +import { useEnvironmentProviders } from "../../state/environments"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { toastManager } from "../ui/toast"; @@ -30,6 +35,57 @@ import { type ScopedSettingsPatch, } from "./scopedSettings"; +const MODEL_SELECTION_KEYS = [ + "defaultModelSelection", + "fallbackModelSelection", + "textGenerationModelSelection", + "sourceControlWriterModelSelection", + "reviewGuideModelSelection", + "speechPostProcessingModelSelection", +] as const; + +type ModelSelectionFields = { + readonly [K in (typeof MODEL_SELECTION_KEYS)[number]]?: { + readonly instanceId?: ProviderInstanceId; + } | null; +}; + +function localizeModelSelections( + settings: T, + source: ReadonlyArray, + target: ReadonlyArray, +): T { + let next: T | null = null; + for (const key of MODEL_SELECTION_KEYS) { + const value = settings[key]; + const localized = value ? localizeModelSelection(value, source, target) : value; + if (localized !== value) Object.assign((next ??= { ...settings }), { [key]: localized }); + } + return next ?? settings; +} + +/** Pickers show the scope target's instance ids; each environment stores its own. */ +function useModelSelectionLocalizer() { + const { target } = useSettingsScope(); + const providersByEnvironment = useEnvironmentProviders(); + const shown = target ? providersByEnvironment.get(target.environmentId) : undefined; + return { + toEnvironment: ( + environmentId: EnvironmentId, + settings: T, + ): T => { + const destination = providersByEnvironment.get(environmentId); + return shown && destination + ? localizeModelSelections(settings, shown, destination) + : settings; + }, + toShown: (environmentId: EnvironmentId, settings: T): T => { + const source = providersByEnvironment.get(environmentId); + return shown && source ? localizeModelSelections(settings, source, shown) : settings; + }, + }; +} + export function useScopedSettings( selector?: (settings: UnifiedSettings) => T, ): T { @@ -39,7 +95,13 @@ export function useScopedSettings( export function useScopedSettingsMixed(keys: readonly (keyof ServerSettings)[]): boolean { const { targets } = useSettingsScope(); - return scopedSettingsAreMixed(targets, keys); + const { toShown } = useModelSelectionLocalizer(); + return scopedSettingsAreMixed( + targets.map((candidate) => ({ + settings: toShown(candidate.environmentId, candidate.settings), + })), + keys, + ); } export function useScopedSettingSource(keys: readonly (keyof ServerSettings)[]) { @@ -100,9 +162,10 @@ function useRunScopedPlan() { export function useUpdateScopedSettings() { const { scope, environments } = useSettingsScope(); const run = useRunScopedPlan(); + const { toEnvironment } = useModelSelectionLocalizer(); /** `onFailed` runs when any target rejects or drops the patch, so a draft can fall back. */ return (patch: ScopedSettingsPatch, options?: { readonly onFailed?: () => void }) => - run(planScopedSettingsPatch(scope, environments, patch), options?.onFailed); + run(planScopedSettingsPatch(scope, environments, patch, toEnvironment), options?.onFailed); } export function useClearScopedSettings() { diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 61be2ece7632..5f1bbcaad07f 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -320,27 +320,30 @@ export function defaultModelSelectionsAreMixed( projectId: ProjectId | null; settings: ServerSettings; providers: ReadonlyArray; + /** Maps this target's instance ids onto the shown environment's, so equal accounts compare equal. */ + toShown?: (selection: ModelSelection) => ModelSelection; }>, ): boolean { - const selections = targets.map(({ projectId, settings, providers }) => { + const selections = targets.map(({ projectId, settings, providers, toShown = (s) => s }) => { const stored = settings.defaultModelSelection; const selection = stored === null ? resolveProjectDefaultProviderModelSelection(providers, settings, projectId) : resolveDefaultProviderModelSelection(providers, stored); + const effective = selection + ? resolveEffectiveDefaultModelSelection( + { + ...clientSettings, + ...settings, + ...resolveModelPreferences(settings, clientSettings), + }, + providers, + selection, + ) + : null; return { - stored, - effective: selection - ? resolveEffectiveDefaultModelSelection( - { - ...clientSettings, - ...settings, - ...resolveModelPreferences(settings, clientSettings), - }, - providers, - selection, - ) - : null, + stored: stored === null ? null : toShown(stored), + effective: effective ? toShown(effective) : null, }; }); return selections.some((selection) => !Equal.equals(selection, selections[0])); diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index 891f3312ae83..cd92ab8a039d 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -13,6 +13,7 @@ import { deriveProviderInstanceEntries, getDefaultProviderInstanceModel, isProviderInstancePickerReady, + matchingProviderInstanceId, resolveDefaultProviderModelSelection, resolveSelectableProviderInstance, shouldShowInstanceBadge, @@ -281,6 +282,33 @@ describe("brandProviderInstanceEntries", () => { }); }); +describe("matchingProviderInstanceId", () => { + it("maps an instance to the one signed in to the same account elsewhere", () => { + const codex = ProviderDriverKind.make("codex"); + const signedIn = (instanceId: string, email: string, workspaceId = "personal") => ({ + ...provider({ provider: codex, instanceId }), + auth: { status: "authenticated" as const, email, workspaceId }, + }); + const id = ProviderInstanceId.make; + const laptop = [ + signedIn("codex_laptop", "a@example.test"), + provider({ provider: codex, instanceId: "codex" }), + ]; + const match = (target: ServerProvider[], instanceId = "codex_laptop") => + matchingProviderInstanceId(id(instanceId), laptop, target); + + expect(match([signedIn("codex_server", "A@example.test")])).toBe("codex_server"); + expect(match([signedIn("codex_laptop", "b@example.test")])).toBe("codex_laptop"); + expect(match([provider({ provider: codex, instanceId: "codex" })], "codex")).toBe("codex"); + expect(match([signedIn("codex_server", "b@example.test")])).toBeNull(); + expect(match([signedIn("codex_server", "a@example.test", "team")])).toBeNull(); + expect( + match([signedIn("codex_one", "a@example.test"), signedIn("codex_two", "a@example.test")]), + ).toBeNull(); + expect(match([provider({ provider: codex, instanceId: "codex_server" })])).toBeNull(); + }); +}); + describe("resolveSelectableProviderInstance", () => { it("falls back to the first enabled and available instance", () => { const disabled = ProviderInstanceId.make("codex"); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index c2120f60e31c..c0077845de12 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -80,6 +80,42 @@ export function deriveProviderInstanceEntries( }); } +function providerAccountKey(provider: ServerProvider): string | null { + const email = provider.auth.email?.trim().toLowerCase(); + return provider.auth.status === "authenticated" && email + ? `${provider.driver}:${email}:${provider.auth.workspaceId ?? ""}` + : null; +} + +/** + * Instance ids are machine-local, so the same signed-in account can have a different id on + * another environment. Prefers the same id, then the one instance signed in to the same account. + */ +export function matchingProviderInstanceId( + instanceId: ProviderInstanceId, + source: ReadonlyArray, + target: ReadonlyArray, +): ProviderInstanceId | null { + if (target.some((provider) => provider.instanceId === instanceId)) return instanceId; + const sourceProvider = source.find((provider) => provider.instanceId === instanceId); + const key = sourceProvider ? providerAccountKey(sourceProvider) : null; + if (key === null) return null; + const matches = target.filter((provider) => providerAccountKey(provider) === key); + return matches.length === 1 ? matches[0]!.instanceId : null; +} + +export function localizeModelSelection( + selection: T, + source: ReadonlyArray, + target: ReadonlyArray, +): T { + if (selection.instanceId === undefined) return selection; + const instanceId = matchingProviderInstanceId(selection.instanceId, source, target); + return instanceId === null || instanceId === selection.instanceId + ? selection + : { ...selection, instanceId }; +} + /** Instance ids repeat across environments; lookups must stay scoped. */ export function deriveProviderEntriesByEnvironment( providersByEnvironment: Iterable<