diff --git a/bun.lock b/bun.lock
index bb621335ec..8992576c8f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -124,6 +124,7 @@
"opencode2": "./bin/opencode2.cjs",
},
"dependencies": {
+ "@agentclientprotocol/sdk": "0.21.0",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -1159,6 +1160,8 @@
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
+ "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.21.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ONj+Q8qOdNQp5XbH5jnMwzT9IKZJsSN0p0lkceS4GtUtNOPVLpNzSS8gqQdGMKfBvA0ESbkL8BTaSN1Rc9miEw=="],
+
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.81", "@ai-sdk/openai": "3.0.67", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@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-PsSh7a6qW+3kQXPs1kD4wDwuZby0t1PIaB6j/1aMKmPFJ5LxcIcULLMF/bjITLt5o/8lc0t6TXIwG0zlhH7uZw=="],
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 096124aea7..7ace585e6a 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -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:*",
diff --git a/packages/cli/src/acp/agent.ts b/packages/cli/src/acp/agent.ts
new file mode 100644
index 0000000000..cf8eb693f7
--- /dev/null
+++ b/packages/cli/src/acp/agent.ts
@@ -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(promise: Promise) {
+ 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"
diff --git a/packages/cli/src/acp/config-option.ts b/packages/cli/src/acp/config-option.ts
new file mode 100644
index 0000000000..eaba3cd66a
--- /dev/null
+++ b/packages/cli/src/acp/config-option.ts
@@ -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
+}
+
+export type ConfigOptionProvider = {
+ id: string
+ name: string
+ models: ReadonlyArray
+}
+
+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"
diff --git a/packages/cli/src/acp/content.ts b/packages/cli/src/acp/content.ts
new file mode 100644
index 0000000000..3755591c1f
--- /dev/null
+++ b/packages/cli/src/acp/content.ts
@@ -0,0 +1,183 @@
+import type { ContentBlock, ContentChunk, ResourceLink } 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": {
+ const audience = block.annotations?.audience
+ if (audience?.length === 1 && audience[0] === "assistant") {
+ return [{ type: "text", text: block.text, synthetic: true }]
+ }
+ if (audience?.length === 1 && audience[0] === "user") {
+ return [{ type: "text", text: block.text, ignored: true }]
+ }
+ return [{ type: "text", text: block.text }]
+ }
+ 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.synthetic && 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 match = /^data:([^;]+);base64,(.*)$/.exec(part.url)
+ if (!match?.[1] || match[2] === undefined) return []
+ const mime = match[1]
+ const data = match[2]
+ if (mime.startsWith("image/")) {
+ return [
+ {
+ content: {
+ type: "image",
+ mimeType: mime,
+ data,
+ uri: pathToFileURL(part.filename ?? "image").href,
+ },
+ },
+ ]
+ }
+ return [
+ {
+ content: {
+ type: "resource",
+ resource:
+ mime.startsWith("text/") || mime === "application/json"
+ ? {
+ uri: pathToFileURL(part.filename ?? "file").href,
+ mimeType: mime,
+ text: Buffer.from(data, "base64").toString("utf8"),
+ }
+ : {
+ uri: pathToFileURL(part.filename ?? "file").href,
+ mimeType: mime,
+ blob: data,
+ },
+ },
+ },
+ ]
+ })
+}
+
+function resourceLinkToPart(link: ResourceLink): PromptPart {
+ 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://") && URL.canParse(link.uri)) {
+ 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",
+ }
+ }
+ return { type: "text", text: link.uri }
+}
+
+function filenameFromUri(uri: string | undefined): string | undefined {
+ if (!uri || uri.startsWith("data:")) return undefined
+ if (URL.canParse(uri)) return path.basename(new URL(uri).pathname) || undefined
+ return path.basename(uri) || undefined
+}
+
+export * as ACPContent from "./content"
diff --git a/packages/cli/src/acp/error.ts b/packages/cli/src/acp/error.ts
new file mode 100644
index 0000000000..50dec3f4f6
--- /dev/null
+++ b/packages/cli/src/acp/error.ts
@@ -0,0 +1,86 @@
+import { RequestError } from "@agentclientprotocol/sdk"
+import { Schema } from "effect"
+
+export class SessionNotFoundError extends Schema.TaggedErrorClass()("ACPSessionNotFoundError", {
+ sessionId: Schema.String,
+}) {}
+
+export class InvalidConfigOptionError extends Schema.TaggedErrorClass()(
+ "ACPInvalidConfigOptionError",
+ { configId: Schema.String },
+) {}
+
+export class InvalidModelError extends Schema.TaggedErrorClass()("ACPInvalidModelError", {
+ modelId: Schema.String,
+ providerId: Schema.optional(Schema.String),
+}) {}
+
+export class InvalidEffortError extends Schema.TaggedErrorClass()("ACPInvalidEffortError", {
+ effort: Schema.String,
+}) {}
+
+export class InvalidModeError extends Schema.TaggedErrorClass()("ACPInvalidModeError", {
+ mode: Schema.String,
+}) {}
+
+export class AuthRequiredError extends Schema.TaggedErrorClass()("ACPAuthRequiredError", {}) {}
+
+export class UnknownAuthMethodError extends Schema.TaggedErrorClass()(
+ "ACPUnknownAuthMethodError",
+ { methodId: Schema.String },
+) {}
+
+export class ServiceFailureError extends Schema.TaggedErrorClass()("ACPServiceFailureError", {
+ safeMessage: Schema.String,
+ service: Schema.optional(Schema.String),
+ errorName: Schema.optional(Schema.String),
+}) {}
+
+export type Error =
+ | SessionNotFoundError
+ | InvalidConfigOptionError
+ | InvalidModelError
+ | InvalidEffortError
+ | InvalidModeError
+ | AuthRequiredError
+ | UnknownAuthMethodError
+ | ServiceFailureError
+
+export function toRequestError(error: Error): RequestError {
+ 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 "ACPAuthRequiredError":
+ return RequestError.authRequired({}, "provider authentication required")
+ 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,
+ )
+ }
+ const exhaustive: never = error
+ return exhaustive
+}
+
+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"
diff --git a/packages/cli/src/acp/event.ts b/packages/cli/src/acp/event.ts
new file mode 100644
index 0000000000..11bf4c11c5
--- /dev/null
+++ b/packages/cli/src/acp/event.ts
@@ -0,0 +1,441 @@
+import type { AgentSideConnection, PromptResponse } from "@agentclientprotocol/sdk"
+import type {
+ EventSubscribeOutput,
+ OpenCodeClient,
+ SessionMessageAssistant,
+ SessionMessageInfo,
+} from "@opencode-ai/client/promise"
+import { partsToContentChunks, type ReplayPart } from "./content"
+import { ACPError } from "./error"
+import { replyPermission, syncEditedFiles } from "./permission"
+import {
+ completedToolUpdate,
+ errorToolUpdate,
+ pendingToolCall,
+ runningToolUpdate,
+ type ToolContent,
+ type ToolInput,
+} from "./tool"
+
+type Connection = Pick &
+ Partial>
+
+export type TurnControl = {
+ cancelled: boolean
+ readonly admission: AbortController
+}
+
+type ToolState = {
+ readonly name: string
+ input: ToolInput
+ structured: Record
+ content: ToolContent
+}
+
+export type TurnStart =
+ | { readonly type: "input"; readonly id: string }
+ | { readonly type: "skill"; readonly id: string }
+ | { readonly type: "compaction"; readonly id: string }
+
+function emptyToolState(): ToolState {
+ return { name: "tool", input: {}, structured: {}, content: [] }
+}
+
+export async function streamTurn(input: {
+ readonly client: OpenCodeClient
+ readonly connection: Connection
+ readonly sessionID: string
+ readonly cwd: string
+ readonly start: TurnStart
+ readonly userMessageID?: string | null
+ readonly submit: (signal: AbortSignal) => Promise
+ readonly control: TurnControl
+}): Promise {
+ const streamController = new AbortController()
+ const stream = input.client.event.subscribe({ signal: streamController.signal })[Symbol.asyncIterator]()
+ const connected = await stream.next()
+ if (connected.done) throw new Error("event stream disconnected before prompt admission")
+
+ const control = input.control
+ let started = false
+ let assistantMessageID: string | undefined
+ let finish: SessionMessageAssistant["finish"]
+ let executionError: { readonly type: string; readonly message: string } | undefined
+ const tools = new Map()
+
+ const update = (value: Parameters[0]["update"]) =>
+ input.connection.sessionUpdate({ sessionId: input.sessionID, update: value })
+
+ const consume = async () => {
+ while (!streamController.signal.aborted) {
+ const next = await stream.next()
+ if (next.done) throw new Error("event stream disconnected during prompt execution")
+ const event = next.value
+ if (event.type === "permission.v2.asked" && event.data.sessionID === input.sessionID) {
+ const tool = event.data.source?.callID ? tools.get(event.data.source.callID) : undefined
+ await replyPermission({
+ client: input.client,
+ connection: input.connection,
+ event,
+ sessionID: input.sessionID,
+ cwd: input.cwd,
+ tool,
+ })
+ continue
+ }
+ if (event.type === "form.created" && event.data.form.sessionID === input.sessionID) {
+ await input.client.form
+ .cancel({ sessionID: input.sessionID, formID: event.data.form.id })
+ .catch(() => input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}))
+ continue
+ }
+ if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
+ if (matchesStart(event, input.start)) {
+ started = true
+ continue
+ }
+ if (!started) continue
+
+ if (event.type === "session.step.started") {
+ assistantMessageID = event.data.assistantMessageID
+ continue
+ }
+ if (event.type === "session.text.delta") {
+ assistantMessageID = event.data.assistantMessageID
+ await update({
+ sessionUpdate: "agent_message_chunk",
+ messageId: event.data.assistantMessageID,
+ content: { type: "text", text: event.data.delta },
+ })
+ continue
+ }
+ if (event.type === "session.reasoning.delta") {
+ assistantMessageID = event.data.assistantMessageID
+ await update({
+ sessionUpdate: "agent_thought_chunk",
+ messageId: event.data.assistantMessageID,
+ content: { type: "text", text: event.data.delta },
+ })
+ continue
+ }
+ if (event.type === "session.tool.input.started") {
+ assistantMessageID = event.data.assistantMessageID
+ tools.set(event.data.callID, { name: event.data.name, input: {}, structured: {}, content: [] })
+ await update({
+ sessionUpdate: "tool_call",
+ ...pendingToolCall({
+ toolCallId: event.data.callID,
+ toolName: event.data.name,
+ state: { input: {} },
+ cwd: input.cwd,
+ }),
+ })
+ continue
+ }
+ if (event.type === "session.tool.called") {
+ assistantMessageID = event.data.assistantMessageID
+ const current = tools.get(event.data.callID) ?? emptyToolState()
+ current.input = event.data.input
+ tools.set(event.data.callID, current)
+ await update({
+ sessionUpdate: "tool_call_update",
+ ...runningToolUpdate({
+ toolCallId: event.data.callID,
+ toolName: current.name,
+ state: { input: current.input },
+ cwd: input.cwd,
+ }),
+ })
+ continue
+ }
+ if (event.type === "session.tool.progress") {
+ const current = tools.get(event.data.callID)
+ if (!current) continue
+ current.structured = event.data.structured
+ current.content = event.data.content
+ await update({
+ sessionUpdate: "tool_call_update",
+ ...runningToolUpdate({
+ toolCallId: event.data.callID,
+ toolName: current.name,
+ state: { input: current.input },
+ content: current.content,
+ cwd: input.cwd,
+ }),
+ })
+ continue
+ }
+ if (event.type === "session.tool.success") {
+ const current = tools.get(event.data.callID) ?? emptyToolState()
+ tools.delete(event.data.callID)
+ await syncEditedFiles({
+ connection: input.connection,
+ sessionID: input.sessionID,
+ cwd: input.cwd,
+ toolName: current.name,
+ toolInput: current.input,
+ structured: event.data.structured,
+ }).catch(() => {})
+ await update({
+ sessionUpdate: "tool_call_update",
+ ...completedToolUpdate({
+ toolCallId: event.data.callID,
+ toolName: current.name,
+ input: current.input,
+ structured: event.data.structured,
+ content: event.data.content,
+ result: event.data.result,
+ }),
+ })
+ continue
+ }
+ if (event.type === "session.tool.failed") {
+ const current = tools.get(event.data.callID) ?? emptyToolState()
+ tools.delete(event.data.callID)
+ await update({
+ sessionUpdate: "tool_call_update",
+ ...errorToolUpdate({
+ toolCallId: event.data.callID,
+ toolName: current.name,
+ input: current.input,
+ structured: current.structured,
+ content: current.content,
+ error: event.data.error.message,
+ cwd: input.cwd,
+ }),
+ })
+ continue
+ }
+ if (event.type === "session.step.ended") {
+ assistantMessageID = event.data.assistantMessageID
+ finish = event.data.finish
+ continue
+ }
+ if (event.type === "session.execution.succeeded") return "succeeded" as const
+ if (event.type === "session.execution.interrupted") return "interrupted" as const
+ if (event.type === "session.execution.failed") {
+ executionError = event.data.error
+ return "failed" as const
+ }
+ }
+ return "interrupted" as const
+ }
+
+ const completed = consume()
+ try {
+ await input.submit(control.admission.signal).catch((error) => {
+ if (!control.cancelled) throw error
+ })
+ if (control.cancelled) {
+ await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
+ if (!started) {
+ streamController.abort()
+ await completed.catch(() => {})
+ return response(undefined, undefined, "interrupted", true, undefined, input.userMessageID)
+ }
+ }
+ const terminal = await completed
+ const assistant = assistantMessageID
+ ? await input.client.session
+ .message({ sessionID: input.sessionID, messageID: assistantMessageID })
+ .catch(() => undefined)
+ : undefined
+ return response(
+ assistant?.type === "assistant" ? assistant : undefined,
+ executionError,
+ terminal,
+ control.cancelled,
+ finish,
+ input.userMessageID,
+ )
+ } catch (error) {
+ streamController.abort()
+ await completed.catch(() => {})
+ throw error
+ } finally {
+ streamController.abort()
+ await stream.return?.(undefined).catch(() => {})
+ }
+}
+
+export async function replayMessages(
+ connection: Pick,
+ sessionID: string,
+ cwd: string,
+ messages: readonly SessionMessageInfo[],
+) {
+ for (const message of messages) await replayMessage(connection, sessionID, cwd, message).catch(() => {})
+}
+
+async function replayMessage(
+ connection: Pick,
+ sessionID: string,
+ cwd: string,
+ message: SessionMessageInfo,
+) {
+ if (message.type === "user") {
+ await connection.sessionUpdate({
+ sessionId: sessionID,
+ update: {
+ sessionUpdate: "user_message_chunk",
+ messageId: message.id,
+ content: { type: "text", text: message.text },
+ },
+ })
+ const files: ReplayPart[] = (message.files ?? []).map((file) => ({
+ type: "file",
+ url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
+ filename: file.name,
+ mime: file.mime,
+ }))
+ for (const chunk of partsToContentChunks(files)) {
+ await connection.sessionUpdate({
+ sessionId: sessionID,
+ update: { sessionUpdate: "user_message_chunk", messageId: message.id, ...chunk },
+ })
+ }
+ return
+ }
+ if (message.type !== "assistant") return
+ for (const part of message.content) {
+ if (part.type === "text") {
+ await connection.sessionUpdate({
+ sessionId: sessionID,
+ update: {
+ sessionUpdate: "agent_message_chunk",
+ messageId: message.id,
+ content: { type: "text", text: part.text },
+ },
+ })
+ continue
+ }
+ if (part.type === "reasoning") {
+ await connection.sessionUpdate({
+ sessionId: sessionID,
+ update: {
+ sessionUpdate: "agent_thought_chunk",
+ messageId: message.id,
+ content: { type: "text", text: part.text },
+ },
+ })
+ continue
+ }
+ await connection.sessionUpdate({
+ sessionId: sessionID,
+ update: {
+ sessionUpdate: "tool_call",
+ ...pendingToolCall({
+ toolCallId: part.id,
+ toolName: part.name,
+ state: { input: part.state.status === "streaming" ? {} : part.state.input },
+ cwd,
+ }),
+ },
+ })
+ switch (part.state.status) {
+ case "completed":
+ await connection.sessionUpdate({
+ sessionId: sessionID,
+ update: {
+ sessionUpdate: "tool_call_update",
+ ...completedToolUpdate({
+ toolCallId: part.id,
+ toolName: part.name,
+ input: part.state.input,
+ structured: part.state.structured,
+ content: part.state.content,
+ result: part.state.result,
+ }),
+ },
+ })
+ break
+ case "running":
+ await connection.sessionUpdate({
+ sessionId: sessionID,
+ update: {
+ sessionUpdate: "tool_call_update",
+ ...runningToolUpdate({
+ toolCallId: part.id,
+ toolName: part.name,
+ state: { input: part.state.input },
+ content: part.state.content,
+ cwd,
+ }),
+ },
+ })
+ break
+ case "error":
+ await connection.sessionUpdate({
+ sessionId: sessionID,
+ update: {
+ sessionUpdate: "tool_call_update",
+ ...errorToolUpdate({
+ toolCallId: part.id,
+ toolName: part.name,
+ input: part.state.input,
+ structured: part.state.structured,
+ content: part.state.content,
+ error: part.state.error.message,
+ cwd,
+ }),
+ },
+ })
+ break
+ case "streaming":
+ break
+ }
+ }
+}
+
+function matchesStart(event: EventSubscribeOutput, start: TurnStart) {
+ if (start.type === "input") return event.type === "session.input.promoted" && event.data.inputID === start.id
+ if (start.type === "compaction")
+ return event.type === "session.compaction.admitted" && event.data.inputID === start.id
+ return event.type === "session.skill.activated" && event.id === start.id.replace(/^msg_/, "evt_")
+}
+
+function response(
+ assistant: SessionMessageAssistant | undefined,
+ executionError: { readonly type: string; readonly message: string } | undefined,
+ terminal: "succeeded" | "failed" | "interrupted",
+ cancelled: boolean,
+ finish: SessionMessageAssistant["finish"],
+ messageID: string | null | undefined,
+): PromptResponse {
+ const error = assistant?.error ?? executionError
+ if (error?.type === "provider.auth") throw new ACPError.AuthRequiredError()
+ if (error && error.type !== "aborted" && error.type !== "provider.content-filter") {
+ throw new ACPError.ServiceFailureError({
+ safeMessage: error.message || "OpenCode prompt failed",
+ service: "session",
+ errorName: error.type,
+ })
+ }
+ const tokens = assistant?.tokens
+ const usage = tokens
+ ? {
+ inputTokens: tokens.input,
+ outputTokens: tokens.output,
+ totalTokens: tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write,
+ ...(tokens.reasoning > 0 ? { thoughtTokens: tokens.reasoning } : {}),
+ ...(tokens.cache.read > 0 ? { cachedReadTokens: tokens.cache.read } : {}),
+ ...(tokens.cache.write > 0 ? { cachedWriteTokens: tokens.cache.write } : {}),
+ }
+ : undefined
+ const stopReason = resolveStopReason({ terminal, cancelled, finish, error: error?.type })
+ return { stopReason, ...(usage ? { usage } : {}), ...(messageID ? { userMessageId: messageID } : {}), _meta: {} }
+}
+
+function resolveStopReason(input: {
+ readonly terminal: "succeeded" | "failed" | "interrupted"
+ readonly cancelled: boolean
+ readonly finish: SessionMessageAssistant["finish"]
+ readonly error?: string
+}): PromptResponse["stopReason"] {
+ if (input.cancelled || input.terminal === "interrupted" || input.error === "aborted") return "cancelled"
+ if (input.finish === "length") return "max_tokens"
+ if (input.finish === "content-filter" || input.error === "provider.content-filter") return "refusal"
+ return "end_turn"
+}
+
+export * as ACPEvent from "./event"
diff --git a/packages/cli/src/acp/permission.ts b/packages/cli/src/acp/permission.ts
new file mode 100644
index 0000000000..71086bf909
--- /dev/null
+++ b/packages/cli/src/acp/permission.ts
@@ -0,0 +1,179 @@
+import type { AgentSideConnection, PermissionOption, ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk"
+import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
+import { Patch } from "@opencode-ai/util/patch"
+import { Result } from "effect"
+import { isAbsolute, resolve } from "node:path"
+import { pendingToolCall, stringValue, toLocations, toToolKind, type ToolInput } from "./tool"
+
+type PermissionEvent = Extract
+type Connection = Pick & Partial>
+type Tool = { readonly name: string; readonly input: ToolInput }
+
+const options: PermissionOption[] = [
+ { optionId: "once", kind: "allow_once", name: "Allow once" },
+ { optionId: "always", kind: "allow_always", name: "Always allow" },
+ { optionId: "reject", kind: "reject_once", name: "Reject" },
+]
+
+export async function replyPermission(input: {
+ readonly client: OpenCodeClient
+ readonly connection: Connection
+ readonly event: PermissionEvent
+ readonly sessionID: string
+ readonly cwd: string
+ readonly tool?: Tool
+}) {
+ const toolName = input.tool?.name ?? input.event.data.action
+ const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
+ const previews = await permissionPreviews(toolName, toolInput, input.cwd)
+ const result = await input.connection
+ .requestPermission({
+ sessionId: input.sessionID,
+ toolCall: {
+ ...pendingToolCall({
+ toolCallId: input.event.data.source?.callID ?? input.event.data.id,
+ toolName,
+ state: { input: toolInput, title: permissionTitle(toolName, toolInput, previews) },
+ cwd: input.cwd,
+ }),
+ locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
+ ...(previews.length > 0 ? { content: previews } : {}),
+ },
+ options,
+ })
+ .catch(() => undefined)
+ const selected = result?.outcome.outcome === "selected" ? result.outcome.optionId : undefined
+ const reply = selected === "once" || selected === "always" ? selected : "reject"
+ await input.client.permission.reply({
+ sessionID: input.sessionID,
+ requestID: input.event.data.id,
+ reply,
+ })
+}
+
+export async function syncEditedFiles(input: {
+ readonly connection: Partial>
+ readonly sessionID: string
+ readonly cwd: string
+ readonly toolName: string
+ readonly toolInput: ToolInput
+ readonly structured: Readonly>
+}) {
+ if (!input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return
+ const files = Array.isArray(input.structured.files)
+ ? input.structured.files.flatMap((file): string[] => {
+ if (!file || typeof file !== "object") return []
+ const path = Reflect.get(file, "file")
+ return typeof path === "string" ? [path] : []
+ })
+ : []
+ const path = filePath(input.toolInput)
+ const paths = [...new Set([...files, ...(path ? [path] : [])])]
+ await Promise.all(
+ paths.map(async (path) => {
+ const target = resolvePath(path, input.cwd)
+ const file = Bun.file(target)
+ if (!(await file.exists())) return
+ await input.connection.writeTextFile?.({ sessionId: input.sessionID, path: target, content: await file.text() })
+ }),
+ )
+}
+
+async function permissionPreviews(toolName: string, input: ToolInput, cwd: string): Promise {
+ const tool = toolName.toLocaleLowerCase()
+ if (tool === "patch" || tool === "apply_patch") return patchPreviews(input, cwd)
+ const path = filePath(input)
+ if (!path) return []
+ const oldText = await readText(path, cwd)
+ if (tool === "write") {
+ const content = stringValue(input.content)
+ return content === undefined ? [] : [{ type: "diff", path, oldText, newText: content }]
+ }
+ if (tool !== "edit") return []
+ const oldString = stringValue(input.oldString)
+ const newString = stringValue(input.newString)
+ if (oldString === undefined || newString === undefined) return []
+ const newText =
+ input.replaceAll === true ? oldText.replaceAll(oldString, newString) : oldText.replace(oldString, newString)
+ return [{ type: "diff", path, oldText, newText }]
+}
+
+async function patchPreviews(input: ToolInput, cwd: string): Promise {
+ const patchText = stringValue(input.patchText)
+ if (!patchText) return []
+ try {
+ const parsed = Patch.parse(patchText)
+ if (Result.isFailure(parsed)) return []
+ return await Promise.all(
+ parsed.success.map(async (hunk): Promise => {
+ const oldText = hunk.type === "add" ? "" : await readText(hunk.path, cwd)
+ if (hunk.type === "add") {
+ const newText = hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
+ return { type: "diff", path: hunk.path, oldText, newText }
+ }
+ if (hunk.type === "delete") return { type: "diff", path: hunk.path, oldText, newText: "" }
+ return {
+ type: "diff",
+ path: hunk.movePath ?? hunk.path,
+ oldText,
+ newText: Patch.derive(hunk.path, hunk.chunks, oldText).content,
+ }
+ }),
+ )
+ } catch {
+ return []
+ }
+}
+
+function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray) {
+ if (previews.length > 1) return `${previews.length} files`
+ switch (toolName.toLocaleLowerCase()) {
+ case "external_directory":
+ return stringValue(input.description) ?? stringValue(input.command) ?? stringValue(input.parentDir)
+ case "webfetch":
+ return stringValue(input.url)
+ case "websearch":
+ return stringValue(input.query)
+ case "grep":
+ case "glob":
+ return stringValue(input.pattern)
+ case "read":
+ case "edit":
+ case "write":
+ case "patch":
+ case "apply_patch":
+ return filePath(input) ?? (previews[0]?.type === "diff" ? previews[0].path : undefined)
+ default:
+ return undefined
+ }
+}
+
+function permissionLocations(
+ toolName: string,
+ input: ToolInput,
+ resources: ReadonlyArray,
+ cwd: string,
+ previews: ReadonlyArray,
+): ToolCallLocation[] {
+ const paths = previews.flatMap((preview) => (preview.type === "diff" ? [preview.path] : []))
+ if (paths.length > 0) return [...new Set(paths)].map((path) => ({ path }))
+ const locations = toLocations(toolName, input, cwd)
+ if (locations.length > 0) return locations
+ return resources.filter((resource) => resource !== "*").map((path) => ({ path }))
+}
+
+function readText(path: string, cwd: string) {
+ return Bun.file(resolvePath(path, cwd))
+ .text()
+ .catch(() => "")
+}
+
+function filePath(input: ToolInput) {
+ return stringValue(input.path) ?? stringValue(input.filePath) ?? stringValue(input.filepath)
+}
+
+function resolvePath(path: string, cwd: string) {
+ return isAbsolute(path) ? path : resolve(cwd, path)
+}
+
+export * as ACPPermission from "./permission"
diff --git a/packages/cli/src/acp/service.ts b/packages/cli/src/acp/service.ts
new file mode 100644
index 0000000000..ff68dd93ef
--- /dev/null
+++ b/packages/cli/src/acp/service.ts
@@ -0,0 +1,531 @@
+import {
+ isSessionNotFoundError,
+ type CommandInfo,
+ type ModelInfo,
+ type ModelRef,
+ type OpenCodeClient,
+ type SessionInfo,
+ type SessionMessageInfo,
+ type SkillInfo,
+} from "@opencode-ai/client/promise"
+import type {
+ AgentSideConnection,
+ AuthenticateRequest,
+ AuthenticateResponse,
+ AuthMethod,
+ CancelNotification,
+ CloseSessionRequest,
+ CloseSessionResponse,
+ ForkSessionRequest,
+ ForkSessionResponse,
+ InitializeRequest,
+ InitializeResponse,
+ ListSessionsRequest,
+ ListSessionsResponse,
+ LoadSessionRequest,
+ LoadSessionResponse,
+ McpServer,
+ NewSessionRequest,
+ NewSessionResponse,
+ PromptRequest,
+ PromptResponse,
+ ResumeSessionRequest,
+ ResumeSessionResponse,
+ SetSessionConfigOptionRequest,
+ SetSessionConfigOptionResponse,
+ SetSessionModelRequest,
+ SetSessionModelResponse,
+ SetSessionModeRequest,
+ SetSessionModeResponse,
+} from "@agentclientprotocol/sdk"
+import { InstallationVersion } from "@opencode-ai/util/installation/version"
+import { SessionMessage } from "@opencode-ai/schema/session-message"
+import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
+import { promptContentToParts } from "./content"
+import { replayMessages, streamTurn, type TurnControl, type TurnStart } from "./event"
+import { ACPError } from "./error"
+
+export const AuthMethodID = "opencode-login"
+
+type Connection = Pick
+
+type Catalog = {
+ readonly providers: ConfigOptionProvider[]
+ readonly models: ModelInfo[]
+ readonly defaultModel: ModelRef
+ readonly modes: Array<{ id: string; name: string; description?: string }>
+ readonly defaultModeID: string
+ readonly commands: CommandInfo[]
+ readonly skills: SkillInfo[]
+}
+
+type Attached = {
+ readonly id: string
+ readonly cwd: string
+ catalog: Catalog
+ model: ModelRef
+ modeID: string
+}
+
+type PreparedPrompt = {
+ readonly start: TurnStart
+ readonly text: string
+ readonly files: Array<{ readonly uri: string; readonly name?: string }>
+ readonly synthetic: ReadonlyArray
+ readonly slash?: { readonly name: string; readonly args: string }
+ readonly command?: CommandInfo
+ readonly skill?: SkillInfo
+}
+
+export interface Interface {
+ initialize(input: InitializeRequest): Promise
+ authenticate(input: AuthenticateRequest): Promise
+ newSession(input: NewSessionRequest): Promise
+ loadSession(input: LoadSessionRequest): Promise
+ listSessions(input: ListSessionsRequest): Promise
+ resumeSession(input: ResumeSessionRequest): Promise
+ closeSession(input: CloseSessionRequest): Promise
+ forkSession(input: ForkSessionRequest): Promise
+ setSessionConfigOption(input: SetSessionConfigOptionRequest): Promise
+ setSessionMode(input: SetSessionModeRequest): Promise
+ setSessionModel(input: SetSessionModelRequest): Promise
+ prompt(input: PromptRequest): Promise
+ cancel(input: CancelNotification): Promise
+}
+
+export function make(input: { readonly client: OpenCodeClient; readonly connection: Connection }): Interface {
+ const sessions = new Map()
+ const catalogs = new Map>()
+ const registeredMcp = new Map>()
+ const active = new Map()
+
+ const catalog = (cwd: string) => {
+ const cached = catalogs.get(cwd)
+ if (cached) return cached
+ const loaded = loadCatalog(input.client, cwd).catch((error) => {
+ catalogs.delete(cwd)
+ throw error
+ })
+ catalogs.set(cwd, loaded)
+ return loaded
+ }
+
+ const requireSession = async (sessionID: string) => {
+ const current = sessions.get(sessionID)
+ if (current) return current
+ throw new ACPError.SessionNotFoundError({ sessionId: sessionID })
+ }
+
+ const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => {
+ const currentCatalog = await catalog(cwd)
+ const state: Attached = {
+ id: session.id,
+ cwd,
+ catalog: currentCatalog,
+ model: session.model ?? currentCatalog.defaultModel,
+ modeID: session.agent ?? currentCatalog.defaultModeID,
+ }
+ sessions.set(session.id, state)
+ await registerMcpServers(input.client, registeredMcp, state, mcpServers)
+ await input.connection.sessionUpdate({
+ sessionId: state.id,
+ update: {
+ sessionUpdate: "available_commands_update",
+ availableCommands: [
+ ...state.catalog.commands,
+ ...state.catalog.skills.filter(
+ (skill) => !state.catalog.commands.some((command) => command.name === skill.name),
+ ),
+ ].map((command) => ({ name: command.name, description: command.description ?? "" })),
+ },
+ })
+ return state
+ }
+
+ const replay = async (state: Attached) => {
+ await replayMessages(input.connection, state.id, state.cwd, await messages(input.client, state.id))
+ }
+
+ const configOptions = (state: Attached) =>
+ buildConfigOptions({
+ providers: state.catalog.providers,
+ currentModel: { providerID: state.model.providerID, modelID: state.model.id },
+ currentVariant: state.model.variant,
+ modes: state.catalog.modes,
+ currentModeId: state.modeID,
+ })
+
+ return {
+ initialize: async (params) => {
+ const authMethod: AuthMethod = {
+ description: "Run `opencode auth login` in the terminal",
+ name: "Login with opencode",
+ id: AuthMethodID,
+ }
+ if (params.clientCapabilities?._meta?.["terminal-auth"] === true) {
+ authMethod._meta = {
+ "terminal-auth": { command: "opencode", args: ["auth", "login"], label: "OpenCode Login" },
+ }
+ }
+ return {
+ protocolVersion: 1,
+ agentCapabilities: {
+ loadSession: true,
+ mcpCapabilities: { http: true, sse: false },
+ promptCapabilities: { embeddedContext: true, image: true },
+ sessionCapabilities: { close: {}, fork: {}, list: {}, resume: {} },
+ },
+ authMethods: [authMethod],
+ agentInfo: { name: "OpenCode", version: InstallationVersion },
+ }
+ },
+ authenticate: async (params) => {
+ if (params.methodId !== AuthMethodID) throw new ACPError.UnknownAuthMethodError({ methodId: params.methodId })
+ return {}
+ },
+ newSession: async (params) => {
+ const currentCatalog = await catalog(params.cwd)
+ const created = await input.client.session.create({
+ location: { directory: params.cwd },
+ agent: currentCatalog.defaultModeID,
+ model: currentCatalog.defaultModel,
+ })
+ const state = await attach(created, params.cwd, params.mcpServers)
+ return { sessionId: state.id, configOptions: configOptions(state) }
+ },
+ loadSession: async (params) => {
+ const session = await getSession(input.client, params.sessionId)
+ const state = await attach(session, session.location.directory, params.mcpServers)
+ await replay(state)
+ return { configOptions: configOptions(state) }
+ },
+ listSessions: async (params) => {
+ const page = await input.client.session.list({
+ ...(params.cwd ? { directory: params.cwd } : {}),
+ order: "desc",
+ limit: 100,
+ ...(params.cursor ? { cursor: params.cursor } : {}),
+ })
+ return {
+ sessions: page.data.map((session) => ({
+ sessionId: session.id,
+ cwd: session.location.directory,
+ title: session.title,
+ updatedAt: new Date(session.time.updated).toISOString(),
+ })),
+ ...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
+ }
+ },
+ resumeSession: async (params) => {
+ const session = await getSession(input.client, params.sessionId)
+ const state = await attach(session, session.location.directory, params.mcpServers ?? [])
+ return { configOptions: configOptions(state) }
+ },
+ closeSession: async (params) => {
+ sessions.delete(params.sessionId)
+ registeredMcp.delete(params.sessionId)
+ const turn = active.get(params.sessionId)
+ if (turn) {
+ turn.cancelled = true
+ turn.admission.abort()
+ }
+ await input.client.session.interrupt({ sessionID: params.sessionId }).catch(() => {})
+ return {}
+ },
+ forkSession: async (params) => {
+ const forked = await input.client.session.fork({ sessionID: params.sessionId })
+ const state = await attach(forked, forked.location.directory, params.mcpServers ?? [])
+ await replay(state)
+ return { sessionId: state.id, configOptions: configOptions(state) }
+ },
+ setSessionConfigOption: async (params) => {
+ const state = await requireSession(params.sessionId)
+ if (typeof params.value !== "string") throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
+ switch (params.configId) {
+ case "model": {
+ const selected = requireModel(state.catalog, params.value)
+ state.model = selected
+ await input.client.session.switchModel({ sessionID: state.id, model: selected })
+ break
+ }
+ case "effort": {
+ const model = state.catalog.models.find(
+ (item) => item.providerID === state.model.providerID && item.id === state.model.id,
+ )
+ if (!model?.variants.some((variant) => variant.id === params.value))
+ throw new ACPError.InvalidEffortError({ effort: params.value })
+ state.model = { ...state.model, variant: params.value }
+ await input.client.session.switchModel({ sessionID: state.id, model: state.model })
+ break
+ }
+ case "mode":
+ await selectMode(input.client, state, params.value)
+ break
+ default:
+ throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
+ }
+ return { configOptions: configOptions(state) }
+ },
+ setSessionMode: async (params) => {
+ await selectMode(input.client, await requireSession(params.sessionId), params.modeId)
+ return {}
+ },
+ setSessionModel: async (params) => {
+ const state = await requireSession(params.sessionId)
+ const selected = requireModel(state.catalog, params.modelId)
+ state.model = selected
+ await input.client.session.switchModel({ sessionID: state.id, model: selected })
+ return {}
+ },
+ prompt: async (params) => {
+ const state = await requireSession(params.sessionId)
+ if (active.has(state.id)) {
+ throw new ACPError.ServiceFailureError({
+ safeMessage: `Session already has an active ACP prompt: ${state.id}`,
+ service: "session",
+ })
+ }
+ const messageID = SessionMessage.ID.create()
+ const prepared = preparePrompt(state.catalog, params.prompt, messageID)
+ const control: TurnControl = { cancelled: false, admission: new AbortController() }
+ active.set(state.id, control)
+ const response = await streamTurn({
+ client: input.client,
+ connection: input.connection,
+ sessionID: state.id,
+ cwd: state.cwd,
+ start: prepared.start,
+ userMessageID: params.messageId,
+ control,
+ submit: (signal) => submitPrompt(input.client, state, prepared, signal),
+ }).finally(() => {
+ if (active.get(state.id) === control) active.delete(state.id)
+ })
+ await sendUsageUpdate(input.client, input.connection, state, response.usage?.totalTokens).catch(() => {})
+ return response
+ },
+ cancel: async (params) => {
+ const current = active.get(params.sessionId)
+ if (current) {
+ current.cancelled = true
+ current.admission.abort()
+ }
+ await input.client.session.interrupt({ sessionID: params.sessionId }).catch(() => {})
+ },
+ }
+}
+
+function preparePrompt(catalog: Catalog, prompt: PromptRequest["prompt"], messageID: string): PreparedPrompt {
+ const parts = promptContentToParts(prompt)
+ const visible = parts.filter((part) => part.type !== "text" || (!part.synthetic && !part.ignored))
+ const synthetic = parts.flatMap((part) => (part.type === "text" && part.synthetic ? [part.text] : []))
+ const text = visible.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
+ const files = visible.flatMap((part) => (part.type === "file" ? [{ uri: part.url, name: part.filename }] : []))
+ const slash = detectSlashCommand(text)
+ const command = slash ? catalog.commands.find((item) => item.name === slash.name) : undefined
+ const skill = slash ? catalog.skills.find((item) => item.name === slash.name) : undefined
+ const start = turnStart(messageID, slash, skill)
+ return { start, text, files, synthetic, slash, command, skill }
+}
+
+async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: PreparedPrompt, signal: AbortSignal) {
+ if (prompt.synthetic.length > 0) {
+ await client.session.synthetic({
+ sessionID: session.id,
+ text: prompt.synthetic.join("\n\n"),
+ description: "ACP embedded context",
+ delivery: "steer",
+ resume: false,
+ })
+ }
+ if (prompt.start.type === "compaction") return client.session.compact({ sessionID: session.id, id: prompt.start.id })
+ if (prompt.skill) return client.session.skill({ sessionID: session.id, id: prompt.start.id, skill: prompt.skill.id })
+ if (prompt.command) {
+ return client.session.command(
+ {
+ sessionID: session.id,
+ id: prompt.start.id,
+ command: prompt.command.name,
+ arguments: prompt.slash?.args,
+ files: prompt.files,
+ delivery: "steer",
+ },
+ { signal },
+ )
+ }
+ return client.session.prompt(
+ { sessionID: session.id, id: prompt.start.id, text: prompt.text, files: prompt.files, delivery: "steer" },
+ { signal },
+ )
+}
+
+function turnStart(messageID: string, slash: PreparedPrompt["slash"], skill: SkillInfo | undefined): TurnStart {
+ if (slash?.name === "compact") return { type: "compaction", id: messageID }
+ if (skill) return { type: "skill", id: messageID }
+ return { type: "input", id: messageID }
+}
+
+async function loadCatalog(client: OpenCodeClient, cwd: string): Promise {
+ const location = { directory: cwd }
+ // Location plugins initialize asynchronously, so the first ACP request may observe an empty catalog.
+ const deadline = Date.now() + 5_000
+ let missing = "No models are available"
+ while (Date.now() < deadline) {
+ const [modelResult, defaultResult, agentResult, commandResult, skillResult] = await Promise.all([
+ client.model.list({ location }),
+ client.model.default({ location }),
+ client.agent.list({ location }),
+ client.command.list({ location }),
+ client.skill.list({ location }),
+ ])
+ const models = modelResult.data.filter((model) => model.enabled)
+ const defaultModel = defaultResult.data ?? models[0]
+ const agents = agentResult.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
+ const defaultAgent = agents.find((agent) => agent.mode === "primary") ?? agents[0]
+ if (defaultModel && defaultAgent) {
+ return {
+ providers: providers(models),
+ models,
+ defaultModel: {
+ providerID: defaultModel.providerID,
+ id: defaultModel.id,
+ variant:
+ defaultModel.variants.find((variant) => variant.id === "default")?.id ?? defaultModel.variants[0]?.id,
+ },
+ modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
+ defaultModeID: defaultAgent.id,
+ commands: commandResult.data,
+ skills: skillResult.data.filter((skill) => skill.slash !== false),
+ }
+ }
+ missing = defaultModel ? "No primary agents are available" : "No models are available"
+ await Bun.sleep(25)
+ }
+ throw new Error(missing)
+}
+
+function providers(models: readonly ModelInfo[]): ConfigOptionProvider[] {
+ return Array.from(new Set(models.map((model) => model.providerID)))
+ .toSorted()
+ .map((providerID) => ({
+ id: providerID,
+ name: providerID,
+ models: models
+ .filter((model) => model.providerID === providerID)
+ .map((model) => ({ id: model.id, name: model.name, variants: model.variants.map((variant) => variant.id) })),
+ }))
+}
+
+function requireModel(catalog: Catalog, modelID: string): ModelRef {
+ const selected = parseModelSelection(modelID, catalog.providers)
+ const model = catalog.models.find(
+ (item) => item.providerID === selected.model.providerID && item.id === selected.model.modelID,
+ )
+ if (!model) throw new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId: modelID })
+ if (selected.variant && !model.variants.some((variant) => variant.id === selected.variant))
+ throw new ACPError.InvalidEffortError({ effort: selected.variant })
+ return { providerID: model.providerID, id: model.id, variant: selected.variant }
+}
+
+async function selectMode(client: OpenCodeClient, state: Attached, modeID: string) {
+ if (!state.catalog.modes.some((mode) => mode.id === modeID)) throw new ACPError.InvalidModeError({ mode: modeID })
+ state.modeID = modeID
+ await client.session.switchAgent({ sessionID: state.id, agent: modeID })
+}
+
+async function getSession(client: OpenCodeClient, sessionID: string) {
+ return client.session.get({ sessionID }).catch((error) => {
+ if (isSessionNotFoundError(error)) throw new ACPError.SessionNotFoundError({ sessionId: sessionID })
+ throw error
+ })
+}
+
+async function messages(client: OpenCodeClient, sessionID: string) {
+ const result: SessionMessageInfo[] = []
+ let cursor: string | undefined
+ do {
+ const page = cursor
+ ? await client.message.list({ sessionID, limit: 200, cursor })
+ : await client.message.list({ sessionID, limit: 200, order: "asc" })
+ result.push(...page.data)
+ cursor = page.cursor.next ?? undefined
+ } while (cursor)
+ return result
+}
+
+async function registerMcpServers(
+ client: OpenCodeClient,
+ registered: Map>,
+ session: Attached,
+ servers: readonly McpServer[],
+) {
+ const current = registered.get(session.id) ?? new Set()
+ registered.set(session.id, current)
+ await Promise.all(
+ servers.flatMap((server) => {
+ const config = mcpConfig(server)
+ const key = `${server.name}:${stableStringify(config)}`
+ if (current.has(key)) return []
+ current.add(key)
+ return [
+ client.mcp.add({ server: server.name, location: { directory: session.cwd }, config }).catch((error) => {
+ current.delete(key)
+ throw error
+ }),
+ ]
+ }),
+ )
+}
+
+function mcpConfig(server: McpServer) {
+ if ("type" in server) {
+ return {
+ type: "remote" as const,
+ url: server.url,
+ headers: Object.fromEntries(server.headers.map((header) => [header.name, header.value])),
+ oauth: false as const,
+ }
+ }
+ return {
+ type: "local" as const,
+ command: [server.command, ...server.args],
+ environment: Object.fromEntries(server.env.map((entry) => [entry.name, entry.value])),
+ }
+}
+
+function stableStringify(value: unknown): string {
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`
+ if (!value || typeof value !== "object") return JSON.stringify(value)
+ return `{${Object.entries(value)
+ .toSorted(([a], [b]) => a.localeCompare(b))
+ .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
+ .join(",")}}`
+}
+
+async function sendUsageUpdate(client: OpenCodeClient, connection: Connection, session: Attached, used?: number) {
+ if (!used) return
+ const model = session.catalog.models.find(
+ (item) => item.providerID === session.model.providerID && item.id === session.model.id,
+ )
+ if (!model?.limit.context) return
+ const info = await client.session.get({ sessionID: session.id })
+ await connection.sessionUpdate({
+ sessionId: session.id,
+ update: {
+ sessionUpdate: "usage_update",
+ used,
+ size: model.limit.context,
+ cost: { amount: info.cost, currency: "USD" },
+ },
+ })
+}
+
+function detectSlashCommand(text: string): { readonly name: string; readonly args: string } | undefined {
+ const value = text.trim()
+ if (!value.startsWith("/")) return undefined
+ const [name, ...rest] = value.slice(1).split(/\s+/)
+ if (!name) return undefined
+ return { name, args: rest.join(" ").trim() }
+}
+
+export * as ACPService from "./service"
diff --git a/packages/cli/src/acp/tool.ts b/packages/cli/src/acp/tool.ts
new file mode 100644
index 0000000000..468512f892
--- /dev/null
+++ b/packages/cli/src/acp/tool.ts
@@ -0,0 +1,222 @@
+import { isAbsolute, resolve } from "node:path"
+import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
+
+export type ToolInput = Record
+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>
+ readonly result?: unknown
+}): ToolCallUpdate {
+ const normalized = toolContent(input.content)
+ const read = input.toolName.toLocaleLowerCase() === "read" ? readDisplayText(input.structured) : undefined
+ const images = normalized.filter((part) => part.type === "content" && part.content.type === "image")
+ const primary =
+ read === undefined
+ ? normalized.filter((part) => !images.includes(part))
+ : [{ type: "content" as const, content: { type: "text" as const, text: read } }]
+ const oldText = stringValue(input.input.oldString)
+ const newText = stringValue(input.input.newString)
+ const diff: ToolCallContent[] =
+ oldText === undefined || newText === undefined
+ ? []
+ : [
+ {
+ type: "diff",
+ path: stringValue(input.input.path) ?? stringValue(input.input.filePath) ?? "",
+ oldText,
+ newText,
+ },
+ ]
+ return {
+ toolCallId: input.toolCallId,
+ status: "completed",
+ content: [...primary, ...diff, ...images],
+ 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>
+ 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 readDisplayText(structured: Readonly>) {
+ if (typeof structured.content === "string") {
+ if (structured.type === "text-page" || structured.encoding === "utf8") return structured.content
+ }
+ if (!Array.isArray(structured.entries)) return undefined
+ return structured.entries
+ .flatMap((entry): string[] => {
+ if (typeof entry === "string") return [entry]
+ if (!entry || typeof entry !== "object") return []
+ const path = Reflect.get(entry, "path")
+ return typeof path === "string" ? [path] : []
+ })
+ .join("\n")
+}
+
+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 }),
+ )
+}
+
+export function stringValue(value: unknown) {
+ return typeof value === "string" ? value : undefined
+}
+
+export * as ACPTool from "./tool"
diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts
index 771f57f42a..a287c3c9ec 100644
--- a/packages/cli/src/commands/commands.ts
+++ b/packages/cli/src/commands/commands.ts
@@ -34,6 +34,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
),
},
commands: [
+ Spec.make("acp", { description: "Start an Agent Client Protocol server" }),
Spec.make("api", {
description: "Make a request to the running server",
params: {
diff --git a/packages/cli/src/commands/handlers/acp.ts b/packages/cli/src/commands/handlers/acp.ts
new file mode 100644
index 0000000000..3d0e9f73bb
--- /dev/null
+++ b/packages/cli/src/commands/handlers/acp.ts
@@ -0,0 +1,36 @@
+import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
+import { OpenCode } from "@opencode-ai/client/promise"
+import { Service } from "@opencode-ai/client/effect/service"
+import { Effect } from "effect"
+import { ACP } from "../../acp/agent"
+import { Commands } from "../commands"
+import { Runtime } from "../../framework/runtime"
+import { Standalone } from "../../services/standalone"
+
+export default Runtime.handler(
+ Commands.commands.acp,
+ Effect.fn("cli.acp")(function* () {
+ process.env.OPENCODE_CLIENT = "acp"
+ const endpoint = yield* Standalone.start()
+ const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
+ const input = new WritableStream({
+ write: (chunk) =>
+ new Promise((resolve, reject) => {
+ process.stdout.write(chunk, (error) => (error ? reject(error) : resolve()))
+ }),
+ })
+ const output = new ReadableStream({
+ start(controller) {
+ process.stdin.on("data", (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)))
+ process.stdin.on("end", () => controller.close())
+ process.stdin.on("error", (error) => controller.error(error))
+ },
+ })
+ const stream = ndJsonStream(input, output)
+ const connection = new AgentSideConnection((connection) => ACP.create(client, connection), stream)
+ process.stdin.resume()
+ yield* Effect.promise(() => connection.closed)
+ // EOF owns this stdio process; exiting also closes the private server's lease pipe.
+ yield* Effect.sync(() => process.exit(0))
+ }),
+)
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index d5138b5c49..ed0db805c7 100755
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -15,6 +15,7 @@ import { Npm } from "@opencode-ai/util/npm"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
+ acp: () => import("./commands/handlers/acp"),
api: () => import("./commands/handlers/api"),
auth: {
connect: () => import("./commands/handlers/auth/connect"),
diff --git a/packages/cli/test/acp/command.test.ts b/packages/cli/test/acp/command.test.ts
new file mode 100644
index 0000000000..1bf01db2d2
--- /dev/null
+++ b/packages/cli/test/acp/command.test.ts
@@ -0,0 +1,107 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import path from "node:path"
+
+type Message = { readonly id?: number; readonly result?: unknown; readonly error?: unknown }
+const children: Bun.Subprocess[] = []
+
+afterEach(async () => {
+ await Promise.all(
+ children.splice(0).map(async (child) => {
+ child.kill("SIGKILL")
+ await child.exited
+ }),
+ )
+})
+
+describe("acp command", () => {
+ test("is registered", async () => {
+ const result = await cli(["--help"])
+ expect(result.exitCode).toBe(0)
+ expect(result.stdout).toContain("acp Start an Agent Client Protocol server")
+ })
+
+ test("initializes over ndjson and exits on stdin eof", async () => {
+ const child = spawn()
+ const stderr = new Response(child.stderr).text()
+ await child.stdin.write(
+ new TextEncoder().encode(
+ JSON.stringify({
+ jsonrpc: "2.0",
+ id: 1,
+ method: "initialize",
+ params: {
+ protocolVersion: 1,
+ clientCapabilities: {},
+ clientInfo: { name: "test", version: "1.0.0" },
+ },
+ }) + "\n",
+ ),
+ )
+ await child.stdin.flush()
+ const response = await readMessage(child.stdout)
+ expect(response.id).toBe(1)
+ expect(response.error).toBeUndefined()
+ expect(response.result).toMatchObject({
+ protocolVersion: 1,
+ agentCapabilities: { loadSession: true },
+ agentInfo: { name: "OpenCode" },
+ })
+
+ await child.stdin.end()
+ const exitCode = await child.exited
+ const errorOutput = await stderr
+ if (exitCode !== 0) throw new Error(`ACP exited with ${exitCode}: ${errorOutput}`)
+ children.splice(children.indexOf(child), 1)
+ }, 30_000)
+})
+
+function spawn() {
+ const child = Bun.spawn([process.execPath, "run", "src/index.ts", "acp"], {
+ cwd: path.join(import.meta.dir, "../.."),
+ stdin: "pipe",
+ stdout: "pipe",
+ stderr: "pipe",
+ })
+ children.push(child)
+ return child
+}
+
+async function readMessage(stream: ReadableStream) {
+ const reader = stream.getReader()
+ const decoder = new TextDecoder()
+ let output = ""
+ while (true) {
+ const result = await Promise.race([
+ reader.read(),
+ Bun.sleep(20_000).then(() => {
+ throw new Error("timed out waiting for ACP response")
+ }),
+ ])
+ if (result.done) throw new Error(`ACP exited before responding: ${output}`)
+ output += decoder.decode(result.value, { stream: true })
+ const newline = output.indexOf("\n")
+ if (newline === -1) continue
+ reader.releaseLock()
+ const message: unknown = JSON.parse(output.slice(0, newline))
+ if (!isMessage(message)) throw new Error(`invalid ACP response: ${output.slice(0, newline)}`)
+ return message
+ }
+}
+
+function isMessage(value: unknown): value is Message {
+ return typeof value === "object" && value !== null
+}
+
+async function cli(args: string[]) {
+ const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
+ cwd: path.join(import.meta.dir, "../.."),
+ stdout: "pipe",
+ stderr: "pipe",
+ })
+ const [stdout, stderr, exitCode] = await Promise.all([
+ new Response(child.stdout).text(),
+ new Response(child.stderr).text(),
+ child.exited,
+ ])
+ return { stdout, stderr, exitCode }
+}
diff --git a/packages/cli/test/acp/config-option.test.ts b/packages/cli/test/acp/config-option.test.ts
new file mode 100644
index 0000000000..05adc0aa3d
--- /dev/null
+++ b/packages/cli/test/acp/config-option.test.ts
@@ -0,0 +1,151 @@
+import { describe, expect, test } from "bun:test"
+import {
+ buildConfigOptions,
+ buildEffortSelectOption,
+ buildModeSelectOption,
+ buildModelSelectOption,
+ 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 the model select option with ACP verifier category", () => {
+ expect(
+ buildModelSelectOption({
+ providers,
+ currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
+ }),
+ ).toEqual({
+ id: "model",
+ name: "Model",
+ category: "model",
+ type: "select",
+ currentValue: "anthropic/claude/sonnet-4",
+ options: [
+ { value: "anthropic/claude-haiku", name: "Anthropic/Claude Haiku" },
+ { value: "anthropic/claude/sonnet-4", name: "Anthropic/Claude Sonnet 4" },
+ { value: "openai/gpt-5", name: "OpenAI/GPT-5" },
+ ],
+ })
+ })
+
+ test("builds effort option from variants and falls back to default when current variant is invalid", () => {
+ expect(buildEffortSelectOption({ variants: ["low", "default", "high"], currentVariant: "missing" })).toEqual({
+ id: "effort",
+ name: "Effort",
+ description: "Available effort levels for this model",
+ category: "thought_level",
+ type: "select",
+ currentValue: "default",
+ options: [
+ { value: "low", name: "Low" },
+ { value: "default", name: "Default" },
+ { value: "high", name: "High" },
+ ],
+ })
+ })
+
+ test("effort fallback uses the first variant when default is absent", () => {
+ expect(buildEffortSelectOption({ variants: ["minimal", "low"], currentVariant: "missing" }).currentValue).toBe(
+ "minimal",
+ )
+ })
+
+ test("builds the mode select option with descriptions when present", () => {
+ expect(
+ buildModeSelectOption({
+ currentModeId: "build",
+ modes: [
+ { id: "build", name: "Build", description: "Make code changes" },
+ { id: "plan", name: "Plan" },
+ ],
+ }),
+ ).toEqual({
+ id: "mode",
+ name: "Session Mode",
+ category: "mode",
+ type: "select",
+ currentValue: "build",
+ options: [
+ { value: "build", name: "Build", description: "Make code changes" },
+ { value: "plan", name: "Plan" },
+ ],
+ })
+ })
+
+ test("builds full config options with model, effort, and mode in stable order", () => {
+ const options = buildConfigOptions({
+ providers,
+ currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
+ currentVariant: "very-high",
+ modes: [
+ { id: "build", name: "Build" },
+ { id: "plan", name: "Plan" },
+ ],
+ currentModeId: "plan",
+ })
+
+ expect(options.map((option) => option.id)).toEqual(["model", "effort", "mode"])
+ expect(options.map((option) => option.category)).toEqual(["model", "thought_level", "mode"])
+ expect(options[0]?.currentValue).toBe("anthropic/claude/sonnet-4")
+ expect(options[1]?.currentValue).toBe("very-high")
+ })
+
+ test("full config options omit effort for models without variants", () => {
+ expect(
+ buildConfigOptions({
+ providers,
+ currentModel: { providerID: "anthropic", modelID: "claude-haiku" },
+ }).map((option) => option.id),
+ ).toEqual(["model"])
+ })
+
+ test("parses provider/model selections", () => {
+ expect(parseModelSelection("openai/gpt-5", providers)).toEqual({
+ model: { providerID: "openai", modelID: "gpt-5" },
+ })
+ })
+
+ test("parses provider/model/variant selections when the base model exposes that variant", () => {
+ expect(parseModelSelection("openai/gpt-5/low", providers)).toEqual({
+ model: { providerID: "openai", modelID: "gpt-5" },
+ variant: "low",
+ })
+ })
+
+ test("prefers exact slash-containing model ids before treating the tail as a variant", () => {
+ expect(parseModelSelection("anthropic/claude/sonnet-4", providers)).toEqual({
+ model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
+ })
+ })
+
+ test("parses trailing variants for slash-containing model ids", () => {
+ expect(parseModelSelection("anthropic/claude/sonnet-4/high", providers)).toEqual({
+ model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
+ variant: "high",
+ })
+ })
+
+ test("keeps unknown trailing segments in the model id when they are not valid variants", () => {
+ expect(parseModelSelection("anthropic/claude/sonnet-4/missing", providers)).toEqual({
+ model: { providerID: "anthropic", modelID: "claude/sonnet-4/missing" },
+ })
+ })
+
+ test("formats variant names for display", () => {
+ expect(formatVariantName("very_high-effort")).toBe("Very High Effort")
+ })
+})
diff --git a/packages/cli/test/acp/config-options.subprocess.test.ts b/packages/cli/test/acp/config-options.subprocess.test.ts
new file mode 100644
index 0000000000..e2f31ed1cb
--- /dev/null
+++ b/packages/cli/test/acp/config-options.subprocess.test.ts
@@ -0,0 +1,74 @@
+import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk"
+import { describe, expect, test } from "bun:test"
+import {
+ alternateValue,
+ createAcpFixture,
+ expectOk,
+ flattenSelectOptions,
+ initialize,
+ newSession,
+ requireSelectOption,
+ selectConfigOption,
+} from "./subprocess"
+
+describe("acp config option subprocess", () => {
+ test('model option is listed with category "model"', async () => {
+ await using fixture = await createAcpFixture()
+ const acp = fixture.spawn()
+ await initialize(acp)
+ const model = requireSelectOption((await newSession(acp, fixture.home)).configOptions, "model")
+
+ expect(model.category).toBe("model")
+ expect(model.currentValue).toBe("test/test-model")
+ expect(flattenSelectOptions(model).length).toBeGreaterThanOrEqual(2)
+ }, 60_000)
+
+ test("model switch updates currentValue", async () => {
+ await using fixture = await createAcpFixture()
+ const acp = fixture.spawn()
+ await initialize(acp)
+ const session = await newSession(acp, fixture.home)
+ const model = requireSelectOption(session.configOptions, "model")
+ const nextModel = flattenSelectOptions(model).find((option) => option.value === "test/second-model")?.value
+ expect(nextModel).toBe("test/second-model")
+
+ const updated = expectOk(
+ await acp.request("session/set_config_option", {
+ sessionId: session.sessionId,
+ configId: "model",
+ value: nextModel,
+ }),
+ )
+
+ expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe(nextModel)
+ }, 60_000)
+
+ test('effort option is listed with category "thought_level" when selected model supports variants', async () => {
+ await using fixture = await createAcpFixture()
+ const acp = fixture.spawn()
+ await initialize(acp)
+ const effort = requireSelectOption((await newSession(acp, fixture.home)).configOptions, "effort")
+
+ expect(effort.category).toBe("thought_level")
+ expect(effort.currentValue).toBe("low")
+ expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high"])
+ }, 60_000)
+
+ test("effort switch updates currentValue", async () => {
+ await using fixture = await createAcpFixture()
+ const acp = fixture.spawn()
+ await initialize(acp)
+ const session = await newSession(acp, fixture.home)
+ const nextEffort = alternateValue(requireSelectOption(session.configOptions, "effort"))
+
+ const updated = expectOk(
+ await acp.request("session/set_config_option", {
+ sessionId: session.sessionId,
+ configId: "effort",
+ value: nextEffort,
+ }),
+ )
+
+ expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
+ }, 60_000)
+})
diff --git a/packages/cli/test/acp/content.test.ts b/packages/cli/test/acp/content.test.ts
new file mode 100644
index 0000000000..523639925b
--- /dev/null
+++ b/packages/cli/test/acp/content.test.ts
@@ -0,0 +1,239 @@
+import { describe, expect, test } from "bun:test"
+import { pathToFileURL } from "node:url"
+import { contentBlockToParts, partsToContentChunks, promptContentToParts } from "../../src/acp/content"
+
+describe("acp content conversion", () => {
+ test("plain text block becomes a text part", () => {
+ expect(contentBlockToParts({ type: "text", text: "hello" })).toEqual([{ type: "text", text: "hello" }])
+ })
+
+ test("assistant-only text audience becomes synthetic", () => {
+ expect(
+ contentBlockToParts({
+ type: "text",
+ text: "internal",
+ annotations: { audience: ["assistant"] },
+ }),
+ ).toEqual([{ type: "text", text: "internal", synthetic: true }])
+ })
+
+ test("user-only text audience becomes ignored", () => {
+ expect(
+ contentBlockToParts({
+ type: "text",
+ text: "visible to user",
+ annotations: { audience: ["user"] },
+ }),
+ ).toEqual([{ type: "text", text: "visible to user", ignored: true }])
+ })
+
+ test("image block with base64 data becomes a data URL file part", () => {
+ expect(
+ contentBlockToParts({
+ type: "image",
+ data: "AAAA",
+ mimeType: "image/png",
+ uri: "file:///tmp/screenshot.png",
+ }),
+ ).toEqual([
+ {
+ type: "file",
+ url: "data:image/png;base64,AAAA",
+ filename: "screenshot.png",
+ mime: "image/png",
+ },
+ ])
+ })
+
+ test("image block with http URI becomes a file part", () => {
+ expect(
+ contentBlockToParts({
+ type: "image",
+ data: "",
+ mimeType: "image/jpeg",
+ uri: "http://example.com/assets/photo.jpg",
+ }),
+ ).toEqual([
+ {
+ type: "file",
+ url: "http://example.com/assets/photo.jpg",
+ filename: "photo.jpg",
+ mime: "image/jpeg",
+ },
+ ])
+ })
+
+ test("resource_link file URL becomes a file part with name and fallback mime", () => {
+ expect(
+ contentBlockToParts({
+ type: "resource_link",
+ uri: "file:///tmp/notes.txt",
+ name: "client-notes.txt",
+ }),
+ ).toEqual([
+ {
+ type: "file",
+ url: "file:///tmp/notes.txt",
+ filename: "client-notes.txt",
+ mime: "text/plain",
+ },
+ ])
+ })
+
+ test("resource_link zed path becomes a file URL part", () => {
+ expect(
+ contentBlockToParts({
+ type: "resource_link",
+ uri: "zed://workspace?path=/tmp/project/src/app.ts",
+ name: "app.ts",
+ mimeType: "text/typescript",
+ }),
+ ).toEqual([
+ {
+ type: "file",
+ url: pathToFileURL("/tmp/project/src/app.ts").href,
+ filename: "app.ts",
+ mime: "text/typescript",
+ },
+ ])
+ })
+
+ test("resource with text becomes a sourced text part", () => {
+ const result = contentBlockToParts({
+ type: "resource",
+ resource: {
+ uri: "file:///tmp/context.txt#L12-L14",
+ mimeType: "text/plain",
+ text: "context",
+ },
+ })
+ expect(result).toHaveLength(1)
+ expect(result[0]?.type).toBe("text")
+ if (result[0]?.type === "text") {
+ expect(result[0].text.endsWith("\ncontext")).toBe(true)
+ expect(result[0].text.includes("context.txt")).toBe(true)
+ expect(result[0].text.includes("12")).toBe(true)
+ }
+ })
+
+ test("resource with text uses URI fallback for non-file resources", () => {
+ expect(
+ contentBlockToParts({
+ type: "resource",
+ resource: {
+ uri: "mcp://server/context",
+ text: "context",
+ },
+ }),
+ ).toEqual([{ type: "text", text: "[mcp://server/context]\ncontext" }])
+ })
+
+ test("resource with text includes file path", () => {
+ const result = contentBlockToParts({
+ type: "resource",
+ resource: {
+ uri: "file:///tmp/context.txt",
+ mimeType: "text/plain",
+ text: "context",
+ },
+ })
+ expect(result).toHaveLength(1)
+ expect(result[0]?.type).toBe("text")
+ if (result[0]?.type === "text") {
+ expect(result[0].text.endsWith("\ncontext")).toBe(true)
+ expect(result[0].text.includes("context.txt")).toBe(true)
+ }
+ })
+
+ test("resource with blob and mimeType becomes a data URL file part", () => {
+ expect(
+ contentBlockToParts({
+ type: "resource",
+ resource: {
+ uri: "file:///tmp/report.pdf",
+ mimeType: "application/pdf",
+ blob: "JVBERg==",
+ },
+ }),
+ ).toEqual([
+ {
+ type: "file",
+ url: "data:application/pdf;base64,JVBERg==",
+ filename: "report.pdf",
+ mime: "application/pdf",
+ },
+ ])
+ })
+
+ test("data URL resource is preserved as a file part", () => {
+ expect(
+ contentBlockToParts({
+ type: "resource",
+ resource: {
+ uri: "data:text/plain;base64,aGVsbG8=",
+ mimeType: "text/plain",
+ blob: "ignored",
+ },
+ }),
+ ).toEqual([
+ {
+ type: "file",
+ url: "data:text/plain;base64,aGVsbG8=",
+ filename: "file",
+ mime: "text/plain",
+ },
+ ])
+ })
+
+ test("unsupported blocks are ignored", () => {
+ expect(promptContentToParts([{ type: "audio", data: "AAAA", mimeType: "audio/wav" }])).toEqual([])
+ expect(
+ promptContentToParts([
+ // @ts-expect-error Exercise forward compatibility with an unknown ACP content block.
+ { type: "unknown", text: "skip" },
+ ]),
+ ).toEqual([])
+ })
+})
+
+describe("acp replay conversion", () => {
+ test("replays text audience annotations", () => {
+ expect(partsToContentChunks([{ type: "text", text: "cached", synthetic: true }])).toEqual([
+ {
+ content: {
+ type: "text",
+ text: "cached",
+ annotations: { audience: ["assistant"] },
+ },
+ },
+ ])
+ })
+
+ test("replays file and data URL parts as ACP content", () => {
+ 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",
+ },
+ },
+ },
+ ])
+ })
+})
diff --git a/packages/cli/test/acp/error.test.ts b/packages/cli/test/acp/error.test.ts
new file mode 100644
index 0000000000..421364d0fe
--- /dev/null
+++ b/packages/cli/test/acp/error.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, test } from "bun:test"
+import { RequestError } from "@agentclientprotocol/sdk"
+import { ACPError } from "../../src/acp/error"
+
+describe("acp errors", () => {
+ test("maps validation failures to invalid params", () => {
+ const cases: ACPError.Error[] = [
+ new ACPError.SessionNotFoundError({ sessionId: "ses_missing" }),
+ new ACPError.InvalidConfigOptionError({ configId: "temperature" }),
+ new ACPError.InvalidModelError({ providerId: "anthropic", modelId: "claude-missing" }),
+ new ACPError.InvalidEffortError({ effort: "extreme" }),
+ new ACPError.InvalidModeError({ mode: "turbo" }),
+ ]
+
+ expect(cases.map((error) => ACPError.toRequestError(error).code)).toEqual([-32602, -32602, -32602, -32602, -32602])
+ })
+
+ test("includes safe validation details", () => {
+ expect(ACPError.toRequestError(new ACPError.SessionNotFoundError({ sessionId: "ses_123" }))).toMatchObject({
+ code: -32602,
+ data: { sessionId: "ses_123" },
+ })
+ expect(ACPError.toRequestError(new ACPError.InvalidModelError({ modelId: "gpt-missing" }))).toMatchObject({
+ code: -32602,
+ data: { modelId: "gpt-missing" },
+ })
+ })
+
+ test("maps auth required to the SDK auth error", () => {
+ const requestError = ACPError.toRequestError(new ACPError.AuthRequiredError())
+
+ expect(requestError).toBeInstanceOf(RequestError)
+ expect(requestError.code).toBe(-32000)
+ expect(requestError.message).toBe("Authentication required: provider authentication required")
+ expect(requestError.data).toEqual({})
+ })
+
+ test("maps service failures to safe internal errors", () => {
+ const requestError = ACPError.toRequestError(
+ new ACPError.ServiceFailureError({ service: "provider", safeMessage: "Provider request failed" }),
+ )
+
+ expect(requestError.code).toBe(-32603)
+ expect(requestError.message).toBe("Internal error: Provider request failed")
+ expect(requestError.data).toEqual({ service: "provider" })
+ })
+
+ test("wraps unknown defects without leaking raw details", () => {
+ const requestError = ACPError.toRequestError(
+ ACPError.fromUnknown(new Error("stack has sk-ant-secret and oauth refresh token")),
+ )
+ const serialized = JSON.stringify(requestError.toErrorResponse())
+
+ expect(requestError.code).toBe(-32603)
+ expect(requestError.message).toBe("Internal error: Internal service failure")
+ expect(serialized).not.toContain("sk-ant-secret")
+ expect(serialized).not.toContain("oauth refresh token")
+ expect(serialized).not.toContain("stack")
+ })
+})
diff --git a/packages/cli/test/acp/event-behavior.test.ts b/packages/cli/test/acp/event-behavior.test.ts
new file mode 100644
index 0000000000..9de17cf4e1
--- /dev/null
+++ b/packages/cli/test/acp/event-behavior.test.ts
@@ -0,0 +1,684 @@
+import { describe, expect, test } from "bun:test"
+import type { AgentSideConnection } from "@agentclientprotocol/sdk"
+import type { SessionMessageInfo } from "@opencode-ai/client/promise"
+import { resolve } from "node:path"
+import { replayMessages, streamTurn, type TurnControl } from "../../src/acp/event"
+import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
+
+type SessionUpdateParams = Parameters[0]
+type Connection = Pick
+type Fixture = ReturnType
+
+describe("acp event behavior", () => {
+ test("subscribes before admission and isolates sessions and input IDs", async () => {
+ const updates: SessionUpdateParams[] = []
+ const fixture = createSseFixture({
+ onPrompt({ id, send }) {
+ send(
+ ephemeralEvent("session.text.delta", {
+ sessionID: "ses_a",
+ assistantMessageID: "msg_before",
+ ordinal: 0,
+ delta: "before admission",
+ }),
+ )
+ send(durableEvent("session.input.promoted", { sessionID: "ses_b", inputID: id }))
+ send(durableEvent("session.input.promoted", { sessionID: "ses_a", inputID: "input_other" }))
+ send(
+ ephemeralEvent("session.text.delta", {
+ sessionID: "ses_a",
+ assistantMessageID: "msg_wrong_input",
+ ordinal: 0,
+ delta: "wrong input",
+ }),
+ )
+ send(durableEvent("session.input.promoted", { sessionID: "ses_a", inputID: id }))
+ send(
+ ephemeralEvent("session.text.delta", {
+ sessionID: "ses_b",
+ assistantMessageID: "msg_b",
+ ordinal: 0,
+ delta: "other session",
+ }),
+ )
+ send(
+ ephemeralEvent("session.text.delta", {
+ sessionID: "ses_a",
+ assistantMessageID: "msg_a",
+ ordinal: 0,
+ delta: "accepted",
+ }),
+ )
+ send(
+ durableEvent("session.step.ended", {
+ sessionID: "ses_a",
+ assistantMessageID: "msg_a",
+ finish: "stop",
+ cost: 0,
+ tokens: tokens(),
+ }),
+ )
+ send(durableEvent("session.execution.succeeded", { sessionID: "ses_b" }))
+ send(durableEvent("session.execution.succeeded", { sessionID: "ses_a" }))
+ },
+ })
+
+ try {
+ const response = await turn({
+ fixture,
+ connection: recordingConnection(updates),
+ sessionID: "ses_a",
+ inputID: "input_a",
+ })
+
+ expect(fixture.requests.slice(0, 2).map((request) => request.path)).toEqual([
+ "/api/event",
+ "/api/session/ses_a/prompt",
+ ])
+ expect(updates).toEqual([
+ {
+ sessionId: "ses_a",
+ update: {
+ sessionUpdate: "agent_message_chunk",
+ messageId: "msg_a",
+ content: { type: "text", text: "accepted" },
+ },
+ },
+ ])
+ expect(response.stopReason).toBe("end_turn")
+ } finally {
+ await fixture.stop()
+ }
+ })
+
+ test("preserves text and reasoning order before returning the terminal response", async () => {
+ const firstUpdate = Promise.withResolvers()
+ const releaseUpdate = Promise.withResolvers()
+ const allUpdates = Promise.withResolvers()
+ const releaseSubmit = Promise.withResolvers()
+ const updates: SessionUpdateParams[] = []
+ const fixture = createSseFixture({
+ async onPrompt({ id, send }) {
+ send(durableEvent("session.input.promoted", { sessionID: "ses_order", inputID: id }))
+ send(
+ ephemeralEvent("session.reasoning.delta", {
+ sessionID: "ses_order",
+ assistantMessageID: "msg_order",
+ ordinal: 0,
+ delta: "think-1",
+ }),
+ )
+ send(
+ ephemeralEvent("session.text.delta", {
+ sessionID: "ses_order",
+ assistantMessageID: "msg_order",
+ ordinal: 1,
+ delta: "answer",
+ }),
+ )
+ send(
+ ephemeralEvent("session.reasoning.delta", {
+ sessionID: "ses_order",
+ assistantMessageID: "msg_order",
+ ordinal: 2,
+ delta: "think-2",
+ }),
+ )
+ send(
+ durableEvent("session.step.ended", {
+ sessionID: "ses_order",
+ assistantMessageID: "msg_order",
+ finish: "stop",
+ cost: 0,
+ tokens: tokens(),
+ }),
+ )
+ send(durableEvent("session.execution.succeeded", { sessionID: "ses_order" }))
+ await releaseSubmit.promise
+ },
+ })
+ const connection = {
+ sessionUpdate: async (update) => {
+ updates.push(update)
+ if (updates.length === 1) {
+ firstUpdate.resolve()
+ await releaseUpdate.promise
+ }
+ if (updates.length === 3) allUpdates.resolve()
+ },
+ requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
+ } satisfies Connection
+ const result = turn({ fixture, connection, sessionID: "ses_order", inputID: "input_order" })
+
+ try {
+ await withTimeout(firstUpdate.promise, "first ordered update was not delivered")
+ expect(updates).toHaveLength(1)
+ expect(fixture.requests.some((request) => request.path.includes("/message/"))).toBe(false)
+
+ releaseUpdate.resolve()
+ await withTimeout(allUpdates.promise, "ordered updates did not finish")
+ expect(await Promise.race([result.then(() => "resolved"), Promise.resolve("pending")])).toBe("pending")
+ expect(fixture.requests.some((request) => request.path.includes("/message/"))).toBe(false)
+
+ releaseSubmit.resolve()
+ const response = await withTimeout(result, "turn did not resolve after admission returned")
+
+ expect(
+ updates.map((item) => {
+ if (
+ item.update.sessionUpdate === "agent_message_chunk" ||
+ item.update.sessionUpdate === "agent_thought_chunk"
+ ) {
+ return [
+ item.update.sessionUpdate,
+ item.update.content.type === "text" ? item.update.content.text : undefined,
+ ]
+ }
+ return [item.update.sessionUpdate, undefined]
+ }),
+ ).toEqual([
+ ["agent_thought_chunk", "think-1"],
+ ["agent_message_chunk", "answer"],
+ ["agent_thought_chunk", "think-2"],
+ ])
+ expect(fixture.requests.at(-1)?.path).toBe("/api/session/ses_order/message/msg_order")
+ expect(response).toMatchObject({ stopReason: "end_turn", usage: { totalTokens: 2 } })
+ } finally {
+ releaseUpdate.resolve()
+ releaseSubmit.resolve()
+ await result.catch(() => undefined)
+ await fixture.stop()
+ }
+ })
+
+ test("streams tool pending, progress, success, and failure updates", async () => {
+ const updates: SessionUpdateParams[] = []
+ const fixture = createSseFixture({
+ onPrompt({ id, send }) {
+ send(durableEvent("session.input.promoted", { sessionID: "ses_tools", inputID: id }))
+ send(
+ durableEvent("session.tool.input.started", {
+ sessionID: "ses_tools",
+ assistantMessageID: "msg_tools",
+ callID: "call_ok",
+ name: "shell",
+ }),
+ )
+ send(
+ durableEvent("session.tool.called", {
+ sessionID: "ses_tools",
+ assistantMessageID: "msg_tools",
+ callID: "call_ok",
+ input: { command: "printf done", workdir: "sub" },
+ executed: false,
+ }),
+ )
+ send(
+ durableEvent("session.tool.progress", {
+ sessionID: "ses_tools",
+ assistantMessageID: "msg_tools",
+ callID: "call_ok",
+ structured: { phase: 1 },
+ content: [{ type: "text", text: "working" }],
+ }),
+ )
+ send(
+ durableEvent("session.tool.success", {
+ sessionID: "ses_tools",
+ assistantMessageID: "msg_tools",
+ callID: "call_ok",
+ structured: { exit: 0 },
+ content: [{ type: "text", text: "done" }],
+ result: { code: 0 },
+ executed: true,
+ }),
+ )
+ send(
+ durableEvent("session.tool.input.started", {
+ sessionID: "ses_tools",
+ assistantMessageID: "msg_tools",
+ callID: "call_fail",
+ name: "read",
+ }),
+ )
+ send(
+ durableEvent("session.tool.called", {
+ sessionID: "ses_tools",
+ assistantMessageID: "msg_tools",
+ callID: "call_fail",
+ input: { filePath: "/workspace/missing.ts" },
+ executed: false,
+ }),
+ )
+ send(
+ durableEvent("session.tool.progress", {
+ sessionID: "ses_tools",
+ assistantMessageID: "msg_tools",
+ callID: "call_fail",
+ structured: { bytes: 0 },
+ content: [{ type: "text", text: "opening" }],
+ }),
+ )
+ send(
+ durableEvent("session.tool.failed", {
+ sessionID: "ses_tools",
+ assistantMessageID: "msg_tools",
+ callID: "call_fail",
+ error: { type: "tool.error", message: "not found" },
+ executed: true,
+ }),
+ )
+ send(
+ durableEvent("session.step.ended", {
+ sessionID: "ses_tools",
+ assistantMessageID: "msg_tools",
+ finish: "stop",
+ cost: 0,
+ tokens: tokens(),
+ }),
+ )
+ send(durableEvent("session.execution.succeeded", { sessionID: "ses_tools" }))
+ },
+ })
+
+ try {
+ const response = await turn({
+ fixture,
+ connection: recordingConnection(updates),
+ sessionID: "ses_tools",
+ inputID: "input_tools",
+ })
+
+ expect(
+ updates.map((item) => [
+ item.update.sessionUpdate,
+ "status" in item.update ? item.update.status : undefined,
+ "toolCallId" in item.update ? item.update.toolCallId : undefined,
+ ]),
+ ).toEqual([
+ ["tool_call", "pending", "call_ok"],
+ ["tool_call_update", "in_progress", "call_ok"],
+ ["tool_call_update", "in_progress", "call_ok"],
+ ["tool_call_update", "completed", "call_ok"],
+ ["tool_call", "pending", "call_fail"],
+ ["tool_call_update", "in_progress", "call_fail"],
+ ["tool_call_update", "in_progress", "call_fail"],
+ ["tool_call_update", "failed", "call_fail"],
+ ])
+ expect(updates[1]?.update).toMatchObject({
+ title: "printf done",
+ kind: "execute",
+ locations: [{ path: resolve("/workspace", "sub") }],
+ rawInput: { command: "printf done", workdir: "sub" },
+ })
+ expect(updates[2]?.update).toMatchObject({
+ content: [{ type: "content", content: { type: "text", text: "working" } }],
+ })
+ expect(updates[3]?.update).toMatchObject({
+ content: [{ type: "content", content: { type: "text", text: "done" } }],
+ rawOutput: { structured: { exit: 0 }, result: { code: 0 } },
+ })
+ expect(updates[7]?.update).toMatchObject({
+ kind: "read",
+ locations: [{ path: "/workspace/missing.ts" }],
+ content: [
+ { type: "content", content: { type: "text", text: "opening" } },
+ { type: "content", content: { type: "text", text: "not found" } },
+ ],
+ rawOutput: { structured: { bytes: 0 }, error: "not found" },
+ })
+ expect(response.stopReason).toBe("end_turn")
+ } finally {
+ await fixture.stop()
+ }
+ })
+
+ test("replays user, text, reasoning, and tool messages in order", async () => {
+ const updates: SessionUpdateParams[] = []
+ const messages = replayFixtureMessages()
+ const connection = {
+ sessionUpdate: async (update) => {
+ updates.push(update)
+ },
+ } satisfies Pick
+
+ await replayMessages(connection, "ses_replay", "/workspace", messages)
+
+ expect(updates.every((update) => update.sessionId === "ses_replay")).toBe(true)
+ expect(updates.map((item) => item.update.sessionUpdate)).toEqual([
+ "user_message_chunk",
+ "user_message_chunk",
+ "user_message_chunk",
+ "agent_message_chunk",
+ "agent_thought_chunk",
+ "tool_call",
+ "tool_call_update",
+ "tool_call",
+ "tool_call_update",
+ "tool_call",
+ "tool_call_update",
+ "tool_call",
+ ])
+ expect(updates[1]?.update).toMatchObject({
+ content: {
+ type: "resource_link",
+ uri: "file:///workspace/note.md",
+ name: "note.md",
+ mimeType: "text/markdown",
+ },
+ })
+ expect(updates[2]?.update).toMatchObject({
+ content: { type: "resource", resource: { mimeType: "text/plain", text: "hello" } },
+ })
+ expect(updates[6]?.update).toMatchObject({
+ toolCallId: "call_done",
+ status: "completed",
+ content: [
+ { type: "content", content: { type: "text", text: "done" } },
+ { type: "content", content: { type: "image", mimeType: "image/png", data: "AAAA" } },
+ ],
+ rawOutput: { structured: { exit: 0 }, result: { code: 0 } },
+ })
+ expect(updates[8]?.update).toMatchObject({
+ toolCallId: "call_running",
+ status: "in_progress",
+ title: "pwd",
+ locations: [{ path: "/workspace" }],
+ })
+ expect(updates[10]?.update).toMatchObject({
+ toolCallId: "call_failed",
+ status: "failed",
+ content: [
+ { type: "content", content: { type: "text", text: "partial" } },
+ { type: "content", content: { type: "text", text: "failed hard" } },
+ ],
+ })
+ })
+
+ test("continues replay after a session update callback rejects", async () => {
+ const attempts: Array<[string, string]> = []
+ const connection = {
+ sessionUpdate: async (params) => {
+ if (params.update.sessionUpdate !== "tool_call" && params.update.sessionUpdate !== "tool_call_update") return
+ attempts.push([params.update.toolCallId, params.update.sessionUpdate])
+ if (params.update.toolCallId === "call_first" && params.update.sessionUpdate === "tool_call_update") {
+ throw new Error("replay send failed")
+ }
+ },
+ } satisfies Pick
+
+ await replayMessages(connection, "ses_replay_failure", "/workspace", [
+ replayToolMessage("call_first"),
+ replayToolMessage("call_after"),
+ ])
+
+ expect(attempts).toEqual([
+ ["call_first", "tool_call"],
+ ["call_first", "tool_call_update"],
+ ["call_after", "tool_call"],
+ ["call_after", "tool_call_update"],
+ ])
+ })
+
+ test("returns cancelled after an admitted turn is interrupted", async () => {
+ const submitted = Promise.withResolvers()
+ const control: TurnControl = { cancelled: false, admission: new AbortController() }
+ const fixture = createSseFixture({
+ onPrompt({ id, send }) {
+ send(durableEvent("session.input.promoted", { sessionID: "ses_cancel", inputID: id }))
+ },
+ onInterrupt({ sessionID, send }) {
+ send(durableEvent("session.execution.interrupted", { sessionID, reason: "user" }))
+ },
+ })
+ const result = streamTurn({
+ client: fixture.client,
+ connection: recordingConnection([]),
+ sessionID: "ses_cancel",
+ cwd: "/workspace",
+ start: { type: "input", id: "input_cancel" },
+ control,
+ submit: async (signal) => {
+ await fixture.client.session.prompt(
+ { sessionID: "ses_cancel", id: "input_cancel", text: "cancel me" },
+ { signal },
+ )
+ submitted.resolve()
+ },
+ })
+
+ try {
+ await withTimeout(submitted.promise, "cancel test prompt was not admitted")
+ control.cancelled = true
+ control.admission.abort()
+ await fixture.client.session.interrupt({ sessionID: "ses_cancel" })
+
+ const response = await withTimeout(result, "cancelled turn did not terminate")
+ expect(response).toMatchObject({ stopReason: "cancelled" })
+ expect(fixture.requests.filter((request) => request.path.endsWith("/interrupt"))).toHaveLength(1)
+ } finally {
+ await fixture.stop()
+ }
+ })
+
+ test("returns cancelled when admission is aborted before promotion", async () => {
+ const submitted = Promise.withResolvers()
+ const control: TurnControl = { cancelled: false, admission: new AbortController() }
+ const fixture = createSseFixture({
+ onPrompt({ signal }) {
+ submitted.resolve()
+ return new Promise((resolve) => {
+ if (signal.aborted) return resolve()
+ signal.addEventListener("abort", () => resolve(), { once: true })
+ })
+ },
+ })
+ const result = streamTurn({
+ client: fixture.client,
+ connection: recordingConnection([]),
+ sessionID: "ses_cancel_admission",
+ cwd: "/workspace",
+ start: { type: "input", id: "input_cancel_admission" },
+ control,
+ submit: (signal) =>
+ fixture.client.session.prompt(
+ { sessionID: "ses_cancel_admission", id: "input_cancel_admission", text: "cancel me" },
+ { signal },
+ ),
+ })
+
+ try {
+ await withTimeout(submitted.promise, "cancel test prompt was not submitted")
+ control.cancelled = true
+ control.admission.abort()
+
+ const response = await withTimeout(result, "pre-admission cancellation did not terminate")
+ expect(response).toMatchObject({ stopReason: "cancelled" })
+ expect(fixture.requests.filter((request) => request.path.endsWith("/interrupt"))).toHaveLength(1)
+ } finally {
+ control.cancelled = true
+ control.admission.abort()
+ await result.catch(() => undefined)
+ await fixture.stop()
+ }
+ })
+
+ test("cancels unsupported session forms so execution can continue", async () => {
+ const fixture = createSseFixture({
+ onPrompt({ id, send }) {
+ send(durableEvent("session.input.promoted", { sessionID: "ses_form", inputID: id }))
+ send(
+ ephemeralEvent("form.created", {
+ form: {
+ id: "frm_question",
+ sessionID: "ses_form",
+ title: "Questions",
+ metadata: { kind: "question" },
+ fields: [{ key: "q0", title: "Choice", type: "string" }],
+ },
+ }),
+ )
+ },
+ onFormCancel({ sessionID, formID, send }) {
+ send(ephemeralEvent("form.cancelled", { sessionID, id: formID }))
+ send(durableEvent("session.execution.succeeded", { sessionID }))
+ },
+ })
+
+ try {
+ const response = await turn({
+ fixture,
+ connection: recordingConnection([]),
+ sessionID: "ses_form",
+ inputID: "input_form",
+ })
+
+ expect(response.stopReason).toBe("end_turn")
+ expect(
+ fixture.requests.some((request) => request.path === "/api/session/ses_form/form/frm_question/cancel"),
+ ).toBe(true)
+ } finally {
+ await fixture.stop()
+ }
+ })
+})
+
+function recordingConnection(updates: SessionUpdateParams[]) {
+ return {
+ sessionUpdate: async (update) => {
+ updates.push(update)
+ },
+ requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
+ } satisfies Connection
+}
+
+function turn(input: {
+ readonly fixture: Fixture
+ readonly connection: Connection
+ readonly sessionID: string
+ readonly inputID: string
+}) {
+ return streamTurn({
+ client: input.fixture.client,
+ connection: input.connection,
+ sessionID: input.sessionID,
+ cwd: "/workspace",
+ start: { type: "input", id: input.inputID },
+ userMessageID: `client_${input.inputID}`,
+ control: { cancelled: false, admission: new AbortController() },
+ submit: (signal) =>
+ input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
+ })
+}
+
+function tokens() {
+ return { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }
+}
+
+function replayFixtureMessages(): SessionMessageInfo[] {
+ return [
+ {
+ id: "msg_user",
+ type: "user",
+ text: "hello",
+ time: { created: 1 },
+ files: [
+ {
+ data: "",
+ mime: "text/markdown",
+ name: "note.md",
+ source: { type: "uri", uri: "file:///workspace/note.md" },
+ },
+ {
+ data: "aGVsbG8=",
+ mime: "text/plain",
+ name: "inline.txt",
+ source: { type: "inline" },
+ },
+ ],
+ },
+ {
+ id: "msg_assistant",
+ type: "assistant",
+ agent: "build",
+ model: { providerID: "test", id: "test-model" },
+ time: { created: 2, completed: 3 },
+ content: [
+ { type: "text", text: "answer" },
+ { type: "reasoning", text: "thinking" },
+ {
+ type: "tool",
+ id: "call_done",
+ name: "shell",
+ time: { created: 2, completed: 3 },
+ state: {
+ status: "completed",
+ input: { command: "printf done" },
+ structured: { exit: 0 },
+ content: [
+ { type: "text", text: "done" },
+ { type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "image.png" },
+ ],
+ result: { code: 0 },
+ },
+ },
+ {
+ type: "tool",
+ id: "call_running",
+ name: "shell",
+ time: { created: 2, ran: 2 },
+ state: {
+ status: "running",
+ input: { command: "pwd" },
+ structured: {},
+ content: [{ type: "text", text: "/workspace" }],
+ },
+ },
+ {
+ type: "tool",
+ id: "call_failed",
+ name: "read",
+ time: { created: 2, completed: 3 },
+ state: {
+ status: "error",
+ input: { filePath: "/workspace/missing.ts" },
+ structured: { bytes: 0 },
+ content: [{ type: "text", text: "partial" }],
+ error: { type: "tool.error", message: "failed hard" },
+ },
+ },
+ {
+ type: "tool",
+ id: "call_streaming",
+ name: "shell",
+ time: { created: 2 },
+ state: { status: "streaming", input: '{"command":' },
+ },
+ ],
+ },
+ ]
+}
+
+function replayToolMessage(id: string) {
+ return {
+ id: `msg_${id}`,
+ type: "assistant",
+ agent: "build",
+ model: { providerID: "test", id: "test-model" },
+ time: { created: 1, completed: 2 },
+ content: [
+ {
+ type: "tool",
+ id,
+ name: "shell",
+ time: { created: 1, completed: 2 },
+ state: {
+ status: "completed",
+ input: { command: "printf done" },
+ structured: { exit: 0 },
+ content: [{ type: "text", text: "done" }],
+ },
+ },
+ ],
+ } satisfies SessionMessageInfo
+}
diff --git a/packages/cli/test/acp/event.test.ts b/packages/cli/test/acp/event.test.ts
new file mode 100644
index 0000000000..e6d8db9deb
--- /dev/null
+++ b/packages/cli/test/acp/event.test.ts
@@ -0,0 +1,124 @@
+import { expect, test } from "bun:test"
+import type { AgentSideConnection } from "@agentclientprotocol/sdk"
+import { OpenCode } from "@opencode-ai/client/promise"
+import { streamTurn } from "../../src/acp/event"
+
+test("acp prompt resolves after ordered turn updates", async () => {
+ const encoder = new TextEncoder()
+ let events: ReadableStreamDefaultController | undefined
+ const updates: Parameters[0][] = []
+ const server = Bun.serve({
+ port: 0,
+ async fetch(request) {
+ const url = new URL(request.url)
+ if (url.pathname === "/api/event") {
+ return new Response(
+ new ReadableStream({
+ start(controller) {
+ events = controller
+ send(controller, { id: "evt_connected", type: "server.connected", data: {} })
+ },
+ }),
+ { headers: { "content-type": "text/event-stream" } },
+ )
+ }
+ if (url.pathname === "/api/session/ses_test/prompt") {
+ const body: unknown = await request.json()
+ if (!body || typeof body !== "object") {
+ return new Response(null, { status: 400 })
+ }
+ const id = Reflect.get(body, "id")
+ if (typeof id !== "string") return new Response(null, { status: 400 })
+ queueMicrotask(() => {
+ if (!events) return
+ send(events, {
+ id: "evt_promoted",
+ created: 1,
+ type: "session.input.promoted",
+ data: { sessionID: "ses_test", inputID: id },
+ })
+ send(events, {
+ id: "evt_text",
+ created: 2,
+ type: "session.text.delta",
+ data: { sessionID: "ses_test", assistantMessageID: "msg_assistant", ordinal: 0, delta: "hello" },
+ })
+ send(events, {
+ id: "evt_step",
+ created: 3,
+ type: "session.step.ended",
+ data: {
+ sessionID: "ses_test",
+ assistantMessageID: "msg_assistant",
+ finish: "stop",
+ cost: 0,
+ tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
+ },
+ })
+ send(events, {
+ id: "evt_done",
+ created: 4,
+ type: "session.execution.succeeded",
+ data: { sessionID: "ses_test" },
+ })
+ })
+ return Response.json({ data: {} })
+ }
+ if (url.pathname === "/api/session/ses_test/message/msg_assistant") {
+ return Response.json({
+ data: {
+ id: "msg_assistant",
+ type: "assistant",
+ agent: "build",
+ model: { providerID: "test", id: "test-model" },
+ content: [{ type: "text", text: "hello" }],
+ finish: "stop",
+ tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
+ time: { created: 1, completed: 4 },
+ },
+ })
+ }
+ return new Response(null, { status: 404 })
+ },
+ })
+ const client = OpenCode.make({ baseUrl: server.url.toString() })
+
+ try {
+ const id = "msg_prompt"
+ const userMessageID = "client-message"
+ const response = await streamTurn({
+ client,
+ connection: {
+ sessionUpdate: async (update) => {
+ updates.push(update)
+ },
+ requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
+ },
+ sessionID: "ses_test",
+ cwd: "/workspace",
+ start: { type: "input", id },
+ userMessageID,
+ control: { cancelled: false, admission: new AbortController() },
+ submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }),
+ })
+
+ expect(updates).toEqual([
+ {
+ sessionId: "ses_test",
+ update: {
+ sessionUpdate: "agent_message_chunk",
+ messageId: "msg_assistant",
+ content: { type: "text", text: "hello" },
+ },
+ },
+ ])
+ expect(response).toMatchObject({ stopReason: "end_turn", userMessageId: userMessageID, usage: { totalTokens: 2 } })
+ } finally {
+ events?.close()
+ await server.stop(true)
+ }
+
+ function send(controller: ReadableStreamDefaultController, event: unknown) {
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
+ }
+})
diff --git a/packages/cli/test/acp/initialize-auth.subprocess.test.ts b/packages/cli/test/acp/initialize-auth.subprocess.test.ts
new file mode 100644
index 0000000000..2303b5b3a1
--- /dev/null
+++ b/packages/cli/test/acp/initialize-auth.subprocess.test.ts
@@ -0,0 +1,49 @@
+import type { AuthenticateResponse, InitializeResponse } from "@agentclientprotocol/sdk"
+import { describe, expect, test } from "bun:test"
+import { createAcpFixture, expectOk, initialize } from "./subprocess"
+
+describe("acp initialize/auth subprocess", () => {
+ test("initialize responds with capabilities", async () => {
+ await using fixture = await createAcpFixture()
+ const initialized = await initialize(fixture.spawn())
+
+ expect(initialized.protocolVersion).toBe(1)
+ expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true)
+ expect(initialized.agentCapabilities?.promptCapabilities?.image).toBe(true)
+ expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true)
+ expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(false)
+ expect(initialized.agentCapabilities?.loadSession).toBe(true)
+ expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
+ expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({})
+ expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
+ expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
+ expect(initialized.agentInfo?.name).toBe("OpenCode")
+ }, 60_000)
+
+ test("auth negotiation is explicit and safe", async () => {
+ await using fixture = await createAcpFixture()
+ const secret = "subprocess-auth-secret"
+ const acp = fixture.spawn({ OPENCODE_AUTH_CONTENT: secret })
+ const initialized = await initialize(acp)
+
+ expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
+ expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeDefined()
+ expect(expectOk(await acp.request("authenticate", { methodId: "opencode-login" }))).toEqual(
+ {},
+ )
+
+ const rejected = await acp.request("authenticate", { methodId: "missing-auth-method" })
+ expect(rejected.error?.code).toBe(-32602)
+ expect(JSON.stringify(rejected.error)).not.toContain(secret)
+ }, 60_000)
+
+ test("initialize without terminal-auth metadata keeps auth command implicit", async () => {
+ await using fixture = await createAcpFixture()
+ const initialized = expectOk(
+ await fixture.spawn().request("initialize", { protocolVersion: 1 }),
+ )
+
+ expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
+ expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeUndefined()
+ }, 60_000)
+})
diff --git a/packages/cli/test/acp/lifecycle.subprocess.test.ts b/packages/cli/test/acp/lifecycle.subprocess.test.ts
new file mode 100644
index 0000000000..ffd0332620
--- /dev/null
+++ b/packages/cli/test/acp/lifecycle.subprocess.test.ts
@@ -0,0 +1,85 @@
+import type {
+ CloseSessionResponse,
+ ListSessionsResponse,
+ LoadSessionResponse,
+ ResumeSessionResponse,
+} from "@agentclientprotocol/sdk"
+import { describe, expect, test } from "bun:test"
+import { createAcpFixture, expectOk, initialize, newSession, selectConfigOption } from "./subprocess"
+
+describe("acp lifecycle subprocess", () => {
+ test("stdin EOF exits cleanly", async () => {
+ await using fixture = await createAcpFixture()
+ expect(await fixture.spawn().close()).toBe(0)
+ }, 60_000)
+
+ test("close capability and close request", async () => {
+ await using fixture = await createAcpFixture()
+ const acp = fixture.spawn()
+ const initialized = await initialize(acp)
+ expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
+
+ const session = await newSession(acp, fixture.home)
+ expect(
+ expectOk(await acp.request("session/close", { sessionId: session.sessionId })),
+ ).toEqual({})
+ }, 60_000)
+
+ test("new session succeeds on the first request", async () => {
+ await using fixture = await createAcpFixture()
+ const acp = fixture.spawn()
+ await initialize(acp)
+
+ expect((await newSession(acp, fixture.home)).sessionId).toStartWith("ses_")
+ }, 60_000)
+
+ test("loadSession capability and load request return session config options", async () => {
+ await using fixture = await createAcpFixture()
+ const acp = fixture.spawn()
+ const initialized = await initialize(acp)
+ expect(initialized.agentCapabilities?.loadSession).toBe(true)
+ const session = await newSession(acp, fixture.home)
+ const loaded = expectOk(
+ await acp.request("session/load", {
+ cwd: fixture.home,
+ sessionId: session.sessionId,
+ mcpServers: [],
+ }),
+ )
+
+ expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model")
+ }, 60_000)
+
+ test("list request includes a live ACP-created session", async () => {
+ await using fixture = await createAcpFixture()
+ const acp = fixture.spawn()
+ await initialize(acp)
+ const session = await newSession(acp, fixture.home)
+ const listed = expectOk(await acp.request("session/list", { cwd: fixture.home }))
+
+ expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true)
+ }, 60_000)
+
+ test("resume capability advertisement", async () => {
+ await using fixture = await createAcpFixture()
+ const initialized = await initialize(fixture.spawn())
+
+ expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
+ }, 60_000)
+
+ test("resume request returns session config options", async () => {
+ await using fixture = await createAcpFixture()
+ const acp = fixture.spawn()
+ await initialize(acp)
+ const session = await newSession(acp, fixture.home)
+ const resumed = expectOk(
+ await acp.request("session/resume", {
+ cwd: fixture.home,
+ sessionId: session.sessionId,
+ mcpServers: [],
+ }),
+ )
+
+ expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model")
+ }, 60_000)
+})
diff --git a/packages/cli/test/acp/permission-behavior.test.ts b/packages/cli/test/acp/permission-behavior.test.ts
new file mode 100644
index 0000000000..ad8ccd128a
--- /dev/null
+++ b/packages/cli/test/acp/permission-behavior.test.ts
@@ -0,0 +1,499 @@
+import { describe, expect, test } from "bun:test"
+import type { AgentSideConnection, RequestPermissionRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"
+import fs from "node:fs/promises"
+import os from "node:os"
+import path from "node:path"
+import { streamTurn } from "../../src/acp/event"
+import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
+
+type SessionUpdateParams = Parameters[0]
+type Connection = Pick &
+ Partial>
+type Fixture = ReturnType
+
+describe("acp permission behavior", () => {
+ test("forwards allow-once and allow-always selections to the generated client", async () => {
+ const permissionRequests: RequestPermissionRequest[] = []
+ const fixture = createSseFixture({
+ onPrompt({ id, send }) {
+ send(durableEvent("session.input.promoted", { sessionID: "ses_allow", inputID: id }))
+ send(
+ permissionAsked("ses_allow", "perm_once", {
+ action: "shell",
+ metadata: { command: "printf hello" },
+ source: { type: "tool", messageID: "msg_allow", callID: "call_once" },
+ }),
+ )
+ send(
+ permissionAsked("ses_allow", "perm_always", {
+ action: "read",
+ metadata: { filePath: "/workspace/file.ts" },
+ source: { type: "tool", messageID: "msg_allow", callID: "call_always" },
+ }),
+ )
+ send(durableEvent("session.execution.succeeded", { sessionID: "ses_allow" }))
+ },
+ })
+ const connection = {
+ sessionUpdate: async () => {},
+ requestPermission: async (request) => {
+ permissionRequests.push(request)
+ return {
+ outcome: {
+ outcome: "selected",
+ optionId: request.toolCall.toolCallId === "call_once" ? "once" : "always",
+ },
+ }
+ },
+ } satisfies Connection
+
+ try {
+ await startTurn(fixture, connection, "ses_allow", "input_allow")
+
+ expect(permissionRequests[0]).toMatchObject({
+ sessionId: "ses_allow",
+ toolCall: {
+ toolCallId: "call_once",
+ status: "pending",
+ title: "printf hello",
+ kind: "execute",
+ locations: [{ path: "/workspace" }],
+ rawInput: { command: "printf hello", cwd: "/workspace" },
+ },
+ options: [
+ { optionId: "once", kind: "allow_once", name: "Allow once" },
+ { optionId: "always", kind: "allow_always", name: "Always allow" },
+ { optionId: "reject", kind: "reject_once", name: "Reject" },
+ ],
+ })
+ expect(permissionRequests[1]).toMatchObject({
+ sessionId: "ses_allow",
+ toolCall: {
+ toolCallId: "call_always",
+ status: "pending",
+ title: "/workspace/file.ts",
+ kind: "read",
+ locations: [{ path: "/workspace/file.ts" }],
+ rawInput: { filePath: "/workspace/file.ts" },
+ },
+ })
+ expect(permissionReplies(fixture)).toEqual([
+ ["perm_once", "once"],
+ ["perm_always", "always"],
+ ])
+ } finally {
+ await fixture.stop()
+ }
+ })
+
+ test("preserves external directory permission context", async () => {
+ const permissionRequests: RequestPermissionRequest[] = []
+ const fixture = createSseFixture({
+ onPrompt({ id, send }) {
+ send(durableEvent("session.input.promoted", { sessionID: "ses_external", inputID: id }))
+ send(
+ permissionAsked("ses_external", "perm_external", {
+ action: "external_directory",
+ metadata: {
+ command: "mkdir -p /tmp/outside",
+ description: "Create external directory",
+ directories: ["/tmp/outside"],
+ patterns: ["/tmp/outside/*"],
+ },
+ }),
+ )
+ send(durableEvent("session.execution.succeeded", { sessionID: "ses_external" }))
+ },
+ })
+ const connection = {
+ sessionUpdate: async () => {},
+ requestPermission: async (request) => {
+ permissionRequests.push(request)
+ return { outcome: { outcome: "selected", optionId: "once" } } as const
+ },
+ } satisfies Connection
+
+ try {
+ await startTurn(fixture, connection, "ses_external", "input_external")
+
+ expect(permissionRequests[0]?.toolCall).toMatchObject({
+ title: "Create external directory",
+ locations: [{ path: "/tmp/outside" }],
+ rawInput: {
+ command: "mkdir -p /tmp/outside",
+ description: "Create external directory",
+ directories: ["/tmp/outside"],
+ patterns: ["/tmp/outside/*"],
+ },
+ })
+ } finally {
+ await fixture.stop()
+ }
+ })
+
+ test("previews edits during approval and syncs the completed file", async () => {
+ const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-permission-"))
+ const file = path.join(cwd, "file.ts")
+ await fs.writeFile(file, "before")
+ const permissionRequests: RequestPermissionRequest[] = []
+ const writes: Parameters[0][] = []
+ const fixture = createSseFixture({
+ onPrompt({ id, send }) {
+ send(durableEvent("session.input.promoted", { sessionID: "ses_edit", inputID: id }))
+ send(
+ durableEvent("session.tool.input.started", {
+ sessionID: "ses_edit",
+ assistantMessageID: "msg_edit",
+ callID: "call_edit",
+ name: "edit",
+ }),
+ )
+ send(
+ durableEvent("session.tool.called", {
+ sessionID: "ses_edit",
+ assistantMessageID: "msg_edit",
+ callID: "call_edit",
+ input: { path: "file.ts", oldString: "before", newString: "after" },
+ executed: false,
+ }),
+ )
+ send(
+ permissionAsked("ses_edit", "perm_edit", {
+ action: "edit",
+ source: { type: "tool", messageID: "msg_edit", callID: "call_edit" },
+ }),
+ )
+ },
+ async onPermissionReply({ send }) {
+ await fs.writeFile(file, "after")
+ send(
+ durableEvent("session.tool.success", {
+ sessionID: "ses_edit",
+ assistantMessageID: "msg_edit",
+ callID: "call_edit",
+ structured: { files: [{ file: "file.ts" }], replacements: 1 },
+ content: [{ type: "text", text: "edited" }],
+ executed: true,
+ }),
+ )
+ send(durableEvent("session.execution.succeeded", { sessionID: "ses_edit" }))
+ },
+ })
+ const connection = {
+ sessionUpdate: async () => {},
+ requestPermission: async (request) => {
+ permissionRequests.push(request)
+ return { outcome: { outcome: "selected", optionId: "once" } } as const
+ },
+ writeTextFile: async (request) => {
+ writes.push(request)
+ return {}
+ },
+ } satisfies Connection
+
+ try {
+ await startTurn(fixture, connection, "ses_edit", "input_edit", cwd)
+
+ expect(permissionRequests[0]?.toolCall).toMatchObject({
+ title: "file.ts",
+ kind: "edit",
+ locations: [{ path: "file.ts" }],
+ content: [{ type: "diff", path: "file.ts", oldText: "before", newText: "after" }],
+ })
+ expect(writes).toEqual([{ sessionId: "ses_edit", path: file, content: "after" }])
+ } finally {
+ await fixture.stop()
+ await fs.rm(cwd, { recursive: true, force: true })
+ }
+ })
+
+ test("previews and syncs each file in a patch", async () => {
+ const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-patch-permission-"))
+ await Promise.all([
+ fs.writeFile(path.join(cwd, "first.ts"), "one\n"),
+ fs.writeFile(path.join(cwd, "second.ts"), "alpha\n"),
+ ])
+ const patchText = [
+ "*** Begin Patch",
+ "*** Update File: first.ts",
+ "@@",
+ "-one",
+ "+two",
+ "*** Update File: second.ts",
+ "@@",
+ "-alpha",
+ "+beta",
+ "*** End Patch",
+ ].join("\n")
+ const permissionRequests: RequestPermissionRequest[] = []
+ const writes: Parameters[0][] = []
+ const fixture = createSseFixture({
+ onPrompt({ id, send }) {
+ send(durableEvent("session.input.promoted", { sessionID: "ses_patch", inputID: id }))
+ send(
+ durableEvent("session.tool.input.started", {
+ sessionID: "ses_patch",
+ assistantMessageID: "msg_patch",
+ callID: "call_patch",
+ name: "patch",
+ }),
+ )
+ send(
+ durableEvent("session.tool.called", {
+ sessionID: "ses_patch",
+ assistantMessageID: "msg_patch",
+ callID: "call_patch",
+ input: { patchText },
+ executed: false,
+ }),
+ )
+ send(
+ permissionAsked("ses_patch", "perm_patch", {
+ action: "edit",
+ source: { type: "tool", messageID: "msg_patch", callID: "call_patch" },
+ }),
+ )
+ },
+ async onPermissionReply({ send }) {
+ await Promise.all([
+ fs.writeFile(path.join(cwd, "first.ts"), "two\n"),
+ fs.writeFile(path.join(cwd, "second.ts"), "beta\n"),
+ ])
+ send(
+ durableEvent("session.tool.success", {
+ sessionID: "ses_patch",
+ assistantMessageID: "msg_patch",
+ callID: "call_patch",
+ structured: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
+ content: [{ type: "text", text: "patched" }],
+ executed: true,
+ }),
+ )
+ send(durableEvent("session.execution.succeeded", { sessionID: "ses_patch" }))
+ },
+ })
+ const connection = {
+ sessionUpdate: async () => {},
+ requestPermission: async (request) => {
+ permissionRequests.push(request)
+ return { outcome: { outcome: "selected", optionId: "once" } } as const
+ },
+ writeTextFile: async (request) => {
+ writes.push(request)
+ return {}
+ },
+ } satisfies Connection
+
+ try {
+ await startTurn(fixture, connection, "ses_patch", "input_patch", cwd)
+
+ expect(permissionRequests[0]?.toolCall).toMatchObject({
+ title: "2 files",
+ kind: "edit",
+ locations: [{ path: "first.ts" }, { path: "second.ts" }],
+ content: [
+ { type: "diff", path: "first.ts", oldText: "one\n", newText: "two\n" },
+ { type: "diff", path: "second.ts", oldText: "alpha\n", newText: "beta\n" },
+ ],
+ })
+ expect(writes.toSorted((a, b) => a.path.localeCompare(b.path))).toEqual([
+ { sessionId: "ses_patch", path: path.join(cwd, "first.ts"), content: "two\n" },
+ { sessionId: "ses_patch", path: path.join(cwd, "second.ts"), content: "beta\n" },
+ ])
+ } finally {
+ await fixture.stop()
+ await fs.rm(cwd, { recursive: true, force: true })
+ }
+ })
+
+ test("rejects explicit rejection, cancellation, and permission UI failure", async () => {
+ const fixture = createSseFixture({
+ onPrompt({ id, send }) {
+ send(durableEvent("session.input.promoted", { sessionID: "ses_reject", inputID: id }))
+ send(permissionAsked("ses_reject", "perm_selected_reject"))
+ send(permissionAsked("ses_reject", "perm_cancelled"))
+ send(permissionAsked("ses_reject", "perm_failed"))
+ send(durableEvent("session.execution.succeeded", { sessionID: "ses_reject" }))
+ },
+ })
+ const connection = {
+ sessionUpdate: async () => {},
+ requestPermission: async (request): Promise => {
+ if (request.toolCall.toolCallId === "perm_selected_reject") {
+ return { outcome: { outcome: "selected", optionId: "reject" } }
+ }
+ if (request.toolCall.toolCallId === "perm_cancelled") return { outcome: { outcome: "cancelled" } }
+ throw new Error("client permission UI failed")
+ },
+ } satisfies Connection
+
+ try {
+ const response = await startTurn(fixture, connection, "ses_reject", "input_reject")
+ expect(response).toMatchObject({ stopReason: "end_turn" })
+ expect(permissionReplies(fixture)).toEqual([
+ ["perm_selected_reject", "reject"],
+ ["perm_cancelled", "reject"],
+ ["perm_failed", "reject"],
+ ])
+ } finally {
+ await fixture.stop()
+ }
+ })
+
+ test("serializes permission requests and replies within one session", async () => {
+ const firstRequested = Promise.withResolvers()
+ const releaseFirst = Promise.withResolvers()
+ const permissionRequests: RequestPermissionRequest[] = []
+ const fixture = createSseFixture({
+ onPrompt({ id, send }) {
+ send(durableEvent("session.input.promoted", { sessionID: "ses_serial", inputID: id }))
+ send(permissionAsked("ses_serial", "perm_1"))
+ send(permissionAsked("ses_serial", "perm_2"))
+ send(durableEvent("session.execution.succeeded", { sessionID: "ses_serial" }))
+ },
+ })
+ const connection = {
+ sessionUpdate: async () => {},
+ requestPermission: async (request) => {
+ permissionRequests.push(request)
+ if (request.toolCall.toolCallId === "perm_1") {
+ firstRequested.resolve()
+ return releaseFirst.promise
+ }
+ return { outcome: { outcome: "selected", optionId: "always" } } as const
+ },
+ } satisfies Connection
+ const result = startTurn(fixture, connection, "ses_serial", "input_serial")
+
+ try {
+ await withTimeout(firstRequested.promise, "first permission was not requested")
+ expect(permissionRequests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1"])
+ expect(permissionReplies(fixture)).toEqual([])
+
+ releaseFirst.resolve({ outcome: { outcome: "selected", optionId: "once" } })
+ await withTimeout(result, "serialized permission turn did not finish")
+
+ expect(permissionRequests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1", "perm_2"])
+ expect(permissionReplies(fixture)).toEqual([
+ ["perm_1", "once"],
+ ["perm_2", "always"],
+ ])
+ } finally {
+ releaseFirst.resolve({ outcome: { outcome: "cancelled" } })
+ await result.catch(() => undefined)
+ await fixture.stop()
+ }
+ })
+
+ test("does not let one session's blocked permission stall another session", async () => {
+ const blockedRequested = Promise.withResolvers()
+ const releaseBlocked = Promise.withResolvers()
+ const promptIDs = new Map()
+ const updates: SessionUpdateParams[] = []
+ const fixture = createSseFixture({
+ onPrompt({ sessionID, id, send }) {
+ promptIDs.set(sessionID, id)
+ if (promptIDs.size !== 2) return
+ const blockedID = promptIDs.get("ses_blocked")
+ const freeID = promptIDs.get("ses_free")
+ if (!blockedID || !freeID) throw new Error("both permission test prompts must be registered")
+ send(durableEvent("session.input.promoted", { sessionID: "ses_blocked", inputID: blockedID }))
+ send(durableEvent("session.input.promoted", { sessionID: "ses_free", inputID: freeID }))
+ send(permissionAsked("ses_blocked", "perm_blocked"))
+ send(
+ ephemeralEvent("session.text.delta", {
+ sessionID: "ses_free",
+ assistantMessageID: "msg_free",
+ ordinal: 0,
+ delta: "session B continued",
+ }),
+ )
+ send(
+ durableEvent("session.step.ended", {
+ sessionID: "ses_free",
+ assistantMessageID: "msg_free",
+ finish: "stop",
+ cost: 0,
+ tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
+ }),
+ )
+ send(durableEvent("session.execution.succeeded", { sessionID: "ses_free" }))
+ send(durableEvent("session.execution.succeeded", { sessionID: "ses_blocked" }))
+ },
+ })
+ const connection = {
+ sessionUpdate: async (update) => {
+ updates.push(update)
+ },
+ requestPermission: async () => {
+ blockedRequested.resolve()
+ return releaseBlocked.promise
+ },
+ } satisfies Connection
+ const blocked = startTurn(fixture, connection, "ses_blocked", "input_blocked")
+ const free = startTurn(fixture, connection, "ses_free", "input_free")
+
+ try {
+ await withTimeout(blockedRequested.promise, "blocked permission was not requested")
+ const response = await withTimeout(free, "free session was stalled by another session's permission")
+ expect(response).toMatchObject({ stopReason: "end_turn" })
+ expect(updates).toContainEqual({
+ sessionId: "ses_free",
+ update: {
+ sessionUpdate: "agent_message_chunk",
+ messageId: "msg_free",
+ content: { type: "text", text: "session B continued" },
+ },
+ })
+ expect(permissionReplies(fixture)).toEqual([])
+
+ releaseBlocked.resolve({ outcome: { outcome: "selected", optionId: "once" } })
+ await withTimeout(blocked, "blocked session did not resume after permission selection")
+ expect(permissionReplies(fixture)).toEqual([["perm_blocked", "once"]])
+ } finally {
+ releaseBlocked.resolve({ outcome: { outcome: "cancelled" } })
+ await Promise.all([blocked.catch(() => undefined), free.catch(() => undefined)])
+ await fixture.stop()
+ }
+ })
+})
+
+function startTurn(fixture: Fixture, connection: Connection, sessionID: string, inputID: string, cwd = "/workspace") {
+ return streamTurn({
+ client: fixture.client,
+ connection,
+ sessionID,
+ cwd,
+ start: { type: "input", id: inputID },
+ control: { cancelled: false, admission: new AbortController() },
+ submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, text: "hello" }, { signal }),
+ })
+}
+
+function permissionAsked(
+ sessionID: string,
+ id: string,
+ input: {
+ readonly action?: string
+ readonly metadata?: Record
+ readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
+ } = {},
+) {
+ return ephemeralEvent("permission.v2.asked", {
+ id,
+ sessionID,
+ action: input.action ?? "shell",
+ resources: ["*"],
+ metadata: input.metadata ?? { command: "printf hello" },
+ ...(input.source ? { source: input.source } : {}),
+ })
+}
+
+function permissionReplies(fixture: Fixture) {
+ return fixture.requests.flatMap((request): Array<[string, string]> => {
+ const match = /^\/api\/session\/[^/]+\/permission\/([^/]+)\/reply$/.exec(request.path)
+ if (!match?.[1] || !request.body || typeof request.body !== "object") return []
+ const reply = Reflect.get(request.body, "reply")
+ return typeof reply === "string" ? [[decodeURIComponent(match[1]), reply]] : []
+ })
+}
diff --git a/packages/cli/test/acp/prompt-content.subprocess.test.ts b/packages/cli/test/acp/prompt-content.subprocess.test.ts
new file mode 100644
index 0000000000..442880c609
--- /dev/null
+++ b/packages/cli/test/acp/prompt-content.subprocess.test.ts
@@ -0,0 +1,62 @@
+import type { PromptResponse } from "@agentclientprotocol/sdk"
+import { describe, expect, test } from "bun:test"
+import path from "node:path"
+import { pathToFileURL } from "node:url"
+import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
+
+const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="
+
+describe("acp prompt content subprocess", () => {
+ test("accepts embedded text resource image and file resource link prompt content", async () => {
+ await using fixture = await createAcpFixture()
+ await Bun.write(path.join(fixture.home, "README.md"), "# ACP content smoke\n")
+ const acp = fixture.spawn()
+ await initialize(acp)
+ const session = await newSession(acp, fixture.home)
+
+ expectOk(
+ await acp.request("session/prompt", {
+ sessionId: session.sessionId,
+ prompt: [
+ { type: "text", text: "Use this embedded resource." },
+ {
+ type: "resource",
+ resource: { uri: "file:///context.txt", mimeType: "text/plain", text: "embedded context" },
+ },
+ ],
+ }),
+ )
+
+ expectOk(
+ await acp.request("session/prompt", {
+ sessionId: session.sessionId,
+ prompt: [
+ { type: "text", text: "Use this image." },
+ {
+ type: "image",
+ mimeType: "image/png",
+ data: tinyPng,
+ },
+ ],
+ }),
+ )
+
+ const linked = expectOk(
+ await acp.request("session/prompt", {
+ sessionId: session.sessionId,
+ prompt: [
+ { type: "text", text: "Use this linked file." },
+ {
+ type: "resource_link",
+ uri: pathToFileURL(path.join(fixture.home, "README.md")).href,
+ name: "README.md",
+ mimeType: "text/markdown",
+ },
+ ],
+ }),
+ )
+
+ expect(linked.stopReason).toBe("end_turn")
+ expect(fixture.llm.requests.length).toBeGreaterThanOrEqual(3)
+ }, 60_000)
+})
diff --git a/packages/cli/test/acp/service-directory.test.ts b/packages/cli/test/acp/service-directory.test.ts
new file mode 100644
index 0000000000..9690622372
--- /dev/null
+++ b/packages/cli/test/acp/service-directory.test.ts
@@ -0,0 +1,269 @@
+import { describe, expect, test } from "bun:test"
+import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk"
+import { makeACPFixture, makeSession, secondModel, testModel } from "./service-fixture"
+
+describe("acp service directory behavior", () => {
+ test("creates sessions from a catalog shared by concurrent callers in the same cwd", async () => {
+ let created = 0
+ await using fixture = makeACPFixture({
+ fetch(request) {
+ if (request.method !== "POST" || request.path !== "/api/session") return undefined
+ created++
+ return Response.json({
+ data: makeSession(`ses_${created}`, { cwd: created <= 2 ? "/workspace" : "/other" }),
+ })
+ },
+ })
+
+ const first = await Promise.all([
+ fixture.service.newSession({ cwd: "/workspace", mcpServers: [] }),
+ fixture.service.newSession({ cwd: "/workspace", mcpServers: [] }),
+ ])
+ const other = await fixture.service.newSession({ cwd: "/other", mcpServers: [] })
+
+ expect(first.map((session) => session.sessionId).toSorted()).toEqual(["ses_1", "ses_2"])
+ expect(other.sessionId).toBe("ses_3")
+ expect(currentValue(first[0], "model")).toBe("test/test-model")
+ expect(currentValue(first[0], "mode")).toBe("build")
+ expect(
+ ["/api/model", "/api/model/default", "/api/agent", "/api/command", "/api/skill"].map((path) =>
+ fixture.requests
+ .filter((request) => request.path === path)
+ .map((request) => request.query["location[directory]"]),
+ ),
+ ).toEqual([
+ ["/workspace", "/other"],
+ ["/workspace", "/other"],
+ ["/workspace", "/other"],
+ ["/workspace", "/other"],
+ ["/workspace", "/other"],
+ ])
+ expect(
+ fixture.requests
+ .filter((request) => request.method === "POST" && request.path === "/api/session")
+ .map((request) => request.body),
+ ).toEqual([
+ {
+ agent: "build",
+ model: { providerID: "test", id: "test-model", variant: "default" },
+ location: { directory: "/workspace" },
+ },
+ {
+ agent: "build",
+ model: { providerID: "test", id: "test-model", variant: "default" },
+ location: { directory: "/workspace" },
+ },
+ {
+ agent: "build",
+ model: { providerID: "test", id: "test-model", variant: "default" },
+ location: { directory: "/other" },
+ },
+ ])
+ expect(
+ fixture.updates.map((item) =>
+ item.update.sessionUpdate === "available_commands_update"
+ ? item.update.availableCommands.map((command) => command.name)
+ : [],
+ ),
+ ).toEqual([
+ ["review", "verify"],
+ ["review", "verify"],
+ ["review", "verify"],
+ ])
+ })
+
+ test("does not cache a failed catalog load", async () => {
+ let modelCalls = 0
+ await using fixture = makeACPFixture({
+ fetch(request) {
+ if (request.path === "/api/model") {
+ modelCalls++
+ if (modelCalls === 1) {
+ return Response.json(
+ { name: "ModelsNotReadyError", data: { message: "catalog is warming" } },
+ { status: 503 },
+ )
+ }
+ }
+ if (request.method === "POST" && request.path === "/api/session") {
+ return Response.json({ data: makeSession("ses_retry") })
+ }
+ return undefined
+ },
+ })
+
+ const failure = await fixture.service
+ .newSession({ cwd: "/workspace", mcpServers: [] })
+ .catch((error: unknown) => error)
+ expect(failure).toMatchObject({ name: "ModelsNotReadyError" })
+ const retried = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
+
+ expect(retried.sessionId).toBe("ses_retry")
+ expect(modelCalls).toBe(2)
+ })
+
+ test("switches model, effort, and mode against the warm catalog", async () => {
+ await using fixture = makeACPFixture({
+ fetch(request) {
+ if (request.method === "POST" && request.path === "/api/session") {
+ return Response.json({ data: makeSession("ses_config") })
+ }
+ if (
+ request.method === "POST" &&
+ (request.path === "/api/session/ses_config/model" || request.path === "/api/session/ses_config/agent")
+ ) {
+ return new Response(null, { status: 204 })
+ }
+ return undefined
+ },
+ })
+ const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
+
+ const selectedModel = await fixture.service.setSessionConfigOption({
+ sessionId: session.sessionId,
+ configId: "model",
+ value: "test/second-model",
+ })
+ const selectedEffort = await fixture.service.setSessionConfigOption({
+ sessionId: session.sessionId,
+ configId: "effort",
+ value: "medium",
+ })
+ const selectedMode = await fixture.service.setSessionConfigOption({
+ sessionId: session.sessionId,
+ configId: "mode",
+ value: "plan",
+ })
+ await fixture.service.setSessionModel({ sessionId: session.sessionId, modelId: "test/test-model/high" })
+ await fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "build" })
+
+ expect(currentValue(selectedModel, "model")).toBe("test/second-model")
+ expect(currentValue(selectedModel, "effort")).toBe("low")
+ expect(currentValue(selectedEffort, "effort")).toBe("medium")
+ expect(currentValue(selectedMode, "mode")).toBe("plan")
+ expect(
+ fixture.requests
+ .filter((request) => request.path === "/api/session/ses_config/model")
+ .map((request) => request.body),
+ ).toEqual([
+ { model: { providerID: "test", id: secondModel.id } },
+ { model: { providerID: "test", id: secondModel.id, variant: "medium" } },
+ { model: { providerID: "test", id: testModel.id, variant: "high" } },
+ ])
+ expect(
+ fixture.requests
+ .filter((request) => request.path === "/api/session/ses_config/agent")
+ .map((request) => request.body),
+ ).toEqual([{ agent: "plan" }, { agent: "build" }])
+ expect(fixture.requests.filter((request) => request.path === "/api/model")).toHaveLength(1)
+
+ const invalidEffort = await fixture.service
+ .setSessionConfigOption({
+ sessionId: session.sessionId,
+ configId: "effort",
+ value: "maximum",
+ })
+ .catch((error: unknown) => error)
+ const invalidMode = await fixture.service
+ .setSessionConfigOption({
+ sessionId: session.sessionId,
+ configId: "mode",
+ value: "missing",
+ })
+ .catch((error: unknown) => error)
+ const invalidConfig = await fixture.service
+ .setSessionConfigOption({
+ sessionId: session.sessionId,
+ configId: "missing",
+ value: "value",
+ })
+ .catch((error: unknown) => error)
+ expect(invalidEffort).toMatchObject({ _tag: "ACPInvalidEffortError" })
+ expect(invalidMode).toMatchObject({ _tag: "ACPInvalidModeError" })
+ expect(invalidConfig).toMatchObject({ _tag: "ACPInvalidConfigOptionError" })
+ })
+
+ test("converts MCP configs and deduplicates registrations per session and config", async () => {
+ const local: McpServer = {
+ name: "tools",
+ command: "bun",
+ args: ["server.ts"],
+ env: [{ name: "TOKEN", value: "x" }],
+ }
+ const changed: McpServer = { ...local, args: ["changed.ts"] }
+ const remote: McpServer = {
+ type: "http",
+ name: "docs",
+ url: "https://example.com/mcp",
+ headers: [{ name: "Authorization", value: "Bearer x" }],
+ }
+ let created = 0
+ await using fixture = makeACPFixture({
+ fetch(request) {
+ if (request.method === "POST" && request.path === "/api/session") {
+ created++
+ return Response.json({ data: makeSession(`ses_${created}`) })
+ }
+ if (request.method === "GET" && request.path === "/api/session/ses_1") {
+ return Response.json({ data: makeSession("ses_1") })
+ }
+ if (request.method === "PUT" && request.path.startsWith("/api/mcp/")) {
+ return new Response(null, { status: 204 })
+ }
+ return undefined
+ },
+ })
+
+ await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local, local, remote] })
+ await fixture.service.resumeSession({ cwd: "/workspace", sessionId: "ses_1", mcpServers: [local, remote] })
+ await fixture.service.resumeSession({ cwd: "/workspace", sessionId: "ses_1", mcpServers: [changed] })
+ await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local] })
+
+ const adds = fixture.requests.filter((request) => request.method === "PUT" && request.path.startsWith("/api/mcp/"))
+ expect(adds).toHaveLength(4)
+ expect(adds.filter((request) => request.path === "/api/mcp/tools").map((request) => request.body)).toEqual([
+ {
+ config: {
+ type: "local",
+ command: ["bun", "server.ts"],
+ environment: { TOKEN: "x" },
+ },
+ },
+ {
+ config: {
+ type: "local",
+ command: ["bun", "changed.ts"],
+ environment: { TOKEN: "x" },
+ },
+ },
+ {
+ config: {
+ type: "local",
+ command: ["bun", "server.ts"],
+ environment: { TOKEN: "x" },
+ },
+ },
+ ])
+ expect(adds.find((request) => request.path === "/api/mcp/docs")?.body).toEqual({
+ config: {
+ type: "remote",
+ url: "https://example.com/mcp",
+ headers: { Authorization: "Bearer x" },
+ oauth: false,
+ },
+ })
+ expect(adds.map((request) => request.query)).toEqual([
+ { "location[directory]": "/workspace" },
+ { "location[directory]": "/workspace" },
+ { "location[directory]": "/workspace" },
+ { "location[directory]": "/workspace" },
+ ])
+ })
+})
+
+function currentValue(
+ result: { readonly configOptions?: readonly SessionConfigOption[] | null } | undefined,
+ id: string,
+) {
+ return result?.configOptions?.find((option) => option.id === id)?.currentValue
+}
diff --git a/packages/cli/test/acp/service-fixture.ts b/packages/cli/test/acp/service-fixture.ts
new file mode 100644
index 0000000000..68deed29be
--- /dev/null
+++ b/packages/cli/test/acp/service-fixture.ts
@@ -0,0 +1,207 @@
+import type { AgentSideConnection } from "@agentclientprotocol/sdk"
+import {
+ OpenCode,
+ type AgentInfo,
+ type CommandInfo,
+ type ModelInfo,
+ type ModelRef,
+ type SessionInfo,
+ type SkillInfo,
+ type TokenUsageInfo,
+} from "@opencode-ai/client/promise"
+import { ACPService } from "../../src/acp/service"
+
+export type FixtureRequest = {
+ readonly method: string
+ readonly path: string
+ readonly query: Record
+ readonly body: unknown
+}
+
+export type FixtureContext = {
+ readonly requests: FixtureRequest[]
+ send(event: unknown): void
+}
+
+type FixtureHandler = (
+ request: FixtureRequest,
+ context: FixtureContext,
+) => Response | undefined | Promise
+
+type FixtureOptions = {
+ readonly fetch?: FixtureHandler
+ readonly models?: readonly ModelInfo[]
+ readonly defaultModel?: ModelInfo
+ readonly agents?: readonly AgentInfo[]
+ readonly commands?: readonly CommandInfo[]
+ readonly skills?: readonly SkillInfo[]
+}
+
+export const testModel = {
+ id: "test-model",
+ modelID: "test-model",
+ providerID: "test",
+ name: "Test Model",
+ capabilities: { tools: true, input: ["text"], output: ["text"] },
+ variants: [{ id: "default" }, { id: "high" }],
+ time: { released: 0 },
+ cost: [],
+ status: "active",
+ enabled: true,
+ limit: { context: 100_000, output: 10_000 },
+} satisfies ModelInfo
+
+export const secondModel = {
+ id: "second-model",
+ modelID: "second-model",
+ providerID: "test",
+ name: "Second Model",
+ capabilities: { tools: true, input: ["text"], output: ["text"] },
+ variants: [{ id: "low" }, { id: "medium" }],
+ time: { released: 0 },
+ cost: [],
+ status: "active",
+ enabled: true,
+ limit: { context: 200_000, output: 20_000 },
+} satisfies ModelInfo
+
+export const buildAgent = {
+ id: "build",
+ name: "Build",
+ request: { settings: {}, headers: {}, body: {} },
+ mode: "primary",
+ hidden: false,
+ permissions: [],
+} satisfies AgentInfo
+
+export const planAgent = {
+ id: "plan",
+ name: "Plan",
+ description: "Plan first",
+ request: { settings: {}, headers: {}, body: {} },
+ mode: "primary",
+ hidden: false,
+ permissions: [],
+} satisfies AgentInfo
+
+export const reviewCommand = {
+ name: "review",
+ description: "Review changes",
+ template: "",
+} satisfies CommandInfo
+
+export const verifySkill = {
+ id: "verify",
+ name: "verify",
+ description: "Verify work",
+ slash: true,
+ location: "/skills/verify.md",
+ content: "verify",
+} satisfies SkillInfo
+
+export function makeSession(
+ id: string,
+ input: {
+ readonly cwd?: string
+ readonly agent?: string
+ readonly model?: ModelRef
+ readonly cost?: number
+ readonly tokens?: TokenUsageInfo
+ readonly time?: SessionInfo["time"]
+ readonly title?: string
+ } = {},
+): SessionInfo {
+ return {
+ id,
+ projectID: "global",
+ agent: input.agent ?? "build",
+ model: input.model ?? { providerID: "test", id: "test-model", variant: "default" },
+ cost: input.cost ?? 0,
+ tokens: input.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+ time: input.time ?? { created: 0, updated: 0 },
+ title: input.title ?? `Session ${id}`,
+ location: { directory: input.cwd ?? "/workspace" },
+ }
+}
+
+export function makeACPFixture(options: FixtureOptions = {}) {
+ const requests: FixtureRequest[] = []
+ const updates: Parameters[0][] = []
+ const encoder = new TextEncoder()
+ let eventController: ReadableStreamDefaultController | undefined
+ const models = options.models ?? [testModel, secondModel]
+ const context: FixtureContext = {
+ requests,
+ send(event) {
+ if (!eventController) throw new Error("ACP fixture has no active event stream")
+ eventController.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
+ },
+ }
+ const server = Bun.serve({
+ port: 0,
+ async fetch(raw) {
+ const url = new URL(raw.url)
+ const request: FixtureRequest = {
+ method: raw.method,
+ path: url.pathname,
+ query: Object.fromEntries(url.searchParams.entries()),
+ body: raw.method === "GET" || raw.method === "HEAD" ? undefined : await raw.json().catch(() => undefined),
+ }
+ requests.push(request)
+ const response = await options.fetch?.(request, context)
+ if (response) return response
+
+ const directory = request.query["location[directory]"] ?? "/workspace"
+ const location = { directory, project: { id: "global", directory } }
+ if (request.path === "/api/event") {
+ let controller: ReadableStreamDefaultController | undefined
+ return new Response(
+ new ReadableStream({
+ start(value) {
+ controller = value
+ eventController = value
+ context.send({ id: "evt_connected", type: "server.connected", data: {} })
+ },
+ cancel() {
+ if (eventController === controller) eventController = undefined
+ },
+ }),
+ { headers: { "content-type": "text/event-stream" } },
+ )
+ }
+ if (request.path === "/api/model") return Response.json({ location, data: models })
+ if (request.path === "/api/model/default") {
+ return Response.json({ location, data: options.defaultModel ?? models[0] ?? null })
+ }
+ if (request.path === "/api/agent") {
+ return Response.json({ location, data: options.agents ?? [buildAgent, planAgent] })
+ }
+ if (request.path === "/api/command") {
+ return Response.json({ location, data: options.commands ?? [reviewCommand] })
+ }
+ if (request.path === "/api/skill") {
+ return Response.json({ location, data: options.skills ?? [verifySkill] })
+ }
+ return new Response(null, { status: 404 })
+ },
+ })
+ const service = ACPService.make({
+ client: OpenCode.make({ baseUrl: server.url.toString() }),
+ connection: {
+ sessionUpdate: async (update) => {
+ updates.push(update)
+ },
+ requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
+ },
+ })
+
+ return {
+ service,
+ requests,
+ updates,
+ async [Symbol.asyncDispose]() {
+ eventController?.close()
+ await server.stop(true)
+ },
+ }
+}
diff --git a/packages/cli/test/acp/service-lifecycle.test.ts b/packages/cli/test/acp/service-lifecycle.test.ts
new file mode 100644
index 0000000000..2c4992b8e5
--- /dev/null
+++ b/packages/cli/test/acp/service-lifecycle.test.ts
@@ -0,0 +1,232 @@
+import { describe, expect, test } from "bun:test"
+import type { SessionConfigOption } from "@agentclientprotocol/sdk"
+import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
+
+describe("acp service lifecycle", () => {
+ test("loads and forks with paginated replay while resume does not replay", async () => {
+ await using fixture = makeACPFixture({
+ fetch(request) {
+ if (request.method === "GET" && request.path === "/api/session/ses_loaded") {
+ return Response.json({
+ data: makeSession("ses_loaded", {
+ cwd: "/workspace",
+ agent: "plan",
+ model: { providerID: "test", id: secondModel.id, variant: "medium" },
+ }),
+ })
+ }
+ if (request.method === "GET" && request.path === "/api/session/ses_resume") {
+ return Response.json({
+ data: makeSession("ses_resume", {
+ cwd: "/workspace",
+ agent: "plan",
+ model: { providerID: "test", id: secondModel.id, variant: "low" },
+ }),
+ })
+ }
+ if (request.method === "POST" && request.path === "/api/session/ses_loaded/fork") {
+ return Response.json({
+ data: makeSession("ses_fork", {
+ cwd: "/workspace",
+ agent: "plan",
+ model: { providerID: "test", id: secondModel.id, variant: "medium" },
+ }),
+ })
+ }
+ if (request.method === "GET" && request.path === "/api/session/ses_loaded/message") {
+ if (request.query.cursor === "messages-2") {
+ return Response.json({
+ data: [
+ {
+ id: "msg_assistant",
+ type: "assistant",
+ content: [{ type: "text", text: "hi there" }],
+ },
+ ],
+ cursor: {},
+ })
+ }
+ return Response.json({
+ data: [{ id: "msg_user", type: "user", text: "hello", time: { created: 1 } }],
+ cursor: { next: "messages-2" },
+ })
+ }
+ if (request.method === "GET" && request.path === "/api/session/ses_fork/message") {
+ return Response.json({
+ data: [{ id: "msg_fork", type: "user", text: "forked", time: { created: 2 } }],
+ cursor: {},
+ })
+ }
+ return undefined
+ },
+ })
+
+ const loaded = await fixture.service.loadSession({
+ cwd: "/ignored",
+ sessionId: "ses_loaded",
+ mcpServers: [],
+ })
+ const resumed = await fixture.service.resumeSession({
+ cwd: "/ignored",
+ sessionId: "ses_resume",
+ mcpServers: [],
+ })
+ const forked = await fixture.service.forkSession({
+ cwd: "/ignored",
+ sessionId: "ses_loaded",
+ mcpServers: [],
+ })
+
+ expect(currentValue(loaded, "model")).toBe("test/second-model")
+ expect(currentValue(loaded, "effort")).toBe("medium")
+ expect(currentValue(loaded, "mode")).toBe("plan")
+ expect(currentValue(resumed, "effort")).toBe("low")
+ expect(forked.sessionId).toBe("ses_fork")
+ expect(currentValue(forked, "effort")).toBe("medium")
+ expect(
+ fixture.updates.filter(
+ (item) =>
+ item.update.sessionUpdate === "user_message_chunk" || item.update.sessionUpdate === "agent_message_chunk",
+ ),
+ ).toEqual([
+ {
+ sessionId: "ses_loaded",
+ update: {
+ sessionUpdate: "user_message_chunk",
+ messageId: "msg_user",
+ content: { type: "text", text: "hello" },
+ },
+ },
+ {
+ sessionId: "ses_loaded",
+ update: {
+ sessionUpdate: "agent_message_chunk",
+ messageId: "msg_assistant",
+ content: { type: "text", text: "hi there" },
+ },
+ },
+ {
+ sessionId: "ses_fork",
+ update: {
+ sessionUpdate: "user_message_chunk",
+ messageId: "msg_fork",
+ content: { type: "text", text: "forked" },
+ },
+ },
+ ])
+ expect(
+ fixture.requests
+ .filter((request) => request.path.endsWith("/message"))
+ .map((request) => ({ path: request.path, query: request.query })),
+ ).toEqual([
+ {
+ path: "/api/session/ses_loaded/message",
+ query: { limit: "200", order: "asc" },
+ },
+ {
+ path: "/api/session/ses_loaded/message",
+ query: { limit: "200", cursor: "messages-2" },
+ },
+ {
+ path: "/api/session/ses_fork/message",
+ query: { limit: "200", order: "asc" },
+ },
+ ])
+ expect(fixture.requests).toContainEqual({
+ method: "POST",
+ path: "/api/session/ses_loaded/fork",
+ query: {},
+ body: {},
+ })
+ })
+
+ test("lists server-backed pages and forwards cwd and cursor", async () => {
+ const firstPage = Array.from({ length: 100 }, (_, index) =>
+ makeSession(`ses_${100 - index}`, {
+ cwd: "/workspace",
+ time: { created: index, updated: 100_000 - index },
+ title: `Session ${100 - index}`,
+ }),
+ )
+ await using fixture = makeACPFixture({
+ fetch(request) {
+ if (request.method !== "GET" || request.path !== "/api/session") return undefined
+ if (request.query.cursor === "page-2") {
+ return Response.json({
+ data: [makeSession("ses_0", { cwd: "/workspace", time: { created: 0, updated: 1 } })],
+ cursor: {},
+ })
+ }
+ return Response.json({ data: firstPage, cursor: { next: "page-2" } })
+ },
+ })
+
+ const first = await fixture.service.listSessions({ cwd: "/workspace" })
+ const second = await fixture.service.listSessions({ cwd: "/workspace", cursor: first.nextCursor })
+
+ expect(first.sessions).toHaveLength(100)
+ expect(first.sessions[0]).toEqual({
+ sessionId: "ses_100",
+ cwd: "/workspace",
+ title: "Session 100",
+ updatedAt: new Date(100_000).toISOString(),
+ })
+ expect(first.nextCursor).toBe("page-2")
+ expect(second.sessions.map((session) => session.sessionId)).toEqual(["ses_0"])
+ expect(second.nextCursor).toBeUndefined()
+ expect(
+ fixture.requests.filter((request) => request.path === "/api/session").map((request) => request.query),
+ ).toEqual([
+ { limit: "100", order: "desc", directory: "/workspace" },
+ { limit: "100", order: "desc", directory: "/workspace", cursor: "page-2" },
+ ])
+ })
+
+ test("cancel preserves the attachment while close removes it and interrupts best-effort", async () => {
+ await using fixture = makeACPFixture({
+ fetch(request) {
+ if (request.method === "POST" && request.path === "/api/session") {
+ return Response.json({ data: makeSession("ses_lifecycle") })
+ }
+ if (request.method === "POST" && request.path === "/api/session/ses_lifecycle/model") {
+ return new Response(null, { status: 204 })
+ }
+ if (request.method === "POST" && request.path.endsWith("/interrupt")) {
+ return new Response(null, { status: 500 })
+ }
+ return undefined
+ },
+ })
+ const created = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
+
+ await fixture.service.cancel({ sessionId: created.sessionId })
+ const updated = await fixture.service.setSessionConfigOption({
+ sessionId: created.sessionId,
+ configId: "effort",
+ value: "high",
+ })
+
+ expect(currentValue(updated, "effort")).toBe("high")
+ expect(await fixture.service.closeSession({ sessionId: created.sessionId })).toEqual({})
+ const missing = await fixture.service
+ .setSessionConfigOption({
+ sessionId: created.sessionId,
+ configId: "effort",
+ value: "default",
+ })
+ .catch((error: unknown) => error)
+ expect(missing).toMatchObject({ _tag: "ACPSessionNotFoundError", sessionId: created.sessionId })
+ expect(await fixture.service.closeSession({ sessionId: "missing" })).toEqual({})
+ expect(
+ fixture.requests.filter((request) => request.path.endsWith("/interrupt")).map((request) => request.path),
+ ).toEqual([
+ "/api/session/ses_lifecycle/interrupt",
+ "/api/session/ses_lifecycle/interrupt",
+ "/api/session/missing/interrupt",
+ ])
+ })
+})
+
+function currentValue(result: { readonly configOptions?: readonly SessionConfigOption[] | null }, id: string) {
+ return result.configOptions?.find((option) => option.id === id)?.currentValue
+}
diff --git a/packages/cli/test/acp/service-usage.test.ts b/packages/cli/test/acp/service-usage.test.ts
new file mode 100644
index 0000000000..1ad9eee7b7
--- /dev/null
+++ b/packages/cli/test/acp/service-usage.test.ts
@@ -0,0 +1,264 @@
+import { describe, expect, test } from "bun:test"
+import { makeACPFixture, makeSession, secondModel, type FixtureContext, type FixtureRequest } from "./service-fixture"
+
+describe("acp service prompt routing and usage", () => {
+ test("routes slash commands, skills, and compact through their session endpoints", async () => {
+ await using fixture = makeACPFixture({
+ fetch(request, context) {
+ if (request.method === "POST" && request.path === "/api/session") {
+ return Response.json({ data: makeSession("ses_routes") })
+ }
+ if (request.method === "GET" && request.path === "/api/session/ses_routes") {
+ return Response.json({ data: makeSession("ses_routes") })
+ }
+ if (request.method === "POST" && request.path === "/api/session/ses_routes/command") {
+ const id = requestID(request)
+ completeTurn(context, "ses_routes", {
+ id: `evt_${id}`,
+ type: "session.input.promoted",
+ data: { sessionID: "ses_routes", inputID: id },
+ })
+ return Response.json({ data: {} })
+ }
+ if (request.method === "POST" && request.path === "/api/session/ses_routes/skill") {
+ const id = requestID(request)
+ completeTurn(context, "ses_routes", {
+ id: id.replace(/^msg_/, "evt_"),
+ type: "session.skill.activated",
+ data: { sessionID: "ses_routes", skill: "verify" },
+ })
+ return new Response(null, { status: 204 })
+ }
+ if (request.method === "POST" && request.path === "/api/session/ses_routes/compact") {
+ const id = requestID(request)
+ completeTurn(context, "ses_routes", {
+ id: `evt_${id}`,
+ type: "session.compaction.admitted",
+ data: { sessionID: "ses_routes", inputID: id },
+ })
+ return Response.json({ data: {} })
+ }
+ return undefined
+ },
+ })
+ const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
+
+ const commandResult = await fixture.service.prompt({
+ sessionId: session.sessionId,
+ messageId: "client-command",
+ prompt: [{ type: "text", text: "/review now" }],
+ })
+ const skillResult = await fixture.service.prompt({
+ sessionId: session.sessionId,
+ messageId: "client-skill",
+ prompt: [{ type: "text", text: "/verify" }],
+ })
+ const compactResult = await fixture.service.prompt({
+ sessionId: session.sessionId,
+ messageId: "client-compact",
+ prompt: [{ type: "text", text: "/compact" }],
+ })
+
+ expect([commandResult.stopReason, skillResult.stopReason, compactResult.stopReason]).toEqual([
+ "end_turn",
+ "end_turn",
+ "end_turn",
+ ])
+ const command = fixture.requests.find((request) => request.path === "/api/session/ses_routes/command")
+ const skill = fixture.requests.find((request) => request.path === "/api/session/ses_routes/skill")
+ const compact = fixture.requests.find((request) => request.path === "/api/session/ses_routes/compact")
+ expect(command?.body).toMatchObject({
+ id: expect.any(String),
+ command: "review",
+ arguments: "now",
+ files: [],
+ delivery: "steer",
+ })
+ expect(skill?.body).toMatchObject({ id: expect.any(String), skill: "verify" })
+ expect(compact?.body).toMatchObject({ id: expect.any(String) })
+ expect(fixture.requests.some((request) => request.path === "/api/session/ses_routes/prompt")).toBe(false)
+ })
+
+ test("returns turn usage and publishes current context usage with cumulative session cost", async () => {
+ const assistantTokens = {
+ input: 100,
+ output: 40,
+ reasoning: 7,
+ cache: { read: 11, write: 13 },
+ }
+ await using fixture = makeACPFixture({
+ fetch(request, context) {
+ if (request.method === "POST" && request.path === "/api/session") {
+ return Response.json({ data: makeSession("ses_usage") })
+ }
+ if (request.method === "POST" && request.path === "/api/session/ses_usage/model") {
+ return new Response(null, { status: 204 })
+ }
+ if (request.method === "POST" && request.path === "/api/session/ses_usage/prompt") {
+ const id = requestID(request)
+ context.send({
+ id: `evt_${id}`,
+ type: "session.input.promoted",
+ data: { sessionID: "ses_usage", inputID: id },
+ })
+ context.send({
+ id: "evt_step",
+ type: "session.step.ended",
+ data: {
+ sessionID: "ses_usage",
+ assistantMessageID: "msg_assistant",
+ finish: "stop",
+ cost: 0.5,
+ tokens: assistantTokens,
+ },
+ })
+ context.send({
+ id: "evt_done",
+ type: "session.execution.succeeded",
+ data: { sessionID: "ses_usage" },
+ })
+ return Response.json({ data: {} })
+ }
+ if (request.method === "GET" && request.path === "/api/session/ses_usage/message/msg_assistant") {
+ return Response.json({
+ data: {
+ id: "msg_assistant",
+ type: "assistant",
+ agent: "build",
+ model: { providerID: "test", id: secondModel.id },
+ content: [{ type: "text", text: "done" }],
+ finish: "stop",
+ tokens: assistantTokens,
+ time: { created: 1, completed: 2 },
+ },
+ })
+ }
+ if (request.method === "GET" && request.path === "/api/session/ses_usage") {
+ return Response.json({
+ data: makeSession("ses_usage", {
+ model: { providerID: "test", id: secondModel.id },
+ cost: 3.5,
+ tokens: { input: 120, output: 50, reasoning: 8, cache: { read: 30, write: 4 } },
+ }),
+ })
+ }
+ return undefined
+ },
+ })
+ const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
+ await fixture.service.setSessionConfigOption({
+ sessionId: session.sessionId,
+ configId: "model",
+ value: "test/second-model",
+ })
+
+ const response = await fixture.service.prompt({
+ sessionId: session.sessionId,
+ messageId: "client-message",
+ prompt: [{ type: "text", text: "hello" }],
+ })
+
+ expect(response).toEqual({
+ stopReason: "end_turn",
+ userMessageId: "client-message",
+ usage: {
+ inputTokens: 100,
+ outputTokens: 40,
+ thoughtTokens: 7,
+ cachedReadTokens: 11,
+ cachedWriteTokens: 13,
+ totalTokens: 171,
+ },
+ _meta: {},
+ })
+ expect(fixture.updates.filter((item) => item.update.sessionUpdate === "usage_update")).toEqual([
+ {
+ sessionId: "ses_usage",
+ update: {
+ sessionUpdate: "usage_update",
+ used: 171,
+ size: 200_000,
+ cost: { amount: 3.5, currency: "USD" },
+ },
+ },
+ ])
+ })
+
+ test("does not fail a completed prompt when the usage refresh fails", async () => {
+ await using fixture = makeACPFixture({
+ fetch(request, context) {
+ if (request.method === "POST" && request.path === "/api/session") {
+ return Response.json({ data: makeSession("ses_usage_failure") })
+ }
+ if (request.method === "POST" && request.path === "/api/session/ses_usage_failure/prompt") {
+ const id = requestID(request)
+ context.send({
+ id: `evt_${id}`,
+ type: "session.input.promoted",
+ data: { sessionID: "ses_usage_failure", inputID: id },
+ })
+ context.send({
+ id: "evt_step_failure",
+ type: "session.step.ended",
+ data: {
+ sessionID: "ses_usage_failure",
+ assistantMessageID: "msg_usage_failure",
+ finish: "stop",
+ cost: 0,
+ tokens: { input: 3, output: 2, reasoning: 0, cache: { read: 0, write: 0 } },
+ },
+ })
+ context.send({
+ id: "evt_done_failure",
+ type: "session.execution.succeeded",
+ data: { sessionID: "ses_usage_failure" },
+ })
+ return Response.json({ data: {} })
+ }
+ if (request.method === "GET" && request.path === "/api/session/ses_usage_failure/message/msg_usage_failure") {
+ return Response.json({
+ data: {
+ id: "msg_usage_failure",
+ type: "assistant",
+ agent: "build",
+ model: { providerID: "test", id: "test-model" },
+ content: [],
+ finish: "stop",
+ tokens: { input: 3, output: 2, reasoning: 0, cache: { read: 0, write: 0 } },
+ time: { created: 1, completed: 2 },
+ },
+ })
+ }
+ if (request.method === "GET" && request.path === "/api/session/ses_usage_failure") {
+ return new Response(null, { status: 500 })
+ }
+ return undefined
+ },
+ })
+ const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
+
+ const response = await fixture.service.prompt({
+ sessionId: session.sessionId,
+ prompt: [{ type: "text", text: "hello" }],
+ })
+
+ expect(response.stopReason).toBe("end_turn")
+ expect(fixture.updates.some((item) => item.update.sessionUpdate === "usage_update")).toBe(false)
+ })
+})
+
+function requestID(request: FixtureRequest) {
+ if (!request.body || typeof request.body !== "object") throw new Error(`missing body for ${request.path}`)
+ const id = Reflect.get(request.body, "id")
+ if (typeof id !== "string") throw new Error(`missing prompt id for ${request.path}`)
+ return id
+}
+
+function completeTurn(context: FixtureContext, sessionID: string, start: unknown) {
+ context.send(start)
+ context.send({
+ id: `evt_done_${sessionID}`,
+ type: "session.execution.succeeded",
+ data: { sessionID },
+ })
+}
diff --git a/packages/cli/test/acp/service.test.ts b/packages/cli/test/acp/service.test.ts
new file mode 100644
index 0000000000..08bd95b310
--- /dev/null
+++ b/packages/cli/test/acp/service.test.ts
@@ -0,0 +1,110 @@
+import { describe, expect, test } from "bun:test"
+import type { AgentSideConnection } from "@agentclientprotocol/sdk"
+import { OpenCode } from "@opencode-ai/client/promise"
+import { ACPService } from "../../src/acp/service"
+
+describe("acp service", () => {
+ test("creates a v2 session, registers mcp, and publishes commands", async () => {
+ const requests: Array<{ method: string; path: string; body?: unknown }> = []
+ const updates: Parameters[0][] = []
+ const server = Bun.serve({
+ port: 0,
+ async fetch(request) {
+ const url = new URL(request.url)
+ requests.push({
+ method: request.method,
+ path: url.pathname,
+ body: request.method === "GET" ? undefined : await request.json().catch(() => undefined),
+ })
+ const location = { directory: "/workspace", project: { id: "global", directory: "/workspace" } }
+ if (url.pathname === "/api/model") return Response.json({ location, data: [model] })
+ if (url.pathname === "/api/model/default") return Response.json({ location, data: model })
+ if (url.pathname === "/api/agent") return Response.json({ location, data: [agent] })
+ if (url.pathname === "/api/command")
+ return Response.json({ location, data: [{ name: "review", template: "" }] })
+ if (url.pathname === "/api/skill") return Response.json({ location, data: [skill] })
+ if (url.pathname === "/api/session" && request.method === "POST") return Response.json({ data: session })
+ if (url.pathname === "/api/mcp/docs" && request.method === "PUT") return new Response(null, { status: 204 })
+ return new Response(null, { status: 404 })
+ },
+ })
+ const service = ACPService.make({
+ client: OpenCode.make({ baseUrl: server.url.toString() }),
+ connection: {
+ sessionUpdate: async (update) => {
+ updates.push(update)
+ },
+ requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
+ },
+ })
+
+ try {
+ const result = await service.newSession({
+ cwd: "/workspace",
+ mcpServers: [{ name: "docs", command: "bun", args: ["docs.ts"], env: [{ name: "TOKEN", value: "x" }] }],
+ })
+ expect(result.sessionId).toBe("ses_acp")
+ expect(result.configOptions?.map((option) => option.id)).toEqual(["model", "effort", "mode"])
+ expect(requests).toContainEqual({
+ method: "PUT",
+ path: "/api/mcp/docs",
+ body: {
+ config: { type: "local", command: ["bun", "docs.ts"], environment: { TOKEN: "x" } },
+ },
+ })
+ expect(updates.at(-1)).toMatchObject({
+ sessionId: "ses_acp",
+ update: {
+ sessionUpdate: "available_commands_update",
+ availableCommands: [{ name: "review" }, { name: "verify", description: "Verify work" }],
+ },
+ })
+ } finally {
+ await server.stop(true)
+ }
+ })
+})
+
+const model = {
+ id: "test-model",
+ modelID: "test-model",
+ providerID: "test",
+ name: "Test Model",
+ capabilities: { tools: true, input: ["text"], output: ["text"] },
+ variants: [{ id: "default" }, { id: "high" }],
+ time: { released: 0 },
+ cost: [],
+ status: "active" as const,
+ enabled: true,
+ limit: { context: 100_000, output: 10_000 },
+}
+
+const agent = {
+ id: "build",
+ name: "Build",
+ request: { settings: {}, headers: {}, body: {} },
+ mode: "primary" as const,
+ hidden: false,
+ permissions: [],
+}
+
+const skill = {
+ id: "verify",
+ name: "verify",
+ description: "Verify work",
+ slash: true,
+ location: "/skills/verify.md",
+ content: "verify",
+}
+
+const session = {
+ id: "ses_acp",
+ projectID: "global",
+ agent: "build",
+ model: { providerID: "test", id: "test-model", variant: "default" },
+ cost: 0,
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+ time: { created: 0, updated: 0 },
+ title: "New session",
+ location: { directory: "/workspace" },
+}
diff --git a/packages/cli/test/acp/skills.subprocess.test.ts b/packages/cli/test/acp/skills.subprocess.test.ts
new file mode 100644
index 0000000000..cf0730b3bc
--- /dev/null
+++ b/packages/cli/test/acp/skills.subprocess.test.ts
@@ -0,0 +1,24 @@
+import type { SessionNotification } from "@agentclientprotocol/sdk"
+import { describe, expect, test } from "bun:test"
+import { createAcpFixture, initialize, newSession, verifierSkill } from "./subprocess"
+
+describe("acp skills subprocess", () => {
+ test("skill slash command appears through available_commands_update", async () => {
+ await using fixture = await createAcpFixture({ skill: verifierSkill })
+ const acp = fixture.spawn()
+ await initialize(acp)
+ const session = await newSession(acp, fixture.home)
+
+ const update = await acp.waitForNotification(
+ "session/update",
+ (params) =>
+ params.sessionId === session.sessionId &&
+ params.update.sessionUpdate === "available_commands_update" &&
+ params.update.availableCommands.some(
+ (command) => command.name === "verifier-skill" && command.description.length > 0,
+ ),
+ )
+
+ expect(update.params.sessionId).toBe(session.sessionId)
+ }, 60_000)
+})
diff --git a/packages/cli/test/acp/sse-fixture.ts b/packages/cli/test/acp/sse-fixture.ts
new file mode 100644
index 0000000000..1f1e3f5d19
--- /dev/null
+++ b/packages/cli/test/acp/sse-fixture.ts
@@ -0,0 +1,207 @@
+import { OpenCode, type OpenCodeEvent, type SessionMessageInfo } from "@opencode-ai/client/promise"
+
+type DurableEvent = Extract
+type EphemeralEvent = Exclude
+
+type RequestRecord = {
+ readonly method: string
+ readonly path: string
+ readonly body?: unknown
+}
+
+type FixtureOptions = {
+ readonly onPrompt?: (input: {
+ readonly sessionID: string
+ readonly id: string
+ readonly body: unknown
+ readonly signal: AbortSignal
+ readonly send: (event: unknown) => void
+ }) => void | Promise
+ readonly onInterrupt?: (input: {
+ readonly sessionID: string
+ readonly send: (event: unknown) => void
+ }) => void | Promise
+ readonly onPermissionReply?: (input: {
+ readonly sessionID: string
+ readonly requestID: string
+ readonly reply: string
+ readonly body: unknown
+ readonly send: (event: unknown) => void
+ }) => void | Promise
+ readonly onFormCancel?: (input: {
+ readonly sessionID: string
+ readonly formID: string
+ readonly send: (event: unknown) => void
+ }) => void | Promise
+}
+
+const ids = { next: 0 }
+
+export function durableEvent(
+ type: Type,
+ data: Extract["data"],
+) {
+ ids.next++
+ return {
+ id: `evt_${ids.next}`,
+ created: ids.next,
+ type,
+ durable: { aggregateID: "test", seq: ids.next, version: 1 },
+ data,
+ }
+}
+
+export function ephemeralEvent(
+ type: Type,
+ data: Extract["data"],
+) {
+ ids.next++
+ return { id: `evt_${ids.next}`, created: ids.next, type, data }
+}
+
+export function createSseFixture(options: FixtureOptions = {}) {
+ const encoder = new TextEncoder()
+ const streams = new Set>()
+ const requests: RequestRecord[] = []
+ const messages = new Map()
+
+ const send = (event: unknown) => {
+ for (const stream of streams) {
+ try {
+ stream.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
+ } catch {
+ streams.delete(stream)
+ }
+ }
+ }
+
+ const server = Bun.serve({
+ port: 0,
+ async fetch(request) {
+ const url = new URL(request.url)
+ const body = request.method === "GET" ? undefined : await request.json().catch(() => undefined)
+ requests.push({ method: request.method, path: url.pathname, ...(body === undefined ? {} : { body }) })
+
+ if (url.pathname === "/api/event") {
+ const state: { stream?: ReadableStreamDefaultController } = {}
+ return new Response(
+ new ReadableStream({
+ start(stream) {
+ state.stream = stream
+ streams.add(stream)
+ stream.enqueue(
+ encoder.encode(
+ `data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n`,
+ ),
+ )
+ },
+ cancel() {
+ if (state.stream) streams.delete(state.stream)
+ },
+ }),
+ { headers: { "content-type": "text/event-stream" } },
+ )
+ }
+
+ const prompt = /^\/api\/session\/([^/]+)\/prompt$/.exec(url.pathname)
+ if (prompt?.[1]) {
+ const id = stringField(body, "id")
+ if (!id) return new Response(null, { status: 400 })
+ await options.onPrompt?.({
+ sessionID: decodeURIComponent(prompt[1]),
+ id,
+ body,
+ signal: request.signal,
+ send,
+ })
+ return Response.json({ data: { text: stringField(body, "text") ?? "" } })
+ }
+
+ const message = /^\/api\/session\/([^/]+)\/message\/([^/]+)$/.exec(url.pathname)
+ if (message?.[1] && message[2]) {
+ const sessionID = decodeURIComponent(message[1])
+ const messageID = decodeURIComponent(message[2])
+ return Response.json({
+ data: messages.get(`${sessionID}/${messageID}`) ?? messages.get(messageID) ?? assistantMessage(messageID),
+ })
+ }
+
+ const permission = /^\/api\/session\/([^/]+)\/permission\/([^/]+)\/reply$/.exec(url.pathname)
+ if (permission?.[1] && permission[2]) {
+ const reply = stringField(body, "reply")
+ if (!reply) return new Response(null, { status: 400 })
+ await options.onPermissionReply?.({
+ sessionID: decodeURIComponent(permission[1]),
+ requestID: decodeURIComponent(permission[2]),
+ reply,
+ body,
+ send,
+ })
+ return new Response(null, { status: 204 })
+ }
+
+ const form = /^\/api\/session\/([^/]+)\/form\/([^/]+)\/cancel$/.exec(url.pathname)
+ if (form?.[1] && form[2]) {
+ await options.onFormCancel?.({
+ sessionID: decodeURIComponent(form[1]),
+ formID: decodeURIComponent(form[2]),
+ send,
+ })
+ return new Response(null, { status: 204 })
+ }
+
+ const interrupt = /^\/api\/session\/([^/]+)\/interrupt$/.exec(url.pathname)
+ if (interrupt?.[1]) {
+ await options.onInterrupt?.({ sessionID: decodeURIComponent(interrupt[1]), send })
+ return new Response(null, { status: 204 })
+ }
+
+ return new Response(null, { status: 404 })
+ },
+ })
+
+ return {
+ client: OpenCode.make({ baseUrl: server.url.toString() }),
+ messages,
+ requests,
+ send,
+ async stop() {
+ for (const stream of streams) {
+ try {
+ stream.close()
+ } catch {}
+ }
+ streams.clear()
+ await server.stop(true)
+ },
+ }
+}
+
+export async function withTimeout(promise: Promise, message: string, milliseconds = 2_000) {
+ const timeout = Promise.withResolvers()
+ const timer = setTimeout(() => timeout.reject(new Error(message)), milliseconds)
+ try {
+ return await Promise.race([promise, timeout.promise])
+ } finally {
+ clearTimeout(timer)
+ }
+}
+
+function stringField(value: unknown, key: string) {
+ if (!value || typeof value !== "object") return undefined
+ const field = Reflect.get(value, key)
+ return typeof field === "string" ? field : undefined
+}
+
+function assistantMessage(id: string) {
+ return {
+ id,
+ type: "assistant",
+ agent: "build",
+ model: { providerID: "test", id: "test-model" },
+ content: [],
+ finish: "stop",
+ tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
+ time: { created: 1, completed: 2 },
+ } satisfies SessionMessageInfo
+}
diff --git a/packages/cli/test/acp/subprocess.ts b/packages/cli/test/acp/subprocess.ts
new file mode 100644
index 0000000000..79afd1b152
--- /dev/null
+++ b/packages/cli/test/acp/subprocess.ts
@@ -0,0 +1,384 @@
+import type {
+ InitializeResponse,
+ NewSessionResponse,
+ SessionConfigOption,
+ SessionConfigSelectOption,
+} from "@agentclientprotocol/sdk"
+import fs from "node:fs/promises"
+import os from "node:os"
+import path from "node:path"
+
+type JsonRpcRequest = {
+ readonly jsonrpc: "2.0"
+ readonly id: number
+ readonly method: string
+ readonly params?: unknown
+}
+
+export type JsonRpcError = {
+ readonly code: number
+ readonly message?: string
+ readonly data?: unknown
+}
+
+export type JsonRpcResponse = {
+ readonly jsonrpc: "2.0"
+ readonly id: number
+ readonly result?: T
+ readonly error?: JsonRpcError
+}
+
+type JsonRpcNotification = {
+ readonly jsonrpc: "2.0"
+ readonly method: string
+ readonly params: T
+}
+
+type JsonRpcMessage = Record
+
+type Waiter = {
+ readonly predicate: (message: JsonRpcMessage) => boolean
+ readonly resolve: (message: JsonRpcMessage) => void
+ readonly reject: (error: Error) => void
+ readonly timer: ReturnType
+}
+
+export type AcpProcess = {
+ readonly request: (method: string, params?: unknown) => Promise>
+ readonly waitForNotification: (
+ method: string,
+ predicate: (params: T) => boolean,
+ timeoutMs?: number,
+ ) => Promise>
+ readonly close: () => Promise
+ readonly stderr: () => string
+ readonly [Symbol.asyncDispose]: () => Promise
+}
+
+export const verifierSkill = `---
+name: verifier-skill
+description: Verifier compatibility skill.
+---
+
+# Verifier Skill
+`
+
+export async function createAcpFixture(options: { readonly skill?: string } = {}) {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cli-acp-"))
+ const home = path.join(root, "workspace")
+ const config = path.join(root, "config")
+ const skills = path.join(root, "skills")
+ await Promise.all([fs.mkdir(home, { recursive: true }), fs.mkdir(config, { recursive: true })])
+ if (options.skill) {
+ await fs.mkdir(path.join(skills, "verifier-skill"), { recursive: true })
+ await Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), options.skill)
+ }
+
+ const requests: unknown[] = []
+ const llm = Bun.serve({
+ hostname: "127.0.0.1",
+ port: 0,
+ async fetch(request) {
+ if (request.method !== "POST" || new URL(request.url).pathname !== "/v1/chat/completions") {
+ return new Response("Not found", { status: 404 })
+ }
+ requests.push(await request.json().catch(() => undefined))
+ return new Response(completion("accepted"), {
+ headers: { "content-type": "text/event-stream" },
+ })
+ },
+ })
+ await Bun.write(
+ path.join(config, "opencode.json"),
+ JSON.stringify(verifierConfig(`http://127.0.0.1:${llm.port}/v1`, options.skill ? skills : undefined)),
+ )
+
+ const processes = new Set()
+ return {
+ root,
+ home,
+ llm: { requests },
+ spawn(extraEnv: Record = {}) {
+ const acp = spawnAcp({
+ env: {
+ ...process.env,
+ HOME: root,
+ USERPROFILE: root,
+ OPENCODE_CONFIG: undefined,
+ OPENCODE_CONFIG_CONTENT: undefined,
+ OPENCODE_CONFIG_DIR: config,
+ OPENCODE_DB: path.join(root, "opencode.db"),
+ OPENCODE_DISABLE_AUTOUPDATE: "true",
+ OPENCODE_DISABLE_FILEWATCHER: "true",
+ OPENCODE_DISABLE_MODELS_FETCH: "true",
+ OPENCODE_MODELS_PATH: undefined,
+ OPENCODE_TEST_HOME: root,
+ XDG_CACHE_HOME: path.join(root, "cache"),
+ XDG_CONFIG_HOME: path.join(root, "xdg-config"),
+ XDG_DATA_HOME: path.join(root, "data"),
+ XDG_STATE_HOME: path.join(root, "state"),
+ ...extraEnv,
+ },
+ })
+ processes.add(acp)
+ return acp
+ },
+ async [Symbol.asyncDispose]() {
+ await Promise.all([...processes].map((process) => process[Symbol.asyncDispose]()))
+ await llm.stop(true)
+ await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
+ },
+ }
+}
+
+export function initialize(acp: AcpProcess) {
+ return acp
+ .request("initialize", {
+ protocolVersion: 1,
+ clientCapabilities: { _meta: { "terminal-auth": true } },
+ clientInfo: { name: "opencode-local-acp", version: "0.1.0" },
+ })
+ .then(expectOk)
+}
+
+export function newSession(acp: AcpProcess, cwd: string) {
+ return acp.request("session/new", { cwd, mcpServers: [] }).then(expectOk)
+}
+
+export function expectOk(response: JsonRpcResponse) {
+ if (response.error) throw new Error(`ACP request failed: ${JSON.stringify(response.error)}`)
+ if (response.result === undefined) throw new Error("ACP response did not include a result")
+ return response.result
+}
+
+export function selectConfigOption(options: SessionConfigOption[] | null | undefined, id: string) {
+ return options?.find(
+ (option): option is Extract =>
+ option.id === id && option.type === "select",
+ )
+}
+
+export function requireSelectOption(options: SessionConfigOption[] | null | undefined, id: string) {
+ const option = selectConfigOption(options, id)
+ if (!option) throw new Error(`Missing ACP config option: ${id}`)
+ return option
+}
+
+export function flattenSelectOptions(option: Extract) {
+ return option.options.flatMap((item): SessionConfigSelectOption[] => ("value" in item ? [item] : item.options))
+}
+
+export function alternateValue(option: Extract) {
+ const value = flattenSelectOptions(option).find((item) => item.value !== option.currentValue)?.value
+ if (!value) throw new Error(`ACP config option ${option.id} has no alternate value`)
+ return value
+}
+
+function verifierConfig(llmUrl: string, skills?: string) {
+ const model = {
+ capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
+ cost: { input: 0, output: 0 },
+ limit: { context: 100_000, output: 10_000 },
+ }
+ return {
+ autoupdate: false,
+ model: "test/test-model",
+ ...(skills ? { skills: [skills] } : {}),
+ providers: {
+ test: {
+ name: "Test",
+ package: "aisdk:@ai-sdk/openai-compatible",
+ settings: { apiKey: "test-key", baseURL: llmUrl },
+ models: {
+ "test-model": {
+ ...model,
+ name: "Test Model",
+ variants: [{ id: "low" }, { id: "high" }],
+ },
+ "second-model": {
+ ...model,
+ name: "Second Test Model",
+ variants: [{ id: "medium" }, { id: "max" }],
+ },
+ },
+ },
+ },
+ }
+}
+
+function spawnAcp(input: { readonly env: Record }): AcpProcess {
+ const child = Bun.spawn([process.execPath, "run", "src/index.ts", "acp"], {
+ cwd: path.join(import.meta.dir, "../.."),
+ env: input.env,
+ stdin: "pipe",
+ stdout: "pipe",
+ stderr: "pipe",
+ })
+ const encoder = new TextEncoder()
+ const decoder = new TextDecoder()
+ const errorDecoder = new TextDecoder()
+ const messages: JsonRpcMessage[] = []
+ const waiters: Waiter[] = []
+ let nextID = 1
+ let failure: Error | undefined
+ let stderr = ""
+ let inputClosed = false
+ let disposed = false
+
+ const fail = (error: Error) => {
+ if (failure) return
+ failure = error
+ waiters.splice(0).forEach((waiter) => {
+ clearTimeout(waiter.timer)
+ waiter.reject(error)
+ })
+ }
+
+ const dispatch = (message: JsonRpcMessage) => {
+ const index = waiters.findIndex((waiter) => waiter.predicate(message))
+ if (index === -1) {
+ messages.push(message)
+ return
+ }
+ const waiter = waiters.splice(index, 1)[0]
+ clearTimeout(waiter.timer)
+ waiter.resolve(message)
+ }
+
+ const output = (async () => {
+ const reader = child.stdout.getReader()
+ let buffered = ""
+ while (true) {
+ const chunk = await reader.read()
+ if (chunk.done) break
+ buffered += decoder.decode(chunk.value, { stream: true })
+ while (true) {
+ const newline = buffered.indexOf("\n")
+ if (newline === -1) break
+ const line = buffered.slice(0, newline).trim()
+ buffered = buffered.slice(newline + 1)
+ if (line) dispatch(parseMessage(line))
+ }
+ }
+ buffered += decoder.decode()
+ if (buffered.trim()) dispatch(parseMessage(buffered.trim()))
+ fail(new Error(`ACP exited before another response${stderr ? `: ${stderr}` : ""}`))
+ })().catch((error) => fail(asError(error)))
+
+ const errors = (async () => {
+ const reader = child.stderr.getReader()
+ while (true) {
+ const chunk = await reader.read()
+ if (chunk.done) break
+ stderr += errorDecoder.decode(chunk.value, { stream: true })
+ }
+ stderr += errorDecoder.decode()
+ })()
+
+ const take = (predicate: (message: JsonRpcMessage) => boolean, timeoutMs: number, description: string) => {
+ const index = messages.findIndex(predicate)
+ if (index !== -1) return Promise.resolve(messages.splice(index, 1)[0])
+ if (failure) return Promise.reject(failure)
+ return new Promise((resolve, reject) => {
+ const waiter: Waiter = {
+ predicate,
+ resolve,
+ reject,
+ timer: setTimeout(() => {
+ const index = waiters.indexOf(waiter)
+ if (index !== -1) waiters.splice(index, 1)
+ reject(new Error(`Timed out waiting for ${description}${stderr ? `: ${stderr}` : ""}`))
+ }, timeoutMs),
+ }
+ waiters.push(waiter)
+ })
+ }
+
+ return {
+ async request(method: string, params?: unknown) {
+ if (inputClosed) throw new Error("ACP stdin is closed")
+ const id = nextID++
+ const request: JsonRpcRequest =
+ params === undefined ? { jsonrpc: "2.0", id, method } : { jsonrpc: "2.0", id, method, params }
+ await child.stdin.write(encoder.encode(`${JSON.stringify(request)}\n`))
+ await child.stdin.flush()
+ const response = await take((message) => isResponse(message) && message.id === id, 20_000, `${method} response`)
+ if (!isResponse(response)) throw new Error(`Invalid ACP response: ${JSON.stringify(response)}`)
+ return response
+ },
+ async waitForNotification(method: string, predicate: (params: T) => boolean, timeoutMs = 20_000) {
+ const notification = await take(
+ (message) => isNotification(message) && message.method === method && predicate(message.params),
+ timeoutMs,
+ `${method} notification`,
+ )
+ if (!isNotification(notification)) {
+ throw new Error(`Invalid ACP notification: ${JSON.stringify(notification)}`)
+ }
+ return notification
+ },
+ async close() {
+ if (!inputClosed) {
+ inputClosed = true
+ await child.stdin.end()
+ }
+ const exitCode = await withTimeout(child.exited, 5_000, "ACP did not exit after stdin EOF")
+ await Promise.all([output, errors])
+ if (exitCode !== 0) throw new Error(`ACP exited with ${exitCode}: ${stderr}`)
+ return exitCode
+ },
+ stderr: () => stderr,
+ async [Symbol.asyncDispose]() {
+ if (disposed) return
+ disposed = true
+ if (child.exitCode === null) child.kill("SIGKILL")
+ await child.exited
+ await Promise.all([output, errors])
+ },
+ }
+}
+
+function parseMessage(line: string): JsonRpcMessage {
+ const message: unknown = JSON.parse(line)
+ if (!isJsonRpcMessage(message)) throw new Error(`Invalid ACP message: ${line}`)
+ return message
+}
+
+function isJsonRpcMessage(message: unknown): message is JsonRpcMessage {
+ return !!message && typeof message === "object" && !Array.isArray(message)
+}
+
+function isResponse(message: JsonRpcMessage): message is JsonRpcMessage & JsonRpcResponse {
+ return message.jsonrpc === "2.0" && typeof message.id === "number" && !("method" in message)
+}
+
+function isNotification(message: JsonRpcMessage): message is JsonRpcMessage & JsonRpcNotification {
+ return message.jsonrpc === "2.0" && typeof message.method === "string" && !("id" in message)
+}
+
+function asError(error: unknown) {
+ return error instanceof Error ? error : new Error(String(error))
+}
+
+function withTimeout(promise: Promise, timeoutMs: number, message: string) {
+ let timer: ReturnType | undefined
+ return Promise.race([
+ promise,
+ new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error(message)), timeoutMs)
+ }),
+ ]).finally(() => clearTimeout(timer))
+}
+
+function completion(text: string) {
+ const chunks = [
+ { choices: [{ delta: { role: "assistant" }, finish_reason: null }], usage: null },
+ { choices: [{ delta: { content: text }, finish_reason: null }], usage: null },
+ { choices: [{ delta: {}, finish_reason: "stop" }], usage: null },
+ {
+ choices: [],
+ usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 },
+ },
+ ]
+ return `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`
+}
diff --git a/packages/cli/test/acp/tool.test.ts b/packages/cli/test/acp/tool.test.ts
new file mode 100644
index 0000000000..da90fb7c05
--- /dev/null
+++ b/packages/cli/test/acp/tool.test.ts
@@ -0,0 +1,292 @@
+import { resolve } from "node:path"
+import { describe, expect, test } from "bun:test"
+import {
+ completedToolUpdate,
+ errorToolUpdate,
+ pendingToolCall,
+ runningToolUpdate,
+ toLocations,
+ toToolKind,
+} from "../../src/acp/tool"
+
+describe("acp tools", () => {
+ test("maps OpenCode tool ids to ACP tool kinds", () => {
+ expect(toToolKind("bash")).toBe("execute")
+ expect(toToolKind("shell")).toBe("execute")
+ expect(toToolKind("webfetch")).toBe("fetch")
+ expect(toToolKind("edit")).toBe("edit")
+ expect(toToolKind("apply_patch")).toBe("edit")
+ expect(toToolKind("patch")).toBe("edit")
+ expect(toToolKind("write")).toBe("edit")
+ expect(toToolKind("grep")).toBe("search")
+ expect(toToolKind("glob")).toBe("search")
+ expect(toToolKind("context7_resolve_library_id")).toBe("search")
+ expect(toToolKind("context7_get_library_docs")).toBe("search")
+ expect(toToolKind("read")).toBe("read")
+ expect(toToolKind("task")).toBe("think")
+ expect(toToolKind("custom_tool")).toBe("other")
+ })
+
+ test("extracts file locations from tool input", () => {
+ expect(toLocations("read", { filePath: "/tmp/a.ts" })).toEqual([{ path: "/tmp/a.ts" }])
+ expect(toLocations("edit", { filePath: "/tmp/b.ts" })).toEqual([{ path: "/tmp/b.ts" }])
+ expect(toLocations("write", { filePath: "/tmp/c.ts" })).toEqual([{ path: "/tmp/c.ts" }])
+ expect(toLocations("grep", { path: "/repo/src" })).toEqual([{ path: "/repo/src" }])
+ expect(toLocations("glob", { path: "/repo/test" })).toEqual([{ path: "/repo/test" }])
+ expect(toLocations("context7_get_library_docs", { path: "/docs" })).toEqual([{ path: "/docs" }])
+ expect(toLocations("external_directory", { directories: ["/tmp/outside"], patterns: ["/tmp/outside/*"] })).toEqual([
+ { path: "/tmp/outside" },
+ ])
+ expect(toLocations("bash", { cmd: "pwd" }, "/workspace")).toEqual([{ path: "/workspace" }])
+ expect(toLocations("bash", { command: "pwd", workdir: "subdir" }, "/workspace")).toEqual([
+ { path: resolve("/workspace", "subdir") },
+ ])
+ expect(toLocations("bash", { command: "pwd", workdir: "/abs/dir" }, "/workspace")).toEqual([{ path: "/abs/dir" }])
+ expect(toLocations("bash", { command: "printf hello" })).toEqual([])
+ expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([])
+ })
+
+ test("builds completed content with text and image attachments", () => {
+ const image = Buffer.from("image-data").toString("base64")
+
+ expect(
+ completedToolUpdate({
+ toolCallId: "tool-1",
+ toolName: "edit",
+ input: {
+ filePath: "/tmp/file.ts",
+ oldString: "before",
+ newString: "after",
+ },
+ content: [
+ { type: "text", text: "edited /tmp/file.ts" },
+ { type: "file", mime: "image/png", name: "image.png", uri: `data:image/png;base64,${image}` },
+ { type: "file", mime: "text/plain", name: "note.txt", uri: "data:text/plain;base64,bm90ZQ==" },
+ ],
+ structured: {},
+ }).content,
+ ).toEqual([
+ {
+ type: "content",
+ content: { type: "text", text: "edited /tmp/file.ts" },
+ },
+ {
+ type: "diff",
+ path: "/tmp/file.ts",
+ oldText: "before",
+ newText: "after",
+ },
+ {
+ type: "content",
+ content: { type: "image", mimeType: "image/png", data: image },
+ },
+ ])
+ })
+
+ test("omits edit diffs when normalized content does not contain one", () => {
+ expect(
+ completedToolUpdate({
+ toolCallId: "tool-1",
+ toolName: "write",
+ input: {
+ filePath: "/tmp/file.ts",
+ content: "created",
+ },
+ content: [{ type: "text", text: "wrote /tmp/file.ts" }],
+ structured: {},
+ }).content,
+ ).toEqual([
+ {
+ type: "content",
+ content: { type: "text", text: "wrote /tmp/file.ts" },
+ },
+ ])
+ })
+
+ test("uses clean structured read content instead of model-facing formatting", () => {
+ expect(
+ completedToolUpdate({
+ toolCallId: "tool-read",
+ toolName: "read",
+ input: { path: "/tmp/file.ts" },
+ content: [{ type: "text", text: "1: first\n2: second" }],
+ structured: {
+ type: "text-page",
+ content: "first\nsecond",
+ mime: "text/plain",
+ offset: 1,
+ truncated: false,
+ },
+ }).content,
+ ).toEqual([{ type: "content", content: { type: "text", text: "first\nsecond" } }])
+
+ expect(
+ completedToolUpdate({
+ toolCallId: "tool-list",
+ toolName: "read",
+ input: { path: "/tmp" },
+ content: [],
+ structured: {
+ entries: [
+ { path: "a.ts", type: "file" },
+ { path: "src", type: "directory" },
+ ],
+ },
+ }).content,
+ ).toEqual([{ type: "content", content: { type: "text", text: "a.ts\nsrc" } }])
+ })
+
+ test("sends completed tool calls as partial updates", () => {
+ expect(
+ pendingToolCall({
+ toolCallId: "tool-1",
+ toolName: "edit",
+ state: {
+ input: {
+ filePath: "/tmp/file.ts",
+ oldString: "before",
+ newString: "after",
+ },
+ },
+ }),
+ ).toMatchObject({
+ toolCallId: "tool-1",
+ status: "pending",
+ kind: "edit",
+ locations: [{ path: "/tmp/file.ts" }],
+ rawInput: {
+ filePath: "/tmp/file.ts",
+ oldString: "before",
+ newString: "after",
+ },
+ })
+
+ expect(
+ completedToolUpdate({
+ toolCallId: "tool-1",
+ toolName: "edit",
+ input: {
+ filePath: "/tmp/file.ts",
+ oldString: "before",
+ newString: "after",
+ },
+ content: [{ type: "text", text: "Edit applied successfully." }],
+ structured: { output: "Edit applied successfully." },
+ }),
+ ).toEqual({
+ toolCallId: "tool-1",
+ status: "completed",
+ content: [
+ {
+ type: "content",
+ content: { type: "text", text: "Edit applied successfully." },
+ },
+ {
+ type: "diff",
+ path: "/tmp/file.ts",
+ oldText: "before",
+ newText: "after",
+ },
+ ],
+ rawOutput: {
+ structured: { output: "Edit applied successfully." },
+ },
+ })
+ })
+
+ test("builds running tool updates with normalized content", () => {
+ expect(
+ runningToolUpdate({
+ toolCallId: "call",
+ toolName: "read",
+ state: { input: { filePath: "/tmp/a" } },
+ content: [{ type: "text", text: "done" }],
+ }),
+ ).toMatchObject({
+ toolCallId: "call",
+ status: "in_progress",
+ content: [{ type: "content", content: { type: "text", text: "done" } }],
+ })
+ })
+
+ test("builds completed raw output with structured data and optional result", () => {
+ const attachments = [
+ {
+ type: "file",
+ mime: "image/jpeg",
+ name: "photo.jpg",
+ uri: "data:image/jpeg;base64,AAAA",
+ },
+ ]
+
+ expect(
+ completedToolUpdate({
+ toolCallId: "call",
+ toolName: "read",
+ input: {},
+ content: [],
+ structured: { output: "done", metadata: { exit: 0 }, attachments },
+ result: "done",
+ }).rawOutput,
+ ).toEqual({
+ structured: { output: "done", metadata: { exit: 0 }, attachments },
+ result: "done",
+ })
+
+ expect(
+ completedToolUpdate({
+ toolCallId: "call",
+ toolName: "read",
+ input: {},
+ content: [],
+ structured: { output: "done" },
+ }).rawOutput,
+ ).toEqual({ structured: { output: "done" } })
+ })
+
+ test("extracts image attachments only from data URLs", () => {
+ expect(
+ completedToolUpdate({
+ toolCallId: "call",
+ toolName: "read",
+ input: {},
+ content: [
+ { type: "file", mime: "image/webp", uri: "data:image/webp;charset=utf-8;base64,AAAA" },
+ { type: "file", mime: "image/png", uri: "https://example.com/image.png" },
+ { type: "file", mime: "text/plain", uri: "data:text/plain;base64,BBBB" },
+ ],
+ structured: {},
+ }).content,
+ ).toEqual([
+ {
+ type: "content",
+ content: { type: "image", mimeType: "image/webp", data: "AAAA" },
+ },
+ ])
+ })
+
+ test("builds failed tool updates", () => {
+ expect(
+ errorToolUpdate({
+ toolCallId: "call",
+ toolName: "read",
+ input: { filePath: "/tmp/a" },
+ content: [{ type: "text", text: "partial output" }],
+ structured: { path: "/tmp/a" },
+ error: "failed",
+ }),
+ ).toEqual({
+ toolCallId: "call",
+ status: "failed",
+ kind: "read",
+ title: "read",
+ locations: [{ path: "/tmp/a" }],
+ rawInput: { filePath: "/tmp/a" },
+ content: [
+ { type: "content", content: { type: "text", text: "partial output" } },
+ { type: "content", content: { type: "text", text: "failed" } },
+ ],
+ rawOutput: { structured: { path: "/tmp/a" }, error: "failed" },
+ })
+ })
+})
diff --git a/packages/core/package.json b/packages/core/package.json
index 3dfce047f4..cf54590d52 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -41,11 +41,6 @@
"node": "./src/image/photon-wasm.node.ts",
"default": "./src/image/photon-wasm.bun.ts"
},
- "#runtime-import": {
- "bun": "@opencode-ai/util/runtime/import.bun",
- "node": "@opencode-ai/util/runtime/import.node",
- "default": "@opencode-ai/util/runtime/import.bun"
- },
"#process-lock-ffi": {
"bun": "./src/util/process-lock-ffi.bun.ts",
"node": "./src/util/process-lock-ffi.node.ts",
diff --git a/packages/core/src/plugin/provider/dynamic.ts b/packages/core/src/plugin/provider/dynamic.ts
index e5876a5a82..4e8eedb81e 100644
--- a/packages/core/src/plugin/provider/dynamic.ts
+++ b/packages/core/src/plugin/provider/dynamic.ts
@@ -2,7 +2,7 @@ import { Effect } from "effect"
import { pathToFileURL } from "url"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Npm } from "@opencode-ai/util/npm"
-import { importModule } from "#runtime-import"
+import { importModule } from "@opencode-ai/util/runtime-import"
export const DynamicProviderPlugin = define({
id: "opencode.provider.dynamic",
diff --git a/packages/core/src/plugin/provider/sap-ai-core.ts b/packages/core/src/plugin/provider/sap-ai-core.ts
index 07b19a9f24..4015a77c14 100644
--- a/packages/core/src/plugin/provider/sap-ai-core.ts
+++ b/packages/core/src/plugin/provider/sap-ai-core.ts
@@ -3,7 +3,7 @@ import { pathToFileURL } from "url"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Npm } from "@opencode-ai/util/npm"
import { ProviderV2 } from "../../provider"
-import { importModule } from "#runtime-import"
+import { importModule } from "@opencode-ai/util/runtime-import"
export const SapAICorePlugin = define({
id: "opencode.provider.sap-ai-core",
diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts
index 1e0b604952..b512ac35c6 100644
--- a/packages/core/src/plugin/supervisor.ts
+++ b/packages/core/src/plugin/supervisor.ts
@@ -39,7 +39,7 @@ import { WellKnown } from "../wellknown"
import { PluginInternal } from "./internal"
import { PluginRuntime } from "./runtime"
import { SdkPlugins } from "./sdk"
-import { importModule } from "#runtime-import"
+import { importModule } from "@opencode-ai/util/runtime-import"
const PluginModule = Schema.Struct({
default: Schema.Union([
diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts
index fd8be3d788..e378882d79 100644
--- a/packages/core/src/provider.ts
+++ b/packages/core/src/provider.ts
@@ -5,7 +5,7 @@ import { Provider } from "@opencode-ai/schema/provider"
import type { ProviderPackageDefinition } from "@opencode-ai/ai"
import { Npm } from "@opencode-ai/util/npm"
import type { DeepMutable } from "./schema"
-import { importModule, resolveModule } from "#runtime-import"
+import { importModule, resolveModule } from "@opencode-ai/util/runtime-import"
export const ID = Provider.ID
export type ID = typeof ID.Type
diff --git a/packages/core/src/tool/patch.ts b/packages/core/src/tool/patch.ts
index 7de2b4284d..fef7e9a6d2 100644
--- a/packages/core/src/tool/patch.ts
+++ b/packages/core/src/tool/patch.ts
@@ -8,7 +8,7 @@ import { Effect, Schema } from "effect"
import path from "path"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../location"
-import { Patch } from "../patch"
+import { Patch } from "@opencode-ai/util/patch"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import DESCRIPTION from "./patch.txt"
@@ -186,8 +186,7 @@ export const Plugin = {
const before = original.replace(/^\uFEFF/, "")
const update = yield* Effect.try({
try: () => Patch.derive(hunk.path, hunk.chunks, original),
- catch: (error) =>
- new ToolFailure({ message: `patch verification failed: ${String(error)}` }),
+ catch: (error) => new ToolFailure({ message: `patch verification failed: ${String(error)}` }),
})
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
if (moveTarget?.externalDirectory) {
diff --git a/packages/core/test/patch.test.ts b/packages/core/test/patch.test.ts
index f5bdddb333..d094ced63b 100644
--- a/packages/core/test/patch.test.ts
+++ b/packages/core/test/patch.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
-import { Patch } from "@opencode-ai/core/patch"
+import { Patch } from "@opencode-ai/util/patch"
import { Result } from "effect"
const parse = (input: string) => Result.getOrThrow(Patch.parse(input))
@@ -24,9 +24,7 @@ describe("Patch", () => {
test("parses a file move", () => {
expect(
- parse(
- "*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n-old\n+new\n*** End Patch",
- ),
+ parse("*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n-old\n+new\n*** End Patch"),
).toEqual([
{
type: "update",
@@ -38,9 +36,7 @@ describe("Patch", () => {
})
test("identifies the missing patch boundary", () => {
- expect(() => parse("This is not a valid patch")).toThrow(
- "The first line of the patch must be '*** Begin Patch'",
- )
+ expect(() => parse("This is not a valid patch")).toThrow("The first line of the patch must be '*** Begin Patch'")
expect(() => parse("*** Begin Patch\n*** Add File: add.txt\n+added")).toThrow(
"The last line of the patch must be '*** End Patch'",
)
@@ -148,12 +144,8 @@ describe("Patch", () => {
})
test("updates empty files and adds a trailing newline", () => {
- expect(Patch.derive("empty.txt", [{ oldLines: [], newLines: ["First line"] }], "").content).toBe(
- "First line\n",
- )
- expect(Patch.derive("no-newline.txt", [{ oldLines: ["old"], newLines: ["new"] }], "old").content).toBe(
- "new\n",
- )
+ expect(Patch.derive("empty.txt", [{ oldLines: [], newLines: ["First line"] }], "").content).toBe("First line\n")
+ expect(Patch.derive("no-newline.txt", [{ oldLines: ["old"], newLines: ["new"] }], "old").content).toBe("new\n")
})
test("disambiguates updates with change context", () => {
@@ -167,14 +159,12 @@ describe("Patch", () => {
})
test("matches leading, trailing, and Unicode punctuation differences", () => {
- expect(Patch.derive("leading.txt", [{ oldLines: ["line"], newLines: ["next"] }], " line\n").content).toBe(
- "next\n",
- )
+ expect(Patch.derive("leading.txt", [{ oldLines: ["line"], newLines: ["next"] }], " line\n").content).toBe("next\n")
expect(Patch.derive("trailing.txt", [{ oldLines: ["line"], newLines: ["next"] }], "line \n").content).toBe(
"next\n",
)
expect(
- Patch.derive('unicode.txt', [{ oldLines: ['He said "hello"'], newLines: ['He said "hi"'] }], 'He said “hello”\n')
+ Patch.derive("unicode.txt", [{ oldLines: ['He said "hello"'], newLines: ['He said "hi"'] }], "He said “hello”\n")
.content,
).toBe('He said "hi"\n')
})
diff --git a/packages/core/src/patch.ts b/packages/util/src/patch.ts
similarity index 98%
rename from packages/core/src/patch.ts
rename to packages/util/src/patch.ts
index 81877e0d78..3ab103fa97 100644
--- a/packages/core/src/patch.ts
+++ b/packages/util/src/patch.ts
@@ -100,9 +100,7 @@ export function parse(patchText: string): Result.Result, Par
if (hunks.length === 0) {
const invalid = lines.findIndex((line, index) => index > begin && index < end && line.trim() !== "")
if (invalid !== -1) {
- return Result.fail(
- new InvalidHunkError({ line: lines[invalid]!.trim(), lineNumber: invalid + 1 }),
- )
+ return Result.fail(new InvalidHunkError({ line: lines[invalid]!.trim(), lineNumber: invalid + 1 }))
}
}
return Result.succeed(hunks)
diff --git a/packages/util/src/runtime-import.ts b/packages/util/src/runtime-import.ts
new file mode 100644
index 0000000000..2af30acb77
--- /dev/null
+++ b/packages/util/src/runtime-import.ts
@@ -0,0 +1 @@
+export { importModule, resolveModule } from "#runtime-import"