diff --git a/packages/opencode/src/acp-next/config-option.ts b/packages/opencode/src/acp-next/config-option.ts new file mode 100644 index 0000000000..b730ae0753 --- /dev/null +++ b/packages/opencode/src/acp-next/config-option.ts @@ -0,0 +1,203 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk" + +export const DEFAULT_VARIANT_VALUE = "default" + +export type ConfigOptionModel = { + id: string + name: string + variants?: Record> +} + +export type ConfigOptionProvider = { + id: string + name: string + models: Record +} + +export type ConfigOptionMode = { + id: string + name: string + description?: string +} + +export type ModelSelection = { + model: { + providerID: string + modelID: string + } + variant?: string +} + +export function buildModelSelectOption(input: { + providers: readonly ConfigOptionProvider[] + currentModel: ModelSelection["model"] + currentVariant?: string + includeVariants?: boolean +}): SessionConfigOption { + return { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: formatCurrentModelId({ + model: input.currentModel, + variant: input.currentVariant, + variants: variantsForModel(input.providers, input.currentModel), + includeVariant: input.includeVariants ?? false, + }), + options: buildModelSelectOptions(input.providers, { includeVariants: input.includeVariants ?? false }), + } +} + +export function buildEffortSelectOption(input: { + variants: readonly string[] + currentVariant?: string +}): SessionConfigOption | undefined { + if (input.variants.length === 0) return undefined + + return { + id: "effort", + name: "Effort", + description: "Available effort levels for this model", + category: "thought_level", + type: "select", + currentValue: selectVariant(input.currentVariant, input.variants), + options: input.variants.map((variant) => ({ + value: variant, + name: formatVariantName(variant), + })), + } +} + +export function buildModeSelectOption(input: { + modes: readonly ConfigOptionMode[] + currentModeId: string +}): SessionConfigOption { + return { + id: "mode", + name: "Session Mode", + category: "mode", + type: "select", + currentValue: input.currentModeId, + options: input.modes.map((mode) => ({ + value: mode.id, + name: mode.name, + ...(mode.description ? { description: mode.description } : {}), + })), + } +} + +export function buildConfigOptions(input: { + providers: readonly ConfigOptionProvider[] + currentModel: ModelSelection["model"] + currentVariant?: string + includeModelVariants?: boolean + modes?: readonly ConfigOptionMode[] + currentModeId?: string +}): SessionConfigOption[] { + const variants = variantsForModel(input.providers, input.currentModel) + const effort = buildEffortSelectOption({ variants, currentVariant: input.currentVariant }) + + return [ + buildModelSelectOption({ + providers: input.providers, + currentModel: input.currentModel, + currentVariant: input.currentVariant, + includeVariants: input.includeModelVariants ?? false, + }), + ...(effort ? [effort] : []), + ...(input.modes && input.currentModeId + ? [buildModeSelectOption({ modes: input.modes, currentModeId: input.currentModeId })] + : []), + ] +} + +export function parseModelSelection(modelId: string, providers: readonly ConfigOptionProvider[]): ModelSelection { + const provider = providers.find((item) => modelId.startsWith(`${item.id}/`)) + if (provider) { + const modelID = modelId.slice(provider.id.length + 1) + if (provider.models[modelID]) { + return { model: { providerID: provider.id, modelID } } + } + + const separator = modelID.lastIndexOf("/") + if (separator > -1) { + const baseModelID = modelID.slice(0, separator) + const variant = modelID.slice(separator + 1) + if (provider.models[baseModelID]?.variants?.[variant]) { + return { model: { providerID: provider.id, modelID: baseModelID }, variant } + } + } + + return { model: { providerID: provider.id, modelID } } + } + + const separator = modelId.indexOf("/") + if (separator === -1) { + return { model: { providerID: modelId, modelID: "" } } + } + + return { + model: { + providerID: modelId.slice(0, separator), + modelID: modelId.slice(separator + 1), + }, + } +} + +export function formatCurrentModelId(input: { + model: ModelSelection["model"] + variant?: string + variants?: readonly string[] + includeVariant?: boolean +}) { + const base = `${input.model.providerID}/${input.model.modelID}` + if (!input.includeVariant || !input.variants?.length) return base + return `${base}/${selectVariant(input.variant, input.variants)}` +} + +export function formatVariantName(variant: string) { + return variant + .split(/[_-]/) + .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part)) + .join(" ") +} + +function buildModelSelectOptions( + providers: readonly ConfigOptionProvider[], + options: { includeVariants: boolean }, +): Array<{ value: string; name: string }> { + return providers.flatMap((provider) => + Object.values(provider.models) + .sort((a, b) => a.name.localeCompare(b.name)) + .flatMap((model) => { + const base = { + value: `${provider.id}/${model.id}`, + name: `${provider.name}/${model.name}`, + } + if (!options.includeVariants || !model.variants) return [base] + + return [ + base, + ...Object.keys(model.variants) + .filter((variant) => variant !== DEFAULT_VARIANT_VALUE) + .map((variant) => ({ + value: `${provider.id}/${model.id}/${variant}`, + name: `${provider.name}/${model.name} (${formatVariantName(variant)})`, + })), + ] + }), + ) +} + +function variantsForModel(providers: readonly ConfigOptionProvider[], model: ModelSelection["model"]) { + return Object.keys( + providers.find((provider) => provider.id === model.providerID)?.models[model.modelID]?.variants ?? {}, + ) +} + +function selectVariant(variant: string | undefined, variants: readonly string[]) { + if (variant && variants.includes(variant)) return variant + if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE + return variants[0] +} diff --git a/packages/opencode/test/acp-next/config-option.test.ts b/packages/opencode/test/acp-next/config-option.test.ts new file mode 100644 index 0000000000..d538ee81c0 --- /dev/null +++ b/packages/opencode/test/acp-next/config-option.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, test } from "bun:test" +import { + buildConfigOptions, + buildEffortSelectOption, + buildModeSelectOption, + buildModelSelectOption, + formatCurrentModelId, + formatVariantName, + parseModelSelection, + type ConfigOptionProvider, +} from "@/acp-next/config-option" + +const providers: ConfigOptionProvider[] = [ + { + id: "anthropic", + name: "Anthropic", + models: { + "claude/sonnet-4": { + id: "claude/sonnet-4", + name: "Claude Sonnet 4", + variants: { + default: {}, + high: {}, + "very-high": {}, + }, + }, + "claude-haiku": { + id: "claude-haiku", + name: "Claude Haiku", + }, + }, + }, + { + id: "openai", + name: "OpenAI", + models: { + "gpt-5": { + id: "gpt-5", + name: "GPT-5", + variants: { + minimal: {}, + low: {}, + }, + }, + }, + }, +] + +describe("acp-next config options", () => { + test("builds the model select option with ACP verifier category", () => { + expect( + buildModelSelectOption({ + providers, + currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" }, + currentVariant: "high", + }), + ).toEqual({ + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "anthropic/claude/sonnet-4", + options: [ + { value: "anthropic/claude-haiku", name: "Anthropic/Claude Haiku" }, + { value: "anthropic/claude/sonnet-4", name: "Anthropic/Claude Sonnet 4" }, + { value: "openai/gpt-5", name: "OpenAI/GPT-5" }, + ], + }) + }) + + test("includes variant ids in the model option only when requested", () => { + const option = buildModelSelectOption({ + providers, + currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" }, + currentVariant: "high", + includeVariants: true, + }) + + expect(option.currentValue).toBe("anthropic/claude/sonnet-4/high") + if (option.type !== "select") throw new Error("expected select option") + expect(option.options).toContainEqual({ + value: "anthropic/claude/sonnet-4/high", + name: "Anthropic/Claude Sonnet 4 (High)", + }) + expect(option.options).not.toContainEqual({ + value: "anthropic/claude/sonnet-4/default", + name: "Anthropic/Claude Sonnet 4 (Default)", + }) + }) + + test("builds effort option from variants and falls back to default when current variant is invalid", () => { + expect(buildEffortSelectOption({ variants: ["low", "default", "high"], currentVariant: "missing" })).toEqual({ + id: "effort", + name: "Effort", + description: "Available effort levels for this model", + category: "thought_level", + type: "select", + currentValue: "default", + options: [ + { value: "low", name: "Low" }, + { value: "default", name: "Default" }, + { value: "high", name: "High" }, + ], + }) + }) + + test("effort fallback uses the first variant when default is absent", () => { + expect(buildEffortSelectOption({ variants: ["minimal", "low"], currentVariant: "missing" })?.currentValue).toBe( + "minimal", + ) + }) + + test("omits effort option when there are no variants", () => { + expect(buildEffortSelectOption({ variants: [] })).toBeUndefined() + }) + + test("builds the mode select option with descriptions when present", () => { + expect( + buildModeSelectOption({ + currentModeId: "build", + modes: [ + { id: "build", name: "Build", description: "Make code changes" }, + { id: "plan", name: "Plan" }, + ], + }), + ).toEqual({ + id: "mode", + name: "Session Mode", + category: "mode", + type: "select", + currentValue: "build", + options: [ + { value: "build", name: "Build", description: "Make code changes" }, + { value: "plan", name: "Plan" }, + ], + }) + }) + + test("builds full config options with model, effort, and mode in stable order", () => { + const options = buildConfigOptions({ + providers, + currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" }, + currentVariant: "very-high", + modes: [ + { id: "build", name: "Build" }, + { id: "plan", name: "Plan" }, + ], + currentModeId: "plan", + }) + + expect(options.map((option) => option.id)).toEqual(["model", "effort", "mode"]) + expect(options.map((option) => option.category)).toEqual(["model", "thought_level", "mode"]) + expect(options[1]?.currentValue).toBe("very-high") + }) + + test("full config options omit effort for models without variants", () => { + expect( + buildConfigOptions({ + providers, + currentModel: { providerID: "anthropic", modelID: "claude-haiku" }, + }).map((option) => option.id), + ).toEqual(["model"]) + }) + + test("parses provider/model selections", () => { + expect(parseModelSelection("openai/gpt-5", providers)).toEqual({ + model: { providerID: "openai", modelID: "gpt-5" }, + }) + }) + + test("parses provider/model/variant selections when the base model exposes that variant", () => { + expect(parseModelSelection("openai/gpt-5/low", providers)).toEqual({ + model: { providerID: "openai", modelID: "gpt-5" }, + variant: "low", + }) + }) + + test("prefers exact slash-containing model ids before treating the tail as a variant", () => { + expect(parseModelSelection("anthropic/claude/sonnet-4", providers)).toEqual({ + model: { providerID: "anthropic", modelID: "claude/sonnet-4" }, + }) + }) + + test("parses trailing variants for slash-containing model ids", () => { + expect(parseModelSelection("anthropic/claude/sonnet-4/high", providers)).toEqual({ + model: { providerID: "anthropic", modelID: "claude/sonnet-4" }, + variant: "high", + }) + }) + + test("keeps unknown trailing segments in the model id when they are not valid variants", () => { + expect(parseModelSelection("anthropic/claude/sonnet-4/missing", providers)).toEqual({ + model: { providerID: "anthropic", modelID: "claude/sonnet-4/missing" }, + }) + }) + + test("formats current model ids with and without selected variants", () => { + expect( + formatCurrentModelId({ + model: { providerID: "openai", modelID: "gpt-5" }, + variant: "low", + variants: ["minimal", "low"], + }), + ).toBe("openai/gpt-5") + expect( + formatCurrentModelId({ + model: { providerID: "openai", modelID: "gpt-5" }, + variant: "low", + variants: ["minimal", "low"], + includeVariant: true, + }), + ).toBe("openai/gpt-5/low") + }) + + test("formats current model ids with variant fallback", () => { + expect( + formatCurrentModelId({ + model: { providerID: "anthropic", modelID: "claude/sonnet-4" }, + variant: "missing", + variants: ["default", "high"], + includeVariant: true, + }), + ).toBe("anthropic/claude/sonnet-4/default") + }) + + test("formats variant names for display", () => { + expect(formatVariantName("very_high-effort")).toBe("Very High Effort") + }) +})