From b5379441f7b4b2f3d7922d3a888d277db9835983 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 01:54:10 +0000 Subject: [PATCH 1/8] test(opencode): reproduce #27304, no way back to sending no variant These tests reproduce pingdotgg/t3code#27304. When OpenCode cannot infer a default variant for a model (a custom provider such as an openai-compatible gateway), the catalog offers only the real variants, so once Low/Medium/High is picked there is no choice that sends no variant. - status: 1.x and 2.x catalogs offer a `default` choice as isDefault and currentValue for such a model - adapter: a turn selecting variant `default` reaches promptAsync without a variant - textGeneration: the same for text generation prompts Co-Authored-By: Claude Opus 5.5 --- .../src/server/adapter.test.ts | 37 +++++++++++- .../src/server/status.test.ts | 60 +++++++++++++++++++ .../src/server/textGeneration.test.ts | 21 ++++++- 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 52f7590e63f4..dc1ea757c7f0 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -20,6 +20,7 @@ import { ThreadId, type OrchestrationV2ProviderThread, type OrchestrationV2ProviderTurn, + type ProviderOptionSelection, } from "@t3tools/contracts"; import { OpenCodeSettings } from "../settings.ts"; import * as Effect from "effect/Effect"; @@ -151,13 +152,14 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct nativeSessionId: string, client: object, host: Partial = {}, + modelOptions: ReadonlyArray = [], ) { const instanceId = ProviderInstanceId.make(`opencode-${suffix}`); const threadId = ThreadId.make(`thread-opencode-${suffix}`); const modelSelection = { instanceId, model: "anthropic/claude-sonnet", - options: [], + options: modelOptions, }; const policy = runtimePolicy("full-access", { cwd: "/workspace" }); const adapter = yield* makeOpenCodeAdapterV2({ @@ -291,6 +293,39 @@ describe("OpenCodeAdapterV2", () => { ), ); + it.effect("sends no variant when the turn selects OpenCode's default variant", () => + Effect.gen(function* () { + const prompted = promiseGate<{ readonly variant?: string }>(); + const harness = yield* makeOpenCodeRuntimeHarness( + "default-variant", + "root", + { + event: { subscribe: async () => ({ stream: asyncEventStream().stream }) }, + session: { + create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), + promptAsync: async (input: { readonly variant?: string }) => { + prompted.resolve(input); + return { data: true }; + }, + abort: async () => ({ data: true }), + children: async () => ({ data: [] }), + }, + }, + {}, + [{ id: "variant", value: "default" }], + ); + yield* harness.startTurn().pipe(Effect.forkScoped); + const prompt = yield* Effect.promise(() => prompted.promise); + assert.isFalse( + "variant" in prompt, + `OpenCode received variant ${JSON.stringify(prompt.variant)} for the Default choice`, + ); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + it.effect("applies a changed runtime policy to the session on resume and turn start", () => Effect.gen(function* () { const updates: Array<{ sessionID: string; permission: unknown }> = []; diff --git a/packages/provider-opencode/src/server/status.test.ts b/packages/provider-opencode/src/server/status.test.ts index 6c16fb9fabab..7f2d43264bfc 100644 --- a/packages/provider-opencode/src/server/status.test.ts +++ b/packages/provider-opencode/src/server/status.test.ts @@ -610,6 +610,66 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { }), ); + it.effect.each(["1.x", "2.x"] as const)( + "offers a Default reasoning choice for a %s custom model with no inferable default", + (generation) => + Effect.gen(function* () { + const variants = ["low", "medium", "high"]; + runtimeMock.state.versionStdout = + generation === "2.x" ? "opencode v2.0.18\n" : DEFAULT_VERSION_STDOUT; + runtimeMock.state.inventory = { + providerList: { + connected: ["acme"], + all: [ + { + id: "acme", + name: "Acme", + models: { + "acme-chat": { + id: "acme-chat", + name: "Acme Chat", + variants: Object.fromEntries(variants.map((id) => [id, {}])), + }, + }, + }, + ], + default: {}, + }, + agents: [], + }; + + const snapshot = yield* checkProvider( + makeOpenCodeSettings(), + process.cwd(), + undefined, + undefined, + Effect.succeed([ + { + providerID: "acme", + id: "acme-chat", + name: "Acme Chat", + variants: variants.map((id) => ({ id })), + }, + ]), + ); + const variant = snapshot.models + .find((model) => model.slug === "acme/acme-chat") + ?.capabilities?.optionDescriptors?.find((descriptor) => descriptor.id === "variant"); + + NodeAssert.ok(variant?.type === "select"); + NodeAssert.deepEqual( + variant.options.find((option) => option.isDefault === true), + { id: "default", label: "Default", isDefault: true }, + "OpenCode cannot infer a default variant, so the picker needs a choice that sends none", + ); + NodeAssert.equal(variant.currentValue, "default"); + NodeAssert.deepEqual( + variant.options.map((option) => option.id).filter((id) => id !== "default"), + variants, + ); + }), + ); + it.effect("includes OpenCode skills in the provider snapshot", () => Effect.gen(function* () { runtimeMock.state.inventory = { diff --git a/packages/provider-opencode/src/server/textGeneration.test.ts b/packages/provider-opencode/src/server/textGeneration.test.ts index 9b9c2525190c..93e9dbe5d155 100644 --- a/packages/provider-opencode/src/server/textGeneration.test.ts +++ b/packages/provider-opencode/src/server/textGeneration.test.ts @@ -25,6 +25,7 @@ const runtimeMock = { startCalls: [] as string[], promptUrls: [] as string[], promptParts: [] as ReadonlyArray[], + promptVariants: [] as Array, authHeaders: [] as Array, closeCalls: [] as string[], sessionCreateCalls: 0, @@ -46,6 +47,7 @@ const runtimeMock = { this.state.startCalls.length = 0; this.state.promptUrls.length = 0; this.state.promptParts.length = 0; + this.state.promptVariants.length = 0; this.state.authHeaders.length = 0; this.state.closeCalls.length = 0; this.state.sessionCreateCalls = 0; @@ -126,11 +128,12 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = { return { data: true }; }, prompt: async ( - input: { readonly parts: ReadonlyArray }, + input: { readonly parts: ReadonlyArray; readonly variant?: string }, options?: { readonly signal?: AbortSignal }, ) => { runtimeMock.state.promptUrls.push(baseUrl); runtimeMock.state.promptParts.push(input.parts); + runtimeMock.state.promptVariants.push(input.variant); runtimeMock.state.authHeaders.push( serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, ); @@ -401,6 +404,22 @@ it.layer(layerOpenCodeTextGenerationTest)("OpenCodeTextGeneration", (it) => { ), ); + it.effect("sends no variant when the selection uses OpenCode's default variant", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage({ + ...DEFAULT_COMMIT_MESSAGE_INPUT, + modelSelection: { + ...DEFAULT_TEST_MODEL_SELECTION, + options: [{ id: "variant", value: "default" }], + }, + }); + + expect(runtimeMock.state.promptVariants).toEqual([undefined]); + }), + ), + ); + it.effect("parses JSON returned as plain text output", () => withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { From 73039a326e15487c757cbf345e3abe692597daa8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 01:59:31 +0000 Subject: [PATCH 2/8] fix(opencode): offer a Default reasoning choice that sends no variant When OpenCode cannot infer a default variant for a model, as with a custom provider's gateway, the reasoning picker offered only the model's variants. After picking one there was no way back to sending none, so a gateway without reasoning support rejected every turn. Both catalog paths now build the variant descriptor in one place. When no default is inferable it adds a Default choice, selected by default, unless the model already has a variant named "default". Every OpenCode 1.x and 2.x turn and text-generation request maps "default" to sending no variant. Fixes pingdotgg/t3code#27304 Co-Authored-By: Claude Opus 5.5 --- .../src/server/OpenCodeRuntime.ts | 11 ++- .../provider-opencode/src/server/adapter.ts | 5 +- .../provider-opencode/src/server/status.ts | 69 +++++++++---------- .../src/server/textGeneration.ts | 2 +- .../src/server/v2/adapter.ts | 4 +- .../src/server/v2/textGeneration.ts | 3 +- 6 files changed, 49 insertions(+), 45 deletions(-) diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.ts index f18ab07bbe19..49fd9de56b7f 100644 --- a/packages/provider-opencode/src/server/OpenCodeRuntime.ts +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.ts @@ -1,6 +1,6 @@ import * as NodeURL from "node:url"; -import type { ChatAttachment, ProviderApprovalDecision } from "@t3tools/contracts"; +import type { ChatAttachment, ModelSelection, ProviderApprovalDecision } from "@t3tools/contracts"; import { createOpencodeClient, type Agent, @@ -37,6 +37,7 @@ import { collectStreamAsString } from "@t3tools/provider-core/server/snapshotPro import * as NetService from "@t3tools/shared/Net"; import { AgentScope } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); @@ -304,6 +305,14 @@ export function parseOpenCodeModelSlug( }; } +/** The picker's choice for models with no inferable default; OpenCode then uses the model's own. */ +export const OPENCODE_DEFAULT_VARIANT = "default"; + +export function openCodeVariant(selection: ModelSelection): string | undefined { + const variant = getModelSelectionStringOptionValue(selection, "variant"); + return variant === OPENCODE_DEFAULT_VARIANT ? undefined : variant; +} + export function openCodeQuestionId( index: number, question: QuestionRequest["questions"][number], diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 7762c3b64503..649ffad8c07a 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -3269,10 +3269,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function const agent = getModelSelectionStringOptionValue(turnInput.modelSelection, "agent") ?? (turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : undefined); - const variant = getModelSelectionStringOptionValue( - turnInput.modelSelection, - "variant", - ); + const variant = OpenCodeRuntime.openCodeVariant(turnInput.modelSelection); yield* submitPrompt( state, turn, diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts index c113bd5551e8..a959d44ed7c1 100644 --- a/packages/provider-opencode/src/server/status.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -1,5 +1,6 @@ import { type ModelCapabilities, + type ProviderOptionDescriptor, type ServerProviderModel, type ServerProviderSkill, type ServerProviderSlashCommand, @@ -195,6 +196,34 @@ function inferDefaultVariant( return undefined; } +/** Without an inferable default, a Default choice lets the user go back to sending no variant. */ +function openCodeVariantDescriptors( + providerID: string, + variants: ReadonlyArray, +): Array { + if (variants.length === 0) return []; + const { OPENCODE_DEFAULT_VARIANT } = OpenCodeRuntime; + const inferred = inferDefaultVariant(providerID, variants); + const ids = + inferred !== undefined || variants.includes(OPENCODE_DEFAULT_VARIANT) + ? variants + : [OPENCODE_DEFAULT_VARIANT, ...variants]; + const defaultVariant = inferred ?? OPENCODE_DEFAULT_VARIANT; + return [ + { + id: "variant", + label: "Reasoning", + type: "select", + options: ids.map((id) => ({ + id, + label: titleCaseSlug(id), + ...(id === defaultVariant ? { isDefault: true } : {}), + })), + currentValue: defaultVariant, + }, + ]; +} + function inferDefaultAgent(agents: ReadonlyArray): string | undefined { return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined; } @@ -234,12 +263,6 @@ function openCodeCapabilitiesForModel(input: { const rawVariantValues = Object.keys(input.model.variants ?? {}); const variantValues = rawVariantValues.length > 0 ? rawVariantValues : ["low", "medium", "high", "xhigh"]; - const defaultVariant = inferDefaultVariant(input.providerID, variantValues); - const variantOptions = variantValues.map((value) => - defaultVariant === value - ? { id: value, label: titleCaseSlug(value), isDefault: true as const } - : { id: value, label: titleCaseSlug(value) }, - ); const primaryAgents = input.agents.filter( (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), ); @@ -251,17 +274,7 @@ function openCodeCapabilitiesForModel(input: { ); return createModelCapabilities({ optionDescriptors: [ - ...(variantOptions.length > 0 - ? [ - { - id: "variant", - label: "Reasoning", - type: "select" as const, - options: variantOptions, - ...(defaultVariant ? { currentValue: defaultVariant } : {}), - }, - ] - : []), + ...openCodeVariantDescriptors(input.providerID, variantValues), ...(agentOptions.length > 0 ? [ { @@ -580,25 +593,11 @@ const OPENCODE_2_PRESENTATION = { } as const; function openCode2ModelCapabilities(model: OpenCode2Model): ModelCapabilities { - const variants = model.variants.map((variant) => variant.id); - const defaultVariant = inferDefaultVariant(model.providerID, variants); return createModelCapabilities({ - optionDescriptors: - variants.length === 0 - ? [] - : [ - { - id: "variant", - label: "Reasoning", - type: "select", - options: variants.map((id) => ({ - id, - label: titleCaseSlug(id), - ...(id === defaultVariant ? { isDefault: true } : {}), - })), - ...(defaultVariant === undefined ? {} : { currentValue: defaultVariant }), - }, - ], + optionDescriptors: openCodeVariantDescriptors( + model.providerID, + model.variants.map((variant) => variant.id), + ), }); } diff --git a/packages/provider-opencode/src/server/textGeneration.ts b/packages/provider-opencode/src/server/textGeneration.ts index 11d6c19fba2c..15e551fc3975 100644 --- a/packages/provider-opencode/src/server/textGeneration.ts +++ b/packages/provider-opencode/src/server/textGeneration.ts @@ -208,7 +208,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }); } const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); - const selectedVariant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); + const selectedVariant = OpenCodeRuntime.openCodeVariant(input.modelSelection); const promptContext = { operation: input.operation, cwd: input.cwd, diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 1f2dd94ca211..377309f0f6c0 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -61,7 +61,7 @@ import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeI import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import * as KeyedLock from "@t3tools/shared/KeyedLock"; -import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; +import { modelSelectionsEqual } from "@t3tools/shared/model"; import { causeErrorTag } from "@t3tools/shared/observability"; import { @@ -596,7 +596,7 @@ const isProviderAdapterError = Schema.is(ProviderAdapter.ProviderAdapterV2Error) const modelRef = (selection: ProviderAdapter.ProviderAdapterV2TurnInput["modelSelection"]) => { const parsed = OpenCodeRuntime.parseOpenCodeModelSlug(selection.model); if (parsed === null) return undefined; - const variant = getModelSelectionStringOptionValue(selection, "variant"); + const variant = OpenCodeRuntime.openCodeVariant(selection); return Model.Ref.make({ providerID: Provider.ID.make(parsed.providerID), id: Model.ID.make(parsed.modelID), diff --git a/packages/provider-opencode/src/server/v2/textGeneration.ts b/packages/provider-opencode/src/server/v2/textGeneration.ts index ff47491e14fe..fb7f4103b09d 100644 --- a/packages/provider-opencode/src/server/v2/textGeneration.ts +++ b/packages/provider-opencode/src/server/v2/textGeneration.ts @@ -1,7 +1,6 @@ // Generate endpoints return 403 on the free tier; use and remove a temporary prompt session. import { AbsolutePath, Location, Model, Provider, Session } from "@opencode/client/effect"; import { TextGenerationError } from "@t3tools/contracts"; -import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -37,7 +36,7 @@ const runOnServer = Effect.fn("Opencode2TextGeneration.runOnServer")(function* ( detail: "OpenCode model selection must use the 'provider/model' format.", }); } - const variant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); + const variant = OpenCodeRuntime.openCodeVariant(input.modelSelection); const outcome = yield* Deferred.make(); let sessionId: string | undefined; const texts = new Map(); From 1d8a2f1da967bb6b61e42f5513fecca1fa5ecf7f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 02:10:46 +0000 Subject: [PATCH 3/8] fix(opencode): show Default when no variant is chosen An untouched thread sends no variant, which is the catalog's Default choice, but the picker still said "Unknown". The synthetic choice also drops the Default badge it would otherwise repeat next to its label. Co-Authored-By: Claude Opus 5.5 --- packages/provider-opencode/src/server/status.test.ts | 4 ++-- packages/provider-opencode/src/server/status.ts | 2 +- packages/shared/src/model.test.ts | 11 +++++++++++ packages/shared/src/model.ts | 8 +++++++- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/provider-opencode/src/server/status.test.ts b/packages/provider-opencode/src/server/status.test.ts index 7f2d43264bfc..719acd4eacb6 100644 --- a/packages/provider-opencode/src/server/status.test.ts +++ b/packages/provider-opencode/src/server/status.test.ts @@ -658,8 +658,8 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { NodeAssert.ok(variant?.type === "select"); NodeAssert.deepEqual( - variant.options.find((option) => option.isDefault === true), - { id: "default", label: "Default", isDefault: true }, + variant.options.find((option) => option.id === "default"), + { id: "default", label: "Default" }, "OpenCode cannot infer a default variant, so the picker needs a choice that sends none", ); NodeAssert.equal(variant.currentValue, "default"); diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts index a959d44ed7c1..e02f8960abf7 100644 --- a/packages/provider-opencode/src/server/status.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -217,7 +217,7 @@ function openCodeVariantDescriptors( options: ids.map((id) => ({ id, label: titleCaseSlug(id), - ...(id === defaultVariant ? { isDefault: true } : {}), + ...(id === inferred ? { isDefault: true } : {}), })), currentValue: defaultVariant, }, diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 396d4d9c86c2..63bb7246ca5b 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -8,6 +8,7 @@ import { createModelCapabilities, createModelSelection, getProviderOptionCurrentLabel, + getProviderOptionCurrentValue, getProviderOptionDescriptors, readCustomModelEntries, modelSelectionsEqual, @@ -193,6 +194,16 @@ describe("provider-reported option display", () => { ).toBe("Unknown"); }); + it("shows the catalog's Default choice while no variant is chosen", () => { + const withDefault = { + ...descriptor, + options: [{ id: "default", label: "Default" }, ...descriptor.options], + currentValue: "default", + }; + expect(getProviderOptionCurrentValue(withDefault, selection)).toBe("default"); + expect(getProviderOptionCurrentLabel(withDefault, selection)).toBe("Default"); + }); + it.each([ { ...selection, model: "other" }, { ...selection, instanceId: ProviderInstanceId.make("other") }, diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 51cc1430321d..fa78c3351950 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -250,7 +250,13 @@ export function getProviderOptionCurrentValue( // Reported values are display-only; callers that build dispatch options omit this context. const reportedValue = getReportedOptionValue(descriptor.id, selection, reportedSelection); if (reportedValue !== undefined) return reportedValue; - if (descriptor.id === "variant" && selection && !hasExplicitOption) return undefined; + // Without an explicit variant none is sent, which is the catalog's "default" choice when offered. + if (descriptor.id === "variant" && selection && !hasExplicitOption) { + return descriptor.type === "select" && + descriptor.options.some((option) => option.id === "default") + ? "default" + : undefined; + } if (descriptor.type === "boolean") { return descriptor.currentValue; } From bbc82578483a6fe3b45262f1bde9ff78d8a95ef8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 02:17:00 +0000 Subject: [PATCH 4/8] fix(opencode): keep a variant named default distinct from the Default choice OpenCode resolves "default" through a model's variant map, so a custom model can define it. The choice that sends no variant now uses its own id, and a variant literally named default is forwarded as before. Co-Authored-By: Claude Opus 5.5 --- .../src/server/OpenCodeRuntime.ts | 10 ++++---- .../src/server/adapter.test.ts | 3 ++- .../src/server/status.test.ts | 11 +++++---- .../provider-opencode/src/server/status.ts | 24 +++++++++---------- .../src/server/textGeneration.test.ts | 21 +++++++++------- packages/shared/src/model.test.ts | 9 +++---- packages/shared/src/model.ts | 21 ++++++++++------ 7 files changed, 56 insertions(+), 43 deletions(-) diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.ts index 49fd9de56b7f..64f3e6b99e52 100644 --- a/packages/provider-opencode/src/server/OpenCodeRuntime.ts +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.ts @@ -37,7 +37,10 @@ import { collectStreamAsString } from "@t3tools/provider-core/server/snapshotPro import * as NetService from "@t3tools/shared/Net"; import { AgentScope } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { + DEFAULT_VARIANT_CHOICE_ID, + getModelSelectionStringOptionValue, +} from "@t3tools/shared/model"; import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); @@ -305,12 +308,9 @@ export function parseOpenCodeModelSlug( }; } -/** The picker's choice for models with no inferable default; OpenCode then uses the model's own. */ -export const OPENCODE_DEFAULT_VARIANT = "default"; - export function openCodeVariant(selection: ModelSelection): string | undefined { const variant = getModelSelectionStringOptionValue(selection, "variant"); - return variant === OPENCODE_DEFAULT_VARIANT ? undefined : variant; + return variant === DEFAULT_VARIANT_CHOICE_ID ? undefined : variant; } export function openCodeQuestionId( diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index dc1ea757c7f0..1769eca9399e 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -54,6 +54,7 @@ import { reconcileOpenCodePromptAdmissionStatus, } from "./adapter.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import { DEFAULT_VARIANT_CHOICE_ID } from "@t3tools/shared/model"; const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const decodeOpenCodeSettings = Schema.decodeEffect(OpenCodeSettings); @@ -312,7 +313,7 @@ describe("OpenCodeAdapterV2", () => { }, }, {}, - [{ id: "variant", value: "default" }], + [{ id: "variant", value: DEFAULT_VARIANT_CHOICE_ID }], ); yield* harness.startTurn().pipe(Effect.forkScoped); const prompt = yield* Effect.promise(() => prompted.promise); diff --git a/packages/provider-opencode/src/server/status.test.ts b/packages/provider-opencode/src/server/status.test.ts index 719acd4eacb6..62da2263c2c9 100644 --- a/packages/provider-opencode/src/server/status.test.ts +++ b/packages/provider-opencode/src/server/status.test.ts @@ -31,6 +31,7 @@ import { OPENCODE_2_RESPONSES, replayOpenCodeServer, } from "./probeResponses.fixture.ts"; +import { DEFAULT_VARIANT_CHOICE_ID } from "@t3tools/shared/model"; const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; @@ -658,13 +659,15 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { NodeAssert.ok(variant?.type === "select"); NodeAssert.deepEqual( - variant.options.find((option) => option.id === "default"), - { id: "default", label: "Default" }, + variant.options.find((option) => option.id === DEFAULT_VARIANT_CHOICE_ID), + { id: DEFAULT_VARIANT_CHOICE_ID, label: "Default" }, "OpenCode cannot infer a default variant, so the picker needs a choice that sends none", ); - NodeAssert.equal(variant.currentValue, "default"); + NodeAssert.equal(variant.currentValue, DEFAULT_VARIANT_CHOICE_ID); NodeAssert.deepEqual( - variant.options.map((option) => option.id).filter((id) => id !== "default"), + variant.options + .map((option) => option.id) + .filter((id) => id !== DEFAULT_VARIANT_CHOICE_ID), variants, ); }), diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts index e02f8960abf7..69edc767b202 100644 --- a/packages/provider-opencode/src/server/status.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -12,7 +12,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Schedule from "effect/Schedule"; -import { createModelCapabilities } from "@t3tools/shared/model"; +import { createModelCapabilities, DEFAULT_VARIANT_CHOICE_ID } from "@t3tools/shared/model"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { buildServerProvider, @@ -202,24 +202,22 @@ function openCodeVariantDescriptors( variants: ReadonlyArray, ): Array { if (variants.length === 0) return []; - const { OPENCODE_DEFAULT_VARIANT } = OpenCodeRuntime; const inferred = inferDefaultVariant(providerID, variants); - const ids = - inferred !== undefined || variants.includes(OPENCODE_DEFAULT_VARIANT) - ? variants - : [OPENCODE_DEFAULT_VARIANT, ...variants]; - const defaultVariant = inferred ?? OPENCODE_DEFAULT_VARIANT; + const options = variants.map((id) => ({ + id, + label: titleCaseSlug(id), + ...(id === inferred ? { isDefault: true } : {}), + })); return [ { id: "variant", label: "Reasoning", type: "select", - options: ids.map((id) => ({ - id, - label: titleCaseSlug(id), - ...(id === inferred ? { isDefault: true } : {}), - })), - currentValue: defaultVariant, + options: + inferred === undefined + ? [{ id: DEFAULT_VARIANT_CHOICE_ID, label: "Default" }, ...options] + : options, + currentValue: inferred ?? DEFAULT_VARIANT_CHOICE_ID, }, ]; } diff --git a/packages/provider-opencode/src/server/textGeneration.test.ts b/packages/provider-opencode/src/server/textGeneration.test.ts index 93e9dbe5d155..5eb55b79d3de 100644 --- a/packages/provider-opencode/src/server/textGeneration.test.ts +++ b/packages/provider-opencode/src/server/textGeneration.test.ts @@ -19,6 +19,7 @@ import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; import * as OpenCodeTextGeneration from "./textGeneration.ts"; import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; +import { DEFAULT_VARIANT_CHOICE_ID } from "@t3tools/shared/model"; const runtimeMock = { state: { @@ -404,18 +405,20 @@ it.layer(layerOpenCodeTextGenerationTest)("OpenCodeTextGeneration", (it) => { ), ); - it.effect("sends no variant when the selection uses OpenCode's default variant", () => + it.effect("sends no variant for the Default choice and keeps a variant named default", () => withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { - yield* textGeneration.generateCommitMessage({ - ...DEFAULT_COMMIT_MESSAGE_INPUT, - modelSelection: { - ...DEFAULT_TEST_MODEL_SELECTION, - options: [{ id: "variant", value: "default" }], - }, - }); + for (const value of [DEFAULT_VARIANT_CHOICE_ID, "default"]) { + yield* textGeneration.generateCommitMessage({ + ...DEFAULT_COMMIT_MESSAGE_INPUT, + modelSelection: { + ...DEFAULT_TEST_MODEL_SELECTION, + options: [{ id: "variant", value }], + }, + }); + } - expect(runtimeMock.state.promptVariants).toEqual([undefined]); + expect(runtimeMock.state.promptVariants).toEqual([undefined, "default"]); }), ), ); diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 63bb7246ca5b..4f5c6943c6ad 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -7,6 +7,7 @@ import { buildProviderOptionSelectionsFromDescriptors, createModelCapabilities, createModelSelection, + DEFAULT_VARIANT_CHOICE_ID, getProviderOptionCurrentLabel, getProviderOptionCurrentValue, getProviderOptionDescriptors, @@ -197,11 +198,11 @@ describe("provider-reported option display", () => { it("shows the catalog's Default choice while no variant is chosen", () => { const withDefault = { ...descriptor, - options: [{ id: "default", label: "Default" }, ...descriptor.options], - currentValue: "default", + options: [{ id: DEFAULT_VARIANT_CHOICE_ID, label: "Default" }, ...descriptor.options], + currentValue: DEFAULT_VARIANT_CHOICE_ID, }; - expect(getProviderOptionCurrentValue(withDefault, selection)).toBe("default"); - expect(getProviderOptionCurrentLabel(withDefault, selection)).toBe("Default"); + expect(getProviderOptionCurrentValue(withDefault, selection)).toBe(DEFAULT_VARIANT_CHOICE_ID); + expect(getProviderOptionCurrentLabel(withDefault, selection, reported)).toBe("Default"); }); it.each([ diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index fa78c3351950..9657e8c5a1f1 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -238,6 +238,9 @@ export function getProviderOptionStatedValue( ); } +/** The variant choice that sends no variant; distinct from any variant name a provider defines. */ +export const DEFAULT_VARIANT_CHOICE_ID = "t3:default"; + export function getProviderOptionCurrentValue( descriptor: ProviderOptionDescriptor | null | undefined, selection?: ModelSelection | null, @@ -247,16 +250,20 @@ export function getProviderOptionCurrentValue( return undefined; } const hasExplicitOption = selection?.options?.some((option) => option.id === descriptor.id); + // Without an explicit variant none is sent, which is exactly the catalog's Default choice. + if ( + descriptor.type === "select" && + descriptor.id === "variant" && + selection && + !hasExplicitOption && + descriptor.options.some((option) => option.id === DEFAULT_VARIANT_CHOICE_ID) + ) { + return DEFAULT_VARIANT_CHOICE_ID; + } // Reported values are display-only; callers that build dispatch options omit this context. const reportedValue = getReportedOptionValue(descriptor.id, selection, reportedSelection); if (reportedValue !== undefined) return reportedValue; - // Without an explicit variant none is sent, which is the catalog's "default" choice when offered. - if (descriptor.id === "variant" && selection && !hasExplicitOption) { - return descriptor.type === "select" && - descriptor.options.some((option) => option.id === "default") - ? "default" - : undefined; - } + if (descriptor.id === "variant" && selection && !hasExplicitOption) return undefined; if (descriptor.type === "boolean") { return descriptor.currentValue; } From 535fbb444a6603083646b54a1f5e4112de16c640 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 02:23:41 +0000 Subject: [PATCH 5/8] fix(web): subagent tooltip omits the Default reasoning choice Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/chat/SubagentTooltipContent.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/SubagentTooltipContent.tsx b/apps/web/src/components/chat/SubagentTooltipContent.tsx index a965e89efd72..6637a298c155 100644 --- a/apps/web/src/components/chat/SubagentTooltipContent.tsx +++ b/apps/web/src/components/chat/SubagentTooltipContent.tsx @@ -12,7 +12,11 @@ import { subagentDetailPreview, } from "@t3tools/client-runtime/state/subagent-display"; import { REASONING_EFFORT_OPTION_IDS } from "@t3tools/client-runtime/state/thread-execution"; -import { getModelSelectionStringOptionValue, resolveSelectableModel } from "@t3tools/shared/model"; +import { + DEFAULT_VARIANT_CHOICE_ID, + getModelSelectionStringOptionValue, + resolveSelectableModel, +} from "@t3tools/shared/model"; import { getTraitsSpeedDisplay, TraitsSpeedIcon } from "./TraitsSpeed"; import type { ReactNode } from "react"; import { @@ -74,7 +78,7 @@ export function SubagentTooltipContent(props: { : undefined; const effort = REASONING_EFFORT_OPTION_IDS.map((id) => getModelSelectionStringOptionValue(matchingSelection, id), - ).find(Boolean); + ).find((value) => value && value !== DEFAULT_VARIANT_CHOICE_ID); const speed = provider ? providerModel?.capabilities?.optionDescriptors ?.map((descriptor) => { From 418286fad320abfb5d0da8ec71f2929699db6235 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 00:34:38 +0000 Subject: [PATCH 6/8] fix(opencode): a reported variant outranks the Default choice Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/model.test.ts | 8 +++++++- packages/shared/src/model.ts | 6 +++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 30d6f88a28b9..8c6cc3d9ea24 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -228,7 +228,13 @@ describe("provider-reported option display", () => { currentValue: DEFAULT_VARIANT_CHOICE_ID, }; expect(getProviderOptionCurrentValue(withDefault, selection)).toBe(DEFAULT_VARIANT_CHOICE_ID); - expect(getProviderOptionCurrentLabel(withDefault, selection, reported)).toBe("Default"); + expect(getProviderOptionCurrentLabel(withDefault, selection)).toBe("Default"); + expect( + getProviderOptionCurrentLabel(withDefault, selection, { + ...reported, + options: [{ id: "variant", value: "thinking" }], + }), + ).toBe("Thinking"); }); it.each([ diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 610a03fce04c..26f1537e2170 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -278,6 +278,9 @@ export function getProviderOptionCurrentValue( return undefined; } const hasExplicitOption = selection?.options?.some((option) => option.id === descriptor.id); + // Reported values are display-only; callers that build dispatch options omit this context. + const reportedValue = getReportedOptionValue(descriptor.id, selection, reportedSelection); + if (reportedValue !== undefined) return reportedValue; // Without an explicit variant none is sent, which is exactly the catalog's Default choice. if ( descriptor.type === "select" && @@ -288,9 +291,6 @@ export function getProviderOptionCurrentValue( ) { return DEFAULT_VARIANT_CHOICE_ID; } - // Reported values are display-only; callers that build dispatch options omit this context. - const reportedValue = getReportedOptionValue(descriptor.id, selection, reportedSelection); - if (reportedValue !== undefined) return reportedValue; if (descriptor.id === "variant" && selection && !hasExplicitOption) return undefined; if (descriptor.type === "boolean") { return descriptor.currentValue; From c3ef908595cc84256002b2dbac8f28ac3d37b893 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:04:46 +0000 Subject: [PATCH 7/8] test(opencode): cover the Default variant once, at text generation AI-assisted; verified with the tests and tools listed above. --- .../src/server/adapter.test.ts | 34 ------------------- 1 file changed, 34 deletions(-) diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 5becda9df0f7..b2460ff90a59 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -55,7 +55,6 @@ import { reconcileOpenCodePromptAdmissionStatus, } from "./adapter.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; -import { DEFAULT_VARIANT_CHOICE_ID } from "@t3tools/shared/model"; const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const decodeOpenCodeSettings = Schema.decodeEffect(OpenCodeSettings); @@ -365,39 +364,6 @@ describe("OpenCodeAdapterV2", () => { ), ); - it.effect("sends no variant when the turn selects OpenCode's default variant", () => - Effect.gen(function* () { - const prompted = promiseGate<{ readonly variant?: string }>(); - const harness = yield* makeOpenCodeRuntimeHarness( - "default-variant", - "root", - { - event: { subscribe: async () => ({ stream: asyncEventStream().stream }) }, - session: { - create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), - promptAsync: async (input: { readonly variant?: string }) => { - prompted.resolve(input); - return { data: true }; - }, - abort: async () => ({ data: true }), - children: async () => ({ data: [] }), - }, - }, - {}, - { options: [{ id: "variant", value: DEFAULT_VARIANT_CHOICE_ID }] }, - ); - yield* harness.startTurn().pipe(Effect.forkScoped); - const prompt = yield* Effect.promise(() => prompted.promise); - assert.isFalse( - "variant" in prompt, - `OpenCode received variant ${JSON.stringify(prompt.variant)} for the Default choice`, - ); - }).pipe( - Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), - Effect.scoped, - ), - ); - it.effect("applies a changed runtime policy to the session on resume and turn start", () => Effect.gen(function* () { const updates: Array<{ sessionID: string; permission: unknown }> = []; From 6de63a97f871847e95f9ecc6af0504d37a7595f0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 20:12:49 +0000 Subject: [PATCH 8/8] fix(mobile): subagent effort omits the Default reasoning choice The catalog-less fallback in formatSubagentEffort showed the raw Default sentinel, matching the web tooltip fix. Also note why OpenCode 2 treats a missing variant as "default" when deciding whether to switch models. AI-assisted; verified with the tests and tools listed above. --- packages/client-runtime/src/state/threadExecution.ts | 3 ++- packages/provider-opencode/src/server/v2/adapter.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index 44600ffa259e..f0ba4fb30dd2 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -28,6 +28,7 @@ import { derivePendingBackgroundWork, } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { + DEFAULT_VARIANT_CHOICE_ID, getModelSelectionStringOptionValue, getProviderOptionCurrentLabel, getProviderOptionStatedValue, @@ -249,7 +250,7 @@ export function formatSubagentEffort(input: { } return ( REASONING_EFFORT_OPTION_IDS.map((id) => getModelSelectionStringOptionValue(selection, id)).find( - Boolean, + (value) => value && value !== DEFAULT_VARIANT_CHOICE_ID, ) ?? null ); } diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index a3ae7dee4f1c..abf43cecfd3f 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -666,6 +666,7 @@ const modelRef = (selection: ProviderAdapter.ProviderAdapterV2TurnInput["modelSe }; const malformedModel = (model: string) => `OpenCode model '${model}' must use provider/model format`; +// OpenCode 2 reports a session created without a variant as variant "default". const sameModel = (left: ModelRef, right: ModelRef | undefined) => left.providerID === right?.providerID && left.id === right?.id &&