From ca168a327da70e309c098e76a2fb6495bdfb9781 Mon Sep 17 00:00:00 2001 From: Alvaro Parker Date: Wed, 8 Apr 2026 15:27:14 -0400 Subject: [PATCH] Use setSessionConfigOption to choose variant and respect defaults --- packages/opencode/src/acp/agent.ts | 431 ++++++++++++++---- packages/opencode/src/acp/session.ts | 22 +- packages/opencode/src/acp/types.ts | 4 + .../opencode/test/acp/agent-interface.test.ts | 1 + .../opencode/test/acp/config-options.test.ts | 238 ++++++++++ 5 files changed, 593 insertions(+), 103 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 96a97be75296..382f85a15000 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -19,7 +19,10 @@ import { type ResumeSessionRequest, type ResumeSessionResponse, type Role, + type SessionConfigOption, type SessionInfo, + type SetSessionConfigOptionRequest, + type SetSessionConfigOptionResponse, type SetSessionModelRequest, type SetSessionModeRequest, type SetSessionModeResponse, @@ -33,7 +36,7 @@ import { pathToFileURL } from "url" import { Filesystem } from "../util/filesystem" import { Hash } from "../util/hash" import { ACPSessionManager } from "./session" -import type { ACPConfig } from "./types" +import type { ACPConfig, ACPSessionState } from "./types" import { Provider } from "../provider/provider" import { ModelID, ProviderID } from "../provider/schema" import { Agent as AgentModule } from "../agent/agent" @@ -46,8 +49,29 @@ import { LoadAPIKeyError } from "ai" import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2" import { applyPatch } from "diff" -type ModeOption = { id: string; name: string; description?: string } +type ModeOption = { + id: string + name: string + description?: string + model?: { providerID: ProviderID; modelID: ModelID } + variant?: string +} type ModelOption = { modelId: string; name: string } +type ProviderOption = { + id: string + name: string + models: Record }> +} +type ResolvedState = { + currentModeId?: string + availableModes: ModeOption[] + model: { providerID: ProviderID; modelID: ModelID } + variant?: string + availableVariants: string[] + availableModels: ModelOption[] + legacyModels: ModelOption[] + configOptions: SessionConfigOption[] +} const DEFAULT_VARIANT_VALUE = "default" @@ -585,10 +609,7 @@ export namespace ACP { async newSession(params: NewSessionRequest) { const directory = params.cwd try { - const model = await defaultModel(this.config, directory) - - // Store ACP session state - const state = await this.sessionManager.create(params.cwd, params.mcpServers, model) + const state = await this.sessionManager.create(params.cwd, params.mcpServers) const sessionId = state.id log.info("creating_session", { sessionId, mcpServers: params.mcpServers.length }) @@ -601,6 +622,7 @@ export namespace ACP { return { sessionId, + configOptions: load.configOptions, models: load.models, modes: load.modes, _meta: load._meta, @@ -621,19 +643,10 @@ export namespace ACP { const sessionId = params.sessionId try { - const model = await defaultModel(this.config, directory) - - // Store ACP session state - await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model) + await this.sessionManager.load(sessionId, params.cwd, params.mcpServers) log.info("load_session", { sessionId, mcpServers: params.mcpServers.length }) - const result = await this.loadSessionMode({ - cwd: directory, - mcpServers: params.mcpServers, - sessionId, - }) - // Replay session history const messages = await this.sdk.session .messages( @@ -651,17 +664,15 @@ export namespace ACP { const lastUser = messages?.findLast((m) => m.info.role === "user")?.info if (lastUser?.role === "user") { - result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}` - this.sessionManager.setModel(sessionId, { - providerID: ProviderID.make(lastUser.model.providerID), - modelID: ModelID.make(lastUser.model.modelID), - }) - if (result.modes?.availableModes.some((m) => m.id === lastUser.agent)) { - result.modes.currentModeId = lastUser.agent + this.restoreSessionState(sessionId, lastUser) + const modes = await this.loadAvailableModes(directory) + if (modes.some((mode) => mode.id === lastUser.agent)) { this.sessionManager.setMode(sessionId, lastUser.agent) } } + const result = await this.loadSessionMode({ cwd: directory, mcpServers: params.mcpServers, sessionId }) + for (const msg of messages ?? []) { log.debug("replay message", msg) await this.processMessage(msg) @@ -731,8 +742,6 @@ export namespace ACP { const mcpServers = params.mcpServers ?? [] try { - const model = await defaultModel(this.config, directory) - const forked = await this.sdk.session .fork( { @@ -748,16 +757,10 @@ export namespace ACP { } const sessionId = forked.id - await this.sessionManager.load(sessionId, directory, mcpServers, model) + await this.sessionManager.load(sessionId, directory, mcpServers) log.info("fork_session", { sessionId, mcpServers: mcpServers.length }) - const mode = await this.loadSessionMode({ - cwd: directory, - mcpServers, - sessionId, - }) - const messages = await this.sdk.session .messages( { @@ -772,6 +775,21 @@ export namespace ACP { return undefined }) + const lastUser = messages?.findLast((m) => m.info.role === "user")?.info + if (lastUser?.role === "user") { + this.restoreSessionState(sessionId, lastUser) + const modes = await this.loadAvailableModes(directory) + if (modes.some((mode) => mode.id === lastUser.agent)) { + this.sessionManager.setMode(sessionId, lastUser.agent) + } + } + + const result = await this.loadSessionMode({ + cwd: directory, + mcpServers, + sessionId, + }) + for (const msg of messages ?? []) { log.debug("replay message", msg) await this.processMessage(msg) @@ -779,7 +797,7 @@ export namespace ACP { await sendUsageUpdate(this.connection, this.sdk, sessionId, directory) - return mode + return result } catch (e) { const error = MessageV2.fromError(e, { providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), @@ -797,11 +815,33 @@ export namespace ACP { const mcpServers = params.mcpServers ?? [] try { - const model = await defaultModel(this.config, directory) - await this.sessionManager.load(sessionId, directory, mcpServers, model) + await this.sessionManager.load(sessionId, directory, mcpServers) log.info("resume_session", { sessionId, mcpServers: mcpServers.length }) + const messages = await this.sdk.session + .messages( + { + sessionID: sessionId, + directory, + }, + { throwOnError: true }, + ) + .then((x) => x.data) + .catch((err) => { + log.error("unexpected error when fetching message", { error: err }) + return undefined + }) + + const lastUser = messages?.findLast((m) => m.info.role === "user")?.info + if (lastUser?.role === "user") { + this.restoreSessionState(sessionId, lastUser) + const modes = await this.loadAvailableModes(directory) + if (modes.some((mode) => mode.id === lastUser.agent)) { + this.sessionManager.setMode(sessionId, lastUser.agent) + } + } + const result = await this.loadSessionMode({ cwd: directory, mcpServers, @@ -1129,6 +1169,12 @@ export namespace ACP { }) } + private async loadProviders(directory: string): Promise { + return this.sdk.config + .providers({ directory }, { throwOnError: true }) + .then((x) => x.data!.providers as ProviderOption[]) + } + private async loadAvailableModes(directory: string): Promise { const agents = await this.config.sdk.app .agents( @@ -1145,47 +1191,139 @@ export namespace ACP { id: agent.name, name: agent.name, description: agent.description, + model: agent.model + ? { + providerID: ProviderID.make(agent.model.providerID), + modelID: ModelID.make(agent.model.modelID), + } + : undefined, + variant: agent.variant ?? undefined, })) } private async resolveModeState( directory: string, sessionId: string, + availableModes?: ModeOption[], ): Promise<{ availableModes: ModeOption[]; currentModeId?: string }> { - const availableModes = await this.loadAvailableModes(directory) + const modes = availableModes ?? (await this.loadAvailableModes(directory)) const currentModeId = this.sessionManager.get(sessionId).modeId || (await (async () => { - if (!availableModes.length) return undefined - const defaultAgentName = await AgentModule.defaultAgent() - const resolvedModeId = - availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id - this.sessionManager.setMode(sessionId, resolvedModeId) - return resolvedModeId + if (!modes.length) return undefined + const name = await AgentModule.defaultAgent() + const currentModeId = modes.find((mode) => mode.name === name)?.id ?? modes[0].id + this.sessionManager.setMode(sessionId, currentModeId) + return currentModeId })()) - return { availableModes, currentModeId } + return { availableModes: modes, currentModeId } } - private async loadSessionMode(params: LoadSessionRequest) { - const directory = params.cwd - const model = await defaultModel(this.config, directory) - const sessionId = params.sessionId - - const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers) - const entries = sortProvidersByName(providers) + private async resolveSessionState( + directory: string, + sessionId: string, + providers?: ProviderOption[], + ): Promise { + const entries = sortProvidersByName(providers ?? (await this.loadProviders(directory))) + const modeState = await this.resolveModeState(directory, sessionId) + const currentMode = modeState.currentModeId + ? modeState.availableModes.find((mode) => mode.id === modeState.currentModeId) + : undefined + const session = this.sessionManager.get(sessionId) + const model = + session.modelSource && session.model + ? session.model + : (currentMode?.model ?? (await defaultModel(this.config, directory))) const availableVariants = modelVariantsFromProviders(entries, model) - const currentVariant = this.sessionManager.getVariant(sessionId) - if (currentVariant && !availableVariants.includes(currentVariant)) { + + if (session.variantSource && session.variant && !availableVariants.includes(session.variant)) { this.sessionManager.setVariant(sessionId, undefined) } - const availableModels = buildAvailableModels(entries, { includeVariants: true }) - const modeState = await this.resolveModeState(directory, sessionId) - const currentModeId = modeState.currentModeId - const modes = currentModeId - ? { - availableModes: modeState.availableModes, + + const state = this.sessionManager.get(sessionId) + const variant = + state.variantSource !== undefined + ? state.variant + : currentMode?.model && + currentMode.variant && + sameModel(currentMode.model, model) && + availableVariants.includes(currentMode.variant) + ? currentMode.variant + : undefined + + const availableModels = buildAvailableModels(entries) + const legacyModels = buildAvailableModels(entries, { includeVariants: true }) + + return { + currentModeId: modeState.currentModeId, + availableModes: modeState.availableModes, + model, + variant, + availableVariants, + availableModels, + legacyModels, + configOptions: buildSessionConfigOptions({ + currentModeId: modeState.currentModeId, + availableModes: modeState.availableModes, + model, + availableModels, + variant, + availableVariants, + }), + } + } + + private async sendConfigUpdate(sessionId: string, configOptions: SessionConfigOption[]) { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "config_option_update", + configOptions, + }, + }) + .catch((error) => { + log.error("failed to send config option update", { error, sessionId }) + }) + } + + private async sendModeUpdate(sessionId: string, currentModeId: string) { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "current_mode_update", currentModeId, + }, + }) + .catch((error) => { + log.error("failed to send mode update", { error, sessionId }) + }) + } + + private restoreSessionState(sessionId: string, info?: SessionMessageResponse["info"]) { + if (info?.role !== "user") return + this.sessionManager.setModel( + sessionId, + { + providerID: ProviderID.make(info.model.providerID), + modelID: ModelID.make(info.model.modelID), + }, + "restored", + ) + this.sessionManager.setVariant(sessionId, info.variant, "restored") + } + + private async loadSessionMode(params: LoadSessionRequest) { + const directory = params.cwd + const sessionId = params.sessionId + const providers = await this.loadProviders(directory) + const state = await this.resolveSessionState(directory, sessionId, providers) + const modes = state.currentModeId + ? { + availableModes: state.availableModes, + currentModeId: state.currentModeId, } : undefined @@ -1261,61 +1399,115 @@ export namespace ACP { return { sessionId, + configOptions: state.configOptions, models: { - currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true), - availableModels, + currentModelId: formatModelIdWithVariant(state.model, state.variant, state.availableVariants, true), + availableModels: state.legacyModels, }, modes, _meta: buildVariantMeta({ - model, - variant: this.sessionManager.getVariant(sessionId), - availableVariants, + model: state.model, + variant: state.variant, + availableVariants: state.availableVariants, }), } } async unstable_setSessionModel(params: SetSessionModelRequest) { 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 providers = await this.loadProviders(session.cwd) const selection = parseModelSelection(params.modelId, providers) - this.sessionManager.setModel(session.id, selection.model) - this.sessionManager.setVariant(session.id, selection.variant) + this.sessionManager.setModel(session.id, selection.model, "user") + this.sessionManager.setVariant(session.id, selection.variant, "user") + const state = await this.resolveSessionState(session.cwd, session.id, providers) - const entries = sortProvidersByName(providers) - const availableVariants = modelVariantsFromProviders(entries, selection.model) + await this.sendConfigUpdate(session.id, state.configOptions) return { _meta: buildVariantMeta({ - model: selection.model, - variant: selection.variant, - availableVariants, + model: state.model, + variant: state.variant, + availableVariants: state.availableVariants, }), } } - async setSessionMode(params: SetSessionModeRequest): Promise { + async setSessionMode(params: SetSessionModeRequest): Promise { const session = this.sessionManager.get(params.sessionId) const availableModes = await this.loadAvailableModes(session.cwd) if (!availableModes.some((mode) => mode.id === params.modeId)) { throw new Error(`Agent not found: ${params.modeId}`) } this.sessionManager.setMode(params.sessionId, params.modeId) + + const state = await this.resolveSessionState(session.cwd, session.id) + await this.sendModeUpdate(session.id, params.modeId) + await this.sendConfigUpdate(session.id, state.configOptions) + return {} + } + + async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise { + const session = this.sessionManager.get(params.sessionId) + const providers = await this.loadProviders(session.cwd) + const state = await this.resolveSessionState(session.cwd, session.id, providers) + + if (params.configId === "mode") { + if (typeof params.value !== "string") { + throw RequestError.invalidParams("mode option requires a string value") + } + if (!state.availableModes.some((mode) => mode.id === params.value)) { + throw RequestError.invalidParams(`Unknown mode: ${params.value}`) + } + this.sessionManager.setMode(session.id, params.value) + const next = await this.resolveSessionState(session.cwd, session.id, providers) + await this.sendModeUpdate(session.id, params.value) + return { configOptions: next.configOptions } + } + + if (params.configId === "model") { + if (typeof params.value !== "string") { + throw RequestError.invalidParams("model option requires a string value") + } + if (!state.availableModels.some((model) => model.modelId === params.value)) { + throw RequestError.invalidParams(`Unknown model: ${params.value}`) + } + const selection = parseModelSelection(params.value, providers) + this.sessionManager.setModel(session.id, selection.model, "user") + if (selection.variant !== undefined) { + this.sessionManager.setVariant(session.id, selection.variant, "user") + } + const next = await this.resolveSessionState(session.cwd, session.id, providers) + return { configOptions: next.configOptions } + } + + if (params.configId === "variant") { + if (typeof params.value !== "string") { + throw RequestError.invalidParams("variant option requires a string value") + } + if (params.value === DEFAULT_VARIANT_VALUE) { + this.sessionManager.setVariant(session.id, undefined, "user") + const next = await this.resolveSessionState(session.cwd, session.id, providers) + return { configOptions: next.configOptions } + } + if (!state.availableVariants.includes(params.value)) { + throw RequestError.invalidParams(`Unknown variant: ${params.value}`) + } + this.sessionManager.setVariant(session.id, params.value, "user") + const next = await this.resolveSessionState(session.cwd, session.id, providers) + return { configOptions: next.configOptions } + } + + throw RequestError.invalidParams(`Unknown config option: ${params.configId}`) } async prompt(params: PromptRequest) { const sessionID = params.sessionId const session = this.sessionManager.get(sessionID) const directory = session.cwd - - const current = session.model - const model = current ?? (await defaultModel(this.config, directory)) - if (!current) { - this.sessionManager.setModel(session.id, model) - } - const agent = session.modeId ?? (await AgentModule.defaultAgent()) + const state = await this.resolveSessionState(directory, sessionID) + const model = state.model + const agent = state.currentModeId ?? (await AgentModule.defaultAgent()) const parts: Array< | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean } @@ -1427,7 +1619,7 @@ export namespace ACP { providerID: model.providerID, modelID: model.modelID, }, - variant: this.sessionManager.getVariant(sessionID), + variant: state.variant, parts, agent, directory, @@ -1667,8 +1859,12 @@ export namespace ACP { }) } + function sameModel(a: { providerID: ProviderID; modelID: ModelID }, b: { providerID: ProviderID; modelID: ModelID }) { + return a.providerID === b.providerID && a.modelID === b.modelID + } + function modelVariantsFromProviders( - providers: Array<{ id: string; models: Record }> }>, + providers: ProviderOption[], model: { providerID: ProviderID; modelID: ModelID }, ): string[] { const provider = providers.find((entry) => entry.id === model.providerID) @@ -1679,14 +1875,12 @@ export namespace ACP { } function buildAvailableModels( - providers: Array<{ id: string; name: string; models: Record }>, + providers: ProviderOption[], options: { includeVariants?: boolean } = {}, ): ModelOption[] { const includeVariants = options.includeVariants ?? false return providers.flatMap((provider) => { - const unsorted: Array<{ id: string; name: string; variants?: Record }> = Object.values( - provider.models, - ) + const unsorted = Object.values(provider.models) const models = Provider.sort(unsorted) return models.flatMap((model) => { const base: ModelOption = { @@ -1704,6 +1898,67 @@ export namespace ACP { }) } + function buildSessionConfigOptions(input: { + currentModeId?: string + availableModes: ModeOption[] + model: { providerID: ProviderID; modelID: ModelID } + availableModels: ModelOption[] + variant?: string + availableVariants: string[] + }): SessionConfigOption[] { + const options: SessionConfigOption[] = [] + + if (input.currentModeId) { + options.push({ + id: "mode", + name: "Session Mode", + category: "mode", + type: "select", + currentValue: input.currentModeId, + options: input.availableModes.map((mode) => ({ + value: mode.id, + name: mode.name, + description: mode.description, + })), + }) + } + + options.push({ + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: `${input.model.providerID}/${input.model.modelID}`, + options: input.availableModels.map((model) => ({ + value: model.modelId, + name: model.name, + })), + }) + + if (!input.availableVariants.length) return options + + options.push({ + id: "variant", + name: "Thought Level", + category: "thought_level", + type: "select", + currentValue: input.variant ?? DEFAULT_VARIANT_VALUE, + options: [ + { + value: DEFAULT_VARIANT_VALUE, + name: DEFAULT_VARIANT_VALUE, + description: "Use the model default.", + }, + ...input.availableVariants.map((variant) => ({ + value: variant, + name: variant, + })), + ], + }) + + return options + } + function formatModelIdWithVariant( model: { providerID: ProviderID; modelID: ModelID }, variant: string | undefined, @@ -1731,7 +1986,7 @@ export namespace ACP { function parseModelSelection( modelId: string, - providers: Array<{ id: string; models: Record }> }>, + providers: ProviderOption[], ): { model: { providerID: ProviderID; modelID: ModelID }; variant?: string } { const parsed = Provider.parseModel(modelId) const provider = providers.find((p) => p.id === parsed.providerID) diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index b96ebc1c8952..90623c9ed700 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -1,5 +1,5 @@ import { RequestError, type McpServer } from "@agentclientprotocol/sdk" -import type { ACPSessionState } from "./types" +import type { ACPSessionState, ACPSelectionSource } from "./types" import { Log } from "@/util/log" import type { OpencodeClient } from "@opencode-ai/sdk/v2" @@ -17,7 +17,7 @@ export class ACPSessionManager { return this.sessions.get(sessionId) } - async create(cwd: string, mcpServers: McpServer[], model?: ACPSessionState["model"]): Promise { + async create(cwd: string, mcpServers: McpServer[]): Promise { const session = await this.sdk.session .create( { @@ -28,14 +28,12 @@ export class ACPSessionManager { .then((x) => x.data!) const sessionId = session.id - const resolvedModel = model const state: ACPSessionState = { id: sessionId, cwd, mcpServers, createdAt: new Date(), - model: resolvedModel, } log.info("creating_session", { state }) @@ -43,12 +41,7 @@ export class ACPSessionManager { return state } - async load( - sessionId: string, - cwd: string, - mcpServers: McpServer[], - model?: ACPSessionState["model"], - ): Promise { + async load(sessionId: string, cwd: string, mcpServers: McpServer[]): Promise { const session = await this.sdk.session .get( { @@ -59,14 +52,11 @@ export class ACPSessionManager { ) .then((x) => x.data!) - const resolvedModel = model - const state: ACPSessionState = { id: sessionId, cwd, mcpServers, createdAt: new Date(session.time.created), - model: resolvedModel, } log.info("loading_session", { state }) @@ -88,9 +78,10 @@ export class ACPSessionManager { return session.model } - setModel(sessionId: string, model: ACPSessionState["model"]) { + setModel(sessionId: string, model: ACPSessionState["model"], source?: ACPSelectionSource) { const session = this.get(sessionId) session.model = model + session.modelSource = source this.sessions.set(sessionId, session) return session } @@ -100,9 +91,10 @@ export class ACPSessionManager { return session.variant } - setVariant(sessionId: string, variant?: string) { + setVariant(sessionId: string, variant?: string, source?: ACPSelectionSource) { const session = this.get(sessionId) session.variant = variant + session.variantSource = source this.sessions.set(sessionId, session) return session } diff --git a/packages/opencode/src/acp/types.ts b/packages/opencode/src/acp/types.ts index 2c3e886bc185..f0ea2cd3da6a 100644 --- a/packages/opencode/src/acp/types.ts +++ b/packages/opencode/src/acp/types.ts @@ -2,6 +2,8 @@ import type { McpServer } from "@agentclientprotocol/sdk" import type { OpencodeClient } from "@opencode-ai/sdk/v2" import type { ProviderID, ModelID } from "../provider/schema" +export type ACPSelectionSource = "user" | "restored" + export interface ACPSessionState { id: string cwd: string @@ -11,7 +13,9 @@ export interface ACPSessionState { providerID: ProviderID modelID: ModelID } + modelSource?: ACPSelectionSource variant?: string + variantSource?: ACPSelectionSource modeId?: string } 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..bc270319299f --- /dev/null +++ b/packages/opencode/test/acp/config-options.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, test } from "bun:test" +import { ACP } from "../../src/acp/agent" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import type { AgentSideConnection } from "@agentclientprotocol/sdk" + +type EventController = { + close: () => void +} + +function createEventStream() { + const waiters: Array<(value: undefined) => void> = [] + const state = { closed: false } + + const close = () => { + state.closed = true + for (const waiter of waiters.splice(0)) waiter(undefined) + } + + const stream = async function* (signal?: AbortSignal) { + while (!state.closed) { + await new Promise((resolve) => { + waiters.push(resolve) + if (!signal) return + signal.addEventListener("abort", () => resolve(undefined), { once: true }) + }) + if (signal?.aborted) return + } + } + + return { controller: { close } satisfies EventController, stream } +} + +function createACPAgent(input: { messages?: Array; onPrompt?: (params: Record) => void }) { + const sessionUpdates: Array<{ sessionId: string; update: { sessionUpdate: string } & Record }> = [] + const { controller, stream } = createEventStream() + let id = 0 + + const connection = { + async sessionUpdate(params: { sessionId: string; update: { sessionUpdate: string } & Record }) { + sessionUpdates.push(params) + }, + async requestPermission() { + return { outcome: { outcome: "selected", optionId: "once" } } + }, + } as unknown as AgentSideConnection + + const sdk = { + global: { + event: async (opts?: { signal?: AbortSignal }) => ({ stream: stream(opts?.signal) }), + }, + session: { + create: async () => ({ data: { id: `ses_${++id}`, time: { created: new Date().toISOString() } } }), + get: async (params?: { sessionID?: string }) => ({ + data: { id: params?.sessionID ?? "ses_1", time: { created: new Date().toISOString() } }, + }), + fork: async () => ({ data: { id: `ses_${++id}`, time: { created: new Date().toISOString() } } }), + messages: async () => ({ data: input.messages ?? [] }), + message: async () => ({ data: { info: { role: "assistant" }, parts: [] } }), + prompt: async (params: Record) => { + input.onPrompt?.(params) + return { data: {} } + }, + abort: async () => ({ data: true }), + }, + permission: { + reply: async () => ({ data: true }), + respond: async () => ({ data: true }), + }, + config: { + get: async () => ({ + data: { model: "openai/gpt-5.4", agent: { build: { model: "openai/gpt-5.4", variant: "high" } } }, + }), + providers: async () => ({ + data: { + providers: [ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "gpt-5.4", + variants: { + default: {}, + high: {}, + low: {}, + }, + }, + }, + }, + ], + }, + }), + }, + app: { + agents: async () => ({ + data: [ + { + name: "build", + description: "build", + mode: "primary", + hidden: false, + model: { providerID: "openai", modelID: "gpt-5.4" }, + variant: "high", + }, + { + name: "plan", + description: "plan", + mode: "primary", + hidden: false, + model: { providerID: "openai", modelID: "gpt-5.4" }, + variant: "low", + }, + ], + }), + }, + command: { + list: async () => ({ data: [] }), + }, + mcp: { + add: async () => ({ data: true }), + }, + } as any + + const agent = new ACP.Agent(connection, { + sdk, + defaultModel: { providerID: "openai", modelID: "gpt-5.4" }, + } as any) + + const stop = () => { + controller.close() + ;(agent as any).eventAbort.abort() + } + + return { agent, sessionUpdates, stop } +} + +describe("acp session config options", () => { + test("newSession exposes separate mode, model, and variant selectors", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, stop } = createACPAgent({}) + const result = await agent.newSession({ cwd: tmp.path, mcpServers: [] } as any) + + expect(result.models?.currentModelId).toBe("openai/gpt-5.4/high") + expect(result.configOptions?.map((item) => item.id)).toEqual(["mode", "model", "variant"]) + expect(result.configOptions?.find((item) => item.id === "mode")?.currentValue).toBe("build") + expect(result.configOptions?.find((item) => item.id === "model")?.currentValue).toBe("openai/gpt-5.4") + expect(result.configOptions?.find((item) => item.id === "variant")?.currentValue).toBe("high") + + stop() + }, + }) + }) + + test("prompt forwards configured variant on the first turn", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + let call: Record | undefined + const { agent, stop } = createACPAgent({ + onPrompt: (params) => { + call = params + }, + }) + + const sessionId = await agent.newSession({ cwd: tmp.path, mcpServers: [] } as any).then((x) => x.sessionId) + await agent.prompt({ sessionId, prompt: [{ type: "text", text: "hello" }] } as any) + + expect(call?.agent).toBe("build") + expect(call?.variant).toBe("high") + stop() + }, + }) + }) + + test("loadSession restores the last used variant", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, stop } = createACPAgent({ + messages: [ + { + info: { + role: "user", + sessionID: "ses_1", + agent: "plan", + variant: "low", + model: { providerID: "openai", modelID: "gpt-5.4" }, + }, + parts: [], + }, + ], + }) + + const result = await agent.loadSession({ sessionId: "ses_1", cwd: tmp.path, mcpServers: [] } as any) + + expect(result.models?.currentModelId).toBe("openai/gpt-5.4/low") + expect(result.configOptions?.find((item) => item.id === "mode")?.currentValue).toBe("plan") + expect(result.configOptions?.find((item) => item.id === "variant")?.currentValue).toBe("low") + stop() + }, + }) + }) + + test("setSessionConfigOption updates variant independently from model", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + let call: Record | undefined + const { agent, stop } = createACPAgent({ + onPrompt: (params) => { + call = params + }, + }) + + const sessionId = await agent.newSession({ cwd: tmp.path, mcpServers: [] } as any).then((x) => x.sessionId) + const result = await agent.setSessionConfigOption({ + sessionId, + configId: "variant", + value: "default", + }) + + expect(result.configOptions.find((item) => item.id === "variant")?.currentValue).toBe("default") + + await agent.prompt({ sessionId, prompt: [{ type: "text", text: "hello" }] } as any) + expect(call?.variant).toBeUndefined() + stop() + }, + }) + }) +})