diff --git a/bun.lock b/bun.lock index 9cda088153c..aee21dd20bb 100644 --- a/bun.lock +++ b/bun.lock @@ -260,7 +260,7 @@ "dependencies": { "@actions/core": "1.11.1", "@actions/github": "6.0.1", - "@agentclientprotocol/sdk": "0.5.1", + "@agentclientprotocol/sdk": "0.13.0", "@ai-sdk/amazon-bedrock": "3.0.73", "@ai-sdk/anthropic": "2.0.57", "@ai-sdk/azure": "2.0.91", @@ -549,7 +549,7 @@ "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], - "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.5.1", "", { "dependencies": { "zod": "^3.0.0" } }, "sha512-9bq2TgjhLBSUSC5jE04MEe+Hqw8YePzKghhYZ9QcjOyonY3q2oJfX6GoSO83hURpEnsqEPIrex6VZN3+61fBJg=="], + "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.13.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Z6/Fp4cXLbYdMXr5AK752JM5qG2VKb6ShM0Ql6FimBSckMmLyK54OA20UhPYoH4C37FSFwUTARuwQOwQUToYrw=="], "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.73", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.57", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EAAGJ/dfbAZaqIhK3w52hq6cftSLZwXdC6uHKh8Cls1T0N4MxS6ykDf54UyFO3bZWkQxR+Mdw1B3qireGOxtJQ=="], @@ -3965,8 +3965,6 @@ "@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], - "@agentclientprotocol/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="], "@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.5", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.9.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-Ogt4Zi9hEbIP17oQMd68qYOHUzmH47UkK7q7Gl55iIm9oKt27MUGrC5JfpMroeHjdkOliOA4Qt3NQ1xMq/nrlA=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 757e6efde90..bb889d7af33 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -49,7 +49,7 @@ "dependencies": { "@actions/core": "1.11.1", "@actions/github": "6.0.1", - "@agentclientprotocol/sdk": "0.5.1", + "@agentclientprotocol/sdk": "0.13.0", "@ai-sdk/amazon-bedrock": "3.0.73", "@ai-sdk/anthropic": "2.0.57", "@ai-sdk/azure": "2.0.91", diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 6330fae97a2..25a1f5fe491 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -18,6 +18,7 @@ import { type ToolCallContent, type ToolKind, } from "@agentclientprotocol/sdk" + import { Log } from "../util/log" import { ACPSessionManager } from "./session" import type { ACPConfig } from "./types" @@ -32,6 +33,11 @@ import { LoadAPIKeyError } from "ai" import type { Event, OpencodeClient, SessionMessageResponse } 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" + export namespace ACP { const log = Log.create({ service: "acp-agent" }) @@ -463,7 +469,7 @@ export namespace ACP { sessionId, models: load.models, modes: load.modes, - _meta: {}, + _meta: load._meta, } } catch (e) { const error = MessageV2.fromError(e, { @@ -512,7 +518,7 @@ 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}` - if (result.modes.availableModes.some((m) => m.id === lastUser.agent)) { + if (result.modes?.availableModes.some((m) => m.id === lastUser.agent)) { result.modes.currentModeId = lastUser.agent } } @@ -801,27 +807,7 @@ 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 = providers.sort((a, b) => { - const nameA = a.name.toLowerCase() - const nameB = b.name.toLowerCase() - if (nameA < nameB) return -1 - if (nameA > nameB) return 1 - return 0 - }) - const availableModels = entries.flatMap((provider) => { - const models = Provider.sort(Object.values(provider.models)) - return models.map((model) => ({ - modelId: `${provider.id}/${model.id}`, - name: `${provider.name}/${model.name}`, - })) - }) - + private async loadAvailableModes(directory: string): Promise { const agents = await this.config.sdk.app .agents( { @@ -831,6 +817,56 @@ export namespace ACP { ) .then((resp) => resp.data!) + return agents + .filter((agent) => agent.mode !== "subagent" && !agent.hidden) + .map((agent) => ({ + id: agent.name, + name: agent.name, + description: agent.description, + })) + } + + private async resolveModeState( + directory: string, + sessionId: string, + ): Promise<{ availableModes: ModeOption[]; currentModeId?: string }> { + const 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 + })()) + + return { availableModes, 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) + 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 currentModeId = modeState.currentModeId + const modes = currentModeId + ? { + availableModes: modeState.availableModes, + currentModeId, + } + : undefined + const commands = await this.config.sdk.command .list( { @@ -851,20 +887,6 @@ export namespace ACP { description: "compact the session", }) - const availableModes = agents - .filter((agent) => agent.mode !== "subagent" && !agent.hidden) - .map((agent) => ({ - id: agent.name, - name: agent.name, - description: agent.description, - })) - - const defaultAgentName = await AgentModule.defaultAgent() - const currentModeId = availableModes.find((m) => m.name === defaultAgentName)?.id ?? availableModes[0].id - - // Persist the default mode so prompt() uses it immediately - this.sessionManager.setMode(sessionId, currentModeId) - const mcpServers: Record = {} for (const server of params.mcpServers) { if ("type" in server) { @@ -918,40 +940,46 @@ export namespace ACP { return { sessionId, models: { - currentModelId: `${model.providerID}/${model.modelID}`, + currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true), availableModels, }, - modes: { - availableModes, - currentModeId, - }, - _meta: {}, + modes, + _meta: buildVariantMeta({ + model, + variant: this.sessionManager.getVariant(sessionId), + availableVariants, + }), } } - async setSessionModel(params: SetSessionModelRequest) { + 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 model = Provider.parseModel(params.modelId) + 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, { - providerID: model.providerID, - modelID: model.modelID, - }) + const entries = sortProvidersByName(providers) + const availableVariants = modelVariantsFromProviders(entries, selection.model) return { - _meta: {}, + _meta: buildVariantMeta({ + model: selection.model, + variant: selection.variant, + availableVariants, + }), } } async setSessionMode(params: SetSessionModeRequest): Promise { - this.sessionManager.get(params.sessionId) - await this.config.sdk.app - .agents({}, { throwOnError: true }) - .then((x) => x.data) - .then((agent) => { - if (!agent) throw new Error(`Agent not found: ${params.modeId}`) - }) + 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) } @@ -1062,6 +1090,7 @@ export namespace ACP { providerID: model.providerID, modelID: model.modelID, }, + variant: this.sessionManager.getVariant(sessionID), parts, agent, directory, @@ -1273,4 +1302,106 @@ export namespace ACP { } return result } + + function sortProvidersByName(providers: T[]): T[] { + return [...providers].sort((a, b) => { + const nameA = a.name.toLowerCase() + const nameB = b.name.toLowerCase() + if (nameA < nameB) return -1 + if (nameA > nameB) return 1 + return 0 + }) + } + + function modelVariantsFromProviders( + providers: Array<{ id: string; models: Record }> }>, + model: { providerID: string; modelID: string }, + ): string[] { + const provider = providers.find((entry) => entry.id === model.providerID) + if (!provider) return [] + const modelInfo = provider.models[model.modelID] + if (!modelInfo?.variants) return [] + return Object.keys(modelInfo.variants) + } + + 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 = { + 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] + }) + }) + } + + function formatModelIdWithVariant( + model: { providerID: string; modelID: string }, + variant: string | undefined, + availableVariants: string[], + includeVariant: boolean, + ) { + const base = `${model.providerID}/${model.modelID}` + if (!includeVariant || !variant || !availableVariants.includes(variant)) return base + return `${base}/${variant}` + } + + function buildVariantMeta(input: { + model: { providerID: string; modelID: string } + variant?: string + availableVariants: string[] + }) { + return { + opencode: { + modelId: `${input.model.providerID}/${input.model.modelID}`, + variant: input.variant ?? null, + availableVariants: input.availableVariants, + }, + } + } + + function parseModelSelection( + modelId: string, + providers: Array<{ id: string; models: Record }> }>, + ): { model: { providerID: string; modelID: string }; variant?: string } { + const parsed = Provider.parseModel(modelId) + const provider = providers.find((p) => p.id === parsed.providerID) + if (!provider) { + return { model: parsed, variant: undefined } + } + + // Check if modelID exists directly + if (provider.models[parsed.modelID]) { + return { model: parsed, variant: undefined } + } + + // Try to extract variant from end of modelID (e.g., "claude-sonnet-4/high" -> model: "claude-sonnet-4", variant: "high") + const segments = parsed.modelID.split("/") + if (segments.length > 1) { + const candidateVariant = segments[segments.length - 1] + const baseModelId = segments.slice(0, -1).join("/") + const baseModelInfo = provider.models[baseModelId] + if (baseModelInfo?.variants && candidateVariant in baseModelInfo.variants) { + return { + model: { providerID: parsed.providerID, modelID: baseModelId }, + variant: candidateVariant, + } + } + } + + return { model: parsed, variant: undefined } + } + } diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index 151fa5646ba..18aa4231301 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -96,6 +96,18 @@ export class ACPSessionManager { return session } + getVariant(sessionId: string) { + const session = this.get(sessionId) + return session.variant + } + + setVariant(sessionId: string, variant?: string) { + const session = this.get(sessionId) + session.variant = variant + this.sessions.set(sessionId, session) + return session + } + setMode(sessionId: string, modeId: string) { const session = this.get(sessionId) session.modeId = modeId diff --git a/packages/opencode/src/acp/types.ts b/packages/opencode/src/acp/types.ts index 42b23091237..de8ac508122 100644 --- a/packages/opencode/src/acp/types.ts +++ b/packages/opencode/src/acp/types.ts @@ -10,6 +10,7 @@ export interface ACPSessionState { providerID: string modelID: string } + variant?: string modeId?: string }