diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 2a6bbbb1e444..3f8d81f07e4e 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -33,7 +33,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,7 +46,13 @@ 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?: ACPSessionState["model"] + variant?: string +} type ModelOption = { modelId: string; name: string } const DEFAULT_VARIANT_VALUE = "default" @@ -1125,9 +1131,23 @@ 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.model ? agent.variant : undefined, })) } + private selectMode(sessionId: string, mode: ModeOption) { + this.sessionManager.setMode(sessionId, mode.id) + if (!mode.model) return + this.sessionManager.setModel(sessionId, mode.model) + this.sessionManager.setVariant(sessionId, mode.variant) + } + private async resolveModeState( directory: string, sessionId: string, @@ -1138,10 +1158,10 @@ export namespace ACP { (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 + const mode = availableModes.find((mode) => mode.name === defaultAgentName) ?? availableModes[0] + if (!mode) return undefined + this.selectMode(sessionId, mode) + return mode.id })()) return { availableModes, currentModeId } @@ -1149,18 +1169,19 @@ export namespace ACP { 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) + const availableModels = buildAvailableModels(entries, { includeVariants: true }) + const modeState = await this.resolveModeState(directory, sessionId) + const model = this.sessionManager.getModel(sessionId) ?? (await defaultModel(this.config, directory)) const availableVariants = modelVariantsFromProviders(entries, model) const currentVariant = this.sessionManager.getVariant(sessionId) if (currentVariant && !availableVariants.includes(currentVariant)) { this.sessionManager.setVariant(sessionId, undefined) } - const availableModels = buildAvailableModels(entries, { includeVariants: true }) - const modeState = await this.resolveModeState(directory, sessionId) + const variant = this.sessionManager.getVariant(sessionId) const currentModeId = modeState.currentModeId const modes = currentModeId ? { @@ -1242,13 +1263,13 @@ export namespace ACP { return { sessionId, models: { - currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true), + currentModelId: formatModelIdWithVariant(model, variant, availableVariants, true), availableModels, }, modes, _meta: buildVariantMeta({ model, - variant: this.sessionManager.getVariant(sessionId), + variant, availableVariants, }), } @@ -1279,10 +1300,11 @@ export namespace ACP { 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)) { + const mode = availableModes.find((mode) => mode.id === params.modeId) + if (!mode) { throw new Error(`Agent not found: ${params.modeId}`) } - this.sessionManager.setMode(params.sessionId, params.modeId) + this.selectMode(session.id, mode) } async prompt(params: PromptRequest) { diff --git a/packages/opencode/test/acp/set-session-mode.test.ts b/packages/opencode/test/acp/set-session-mode.test.ts new file mode 100644 index 000000000000..21841ebab817 --- /dev/null +++ b/packages/opencode/test/acp/set-session-mode.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, test } from "bun:test" +import type { AgentSideConnection } from "@agentclientprotocol/sdk" +import { ACP } from "../../src/acp/agent" +import type { ACPConfig } from "../../src/acp/types" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +function fake() { + const conn = { + async sessionUpdate() {}, + async requestPermission() { + return { + outcome: { + outcome: "selected", + optionId: "once", + }, + } + }, + } as unknown as AgentSideConnection + + const sdk = { + global: { + event: async (opts?: { signal?: AbortSignal }) => ({ + stream: (async function* () { + if (opts?.signal?.aborted) return + await new Promise((resolve) => opts?.signal?.addEventListener("abort", resolve, { once: true })) + })(), + }), + }, + session: { + create: async () => ({ + data: { + id: "ses_1", + time: { created: new Date().toISOString() }, + }, + }), + messages: async () => ({ data: [] }), + }, + config: { + providers: async () => ({ + data: { + providers: [ + { + id: "opencode", + name: "OpenCode", + models: { + "big-pickle": { id: "big-pickle", name: "Big Pickle" }, + }, + }, + { + id: "openai", + name: "OpenAI", + models: { + "gpt-5": { + id: "gpt-5", + name: "GPT-5", + variants: { + default: {}, + high: {}, + }, + }, + "gpt-4.1": { + id: "gpt-4.1", + name: "GPT-4.1", + variants: { + default: {}, + fast: {}, + }, + }, + }, + }, + ], + }, + }), + }, + app: { + agents: async () => ({ + data: [ + { + name: "build", + description: "build", + mode: "primary", + }, + { + name: "review", + description: "review", + mode: "primary", + model: { + providerID: "openai", + modelID: "gpt-5", + }, + variant: "high", + }, + { + name: "base", + description: "base", + mode: "primary", + model: { + providerID: "openai", + modelID: "gpt-4.1", + }, + }, + { + name: "plain", + description: "plain", + mode: "primary", + }, + ], + }), + }, + command: { + list: async () => ({ data: [] }), + }, + mcp: { + add: async () => ({ data: true }), + }, + } as unknown as ACPConfig["sdk"] + + const agent = new ACP.Agent(conn, { + sdk, + defaultModel: { + providerID: ProviderID.make("opencode"), + modelID: ModelID.make("big-pickle"), + }, + }) + + const raw = agent as unknown as { + eventAbort: AbortController + sessionManager: { + get: (id: string) => { + modeId?: string + model?: { + providerID: string + modelID: string + } + variant?: string + } + } + } + + return { + agent, + stop() { + raw.eventAbort.abort() + }, + get(id: string) { + return raw.sessionManager.get(id) + }, + } +} + +describe("acp.agent setSessionMode", () => { + test("newSession activates the configured model for the default mode", async () => { + await using tmp = await tmpdir({ + config: { + default_agent: "review", + agent: { + review: { + model: "openai/gpt-5", + variant: "high", + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, stop, get } = fake() + + try { + const res = await agent.newSession({ cwd: tmp.path, mcpServers: [] } as Parameters[0]) + const state = get(res.sessionId) + + expect(res.modes?.currentModeId).toBe("review") + expect(res.models.currentModelId).toBe("openai/gpt-5/high") + expect(state.modeId).toBe("review") + expect(state.model).toEqual({ + providerID: "openai", + modelID: "gpt-5", + }) + expect(state.variant).toBe("high") + } finally { + stop() + } + }, + }) + }) + + test("activates the selected mode's configured model and variant", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, stop, get } = fake() + const cwd = "/tmp/opencode-acp-test" + + try { + const sid = await agent + .newSession({ cwd, mcpServers: [] } as Parameters[0]) + .then((x) => x.sessionId) + + await agent.setSessionMode({ sessionId: sid, modeId: "review" } as Parameters[0]) + + const state = get(sid) + expect(state.modeId).toBe("review") + expect(state.model).toEqual({ + providerID: "openai", + modelID: "gpt-5", + }) + expect(state.variant).toBe("high") + } finally { + stop() + } + }, + }) + }) + + test("keeps the current session model when the selected mode has no configured model", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, stop, get } = fake() + const cwd = "/tmp/opencode-acp-test" + + try { + const sid = await agent + .newSession({ cwd, mcpServers: [] } as Parameters[0]) + .then((x) => x.sessionId) + + await agent.setSessionMode({ sessionId: sid, modeId: "review" } as Parameters[0]) + await agent.setSessionMode({ sessionId: sid, modeId: "plain" } as Parameters[0]) + + const state = get(sid) + expect(state.modeId).toBe("plain") + expect(state.model).toEqual({ + providerID: "openai", + modelID: "gpt-5", + }) + expect(state.variant).toBe("high") + } finally { + stop() + } + }, + }) + }) + + test("clears the current variant when the selected mode changes the model without a variant", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, stop, get } = fake() + const cwd = "/tmp/opencode-acp-test" + + try { + const sid = await agent + .newSession({ cwd, mcpServers: [] } as Parameters[0]) + .then((x) => x.sessionId) + + await agent.setSessionMode({ sessionId: sid, modeId: "review" } as Parameters[0]) + await agent.setSessionMode({ sessionId: sid, modeId: "base" } as Parameters[0]) + + const state = get(sid) + expect(state.modeId).toBe("base") + expect(state.model).toEqual({ + providerID: "openai", + modelID: "gpt-4.1", + }) + expect(state.variant).toBeUndefined() + } finally { + stop() + } + }, + }) + }) +})