From b8d76361bf473c93397186d28e4b16467089a57f Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 22 Oct 2026 12:31:08 -0500 Subject: [PATCH 01/13] 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 16c6ff3fb8925..db4d46ec48985 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 3c76207c4816b..93cfb9cfa0e4d 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 0b57a6cbb46ff..e770f38dfa8ca 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 c39a29d6fe776..d300f1c85398d 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 d4368a5a82e4d..456865112ad83 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 3030200c245a3..183bc27c4aad8 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 02/13] 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 b915fce6f81e3..6156761f7ebab 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 f7d091287771d..1f1958c588fce 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 05a7e76b6b25b..afee2d5654bed 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 e770f38dfa8ca..c6d6d3642814a 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 8f20d7e431939..d25560c2ea760 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 183bc27c4aad8..6d6af1719a289 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 03/13] 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 d25560c2ea760..c2c92627ff515 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 04/13] 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 c2c92627ff515..55008ebeb9da8 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 05/13] 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 c6d6d3642814a..5916447c213b8 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 6d6af1719a289..f05cf6308769a 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 06/13] 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 05b63ca97acc3..92be5a4c404a9 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 1f1958c588fce..401e33a89c5e4 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 afee2d5654bed..00b333f468991 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 cd83eed59601c..e3f8781260d3b 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 55008ebeb9da8..50d911f3631b8 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 07/13] 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 850b7ea016c7c..c46a1692612d4 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 abf1e1e1afc03..58ca41b51b330 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 08/13] 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 29d6ffbbef5e4..7ea4d8ddc1498 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 49661ee594056..86a8321520f98 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 bc6dfc9d73521..b9885befb18d3 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 58ca41b51b330..9fa4eeb938b1b 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 09/13] 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 86a8321520f98..d04e929f2f22e 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 b9885befb18d3..205d6aa1d0ca1 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" /> From f68ddf00cc92902fc88610ccb95d301048f9fb4f Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Tue, 10 Nov 2026 14:27:54 +0000 Subject: [PATCH 10/13] fix(pi): Pi drops out of text generation when it cannot start without extensions Text generation runs Pi with the user's launch arguments plus --no-extensions. When those arguments name a provider that only an extension registers, Pi exits before answering, so text generation failed for every Pi model, Pi default and custom models included, while the pickers still offered all of them. Discovery now treats an extension-free launch that exits before answering, or lists no models, as Pi having no text generation, and sets the snapshot's supportsTextGeneration to false. A timeout or malformed answer still leaves Pi usable. Co-Authored-By: Claude Opus 5.5 --- .../provider-pi/src/server/status.test.ts | 55 +++++++++++++++++++ packages/provider-pi/src/server/status.ts | 44 +++++++++------ 2 files changed, 83 insertions(+), 16 deletions(-) diff --git a/packages/provider-pi/src/server/status.test.ts b/packages/provider-pi/src/server/status.test.ts index 05b2f5b3e74f6..7906b498fb345 100644 --- a/packages/provider-pi/src/server/status.test.ts +++ b/packages/provider-pi/src/server/status.test.ts @@ -106,6 +106,9 @@ const piRpcProcess = ( }); }); +/** Makes the extension-free launch exit before answering, like launch args naming an extension provider. */ +const exitsWithoutExtensions = Symbol("exitsWithoutExtensions"); + // Text generation lists models with --no-extensions, so its inventory can differ. const piDiscoverySpawner = ( inventory: unknown, @@ -129,6 +132,11 @@ const piDiscoverySpawner = ( ? Effect.die(`Unexpected pi ${args.join(" ")}`) : Effect.succeed(processHandle({ stdout: options.authCheck, exitCode: 1 })); } + if (args.includes("--no-extensions") && extensionFreeInventory === exitsWithoutExtensions) { + return Effect.succeed( + processHandle({ stderr: 'Error: Unknown provider "demo".', exitCode: 1 }), + ); + } return piRpcProcess( args.includes("--no-extensions") ? extensionFreeInventory : inventory, args.includes("--no-extensions") ? onExtensionFreeModelRequest : Effect.void, @@ -318,9 +326,55 @@ describe("PiProvider", () => { ["demo/demo-model", false], ], ); + assert.equal(snapshot.supportsTextGeneration, undefined); }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect.each([ + { label: "exits before answering", extensionFreeInventory: exitsWithoutExtensions }, + { label: "lists no models", extensionFreeInventory: { models: [] } }, + ])("makes Pi unusable for text generation when Pi without extensions $label", (row) => + Effect.gen(function* () { + const spawner = yield* piDiscoverySpawner( + { models: [{ provider: "demo", id: "extension-model" }] }, + row.extensionFreeInventory, + ); + const snapshot = yield* checkPiProviderStatus({ + ...settings, + launchArgs: "--provider demo --model extension-model", + customModels: ["local/custom-alias"], + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + assert.equal(snapshot.status, "ready"); + assert.equal(snapshot.auth.status, "authenticated"); + // Pi default and custom models still run in chat, so the picker keeps them. + assert.deepEqual( + snapshot.models.map((model) => model.slug), + ["default", "demo/extension-model", "local/custom-alias"], + ); + assert.equal(snapshot.supportsTextGeneration, false); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect( + "keeps Pi usable for text generation when Pi without extensions answers malformed", + () => + Effect.gen(function* () { + const spawner = yield* piDiscoverySpawner( + { models: [{ provider: "demo", id: "demo-model" }] }, + {}, + ); + const snapshot = yield* checkPiProviderStatus(settings).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + assert.equal(snapshot.auth.status, "authenticated"); + assert.equal(snapshot.supportsTextGeneration, undefined); + assert.equal( + snapshot.models.find((model) => model.slug === "demo/demo-model")?.supportsTextGeneration, + undefined, + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("preserves normal discovery when the extension-free model listing times out", () => Effect.gen(function* () { const modelRequested = yield* Deferred.make(); @@ -345,6 +399,7 @@ describe("PiProvider", () => { ["demo/demo-model", undefined], ], ); + assert.equal(snapshot.supportsTextGeneration, undefined); assert.include( snapshot.slashCommands.map((command) => command.name), "compact", diff --git a/packages/provider-pi/src/server/status.ts b/packages/provider-pi/src/server/status.ts index ecee35dcf02fa..01047b00eaf26 100644 --- a/packages/provider-pi/src/server/status.ts +++ b/packages/provider-pi/src/server/status.ts @@ -83,6 +83,8 @@ interface PiDiscovery extends PiDiscoveredCommands { readonly authenticated: boolean; /** Pi's active model when Pi lists no model of its provider, so it cannot start a turn. */ readonly unavailableActiveModel: { readonly provider: string; readonly id: string } | undefined; + /** Pi without extensions can load no model, so text generation fails for every entry. */ + readonly textGenerationUnavailable: boolean; } function piModelsFromSettings( @@ -205,6 +207,12 @@ const discoverPiViaRpc = Effect.fn("PiProviderStatus.discoverPiViaRpc")(function launchArgs, cwd, ).pipe( + // Pi exits before answering when launch args name a provider only an extension registers. + Effect.catchIf( + (error) => + error._tag === "PiRpcError" && (error.operation === "read" || error.operation === "exit"), + () => Effect.succeed(new Set()), + ), Effect.orElseSucceed(() => undefined), Effect.timeoutOrElse({ duration: PI_TEXT_GENERATION_DISCOVERY_TIMEOUT_MS, @@ -244,6 +252,7 @@ const discoverPiViaRpc = Effect.fn("PiProviderStatus.discoverPiViaRpc")(function !discoveredModels.some((model) => model.subProvider === activeProvider) ? { provider: activeProvider, id: activeModelId } : undefined, + textGenerationUnavailable: textGenerationModelSlugs?.size === 0, } satisfies PiDiscovery; }, Effect.scoped); @@ -564,22 +573,25 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function resolvedLaunchArgs.args, discovery.unavailableActiveModel, ); - return buildServerProvider({ - presentation: PI_PRESENTATION, - enabled: piSettings.enabled, - checkedAt, - models, - slashCommands: discovery.slashCommands, - skills: discovery.skills, - probe: { - installed: true, - version, - // Other Pi models still work when only the default is unavailable, so keep the picker usable. - status: discovery.authenticated ? "ready" : "warning", - auth: { status: discovery.authenticated ? "authenticated" : "unauthenticated", type: "pi" }, - ...(message === undefined ? {} : { message }), - }, - }); + return { + ...buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models, + slashCommands: discovery.slashCommands, + skills: discovery.skills, + probe: { + installed: true, + version, + // Other Pi models still work when only the default is unavailable, so keep the picker usable. + status: discovery.authenticated ? "ready" : "warning", + auth: { status: discovery.authenticated ? "authenticated" : "unauthenticated", type: "pi" }, + ...(message === undefined ? {} : { message }), + }, + }), + ...(discovery.textGenerationUnavailable ? { supportsTextGeneration: false } : {}), + }; }); export const enrichPiSnapshot = (input: { From 13069a5406b1983a881795943fdd563cc9cd9980 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Tue, 10 Nov 2026 14:37:40 +0000 Subject: [PATCH 11/13] fix(server): text generation skips a saved provider that cannot generate text A provider snapshot with supportsTextGeneration: false was only skipped when picking a fallback. A saved selection that already pointed at it, such as Pi whose launch arguments name an extension-only provider, still dispatched there and failed. The dispatch gate now treats that snapshot as unusable and uses the existing fallback. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/textGeneration/CodexTextGeneration.test.ts | 4 ++++ apps/server/src/textGeneration/TextGeneration.ts | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index b67d3f0ac5830..44d11b7a4190a 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -547,6 +547,7 @@ const PREFLIGHT_PRIMARY_STATES = [ "not installed, fallback disabled for project", "signed out", "signed out, fallback failed", + "cannot generate text", ] as const; it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (primaryState) => @@ -586,6 +587,9 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima ...(primaryState === "signed out, fallback failed" && instanceId === fallback ? { status: "error", auth: { status: "unknown" } } : {}), + ...(primaryState === "cannot generate text" && instanceId === primary + ? { supportsTextGeneration: false } + : {}), ...(primaryState === "exhausted" && instanceId === primary ? { usageLimits: { diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 3042c97191cbd..e3581d10ebee4 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -111,8 +111,9 @@ export const make = Effect.gen(function* () { const unusable = textGeneratorCannotRun || (selected?.enabled === true && - selected.status === "error" && - (!selected.installed || selected.auth.status === "unauthenticated")); + (!isProviderTextGenerationCapable(selected) || + (selected.status === "error" && + (!selected.installed || selected.auth.status === "unauthenticated")))); if (unusable || exhaustedUsageWindow(selected, now, selection.model)) { const settings = yield* serverSettings.getSettings.pipe( Effect.mapError( From cd85b8c036bedd96aa993298a5b0ceef51bd8c77 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 22:08:07 +0000 Subject: [PATCH 12/13] test: keep the Pi text-generation cases that catch regressions Drop the scoped-writer browser test and two cases already covered by the remaining unit tests. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- ...rceControlWritingSettings.browser.test.tsx | 121 +----------------- apps/web/src/modelSelection.test.ts | 20 --- .../provider-pi/src/server/status.test.ts | 20 --- 3 files changed, 5 insertions(+), 156 deletions(-) diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx index bd6b1b6454932..03ed8c3e3808c 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx @@ -1,13 +1,5 @@ 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"; @@ -18,8 +10,6 @@ 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>(), })); @@ -32,7 +22,6 @@ vi.mock(import("./useScopedSettings"), async (original) => ({ useScopedSettings: (() => ({ ...DEFAULT_UNIFIED_SETTINGS, sourceControlWritingStyle: state.styles[0], - providerInstances: state.providerInstances, })) as never, useScopedSettingsMixed: (keys) => !keys.includes("reviewGuidesEnabled") && @@ -43,33 +32,17 @@ vi.mock(import("./SettingsScopeContext"), async (original) => ({ ...(await original()), useSettingsScope: (() => ({ scope: { kind: "all", environmentIds: [] }, - 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: { - ...DEFAULT_UNIFIED_SETTINGS, - providerInstances: state.providerInstances, - sourceControlWritingStyle: style, - }, + environment: null, + connectedEnvironments: [], + targets: state.styles.map((style) => ({ + settings: { ...DEFAULT_UNIFIED_SETTINGS, sourceControlWritingStyle: style }, })), target: null, })) as never, })); vi.mock(import("../../state/environments"), async (original) => ({ ...(await original()), - useEnvironmentProviders: () => state.providers, - useEnvironments: (() => ({ - environments: [...state.providers.keys()].map((environmentId) => ({ - environmentId, - label: environmentId, - serverConfig: {}, - })), - })) as never, + useEnvironmentProviders: () => new Map(), })); vi.mock(import("./settingsLayout"), async (original) => ({ ...(await original()), @@ -96,8 +69,6 @@ const content = () => ( ); async function setup() { - state.providers = new Map(); - state.providerInstances = {}; state.styles = [ { ...DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle, @@ -144,85 +115,3 @@ describe("mixed source control instructions", () => { ).toEqual(initialInstructions); }); }); - -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 = { - [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("representative"), [provider]], - [ - EnvironmentId.make("environment-0"), - [ - { - ...provider, - models: provider.models.map((model) => ({ ...model, supportsTextGeneration: false })), - }, - ], - ], - ]); - function Availability() { - 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"}

- - ); - } - 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( - screen.getByText( - "This model is unavailable on environment-0. Select that environment to choose its model separately.", - ), - ) - .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/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 76342518aabea..17bd7a4a24183 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -920,26 +920,6 @@ describe("instance-scoped 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"), diff --git a/packages/provider-pi/src/server/status.test.ts b/packages/provider-pi/src/server/status.test.ts index 7906b498fb345..78d16ebd31c62 100644 --- a/packages/provider-pi/src/server/status.test.ts +++ b/packages/provider-pi/src/server/status.test.ts @@ -355,26 +355,6 @@ describe("PiProvider", () => { }).pipe(Effect.provide(NodeServices.layer)), ); - it.effect( - "keeps Pi usable for text generation when Pi without extensions answers malformed", - () => - Effect.gen(function* () { - const spawner = yield* piDiscoverySpawner( - { models: [{ provider: "demo", id: "demo-model" }] }, - {}, - ); - const snapshot = yield* checkPiProviderStatus(settings).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); - assert.equal(snapshot.auth.status, "authenticated"); - assert.equal(snapshot.supportsTextGeneration, undefined); - assert.equal( - snapshot.models.find((model) => model.slug === "demo/demo-model")?.supportsTextGeneration, - undefined, - ); - }).pipe(Effect.provide(NodeServices.layer)), - ); - it.effect("preserves normal discovery when the extension-free model listing times out", () => Effect.gen(function* () { const modelRequested = yield* Deferred.make(); From fab45e124c1855f463af5ee252670a67a8a019fe Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 22:18:51 +0000 Subject: [PATCH 13/13] fix(server): text generation skips a saved model the provider cannot run A selection saved before a model was marked unusable for text generation now falls back like an incapable provider does. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- .../src/textGeneration/CodexTextGeneration.test.ts | 14 ++++++++++++++ apps/server/src/textGeneration/TextGeneration.ts | 2 ++ 2 files changed, 16 insertions(+) diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index e669b7c0535a2..ae5c32bf57432 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -547,6 +547,7 @@ const PREFLIGHT_PRIMARY_STATES = [ "signed out", "signed out, fallback failed", "cannot generate text", + "model cannot generate text", ] as const; it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (primaryState) => @@ -594,6 +595,19 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima ...(primaryState === "cannot generate text" && instanceId === primary ? { supportsTextGeneration: false } : {}), + ...(primaryState === "model cannot generate text" && instanceId === primary + ? { + models: [ + { + slug: selection.model, + name: selection.model, + isCustom: false, + supportsTextGeneration: false, + capabilities: null, + }, + ], + } + : {}), ...(primaryState === "exhausted" && instanceId === primary ? { usageLimits: { diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index de56998f6e4ae..818afb4fae553 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -109,6 +109,8 @@ export const make = Effect.gen(function* () { textGeneratorCannotRun || (selected?.enabled === true && (!isProviderTextGenerationCapable(selected) || + selected.models.find((model) => model.slug === selection.model) + ?.supportsTextGeneration === false || (selected.status === "error" && (!selected.installed || selected.auth.status === "unauthenticated")))); if (unusable || exhaustedUsageWindow(selected, now, selection.model)) {