feat(cli): port ACP to V2 (#37907)

This commit is contained in:
Shoubhit Dash
2026-07-21 21:13:21 +05:30
committed by GitHub
parent 8d80365ef4
commit cf651bc41b
42 changed files with 6020 additions and 32 deletions
+3
View File
@@ -124,6 +124,7 @@
"opencode2": "./bin/opencode2.cjs",
},
"dependencies": {
"@agentclientprotocol/sdk": "0.21.0",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/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=="],
+1
View File
@@ -23,6 +23,7 @@
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@agentclientprotocol/sdk": "0.21.0",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
+65
View File
@@ -0,0 +1,65 @@
import {
RequestError,
type Agent,
type AgentSideConnection,
type AuthenticateRequest,
type CancelNotification,
type CloseSessionRequest,
type ForkSessionRequest,
type InitializeRequest,
type ListSessionsRequest,
type LoadSessionRequest,
type NewSessionRequest,
type PromptRequest,
type ResumeSessionRequest,
type SetSessionConfigOptionRequest,
type SetSessionModelRequest,
type SetSessionModeRequest,
} from "@agentclientprotocol/sdk"
import type { OpenCodeClient } from "@opencode-ai/client/promise"
import { ACPError } from "./error"
import { ACPService } from "./service"
export function create(client: OpenCodeClient, connection: AgentSideConnection) {
const service = ACPService.make({ client, connection })
return {
initialize: (params: InitializeRequest) => run(service.initialize(params)),
authenticate: (params: AuthenticateRequest) => run(service.authenticate(params)),
newSession: (params: NewSessionRequest) => run(service.newSession(params)),
loadSession: (params: LoadSessionRequest) => run(service.loadSession(params)),
listSessions: (params: ListSessionsRequest) => run(service.listSessions(params)),
resumeSession: (params: ResumeSessionRequest) => run(service.resumeSession(params)),
closeSession: (params: CloseSessionRequest) => run(service.closeSession(params)),
unstable_forkSession: (params: ForkSessionRequest) => run(service.forkSession(params)),
setSessionConfigOption: (params: SetSessionConfigOptionRequest) => run(service.setSessionConfigOption(params)),
setSessionMode: (params: SetSessionModeRequest) => run(service.setSessionMode(params)),
unstable_setSessionModel: (params: SetSessionModelRequest) => run(service.setSessionModel(params)),
prompt: (params: PromptRequest) => run(service.prompt(params)),
cancel: (params: CancelNotification) => run(service.cancel(params)),
} satisfies Agent
}
async function run<A>(promise: Promise<A>) {
try {
return await promise
} catch (error) {
if (error instanceof RequestError) throw error
if (isACPError(error)) throw ACPError.toRequestError(error)
throw ACPError.toRequestError(ACPError.fromUnknown(error))
}
}
function isACPError(error: unknown): error is ACPError.Error {
return (
error instanceof ACPError.SessionNotFoundError ||
error instanceof ACPError.InvalidConfigOptionError ||
error instanceof ACPError.InvalidModelError ||
error instanceof ACPError.InvalidEffortError ||
error instanceof ACPError.InvalidModeError ||
error instanceof ACPError.AuthRequiredError ||
error instanceof ACPError.UnknownAuthMethodError ||
error instanceof ACPError.ServiceFailureError
)
}
export * as ACP from "./agent"
+133
View File
@@ -0,0 +1,133 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
export const DEFAULT_VARIANT_VALUE = "default"
export type ConfigOptionModel = {
id: string
name: string
variants?: ReadonlyArray<string>
}
export type ConfigOptionProvider = {
id: string
name: string
models: ReadonlyArray<ConfigOptionModel>
}
export type ConfigOptionMode = {
id: string
name: string
description?: string
}
export type ModelSelection = {
model: { providerID: string; modelID: string }
variant?: string
}
export function buildConfigOptions(input: {
providers: readonly ConfigOptionProvider[]
currentModel: ModelSelection["model"]
currentVariant?: string
modes?: readonly ConfigOptionMode[]
currentModeId?: string
}): SessionConfigOption[] {
const variants =
input.providers
.find((provider) => provider.id === input.currentModel.providerID)
?.models.find((model) => model.id === input.currentModel.modelID)?.variants ?? []
const effort =
variants.length > 0 ? buildEffortSelectOption({ variants, currentVariant: input.currentVariant }) : undefined
return [
buildModelSelectOption({ providers: input.providers, currentModel: input.currentModel }),
...(effort ? [effort] : []),
...(input.modes && input.currentModeId
? [buildModeSelectOption({ modes: input.modes, currentModeId: input.currentModeId })]
: []),
]
}
export function buildModelSelectOption(input: {
providers: readonly ConfigOptionProvider[]
currentModel: ModelSelection["model"]
}): SessionConfigOption {
return {
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: `${input.currentModel.providerID}/${input.currentModel.modelID}`,
options: input.providers.flatMap((provider) =>
provider.models
.toSorted((a, b) => a.name.localeCompare(b.name))
.map((model) => ({ value: `${provider.id}/${model.id}`, name: `${provider.name}/${model.name}` })),
),
}
}
export function buildEffortSelectOption(input: {
variants: readonly string[]
currentVariant?: string
}): SessionConfigOption {
return {
id: "effort",
name: "Effort",
description: "Available effort levels for this model",
category: "thought_level",
type: "select",
currentValue: selectVariant(input.currentVariant, input.variants),
options: input.variants.map((variant) => ({ value: variant, name: formatVariantName(variant) })),
}
}
export function buildModeSelectOption(input: {
modes: readonly ConfigOptionMode[]
currentModeId: string
}): SessionConfigOption {
return {
id: "mode",
name: "Session Mode",
category: "mode",
type: "select",
currentValue: input.currentModeId,
options: input.modes.map((mode) => ({
value: mode.id,
name: mode.name,
...(mode.description ? { description: mode.description } : {}),
})),
}
}
export function parseModelSelection(modelId: string, providers: readonly ConfigOptionProvider[]): ModelSelection {
const provider = providers.find((item) => modelId.startsWith(`${item.id}/`))
if (!provider) {
const separator = modelId.indexOf("/")
if (separator === -1) return { model: { providerID: modelId, modelID: "" } }
return { model: { providerID: modelId.slice(0, separator), modelID: modelId.slice(separator + 1) } }
}
const modelID = modelId.slice(provider.id.length + 1)
if (provider.models.some((model) => model.id === modelID)) return { model: { providerID: provider.id, modelID } }
const separator = modelID.lastIndexOf("/")
const baseModelID = separator === -1 ? modelID : modelID.slice(0, separator)
const variant = separator === -1 ? undefined : modelID.slice(separator + 1)
const model = provider.models.find((item) => item.id === baseModelID)
if (model && variant && model.variants?.includes(variant)) {
return { model: { providerID: provider.id, modelID: baseModelID }, variant }
}
return { model: { providerID: provider.id, modelID } }
}
export function formatVariantName(variant: string) {
return variant
.split(/[_-]/)
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
.join(" ")
}
function selectVariant(variant: string | undefined, variants: readonly string[]) {
if (variant && variants.includes(variant)) return variant
if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE
return variants[0] ?? DEFAULT_VARIANT_VALUE
}
export * as ACPConfigOption from "./config-option"
+183
View File
@@ -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"
+86
View File
@@ -0,0 +1,86 @@
import { RequestError } from "@agentclientprotocol/sdk"
import { Schema } from "effect"
export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()("ACPSessionNotFoundError", {
sessionId: Schema.String,
}) {}
export class InvalidConfigOptionError extends Schema.TaggedErrorClass<InvalidConfigOptionError>()(
"ACPInvalidConfigOptionError",
{ configId: Schema.String },
) {}
export class InvalidModelError extends Schema.TaggedErrorClass<InvalidModelError>()("ACPInvalidModelError", {
modelId: Schema.String,
providerId: Schema.optional(Schema.String),
}) {}
export class InvalidEffortError extends Schema.TaggedErrorClass<InvalidEffortError>()("ACPInvalidEffortError", {
effort: Schema.String,
}) {}
export class InvalidModeError extends Schema.TaggedErrorClass<InvalidModeError>()("ACPInvalidModeError", {
mode: Schema.String,
}) {}
export class AuthRequiredError extends Schema.TaggedErrorClass<AuthRequiredError>()("ACPAuthRequiredError", {}) {}
export class UnknownAuthMethodError extends Schema.TaggedErrorClass<UnknownAuthMethodError>()(
"ACPUnknownAuthMethodError",
{ methodId: Schema.String },
) {}
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPServiceFailureError", {
safeMessage: Schema.String,
service: Schema.optional(Schema.String),
errorName: Schema.optional(Schema.String),
}) {}
export type Error =
| SessionNotFoundError
| InvalidConfigOptionError
| InvalidModelError
| InvalidEffortError
| InvalidModeError
| 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"
+441
View File
@@ -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<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
Partial<Pick<AgentSideConnection, "writeTextFile">>
export type TurnControl = {
cancelled: boolean
readonly admission: AbortController
}
type ToolState = {
readonly name: string
input: ToolInput
structured: Record<string, unknown>
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<unknown>
readonly control: TurnControl
}): Promise<PromptResponse> {
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<string, ToolState>()
const update = (value: Parameters<Connection["sessionUpdate"]>[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<AgentSideConnection, "sessionUpdate">,
sessionID: string,
cwd: string,
messages: readonly SessionMessageInfo[],
) {
for (const message of messages) await replayMessage(connection, sessionID, cwd, message).catch(() => {})
}
async function replayMessage(
connection: Pick<AgentSideConnection, "sessionUpdate">,
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"
+179
View File
@@ -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<EventSubscribeOutput, { type: "permission.v2.asked" }>
type Connection = Pick<AgentSideConnection, "requestPermission"> & Partial<Pick<AgentSideConnection, "writeTextFile">>
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<Pick<AgentSideConnection, "writeTextFile">>
readonly sessionID: string
readonly cwd: string
readonly toolName: string
readonly toolInput: ToolInput
readonly structured: Readonly<Record<string, unknown>>
}) {
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<ToolCallContent[]> {
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<ToolCallContent[]> {
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<ToolCallContent> => {
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<ToolCallContent>) {
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<string>,
cwd: string,
previews: ReadonlyArray<ToolCallContent>,
): 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"
+531
View File
@@ -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<AgentSideConnection, "sessionUpdate" | "requestPermission">
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<string>
readonly slash?: { readonly name: string; readonly args: string }
readonly command?: CommandInfo
readonly skill?: SkillInfo
}
export interface Interface {
initialize(input: InitializeRequest): Promise<InitializeResponse>
authenticate(input: AuthenticateRequest): Promise<AuthenticateResponse>
newSession(input: NewSessionRequest): Promise<NewSessionResponse>
loadSession(input: LoadSessionRequest): Promise<LoadSessionResponse>
listSessions(input: ListSessionsRequest): Promise<ListSessionsResponse>
resumeSession(input: ResumeSessionRequest): Promise<ResumeSessionResponse>
closeSession(input: CloseSessionRequest): Promise<CloseSessionResponse>
forkSession(input: ForkSessionRequest): Promise<ForkSessionResponse>
setSessionConfigOption(input: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse>
setSessionMode(input: SetSessionModeRequest): Promise<SetSessionModeResponse>
setSessionModel(input: SetSessionModelRequest): Promise<SetSessionModelResponse>
prompt(input: PromptRequest): Promise<PromptResponse>
cancel(input: CancelNotification): Promise<void>
}
export function make(input: { readonly client: OpenCodeClient; readonly connection: Connection }): Interface {
const sessions = new Map<string, Attached>()
const catalogs = new Map<string, Promise<Catalog>>()
const registeredMcp = new Map<string, Set<string>>()
const active = new Map<string, TurnControl>()
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<Catalog> {
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<string, Set<string>>,
session: Attached,
servers: readonly McpServer[],
) {
const current = registered.get(session.id) ?? new Set<string>()
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"
+222
View File
@@ -0,0 +1,222 @@
import { isAbsolute, resolve } from "node:path"
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
export type ToolInput = Record<string, unknown>
export type ToolContent = ReadonlyArray<
| { readonly type: "text"; readonly text: string }
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
>
export function toToolKind(toolName: string): ToolKind {
switch (toolName.toLocaleLowerCase()) {
case "bash":
case "shell":
return "execute"
case "webfetch":
return "fetch"
case "edit":
case "apply_patch":
case "patch":
case "write":
return "edit"
case "grep":
case "glob":
case "context":
case "context7_resolve_library_id":
case "context7_get_library_docs":
return "search"
case "read":
return "read"
case "task":
case "subagent":
return "think"
default:
return "other"
}
}
export function toLocations(toolName: string, input: ToolInput, cwd?: string): ToolCallLocation[] {
switch (toolName.toLocaleLowerCase()) {
case "bash":
case "shell": {
const workdir = shellWorkdir(input, cwd)
return workdir ? [{ path: workdir }] : []
}
case "read":
case "edit":
case "write":
case "patch":
case "apply_patch":
return locationFrom(input.filePath ?? input.filepath)
case "external_directory":
return locationFrom(input.filePath ?? input.filepath, input.parentDir, input.directories)
case "grep":
case "glob":
case "context":
case "context7_resolve_library_id":
case "context7_get_library_docs":
return locationFrom(input.path)
default:
return []
}
}
export function pendingToolCall(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: { readonly input: ToolInput; readonly title?: string }
readonly cwd?: string
}): ToolCall {
return {
toolCallId: input.toolCallId,
title: toolTitle(input.toolName, input.state.input, input.state.title),
kind: toToolKind(input.toolName),
status: "pending",
locations: toLocations(input.toolName, input.state.input, input.cwd),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
}
}
export function runningToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: { readonly input: ToolInput; readonly title?: string }
readonly content?: ToolContent
readonly cwd?: string
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "in_progress",
kind: toToolKind(input.toolName),
title: toolTitle(input.toolName, input.state.input, input.state.title),
locations: toLocations(input.toolName, input.state.input, input.cwd),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
...(input.content?.length ? { content: toolContent(input.content) } : {}),
}
}
export function completedToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly input: ToolInput
readonly content: ToolContent
readonly structured: Readonly<Record<string, unknown>>
readonly result?: unknown
}): ToolCallUpdate {
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<Record<string, unknown>>
readonly error: string
readonly cwd?: string
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "failed",
kind: toToolKind(input.toolName),
title: toolTitle(input.toolName, input.input, undefined),
locations: toLocations(input.toolName, input.input, input.cwd),
rawInput: rawInput(input.toolName, input.input, input.cwd),
content: [...toolContent(input.content), { type: "content", content: { type: "text", text: input.error } }],
rawOutput: { structured: input.structured, error: input.error },
}
}
function toolContent(content: ToolContent): ToolCallContent[] {
return content.flatMap((part): ToolCallContent[] => {
if (part.type === "text") return [{ type: "content", content: { type: "text", text: part.text } }]
const match = /^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/.exec(part.uri)
if (!match?.[1]?.startsWith("image/") || match[2] === undefined) return []
return [{ type: "content", content: { type: "image", mimeType: match[1], data: match[2] } }]
})
}
function readDisplayText(structured: Readonly<Record<string, unknown>>) {
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"
+1
View File
@@ -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: {
+36
View File
@@ -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<Uint8Array>({
write: (chunk) =>
new Promise<void>((resolve, reject) => {
process.stdout.write(chunk, (error) => (error ? reject(error) : resolve()))
}),
})
const output = new ReadableStream<Uint8Array>({
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))
}),
)
+1
View File
@@ -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"),
+107
View File
@@ -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<Uint8Array>) {
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 }
}
+151
View File
@@ -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")
})
})
@@ -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<SetSessionConfigOptionResponse>("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<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "effort",
value: nextEffort,
}),
)
expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
}, 60_000)
})
+239
View File
@@ -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",
},
},
},
])
})
})
+60
View File
@@ -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")
})
})
@@ -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<AgentSideConnection["sessionUpdate"]>[0]
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission">
type Fixture = ReturnType<typeof createSseFixture>
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<void>()
const releaseUpdate = Promise.withResolvers<void>()
const allUpdates = Promise.withResolvers<void>()
const releaseSubmit = Promise.withResolvers<void>()
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<AgentSideConnection, "sessionUpdate">
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<AgentSideConnection, "sessionUpdate">
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<void>()
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<void>()
const control: TurnControl = { cancelled: false, admission: new AbortController() }
const fixture = createSseFixture({
onPrompt({ signal }) {
submitted.resolve()
return new Promise<void>((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
}
+124
View File
@@ -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<Uint8Array> | undefined
const updates: Parameters<AgentSideConnection["sessionUpdate"]>[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<Uint8Array>({
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<Uint8Array>, event: unknown) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
}
})
@@ -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<AuthenticateResponse>("authenticate", { methodId: "opencode-login" }))).toEqual(
{},
)
const rejected = await acp.request<AuthenticateResponse>("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<InitializeResponse>("initialize", { protocolVersion: 1 }),
)
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeUndefined()
}, 60_000)
})
@@ -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<CloseSessionResponse>("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<LoadSessionResponse>("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<ListSessionsResponse>("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<ResumeSessionResponse>("session/resume", {
cwd: fixture.home,
sessionId: session.sessionId,
mcpServers: [],
}),
)
expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model")
}, 60_000)
})
@@ -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<AgentSideConnection["sessionUpdate"]>[0]
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
Partial<Pick<AgentSideConnection, "writeTextFile">>
type Fixture = ReturnType<typeof createSseFixture>
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<AgentSideConnection["writeTextFile"]>[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<AgentSideConnection["writeTextFile"]>[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<RequestPermissionResponse> => {
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<void>()
const releaseFirst = Promise.withResolvers<RequestPermissionResponse>()
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<void>()
const releaseBlocked = Promise.withResolvers<RequestPermissionResponse>()
const promptIDs = new Map<string, string>()
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<string, unknown>
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]] : []
})
}
@@ -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<PromptResponse>("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<PromptResponse>("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<PromptResponse>("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)
})
@@ -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
}
+207
View File
@@ -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<string, string>
readonly body: unknown
}
export type FixtureContext = {
readonly requests: FixtureRequest[]
send(event: unknown): void
}
type FixtureHandler = (
request: FixtureRequest,
context: FixtureContext,
) => Response | undefined | Promise<Response | undefined>
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<AgentSideConnection["sessionUpdate"]>[0][] = []
const encoder = new TextEncoder()
let eventController: ReadableStreamDefaultController<Uint8Array> | 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<Uint8Array> | undefined
return new Response(
new ReadableStream<Uint8Array>({
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)
},
}
}
@@ -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
}
+264
View File
@@ -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 },
})
}
+110
View File
@@ -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<AgentSideConnection["sessionUpdate"]>[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" },
}
@@ -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<SessionNotification>(
"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)
})
+207
View File
@@ -0,0 +1,207 @@
import { OpenCode, type OpenCodeEvent, type SessionMessageInfo } from "@opencode-ai/client/promise"
type DurableEvent = Extract<OpenCodeEvent, { durable: unknown }>
type EphemeralEvent = Exclude<OpenCodeEvent, DurableEvent>
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<void>
readonly onInterrupt?: (input: {
readonly sessionID: string
readonly send: (event: unknown) => void
}) => void | Promise<void>
readonly onPermissionReply?: (input: {
readonly sessionID: string
readonly requestID: string
readonly reply: string
readonly body: unknown
readonly send: (event: unknown) => void
}) => void | Promise<void>
readonly onFormCancel?: (input: {
readonly sessionID: string
readonly formID: string
readonly send: (event: unknown) => void
}) => void | Promise<void>
}
const ids = { next: 0 }
export function durableEvent<Type extends DurableEvent["type"]>(
type: Type,
data: Extract<DurableEvent, { type: Type }>["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 extends EphemeralEvent["type"]>(
type: Type,
data: Extract<EphemeralEvent, { type: Type }>["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<ReadableStreamDefaultController<Uint8Array>>()
const requests: RequestRecord[] = []
const messages = new Map<string, SessionMessageInfo>()
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<Uint8Array> } = {}
return new Response(
new ReadableStream<Uint8Array>({
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<Value>(promise: Promise<Value>, message: string, milliseconds = 2_000) {
const timeout = Promise.withResolvers<never>()
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
}
+384
View File
@@ -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<T> = {
readonly jsonrpc: "2.0"
readonly id: number
readonly result?: T
readonly error?: JsonRpcError
}
type JsonRpcNotification<T> = {
readonly jsonrpc: "2.0"
readonly method: string
readonly params: T
}
type JsonRpcMessage = Record<string, unknown>
type Waiter = {
readonly predicate: (message: JsonRpcMessage) => boolean
readonly resolve: (message: JsonRpcMessage) => void
readonly reject: (error: Error) => void
readonly timer: ReturnType<typeof setTimeout>
}
export type AcpProcess = {
readonly request: <T>(method: string, params?: unknown) => Promise<JsonRpcResponse<T>>
readonly waitForNotification: <T>(
method: string,
predicate: (params: T) => boolean,
timeoutMs?: number,
) => Promise<JsonRpcNotification<T>>
readonly close: () => Promise<number>
readonly stderr: () => string
readonly [Symbol.asyncDispose]: () => Promise<void>
}
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<AcpProcess>()
return {
root,
home,
llm: { requests },
spawn(extraEnv: Record<string, string | undefined> = {}) {
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<InitializeResponse>("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<NewSessionResponse>("session/new", { cwd, mcpServers: [] }).then(expectOk)
}
export function expectOk<T>(response: JsonRpcResponse<T>) {
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<SessionConfigOption, { type: "select" }> =>
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<SessionConfigOption, { type: "select" }>) {
return option.options.flatMap((item): SessionConfigSelectOption[] => ("value" in item ? [item] : item.options))
}
export function alternateValue(option: Extract<SessionConfigOption, { type: "select" }>) {
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<string, string | undefined> }): 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<JsonRpcMessage>((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<T>(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<T>(response)) throw new Error(`Invalid ACP response: ${JSON.stringify(response)}`)
return response
},
async waitForNotification<T>(method: string, predicate: (params: T) => boolean, timeoutMs = 20_000) {
const notification = await take(
(message) => isNotification<T>(message) && message.method === method && predicate(message.params),
timeoutMs,
`${method} notification`,
)
if (!isNotification<T>(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<T>(message: JsonRpcMessage): message is JsonRpcMessage & JsonRpcResponse<T> {
return message.jsonrpc === "2.0" && typeof message.id === "number" && !("method" in message)
}
function isNotification<T>(message: JsonRpcMessage): message is JsonRpcMessage & JsonRpcNotification<T> {
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<T>(promise: Promise<T>, timeoutMs: number, message: string) {
let timer: ReturnType<typeof setTimeout> | undefined
return Promise.race([
promise,
new Promise<T>((_, 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`
}
+292
View File
@@ -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: "<content>1: first\n2: second</content>" }],
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" },
})
})
})
-5
View File
@@ -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",
+1 -1
View File
@@ -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",
@@ -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",
+1 -1
View File
@@ -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([
+1 -1
View File
@@ -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
+2 -3
View File
@@ -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) {
+7 -17
View File
@@ -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')
})
@@ -100,9 +100,7 @@ export function parse(patchText: string): Result.Result<ReadonlyArray<Hunk>, 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)
+1
View File
@@ -0,0 +1 @@
export { importModule, resolveModule } from "#runtime-import"