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