diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts index 9a0e696e32..c3231aa469 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts @@ -1,4 +1,4 @@ -import type { SessionMessageInfo } from "@opencode/client"; +import type { ModelInfo, SessionMessageInfo } from "@opencode/client"; import { describe, expect, test } from "vitest"; import { createTestLogger } from "../../../../../test-utils/test-logger.js"; @@ -535,4 +535,85 @@ describe("OpenCode v2 session lifecycle", () => { await session.close(); } }); + + test("switching to a model without the current variant sends the next prompt without it", async () => { + const harness = offeringModels({ + "opencode/space-bunny-free": ["low", "medium", "high"], + "opencode/longcat-2.5-preview-free": [], + "opencode/muse-spark-free": ["minimal", "medium"], + }); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ + provider: "opencode", + cwd: "/tmp/project", + model: "opencode/space-bunny-free", + thinkingOptionId: "medium", + }); + const thinkingChanges: Array = []; + session.subscribe((event) => { + if (event.type === "thinking_option_changed") thinkingChanges.push(event.thinkingOptionId); + }); + try { + await session.setModel!("opencode/muse-spark-free"); + expect(await session.getRuntimeInfo!()).toMatchObject({ thinkingOptionId: "medium" }); + + await session.setModel!("opencode/longcat-2.5-preview-free"); + await session.run("hello"); + expect(harness.prompts).toEqual(["hello"]); + expect(await session.getRuntimeInfo!()).toMatchObject({ + model: "opencode/longcat-2.5-preview-free", + thinkingOptionId: null, + }); + expect(thinkingChanges).toEqual([null]); + } finally { + await session.close(); + } + }); }); + +// Behaves like an OpenCode v2 server: switching to any listed model succeeds, +// and a prompt fails when the session's variant is not one its model offers. +function offeringModels(variantsByModel: Record): V2Harness { + const harness = new V2Harness(); + const catalog: ModelInfo[] = Object.entries(variantsByModel).map(([ref, variants]) => { + const [providerID, id] = ref.split("/"); + return { + id, + modelID: id, + providerID, + name: id, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + variants: variants.map((variant) => ({ id: variant })), + time: { released: 0 }, + cost: [], + status: "active", + enabled: true, + limit: { context: 1000, output: 1000 }, + }; + }); + harness.api.model.list = async (input) => ({ + location: { directory: input?.location?.directory ?? harness.info.location.directory }, + data: catalog, + }); + harness.api.session.create = async (input = {}) => { + harness.creates.push(input); + harness.info.model = input.model; + return harness.info; + }; + harness.api.session.switchModel = async ({ model }) => { + harness.info.model = model; + }; + harness.prompt = async (input) => { + const model = harness.info.model; + const offered = catalog.find( + (entry) => entry.providerID === model?.providerID && entry.id === model?.id, + ); + if (model?.variant && !offered?.variants.some((variant) => variant.id === model.variant)) + throw new Error(`Variant unavailable for ${model.providerID}/${model.id}: ${model.variant}`); + harness.prompts.push(input.text); + }; + return harness; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts index 315e75410c..f3d6febe2c 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -3,7 +3,7 @@ import { SessionTurns } from "./turns.js"; import { V2Timeline } from "./timeline.js"; import { waitForLocationReady, awaitPaseoPlugin } from "./readiness.js"; -import type { SessionInfo, SessionMessageInfo } from "@opencode/client"; +import type { ModelRef, SessionInfo, SessionMessageInfo } from "@opencode/client"; import { setTimeout as delay } from "node:timers/promises"; import type { Logger } from "pino"; @@ -277,24 +277,35 @@ export class OpenCodeV2Session implements AgentSession { async setModel(model: string | null) { await this.reconnectIfExited(); const selected = model - ? modelRef(model, this.config.thinkingOptionId) + ? modelRef(model) : (await this.client.model.default({ location: { directory: this.config.cwd } })).data; if (!selected) throw new Error("OpenCode has no default model"); + // OpenCode rejects a prompt whose variant the model does not offer, so the + // current variant only carries over to a model that has it. + const variant = await this.offeredVariant(selected, this.config.thinkingOptionId); await this.client.session.switchModel({ sessionID: this.id, - model: { - id: selected.id, - providerID: selected.providerID, - variant: this.config.thinkingOptionId, - }, + model: { id: selected.id, providerID: selected.providerID, variant }, }); this.config.model = model ?? undefined; + if (variant !== this.config.thinkingOptionId) { + this.config.thinkingOptionId = variant; + this.emit({ type: "thinking_option_changed", provider: "opencode", thinkingOptionId: null }); + } this.emit({ type: "model_changed", provider: "opencode", runtimeInfo: await this.getRuntimeInfo(), }); } + private async offeredVariant(model: ModelRef, variant: string | undefined) { + if (!variant) return undefined; + const { data } = await this.client.model.list({ location: { directory: this.config.cwd } }); + const offered = data.find( + (entry) => entry.providerID === model.providerID && entry.id === model.id, + ); + return offered?.variants.some((entry) => entry.id === variant) ? variant : undefined; + } async setThinkingOption(variant: string | null) { await this.reconnectIfExited(); const model = this.info.model;