import type { Agent, AgentSideConnection, AuthenticateRequest, AuthenticateResponse, CancelNotification, InitializeRequest, InitializeResponse, ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, NewSessionRequest, NewSessionResponse, PromptRequest, PromptResponse, SetSessionModeRequest, SetSessionModeResponse, StopReason, } from "@agentclientprotocol/sdk"; import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk"; import { randomUUID } from "node:crypto"; import type { GatewayClient } from "../gateway/client.js"; import type { EventFrame } from "../gateway/protocol/index.js"; import type { SessionsListResult } from "../gateway/session-utils.js"; import { getAvailableCommands } from "./commands.js"; import { extractAttachmentsFromPrompt, extractTextFromPrompt, formatToolTitle, inferToolKind, } from "./event-mapper.js"; import { readBool, readNumber, readString } from "./meta.js"; import { parseSessionMeta, resetSessionIfNeeded, resolveSessionKey } from "./session-mapper.js"; import { defaultAcpSessionStore, type AcpSessionStore } from "./session.js"; import { ACP_AGENT_INFO, type AcpServerOptions } from "./types.js"; type PendingPrompt = { sessionId: string; sessionKey: string; idempotencyKey: string; resolve: (response: PromptResponse) => void; reject: (err: Error) => void; sentTextLength?: number; sentText?: string; toolCalls?: Set; }; type AcpGatewayAgentOptions = AcpServerOptions & { sessionStore?: AcpSessionStore; }; export class AcpGatewayAgent implements Agent { private connection: AgentSideConnection; private gateway: GatewayClient; private opts: AcpGatewayAgentOptions; private log: (msg: string) => void; private sessionStore: AcpSessionStore; private pendingPrompts = new Map(); constructor( connection: AgentSideConnection, gateway: GatewayClient, opts: AcpGatewayAgentOptions = {}, ) { this.connection = connection; this.gateway = gateway; this.opts = opts; this.log = opts.verbose ? (msg: string) => process.stderr.write(`[acp] ${msg}\n`) : () => {}; this.sessionStore = opts.sessionStore ?? defaultAcpSessionStore; } start(): void { this.log("ready"); } handleGatewayReconnect(): void { this.log("gateway reconnected"); } handleGatewayDisconnect(reason: string): void { this.log(`gateway disconnected: ${reason}`); for (const pending of this.pendingPrompts.values()) { pending.reject(new Error(`Gateway disconnected: ${reason}`)); this.sessionStore.clearActiveRun(pending.sessionId); } this.pendingPrompts.clear(); } async handleGatewayEvent(evt: EventFrame): Promise { if (evt.event === "chat") { await this.handleChatEvent(evt); return; } if (evt.event === "agent") { await this.handleAgentEvent(evt); } } async initialize(_params: InitializeRequest): Promise { return { protocolVersion: PROTOCOL_VERSION, agentCapabilities: { loadSession: true, promptCapabilities: { image: true, audio: false, embeddedContext: true, }, mcpCapabilities: { http: false, sse: false, }, sessionCapabilities: { list: {}, }, }, agentInfo: ACP_AGENT_INFO, authMethods: [], }; } async newSession(params: NewSessionRequest): Promise { if (params.mcpServers.length > 0) { this.log(`ignoring ${params.mcpServers.length} MCP servers`); } const sessionId = randomUUID(); const meta = parseSessionMeta(params._meta); const sessionKey = await resolveSessionKey({ meta, fallbackKey: `acp:${sessionId}`, gateway: this.gateway, opts: this.opts, }); await resetSessionIfNeeded({ meta, sessionKey, gateway: this.gateway, opts: this.opts, }); const session = this.sessionStore.createSession({ sessionId, sessionKey, cwd: params.cwd, }); this.log(`newSession: ${session.sessionId} -> ${session.sessionKey}`); await this.sendAvailableCommands(session.sessionId); return { sessionId: session.sessionId }; } async loadSession(params: LoadSessionRequest): Promise { if (params.mcpServers.length > 0) { this.log(`ignoring ${params.mcpServers.length} MCP servers`); } const meta = parseSessionMeta(params._meta); const sessionKey = await resolveSessionKey({ meta, fallbackKey: params.sessionId, gateway: this.gateway, opts: this.opts, }); await resetSessionIfNeeded({ meta, sessionKey, gateway: this.gateway, opts: this.opts, }); const session = this.sessionStore.createSession({ sessionId: params.sessionId, sessionKey, cwd: params.cwd, }); this.log(`loadSession: ${session.sessionId} -> ${session.sessionKey}`); await this.sendAvailableCommands(session.sessionId); return {}; } async unstable_listSessions(params: ListSessionsRequest): Promise { const limit = readNumber(params._meta, ["limit"]) ?? 100; const result = await this.gateway.request("sessions.list", { limit }); const cwd = params.cwd ?? process.cwd(); return { sessions: result.sessions.map((session) => ({ sessionId: session.key, cwd, title: session.displayName ?? session.label ?? session.key, updatedAt: session.updatedAt ? new Date(session.updatedAt).toISOString() : undefined, _meta: { sessionKey: session.key, kind: session.kind, channel: session.channel, }, })), nextCursor: null, }; } async authenticate(_params: AuthenticateRequest): Promise { return {}; } async setSessionMode(params: SetSessionModeRequest): Promise { const session = this.sessionStore.getSession(params.sessionId); if (!session) { throw new Error(`Session ${params.sessionId} not found`); } if (!params.modeId) { return {}; } try { await this.gateway.request("sessions.patch", { key: session.sessionKey, thinkingLevel: params.modeId, }); this.log(`setSessionMode: ${session.sessionId} -> ${params.modeId}`); } catch (err) { this.log(`setSessionMode error: ${String(err)}`); } return {}; } async prompt(params: PromptRequest): Promise { const session = this.sessionStore.getSession(params.sessionId); if (!session) { throw new Error(`Session ${params.sessionId} not found`); } if (session.abortController) { this.sessionStore.cancelActiveRun(params.sessionId); } const abortController = new AbortController(); const runId = randomUUID(); this.sessionStore.setActiveRun(params.sessionId, runId, abortController); const meta = parseSessionMeta(params._meta); const userText = extractTextFromPrompt(params.prompt); const attachments = extractAttachmentsFromPrompt(params.prompt); const prefixCwd = meta.prefixCwd ?? this.opts.prefixCwd ?? true; const message = prefixCwd ? `[Working directory: ${session.cwd}]\n\n${userText}` : userText; return new Promise((resolve, reject) => { this.pendingPrompts.set(params.sessionId, { sessionId: params.sessionId, sessionKey: session.sessionKey, idempotencyKey: runId, resolve, reject, }); this.gateway .request( "chat.send", { sessionKey: session.sessionKey, message, attachments: attachments.length > 0 ? attachments : undefined, idempotencyKey: runId, thinking: readString(params._meta, ["thinking", "thinkingLevel"]), deliver: readBool(params._meta, ["deliver"]), timeoutMs: readNumber(params._meta, ["timeoutMs"]), }, { expectFinal: true }, ) .catch((err) => { this.pendingPrompts.delete(params.sessionId); this.sessionStore.clearActiveRun(params.sessionId); reject(err instanceof Error ? err : new Error(String(err))); }); }); } async cancel(params: CancelNotification): Promise { const session = this.sessionStore.getSession(params.sessionId); if (!session) { return; } this.sessionStore.cancelActiveRun(params.sessionId); try { await this.gateway.request("chat.abort", { sessionKey: session.sessionKey }); } catch (err) { this.log(`cancel error: ${String(err)}`); } const pending = this.pendingPrompts.get(params.sessionId); if (pending) { this.pendingPrompts.delete(params.sessionId); pending.resolve({ stopReason: "cancelled" }); } } private async handleAgentEvent(evt: EventFrame): Promise { const payload = evt.payload as Record | undefined; if (!payload) { return; } const stream = payload.stream as string | undefined; const data = payload.data as Record | undefined; const sessionKey = payload.sessionKey as string | undefined; if (!stream || !data || !sessionKey) { return; } if (stream !== "tool") { return; } const phase = data.phase as string | undefined; const name = data.name as string | undefined; const toolCallId = data.toolCallId as string | undefined; if (!toolCallId) { return; } const pending = this.findPendingBySessionKey(sessionKey); if (!pending) { return; } if (phase === "start") { if (!pending.toolCalls) { pending.toolCalls = new Set(); } if (pending.toolCalls.has(toolCallId)) { return; } pending.toolCalls.add(toolCallId); const args = data.args as Record | undefined; await this.connection.sessionUpdate({ sessionId: pending.sessionId, update: { sessionUpdate: "tool_call", toolCallId, title: formatToolTitle(name, args), status: "in_progress", rawInput: args, kind: inferToolKind(name), }, }); return; } if (phase === "result") { const isError = Boolean(data.isError); await this.connection.sessionUpdate({ sessionId: pending.sessionId, update: { sessionUpdate: "tool_call_update", toolCallId, status: isError ? "failed" : "completed", rawOutput: data.result, }, }); } } private async handleChatEvent(evt: EventFrame): Promise { const payload = evt.payload as Record | undefined; if (!payload) { return; } const sessionKey = payload.sessionKey as string | undefined; const state = payload.state as string | undefined; const runId = payload.runId as string | undefined; const messageData = payload.message as Record | undefined; if (!sessionKey || !state) { return; } const pending = this.findPendingBySessionKey(sessionKey); if (!pending) { return; } if (runId && pending.idempotencyKey !== runId) { return; } if (state === "delta" && messageData) { await this.handleDeltaEvent(pending.sessionId, messageData); return; } if (state === "final") { this.finishPrompt(pending.sessionId, pending, "end_turn"); return; } if (state === "aborted") { this.finishPrompt(pending.sessionId, pending, "cancelled"); return; } if (state === "error") { this.finishPrompt(pending.sessionId, pending, "refusal"); } } private async handleDeltaEvent( sessionId: string, messageData: Record, ): Promise { const content = messageData.content as Array<{ type: string; text?: string }> | undefined; const fullText = content?.find((c) => c.type === "text")?.text ?? ""; const pending = this.pendingPrompts.get(sessionId); if (!pending) { return; } const sentSoFar = pending.sentTextLength ?? 0; if (fullText.length <= sentSoFar) { return; } const newText = fullText.slice(sentSoFar); pending.sentTextLength = fullText.length; pending.sentText = fullText; await this.connection.sessionUpdate({ sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: newText }, }, }); } private finishPrompt(sessionId: string, pending: PendingPrompt, stopReason: StopReason): void { this.pendingPrompts.delete(sessionId); this.sessionStore.clearActiveRun(sessionId); pending.resolve({ stopReason }); } private findPendingBySessionKey(sessionKey: string): PendingPrompt | undefined { for (const pending of this.pendingPrompts.values()) { if (pending.sessionKey === sessionKey) { return pending; } } return undefined; } private async sendAvailableCommands(sessionId: string): Promise { await this.connection.sessionUpdate({ sessionId, update: { sessionUpdate: "available_commands_update", availableCommands: getAvailableCommands(), }, }); } }