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
This commit is contained in:
Ogrodev
2026-05-11 12:31:50 -03:00
committed by can1357
parent 7f5ea5d9be
commit b707cdaa08
3 changed files with 334 additions and 2 deletions
@@ -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<ClientBridgeTerminalHandle> {
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<ClientBridgePermissionOutcome> {
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 } : {}),
};
}
@@ -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<AgentSessionEvent, { type: "message_end" }>,
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<PathContainer>(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<TextLikeContent>(value, "text");
if (!text) {
@@ -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 };
}