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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user