feat(cli): add acp protocol mappings

This commit is contained in:
Shoubhit Dash
2026-07-20 15:44:52 +05:30
parent 0eb71d0fc7
commit 4c94897291
9 changed files with 709 additions and 0 deletions
+1
View File
@@ -124,6 +124,7 @@
"opencode2": "./bin/opencode2.cjs",
},
"dependencies": {
"@agentclientprotocol/sdk": "0.21.0",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
+1
View File
@@ -23,6 +23,7 @@
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@agentclientprotocol/sdk": "0.21.0",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
+135
View File
@@ -0,0 +1,135 @@
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 = variantsForModel(input.providers, input.currentModel)
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("/")
return separator === -1
? { model: { providerID: modelId, modelID: "" } }
: { 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 variantsForModel(providers: readonly ConfigOptionProvider[], model: ModelSelection["model"]) {
return providers.find((provider) => provider.id === model.providerID)?.models.find((item) => item.id === model.modelID)
?.variants ?? []
}
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"
+188
View File
@@ -0,0 +1,188 @@
import type { ContentBlock, ContentChunk, ResourceLink, Role } from "@agentclientprotocol/sdk"
import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
export type PromptPart =
| { readonly type: "text"; readonly text: string; readonly synthetic?: boolean; readonly ignored?: boolean }
| { readonly type: "file"; readonly url: string; readonly filename?: string; readonly mime: string }
export type ReplayPart = PromptPart | { readonly type: "reasoning"; readonly text: string }
export function promptContentToParts(content: readonly ContentBlock[]): PromptPart[] {
return content.flatMap(contentBlockToParts)
}
export function contentBlockToParts(block: ContentBlock): PromptPart[] {
switch (block.type) {
case "text":
return [{ type: "text", text: block.text, ...audienceFlags(block.annotations?.audience ?? undefined) }]
case "image":
if (block.data) {
return [
{
type: "file",
url: `data:${block.mimeType};base64,${block.data}`,
filename: filenameFromUri(block.uri ?? undefined) ?? "image",
mime: block.mimeType,
},
]
}
if (block.uri?.startsWith("data:") || block.uri?.startsWith("http://") || block.uri?.startsWith("https://")) {
return [
{
type: "file",
url: block.uri,
filename: filenameFromUri(block.uri) ?? "image",
mime: block.mimeType,
},
]
}
return []
case "resource_link":
return [resourceLinkToPart(block)]
case "resource":
if ("text" in block.resource) {
try {
const parsed = new URL(block.resource.uri)
if (parsed.protocol === "file:") {
const line = parsed.hash.match(/^#L(\d+)/)?.[1]
const decoded = (() => {
try {
return fileURLToPath(parsed)
} catch {
return decodeURIComponent(parsed.pathname)
}
})()
const filepath = path.sep === "\\" ? decoded.replace(/\\/g, "/") : decoded
return [{ type: "text", text: `[${filepath}${line ? `:${line}` : ""}]\n${block.resource.text}` }]
}
} catch {}
return [{ type: "text", text: `[${block.resource.uri}]\n${block.resource.text}` }]
}
if (!block.resource.mimeType) return []
return [
{
type: "file",
url: block.resource.uri.startsWith("data:")
? block.resource.uri
: `data:${block.resource.mimeType};base64,${block.resource.blob}`,
filename: filenameFromUri(block.resource.uri) ?? "file",
mime: block.resource.mimeType,
},
]
default:
return []
}
}
export function partsToContentChunks(parts: readonly ReplayPart[]): ContentChunk[] {
return parts.flatMap((part): ContentChunk[] => {
if (part.type === "text") {
if (!part.text) return []
return [
{
content: {
type: "text",
text: part.text,
...(part.synthetic
? { annotations: { audience: ["assistant" as const] } }
: part.ignored
? { annotations: { audience: ["user" as const] } }
: {}),
},
},
]
}
if (part.type === "reasoning") {
return part.text ? [{ content: { type: "text", text: part.text } }] : []
}
if (part.url.startsWith("file://")) {
return [
{
content: {
type: "resource_link",
uri: part.url,
name: part.filename ?? "file",
mimeType: part.mime,
},
},
]
}
if (!part.url.startsWith("data:")) return []
const data = decodeDataUrl(part.url)
if (!data) return []
if (data.mime.startsWith("image/")) {
return [
{
content: {
type: "image",
mimeType: data.mime,
data: data.base64,
uri: pathToFileURL(part.filename ?? "image").href,
},
},
]
}
return [
{
content: {
type: "resource",
resource:
data.mime.startsWith("text/") || data.mime === "application/json"
? {
uri: pathToFileURL(part.filename ?? "file").href,
mimeType: data.mime,
text: Buffer.from(data.base64, "base64").toString("utf8"),
}
: {
uri: pathToFileURL(part.filename ?? "file").href,
mimeType: data.mime,
blob: data.base64,
},
},
},
]
})
}
function resourceLinkToPart(link: ResourceLink): PromptPart {
try {
if (link.uri.startsWith("file://")) {
return { type: "file", url: link.uri, filename: link.name || filenameFromUri(link.uri) || "file", mime: link.mimeType ?? "text/plain" }
}
if (link.uri.startsWith("zed://")) {
const pathname = new URL(link.uri).searchParams.get("path")
if (pathname)
return {
type: "file",
url: pathToFileURL(pathname).href,
filename: link.name || path.basename(pathname) || "file",
mime: link.mimeType ?? "text/plain",
}
}
} catch {}
return { type: "text", text: link.uri }
}
function decodeDataUrl(url: string) {
const match = /^data:([^;]+);base64,(.*)$/.exec(url)
if (!match?.[1] || match[2] === undefined) return
return { mime: match[1], base64: match[2] }
}
function audienceFlags(audience: readonly Role[] | null | undefined) {
if (audience?.length === 1 && audience[0] === "assistant") return { synthetic: true as const }
if (audience?.length === 1 && audience[0] === "user") return { ignored: true as const }
return {}
}
function filenameFromUri(uri: string | undefined) {
if (!uri || uri.startsWith("data:")) return
try {
return path.basename(new URL(uri).pathname) || undefined
} catch {
return path.basename(uri) || undefined
}
}
export * as ACPContent from "./content"
+79
View File
@@ -0,0 +1,79 @@
import { RequestError } from "@agentclientprotocol/sdk"
import { Schema } from "effect"
export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()("ACPSessionNotFoundError", {
sessionId: Schema.String,
}) {}
export class InvalidConfigOptionError extends Schema.TaggedErrorClass<InvalidConfigOptionError>()(
"ACPInvalidConfigOptionError",
{ configId: Schema.String },
) {}
export class InvalidModelError extends Schema.TaggedErrorClass<InvalidModelError>()("ACPInvalidModelError", {
modelId: Schema.String,
providerId: Schema.optional(Schema.String),
}) {}
export class InvalidEffortError extends Schema.TaggedErrorClass<InvalidEffortError>()("ACPInvalidEffortError", {
effort: Schema.String,
}) {}
export class InvalidModeError extends Schema.TaggedErrorClass<InvalidModeError>()("ACPInvalidModeError", {
mode: Schema.String,
}) {}
export class UnknownAuthMethodError extends Schema.TaggedErrorClass<UnknownAuthMethodError>()(
"ACPUnknownAuthMethodError",
{ methodId: Schema.String },
) {}
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPServiceFailureError", {
safeMessage: Schema.String,
service: Schema.optional(Schema.String),
errorName: Schema.optional(Schema.String),
}) {}
export type Error =
| SessionNotFoundError
| InvalidConfigOptionError
| InvalidModelError
| InvalidEffortError
| InvalidModeError
| UnknownAuthMethodError
| ServiceFailureError
export function toRequestError(error: Error) {
switch (error._tag) {
case "ACPSessionNotFoundError":
return RequestError.invalidParams({ sessionId: error.sessionId }, `session not found: ${error.sessionId}`)
case "ACPInvalidConfigOptionError":
return RequestError.invalidParams({ configId: error.configId }, `unknown config option: ${error.configId}`)
case "ACPInvalidModelError":
return RequestError.invalidParams(
{ providerId: error.providerId, modelId: error.modelId },
`model not found: ${error.modelId}`,
)
case "ACPInvalidEffortError":
return RequestError.invalidParams({ effort: error.effort }, `effort not found: ${error.effort}`)
case "ACPInvalidModeError":
return RequestError.invalidParams({ mode: error.mode }, `mode not found: ${error.mode}`)
case "ACPUnknownAuthMethodError":
return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`)
case "ACPServiceFailureError":
return RequestError.internalError(
{
...(error.service ? { service: error.service } : {}),
...(error.errorName ? { errorName: error.errorName } : {}),
},
error.safeMessage,
)
}
}
export function fromUnknown(error: unknown, service?: string) {
const errorName = error instanceof Error ? error.name : undefined
return new ServiceFailureError({ safeMessage: "Internal service failure", service, errorName })
}
export * as ACPError from "./error"
+189
View File
@@ -0,0 +1,189 @@
import { isAbsolute, resolve } from "node:path"
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
export type ToolInput = Record<string, unknown>
export type ToolContent = ReadonlyArray<
| { readonly type: "text"; readonly text: string }
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
>
export function toToolKind(toolName: string): ToolKind {
switch (toolName.toLocaleLowerCase()) {
case "bash":
case "shell":
return "execute"
case "webfetch":
return "fetch"
case "edit":
case "apply_patch":
case "patch":
case "write":
return "edit"
case "grep":
case "glob":
case "context":
case "context7_resolve_library_id":
case "context7_get_library_docs":
return "search"
case "read":
return "read"
case "task":
case "subagent":
return "think"
default:
return "other"
}
}
export function toLocations(toolName: string, input: ToolInput, cwd?: string): ToolCallLocation[] {
switch (toolName.toLocaleLowerCase()) {
case "bash":
case "shell": {
const workdir = shellWorkdir(input, cwd)
return workdir ? [{ path: workdir }] : []
}
case "read":
case "edit":
case "write":
case "patch":
case "apply_patch":
return locationFrom(input.filePath ?? input.filepath)
case "external_directory":
return locationFrom(input.filePath ?? input.filepath, input.parentDir, input.directories)
case "grep":
case "glob":
case "context":
case "context7_resolve_library_id":
case "context7_get_library_docs":
return locationFrom(input.path)
default:
return []
}
}
export function pendingToolCall(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: { readonly input: ToolInput; readonly title?: string }
readonly cwd?: string
}): ToolCall {
return {
toolCallId: input.toolCallId,
title: toolTitle(input.toolName, input.state.input, input.state.title),
kind: toToolKind(input.toolName),
status: "pending",
locations: toLocations(input.toolName, input.state.input, input.cwd),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
}
}
export function runningToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: { readonly input: ToolInput; readonly title?: string }
readonly content?: ToolContent
readonly cwd?: string
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "in_progress",
kind: toToolKind(input.toolName),
title: toolTitle(input.toolName, input.state.input, input.state.title),
locations: toLocations(input.toolName, input.state.input, input.cwd),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
...(input.content?.length ? { content: toolContent(input.content) } : {}),
}
}
export function completedToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly input: ToolInput
readonly content: ToolContent
readonly structured: Readonly<Record<string, unknown>>
readonly result?: unknown
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "completed",
content: toolContent(input.content),
rawOutput: {
structured: input.structured,
...(input.result === undefined ? {} : { result: input.result }),
},
}
}
export function errorToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly input: ToolInput
readonly content: ToolContent
readonly structured: Readonly<Record<string, unknown>>
readonly error: string
readonly cwd?: string
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "failed",
kind: toToolKind(input.toolName),
title: toolTitle(input.toolName, input.input, undefined),
locations: toLocations(input.toolName, input.input, input.cwd),
rawInput: rawInput(input.toolName, input.input, input.cwd),
content: [
...toolContent(input.content),
{ type: "content", content: { type: "text", text: input.error } },
],
rawOutput: { structured: input.structured, error: input.error },
}
}
function toolContent(content: ToolContent): ToolCallContent[] {
return content.flatMap((part): ToolCallContent[] => {
if (part.type === "text") return [{ type: "content", content: { type: "text", text: part.text } }]
const match = /^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/.exec(part.uri)
if (!match?.[1]?.startsWith("image/") || match[2] === undefined) return []
return [{ type: "content", content: { type: "image", mimeType: match[1], data: match[2] } }]
})
}
function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) {
if (isShell(toolName)) return stringValue(input.command) ?? stringValue(input.cmd) ?? fallback ?? toolName
return fallback || toolName
}
function rawInput(toolName: string, input: ToolInput, cwd?: string): ToolInput {
if (!isShell(toolName) || input.cwd || input.workdir) return input
const workdir = shellWorkdir(input, cwd)
return workdir ? { ...input, cwd: workdir } : input
}
function shellWorkdir(input: ToolInput, cwd?: string) {
const explicit = stringValue(input.workdir) ?? stringValue(input.cwd)
if (!explicit) return cwd
return isAbsolute(explicit) ? explicit : resolve(cwd ?? process.cwd(), explicit)
}
function isShell(toolName: string) {
const tool = toolName.toLocaleLowerCase()
return tool === "bash" || tool === "shell"
}
function locationFrom(...values: unknown[]): ToolCallLocation[] {
return Array.from(
new Set(
values.flatMap((value): string[] => {
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string" && item.length > 0)
const path = stringValue(value)
return path ? [path] : []
}),
),
(path) => ({ path }),
)
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : undefined
}
export * as ACPTool from "./tool"
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test"
import { buildConfigOptions, formatVariantName, parseModelSelection, type ConfigOptionProvider } from "../../src/acp/config-option"
const providers: ConfigOptionProvider[] = [
{
id: "anthropic",
name: "Anthropic",
models: [
{ id: "claude/sonnet-4", name: "Claude Sonnet 4", variants: ["default", "high", "very-high"] },
{ id: "claude-haiku", name: "Claude Haiku" },
],
},
{ id: "openai", name: "OpenAI", models: [{ id: "gpt-5", name: "GPT-5", variants: ["minimal", "low"] }] },
]
describe("acp config options", () => {
test("builds model, effort, and mode options", () => {
const options = buildConfigOptions({
providers,
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
currentVariant: "high",
modes: [{ id: "build", name: "Build" }, { id: "plan", name: "Plan" }],
currentModeId: "build",
})
expect(options.map((option) => option.id)).toEqual(["model", "effort", "mode"])
expect(options[0]?.currentValue).toBe("anthropic/claude/sonnet-4")
expect(options[1]?.currentValue).toBe("high")
})
test("parses slash-containing model ids before variants", () => {
expect(parseModelSelection("anthropic/claude/sonnet-4", providers)).toEqual({
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
})
expect(parseModelSelection("anthropic/claude/sonnet-4/high", providers)).toEqual({
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
variant: "high",
})
})
test("formats variant names", () => {
expect(formatVariantName("very_high-effort")).toBe("Very High Effort")
})
})
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test"
import { pathToFileURL } from "node:url"
import { contentBlockToParts, partsToContentChunks } from "../../src/acp/content"
describe("acp content", () => {
test("converts annotated text and images", () => {
expect(contentBlockToParts({ type: "text", text: "internal", annotations: { audience: ["assistant"] } })).toEqual([
{ type: "text", text: "internal", synthetic: true },
])
expect(contentBlockToParts({ type: "image", data: "AAAA", mimeType: "image/png" })).toEqual([
{ type: "file", url: "data:image/png;base64,AAAA", filename: "image", mime: "image/png" },
])
})
test("converts file and embedded resources", () => {
expect(
contentBlockToParts({ type: "resource_link", uri: "file:///tmp/notes.txt", name: "notes.txt" }),
).toEqual([{ type: "file", url: "file:///tmp/notes.txt", filename: "notes.txt", mime: "text/plain" }])
expect(
contentBlockToParts({ type: "resource", resource: { uri: "mcp://context", text: "hello" } }),
).toEqual([{ type: "text", text: "[mcp://context]\nhello" }])
})
test("replays files and data urls", () => {
expect(
partsToContentChunks([
{ type: "file", url: "file:///tmp/readme.md", filename: "readme.md", mime: "text/markdown" },
{ type: "file", url: "data:text/plain;base64,aGVsbG8=", filename: "note.txt", mime: "text/plain" },
]),
).toEqual([
{ content: { type: "resource_link", uri: "file:///tmp/readme.md", name: "readme.md", mimeType: "text/markdown" } },
{
content: {
type: "resource",
resource: { uri: pathToFileURL("note.txt").href, mimeType: "text/plain", text: "hello" },
},
},
])
})
})
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test"
import { completedToolUpdate, errorToolUpdate, pendingToolCall, runningToolUpdate, toLocations, toToolKind } from "../../src/acp/tool"
describe("acp tools", () => {
test("maps kinds and locations", () => {
expect(toToolKind("shell")).toBe("execute")
expect(toToolKind("edit")).toBe("edit")
expect(toToolKind("grep")).toBe("search")
expect(toLocations("read", { filePath: "/tmp/a.ts" })).toEqual([{ path: "/tmp/a.ts" }])
expect(toLocations("shell", { command: "pwd" }, "/workspace")).toEqual([{ path: "/workspace" }])
})
test("builds tool lifecycle updates", () => {
expect(pendingToolCall({ toolCallId: "call", toolName: "read", state: { input: { filePath: "/tmp/a" } } })).toMatchObject({
toolCallId: "call",
status: "pending",
kind: "read",
})
expect(runningToolUpdate({ toolCallId: "call", toolName: "read", state: { input: {} } })).toMatchObject({
toolCallId: "call",
status: "in_progress",
})
expect(completedToolUpdate({ toolCallId: "call", toolName: "read", input: {}, content: [{ type: "text", text: "done" }], structured: {} })).toMatchObject({
toolCallId: "call",
status: "completed",
content: [{ type: "content", content: { type: "text", text: "done" } }],
})
expect(errorToolUpdate({ toolCallId: "call", toolName: "read", input: {}, content: [], structured: {}, error: "failed" })).toMatchObject({
toolCallId: "call",
status: "failed",
})
})
})