Ver Fonte

test(acp-next): add config option helpers (#29234)

Shoubhit Dash há 3 meses atrás
pai
commit
fe482fe3dd

+ 203 - 0
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<string, Record<string, unknown>>
+}
+
+export type ConfigOptionProvider = {
+  id: string
+  name: string
+  models: Record<string, ConfigOptionModel>
+}
+
+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]
+}

+ 229 - 0
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")
+  })
+})