From b18c384d8d5c6a4e88d67a37608f5038a285c950 Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Sat, 7 Mar 2026 21:09:33 -0500 Subject: [PATCH 1/3] core: thought level config in acp --- packages/opencode/src/acp/agent.ts | 168 +++++++++++++-- .../opencode/test/acp/agent-interface.test.ts | 1 + .../opencode/test/acp/config-options.test.ts | 204 ++++++++++++++++++ 3 files changed, 360 insertions(+), 13 deletions(-) create mode 100644 packages/opencode/test/acp/config-options.test.ts diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index d518dd12a1a4..6b213268a184 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -23,6 +23,9 @@ import { type SetSessionModelRequest, type SetSessionModeRequest, type SetSessionModeResponse, + type SetSessionConfigOptionRequest, + type SetSessionConfigOptionResponse, + type SessionConfigOption, type ToolCallContent, type ToolKind, type Usage, @@ -49,6 +52,8 @@ 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" }) @@ -586,6 +591,7 @@ export namespace ACP { sessionId, models: load.models, modes: load.modes, + configOptions: load.configOptions, _meta: load._meta, } } catch (e) { @@ -1157,7 +1163,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 @@ -1240,10 +1253,11 @@ export namespace ACP { return { sessionId, models: { - currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true), + currentModelId, availableModels, }, modes, + configOptions, _meta: buildVariantMeta({ model, variant: this.sessionManager.getVariant(sessionId), @@ -1263,7 +1277,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({ @@ -1274,6 +1293,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) @@ -1660,25 +1756,71 @@ export namespace ACP { return Object.keys(modelInfo.variants) } + 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: Array<{ id: string; name: string; models: Record }>, - options: { includeVariants?: boolean } = {}, ): ModelOption[] { - const includeVariants = options.includeVariants ?? false return providers.flatMap((provider) => { const models = Provider.sort(Object.values(provider.models) as any) - return models.flatMap((model) => { - const base: ModelOption = { + 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 a915d30ebe66..3e4b3bbb03d4 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 "unstable_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..2f2574aefa6b --- /dev/null +++ b/packages/opencode/test/acp/config-options.test.ts @@ -0,0 +1,204 @@ +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) +} + +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: { id: string }) => item.id === "opencode.model") + const cfg = out.configOptions?.find((item: { id: string }) => item.id === "opencode.thought") + 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 sessionId = await agent.newSession({ cwd: tmp.path, mcpServers: [] } as any).then((x) => x.sessionId) + + await agent.setSessionConfigOption({ + sessionId, + configId: "opencode.thought", + value: "high", + }) + await agent.prompt({ + sessionId, + prompt: [{ type: "text", text: "hi" }], + } as any) + + await agent.setSessionConfigOption({ + sessionId, + configId: "opencode.model", + value: "opencode/small-pickle", + }) + await agent.prompt({ + sessionId, + prompt: [{ type: "text", text: "switch model" }], + } as any) + + await agent.setSessionConfigOption({ + sessionId, + configId: "opencode.thought", + 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() + } + }, + }) + }) +}) From 19ddf059cb6dff3f3b3277c51ebc8d73326a0491 Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Sat, 7 Mar 2026 21:30:30 -0500 Subject: [PATCH 2/3] fix: update config ids in config test --- .../opencode/test/acp/config-options.test.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/opencode/test/acp/config-options.test.ts b/packages/opencode/test/acp/config-options.test.ts index 2f2574aefa6b..3bc2c7feb60b 100644 --- a/packages/opencode/test/acp/config-options.test.ts +++ b/packages/opencode/test/acp/config-options.test.ts @@ -123,6 +123,12 @@ function values(opts: unknown): 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() @@ -137,8 +143,8 @@ describe("acp.agent config options", () => { "opencode/small-pickle", ]) - const model = out.configOptions?.find((item: { id: string }) => item.id === "opencode.model") - const cfg = out.configOptions?.find((item: { id: string }) => item.id === "opencode.thought") + 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") @@ -158,11 +164,14 @@ describe("acp.agent config options", () => { fn: async () => { const { agent, prompts, stop } = create() try { - const sessionId = await agent.newSession({ cwd: tmp.path, mcpServers: [] } as any).then((x) => x.sessionId) + 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: "opencode.thought", + configId: thoughtID, value: "high", }) await agent.prompt({ @@ -172,7 +181,7 @@ describe("acp.agent config options", () => { await agent.setSessionConfigOption({ sessionId, - configId: "opencode.model", + configId: modelID, value: "opencode/small-pickle", }) await agent.prompt({ @@ -182,7 +191,7 @@ describe("acp.agent config options", () => { await agent.setSessionConfigOption({ sessionId, - configId: "opencode.thought", + configId: thoughtID, value: "default", }) await agent.prompt({ From 18c04bb31a77940edc4f84faac40d27362ba54cd Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Sat, 14 Mar 2026 13:52:30 -0400 Subject: [PATCH 3/3] fix type error --- packages/opencode/src/acp/agent.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 5f29795d3fbe..af964f71c91b 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -46,7 +46,14 @@ 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 } @@ -1809,11 +1816,9 @@ export namespace ACP { return value } - function buildAvailableModels( - providers: Array<{ id: string; name: string; models: Record }>, - ): ModelOption[] { + function buildAvailableModels(providers: SDKProvider[]): ModelOption[] { return providers.flatMap((provider) => { - const models = Provider.sort(Object.values(provider.models) as any) + const models = Provider.sort(Object.values(provider.models)) return models.map((model) => { return { modelId: `${provider.id}/${model.id}`,