diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 6e87e7642d65..36568d27c046 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -26,6 +26,9 @@ import { type SetSessionConfigOptionResponse, type SetSessionModeRequest, type SetSessionModeResponse, + type SetSessionConfigOptionRequest, + type SetSessionConfigOptionResponse, + type SessionConfigOption, type ToolCallContent, type ToolKind, type Usage, @@ -46,13 +49,22 @@ import { Config } from "@/config/config" import { Todo } from "@/session/todo" import { z } from "zod" import { LoadAPIKeyError } from "ai" -import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2" +import type { + AssistantMessage, + Event, + OpencodeClient, + Provider as SDKProvider, + SessionMessageResponse, + ToolPart, +} from "@opencode-ai/sdk/v2" import { applyPatch } from "diff" type ModeOption = { id: string; name: string; description?: string } type ModelOption = { modelId: string; name: string } const DEFAULT_VARIANT_VALUE = "default" +const THOUGHT_ID = "thought" +const MODEL_ID = "model" export namespace ACP { const log = Log.create({ service: "acp-agent" }) @@ -607,6 +619,7 @@ export namespace ACP { configOptions: load.configOptions, models: load.models, modes: load.modes, + configOptions: load.configOptions, _meta: load._meta, } } catch (e) { @@ -1188,7 +1201,14 @@ export namespace ACP { if (currentVariant && !availableVariants.includes(currentVariant)) { this.sessionManager.setVariant(sessionId, undefined) } - const availableModels = buildAvailableModels(entries, { includeVariants: true }) + const availableModels = buildAvailableModels(entries) + const currentModelId = formatModelIdWithVariant(model, currentVariant, availableVariants, false) + const configOptions = config( + availableModels, + currentModelId, + availableVariants, + this.sessionManager.getVariant(sessionId), + ) const modeState = await this.resolveModeState(directory, sessionId) const currentModeId = modeState.currentModeId const modes = currentModeId @@ -1271,15 +1291,19 @@ export namespace ACP { return { sessionId, models: { - currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true), + currentModelId, availableModels, }, modes, +<<<<<<< acp-thought-level + configOptions, +======= configOptions: buildConfigOptions({ currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true), availableModels, modes, }), +>>>>>>> dev _meta: buildVariantMeta({ model, variant: this.sessionManager.getVariant(sessionId), @@ -1299,7 +1323,12 @@ export namespace ACP { this.sessionManager.setVariant(session.id, selection.variant) const entries = sortProvidersByName(providers) + const models = buildAvailableModels(entries) const availableVariants = modelVariantsFromProviders(entries, selection.model) + const currentModelId = formatModelIdWithVariant(selection.model, selection.variant, availableVariants, false) + const configOptions = config(models, currentModelId, availableVariants, selection.variant) + + await this.pushConfig(session.id, configOptions) return { _meta: buildVariantMeta({ @@ -1310,6 +1339,83 @@ export namespace ACP { } } + async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise { + if (params.configId !== THOUGHT_ID && params.configId !== MODEL_ID) { + throw RequestError.invalidParams(JSON.stringify({ error: `Unknown config option: ${params.configId}` })) + } + + const session = this.sessionManager.get(params.sessionId) + const providers = await this.sdk.config + .providers({ directory: session.cwd }, { throwOnError: true }) + .then((x) => x.data!.providers) + const entries = sortProvidersByName(providers) + const models = buildAvailableModels(entries) + + const model = this.sessionManager.getModel(session.id) ?? (await defaultModel(this.config, session.cwd)) + if (!this.sessionManager.getModel(session.id)) { + this.sessionManager.setModel(session.id, model) + } + + const variants = modelVariantsFromProviders(entries, model) + if (params.configId === THOUGHT_ID) { + const values = levels(variants) + if (!values.includes(params.value)) { + throw RequestError.invalidParams( + JSON.stringify({ error: `Invalid value for ${params.configId}: ${params.value}` }), + ) + } + + this.sessionManager.setVariant(session.id, select(params.value)) + } + + if (params.configId === MODEL_ID) { + const values = models.map((item) => item.modelId) + if (!values.includes(params.value)) { + throw RequestError.invalidParams( + JSON.stringify({ error: `Invalid value for ${params.configId}: ${params.value}` }), + ) + } + + const selected = parseModelSelection(params.value, providers) + this.sessionManager.setModel(session.id, selected.model) + + const nextVariants = modelVariantsFromProviders(entries, selected.model) + const candidate = selected.variant ?? this.sessionManager.getVariant(session.id) + this.sessionManager.setVariant( + session.id, + candidate && nextVariants.includes(candidate) && candidate !== DEFAULT_VARIANT_VALUE ? candidate : undefined, + ) + } + + const nextModel = this.sessionManager.getModel(session.id) ?? model + const nextVariants = modelVariantsFromProviders(entries, nextModel) + const nextModelId = formatModelIdWithVariant( + nextModel, + this.sessionManager.getVariant(session.id), + nextVariants, + false, + ) + const configOptions = config(models, nextModelId, nextVariants, this.sessionManager.getVariant(session.id)) + + await this.pushConfig(session.id, configOptions) + + return { configOptions } + } + + private async pushConfig(sessionId: string, configOptions: SessionConfigOption[]) { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "config_option_update", + configOptions, + }, + }) + .catch((error) => { + log.error("failed to send config options update", { error }) + }) + } + async setSessionMode(params: SetSessionModeRequest): Promise { const session = this.sessionManager.get(params.sessionId) const availableModes = await this.loadAvailableModes(session.cwd) @@ -1730,28 +1836,69 @@ export namespace ACP { return Object.keys(modelInfo.variants) } - function buildAvailableModels( - providers: Array<{ id: string; name: string; models: Record }>, - options: { includeVariants?: boolean } = {}, - ): ModelOption[] { - const includeVariants = options.includeVariants ?? false + function levels(variants: string[]): string[] { + const rest = variants.filter((item) => item !== DEFAULT_VARIANT_VALUE) + return [DEFAULT_VARIANT_VALUE, ...rest] + } + + function thought(variants: string[], variant?: string): SessionConfigOption[] { + const values = levels(variants) + if (values.length < 2) return [] + const current = variant && values.includes(variant) ? variant : DEFAULT_VARIANT_VALUE + return [ + { + type: "select", + id: THOUGHT_ID, + name: "Thinking", + description: "Reasoning effort for this model", + category: "thought_level", + currentValue: current, + options: values.map((item) => ({ + value: item, + name: item === DEFAULT_VARIANT_VALUE ? "Default" : item, + })), + }, + ] + } + + function models(models: ModelOption[], current: string): SessionConfigOption[] { + return [ + { + type: "select", + id: MODEL_ID, + name: "Model", + category: "model", + currentValue: current, + options: models.map((item) => ({ + value: item.modelId, + name: item.name, + })), + }, + ] + } + + function config( + modelsList: ModelOption[], + currentModel: string, + variants: string[], + variant?: string, + ): SessionConfigOption[] { + return [...models(modelsList, currentModel), ...thought(variants, variant)] + } + + function select(value: string): string | undefined { + if (value === DEFAULT_VARIANT_VALUE) return undefined + return value + } + + function buildAvailableModels(providers: SDKProvider[]): ModelOption[] { return providers.flatMap((provider) => { - const unsorted: Array<{ id: string; name: string; variants?: Record }> = Object.values( - provider.models, - ) - const models = Provider.sort(unsorted) - return models.flatMap((model) => { - const base: ModelOption = { + const models = Provider.sort(Object.values(provider.models)) + return models.map((model) => { + return { modelId: `${provider.id}/${model.id}`, name: `${provider.name}/${model.name}`, } - if (!includeVariants || !model.variants) return [base] - const variants = Object.keys(model.variants).filter((variant) => variant !== DEFAULT_VARIANT_VALUE) - const variantOptions = variants.map((variant) => ({ - modelId: `${provider.id}/${model.id}/${variant}`, - name: `${provider.name}/${model.name} (${variant})`, - })) - return [base, ...variantOptions] }) }) } diff --git a/packages/opencode/test/acp/agent-interface.test.ts b/packages/opencode/test/acp/agent-interface.test.ts index 9fa67de82947..afde83d2016f 100644 --- a/packages/opencode/test/acp/agent-interface.test.ts +++ b/packages/opencode/test/acp/agent-interface.test.ts @@ -33,6 +33,7 @@ describe("acp.agent interface compliance", () => { // Optional but checked by SDK router "loadSession", "setSessionMode", + "setSessionConfigOption", "authenticate", // Unstable - SDK checks these with unstable_ prefix "listSessions", diff --git a/packages/opencode/test/acp/config-options.test.ts b/packages/opencode/test/acp/config-options.test.ts new file mode 100644 index 000000000000..3bc2c7feb60b --- /dev/null +++ b/packages/opencode/test/acp/config-options.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, test } from "bun:test" +import { ACP } from "../../src/acp/agent" +import type { AgentSideConnection } from "@agentclientprotocol/sdk" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +type Update = Parameters[0] + +function create() { + const prompts: Array<{ modelID: string; providerID: string; variant?: string }> = [] + + const connection = { + async sessionUpdate(_input: Update) {}, + async requestPermission() { + return { outcome: { outcome: "selected", optionId: "once" } } + }, + } as unknown as AgentSideConnection + + const sdk = { + global: { + event: async (opts?: { signal?: AbortSignal }) => { + const stream = (async function* () { + await new Promise((resolve) => { + opts?.signal?.addEventListener("abort", () => resolve(), { once: true }) + }) + })() + return { stream } + }, + }, + session: { + create: async () => { + return { + data: { + id: "ses_1", + time: { created: new Date().toISOString() }, + }, + } + }, + prompt: async (input: { model: { providerID: string; modelID: string }; variant?: string }) => { + prompts.push({ providerID: input.model.providerID, modelID: input.model.modelID, variant: input.variant }) + return { data: {} } + }, + messages: async () => { + return { data: [] } + }, + }, + config: { + get: async () => ({ data: {} }), + providers: async () => { + return { + data: { + providers: [ + { + id: "opencode", + name: "OpenCode", + models: { + "big-pickle": { + id: "big-pickle", + name: "big-pickle", + providerID: "opencode", + variants: { + default: {}, + low: {}, + high: {}, + }, + }, + "small-pickle": { + id: "small-pickle", + name: "small-pickle", + providerID: "opencode", + variants: { + default: {}, + low: {}, + }, + }, + }, + }, + ], + }, + } + }, + }, + app: { + agents: async () => { + return { + data: [ + { + name: "build", + description: "build", + mode: "agent", + }, + ], + } + }, + }, + command: { + list: async () => ({ data: [] }), + }, + mcp: { + add: async () => ({ data: true }), + }, + } as any + + const agent = new ACP.Agent(connection, { + sdk, + defaultModel: { providerID: "opencode", modelID: "big-pickle" }, + } as any) + + const stop = () => { + ;(agent as any).eventAbort.abort() + } + + return { agent, prompts, stop } +} + +function values(opts: unknown): string[] { + if (!Array.isArray(opts)) return [] + return opts + .filter( + (item): item is { value: string } => + typeof item === "object" && item !== null && "value" in item && typeof item.value === "string", + ) + .map((item) => item.value) +} + +function id(opts: Array<{ id: string; category?: string | null }> | undefined, category: string) { + const item = opts?.find((entry) => entry.category === category) + if (item) return item.id + throw new Error(`missing config option category: ${category}`) +} + +describe("acp.agent config options", () => { + test("returns thinking levels as config options and keeps model list base-only", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, stop } = create() + try { + const out = await agent.newSession({ cwd: tmp.path, mcpServers: [] } as any) + expect(out.models?.availableModels.map((item) => item.modelId)).toEqual([ + "opencode/big-pickle", + "opencode/small-pickle", + ]) + + const model = out.configOptions?.find((item) => item.category === "model") + const cfg = out.configOptions?.find((item) => item.category === "thought_level") + expect(model?.category).toBe("model") + expect(values(model?.options)).toEqual(["opencode/big-pickle", "opencode/small-pickle"]) + expect(cfg?.category).toBe("thought_level") + expect(cfg?.type).toBe("select") + expect(values(cfg?.options)).toEqual(["default", "low", "high"]) + } finally { + stop() + } + }, + }) + }) + + test("applies thinking level through setSessionConfigOption", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, prompts, stop } = create() + try { + const out = await agent.newSession({ cwd: tmp.path, mcpServers: [] } as any) + const sessionId = out.sessionId + const thoughtID = id(out.configOptions, "thought_level") + const modelID = id(out.configOptions, "model") + + await agent.setSessionConfigOption({ + sessionId, + configId: thoughtID, + value: "high", + }) + await agent.prompt({ + sessionId, + prompt: [{ type: "text", text: "hi" }], + } as any) + + await agent.setSessionConfigOption({ + sessionId, + configId: modelID, + value: "opencode/small-pickle", + }) + await agent.prompt({ + sessionId, + prompt: [{ type: "text", text: "switch model" }], + } as any) + + await agent.setSessionConfigOption({ + sessionId, + configId: thoughtID, + value: "default", + }) + await agent.prompt({ + sessionId, + prompt: [{ type: "text", text: "hi again" }], + } as any) + + expect(prompts).toEqual([ + { providerID: "opencode", modelID: "big-pickle", variant: "high" }, + { providerID: "opencode", modelID: "small-pickle", variant: undefined }, + { providerID: "opencode", modelID: "small-pickle", variant: undefined }, + ]) + } finally { + stop() + } + }, + }) + }) +})