From 0baf2398db5adcd18de3b848fa39c624c4bf345f Mon Sep 17 00:00:00 2001 From: Abdul Alian Date: Tue, 13 Oct 2026 10:41:05 +0100 Subject: [PATCH 1/6] fix: show only advertised OpenCode reasoning variants --- .../src/server/status.test.ts | 50 +++++++++++++++++++ .../provider-opencode/src/server/status.ts | 9 +--- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/packages/provider-opencode/src/server/status.test.ts b/packages/provider-opencode/src/server/status.test.ts index 0ed3d648641e..5603edb33169 100644 --- a/packages/provider-opencode/src/server/status.test.ts +++ b/packages/provider-opencode/src/server/status.test.ts @@ -12,6 +12,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { beforeEach } from "vite-plus/test"; +import { buildProviderOptionSelectionsFromDescriptors } from "@t3tools/shared/model"; import { OpenCodeSettings } from "../settings.ts"; import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; @@ -456,6 +457,55 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { }), ); + it.effect.each(["empty", "absent"] as const)( + "omits Reasoning when model variants are %s", + (kind) => + Effect.gen(function* () { + runtimeMock.state.inventory = { + providerList: { + connected: ["opencode"], + all: [ + { + id: "opencode", + name: "OpenCode Zen", + models: { + "big-pickle": { + id: "big-pickle", + name: "Big Pickle", + ...(kind === "empty" ? { variants: {} } : {}), + }, + }, + }, + ], + default: {}, + }, + agents: [ + { name: "build", hidden: false, mode: "primary" }, + { name: "plan", hidden: false, mode: "primary" }, + ], + }; + const snapshot = yield* checkProvider(makeOpenCodeSettings()); + const model = snapshot.models.find((entry) => entry.slug === "opencode/big-pickle"); + NodeAssert.ok(model); + const descriptors = model.capabilities?.optionDescriptors; + NodeAssert.deepEqual(descriptors, [ + { + id: "agent", + label: "Agent", + type: "select", + options: [ + { id: "build", label: "Build", isDefault: true }, + { id: "plan", label: "Plan" }, + ], + currentValue: "build", + }, + ]); + NodeAssert.deepEqual(buildProviderOptionSelectionsFromDescriptors(descriptors), [ + { id: "agent", value: "build" }, + ]); + }), + ); + it.effect("includes OpenCode skills in the provider snapshot", () => Effect.gen(function* () { runtimeMock.state.inventory = { diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts index cb0892c5d9d6..b4a1cd7094fd 100644 --- a/packages/provider-opencode/src/server/status.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -199,14 +199,7 @@ function openCodeCapabilitiesForModel(input: { readonly model: ProviderListResponse["all"][number]["models"][string]; readonly agents: ReadonlyArray; }): ModelCapabilities { - const rawVariantValues = Object.keys(input.model.variants ?? {}); - // When a model advertises no variants, synthesize the standard reasoning - // levels so the composer still offers a Reasoning selector (mirrors the - // Codex/Grok experience where reasoning is always configurable). The set - // covers the common OpenCode variant spectrum; `inferDefaultVariant` - // picks the provider-appropriate default (e.g. medium for openai/opencode). - const variantValues = - rawVariantValues.length > 0 ? rawVariantValues : ["low", "medium", "high", "xhigh"]; + const variantValues = Object.keys(input.model.variants ?? {}); const defaultVariant = inferDefaultVariant(input.providerID, variantValues); const variantOptions = variantValues.map((value) => defaultVariant === value From 473149e5c770755084062fed07ebec48ea6a5401 Mon Sep 17 00:00:00 2001 From: Abdul Alian Date: Tue, 13 Oct 2026 11:03:43 +0100 Subject: [PATCH 2/6] fix: strip confirmed unsupported OpenCode variants before dispatch --- .../src/server/adapter.test.ts | 107 +++++++++++++++++- .../provider-opencode/src/server/adapter.ts | 27 ++++- 2 files changed, 131 insertions(+), 3 deletions(-) diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index c2ec42bd16b5..ecf2a499edf1 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -15,6 +15,7 @@ import { ThreadId, type OrchestrationV2ProviderThread, type OrchestrationV2ProviderTurn, + type ModelSelection, } from "@t3tools/contracts"; import { OpenCodeSettings } from "../settings.ts"; import * as Effect from "effect/Effect"; @@ -148,13 +149,16 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct suffix: string, nativeSessionId: string, client: object, + selection: Pick = { + model: "anthropic/claude-sonnet", + options: [], + }, ) { const instanceId = ProviderInstanceId.make(`opencode-${suffix}`); const threadId = ThreadId.make(`thread-opencode-${suffix}`); const modelSelection = { instanceId, - model: "anthropic/claude-sonnet", - options: [], + ...selection, }; const policy = runtimePolicy("full-access", { cwd: "/workspace" }); const adapter = yield* makeOpenCodeAdapterV2({ @@ -241,6 +245,105 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct }); describe("OpenCodeAdapterV2", () => { + it.effect.each([ + ["empty", "prompt"], + ["absent", "prompt"], + ["unsupported", "prompt"], + ["supported", "prompt"], + ["unknown", "prompt"], + ["failure", "prompt"], + ["unset", "prompt"], + ["empty", "command"], + ["absent", "command"], + ["unsupported", "command"], + ["supported", "command"], + ["unknown", "command"], + ["failure", "command"], + ["unset", "command"], + ] as const)("normalizes reasoning variants for %j", ([kind, request]) => + Effect.gen(function* () { + const suffix = `variant-${kind}-${request}`; + const nativeEvents = asyncEventStream(); + const inputs: Array> = []; + let inventoryCalls = 0; + const receive = async (input: Record) => { + inputs.push(input); + return { data: true }; + }; + const harness = yield* makeOpenCodeRuntimeHarness( + suffix, + suffix, + { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + provider: { + list: async () => { + inventoryCalls++; + if (kind === "failure") throw new Error("inventory unavailable"); + return { + data: { + all: [ + { + id: "opencode", + models: + kind === "unknown" + ? {} + : { + "big-pickle": { + id: "big-pickle", + ...(kind === "absent" + ? {} + : { + variants: + kind === "supported" + ? { medium: {} } + : kind === "unsupported" + ? { high: {} } + : {}, + }), + }, + }, + }, + ], + }, + }; + }, + }, + command: { list: async () => ({ data: [{ name: "review" }] }) }, + session: { + create: async () => ({ data: { id: suffix, time: { created: 1, updated: 1 } } }), + promptAsync: receive, + command: receive, + }, + }, + { + model: "opencode/big-pickle", + options: [ + ...(kind === "unset" ? [] : [{ id: "variant", value: "medium" }]), + { id: "agent", value: "plan" }, + ], + }, + ); + yield* harness.startTurn(request === "command" ? "/review staged changes" : "hello"); + assert.equal(inputs.length, 1); + const payload = inputs[0]!; + assert.equal(payload.agent, "plan"); + if (kind === "supported" || kind === "unknown" || kind === "failure") { + assert.equal(payload.variant, "medium"); + } else { + assert.equal(Object.hasOwn(payload, "variant"), false); + } + if (kind === "unset") assert.equal(inventoryCalls, 0); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + it.effect.each(["completed", "failed", "unresolved", "unavailable", "reconnect"] as const)( "normalizes OpenCode step usage for %s turns", (ending) => diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index b0b7f0ad29a3..348a38b13528 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -2935,12 +2935,37 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function const submitPrompt = Effect.fn("OpenCode.submitPrompt")(function* ( state: OpenCodeThreadState, turn: ActiveOpenCodeTurn, - payload: Parameters[0] & { + inputPayload: Parameters[0] & { messageID: string; sessionID: string; }, abortController: AbortController, ) { + // Threads and drafts can retain variants after the catalog stops offering them. + // Filter only a confirmed mismatch; unavailable inventories and custom models + // keep their explicit selection rather than silently changing reasoning. + const { variant, ...payloadWithoutVariant } = inputPayload; + const selectedModel = inputPayload.model; + const variantSupported = + variant && selectedModel + ? yield* sdkCall("provider.list", undefined, (signal) => + client.provider.list(undefined, { + signal: AbortSignal.any([signal, abortController.signal]), + }), + ).pipe( + Effect.map((response) => { + const model = response.data?.all.find( + (provider) => provider.id === selectedModel.providerID, + )?.models[selectedModel.modelID]; + return model === undefined + ? undefined + : Object.hasOwn(model.variants ?? {}, variant); + }), + Effect.timeout("4 seconds"), + Effect.orElseSucceed(() => undefined), + ) + : undefined; + const payload = variantSupported === false ? payloadWithoutVariant : inputPayload; const text = payload.parts ?.filter((part) => part.type === "text") .map((part) => part.text) From ecdbe129a7c8c05145a1e0dba1f33f85f164ba6b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 02:27:01 +0000 Subject: [PATCH 3/6] fix(opencode): normalize persisted variants across dispatch paths --- .../src/server/OpenCodeRuntime.ts | 26 +++++++++ .../src/server/adapter.test.ts | 30 ++++------- .../provider-opencode/src/server/adapter.ts | 34 ++++-------- .../src/server/status.test.ts | 53 +++++++++---------- .../src/server/textGeneration.test.ts | 39 +++++++++++++- .../src/server/textGeneration.ts | 6 ++- 6 files changed, 112 insertions(+), 76 deletions(-) diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.ts index bff49126ce4d..e853d7ee9dee 100644 --- a/packages/provider-opencode/src/server/OpenCodeRuntime.ts +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.ts @@ -232,6 +232,32 @@ export interface ParsedOpenCodeModelSlug { readonly modelID: string; } +export const resolveOpenCodeVariant = Effect.fnUntraced(function* ( + client: OpencodeClient, + model: ParsedOpenCodeModelSlug | undefined, + variant: string | undefined, + abortSignal?: AbortSignal, +) { + if (!variant || !model) return variant; + + // Only a confirmed catalog mismatch can invalidate a persisted selection. + return yield* runOpenCodeSdk("provider.list", (signal) => + client.provider.list(undefined, { + signal: abortSignal ? AbortSignal.any([signal, abortSignal]) : signal, + }), + ).pipe( + Effect.map((response) => { + const catalogModel = response.data?.all.find((provider) => provider.id === model.providerID) + ?.models[model.modelID]; + return catalogModel && !Object.hasOwn(catalogModel.variants ?? {}, variant) + ? undefined + : variant; + }), + Effect.timeout("4 seconds"), + Effect.orElseSucceed(() => variant), + ); +}); + export interface OpenCodeSkill { readonly name?: string | null; readonly description?: string | null; diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index e783fa269f94..9fcb4edd9a4b 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -247,20 +247,12 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct describe("OpenCodeAdapterV2", () => { it.effect.each([ ["empty", "prompt"], - ["absent", "prompt"], - ["unsupported", "prompt"], - ["supported", "prompt"], + ["unsupported", "command"], + ["supported", "command"], ["unknown", "prompt"], ["failure", "prompt"], ["unset", "prompt"], - ["empty", "command"], - ["absent", "command"], - ["unsupported", "command"], - ["supported", "command"], - ["unknown", "command"], - ["failure", "command"], - ["unset", "command"], - ] as const)("normalizes reasoning variants for %j", ([kind, request]) => + ] as const)("normalizes reasoning variants for %s inventory at %s dispatch", ([kind, request]) => Effect.gen(function* () { const suffix = `variant-${kind}-${request}`; const nativeEvents = asyncEventStream(); @@ -295,16 +287,12 @@ describe("OpenCodeAdapterV2", () => { : { "big-pickle": { id: "big-pickle", - ...(kind === "absent" - ? {} - : { - variants: - kind === "supported" - ? { medium: {} } - : kind === "unsupported" - ? { high: {} } - : {}, - }), + variants: + kind === "supported" + ? { medium: {} } + : kind === "unsupported" + ? { high: {} } + : {}, }, }, }, diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index f1655cd74640..49be50bf8c29 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -2911,31 +2911,17 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function }, abortController: AbortController, ) { - // Threads and drafts can retain variants after the catalog stops offering them. - // Filter only a confirmed mismatch; unavailable inventories and custom models - // keep their explicit selection rather than silently changing reasoning. const { variant, ...payloadWithoutVariant } = inputPayload; - const selectedModel = inputPayload.model; - const variantSupported = - variant && selectedModel - ? yield* sdkCall("provider.list", undefined, (signal) => - client.provider.list(undefined, { - signal: AbortSignal.any([signal, abortController.signal]), - }), - ).pipe( - Effect.map((response) => { - const model = response.data?.all.find( - (provider) => provider.id === selectedModel.providerID, - )?.models[selectedModel.modelID]; - return model === undefined - ? undefined - : Object.hasOwn(model.variants ?? {}, variant); - }), - Effect.timeout("4 seconds"), - Effect.orElseSucceed(() => undefined), - ) - : undefined; - const payload = variantSupported === false ? payloadWithoutVariant : inputPayload; + const selectedVariant = yield* OpenCodeRuntime.resolveOpenCodeVariant( + client, + inputPayload.model, + variant, + abortController.signal, + ); + const payload = { + ...payloadWithoutVariant, + ...(selectedVariant === undefined ? {} : { variant: selectedVariant }), + }; const text = payload.parts ?.filter((part) => part.type === "text") .map((part) => part.text) diff --git a/packages/provider-opencode/src/server/status.test.ts b/packages/provider-opencode/src/server/status.test.ts index ef4f349b2e2e..121ecc97a13b 100644 --- a/packages/provider-opencode/src/server/status.test.ts +++ b/packages/provider-opencode/src/server/status.test.ts @@ -469,35 +469,31 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { }), ); - it.effect.each(["empty", "absent"] as const)( - "omits Reasoning when model variants are %s", - (kind) => - Effect.gen(function* () { - runtimeMock.state.inventory = { - providerList: { - connected: ["opencode"], - all: [ - { - id: "opencode", - name: "OpenCode Zen", - models: { - "big-pickle": { - id: "big-pickle", - name: "Big Pickle", - ...(kind === "empty" ? { variants: {} } : {}), - }, - }, + it.effect("keeps Agent without inventing Reasoning for models with no variants", () => + Effect.gen(function* () { + runtimeMock.state.inventory = { + providerList: { + connected: ["opencode"], + all: [ + { + id: "opencode", + name: "OpenCode Zen", + models: { + "big-pickle": { id: "big-pickle", name: "Big Pickle", variants: {} }, + "glm-5": { id: "glm-5", name: "GLM-5" }, }, - ], - default: {}, - }, - agents: [ - { name: "build", hidden: false, mode: "primary" }, - { name: "plan", hidden: false, mode: "primary" }, + }, ], - }; - const snapshot = yield* checkProvider(makeOpenCodeSettings()); - const model = snapshot.models.find((entry) => entry.slug === "opencode/big-pickle"); + default: {}, + }, + agents: [ + { name: "build", hidden: false, mode: "primary" }, + { name: "plan", hidden: false, mode: "primary" }, + ], + }; + const snapshot = yield* checkProvider(makeOpenCodeSettings()); + for (const slug of ["opencode/big-pickle", "opencode/glm-5"]) { + const model = snapshot.models.find((entry) => entry.slug === slug); NodeAssert.ok(model); const descriptors = model.capabilities?.optionDescriptors; NodeAssert.deepEqual(descriptors, [ @@ -515,7 +511,8 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { NodeAssert.deepEqual(buildProviderOptionSelectionsFromDescriptors(descriptors), [ { id: "agent", value: "build" }, ]); - }), + } + }), ); it.effect("includes OpenCode skills in the provider snapshot", () => diff --git a/packages/provider-opencode/src/server/textGeneration.test.ts b/packages/provider-opencode/src/server/textGeneration.test.ts index 0cc03755a5ce..0ff7c584aecc 100644 --- a/packages/provider-opencode/src/server/textGeneration.test.ts +++ b/packages/provider-opencode/src/server/textGeneration.test.ts @@ -1,5 +1,6 @@ import { ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; import { OpenCodeSettings } from "../settings.ts"; +import type { OpencodeClient } from "@opencode-ai/sdk/v2"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Duration from "effect/Duration"; @@ -21,6 +22,7 @@ const runtimeMock = { startCalls: [] as string[], promptUrls: [] as string[], promptParts: [] as ReadonlyArray[], + promptInputs: [] as Array[0]>, authHeaders: [] as Array, closeCalls: [] as string[], sessionCreateCalls: 0, @@ -36,6 +38,7 @@ const runtimeMock = { this.state.startCalls.length = 0; this.state.promptUrls.length = 0; this.state.promptParts.length = 0; + this.state.promptInputs.length = 0; this.state.authHeaders.length = 0; this.state.closeCalls.length = 0; this.state.sessionCreateCalls = 0; @@ -92,6 +95,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = { runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => ({ + provider: { + list: async () => ({ + data: { + all: [{ id: "openai", models: { "gpt-5": { id: "gpt-5" } } }], + }, + }), + }, session: { create: async () => { runtimeMock.state.sessionCreateCalls += 1; @@ -100,9 +110,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = { } return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } }; }, - prompt: async (input: { readonly parts: ReadonlyArray }) => { + prompt: async (input: Parameters[0]) => { + runtimeMock.state.promptInputs.push(input); runtimeMock.state.promptUrls.push(baseUrl); - runtimeMock.state.promptParts.push(input.parts); + runtimeMock.state.promptParts.push(input.parts ?? []); runtimeMock.state.authHeaders.push( serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, ); @@ -228,6 +239,30 @@ const advanceIdleClock = Effect.gen(function* () { }); it.layer(layerOpenCodeTextGenerationTest)("OpenCodeTextGeneration", (it) => { + it.effect("omits stale reasoning variants when generating commit messages", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage({ + ...DEFAULT_COMMIT_MESSAGE_INPUT, + modelSelection: { + ...DEFAULT_TEST_MODEL_SELECTION, + options: [ + { id: "variant", value: "medium" }, + { id: "agent", value: "plan" }, + ], + }, + }); + + expect(runtimeMock.state.promptInputs).toHaveLength(1); + expect(runtimeMock.state.promptInputs[0]).toMatchObject({ + model: { providerID: "openai", modelID: "gpt-5" }, + agent: "plan", + }); + expect(runtimeMock.state.promptInputs[0]).not.toHaveProperty("variant"); + }), + ), + ); + it.effect("excludes generic files from thread title generation", () => withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { diff --git a/packages/provider-opencode/src/server/textGeneration.ts b/packages/provider-opencode/src/server/textGeneration.ts index 4c1f13362b78..deef4c377ab0 100644 --- a/packages/provider-opencode/src/server/textGeneration.ts +++ b/packages/provider-opencode/src/server/textGeneration.ts @@ -205,7 +205,11 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }); } const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); - const selectedVariant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); + const selectedVariant = yield* OpenCodeRuntime.resolveOpenCodeVariant( + client, + parsedModel, + getModelSelectionStringOptionValue(input.modelSelection, "variant"), + ); const promptContext = { operation: input.operation, cwd: input.cwd, From 60049a4259790d91594e6caee105c3ed418a37f3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 02:37:38 +0000 Subject: [PATCH 4/6] perf(opencode): reuse model variant support within sessions --- .../src/server/OpenCodeRuntime.ts | 37 ++++++-- .../src/server/adapter.test.ts | 84 +++++++++++++++++++ .../provider-opencode/src/server/adapter.ts | 2 + 3 files changed, 114 insertions(+), 9 deletions(-) diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.ts index e853d7ee9dee..df95b5b5673a 100644 --- a/packages/provider-opencode/src/server/OpenCodeRuntime.ts +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.ts @@ -14,6 +14,7 @@ import { type QuestionRequest, } from "@opencode-ai/sdk/v2"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; import * as Deferred from "effect/Deferred"; @@ -232,30 +233,48 @@ export interface ParsedOpenCodeModelSlug { readonly modelID: string; } +export type OpenCodeVariantCache = Map< + string, + { readonly variants: ReadonlySet | undefined; readonly expiresAt: number } +>; + export const resolveOpenCodeVariant = Effect.fnUntraced(function* ( client: OpencodeClient, model: ParsedOpenCodeModelSlug | undefined, variant: string | undefined, abortSignal?: AbortSignal, + cache?: OpenCodeVariantCache, ) { if (!variant || !model) return variant; + const key = `${model.providerID}/${model.modelID}`; + const cached = cache?.get(key); + if (cached !== undefined) { + if (cached.expiresAt > (yield* Clock.currentTimeMillis)) { + return cached.variants !== undefined && !cached.variants.has(variant) ? undefined : variant; + } + cache?.delete(key); + } + // Only a confirmed catalog mismatch can invalidate a persisted selection. - return yield* runOpenCodeSdk("provider.list", (signal) => + const response = yield* runOpenCodeSdk("provider.list", (signal) => client.provider.list(undefined, { signal: abortSignal ? AbortSignal.any([signal, abortSignal]) : signal, }), ).pipe( - Effect.map((response) => { - const catalogModel = response.data?.all.find((provider) => provider.id === model.providerID) - ?.models[model.modelID]; - return catalogModel && !Object.hasOwn(catalogModel.variants ?? {}, variant) - ? undefined - : variant; - }), Effect.timeout("4 seconds"), - Effect.orElseSucceed(() => variant), + Effect.orElseSucceed(() => undefined), ); + if (response?.data === undefined) return variant; + + const catalogModel = response.data.all.find((provider) => provider.id === model.providerID) + ?.models[model.modelID]; + const variants = catalogModel ? new Set(Object.keys(catalogModel.variants ?? {})) : undefined; + if (cache !== undefined) { + // Keep only this model's names; provider.list can return several megabytes. + cache.set(key, { variants, expiresAt: (yield* Clock.currentTimeMillis) + 60_000 }); + } + return variants !== undefined && !variants.has(variant) ? undefined : variant; }); export interface OpenCodeSkill { diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 9fcb4edd9a4b..83aae11c2bf7 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -332,6 +332,90 @@ describe("OpenCodeAdapterV2", () => { ), ); + it.effect("reuses successful model variants until expiry and retries failed inventories", () => + Effect.gen(function* () { + const sessionId = "variant-cache"; + const nativeEvents = asyncEventStream(); + const inputs: Array> = []; + let inventoryCalls = 0; + let variants: Record = {}; + const harness = yield* makeOpenCodeRuntimeHarness( + sessionId, + sessionId, + { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + provider: { + list: async () => { + inventoryCalls++; + if (inventoryCalls === 1) throw new Error("inventory unavailable"); + return { + data: { + all: [{ id: "opencode", models: { "big-pickle": { variants } } }], + }, + }; + }, + }, + session: { + create: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }), + promptAsync: async (input: Record) => { + inputs.push(input); + return { data: true }; + }, + }, + }, + { model: "opencode/big-pickle", options: [{ id: "variant", value: "medium" }] }, + ); + const submit = Effect.fnUntraced(function* () { + yield* harness.startTurn(); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.updated", + properties: { + sessionID: sessionId, + info: { + id: inputs.at(-1)!.messageID, + sessionID: sessionId, + role: "user", + time: { created: 1 }, + }, + }, + }), + ); + yield* Effect.promise(() => + nativeEvents.push({ type: "session.idle", properties: { sessionID: sessionId } }), + ); + yield* harness.runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.take(1), + Stream.runDrain, + ); + return inputs.at(-1)!; + }); + + assert.equal((yield* submit()).variant, "medium"); + assert.equal(inventoryCalls, 1); + assert.isFalse(Object.hasOwn(yield* submit(), "variant")); + assert.equal(inventoryCalls, 2); + variants = { medium: {} }; + yield* TestClock.adjust("59 seconds"); + assert.isFalse(Object.hasOwn(yield* submit(), "variant")); + assert.equal(inventoryCalls, 2); + yield* TestClock.adjust("1 second"); + assert.equal((yield* submit()).variant, "medium"); + assert.equal(inventoryCalls, 3); + assert.equal((yield* submit()).variant, "medium"); + assert.equal(inventoryCalls, 3); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + it.effect.each(["completed", "failed", "unresolved", "unavailable", "reconnect"] as const)( "normalizes OpenCode step usage for %s turns", (ending) => diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 49be50bf8c29..b99b15cbe95d 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -990,6 +990,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function const events = yield* Queue.unbounded(); let nativeStreamFailure: OrchestrationV2ProviderFailure | null = null; const threads = new Map(); + const variantCache: OpenCodeRuntime.OpenCodeVariantCache = new Map(); const commandReceipts = new Map>(); const commandControllers = new Map>(); const pendingRequests = new Map(); @@ -2917,6 +2918,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function inputPayload.model, variant, abortController.signal, + variantCache, ); const payload = { ...payloadWithoutVariant, From 5d98740ea7b5f7b988665ef61529dcf402429868 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 02:48:59 +0000 Subject: [PATCH 5/6] fix(opencode): retain variants and serialize prompt admission --- .../src/server/OpenCodeRuntime.ts | 17 +-- .../src/server/adapter.test.ts | 104 ++++++++++++++++-- .../provider-opencode/src/server/adapter.ts | 16 ++- 3 files changed, 116 insertions(+), 21 deletions(-) diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.ts index df95b5b5673a..ad75ae02cd4b 100644 --- a/packages/provider-opencode/src/server/OpenCodeRuntime.ts +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.ts @@ -14,7 +14,6 @@ import { type QuestionRequest, } from "@opencode-ai/sdk/v2"; import * as Cause from "effect/Cause"; -import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; import * as Deferred from "effect/Deferred"; @@ -233,10 +232,7 @@ export interface ParsedOpenCodeModelSlug { readonly modelID: string; } -export type OpenCodeVariantCache = Map< - string, - { readonly variants: ReadonlySet | undefined; readonly expiresAt: number } ->; +export type OpenCodeVariantCache = Map | undefined>; export const resolveOpenCodeVariant = Effect.fnUntraced(function* ( client: OpencodeClient, @@ -249,11 +245,8 @@ export const resolveOpenCodeVariant = Effect.fnUntraced(function* ( const key = `${model.providerID}/${model.modelID}`; const cached = cache?.get(key); - if (cached !== undefined) { - if (cached.expiresAt > (yield* Clock.currentTimeMillis)) { - return cached.variants !== undefined && !cached.variants.has(variant) ? undefined : variant; - } - cache?.delete(key); + if (cache?.has(key)) { + return cached !== undefined && !cached.has(variant) ? undefined : variant; } // Only a confirmed catalog mismatch can invalidate a persisted selection. @@ -262,7 +255,7 @@ export const resolveOpenCodeVariant = Effect.fnUntraced(function* ( signal: abortSignal ? AbortSignal.any([signal, abortSignal]) : signal, }), ).pipe( - Effect.timeout("4 seconds"), + Effect.timeout("10 seconds"), Effect.orElseSucceed(() => undefined), ); if (response?.data === undefined) return variant; @@ -272,7 +265,7 @@ export const resolveOpenCodeVariant = Effect.fnUntraced(function* ( const variants = catalogModel ? new Set(Object.keys(catalogModel.variants ?? {})) : undefined; if (cache !== undefined) { // Keep only this model's names; provider.list can return several megabytes. - cache.set(key, { variants, expiresAt: (yield* Clock.currentTimeMillis) + 60_000 }); + cache.set(key, variants); } return variants !== undefined && !variants.has(variant) ? undefined : variant; }); diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 83aae11c2bf7..a56322390b33 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -332,7 +332,7 @@ describe("OpenCodeAdapterV2", () => { ), ); - it.effect("reuses successful model variants until expiry and retries failed inventories", () => + it.effect("reuses successful model variants for the session and retries failed inventories", () => Effect.gen(function* () { const sessionId = "variant-cache"; const nativeEvents = asyncEventStream(); @@ -402,14 +402,104 @@ describe("OpenCodeAdapterV2", () => { assert.isFalse(Object.hasOwn(yield* submit(), "variant")); assert.equal(inventoryCalls, 2); variants = { medium: {} }; - yield* TestClock.adjust("59 seconds"); + yield* TestClock.adjust("61 seconds"); assert.isFalse(Object.hasOwn(yield* submit(), "variant")); assert.equal(inventoryCalls, 2); - yield* TestClock.adjust("1 second"); - assert.equal((yield* submit()).variant, "medium"); - assert.equal(inventoryCalls, 3); - assert.equal((yield* submit()).variant, "medium"); - assert.equal(inventoryCalls, 3); + yield* TestClock.adjust("2 minutes"); + assert.isFalse(Object.hasOwn(yield* submit(), "variant")); + assert.equal(inventoryCalls, 2); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + + it.effect("keeps queued steers behind a cold catalog lookup and cancels them on stop", () => + Effect.gen(function* () { + const sessionId = "catalog-admission"; + const nativeEvents = asyncEventStream(); + const catalogStarted = promiseGate(); + const calls: string[] = []; + const harness = yield* makeOpenCodeRuntimeHarness( + sessionId, + sessionId, + { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + provider: { + list: async (_input: unknown, { signal }: { signal: AbortSignal }) => { + calls.push("catalog"); + catalogStarted.resolve(); + await new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + calls.push("catalog-aborted"); + reject(signal.reason); + }, + { once: true }, + ); + }); + return { data: { all: [] } }; + }, + }, + session: { + create: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }), + get: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }), + messages: async () => ({ data: [] }), + children: async () => ({ data: [] }), + promptAsync: async (_input: unknown, { signal }: { signal: AbortSignal }) => { + signal.throwIfAborted(); + calls.push("prompt"); + return { data: true }; + }, + abort: async () => { + calls.push("abort"); + return { data: true }; + }, + }, + }, + { model: "opencode/big-pickle", options: [{ id: "variant", value: "medium" }] }, + ); + const start = yield* harness.startTurn().pipe(Effect.forkScoped); + yield* Effect.promise(() => catalogStarted.promise); + const snapshot = yield* harness.runtime.readThreadSnapshot({ + providerThread: harness.providerThread, + }); + const activeTurn = snapshot.providerTurns.at(-1)!; + const steer = (message: string) => + harness.runtime + .steerTurn({ + threadId: harness.threadId, + runId: harness.runId, + providerThread: harness.providerThread, + providerTurnId: activeTurn.id, + message: { + messageId: MessageId.make(message), + text: message, + attachments: [], + createdBy: "user", + creationSource: "web", + }, + }) + .pipe(Effect.exit, Effect.forkScoped({ startImmediately: true })); + const firstSteer = yield* steer("first follow-up"); + const secondSteer = yield* steer("second follow-up"); + yield* TestClock.adjust("5 seconds"); + assert.deepEqual(calls, ["catalog"]); + + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: activeTurn.id, + }); + yield* Fiber.join(start); + assert.isTrue(Exit.isFailure(yield* Fiber.join(firstSteer))); + assert.isTrue(Exit.isFailure(yield* Fiber.join(secondSteer))); + assert.deepEqual(calls, ["catalog", "catalog-aborted", "abort"]); }).pipe( Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), Effect.scoped, diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index b99b15cbe95d..a511cd9242cf 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -3338,8 +3338,20 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function ...(text.length === 0 ? [] : [{ type: "text" as const, text }]), ...files, ]; + const admissionMessageId = yield* makeOpenCodeMessageId(messageIdClock); + while (turn.admissionAbortController !== null) { + const pendingAdmission = turn.admissionSettled; + yield* Deferred.await(pendingAdmission); + // Another waiting steer may have claimed admission first. + if (turn.admissionSettled === pendingAdmission) break; + } + if (state.activeTurn !== turn || turn.finalized || turn.interrupted) { + return yield* protocolError( + `OpenCode turn ${steerInput.providerTurnId} is not active`, + ); + } turn.admissionGeneration = state.nextAdmissionGeneration++; - turn.admissionMessageId = yield* makeOpenCodeMessageId(messageIdClock); + turn.admissionMessageId = admissionMessageId; turn.admissionPending = true; turn.admissionAccepted = false; turn.admissionMessageObserved = false; @@ -3372,7 +3384,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function ]).pipe(Effect.asVoid), ), ); - if (state.activeTurn === turn && !turn.finalized) { + if (state.activeTurn === turn && !turn.finalized && !turn.interrupted) { const admissionAction = advanceOpenCodePromptAdmission(turn, "accepted"); if (admissionAction === "reconcile-idle") { yield* reconcilePromptAdmission(state, turn); From 54cb63538dcfdf195decbb59e4b78610a091428e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 06:26:12 +0000 Subject: [PATCH 6/6] test(opencode): fit variant tests to the current adapter harness AI-assisted; verified with the tests and tools listed above. --- packages/provider-opencode/src/server/adapter.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 19d66879eeee..4a9cf5030a88 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -35,6 +35,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";