From b8d76361bf473c93397186d28e4b16467089a57f Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 22 Oct 2026 12:31:08 -0500 Subject: [PATCH 1/9] fix(pi): stop offering extension-only models for text generation Text generation runs Pi with --no-extensions, so models that a Pi extension or package registers fail at set_model. Discovery now also lists models with that launch and marks the rest as unusable for text generation, and the text generation pickers leave them out. Co-Authored-By: Claude Opus 5.5 --- .../components/settings/SettingsPanels.tsx | 5 +- .../settings/SourceControlWritingSettings.tsx | 5 +- apps/web/src/modelSelection.ts | 19 +++++- packages/contracts/src/server.ts | 3 + .../provider-pi/src/server/status.test.ts | 65 +++++++++++++++---- packages/provider-pi/src/server/status.ts | 47 +++++++++++++- 6 files changed, 120 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 16c6ff3fb892..db4d46ec4898 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -93,6 +93,7 @@ import { useThreadActions } from "../../hooks/useThreadActions"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { getCustomModelOptionsByInstance, + getTextGenerationProviders, resolveAppModelSelectionState, selectsPlanAgent, } from "../../modelSelection"; @@ -2107,9 +2108,7 @@ export function GeneralSettingsPanel() { (target) => target.serverConfig?.environment.capabilities.threadRestartContinuation === true, ); - const textGenerationProviders = serverProviders.filter( - (provider) => provider.supportsTextGeneration !== false, - ); + const textGenerationProviders = getTextGenerationProviders(serverProviders); const textGenerationModelSelection = resolveAppModelSelectionState( settings, textGenerationProviders, diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index 3c76207c4816..93cfb9cfa0e4 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -26,6 +26,7 @@ import { } from "../../providerInstances"; import { getCustomModelOptionsByInstance, + getTextGenerationProviders, resolveAppModelSelectionState, selectsPlanAgent, } from "../../modelSelection"; @@ -107,9 +108,7 @@ export function SourceControlWritingSettingsSection() { style.mode !== defaults.mode || style.customInstructions !== defaults.customInstructions; - const textGenerationProviders = serverProviders.filter( - (provider) => provider.supportsTextGeneration !== false, - ); + const textGenerationProviders = getTextGenerationProviders(serverProviders); const instanceEntries = sortProviderInstanceEntries( applyProviderInstanceSettings( deriveProviderInstanceEntries(textGenerationProviders), diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 0b57a6cbb46f..e770f38dfa8c 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -317,6 +317,21 @@ export function getCustomModelOptionsByInstance( return out; } +/** + * Providers and models that application text generation can run. An absent + * `supportsTextGeneration` on either one means supported. + */ +export function getTextGenerationProviders( + providers: ReadonlyArray, +): ReadonlyArray { + return providers + .filter((provider) => provider.supportsTextGeneration !== false) + .map((provider) => { + const models = provider.models.filter((model) => model.supportsTextGeneration !== false); + return models.length === provider.models.length ? provider : { ...provider, models }; + }); +} + export function selectsPlanAgent(options: ModelSelection["options"]): boolean { return options?.some((option) => option.id === "agent" && option.value === "plan") ?? false; } @@ -329,9 +344,7 @@ export function resolveAppModelSelectionState( instanceId: DEFAULT_TEXT_GENERATION_INSTANCE_ID, model: DEFAULT_TEXT_GENERATION_MODEL, }; - const supportedProviders = providers.filter( - (provider) => provider.supportsTextGeneration !== false, - ); + const supportedProviders = getTextGenerationProviders(providers); const entries = deriveProviderInstanceEntries(supportedProviders); const selectedEntry = entries.find( (entry) => entry.instanceId === selection.instanceId && entry.enabled && entry.isAvailable, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index c39a29d6fe77..d300f1c85398 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -81,6 +81,9 @@ export const ServerProviderModel = Schema.Struct({ isCustom: Schema.Boolean, isDefault: Schema.optional(Schema.Boolean), isLegacy: Schema.optional(Schema.Boolean), + // False when the provider can run this model in sessions but not for + // application text generation. Absent means supported. + supportsTextGeneration: Schema.optional(Schema.Boolean), capabilities: Schema.NullOr(ModelCapabilities), }); export type ServerProviderModel = typeof ServerProviderModel.Type; diff --git a/packages/provider-pi/src/server/status.test.ts b/packages/provider-pi/src/server/status.test.ts index d4368a5a82e4..456865112ad8 100644 --- a/packages/provider-pi/src/server/status.test.ts +++ b/packages/provider-pi/src/server/status.test.ts @@ -54,7 +54,7 @@ function piProbeSpawner(version: string) { }); } -const piDiscoverySpawner = ( +const piRpcProcess = ( inventory: unknown, slowStartup?: { readonly delay: Duration.Input; @@ -93,22 +93,33 @@ const piDiscoverySpawner = ( } }), ); - return ChildProcessSpawner.make((command) => { - const args = ChildProcess.isStandardCommand(command) ? command.args : []; - return Effect.succeed( - args.includes("--version") - ? processHandle({ stdout: "pi 0.84.3\n" }) - : ChildProcessSpawner.makeHandle({ - ...processHandle({}), - exitCode: Effect.never, - isRunning: Effect.succeed(true), - stdin, - stdout: Stream.fromQueue(stdout), - }), - ); + return ChildProcessSpawner.makeHandle({ + ...processHandle({}), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + stdin, + stdout: Stream.fromQueue(stdout), }); }); +// Text generation lists models with --no-extensions, so its inventory can differ. +const piDiscoverySpawner = ( + inventory: unknown, + slowStartup?: Parameters[1], + extensionFreeInventory: unknown = inventory, +) => + Effect.succeed( + ChildProcessSpawner.make((command) => { + const args = ChildProcess.isStandardCommand(command) ? command.args : []; + return args.includes("--version") + ? Effect.succeed(processHandle({ stdout: "pi 0.84.3\n" })) + : piRpcProcess( + args.includes("--no-extensions") ? extensionFreeInventory : inventory, + slowStartup, + ); + }), + ); + const settings = { enabled: true, binaryPath: "pi", @@ -158,6 +169,32 @@ describe("PiProvider", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("marks models that only load with Pi extensions as unusable for text generation", () => + Effect.gen(function* () { + const spawner = yield* piDiscoverySpawner( + { + models: [ + { provider: "anthropic", id: "claude-sonnet-4" }, + { provider: "demo", id: "demo-model" }, + ], + }, + undefined, + { models: [{ provider: "anthropic", id: "claude-sonnet-4" }] }, + ); + const snapshot = yield* checkPiProviderStatus(settings).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + assert.deepEqual( + snapshot.models.map((model) => [model.slug, model.supportsTextGeneration]), + [ + ["default", undefined], + ["anthropic/claude-sonnet-4", undefined], + ["demo/demo-model", false], + ], + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect.each([ ["missing models", {}], ["non-array models", { models: {} }], diff --git a/packages/provider-pi/src/server/status.ts b/packages/provider-pi/src/server/status.ts index 3030200c245a..183bc27c4aad 100644 --- a/packages/provider-pi/src/server/status.ts +++ b/packages/provider-pi/src/server/status.ts @@ -11,6 +11,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Stream from "effect/Stream"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as HttpClient from "effect/http/HttpClient"; @@ -134,6 +135,7 @@ const makePiDiscoveryConnection = Effect.fnUntraced(function* ( environment: NodeJS.ProcessEnv, launchArgs: ReadonlyArray, cwd?: string, + textGenerationLaunch = false, ) { const launch = buildPiRpcLaunch({ launchArgs, @@ -141,6 +143,8 @@ const makePiDiscoveryConnection = Effect.fnUntraced(function* ( mcpSession: undefined, extensionPath: undefined, ephemeral: true, + disableExtensions: textGenerationLaunch, + disableTools: textGenerationLaunch, }); const connection = yield* makePiRpcConnection({ command: piSettings.binaryPath || "pi", @@ -156,12 +160,45 @@ const makePiDiscoveryConnection = Effect.fnUntraced(function* ( return connection; }); +// Text generation runs Pi with --no-extensions, so it cannot load models that an +// extension or package registers. Listing models with that launch tells them apart. +const discoverPiTextGenerationModelSlugs = Effect.fn( + "PiProviderStatus.discoverPiTextGenerationModelSlugs", +)(function* ( + piSettings: PiSettings, + environment: NodeJS.ProcessEnv, + launchArgs: ReadonlyArray, + cwd?: string, +) { + const connection = yield* makePiDiscoveryConnection( + piSettings, + environment, + launchArgs, + cwd, + true, + ); + const modelsData = yield* connection.request({ type: "get_available_models" }); + const models = yield* Effect.fromResult(parseDiscoveredModels(modelsData, undefined)); + return new Set(models.map((model) => model.slug)); +}, Effect.scoped); + const discoverPiViaRpc = Effect.fn("PiProviderStatus.discoverPiViaRpc")(function* ( piSettings: PiSettings, environment: NodeJS.ProcessEnv, launchArgs: ReadonlyArray, cwd?: string, ) { + // Without this list every model stays offered, and text generation still + // explains a rejected extension model. + const textGenerationModelSlugsFiber = yield* discoverPiTextGenerationModelSlugs( + piSettings, + environment, + launchArgs, + cwd, + ).pipe( + Effect.orElseSucceed(() => undefined), + Effect.forkScoped, + ); const connection = yield* makePiDiscoveryConnection(piSettings, environment, launchArgs, cwd); const stateData = yield* connection.request({ type: "get_state" }); const modelsData = yield* connection.request({ type: "get_available_models" }); @@ -170,8 +207,16 @@ const discoverPiViaRpc = Effect.fn("PiProviderStatus.discoverPiViaRpc")(function .pipe(Effect.orElseSucceed(() => undefined)); const discoveredModels = yield* Effect.fromResult(parseDiscoveredModels(modelsData, stateData)); const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); + const textGenerationModelSlugs = yield* Fiber.join(textGenerationModelSlugsFiber); return { - models: discoveredModels, + models: + textGenerationModelSlugs === undefined + ? discoveredModels + : discoveredModels.map((model) => + textGenerationModelSlugs.has(model.slug) + ? model + : { ...model, supportsTextGeneration: false }, + ), slashCommands: withPiBuiltinSlashCommands(slashCommands), skills, authenticated: discoveredModels.length > 0, From 1174f26bd2d49044b900f6d3dc84951c62f8dbf2 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 22 Oct 2026 15:48:14 -0500 Subject: [PATCH 2/9] fix(pi): preserve discovery and exclude customized extension models Co-Authored-By: GPT-6.1 Sol --- .../components/settings/SettingsPanels.tsx | 4 +- .../settings/SourceControlWritingSettings.tsx | 6 +- apps/web/src/modelSelection.test.ts | 77 +++++++++++++++++++ apps/web/src/modelSelection.ts | 57 +++++++++----- .../provider-pi/src/server/status.test.ts | 31 ++++++++ packages/provider-pi/src/server/status.ts | 9 ++- 6 files changed, 159 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index b915fce6f81e..6156761f7eba 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -54,7 +54,7 @@ import { useSettingsScope } from "./SettingsScopeContext"; import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { - getCustomModelOptionsByInstance, + getTextGenerationModelOptionsByInstance, getTextGenerationProviders, resolveAppModelSelectionState, selectsPlanAgent, @@ -836,7 +836,7 @@ export function GeneralSettingsPanel() { ); const textGenProvider: ProviderDriverKind = textGenInstanceEntry?.driverKind ?? DEFAULT_DRIVER_KIND; - const textGenerationModelOptionsByInstance = getCustomModelOptionsByInstance( + const textGenerationModelOptionsByInstance = getTextGenerationModelOptionsByInstance( settings, textGenerationProviders, textGenInstanceId, diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index f7d091287771..1f1958c588fc 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -25,7 +25,7 @@ import { sortProviderInstanceEntries, } from "../../providerInstances"; import { - getCustomModelOptionsByInstance, + getTextGenerationModelOptionsByInstance, getTextGenerationProviders, resolveAppModelSelectionState, selectsPlanAgent, @@ -144,7 +144,7 @@ export function SourceControlWritingSettingsSection() { (entry) => entry.instanceId === defaultModelSelection.instanceId && entry.enabled && entry.isAvailable, ); - const modelOptionsByInstance = getCustomModelOptionsByInstance( + const modelOptionsByInstance = getTextGenerationModelOptionsByInstance( settings, projectEffectiveTextGenerationProviders, activeSelection.instanceId, @@ -166,7 +166,7 @@ export function SourceControlWritingSettingsSection() { }, textGenerationProviders, ); - const reviewGuideModelOptionsByInstance = getCustomModelOptionsByInstance( + const reviewGuideModelOptionsByInstance = getTextGenerationModelOptionsByInstance( settings, textGenerationProviders, reviewGuideSelection.instanceId, diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 05a7e76b6b25..afee2d5654be 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -13,6 +13,9 @@ import { getComposerProviderState } from "./components/chat/composerProviderStat import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "./providerInstances"; import { getAppModelOptionsForInstance, + getCustomModelOptionsByInstance, + getTextGenerationModelOptionsByInstance, + getTextGenerationProviders, resolveAppModelSelectionForInstance, resolveAppModelSelectionState, resolveEffectiveDefaultModelSelection, @@ -670,6 +673,80 @@ describe("instance-scoped model selection", () => { NO_PROVIDER_MODEL_SELECTION, ); }); + + it("does not select a model that the provider cannot use for text generation", () => { + const codex = provider({ instanceId: "codex", models: ["gpt-5.6-sol", "extension-model"] }); + const providers = [ + { + ...codex, + models: codex.models.map((m) => + m.slug === "extension-model" ? { ...m, supportsTextGeneration: false } : m, + ), + }, + ]; + const settings: UnifiedSettings = { + ...settingsWithProviderInstances(), + textGenerationModelSelection: createModelSelection(codex.instanceId, "extension-model"), + }; + + expect(resolveAppModelSelectionState(settings, providers)).toEqual( + createModelSelection(codex.instanceId, "gpt-5.6-sol"), + ); + }); + + it("excludes customized extension models while preserving unknown custom and thread models", () => { + const pi = provider({ + provider: ProviderDriverKind.make("pi"), + instanceId: "pi", + models: ["default", "demo/demo-model"], + }); + const providers = [ + { + ...pi, + models: pi.models.map((model) => + model.slug === "demo/demo-model" ? { ...model, supportsTextGeneration: false } : model, + ), + }, + ]; + const settings: UnifiedSettings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [pi.instanceId]: { + driver: ProviderDriverKind.make("pi"), + config: { + customModels: [ + { slug: "demo/demo-model", name: "My extension model" }, + { slug: "unknown/model", name: "My custom model" }, + ], + }, + }, + }, + textGenerationModelSelection: createModelSelection(pi.instanceId, "demo/demo-model"), + }; + const supported = getTextGenerationProviders(providers); + expect( + getTextGenerationModelOptionsByInstance(settings, supported, pi.instanceId, "demo/demo-model") + .get(pi.instanceId) + ?.map((option) => option.slug), + ).toEqual(["default", "unknown/model"]); + expect(resolveAppModelSelectionState(settings, supported)).toEqual( + createModelSelection(pi.instanceId, "default"), + ); + expect( + resolveAppModelSelectionState( + { + ...settings, + textGenerationModelSelection: createModelSelection(pi.instanceId, "unknown/model"), + }, + supported, + ).model, + ).toBe("unknown/model"); + expect( + getCustomModelOptionsByInstance(settings, providers) + .get(pi.instanceId) + ?.map((option) => option.slug), + ).toEqual(["default", "demo/demo-model", "unknown/model"]); + }); }); describe("resolveAppModelSelectionState with the opencode plan agent", () => { diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index e770f38dfa8c..c6d6d3642814 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -317,19 +317,45 @@ export function getCustomModelOptionsByInstance( return out; } -/** - * Providers and models that application text generation can run. An absent - * `supportsTextGeneration` on either one means supported. - */ +/** An absent `supportsTextGeneration` means supported. */ export function getTextGenerationProviders( providers: ReadonlyArray, ): ReadonlyArray { - return providers - .filter((provider) => provider.supportsTextGeneration !== false) - .map((provider) => { - const models = provider.models.filter((model) => model.supportsTextGeneration !== false); - return models.length === provider.models.length ? provider : { ...provider, models }; - }); + return providers.filter((provider) => provider.supportsTextGeneration !== false); +} + +function getTextGenerationModelOptionsForInstance( + settings: UnifiedSettings, + entry: ProviderInstanceEntry, + selectedModel?: string | null, +): AppModelOption[] { + const unsupported = new Set( + entry.models + .filter((model) => model.supportsTextGeneration === false) + .map((model) => model.slug), + ); + // Filter after custom entries are rebuilt so a display-name override cannot restore a model. + return getAppModelOptionsForInstance(settings, entry, selectedModel).filter( + (option) => !unsupported.has(option.slug), + ); +} + +export function getTextGenerationModelOptionsByInstance( + settings: UnifiedSettings, + providers: ReadonlyArray, + selectedInstanceId?: ProviderInstanceId | null, + selectedModel?: string | null, +): ReadonlyMap> { + return new Map( + deriveProviderInstanceEntries(getTextGenerationProviders(providers)).map((entry) => [ + entry.instanceId, + getTextGenerationModelOptionsForInstance( + settings, + entry, + entry.instanceId === selectedInstanceId ? selectedModel : null, + ), + ]), + ); } export function selectsPlanAgent(options: ModelSelection["options"]): boolean { @@ -356,7 +382,7 @@ export function resolveAppModelSelectionState( // A saved model the catalog no longer lists, or a new fallback instance, // gets the driver's cheap text generation default. The catalog's own // default is the flagship chat model, which is too expensive here. - const options = getAppModelOptionsForInstance(settings, entry); + const options = getTextGenerationModelOptionsForInstance(settings, entry); const model = resolveSelectableModel(entry.driverKind, selectedModel, options) ?? resolveSelectableModel( @@ -364,13 +390,8 @@ export function resolveAppModelSelectionState( DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind], options, ) ?? - resolveAppModelSelectionForInstance( - entry.instanceId, - settings, - supportedProviders, - selectedModel, - ) ?? - entry.models[0]?.slug ?? + options.find((option) => option.isDefault)?.slug ?? + options[0]?.slug ?? DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind]; if (!model) { return createModelSelection(entry.instanceId, "", []); diff --git a/packages/provider-pi/src/server/status.test.ts b/packages/provider-pi/src/server/status.test.ts index 8f20d7e43193..d25560c2ea76 100644 --- a/packages/provider-pi/src/server/status.test.ts +++ b/packages/provider-pi/src/server/status.test.ts @@ -2,6 +2,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; @@ -66,6 +68,7 @@ const piRpcProcess = (inventory: unknown) => stdinBuffer = stdinBuffer.slice(newline + 1); if (line.length === 0) continue; const request = decodeRpcRequest(line); + if (request.type === "get_available_models" && inventory === null) continue; const data = request.type === "get_available_models" ? inventory @@ -175,6 +178,34 @@ describe("PiProvider", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("preserves normal discovery when the extension-free model listing times out", () => + Effect.gen(function* () { + const spawner = yield* piDiscoverySpawner( + { models: [{ provider: "demo", id: "demo-model" }] }, + null, + ); + const fiber = yield* checkPiProviderStatus(settings).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.forkChild, + ); + yield* TestClock.adjust("30 seconds"); + const snapshot = yield* Fiber.join(fiber); + assert.equal(snapshot.status, "ready"); + assert.equal(snapshot.auth.status, "authenticated"); + assert.deepEqual( + snapshot.models.map((model) => [model.slug, model.supportsTextGeneration]), + [ + ["default", undefined], + ["demo/demo-model", undefined], + ], + ); + assert.include( + snapshot.slashCommands.map((command) => command.name), + "compact", + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + // regression: #18498 a malformed model inventory must not restore stale Pi models it.effect.each([ ["missing models", {}], diff --git a/packages/provider-pi/src/server/status.ts b/packages/provider-pi/src/server/status.ts index 183bc27c4aad..6d6af1719a28 100644 --- a/packages/provider-pi/src/server/status.ts +++ b/packages/provider-pi/src/server/status.ts @@ -59,6 +59,7 @@ const PI_PRESENTATION = { const VERSION_PROBE_TIMEOUT_MS = 4_000; const PI_RPC_DISCOVERY_TIMEOUT_MS = 30_000; +const PI_TEXT_GENERATION_DISCOVERY_TIMEOUT_MS = 5_000; /** * get_entries arrived in 0.80.3 and agent_settled landed in source at 0.80.4. * Version 0.80.5 was the first published package containing both hooks. @@ -188,8 +189,8 @@ const discoverPiViaRpc = Effect.fn("PiProviderStatus.discoverPiViaRpc")(function launchArgs: ReadonlyArray, cwd?: string, ) { - // Without this list every model stays offered, and text generation still - // explains a rejected extension model. + // This optional probe must finish before the normal discovery deadline so + // it cannot discard an otherwise usable catalog and authentication state. const textGenerationModelSlugsFiber = yield* discoverPiTextGenerationModelSlugs( piSettings, environment, @@ -197,6 +198,10 @@ const discoverPiViaRpc = Effect.fn("PiProviderStatus.discoverPiViaRpc")(function cwd, ).pipe( Effect.orElseSucceed(() => undefined), + Effect.timeoutOrElse({ + duration: PI_TEXT_GENERATION_DISCOVERY_TIMEOUT_MS, + orElse: () => Effect.succeed(undefined), + }), Effect.forkScoped, ); const connection = yield* makePiDiscoveryConnection(piSettings, environment, launchArgs, cwd); From eda6e3569a62eb45a1352b364e6cdc43e0d00b71 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 22 Oct 2026 15:54:14 -0500 Subject: [PATCH 3/9] test(pi): synchronize discovery timeout with the pending request Co-Authored-By: GPT-6.1 Sol --- .../provider-pi/src/server/status.test.ts | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/provider-pi/src/server/status.test.ts b/packages/provider-pi/src/server/status.test.ts index d25560c2ea76..c2c92627ff51 100644 --- a/packages/provider-pi/src/server/status.test.ts +++ b/packages/provider-pi/src/server/status.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as TestClock from "effect/testing/TestClock"; import * as Queue from "effect/Queue"; @@ -54,7 +55,7 @@ function piProbeSpawner(version: string) { }); } -const piRpcProcess = (inventory: unknown) => +const piRpcProcess = (inventory: unknown, onModelRequest: Effect.Effect = Effect.void) => Effect.gen(function* () { const stdout = yield* Queue.unbounded(); let stdinBuffer = ""; @@ -68,7 +69,10 @@ const piRpcProcess = (inventory: unknown) => stdinBuffer = stdinBuffer.slice(newline + 1); if (line.length === 0) continue; const request = decodeRpcRequest(line); - if (request.type === "get_available_models" && inventory === null) continue; + if (request.type === "get_available_models") { + yield* onModelRequest; + if (inventory === null) continue; + } const data = request.type === "get_available_models" ? inventory @@ -94,13 +98,20 @@ const piRpcProcess = (inventory: unknown) => }); // Text generation lists models with --no-extensions, so its inventory can differ. -const piDiscoverySpawner = (inventory: unknown, extensionFreeInventory: unknown = inventory) => +const piDiscoverySpawner = ( + inventory: unknown, + extensionFreeInventory: unknown = inventory, + onExtensionFreeModelRequest: Effect.Effect = Effect.void, +) => Effect.succeed( ChildProcessSpawner.make((command) => { const args = ChildProcess.isStandardCommand(command) ? command.args : []; return args.includes("--version") ? Effect.succeed(processHandle({ stdout: "pi 0.84.3\n" })) - : piRpcProcess(args.includes("--no-extensions") ? extensionFreeInventory : inventory); + : piRpcProcess( + args.includes("--no-extensions") ? extensionFreeInventory : inventory, + args.includes("--no-extensions") ? onExtensionFreeModelRequest : Effect.void, + ); }), ); @@ -180,15 +191,21 @@ describe("PiProvider", () => { it.effect("preserves normal discovery when the extension-free model listing times out", () => Effect.gen(function* () { + const modelRequested = yield* Deferred.make(); + const completed = yield* Deferred.make(); const spawner = yield* piDiscoverySpawner( { models: [{ provider: "demo", id: "demo-model" }] }, null, + Deferred.succeed(modelRequested, undefined).pipe(Effect.asVoid), ); const fiber = yield* checkPiProviderStatus(settings).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.tap(() => Deferred.succeed(completed, undefined)), Effect.forkChild, ); - yield* TestClock.adjust("30 seconds"); + yield* Deferred.await(modelRequested); + yield* TestClock.adjust("5 seconds"); + assert.isTrue(yield* Deferred.isDone(completed)); const snapshot = yield* Fiber.join(fiber); assert.equal(snapshot.status, "ready"); assert.equal(snapshot.auth.status, "authenticated"); From 6c9d66fc3d2b66c31df91877178bc01a4ccc25e8 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 22 Oct 2026 15:55:42 -0500 Subject: [PATCH 4/9] test(pi): advance optional and normal discovery deadlines separately Co-Authored-By: GPT-6.1 Sol --- packages/provider-pi/src/server/status.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/provider-pi/src/server/status.test.ts b/packages/provider-pi/src/server/status.test.ts index c2c92627ff51..55008ebeb9da 100644 --- a/packages/provider-pi/src/server/status.test.ts +++ b/packages/provider-pi/src/server/status.test.ts @@ -192,7 +192,6 @@ describe("PiProvider", () => { it.effect("preserves normal discovery when the extension-free model listing times out", () => Effect.gen(function* () { const modelRequested = yield* Deferred.make(); - const completed = yield* Deferred.make(); const spawner = yield* piDiscoverySpawner( { models: [{ provider: "demo", id: "demo-model" }] }, null, @@ -200,12 +199,11 @@ describe("PiProvider", () => { ); const fiber = yield* checkPiProviderStatus(settings).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.tap(() => Deferred.succeed(completed, undefined)), Effect.forkChild, ); yield* Deferred.await(modelRequested); yield* TestClock.adjust("5 seconds"); - assert.isTrue(yield* Deferred.isDone(completed)); + yield* TestClock.adjust("25 seconds"); const snapshot = yield* Fiber.join(fiber); assert.equal(snapshot.status, "ready"); assert.equal(snapshot.auth.status, "authenticated"); From 09631f3fd1de7240758152fe3b731580ea29adae Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 23 Oct 2026 06:13:45 -0500 Subject: [PATCH 5/9] fix(pi): fall back to a usable catalog model when all options are hidden Co-Authored-By: Claude Opus 5.5 --- apps/web/src/modelSelection.ts | 1 + packages/provider-pi/src/server/status.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index c6d6d3642814..5916447c213b 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -392,6 +392,7 @@ export function resolveAppModelSelectionState( ) ?? options.find((option) => option.isDefault)?.slug ?? options[0]?.slug ?? + entry.models.find((model) => model.supportsTextGeneration !== false)?.slug ?? DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind]; if (!model) { return createModelSelection(entry.instanceId, "", []); diff --git a/packages/provider-pi/src/server/status.ts b/packages/provider-pi/src/server/status.ts index 6d6af1719a28..f05cf6308769 100644 --- a/packages/provider-pi/src/server/status.ts +++ b/packages/provider-pi/src/server/status.ts @@ -200,7 +200,7 @@ const discoverPiViaRpc = Effect.fn("PiProviderStatus.discoverPiViaRpc")(function Effect.orElseSucceed(() => undefined), Effect.timeoutOrElse({ duration: PI_TEXT_GENERATION_DISCOVERY_TIMEOUT_MS, - orElse: () => Effect.succeed(undefined), + orElse: () => Effect.void, }), Effect.forkScoped, ); From 9c79d177bc00550385142977fd9047bbdaa6df51 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 12:12:17 +0000 Subject: [PATCH 6/9] refactor(pi): keep model filtering scoped and tighten timeout coverage --- .../src/components/settings/SettingsPanels.tsx | 5 +++-- .../settings/SourceControlWritingSettings.tsx | 5 +++-- apps/web/src/modelSelection.test.ts | 3 +-- apps/web/src/modelSelection.ts | 15 ++++++--------- packages/provider-pi/src/server/status.test.ts | 1 - 5 files changed, 13 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 05b63ca97acc..92be5a4c404a 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -58,7 +58,6 @@ import { ThreadTitleInstructionsSettings } from "./ThreadTitleInstructionsSettin import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { getTextGenerationModelOptionsByInstance, - getTextGenerationProviders, resolveAppModelSelectionState, selectsPlanAgent, } from "../../modelSelection"; @@ -815,7 +814,9 @@ export function GeneralSettingsPanel() { (target) => target.serverConfig?.environment.capabilities.threadRestartContinuation === true, ); - const textGenerationProviders = getTextGenerationProviders(serverProviders); + const textGenerationProviders = serverProviders.filter( + (provider) => provider.supportsTextGeneration !== false, + ); const textGenerationModelSelection = resolveAppModelSelectionState( settings, textGenerationProviders, diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index 1f1958c588fc..401e33a89c5e 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -26,7 +26,6 @@ import { } from "../../providerInstances"; import { getTextGenerationModelOptionsByInstance, - getTextGenerationProviders, resolveAppModelSelectionState, selectsPlanAgent, } from "../../modelSelection"; @@ -108,7 +107,9 @@ export function SourceControlWritingSettingsSection() { style.mode !== defaults.mode || style.customInstructions !== defaults.customInstructions; - const textGenerationProviders = getTextGenerationProviders(serverProviders); + const textGenerationProviders = serverProviders.filter( + (provider) => provider.supportsTextGeneration !== false, + ); const instanceEntries = sortProviderInstanceEntries( applyProviderInstanceSettings( deriveProviderInstanceEntries(textGenerationProviders), diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index afee2d5654be..00b333f46899 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -15,7 +15,6 @@ import { getAppModelOptionsForInstance, getCustomModelOptionsByInstance, getTextGenerationModelOptionsByInstance, - getTextGenerationProviders, resolveAppModelSelectionForInstance, resolveAppModelSelectionState, resolveEffectiveDefaultModelSelection, @@ -723,7 +722,7 @@ describe("instance-scoped model selection", () => { }, textGenerationModelSelection: createModelSelection(pi.instanceId, "demo/demo-model"), }; - const supported = getTextGenerationProviders(providers); + const supported = providers.filter((provider) => provider.supportsTextGeneration !== false); expect( getTextGenerationModelOptionsByInstance(settings, supported, pi.instanceId, "demo/demo-model") .get(pi.instanceId) diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index cd83eed59601..e3f8781260d3 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -311,13 +311,6 @@ export function getCustomModelOptionsByInstance( return out; } -/** An absent `supportsTextGeneration` means supported. */ -export function getTextGenerationProviders( - providers: ReadonlyArray, -): ReadonlyArray { - return providers.filter((provider) => provider.supportsTextGeneration !== false); -} - function getTextGenerationModelOptionsForInstance( settings: UnifiedSettings, entry: ProviderInstanceEntry, @@ -341,7 +334,9 @@ export function getTextGenerationModelOptionsByInstance( selectedModel?: string | null, ): ReadonlyMap> { return new Map( - deriveProviderInstanceEntries(getTextGenerationProviders(providers)).map((entry) => [ + deriveProviderInstanceEntries( + providers.filter((provider) => provider.supportsTextGeneration !== false), + ).map((entry) => [ entry.instanceId, getTextGenerationModelOptionsForInstance( settings, @@ -364,7 +359,9 @@ export function resolveAppModelSelectionState( instanceId: DEFAULT_TEXT_GENERATION_INSTANCE_ID, model: DEFAULT_TEXT_GENERATION_MODEL, }; - const supportedProviders = getTextGenerationProviders(providers); + const supportedProviders = providers.filter( + (provider) => provider.supportsTextGeneration !== false, + ); const entries = deriveProviderInstanceEntries(supportedProviders); const selectedEntry = entries.find( (entry) => entry.instanceId === selection.instanceId && entry.enabled && entry.isAvailable, diff --git a/packages/provider-pi/src/server/status.test.ts b/packages/provider-pi/src/server/status.test.ts index 55008ebeb9da..50d911f3631b 100644 --- a/packages/provider-pi/src/server/status.test.ts +++ b/packages/provider-pi/src/server/status.test.ts @@ -203,7 +203,6 @@ describe("PiProvider", () => { ); yield* Deferred.await(modelRequested); yield* TestClock.adjust("5 seconds"); - yield* TestClock.adjust("25 seconds"); const snapshot = yield* Fiber.join(fiber); assert.equal(snapshot.status, "ready"); assert.equal(snapshot.auth.status, "authenticated"); From 356cd0b93e2a3278fb6833aa46b030967e1a84fd Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 12:20:03 +0000 Subject: [PATCH 7/9] fix(web): validate text models across selected environments --- ...rceControlWritingSettings.browser.test.tsx | 77 ++++++++++++++++++- .../settings/useScopedModelAvailability.ts | 4 +- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx index 850b7ea016c7..c46a1692612d 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx @@ -1,5 +1,13 @@ import { render } from "../../../test/render"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { useScopedModelDisabledReason } from "./useScopedModelAvailability"; +import { deriveProviderInstanceEntries } from "../../providerInstances"; import { StrictMode, type ReactNode } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; @@ -10,6 +18,8 @@ import type { ScopedSettingsPatch } from "./scopedSettings"; type WritingStyle = typeof DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle; const state = vi.hoisted(() => ({ styles: [] as WritingStyle[], + providerInstances: {} as typeof DEFAULT_UNIFIED_SETTINGS.providerInstances, + providers: new Map(), updateSettings: vi.fn<(patch: ScopedSettingsPatch) => void>(), })); @@ -34,15 +44,27 @@ vi.mock(import("./SettingsScopeContext"), async (original) => ({ scope: { kind: "all", environmentIds: [] }, environment: null, connectedEnvironments: [], - targets: state.styles.map((style) => ({ - settings: { ...DEFAULT_UNIFIED_SETTINGS, sourceControlWritingStyle: style }, + targets: state.styles.map((style, index) => ({ + environmentId: EnvironmentId.make(`environment-${index}`), + settings: { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: state.providerInstances, + sourceControlWritingStyle: style, + }, })), target: null, })) as never, })); vi.mock(import("../../state/environments"), async (original) => ({ ...(await original()), - useEnvironmentProviders: () => new Map(), + useEnvironmentProviders: () => state.providers, + useEnvironments: (() => ({ + environments: [...state.providers.keys()].map((environmentId) => ({ + environmentId, + label: environmentId, + serverConfig: {}, + })), + })) as never, })); vi.mock(import("./settingsLayout"), async (original) => ({ ...(await original()), @@ -69,6 +91,8 @@ const content = () => ( ); async function setup() { + state.providers = new Map(); + state.providerInstances = {}; state.styles = [ { ...DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle, @@ -111,3 +135,50 @@ describe("mixed source control instructions", () => { ).toEqual(initialInstructions); }); }); + +it("rejects a scoped model when another environment requires extensions for it", async () => { + state.styles = [DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle]; + const instanceId = ProviderInstanceId.make("pi"); + state.providerInstances = { + [instanceId]: { driver: ProviderDriverKind.make("pi"), enabled: true }, + }; + const provider: ServerProvider = { + instanceId, + driver: ProviderDriverKind.make("pi"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + slashCommands: [], + skills: [], + models: [{ slug: "demo/model", name: "Demo", isCustom: false, capabilities: {} }], + }; + state.providers = new Map([ + [ + EnvironmentId.make("environment-0"), + [ + { + ...provider, + models: provider.models.map((model) => ({ ...model, supportsTextGeneration: false })), + }, + ], + ], + ]); + function Availability() { + const reason = useScopedModelDisabledReason( + { ...DEFAULT_UNIFIED_SETTINGS, providerInstances: state.providerInstances }, + deriveProviderInstanceEntries([provider]), + ); + return

{reason(instanceId, "demo/model") ?? "Available"}

; + } + await using screen = await render(); + await expect + .element( + screen.getByText( + "This model is unavailable on environment-0. Select that environment to choose its model separately.", + ), + ) + .toBeVisible(); +}); diff --git a/apps/web/src/components/settings/useScopedModelAvailability.ts b/apps/web/src/components/settings/useScopedModelAvailability.ts index abf1e1e1afc0..58ca41b51b33 100644 --- a/apps/web/src/components/settings/useScopedModelAvailability.ts +++ b/apps/web/src/components/settings/useScopedModelAvailability.ts @@ -1,7 +1,7 @@ import type { ProviderInstanceId, UnifiedSettings } from "@t3tools/contracts"; import { mergeEnvironmentSettings } from "../../hooks/useSettings"; -import { getCustomModelOptionsByInstance } from "../../modelSelection"; +import { getTextGenerationModelOptionsByInstance } from "../../modelSelection"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -30,7 +30,7 @@ export function useScopedModelDisabledReason( candidate.settings, candidate.projectId, ).find((option) => option.instanceId === instanceId); - const options = getCustomModelOptionsByInstance( + const options = getTextGenerationModelOptionsByInstance( mergeEnvironmentSettings(candidate.settings, settings), providers, ).get(instanceId); From 0bab335b4f8cbe103dfa2e22786adc6df843a82b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 14:08:30 +0000 Subject: [PATCH 8/9] fix(web): preserve extension models for thread defaults --- .../src/components/settings/SettingsPanels.tsx | 1 + .../SourceControlWritingSettings.browser.test.tsx | 15 +++++++++++++-- .../settings/SourceControlWritingSettings.tsx | 2 +- .../settings/useScopedModelAvailability.ts | 11 +++++++++-- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 29d6ffbbef5e..7ea4d8ddc149 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1529,6 +1529,7 @@ export function ThreadsSettingsPanel() { const textGenerationModelDisabledReason = useScopedModelDisabledReason( settings, textGenerationModelInstanceEntries, + true, ); const mixedAddProjectBaseDirectory = useScopedSettingsMixed(["addProjectBaseDirectory"]); const mixedTextGenerationModel = useScopedSettingsMixed(["textGenerationModelSelection"]); diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx index 49661ee59405..86a8321520f9 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx @@ -168,13 +168,24 @@ it("rejects a scoped model when another environment requires extensions for it", ], ]); function Availability() { - const reason = useScopedModelDisabledReason( + const backgroundReason = useScopedModelDisabledReason( { ...DEFAULT_UNIFIED_SETTINGS, providerInstances: state.providerInstances }, deriveProviderInstanceEntries([provider]), + true, + ); + const threadReason = useScopedModelDisabledReason( + { ...DEFAULT_UNIFIED_SETTINGS, providerInstances: state.providerInstances }, + deriveProviderInstanceEntries([provider]), + ); + return ( + <> +

{backgroundReason(instanceId, "demo/model") ?? "Available"}

+

{threadReason(instanceId, "demo/model") ?? "Available for threads"}

+ ); - return

{reason(instanceId, "demo/model") ?? "Available"}

; } await using screen = await render(); + await expect.element(screen.getByText("Available for threads")).toBeVisible(); await expect .element( screen.getByText( diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index bc6dfc9d7352..b9885befb18d 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -145,7 +145,7 @@ export function SourceControlWritingSettingsSection() { activeSelection.instanceId, activeSelection.model, ); - const writerModelDisabledReason = useScopedModelDisabledReason(settings, instanceEntries); + const writerModelDisabledReason = useScopedModelDisabledReason(settings, instanceEntries, true); const writerInstanceEntry = instanceEntries.find( (entry) => entry.instanceId === activeSelection.instanceId, ); diff --git a/apps/web/src/components/settings/useScopedModelAvailability.ts b/apps/web/src/components/settings/useScopedModelAvailability.ts index 58ca41b51b33..9fa4eeb938b1 100644 --- a/apps/web/src/components/settings/useScopedModelAvailability.ts +++ b/apps/web/src/components/settings/useScopedModelAvailability.ts @@ -1,7 +1,10 @@ import type { ProviderInstanceId, UnifiedSettings } from "@t3tools/contracts"; import { mergeEnvironmentSettings } from "../../hooks/useSettings"; -import { getTextGenerationModelOptionsByInstance } from "../../modelSelection"; +import { + getCustomModelOptionsByInstance, + getTextGenerationModelOptionsByInstance, +} from "../../modelSelection"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -13,6 +16,7 @@ import { useSettingsScope } from "./SettingsScopeContext"; export function useScopedModelDisabledReason( settings: UnifiedSettings, entries: readonly ProviderInstanceEntry[], + textGeneration = false, ) { const { targets } = useSettingsScope(); const { environments } = useEnvironments(); @@ -30,7 +34,10 @@ export function useScopedModelDisabledReason( candidate.settings, candidate.projectId, ).find((option) => option.instanceId === instanceId); - const options = getTextGenerationModelOptionsByInstance( + const optionsForInstance = textGeneration + ? getTextGenerationModelOptionsByInstance + : getCustomModelOptionsByInstance; + const options = optionsForInstance( mergeEnvironmentSettings(candidate.settings, settings), providers, ).get(instanceId); From 12a5a9e250acafe86118208927aca1c21d31a777 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 14:23:15 +0000 Subject: [PATCH 9/9] fix(web): guard scoped writer model enablement --- ...rceControlWritingSettings.browser.test.tsx | 35 +++++++++++++++++-- .../settings/SourceControlWritingSettings.tsx | 20 +++++++++-- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx index 86a8321520f9..d04e929f2f22 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx @@ -32,6 +32,7 @@ vi.mock(import("./useScopedSettings"), async (original) => ({ useScopedSettings: (() => ({ ...DEFAULT_UNIFIED_SETTINGS, sourceControlWritingStyle: state.styles[0], + providerInstances: state.providerInstances, })) as never, useScopedSettingsMixed: (keys) => !keys.includes("reviewGuidesEnabled") && @@ -42,8 +43,12 @@ vi.mock(import("./SettingsScopeContext"), async (original) => ({ ...(await original()), useSettingsScope: (() => ({ scope: { kind: "all", environmentIds: [] }, - environment: null, - connectedEnvironments: [], + environment: { environmentId: EnvironmentId.make("representative"), serverConfig: {} }, + connectedEnvironments: [...state.providers.keys()].map((environmentId) => ({ + environmentId, + serverConfig: {}, + })), + representative: { environmentId: EnvironmentId.make("representative") }, targets: state.styles.map((style, index) => ({ environmentId: EnvironmentId.make(`environment-${index}`), settings: { @@ -138,6 +143,7 @@ describe("mixed source control instructions", () => { }); it("rejects a scoped model when another environment requires extensions for it", async () => { + state.updateSettings.mockReset(); state.styles = [DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle]; const instanceId = ProviderInstanceId.make("pi"); state.providerInstances = { @@ -157,6 +163,7 @@ it("rejects a scoped model when another environment requires extensions for it", models: [{ slug: "demo/model", name: "Demo", isCustom: false, capabilities: {} }], }; state.providers = new Map([ + [EnvironmentId.make("representative"), [provider]], [ EnvironmentId.make("environment-0"), [ @@ -184,7 +191,18 @@ it("rejects a scoped model when another environment requires extensions for it", ); } - await using screen = await render(); + const availability = () => ( + <> + + + + ); + await using screen = await render(availability()); + await userEvent.click(screen.getByRole("button", { name: "Advanced" })); + await userEvent.click( + screen.getByRole("switch", { name: "Use a separate source control writer model" }), + ); + expect(state.updateSettings).not.toHaveBeenCalled(); await expect.element(screen.getByText("Available for threads")).toBeVisible(); await expect .element( @@ -193,4 +211,15 @@ it("rejects a scoped model when another environment requires extensions for it", ), ) .toBeVisible(); + state.providers = new Map([ + [EnvironmentId.make("representative"), [provider]], + [EnvironmentId.make("environment-0"), [provider]], + ]); + await screen.rerender(availability()); + await userEvent.click( + screen.getByRole("switch", { name: "Use a separate source control writer model" }), + ); + expect(state.updateSettings).toHaveBeenCalledWith({ + sourceControlWriterModelSelection: { instanceId, model: "demo/model" }, + }); }); diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index b9885befb18d..205d6aa1d0ca 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -577,7 +577,21 @@ export function SourceControlWritingSettingsSection() { + onCheckedChange={(checked) => { + if (checked) { + const reason = writerModelDisabledReason( + defaultModelSelection.instanceId, + defaultModelSelection.model, + ); + if (reason) { + toastManager.add({ + type: "error", + title: "Writer model not saved", + description: reason, + }); + return; + } + } updateSettings({ sourceControlWriterModelSelection: checked ? createModelSelection( @@ -586,8 +600,8 @@ export function SourceControlWritingSettingsSection() { defaultModelSelection.options, ) : null, - }) - } + }); + }} aria-label="Use a separate source control writer model" />