feat(cli): port ACP to V2 (#37907)
This commit is contained in:
parent
8d80365ef4
commit
cf651bc41b
42 changed files with 6020 additions and 32 deletions
65
packages/cli/src/acp/agent.ts
Normal file
65
packages/cli/src/acp/agent.ts
Normal 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
packages/cli/src/acp/config-option.ts
Normal file
133
packages/cli/src/acp/config-option.ts
Normal 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
packages/cli/src/acp/content.ts
Normal file
183
packages/cli/src/acp/content.ts
Normal 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
packages/cli/src/acp/error.ts
Normal file
86
packages/cli/src/acp/error.ts
Normal 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
packages/cli/src/acp/event.ts
Normal file
441
packages/cli/src/acp/event.ts
Normal 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
packages/cli/src/acp/permission.ts
Normal file
179
packages/cli/src/acp/permission.ts
Normal 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
packages/cli/src/acp/service.ts
Normal file
531
packages/cli/src/acp/service.ts
Normal 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
packages/cli/src/acp/tool.ts
Normal file
222
packages/cli/src/acp/tool.ts
Normal 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"
|
||||
|
|
@ -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
packages/cli/src/commands/handlers/acp.ts
Normal file
36
packages/cli/src/commands/handlers/acp.ts
Normal 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))
|
||||
}),
|
||||
)
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue