From a5ea910eaca8b4491e2fa7660686d23f9cb2b28a Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 26 May 2026 00:24:03 +0530 Subject: [PATCH] fix(acp-next): add config switch fast paths (#29255) --- packages/opencode/src/acp-next/agent.ts | 15 ++ packages/opencode/src/acp-next/service.ts | 122 ++++++++++++- .../test/acp-next/service-session.test.ts | 170 +++++++++++++++++- .../cli/acp-next/acp-next-process.test.ts | 94 +++++++++- 4 files changed, 398 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/acp-next/agent.ts b/packages/opencode/src/acp-next/agent.ts index f0d3a77bcd..a4ae006956 100644 --- a/packages/opencode/src/acp-next/agent.ts +++ b/packages/opencode/src/acp-next/agent.ts @@ -8,6 +8,9 @@ import { type LoadSessionRequest, type NewSessionRequest, type PromptRequest, + type SetSessionConfigOptionRequest, + type SetSessionModelRequest, + type SetSessionModeRequest, } from "@agentclientprotocol/sdk" import { Effect } from "effect" import type { OpencodeClient } from "@opencode-ai/sdk/v2" @@ -41,6 +44,18 @@ export class Agent implements ACPAgent { return run(this.service.loadSession(params)) } + setSessionConfigOption(params: SetSessionConfigOptionRequest) { + return run(this.service.setSessionConfigOption(params)) + } + + setSessionMode(params: SetSessionModeRequest) { + return run(this.service.setSessionMode(params)) + } + + unstable_setSessionModel(params: SetSessionModelRequest) { + return run(this.service.setSessionModel(params)) + } + prompt(params: PromptRequest) { return run(this.service.prompt(params)) } diff --git a/packages/opencode/src/acp-next/service.ts b/packages/opencode/src/acp-next/service.ts index 66ed5aaed0..ce1ea18085 100644 --- a/packages/opencode/src/acp-next/service.ts +++ b/packages/opencode/src/acp-next/service.ts @@ -13,12 +13,18 @@ import { type NewSessionResponse, type PromptRequest, type PromptResponse, + type SetSessionConfigOptionRequest, + type SetSessionConfigOptionResponse, + type SetSessionModelRequest, + type SetSessionModelResponse, + type SetSessionModeRequest, + type SetSessionModeResponse, } from "@agentclientprotocol/sdk" import { InstallationVersion } from "@opencode-ai/core/installation/version" import type { OpencodeClient } from "@opencode-ai/sdk/v2" import { Context, Effect, Layer, ManagedRuntime } from "effect" import * as ACPNextError from "./error" -import { buildConfigOptions } from "./config-option" +import { buildConfigOptions, parseModelSelection } from "./config-option" import { Directory } from "./directory" import { ACPNextSession } from "./session" import { ModelID, ProviderID } from "@/provider/schema" @@ -34,6 +40,11 @@ export type Interface = { readonly authenticate: (input: AuthenticateRequest) => Effect.Effect readonly newSession: (input: NewSessionRequest) => Effect.Effect readonly loadSession: (input: LoadSessionRequest) => Effect.Effect + readonly setSessionConfigOption: ( + input: SetSessionConfigOptionRequest, + ) => Effect.Effect + readonly setSessionMode: (input: SetSessionModeRequest) => Effect.Effect + readonly setSessionModel: (input: SetSessionModelRequest) => Effect.Effect readonly prompt: (input: PromptRequest) => Effect.Effect readonly cancel: (input: CancelNotification) => Effect.Effect } @@ -180,11 +191,96 @@ export function make(input: { } }) + const setSessionConfigOption = Effect.fn("ACPNext.setSessionConfigOption")(function* ( + params: SetSessionConfigOptionRequest, + ) { + const current = yield* session.get(params.sessionId) + const snapshot = yield* directorySnapshot(current.cwd) + if (typeof params.value !== "string") { + return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId }) + } + + if (params.configId === "model") { + const selected = yield* parseSelectedModel(snapshot, params.value) + const variant = selected.variant ?? selectVariant(snapshot, selected.model) + const state = yield* session + .setVariant(params.sessionId, Directory.variants(snapshot, selected.model) ? variant : undefined) + .pipe(Effect.andThen(session.setModel(params.sessionId, selected.model))) + return { + configOptions: configOptions(snapshot, { + model: state.model ?? selected.model, + variant: state.variant, + modeId: state.modeId, + }), + } + } + + if (params.configId === "effort") { + const model = current.model ?? selectDefaultModel(snapshot) + const variants = Directory.variants(snapshot, model) + if (!variants || !Object.keys(variants).includes(params.value)) { + return yield* new ACPNextError.InvalidEffortError({ effort: params.value }) + } + const state = yield* session.setVariant(params.sessionId, params.value) + return { + configOptions: configOptions(snapshot, { + model: state.model ?? model, + variant: state.variant, + modeId: state.modeId, + }), + } + } + + if (params.configId === "mode") { + if (!snapshot.availableModes.some((mode) => mode.id === params.value)) { + return yield* new ACPNextError.InvalidModeError({ mode: params.value }) + } + const state = yield* session.setMode(params.sessionId, params.value) + return { + configOptions: configOptions(snapshot, { + model: state.model ?? selectDefaultModel(snapshot), + variant: state.variant, + modeId: state.modeId, + }), + } + } + + return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId }) + }) + + const setSessionMode = Effect.fn("ACPNext.setSessionMode")(function* (params: SetSessionModeRequest) { + const current = yield* session.get(params.sessionId) + const snapshot = yield* directorySnapshot(current.cwd) + if (!snapshot.availableModes.some((mode) => mode.id === params.modeId)) { + return yield* new ACPNextError.InvalidModeError({ mode: params.modeId }) + } + yield* session.setMode(params.sessionId, params.modeId) + return {} + }) + + const setSessionModel = Effect.fn("ACPNext.setSessionModel")(function* (params: SetSessionModelRequest) { + const current = yield* session.get(params.sessionId) + const snapshot = yield* directorySnapshot(current.cwd) + const selected = yield* parseSelectedModel(snapshot, params.modelId) + yield* session + .setVariant( + params.sessionId, + Directory.variants(snapshot, selected.model) + ? (selected.variant ?? selectVariant(snapshot, selected.model)) + : undefined, + ) + .pipe(Effect.andThen(session.setModel(params.sessionId, selected.model))) + return {} + }) + return { initialize, authenticate, newSession, loadSession, + setSessionConfigOption, + setSessionMode, + setSessionModel, prompt: Effect.fn("ACPNext.prompt")(function* (_input: PromptRequest) { return yield* new ACPNextError.UnsupportedOperationError({ method: "session/prompt" }) }), @@ -371,6 +467,30 @@ function configOptions(snapshot: Directory.Snapshot, session: ConfigState) { }) } +function parseSelectedModel(snapshot: Directory.Snapshot, modelId: string) { + const selected = parseModelSelection(modelId, Object.values(snapshot.providers)) + const provider = snapshot.providers[ProviderID.make(selected.model.providerID)] + const model = provider?.models[ModelID.make(selected.model.modelID)] + if (!model) { + return Effect.fail( + new ACPNextError.InvalidModelError({ + providerId: selected.model.providerID, + modelId, + }), + ) + } + if (selected.variant && !model.variants?.[selected.variant]) { + return Effect.fail(new ACPNextError.InvalidEffortError({ effort: selected.variant })) + } + return Effect.succeed({ + model: { + providerID: provider.id, + modelID: model.id, + }, + variant: selected.variant, + }) +} + function sendAvailableCommands( connection: Pick | undefined, sessionId: string, diff --git a/packages/opencode/test/acp-next/service-session.test.ts b/packages/opencode/test/acp-next/service-session.test.ts index a9ecb3cf48..a024758aaf 100644 --- a/packages/opencode/test/acp-next/service-session.test.ts +++ b/packages/opencode/test/acp-next/service-session.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "bun:test" -import type { AgentSideConnection, LoadSessionResponse, NewSessionResponse } from "@agentclientprotocol/sdk" +import type { + AgentSideConnection, + LoadSessionResponse, + NewSessionResponse, + SessionConfigOption, + SessionConfigSelectOption, + SetSessionConfigOptionResponse, +} from "@agentclientprotocol/sdk" import type { OpencodeClient } from "@opencode-ai/sdk/v2" import { Effect } from "effect" import * as ACPNextService from "@/acp-next/service" @@ -10,6 +17,7 @@ import type { Provider } from "@/provider/provider" const providerID = ProviderID.make("test") const modelID = ModelID.make("test-model") const configuredModelID = ModelID.make("configured-model") +const secondModelID = ModelID.make("second-model") const provider: Provider.Info = { id: providerID, @@ -88,6 +96,43 @@ const provider: Provider.Info = { headers: {}, release_date: "2026-01-01", }, + [secondModelID]: { + id: secondModelID, + providerID, + api: { + id: secondModelID, + url: "https://example.com", + npm: "@ai-sdk/openai-compatible", + }, + name: "Second Model", + family: "test", + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + limit: { + context: 128000, + output: 4096, + }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-01-01", + variants: { + low: { reasoningEffort: "low" }, + medium: { reasoningEffort: "medium" }, + }, + }, }, } @@ -359,8 +404,131 @@ describe("ACP next service sessions", () => { expect(result.sessionId).toBe("configured-model") expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/configured-model") }) + + it("switches model and returns updated model and effort options", async () => { + const { service } = makeService() + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + const updated = await Effect.runPromise( + service.setSessionConfigOption({ + sessionId: session.sessionId, + configId: "model", + value: "test/second-model", + }), + ) + + expect(select(updated, "model")?.currentValue).toBe("test/second-model") + expect(select(updated, "effort")?.currentValue).toBe("low") + expect(flattenSelectOptions(select(updated, "effort")).map((option) => option.value)).toEqual(["low", "medium"]) + }) + + it("switches effort and returns the updated effort current value", async () => { + const { service } = makeService() + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + const updated = await Effect.runPromise( + service.setSessionConfigOption({ + sessionId: session.sessionId, + configId: "effort", + value: "high", + }), + ) + + expect(select(updated, "effort")?.currentValue).toBe("high") + }) + + it("switches mode and returns the updated mode current value", async () => { + const { service } = makeService() + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + const updated = await Effect.runPromise( + service.setSessionConfigOption({ + sessionId: session.sessionId, + configId: "mode", + value: "plan", + }), + ) + + expect(select(updated, "mode")?.currentValue).toBe("plan") + }) + + it("maps invalid model effort mode and config id to invalid params", async () => { + const { service } = makeService() + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + const results = await Promise.all( + [ + { configId: "model", value: "test/missing-model" }, + { configId: "effort", value: "max" }, + { configId: "mode", value: "missing-mode" }, + { configId: "missing", value: "value" }, + ].map((input) => + Effect.runPromise( + service + .setSessionConfigOption({ sessionId: session.sessionId, ...input }) + .pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip), + ), + ), + ) + expect(results.map((error) => error.code)).toEqual([-32602, -32602, -32602, -32602]) + }) + + it("does not reload providers or commands when switching effort from a warm snapshot", async () => { + let providersCalls = 0 + let commandCalls = 0 + const sdk = { + config: { + providers: () => { + providersCalls++ + return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }) + }, + get: () => Promise.resolve({ data: {} }), + }, + app: { + agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }), + skills: () => Promise.resolve({ data: [] }), + }, + command: { + list: () => { + commandCalls++ + return Promise.resolve({ data: [] }) + }, + }, + session: { + create: () => Promise.resolve({ data: { id: "ses_fast" } }), + list: () => Promise.resolve({ data: [] }), + }, + mcp: { + add: () => Promise.resolve({ data: {} }), + }, + } as unknown as OpencodeClient + const service = ACPNextService.make({ sdk }) + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + expect(providersCalls).toBe(1) + expect(commandCalls).toBe(1) + + await Effect.runPromise( + service.setSessionConfigOption({ + sessionId: session.sessionId, + configId: "effort", + value: "high", + }), + ) + + expect(providersCalls).toBe(1) + expect(commandCalls).toBe(1) + }) }) function categories(result: NewSessionResponse | LoadSessionResponse) { return result.configOptions?.map((option) => option.category) ?? [] } + +function select(result: SetSessionConfigOptionResponse, id: string) { + return result.configOptions.find( + (option): option is Extract => + option.id === id && option.type === "select", + ) +} + +function flattenSelectOptions(option: Extract | undefined) { + return option?.options.flatMap((item): SessionConfigSelectOption[] => ("value" in item ? [item] : item.options)) ?? [] +} diff --git a/packages/opencode/test/cli/acp-next/acp-next-process.test.ts b/packages/opencode/test/cli/acp-next/acp-next-process.test.ts index 426f8225bf..c4e88fb74e 100644 --- a/packages/opencode/test/cli/acp-next/acp-next-process.test.ts +++ b/packages/opencode/test/cli/acp-next/acp-next-process.test.ts @@ -5,11 +5,12 @@ import type { LoadSessionResponse, NewSessionResponse, SessionNotification, + SetSessionConfigOptionResponse, } from "@agentclientprotocol/sdk" import { Effect } from "effect" import { cliIt } from "../../lib/cli-process" import { testProviderConfig } from "../../lib/test-provider" -import { createAcpClient, expectOk, selectConfigOption } from "../acp/acp-test-client" +import { createAcpClient, expectOk, firstAlternateValue, selectConfigOption } from "../acp/acp-test-client" describe("opencode acp-next (subprocess)", () => { cliIt.live( @@ -98,6 +99,66 @@ describe("opencode acp-next (subprocess)", () => { 60_000, ) + cliIt.live( + "switches model through config options behind OPENCODE_ACP_NEXT", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = createAcpClient( + yield* opencode.acp({ + env: { + OPENCODE_ACP_NEXT: "1", + OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)), + }, + }), + ) + yield* acp.request("initialize", { protocolVersion: 1 }) + const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) + + const updated = expectOk( + yield* acp.request("session/set_config_option", { + sessionId: session.sessionId, + configId: "model", + value: "test/second-model", + }), + ) + + expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe("test/second-model") + }), + 60_000, + ) + + cliIt.live( + "switches effort through config options behind OPENCODE_ACP_NEXT", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const acp = createAcpClient( + yield* opencode.acp({ + env: { + OPENCODE_ACP_NEXT: "1", + OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)), + }, + }), + ) + yield* acp.request("initialize", { protocolVersion: 1 }) + const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) + const effort = selectConfigOption(session.configOptions, "effort") + expect(effort?.category).toBe("thought_level") + const nextEffort = effort ? firstAlternateValue(effort) : undefined + expect(nextEffort).toBe("high") + + const updated = expectOk( + yield* acp.request("session/set_config_option", { + sessionId: session.sessionId, + configId: "effort", + value: nextEffort, + }), + ) + + expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort) + }), + 60_000, + ) + cliIt.live( "exits cleanly when flagged stdin is closed", ({ opencode }) => @@ -134,3 +195,34 @@ function errorCode(error: unknown) { if (!("code" in error)) return undefined return typeof error.code === "number" ? error.code : undefined } + +function verifierConfig(llmUrl: string) { + const config = testProviderConfig(llmUrl) + return { + ...config, + model: "test/test-model", + provider: { + test: { + ...config.provider.test, + models: { + "test-model": { + ...config.provider.test.models["test-model"], + variants: { + low: {}, + high: {}, + }, + }, + "second-model": { + ...config.provider.test.models["test-model"], + id: "second-model", + name: "Second Test Model", + variants: { + medium: {}, + max: {}, + }, + }, + }, + }, + }, + } +}