Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@agentclientprotocol/sdk": "0.21.0",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
Expand Down
65 changes: 65 additions & 0 deletions packages/cli/src/acp/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {
RequestError,
type Agent,
type AgentSideConnection,
type AuthenticateRequest,
type CancelNotification,
type CloseSessionRequest,
type ForkSessionRequest,
type InitializeRequest,
type ListSessionsRequest,
type LoadSessionRequest,
type NewSessionRequest,
type PromptRequest,
type ResumeSessionRequest,
type SetSessionConfigOptionRequest,
type SetSessionModelRequest,
type SetSessionModeRequest,
} from "@agentclientprotocol/sdk"
import type { OpenCodeClient } from "@opencode-ai/client/promise"
import { ACPError } from "./error"
import { ACPService } from "./service"

export function create(client: OpenCodeClient, connection: AgentSideConnection) {
const service = ACPService.make({ client, connection })
return {
initialize: (params: InitializeRequest) => run(service.initialize(params)),
authenticate: (params: AuthenticateRequest) => run(service.authenticate(params)),
newSession: (params: NewSessionRequest) => run(service.newSession(params)),
loadSession: (params: LoadSessionRequest) => run(service.loadSession(params)),
listSessions: (params: ListSessionsRequest) => run(service.listSessions(params)),
resumeSession: (params: ResumeSessionRequest) => run(service.resumeSession(params)),
closeSession: (params: CloseSessionRequest) => run(service.closeSession(params)),
unstable_forkSession: (params: ForkSessionRequest) => run(service.forkSession(params)),
setSessionConfigOption: (params: SetSessionConfigOptionRequest) => run(service.setSessionConfigOption(params)),
setSessionMode: (params: SetSessionModeRequest) => run(service.setSessionMode(params)),
unstable_setSessionModel: (params: SetSessionModelRequest) => run(service.setSessionModel(params)),
prompt: (params: PromptRequest) => run(service.prompt(params)),
cancel: (params: CancelNotification) => run(service.cancel(params)),
} satisfies Agent
}

async function run<A>(promise: Promise<A>) {
try {
return await promise
} catch (error) {
if (error instanceof RequestError) throw error
if (isACPError(error)) throw ACPError.toRequestError(error)
throw ACPError.toRequestError(ACPError.fromUnknown(error))
}
}

function isACPError(error: unknown): error is ACPError.Error {
return (
error instanceof ACPError.SessionNotFoundError ||
error instanceof ACPError.InvalidConfigOptionError ||
error instanceof ACPError.InvalidModelError ||
error instanceof ACPError.InvalidEffortError ||
error instanceof ACPError.InvalidModeError ||
error instanceof ACPError.AuthRequiredError ||
error instanceof ACPError.UnknownAuthMethodError ||
error instanceof ACPError.ServiceFailureError
)
}

export * as ACP from "./agent"
133 changes: 133 additions & 0 deletions packages/cli/src/acp/config-option.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk"

export const DEFAULT_VARIANT_VALUE = "default"

export type ConfigOptionModel = {
id: string
name: string
variants?: ReadonlyArray<string>
}

export type ConfigOptionProvider = {
id: string
name: string
models: ReadonlyArray<ConfigOptionModel>
}

export type ConfigOptionMode = {
id: string
name: string
description?: string
}

export type ModelSelection = {
model: { providerID: string; modelID: string }
variant?: string
}

export function buildConfigOptions(input: {
providers: readonly ConfigOptionProvider[]
currentModel: ModelSelection["model"]
currentVariant?: string
modes?: readonly ConfigOptionMode[]
currentModeId?: string
}): SessionConfigOption[] {
const variants =
input.providers
.find((provider) => provider.id === input.currentModel.providerID)
?.models.find((model) => model.id === input.currentModel.modelID)?.variants ?? []
const effort =
variants.length > 0 ? buildEffortSelectOption({ variants, currentVariant: input.currentVariant }) : undefined
return [
buildModelSelectOption({ providers: input.providers, currentModel: input.currentModel }),
...(effort ? [effort] : []),
...(input.modes && input.currentModeId
? [buildModeSelectOption({ modes: input.modes, currentModeId: input.currentModeId })]
: []),
]
}

export function buildModelSelectOption(input: {
providers: readonly ConfigOptionProvider[]
currentModel: ModelSelection["model"]
}): SessionConfigOption {
return {
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: `${input.currentModel.providerID}/${input.currentModel.modelID}`,
options: input.providers.flatMap((provider) =>
provider.models
.toSorted((a, b) => a.name.localeCompare(b.name))
.map((model) => ({ value: `${provider.id}/${model.id}`, name: `${provider.name}/${model.name}` })),
),
}
}

export function buildEffortSelectOption(input: {
variants: readonly string[]
currentVariant?: string
}): SessionConfigOption {
return {
id: "effort",
name: "Effort",
description: "Available effort levels for this model",
category: "thought_level",
type: "select",
currentValue: selectVariant(input.currentVariant, input.variants),
options: input.variants.map((variant) => ({ value: variant, name: formatVariantName(variant) })),
}
}

export function buildModeSelectOption(input: {
modes: readonly ConfigOptionMode[]
currentModeId: string
}): SessionConfigOption {
return {
id: "mode",
name: "Session Mode",
category: "mode",
type: "select",
currentValue: input.currentModeId,
options: input.modes.map((mode) => ({
value: mode.id,
name: mode.name,
...(mode.description ? { description: mode.description } : {}),
})),
}
}

export function parseModelSelection(modelId: string, providers: readonly ConfigOptionProvider[]): ModelSelection {
const provider = providers.find((item) => modelId.startsWith(`${item.id}/`))
if (!provider) {
const separator = modelId.indexOf("/")
if (separator === -1) return { model: { providerID: modelId, modelID: "" } }
return { model: { providerID: modelId.slice(0, separator), modelID: modelId.slice(separator + 1) } }
}
const modelID = modelId.slice(provider.id.length + 1)
if (provider.models.some((model) => model.id === modelID)) return { model: { providerID: provider.id, modelID } }
const separator = modelID.lastIndexOf("/")
const baseModelID = separator === -1 ? modelID : modelID.slice(0, separator)
const variant = separator === -1 ? undefined : modelID.slice(separator + 1)
const model = provider.models.find((item) => item.id === baseModelID)
if (model && variant && model.variants?.includes(variant)) {
return { model: { providerID: provider.id, modelID: baseModelID }, variant }
}
return { model: { providerID: provider.id, modelID } }
}

export function formatVariantName(variant: string) {
return variant
.split(/[_-]/)
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
.join(" ")
}

function selectVariant(variant: string | undefined, variants: readonly string[]) {
if (variant && variants.includes(variant)) return variant
if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE
return variants[0] ?? DEFAULT_VARIANT_VALUE
}

export * as ACPConfigOption from "./config-option"
Loading
Loading