From b707cdaa080af61f5df113b0d0eaace9bce62f8a Mon Sep 17 00:00:00 2001 From: Ogrodev Date: Mon, 11 May 2026 12:31:50 -0300 Subject: [PATCH] feat(acp): implement ACP protocol adapters and terminal-auth support - Adds AcpClientBridge, adapting an ACP AgentSideConnection to the internal ClientBridge interface - Adds ACP event mapper translating AgentSessionEvents to ACP SessionUpdate/SessionNotification wire payloads (tool-kind classification, content truncation, text normalization) - Adds terminal-auth flag constant and prepareAcpTerminalAuthArgs helper for authenticated terminal handles --- .../src/modes/acp/acp-client-bridge.ts | 152 ++++++++++++++++++ .../src/modes/acp/acp-event-mapper.ts | 147 ++++++++++++++++- .../src/modes/acp/terminal-auth.ts | 37 +++++ 3 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/src/modes/acp/acp-client-bridge.ts create mode 100644 packages/coding-agent/src/modes/acp/terminal-auth.ts diff --git a/packages/coding-agent/src/modes/acp/acp-client-bridge.ts b/packages/coding-agent/src/modes/acp/acp-client-bridge.ts new file mode 100644 index 000000000..3eab0c987 --- /dev/null +++ b/packages/coding-agent/src/modes/acp/acp-client-bridge.ts @@ -0,0 +1,152 @@ +/** + * ACP-side `ClientBridge` implementation. Wraps `AgentSideConnection` so the + * `read`/`write`/`bash`/`edit` tools (and the permission gate in + * `AgentSession`) can route through the client when it advertises the + * relevant capabilities at `initialize` time. + */ +import type { + PermissionOption as AcpPermissionOption, + TerminalHandle as AcpTerminalHandle, + AgentSideConnection, + ClientCapabilities, + RequestPermissionRequest, + ToolCallUpdate, +} from "@agentclientprotocol/sdk"; +import type { + ClientBridge, + ClientBridgeCapabilities, + ClientBridgeCreateTerminalParams, + ClientBridgePermissionOption, + ClientBridgePermissionOutcome, + ClientBridgePermissionToolCall, + ClientBridgeTerminalHandle, +} from "../../session/client-bridge"; + +export function createAcpClientBridge( + connection: AgentSideConnection, + sessionId: string, + clientCapabilities: ClientCapabilities | undefined, +): ClientBridge { + const capabilities: ClientBridgeCapabilities = { + readTextFile: clientCapabilities?.fs?.readTextFile === true, + writeTextFile: clientCapabilities?.fs?.writeTextFile === true, + terminal: clientCapabilities?.terminal === true, + // Permission requests are always usable on the connection; gating is + // the agent's policy choice rather than a client capability. + requestPermission: true, + }; + + const bridge: ClientBridge = { capabilities }; + + if (capabilities.readTextFile) { + bridge.readTextFile = async params => { + const response = await connection.readTextFile({ + sessionId, + path: params.path, + ...(typeof params.line === "number" ? { line: params.line } : {}), + ...(typeof params.limit === "number" ? { limit: params.limit } : {}), + }); + return response.content; + }; + } + + if (capabilities.writeTextFile) { + bridge.writeTextFile = async params => { + await connection.writeTextFile({ + sessionId, + path: params.path, + content: params.content, + }); + }; + } + + if (capabilities.terminal) { + bridge.createTerminal = (params: ClientBridgeCreateTerminalParams) => + createTerminalHandle(connection, sessionId, params); + } + + bridge.requestPermission = (toolCall, options, signal) => + requestPermission(connection, sessionId, toolCall, options, signal); + + return bridge; +} + +async function createTerminalHandle( + connection: AgentSideConnection, + sessionId: string, + params: ClientBridgeCreateTerminalParams, +): Promise { + const handle = await connection.createTerminal({ + sessionId, + command: params.command, + ...(params.args ? { args: params.args } : {}), + ...(params.env ? { env: params.env } : {}), + ...(params.cwd ? { cwd: params.cwd } : {}), + ...(typeof params.outputByteLimit === "number" ? { outputByteLimit: params.outputByteLimit } : {}), + }); + return wrapTerminalHandle(handle); +} + +function wrapTerminalHandle(handle: AcpTerminalHandle): ClientBridgeTerminalHandle { + return { + terminalId: handle.id, + async currentOutput() { + const out = await handle.currentOutput(); + return { + output: out.output, + truncated: out.truncated, + exitStatus: out.exitStatus ?? null, + }; + }, + async waitForExit() { + const status = await handle.waitForExit(); + return { exitCode: status.exitCode ?? null, signal: status.signal ?? null }; + }, + async kill() { + await handle.kill(); + }, + async release() { + await handle.release(); + }, + }; +} + +async function requestPermission( + connection: AgentSideConnection, + sessionId: string, + toolCall: ClientBridgePermissionToolCall, + options: ClientBridgePermissionOption[], + signal: AbortSignal | undefined, +): Promise { + const update: ToolCallUpdate = { + toolCallId: toolCall.toolCallId, + title: toolCall.title, + ...(toolCall.kind ? { kind: toolCall.kind as ToolCallUpdate["kind"] } : {}), + ...(toolCall.rawInput !== undefined ? { rawInput: toolCall.rawInput } : {}), + ...(toolCall.locations ? { locations: toolCall.locations } : {}), + }; + const acpOptions: AcpPermissionOption[] = options.map(option => ({ + optionId: option.optionId, + name: option.name, + kind: option.kind, + })); + const request: RequestPermissionRequest = { + sessionId, + toolCall: update, + options: acpOptions, + }; + if (signal?.aborted) { + return { outcome: "cancelled" }; + } + const response = await connection.requestPermission(request); + const outcome = response.outcome; + if (outcome.outcome === "cancelled") { + return { outcome: "cancelled" }; + } + const matched = options.find(option => option.optionId === outcome.optionId); + return { + outcome: "selected", + optionId: outcome.optionId, + ...(matched ? { kind: matched.kind } : {}), + }; +} diff --git a/packages/coding-agent/src/modes/acp/acp-event-mapper.ts b/packages/coding-agent/src/modes/acp/acp-event-mapper.ts index f67a1c550..b9f933190 100644 --- a/packages/coding-agent/src/modes/acp/acp-event-mapper.ts +++ b/packages/coding-agent/src/modes/acp/acp-event-mapper.ts @@ -8,8 +8,14 @@ import type { import type { AgentSessionEvent } from "../../session/agent-session"; import type { TodoStatus } from "../../tools/todo-write"; +interface MessageProgress { + textEmitted: boolean; + thoughtEmitted: boolean; +} + interface AcpEventMapperOptions { getMessageId?: (message: unknown) => string | undefined; + getMessageProgress?: (message: unknown) => MessageProgress | undefined; } interface ContentArrayContainer { @@ -127,6 +133,8 @@ export function mapAgentSessionEventToAcpSessionUpdates( switch (event.type) { case "message_update": return mapAssistantMessageUpdate(event, sessionId, options); + case "message_end": + return mapAssistantMessageEnd(event, sessionId, options); case "tool_execution_start": { const update: SessionUpdate = { sessionUpdate: "tool_call", @@ -143,7 +151,9 @@ export function mapAgentSessionEventToAcpSessionUpdates( return [toSessionNotification(sessionId, update)]; } case "tool_execution_update": { - const content = extractToolCallContent(event.partialResult); + const terminalContent = extractTerminalToolCallContent(event.partialResult); + const otherContent = terminalContent.length > 0 ? [] : extractToolCallContent(event.partialResult); + const content = [...terminalContent, ...otherContent]; const update: SessionUpdate = { sessionUpdate: "tool_call_update", toolCallId: event.toolCallId, @@ -153,10 +163,17 @@ export function mapAgentSessionEventToAcpSessionUpdates( if (content.length > 0) { update.content = content; } + const locations = extractToolLocations(event.args); + if (locations.length > 0) { + update.locations = locations; + } return [toSessionNotification(sessionId, update)]; } case "tool_execution_end": { - const content = extractToolCallContent(event.result); + const diffContent = extractDiffToolCallContent(event.result); + const terminalContent = extractTerminalToolCallContent(event.result); + const otherContent = extractToolCallContent(event.result); + const content = [...diffContent, ...terminalContent, ...otherContent]; const update: SessionUpdate = { sessionUpdate: "tool_call_update", toolCallId: event.toolCallId, @@ -166,6 +183,10 @@ export function mapAgentSessionEventToAcpSessionUpdates( if (content.length > 0) { update.content = content; } + const locations = extractToolLocationsFromResult(event.result); + if (locations.length > 0) { + update.locations = locations; + } return [toSessionNotification(sessionId, update)]; } case "todo_reminder": { @@ -194,14 +215,31 @@ function mapAssistantMessageUpdate( let sessionUpdate: "agent_message_chunk" | "agent_thought_chunk"; let text: string; + const progress = options.getMessageProgress?.(event.message); switch (event.assistantMessageEvent.type) { case "text_delta": sessionUpdate = "agent_message_chunk"; text = event.assistantMessageEvent.delta; + if (text.length > 0 && progress) { + progress.textEmitted = true; + } break; case "thinking_delta": sessionUpdate = "agent_thought_chunk"; text = event.assistantMessageEvent.delta; + if (text.length > 0 && progress) { + progress.thoughtEmitted = true; + } + break; + case "done": + if (progress?.textEmitted) { + return []; + } + sessionUpdate = "agent_message_chunk"; + text = extractAssistantMessageText(event.assistantMessageEvent.message); + if (text.length > 0 && progress) { + progress.textEmitted = true; + } break; case "error": sessionUpdate = "agent_message_chunk"; @@ -224,6 +262,33 @@ function mapAssistantMessageUpdate( ]; } +function mapAssistantMessageEnd( + event: Extract, + sessionId: string, + options: AcpEventMapperOptions, +): SessionNotification[] { + if (!isAssistantMessage(event.message)) { + return []; + } + const progress = options.getMessageProgress?.(event.message); + if (!progress || progress.textEmitted) { + return []; + } + const text = extractAssistantMessageText(event.message); + if (text.length === 0) { + return []; + } + progress.textEmitted = true; + const messageId = options.getMessageId?.(event.message); + return [ + toSessionNotification(sessionId, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + messageId, + }), + ]; +} + function toSessionNotification(sessionId: string, update: SessionUpdate): SessionNotification { return { sessionId, update }; } @@ -277,6 +342,70 @@ function extractToolLocations(args: unknown): ToolCallLocation[] { return locations; } +/** Pull locations from a tool result's details (e.g. EditToolDetails.perFileResults[].path). */ +function extractToolLocationsFromResult(result: unknown): ToolCallLocation[] { + if (typeof result !== "object" || result === null) return []; + const details = (result as { details?: unknown }).details; + if (typeof details !== "object" || details === null) return []; + const direct = extractToolLocations(details); + const perFile = (details as { perFileResults?: unknown }).perFileResults; + if (!Array.isArray(perFile)) { + return direct; + } + const seen = new Set(direct.map(loc => loc.path)); + const locations = [...direct]; + for (const entry of perFile) { + const path = extractStringProperty(entry, "path"); + if (path && !seen.has(path)) { + seen.add(path); + locations.push({ path }); + } + } + return locations; +} + +/** Emit a `diff` ToolCallContent for each per-file edit result that carries oldText/newText. */ +function extractDiffToolCallContent(result: unknown): ToolCallContent[] { + if (typeof result !== "object" || result === null) return []; + const details = (result as { details?: unknown }).details; + if (typeof details !== "object" || details === null) return []; + const blocks: ToolCallContent[] = []; + const perFile = (details as { perFileResults?: unknown }).perFileResults; + const entries: unknown[] = Array.isArray(perFile) ? perFile : [details]; + for (const entry of entries) { + const block = buildDiffContent(entry); + if (block) blocks.push(block); + } + return blocks; +} + +function buildDiffContent(entry: unknown): ToolCallContent | undefined { + if (typeof entry !== "object" || entry === null) return undefined; + const candidate = entry as { path?: unknown; oldText?: unknown; newText?: unknown; isError?: unknown }; + if (candidate.isError === true) return undefined; + const path = typeof candidate.path === "string" && candidate.path.length > 0 ? candidate.path : undefined; + if (!path) return undefined; + const oldText = typeof candidate.oldText === "string" ? candidate.oldText : undefined; + const newText = typeof candidate.newText === "string" ? candidate.newText : undefined; + if (oldText === undefined && newText === undefined) return undefined; + return { + type: "diff", + path, + oldText: oldText ?? null, + newText: newText ?? "", + }; +} + +/** Emit a `terminal` ToolCallContent when a tool result carries a `details.terminalId` (e.g. bash routed through ACP terminal/*). */ +function extractTerminalToolCallContent(result: unknown): ToolCallContent[] { + if (typeof result !== "object" || result === null) return []; + const details = (result as { details?: unknown }).details; + if (typeof details !== "object" || details === null) return []; + const terminalId = (details as { terminalId?: unknown }).terminalId; + if (typeof terminalId !== "string" || terminalId.length === 0) return []; + return [{ type: "terminal", terminalId }]; +} + function extractToolCallContent(value: unknown): ToolCallContent[] { const richContent = extractStructuredToolCallContent(value); const fallbackText = extractReadableText(value); @@ -479,6 +608,20 @@ function extractReadableText(value: unknown): string | undefined { return normalizeText(serialized); } +function extractAssistantMessageText(value: unknown): string { + if (typeof value !== "object" || value === null || !("content" in value)) { + return ""; + } + const content = (value as ContentArrayContainer).content; + if (!Array.isArray(content)) { + return ""; + } + return content + .map(block => extractStructuredText(block)) + .filter((chunk): chunk is string => typeof chunk === "string" && chunk.length > 0) + .join("\n"); +} + function extractStructuredText(value: unknown): string | undefined { const text = extractStringProperty(value, "text"); if (!text) { diff --git a/packages/coding-agent/src/modes/acp/terminal-auth.ts b/packages/coding-agent/src/modes/acp/terminal-auth.ts new file mode 100644 index 000000000..3a195e1ee --- /dev/null +++ b/packages/coding-agent/src/modes/acp/terminal-auth.ts @@ -0,0 +1,37 @@ +export const ACP_TERMINAL_AUTH_FLAG = "--acp-terminal-auth"; + +export interface AcpTerminalAuthArgs { + args: string[]; + terminalAuth: boolean; +} + +export function prepareAcpTerminalAuthArgs(rawArgs: readonly string[]): AcpTerminalAuthArgs { + const withoutAuthFlag: string[] = []; + let terminalAuth = false; + for (const arg of rawArgs) { + if (arg === ACP_TERMINAL_AUTH_FLAG) { + terminalAuth = true; + continue; + } + withoutAuthFlag.push(arg); + } + + if (!terminalAuth) { + return { args: withoutAuthFlag, terminalAuth: false }; + } + + const args: string[] = []; + for (let i = 0; i < withoutAuthFlag.length; i++) { + const arg = withoutAuthFlag[i]; + if (arg === "--mode") { + i++; + continue; + } + if (arg.startsWith("--mode=")) { + continue; + } + args.push(arg); + } + + return { args, terminalAuth: true }; +}