From 7761ea2e4a66bcc27c81b182226d3f201e4b2fbd Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 20 Jul 2026 18:24:09 +0530 Subject: [PATCH] refactor(cli): inline acp one-off helpers --- packages/cli/src/acp/config-option.ts | 12 ++---- packages/cli/src/acp/content.ts | 57 ++++++++++++-------------- packages/cli/src/acp/event.ts | 11 ++--- packages/cli/src/acp/permission.ts | 40 ++++++------------ packages/cli/src/acp/service.ts | 28 ++++++------- packages/cli/src/acp/tool.ts | 58 +++++++++++---------------- 6 files changed, 84 insertions(+), 122 deletions(-) diff --git a/packages/cli/src/acp/config-option.ts b/packages/cli/src/acp/config-option.ts index 7b146f6af2..eaba3cd66a 100644 --- a/packages/cli/src/acp/config-option.ts +++ b/packages/cli/src/acp/config-option.ts @@ -32,7 +32,10 @@ export function buildConfigOptions(input: { modes?: readonly ConfigOptionMode[] currentModeId?: string }): SessionConfigOption[] { - const variants = variantsForModel(input.providers, input.currentModel) + 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 [ @@ -121,13 +124,6 @@ export function formatVariantName(variant: string) { .join(" ") } -function variantsForModel(providers: readonly ConfigOptionProvider[], model: ModelSelection["model"]) { - return ( - providers.find((provider) => provider.id === model.providerID)?.models.find((item) => item.id === model.modelID) - ?.variants ?? [] - ) -} - function selectVariant(variant: string | undefined, variants: readonly string[]) { if (variant && variants.includes(variant)) return variant if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE diff --git a/packages/cli/src/acp/content.ts b/packages/cli/src/acp/content.ts index 4659c6c48b..3755591c1f 100644 --- a/packages/cli/src/acp/content.ts +++ b/packages/cli/src/acp/content.ts @@ -1,4 +1,4 @@ -import type { ContentBlock, ContentChunk, ResourceLink, Role } from "@agentclientprotocol/sdk" +import type { ContentBlock, ContentChunk, ResourceLink } from "@agentclientprotocol/sdk" import path from "node:path" import { fileURLToPath, pathToFileURL } from "node:url" @@ -14,8 +14,16 @@ export function promptContentToParts(content: readonly ContentBlock[]): PromptPa export function contentBlockToParts(block: ContentBlock): PromptPart[] { switch (block.type) { - case "text": - return [{ type: "text", text: block.text, ...audienceFlags(block.annotations?.audience ?? undefined) }] + 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 [ @@ -84,7 +92,8 @@ export function partsToContentChunks(parts: readonly ReplayPart[]): ContentChunk content: { type: "text", text: part.text, - ...replayAudience(part), + ...(part.synthetic ? { annotations: { audience: ["assistant" as const] } } : {}), + ...(!part.synthetic && part.ignored ? { annotations: { audience: ["user" as const] } } : {}), }, }, ] @@ -105,15 +114,17 @@ export function partsToContentChunks(parts: readonly ReplayPart[]): ContentChunk ] } if (!part.url.startsWith("data:")) return [] - const data = decodeDataUrl(part.url) - if (!data) return [] - if (data.mime.startsWith("image/")) { + 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: data.mime, - data: data.base64, + mimeType: mime, + data, uri: pathToFileURL(part.filename ?? "image").href, }, }, @@ -124,16 +135,16 @@ export function partsToContentChunks(parts: readonly ReplayPart[]): ContentChunk content: { type: "resource", resource: - data.mime.startsWith("text/") || data.mime === "application/json" + mime.startsWith("text/") || mime === "application/json" ? { uri: pathToFileURL(part.filename ?? "file").href, - mimeType: data.mime, - text: Buffer.from(data.base64, "base64").toString("utf8"), + mimeType: mime, + text: Buffer.from(data, "base64").toString("utf8"), } : { uri: pathToFileURL(part.filename ?? "file").href, - mimeType: data.mime, - blob: data.base64, + mimeType: mime, + blob: data, }, }, }, @@ -163,24 +174,6 @@ function resourceLinkToPart(link: ResourceLink): PromptPart { return { type: "text", text: link.uri } } -function decodeDataUrl(url: string): { readonly mime: string; readonly base64: string } | undefined { - const match = /^data:([^;]+);base64,(.*)$/.exec(url) - if (!match?.[1] || match[2] === undefined) return undefined - return { mime: match[1], base64: match[2] } -} - -function audienceFlags(audience: readonly Role[] | null | undefined) { - if (audience?.length === 1 && audience[0] === "assistant") return { synthetic: true as const } - if (audience?.length === 1 && audience[0] === "user") return { ignored: true as const } - return {} -} - -function replayAudience(part: Extract) { - if (part.synthetic) return { annotations: { audience: ["assistant" as const] } } - if (part.ignored) return { annotations: { audience: ["user" as const] } } - return {} -} - 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 diff --git a/packages/cli/src/acp/event.ts b/packages/cli/src/acp/event.ts index c699830aa0..91d7dbf5d1 100644 --- a/packages/cli/src/acp/event.ts +++ b/packages/cli/src/acp/event.ts @@ -311,7 +311,12 @@ async function replayMessage( sessionId: sessionID, update: { sessionUpdate: "tool_call", - ...pendingToolCall({ toolCallId: part.id, toolName: part.name, state: { input: toolInput(part) }, cwd }), + ...pendingToolCall({ + toolCallId: part.id, + toolName: part.name, + state: { input: part.state.status === "streaming" ? {} : part.state.input }, + cwd, + }), }, }) switch (part.state.status) { @@ -376,10 +381,6 @@ function matchesStart(event: EventSubscribeOutput, start: TurnStart) { return event.type === "session.skill.activated" && event.id === start.id.replace(/^msg_/, "evt_") } -function toolInput(tool: Extract) { - return tool.state.status === "streaming" ? {} : tool.state.input -} - function response( assistant: SessionMessageAssistant | undefined, executionError: { readonly type: string; readonly message: string } | undefined, diff --git a/packages/cli/src/acp/permission.ts b/packages/cli/src/acp/permission.ts index 9b723e9af9..d5a00b69b2 100644 --- a/packages/cli/src/acp/permission.ts +++ b/packages/cli/src/acp/permission.ts @@ -1,10 +1,4 @@ -import type { - AgentSideConnection, - PermissionOption, - RequestPermissionResponse, - ToolCallContent, - ToolCallLocation, -} from "@agentclientprotocol/sdk" +import type { AgentSideConnection, PermissionOption, ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk" import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise" import { Patch } from "@opencode-ai/core/patch" import { isAbsolute, resolve } from "node:path" @@ -47,10 +41,12 @@ export async function replyPermission(input: { 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: selectedReply(result), + reply, }) } @@ -63,7 +59,15 @@ export async function syncEditedFiles(input: { readonly structured: Readonly> }) { if (!input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return - const paths = editedPaths(input.toolInput, input.structured) + 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) @@ -161,18 +165,6 @@ function readText(path: string, cwd: string) { .catch(() => "") } -function editedPaths(input: ToolInput, structured: Readonly>) { - const files = Array.isArray(structured.files) - ? 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) - return [...new Set([...files, ...(path ? [path] : [])])] -} - function filePath(input: ToolInput) { return stringValue(input.path) ?? stringValue(input.filePath) ?? stringValue(input.filepath) } @@ -181,12 +173,6 @@ function resolvePath(path: string, cwd: string) { return isAbsolute(path) ? path : resolve(cwd, path) } -function selectedReply(result: RequestPermissionResponse | undefined): "once" | "always" | "reject" { - if (result?.outcome.outcome !== "selected") return "reject" - if (result.outcome.optionId === "once" || result.outcome.optionId === "always") return result.outcome.optionId - return "reject" -} - function stringValue(value: unknown) { return typeof value === "string" ? value : undefined } diff --git a/packages/cli/src/acp/service.ts b/packages/cli/src/acp/service.ts index eba5483044..d53547a408 100644 --- a/packages/cli/src/acp/service.ts +++ b/packages/cli/src/acp/service.ts @@ -127,7 +127,18 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti } sessions.set(session.id, state) await registerMcpServers(input.client, registeredMcp, state, mcpServers) - await sendAvailableCommands(input.connection, state) + 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 } @@ -491,21 +502,6 @@ function stableStringify(value: unknown): string { .join(",")}}` } -async function sendAvailableCommands(connection: Connection, session: Attached) { - await connection.sessionUpdate({ - sessionId: session.id, - update: { - sessionUpdate: "available_commands_update", - availableCommands: [ - ...session.catalog.commands, - ...session.catalog.skills.filter( - (skill) => !session.catalog.commands.some((command) => command.name === skill.name), - ), - ].map((command) => ({ name: command.name, description: command.description ?? "" })), - }, - }) -} - async function sendUsageUpdate(client: OpenCodeClient, connection: Connection, session: Attached) { const info = await client.session.get({ sessionID: session.id }) const model = session.catalog.models.find( diff --git a/packages/cli/src/acp/tool.ts b/packages/cli/src/acp/tool.ts index bb154bd828..0838d69935 100644 --- a/packages/cli/src/acp/tool.ts +++ b/packages/cli/src/acp/tool.ts @@ -103,31 +103,35 @@ export function completedToolUpdate(input: { readonly structured: Readonly> readonly result?: unknown }): ToolCallUpdate { - return { - toolCallId: input.toolCallId, - status: "completed", - content: completedToolContent(input.toolName, input.input, input.content, input.structured), - rawOutput: { - structured: input.structured, - ...(input.result === undefined ? {} : { result: input.result }), - }, - } -} - -function completedToolContent( - toolName: string, - input: ToolInput, - content: ToolContent, - structured: Readonly>, -): ToolCallContent[] { - const normalized = toolContent(content) - const read = toolName.toLocaleLowerCase() === "read" ? readDisplayText(structured) : undefined + 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 } }] - return [...primary, ...diffContent(input), ...images] + 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: { @@ -160,20 +164,6 @@ function toolContent(content: ToolContent): ToolCallContent[] { }) } -function diffContent(input: ToolInput): ToolCallContent[] { - const oldText = stringValue(input.oldString) - const newText = stringValue(input.newString) - if (oldText === undefined || newText === undefined) return [] - return [ - { - type: "diff", - path: stringValue(input.path) ?? stringValue(input.filePath) ?? "", - oldText, - newText, - }, - ] -} - function readDisplayText(structured: Readonly>) { if (typeof structured.content === "string") { if (structured.type === "text-page" || structured.encoding === "utf8") return structured.content