diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.ts index fffda5ff823b..ec1fa4c7b318 100644 --- a/packages/provider-opencode/src/server/OpenCodeRuntime.ts +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.ts @@ -230,6 +230,44 @@ export interface ParsedOpenCodeModelSlug { readonly modelID: string; } +export type OpenCodeVariantCache = Map | undefined>; + +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 (cache?.has(key)) { + return cached !== undefined && !cached.has(variant) ? undefined : variant; + } + + // Only a confirmed catalog mismatch can invalidate a persisted selection. + const response = yield* runOpenCodeSdk("provider.list", (signal) => + client.provider.list(undefined, { + signal: abortSignal ? AbortSignal.any([signal, abortSignal]) : signal, + }), + ).pipe( + Effect.timeout("10 seconds"), + 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); + } + return variants !== undefined && !variants.has(variant) ? undefined : 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 b2460ff90a59..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"; @@ -154,6 +155,7 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct client: object, host: Partial = {}, turnSettings: { + readonly model?: string; readonly options?: ReadonlyArray; readonly interactionMode?: ProviderAdapter.ProviderAdapterV2RuntimePolicy["interactionMode"]; } = {}, @@ -162,7 +164,7 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct const threadId = ThreadId.make(`thread-opencode-${suffix}`); const modelSelection = { instanceId, - model: "anthropic/claude-sonnet", + model: turnSettings.model ?? "anthropic/claude-sonnet", options: turnSettings.options ?? [], }; const policy = runtimePolicy("full-access", { @@ -255,6 +257,266 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct }); describe("OpenCodeAdapterV2", () => { + it.effect.each([ + ["empty", "prompt"], + ["unsupported", "command"], + ["supported", "command"], + ["unknown", "prompt"], + ["failure", "prompt"], + ["unset", "prompt"], + ] as const)("normalizes reasoning variants for %s inventory at %s dispatch", ([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", + 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("reuses successful model variants for the session 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: {} }; + 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, + ), + ); + it.effect.each([ { message: "", diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index a1b5ccb008eb..9858d81dd1de 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -1002,6 +1002,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(); @@ -2976,12 +2977,24 @@ 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, ) { + const { variant, ...payloadWithoutVariant } = inputPayload; + const selectedVariant = yield* OpenCodeRuntime.resolveOpenCodeVariant( + client, + inputPayload.model, + variant, + abortController.signal, + variantCache, + ); + const payload = { + ...payloadWithoutVariant, + ...(selectedVariant === undefined ? {} : { variant: selectedVariant }), + }; const text = payload.parts ?.filter((part) => part.type === "text") .map((part) => part.text) @@ -3394,8 +3407,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; @@ -3430,7 +3455,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); diff --git a/packages/provider-opencode/src/server/status.test.ts b/packages/provider-opencode/src/server/status.test.ts index b6560a2f4a18..c8d324f06012 100644 --- a/packages/provider-opencode/src/server/status.test.ts +++ b/packages/provider-opencode/src/server/status.test.ts @@ -15,6 +15,7 @@ import * as Tracer from "effect/Tracer"; 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 { settingsSecretLiteral } from "@t3tools/contracts"; import { OpenCodeSettings } from "../settings.ts"; @@ -684,6 +685,52 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { }), ); + 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()); + 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, [ + { + 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 0afd0dde1633..0ba2f84ceb89 100644 --- a/packages/provider-opencode/src/server/status.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -234,9 +234,7 @@ function openCodeCapabilitiesForModel(input: { readonly model: ProviderListResponse["all"][number]["models"][string]; readonly agents: ReadonlyArray; }): ModelCapabilities { - const rawVariantValues = Object.keys(input.model.variants ?? {}); - 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 diff --git a/packages/provider-opencode/src/server/textGeneration.test.ts b/packages/provider-opencode/src/server/textGeneration.test.ts index c877bfdd4eab..b29e2fb9f86d 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, settingsSecretLiteral, 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 Cause from "effect/Cause"; @@ -25,6 +26,7 @@ const runtimeMock = { startCalls: [] as string[], promptUrls: [] as string[], promptParts: [] as ReadonlyArray[], + promptInputs: [] as Array[0]>, authHeaders: [] as Array, closeCalls: [] as string[], sessionCreateCalls: 0, @@ -46,6 +48,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; @@ -106,6 +109,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 (_input: unknown, options?: { readonly signal?: AbortSignal }) => { runtimeMock.state.sessionCreateCalls += 1; @@ -130,11 +140,12 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = { return { data: true }; }, prompt: async ( - input: { readonly parts: ReadonlyArray }, + input: Parameters[0], options?: { readonly signal?: AbortSignal }, ) => { + 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, ); @@ -250,6 +261,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("cancels or times out an in-flight session creation before prompting", () => withOpenCodeTextGeneration(EXISTING_SERVER_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 fe63a3688653..bbb02d36e632 100644 --- a/packages/provider-opencode/src/server/textGeneration.ts +++ b/packages/provider-opencode/src/server/textGeneration.ts @@ -240,7 +240,11 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" ).pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: "Warn" })), ); 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,