From 287c3823bde7a2058903628897daf3194feb9fc9 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Tue, 14 Jul 2026 17:15:45 -0400 Subject: [PATCH 01/21] feat: proxy primitive and public JSON-RPC peer layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ACP intermediaries — session routers, authz gates, audit taps — currently have to reimplement request/response correlation, cancellation propagation, and error passthrough because the SDK's JSON-RPC layer is private and there is no relay primitive. This exports the existing Connection/handler-chain layer, adds proxy() (two Connections wired back to back with forwarding fallbacks, mirroring the Rust SDK's Proxy role), exposes the in-memory stream pair, and lets fluent apps accept raw handlers on connect() so message-level middleware composes with typed apps. Co-Authored-By: Claude Fable 5 --- src/acp.ts | 103 +++++++++++++---- src/proxy.test.ts | 288 ++++++++++++++++++++++++++++++++++++++++++++++ src/proxy.ts | 137 ++++++++++++++++++++++ 3 files changed, 507 insertions(+), 21 deletions(-) create mode 100644 src/proxy.test.ts create mode 100644 src/proxy.ts diff --git a/src/acp.ts b/src/acp.ts index eb6aa189..8d10d4e4 100644 --- a/src/acp.ts +++ b/src/acp.ts @@ -52,15 +52,40 @@ export function ndJsonStream( return createJsonStream(output, input); } -export { RequestError } from "./jsonrpc.js"; +export * from "./proxy.js"; +// The lower-level JSON-RPC peer layer. `agent(...)` and `client(...)` are +// typed sugar over `Connection`; it is exported for integrations that need +// message-level dispatch — proxies, routers, and middleware that intercept +// traffic before a typed handler would see it. +export { + Connection, + ConnectionBuilder, + ConnectionContext, + Handled, + HandlerRegistration, + RequestError, + RequestResponder, + isJsonRpcMessage, + isNotificationMessage, + isRequestMessage, + isResponseMessage, +} from "./jsonrpc.js"; export type { AnyMessage, AnyNotification, AnyRequest, AnyResponse, + ConnectionOptions, ErrorResponse, + HandleResult, + IncomingMessage, + IncomingNotification, + IncomingRequest, + JsonRpcHandler, JsonRpcId, MaybePromise, + NotificationCallback, + RequestCallback, Result, SendRequestOptions, } from "./jsonrpc.js"; @@ -68,7 +93,6 @@ export type { import type { WireStream } from "./stream.js"; import { Connection, Handled, HandlerRegistration } from "./jsonrpc.js"; import type { - AnyWireMessage, ConnectionBuilder, ConnectionContext, ConnectionOptions, @@ -93,9 +117,16 @@ function isStream(value: unknown): value is WireStream { ); } -function memoryStreamPair(): [WireStream, WireStream] { - const leftToRight = new TransformStream(); - const rightToLeft = new TransformStream(); +/** + * Creates a pair of in-memory streams wired back to back. + * + * Messages written to one stream are read from the other. Use this to + * connect ACP endpoints in the same process — for example an app to a + * `proxy(...)` side, or endpoints under test — without a transport. + */ +export function inMemoryStreamPair(): [Stream, Stream] { + const leftToRight = new TransformStream(); + const rightToLeft = new TransformStream(); return [ { readable: rightToLeft.readable, @@ -1806,8 +1837,22 @@ const runAgentConnectHandlers = Symbol("runAgentConnectHandlers"); const runClientConnectHandlers = Symbol("runClientConnectHandlers"); const stableConnectionOptions: ConnectionOptions = { allowBatches: false }; -type AppConnectOptions = { +/** + * Options accepted by `AgentApp.connect(...)` and `ClientApp.connect(...)`. + */ +export type AppConnectOptions = { + /** @internal */ readonly deferConnectHandlers?: boolean; + /** + * Raw JSON-RPC handlers to run before this app's typed handlers. + * + * Handlers see every incoming request and notification and can observe + * them, rewrite them with `Handled.no(message)`, or consume them with + * `Handled.yes()` before a typed handler runs. Use this for + * connection-level middleware — logging, authorization, or parameter + * rewriting — without changing the app's registered handlers. + */ + readonly handlers?: JsonRpcHandler[]; }; type AgentConnectionState = { @@ -1861,9 +1906,9 @@ export class AgentApp { /** * Connects this agent app to a transport stream. */ - connect(stream: Stream): AgentConnection; + connect(stream: Stream, options?: AppConnectOptions): AgentConnection; /** @internal */ - connect(stream: WireStream, options: AppConnectOptions): AgentConnection; + connect(stream: WireStream, options?: AppConnectOptions): AgentConnection; /** * Connects this agent app directly to a client app. * @@ -2036,20 +2081,20 @@ export class AgentApp { options: AppConnectOptions = {}, ): AgentConnectionState { if (isStream(target)) { - const state = this.openStreamConnection(target); + const state = this.openStreamConnection(target, options); if (!options.deferConnectHandlers) { this[runAgentConnectHandlers](state.connection); } return state; } - const [thisStream, peerStream] = memoryStreamPair(); + const [thisStream, peerStream] = inMemoryStreamPair(); const peerRawConnection = target[appBuilder]().connect( peerStream, stableConnectionOptions, ); const peerConnection = clientConnection(peerRawConnection); - const state = this.openStreamConnection(thisStream); + const state = this.openStreamConnection(thisStream, options); void state.rawConnection.closed.then(() => peerConnection.close()); void peerRawConnection.closed.then(() => state.connection.close()); try { @@ -2063,8 +2108,14 @@ export class AgentApp { return state; } - private openStreamConnection(stream: WireStream): AgentConnectionState { - const rawConnection = this.builder.connect(stream, stableConnectionOptions); + private openStreamConnection( + stream: WireStream, + options: AppConnectOptions = {}, + ): AgentConnectionState { + const rawConnection = this.builder.connect(stream, { + ...stableConnectionOptions, + handlers: options.handlers, + }); return { rawConnection, connection: agentConnection(rawConnection, this.connectHandlers), @@ -2118,7 +2169,7 @@ export class ClientApp { /** * Connects this client app to a transport stream. */ - connect(stream: Stream): ClientConnection; + connect(stream: Stream, options?: AppConnectOptions): ClientConnection; /** * Connects this client app directly to an agent app. * @@ -2126,8 +2177,11 @@ export class ClientApp { * transport. */ connect(agent: AgentApp): ClientConnection; - connect(target: WireStream | AgentApp): ClientConnection { - return this.connectConnection(target).connection; + connect( + target: WireStream | AgentApp, + options: AppConnectOptions = {}, + ): ClientConnection { + return this.connectConnection(target, options).connection; } /** @@ -2285,20 +2339,21 @@ export class ClientApp { private connectConnection( target: WireStream | AgentApp, + options: AppConnectOptions = {}, ): ClientConnectionState { if (isStream(target)) { - const state = this.openStreamConnection(target); + const state = this.openStreamConnection(target, options); this[runClientConnectHandlers](state.connection); return state; } - const [thisStream, peerStream] = memoryStreamPair(); + const [thisStream, peerStream] = inMemoryStreamPair(); const peerRawConnection = target[appBuilder]().connect( peerStream, stableConnectionOptions, ); const peerConnection = agentConnection(peerRawConnection); - const state = this.openStreamConnection(thisStream); + const state = this.openStreamConnection(thisStream, options); void state.rawConnection.closed.then(() => peerConnection.close()); void peerRawConnection.closed.then(() => state.connection.close()); try { @@ -2312,8 +2367,14 @@ export class ClientApp { return state; } - private openStreamConnection(stream: WireStream): ClientConnectionState { - const rawConnection = this.builder.connect(stream, stableConnectionOptions); + private openStreamConnection( + stream: WireStream, + options: AppConnectOptions = {}, + ): ClientConnectionState { + const rawConnection = this.builder.connect(stream, { + ...stableConnectionOptions, + handlers: options.handlers, + }); return { rawConnection, connection: clientConnection(rawConnection, this.connectHandlers), diff --git a/src/proxy.test.ts b/src/proxy.test.ts new file mode 100644 index 00000000..73086447 --- /dev/null +++ b/src/proxy.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it, vi } from "vitest"; + +import { agent, client, inMemoryStreamPair } from "./acp.js"; +import { Connection, Handled, RequestError } from "./jsonrpc.js"; +import type { JsonRpcHandler } from "./jsonrpc.js"; +import { proxy } from "./proxy.js"; +import type { ProxyHandle } from "./proxy.js"; +import type { Stream } from "./stream.js"; + +type ProxySetup = { + clientStream: Stream; + agentStream: Stream; + handle: ProxyHandle; +}; + +function setupProxy(options?: { + clientToAgent?: JsonRpcHandler[]; + agentToClient?: JsonRpcHandler[]; +}): ProxySetup { + const [clientStream, proxyClientSide] = inMemoryStreamPair(); + const [proxyAgentSide, agentStream] = inMemoryStreamPair(); + const handle = proxy({ + client: proxyClientSide, + agent: proxyAgentSide, + ...options, + }); + return { clientStream, agentStream, handle }; +} + +describe("proxy forwarding", () => { + it("forwards client requests to the agent and responses back", async () => { + const { clientStream, agentStream } = setupProxy(); + Connection.builder() + .onReceiveRequest( + "echo", + (params) => params, + (request, responder) => responder.respond({ echoed: request }), + ) + .connect(agentStream); + const clientEnd = new Connection(clientStream, []); + + const response = await clientEnd.sendRequest("echo", { value: 42 }); + + expect(response).toEqual({ echoed: { value: 42 } }); + }); + + it("forwards agent-initiated requests to the client", async () => { + const { clientStream, agentStream } = setupProxy(); + Connection.builder() + .onReceiveRequest( + "confirm", + (params) => params, + (_request, responder) => responder.respond({ approved: true }), + ) + .connect(clientStream); + const agentEnd = new Connection(agentStream, []); + + const response = await agentEnd.sendRequest("confirm", { action: "run" }); + + expect(response).toEqual({ approved: true }); + }); + + it("forwards notifications", async () => { + const { clientStream, agentStream } = setupProxy(); + const received = vi.fn(); + const seen = Promise.withResolvers(); + Connection.builder() + .onReceiveNotification( + "log", + (params) => params, + (notification) => { + received(notification); + seen.resolve(); + }, + ) + .connect(agentStream); + const clientEnd = new Connection(clientStream, []); + + await clientEnd.sendNotification("log", { line: "hello" }); + await seen.promise; + + expect(received).toHaveBeenCalledWith({ line: "hello" }); + }); + + it("preserves error responses across the proxy", async () => { + const { clientStream, agentStream } = setupProxy(); + Connection.builder() + .onReceiveRequest( + "always-fails", + (params) => params, + () => { + throw new RequestError(1234, "nope", { reason: "test" }); + }, + ) + .connect(agentStream); + const clientEnd = new Connection(clientStream, []); + + const failure = clientEnd.sendRequest("always-fails", {}); + + await expect(failure).rejects.toMatchObject({ + code: 1234, + message: "nope", + data: { reason: "test" }, + }); + }); + + it("responds method-not-found from the far side, not the proxy", async () => { + const { clientStream, agentStream } = setupProxy(); + Connection.builder().connect(agentStream); + const clientEnd = new Connection(clientStream, []); + + const failure = clientEnd.sendRequest("no-such-method", {}); + + await expect(failure).rejects.toMatchObject({ code: -32601 }); + }); + + it("propagates cancellation to the side handling the request", async () => { + const { clientStream, agentStream } = setupProxy(); + Connection.builder() + .onReceiveRequest( + "slow", + (params) => params, + (_request, responder) => { + // Never respond on our own; settle only when the caller cancels. + responder.signal.addEventListener("abort", () => { + void responder.respondWithError(RequestError.requestCancelled()); + }); + }, + ) + .connect(agentStream); + const clientEnd = new Connection(clientStream, []); + + const canceller = new AbortController(); + const failure = clientEnd.sendRequest("slow", {}, undefined, { + cancellationSignal: canceller.signal, + }); + canceller.abort(); + + await expect(failure).rejects.toMatchObject({ code: -32800 }); + }); + + it("closes the other side when one side of the proxy closes", async () => { + const { handle } = setupProxy(); + + handle.client.close(new Error("client side went away")); + + await handle.closed; + await expect(handle.agent.sendRequest("anything", {})).rejects.toThrow( + "client side went away", + ); + }); + + it("closes both sides through the handle", async () => { + const { handle } = setupProxy(); + + handle.close(); + + await handle.closed; + }); +}); + +describe("proxy interception", () => { + it("rewrites request params before forwarding", async () => { + const { clientStream, agentStream } = setupProxy({ + clientToAgent: [ + { + handleMessage(message) { + if (message.kind !== "request" || message.method !== "echo") { + return Handled.no(message); + } + + return Handled.no({ + ...message, + params: { rewritten: true }, + }); + }, + }, + ], + }); + Connection.builder() + .onReceiveRequest( + "echo", + (params) => params, + (request, responder) => responder.respond({ echoed: request }), + ) + .connect(agentStream); + const clientEnd = new Connection(clientStream, []); + + const response = await clientEnd.sendRequest("echo", { original: true }); + + expect(response).toEqual({ echoed: { rewritten: true } }); + }); + + it("answers intercepted requests without the agent seeing them", async () => { + const agentSaw = vi.fn(); + const { clientStream, agentStream } = setupProxy({ + clientToAgent: [ + { + async handleMessage(message) { + if (message.kind !== "request" || message.method !== "denied") { + return Handled.no(message); + } + + await message.responder.respondWithError( + new RequestError(-32001, "not allowed"), + ); + return Handled.yes(); + }, + }, + ], + }); + Connection.builder() + .onReceiveMessage((message) => { + agentSaw(message.method); + return Handled.no(message); + }) + .connect(agentStream); + const clientEnd = new Connection(clientStream, []); + + const denied = clientEnd.sendRequest("denied", {}); + await expect(denied).rejects.toMatchObject({ code: -32001 }); + + // A permitted request afterwards proves the denied one never crossed. + await clientEnd.sendRequest("allowed", {}).catch(() => {}); + expect(agentSaw).toHaveBeenCalledWith("allowed"); + expect(agentSaw).not.toHaveBeenCalledWith("denied"); + }); + + it("intercepts agent-to-client traffic with agentToClient handlers", async () => { + const { clientStream, agentStream } = setupProxy({ + agentToClient: [ + { + handleMessage(message) { + if ( + message.kind !== "notification" || + message.method !== "update" + ) { + return Handled.no(message); + } + + return Handled.no({ + ...message, + params: { filtered: true }, + }); + }, + }, + ], + }); + const received = Promise.withResolvers(); + Connection.builder() + .onReceiveNotification( + "update", + (params) => params, + (notification) => received.resolve(notification), + ) + .connect(clientStream); + const agentEnd = new Connection(agentStream, []); + + await agentEnd.sendNotification("update", { secret: "value" }); + + await expect(received.promise).resolves.toEqual({ filtered: true }); + }); +}); + +describe("proxy with fluent apps", () => { + it("connects a client app to an agent app through the proxy", async () => { + const { clientStream, agentStream } = setupProxy(); + agent({ name: "test-agent" }) + .onRequest( + "_test/echo", + (params) => params as { value: number }, + ({ params }) => ({ doubled: params.value * 2 }), + ) + .connect(agentStream); + + const connection = client({ name: "test-client" }).connect(clientStream); + try { + const response = await connection.agent.request<{ doubled: number }>( + "_test/echo", + { value: 21 }, + ); + + expect(response).toEqual({ doubled: 42 }); + } finally { + connection.close(); + } + }); +}); diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 00000000..3a752894 --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,137 @@ +import { Connection, Handled } from "./jsonrpc.js"; +import type { JsonRpcHandler } from "./jsonrpc.js"; +import type { Stream } from "./stream.js"; + +/** + * Options for {@link proxy}. + */ +export type ProxyOptions = { + /** + * Stream connected to the client side. The proxy presents as an agent on + * this stream. + */ + client: Stream; + /** + * Stream connected to the agent side. The proxy presents as a client on + * this stream. + */ + agent: Stream; + /** + * Handlers run on messages arriving from the client, in order, before the + * proxy forwards them to the agent. + * + * Return `Handled.no(message)` with a replacement message to rewrite a + * request or notification in flight. Return `Handled.yes()` after + * responding through `message.responder` to intercept a request without + * the agent ever seeing it (for example to deny it). Handlers that return + * `Handled.no()` without a replacement pass the message through untouched. + */ + clientToAgent?: JsonRpcHandler[]; + /** + * Handlers run on messages arriving from the agent, in order, before the + * proxy forwards them to the client. Same contract as `clientToAgent`. + */ + agentToClient?: JsonRpcHandler[]; +}; + +/** + * Handle to a running proxy returned by {@link proxy}. + */ +export type ProxyHandle = { + /** + * Connection facing the client stream. Requests sent here go to the client. + */ + readonly client: Connection; + /** + * Connection facing the agent stream. Requests sent here go to the agent. + */ + readonly agent: Connection; + /** + * Promise that resolves once both sides of the proxy have closed. + */ + readonly closed: Promise; + /** + * Closes both sides of the proxy and rejects pending requests. + */ + close(error?: unknown): void; +}; + +/** + * Runs an ACP proxy between a client stream and an agent stream. + * + * The proxy terminates the protocol on both sides: each side is a full + * JSON-RPC connection, so forwarded requests are re-issued with the proxy's + * own request ids and their responses are correlated back to the original + * caller automatically. This works in both directions — client-initiated + * requests such as `session/prompt` flow toward the agent, and + * agent-initiated requests such as `session/request_permission` flow toward + * the client. + * + * Forwarding preserves the observable protocol behavior of a direct + * connection: + * + * - Error responses pass through with their original code, message, and data. + * - `$/cancel_request` from the original caller is propagated to the side + * handling the request, and the eventual response (which may be a normal + * result) still settles the original request. + * - When either side closes, the other side is closed and its pending + * requests are rejected. + * + * Messages can be observed, rewritten, answered, or dropped before they are + * forwarded by passing `clientToAgent` / `agentToClient` handlers; see + * {@link ProxyOptions}. + * + * This mirrors the Rust SDK's `Proxy` role: unhandled messages forward by + * default, and handlers intercept traffic from an explicit peer. The + * `_proxy/successor` envelope protocol used by the Rust conductor to chain + * proxy processes over a single pipe is not needed here — this proxy owns a + * real stream per side, so proxies chain by connecting one proxy's agent + * stream to the next proxy's client stream. + */ +export function proxy(options: ProxyOptions): ProxyHandle { + // Each side's handler chain ends in a forwarder that re-issues the message + // on the opposite connection. The connections reference each other, so the + // forwarders resolve their target lazily. + const forwardTo = (target: () => Connection): JsonRpcHandler => ({ + handleMessage: async (message) => { + if (message.kind === "request") { + const result = await target().sendRequest( + message.method, + message.params, + undefined, + { cancellationSignal: message.signal }, + ); + await message.responder.respond(result); + } else { + await target().sendNotification(message.method, message.params); + } + + return Handled.yes(); + }, + describe: () => "proxy:forward", + }); + + const client: Connection = new Connection(options.client, [ + ...(options.clientToAgent ?? []), + forwardTo(() => agent), + ]); + const agent: Connection = new Connection(options.agent, [ + ...(options.agentToClient ?? []), + forwardTo(() => client), + ]); + + // When either side closes, close the other with the same reason so its + // pending requests reject with the true cause. + void client.closed.then(() => agent.close(client.signal.reason)); + void agent.closed.then(() => client.close(agent.signal.reason)); + + return { + client, + agent, + closed: Promise.all([client.closed, agent.closed]).then(() => {}), + close(error?: unknown): void { + client.close(error); + agent.close(error); + }, + }; +} From 17a9b01f3b963f9174e2d5905f7b6d2ce6ba0c34 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Tue, 14 Jul 2026 17:25:48 -0400 Subject: [PATCH 02/21] refactor: dedupe cross-close wiring and simplify connect plumbing Extracts linkClosed so all three paired-connection sites (proxy and both in-memory app connects) propagate the close reason instead of two of them dropping it; narrows openStreamConnection to the one option it uses; hoists the proxy forwarder to module level; replaces the proxy star re-export with named exports so future additions to proxy.ts don't silently widen the public API. Co-Authored-By: Claude Fable 5 --- src/acp.ts | 32 ++++++++++++++++------------- src/jsonrpc.ts | 11 ++++++++++ src/proxy.ts | 56 +++++++++++++++++++++++++------------------------- 3 files changed, 57 insertions(+), 42 deletions(-) diff --git a/src/acp.ts b/src/acp.ts index 8d10d4e4..e2de3d1d 100644 --- a/src/acp.ts +++ b/src/acp.ts @@ -52,7 +52,8 @@ export function ndJsonStream( return createJsonStream(output, input); } -export * from "./proxy.js"; +export { proxy } from "./proxy.js"; +export type { ProxyHandle, ProxyOptions } from "./proxy.js"; // The lower-level JSON-RPC peer layer. `agent(...)` and `client(...)` are // typed sugar over `Connection`; it is exported for integrations that need // message-level dispatch — proxies, routers, and middleware that intercept @@ -91,7 +92,12 @@ export type { } from "./jsonrpc.js"; import type { WireStream } from "./stream.js"; -import { Connection, Handled, HandlerRegistration } from "./jsonrpc.js"; +import { + Connection, + Handled, + HandlerRegistration, + linkClosed, +} from "./jsonrpc.js"; import type { ConnectionBuilder, ConnectionContext, @@ -2081,7 +2087,7 @@ export class AgentApp { options: AppConnectOptions = {}, ): AgentConnectionState { if (isStream(target)) { - const state = this.openStreamConnection(target, options); + const state = this.openStreamConnection(target, options.handlers); if (!options.deferConnectHandlers) { this[runAgentConnectHandlers](state.connection); } @@ -2094,9 +2100,8 @@ export class AgentApp { stableConnectionOptions, ); const peerConnection = clientConnection(peerRawConnection); - const state = this.openStreamConnection(thisStream, options); - void state.rawConnection.closed.then(() => peerConnection.close()); - void peerRawConnection.closed.then(() => state.connection.close()); + const state = this.openStreamConnection(thisStream, options.handlers); + linkClosed(state.rawConnection, peerRawConnection); try { target[runClientConnectHandlers](peerConnection); this[runAgentConnectHandlers](state.connection); @@ -2110,11 +2115,11 @@ export class AgentApp { private openStreamConnection( stream: WireStream, - options: AppConnectOptions = {}, + handlers?: JsonRpcHandler[], ): AgentConnectionState { const rawConnection = this.builder.connect(stream, { ...stableConnectionOptions, - handlers: options.handlers, + handlers, }); return { rawConnection, @@ -2342,7 +2347,7 @@ export class ClientApp { options: AppConnectOptions = {}, ): ClientConnectionState { if (isStream(target)) { - const state = this.openStreamConnection(target, options); + const state = this.openStreamConnection(target, options.handlers); this[runClientConnectHandlers](state.connection); return state; } @@ -2353,9 +2358,8 @@ export class ClientApp { stableConnectionOptions, ); const peerConnection = agentConnection(peerRawConnection); - const state = this.openStreamConnection(thisStream, options); - void state.rawConnection.closed.then(() => peerConnection.close()); - void peerRawConnection.closed.then(() => state.connection.close()); + const state = this.openStreamConnection(thisStream, options.handlers); + linkClosed(state.rawConnection, peerRawConnection); try { target[runAgentConnectHandlers](peerConnection); this[runClientConnectHandlers](state.connection); @@ -2369,11 +2373,11 @@ export class ClientApp { private openStreamConnection( stream: WireStream, - options: AppConnectOptions = {}, + handlers?: JsonRpcHandler[], ): ClientConnectionState { const rawConnection = this.builder.connect(stream, { ...stableConnectionOptions, - handlers: options.handlers, + handlers, }); return { rawConnection, diff --git a/src/jsonrpc.ts b/src/jsonrpc.ts index bf03afe2..9e8e6ffa 100644 --- a/src/jsonrpc.ts +++ b/src/jsonrpc.ts @@ -1555,6 +1555,17 @@ export class Connection { } } +/** + * Closes each connection when the other closes. + * + * The close reason is propagated so pending requests on the surviving side + * reject with the true cause rather than a generic connection-closed error. + */ +export function linkClosed(a: Connection, b: Connection): void { + void a.closed.then(() => b.close(a.signal.reason)); + void b.closed.then(() => a.close(b.signal.reason)); +} + /** * Builder for a lower-level handler-based JSON-RPC connection. */ diff --git a/src/proxy.ts b/src/proxy.ts index 3a752894..9cc5ba43 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,4 +1,4 @@ -import { Connection, Handled } from "./jsonrpc.js"; +import { Connection, Handled, linkClosed } from "./jsonrpc.js"; import type { JsonRpcHandler } from "./jsonrpc.js"; import type { Stream } from "./stream.js"; @@ -89,28 +89,6 @@ export type ProxyHandle = { * stream to the next proxy's client stream. */ export function proxy(options: ProxyOptions): ProxyHandle { - // Each side's handler chain ends in a forwarder that re-issues the message - // on the opposite connection. The connections reference each other, so the - // forwarders resolve their target lazily. - const forwardTo = (target: () => Connection): JsonRpcHandler => ({ - handleMessage: async (message) => { - if (message.kind === "request") { - const result = await target().sendRequest( - message.method, - message.params, - undefined, - { cancellationSignal: message.signal }, - ); - await message.responder.respond(result); - } else { - await target().sendNotification(message.method, message.params); - } - - return Handled.yes(); - }, - describe: () => "proxy:forward", - }); - const client: Connection = new Connection(options.client, [ ...(options.clientToAgent ?? []), forwardTo(() => agent), @@ -119,11 +97,7 @@ export function proxy(options: ProxyOptions): ProxyHandle { ...(options.agentToClient ?? []), forwardTo(() => client), ]); - - // When either side closes, close the other with the same reason so its - // pending requests reject with the true cause. - void client.closed.then(() => agent.close(client.signal.reason)); - void agent.closed.then(() => client.close(agent.signal.reason)); + linkClosed(client, agent); return { client, @@ -135,3 +109,29 @@ export function proxy(options: ProxyOptions): ProxyHandle { }, }; } + +/** + * Terminal handler for one proxy side: re-issues the incoming message on the + * opposite connection and relays the response back. The two connections + * reference each other, so the target is resolved lazily. + */ +function forwardTo(target: () => Connection): JsonRpcHandler { + return { + handleMessage: async (message) => { + if (message.kind === "request") { + const result = await target().sendRequest( + message.method, + message.params, + undefined, + { cancellationSignal: message.signal }, + ); + await message.responder.respond(result); + } else { + await target().sendNotification(message.method, message.params); + } + + return Handled.yes(); + }, + describe: () => "proxy:forward", + }; +} From 9af3b68cfe9993acfc6b136307af131ffa427510 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Tue, 14 Jul 2026 17:36:37 -0400 Subject: [PATCH 03/21] docs: proxy example and README, align option names with the Rust Proxy role Renames the interception lists to fromClient/fromAgent to match how the Rust SDK's Proxy role frames interception (on_receive_request_from(Client), from(Agent)) so the two SDKs teach the same mental model, and adds a runnable snoop-proxy example plus README pointers so proxies are discoverable the same way agents and clients are. Co-Authored-By: Claude Fable 5 --- README.md | 2 ++ src/examples/README.md | 1 + src/examples/proxy.ts | 55 ++++++++++++++++++++++++++++++++++++++++++ src/proxy.test.ts | 12 ++++----- src/proxy.ts | 19 +++++++++------ 5 files changed, 75 insertions(+), 14 deletions(-) create mode 100644 src/examples/proxy.ts diff --git a/README.md b/README.md index ca317a21..01ae2e1e 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ If you're building an [Agent](https://agentclientprotocol.com/protocol/overview# If you're building a [Client](https://agentclientprotocol.com/protocol/overview#client), start with `client({ name })`, register client-side handlers such as `requestPermission(...)` and `sessionUpdate(...)`, then run your agent workflow with `connectWith(stream, async (ctx) => ...)`. +If you're building something that sits between the two — a logger, an authorization gate, a message transformer — start with `proxy({ client, agent })`. It forwards all traffic in both directions by default, and handlers can observe, rewrite, answer, or drop messages before they're forwarded. + ### Study a Production Implementation For a complete, production-ready implementation, check out the [Gemini CLI Agent](https://github.com/google-gemini/gemini-cli/blob/main/packages/cli/src/zed-integration/zedIntegration.ts). diff --git a/src/examples/README.md b/src/examples/README.md index f00c3cd7..58c52543 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -4,6 +4,7 @@ This directory contains examples using the [ACP](https://agentclientprotocol.com - [`agent.ts`](./agent.ts) - A minimal agent implementation that simulates LLM interaction - [`client.ts`](./client.ts) - A minimal client implementation that spawns the [`agent.ts`](./agent.ts) as a subprocess +- [`proxy.ts`](./proxy.ts) - A pass-through proxy that wraps any agent command and logs the messages crossing it in both directions - [`http-server.ts`](./http-server.ts) - A minimal ACP Streamable HTTP server with WebSocket upgrade support - [`http-client.ts`](./http-client.ts) - A minimal client using `createHttpStream` - [`ws-client.ts`](./ws-client.ts) - A minimal client using `createWebSocketStream` diff --git a/src/examples/proxy.ts b/src/examples/proxy.ts new file mode 100644 index 00000000..bec1eaa7 --- /dev/null +++ b/src/examples/proxy.ts @@ -0,0 +1,55 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { dirname, join } from "node:path"; +import { Readable, Writable } from "node:stream"; +import { fileURLToPath } from "node:url"; + +import * as acp from "../acp.js"; + +// A minimal ACP proxy: it presents as an agent on its own stdio, spawns the +// real agent as a subprocess, and forwards every message between the two +// while logging traffic to stderr. Point an ACP client (like Zed) at this +// script exactly as it would run the agent directly — neither side needs to +// know the proxy is there. +// +// Usage: proxy.ts [command args...] +// Runs the example agent from this directory when no command is given. + +/** Logs each request and notification crossing the proxy, then passes it on. */ +function snoop(direction: string): acp.JsonRpcHandler { + return { + handleMessage(message) { + console.error(`[proxy] ${direction} ${message.kind}: ${message.method}`); + return acp.Handled.no(message); + }, + }; +} + +// Spawn the wrapped agent: the command from argv, or the example agent. +const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"; +const exampleAgent = join(dirname(fileURLToPath(import.meta.url)), "agent.ts"); +const [command, ...args] = + process.argv.length > 2 + ? process.argv.slice(2) + : [npxCmd, "tsx", exampleAgent]; +const agentProcess = spawn(command, args, { + stdio: ["pipe", "pipe", "inherit"], +}); + +const handle = acp.proxy({ + client: acp.ndJsonStream( + Writable.toWeb(process.stdout), + Readable.toWeb(process.stdin) as ReadableStream, + ), + agent: acp.ndJsonStream( + Writable.toWeb(agentProcess.stdin!), + Readable.toWeb(agentProcess.stdout!) as ReadableStream, + ), + fromClient: [snoop("client → agent")], + fromAgent: [snoop("agent → client")], +}); + +agentProcess.once("exit", () => handle.close()); +await handle.closed; +agentProcess.kill(); diff --git a/src/proxy.test.ts b/src/proxy.test.ts index 73086447..88f887ea 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -14,8 +14,8 @@ type ProxySetup = { }; function setupProxy(options?: { - clientToAgent?: JsonRpcHandler[]; - agentToClient?: JsonRpcHandler[]; + fromClient?: JsonRpcHandler[]; + fromAgent?: JsonRpcHandler[]; }): ProxySetup { const [clientStream, proxyClientSide] = inMemoryStreamPair(); const [proxyAgentSide, agentStream] = inMemoryStreamPair(); @@ -162,7 +162,7 @@ describe("proxy forwarding", () => { describe("proxy interception", () => { it("rewrites request params before forwarding", async () => { const { clientStream, agentStream } = setupProxy({ - clientToAgent: [ + fromClient: [ { handleMessage(message) { if (message.kind !== "request" || message.method !== "echo") { @@ -194,7 +194,7 @@ describe("proxy interception", () => { it("answers intercepted requests without the agent seeing them", async () => { const agentSaw = vi.fn(); const { clientStream, agentStream } = setupProxy({ - clientToAgent: [ + fromClient: [ { async handleMessage(message) { if (message.kind !== "request" || message.method !== "denied") { @@ -226,9 +226,9 @@ describe("proxy interception", () => { expect(agentSaw).not.toHaveBeenCalledWith("denied"); }); - it("intercepts agent-to-client traffic with agentToClient handlers", async () => { + it("intercepts agent-to-client traffic with fromAgent handlers", async () => { const { clientStream, agentStream } = setupProxy({ - agentToClient: [ + fromAgent: [ { handleMessage(message) { if ( diff --git a/src/proxy.ts b/src/proxy.ts index 9cc5ba43..1e8fdfbe 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -26,12 +26,12 @@ export type ProxyOptions = { * the agent ever seeing it (for example to deny it). Handlers that return * `Handled.no()` without a replacement pass the message through untouched. */ - clientToAgent?: JsonRpcHandler[]; + fromClient?: JsonRpcHandler[]; /** * Handlers run on messages arriving from the agent, in order, before the - * proxy forwards them to the client. Same contract as `clientToAgent`. + * proxy forwards them to the client. Same contract as `fromClient`. */ - agentToClient?: JsonRpcHandler[]; + fromAgent?: JsonRpcHandler[]; }; /** @@ -78,11 +78,14 @@ export type ProxyHandle = { * requests are rejected. * * Messages can be observed, rewritten, answered, or dropped before they are - * forwarded by passing `clientToAgent` / `agentToClient` handlers; see + * forwarded by passing `fromClient` / `fromAgent` handlers; see * {@link ProxyOptions}. * - * This mirrors the Rust SDK's `Proxy` role: unhandled messages forward by - * default, and handlers intercept traffic from an explicit peer. The + * This mirrors the Rust SDK's `Proxy` role: a proxy sits between a client + * and an agent, intercepting messages in both directions; messages it does + * not handle are forwarded by default, and handlers intercept traffic from + * an explicit peer (`fromClient` / `fromAgent`, like the Rust builder's + * `on_receive_request_from(Client | Agent, ...)`). The * `_proxy/successor` envelope protocol used by the Rust conductor to chain * proxy processes over a single pipe is not needed here — this proxy owns a * real stream per side, so proxies chain by connecting one proxy's agent @@ -90,11 +93,11 @@ export type ProxyHandle = { */ export function proxy(options: ProxyOptions): ProxyHandle { const client: Connection = new Connection(options.client, [ - ...(options.clientToAgent ?? []), + ...(options.fromClient ?? []), forwardTo(() => agent), ]); const agent: Connection = new Connection(options.agent, [ - ...(options.agentToClient ?? []), + ...(options.fromAgent ?? []), forwardTo(() => client), ]); linkClosed(client, agent); From 1d883fb3187bfde9267475cd29ccc3022533cb39 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 10:08:12 -0400 Subject: [PATCH 04/21] fix: serialize proxy dispatch to match the Rust SDK's ordering guarantee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rust SDK documents sequential dispatch as a core guarantee (the loop waits for each handler before the next message) with forward_response_to as the non-blocking escape for forwarded round trips. The TS Connection dispatches concurrently, so a proxy with slow handlers could reorder notifications. The proxy now runs each side's chain one message at a time and forwards requests split-phase — send, register the response continuation, release the loop — so ordering matches Rust without a pending round trip blocking later messages like session/cancel. Co-Authored-By: Claude Fable 5 --- src/jsonrpc.ts | 7 +++- src/proxy.test.ts | 71 ++++++++++++++++++++++++++++++++++++++- src/proxy.ts | 85 +++++++++++++++++++++++++++++++++++++---------- 3 files changed, 144 insertions(+), 19 deletions(-) diff --git a/src/jsonrpc.ts b/src/jsonrpc.ts index 9e8e6ffa..3f66b711 100644 --- a/src/jsonrpc.ts +++ b/src/jsonrpc.ts @@ -605,7 +605,12 @@ function isZodError(error: unknown): error is { format(): unknown } { ); } -function errorToResult(error: unknown): Result { +/** + * Maps a thrown error to a JSON-RPC result payload: `RequestError` keeps its + * code/message/data, Zod errors become invalid-params, anything else becomes + * a generic internal error. + */ +export function errorToResult(error: unknown): Result { if (error instanceof RequestError) { return error.toResult(); } diff --git a/src/proxy.test.ts b/src/proxy.test.ts index 88f887ea..200291b9 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { agent, client, inMemoryStreamPair } from "./acp.js"; import { Connection, Handled, RequestError } from "./jsonrpc.js"; -import type { JsonRpcHandler } from "./jsonrpc.js"; +import type { JsonRpcHandler, RequestResponder } from "./jsonrpc.js"; import { proxy } from "./proxy.js"; import type { ProxyHandle } from "./proxy.js"; import type { Stream } from "./stream.js"; @@ -139,6 +139,75 @@ describe("proxy forwarding", () => { await expect(failure).rejects.toMatchObject({ code: -32800 }); }); + it("preserves notification order when an earlier handler is slow", async () => { + const { clientStream, agentStream } = setupProxy({ + fromAgent: [ + { + async handleMessage(message) { + if (message.kind === "notification") { + const { delay } = message.params as { delay: number }; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + + return Handled.no(message); + }, + }, + ], + }); + const received: string[] = []; + const gotBoth = Promise.withResolvers(); + Connection.builder() + .onReceiveNotification( + "update", + (params) => params as { tag: string }, + (notification) => { + received.push(notification.tag); + if (received.length === 2) { + gotBoth.resolve(); + } + }, + ) + .connect(clientStream); + const agentEnd = new Connection(agentStream, []); + + // Without serialized dispatch the second (instant) chain would overtake + // the first (delayed) one. + await agentEnd.sendNotification("update", { tag: "first", delay: 25 }); + await agentEnd.sendNotification("update", { tag: "second", delay: 0 }); + await gotBoth.promise; + + expect(received).toEqual(["first", "second"]); + }); + + it("does not block later messages behind a pending request round trip", async () => { + const { clientStream, agentStream } = setupProxy(); + const slowArrived = Promise.withResolvers>(); + Connection.builder() + .onReceiveRequest( + "slow", + (params) => params, + (_request, responder) => { + // Hold the request open; it is answered after `ping` completes. + slowArrived.resolve(responder); + }, + ) + .onReceiveRequest( + "ping", + (params) => params, + (_request, responder) => responder.respond({ pong: true }), + ) + .connect(agentStream); + const clientEnd = new Connection(clientStream, []); + + const slow = clientEnd.sendRequest("slow", {}); + const ping = await clientEnd.sendRequest("ping", {}); + expect(ping).toEqual({ pong: true }); + + const responder = await slowArrived.promise; + await responder.respond({ done: true }); + await expect(slow).resolves.toEqual({ done: true }); + }); + it("closes the other side when one side of the proxy closes", async () => { const { handle } = setupProxy(); diff --git a/src/proxy.ts b/src/proxy.ts index 1e8fdfbe..7ef3f490 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,5 +1,5 @@ -import { Connection, Handled, linkClosed } from "./jsonrpc.js"; -import type { JsonRpcHandler } from "./jsonrpc.js"; +import { Connection, Handled, errorToResult, linkClosed } from "./jsonrpc.js"; +import type { HandleResult, JsonRpcHandler } from "./jsonrpc.js"; import type { Stream } from "./stream.js"; /** @@ -77,6 +77,13 @@ export type ProxyHandle = { * - When either side closes, the other side is closed and its pending * requests are rejected. * + * Each side processes messages one at a time in arrival order, matching the + * Rust SDK's dispatch loop: the next message is not dispatched until the + * previous handler chain completes. A forwarded request releases the loop + * once it has been sent rather than holding it across the round trip (the + * Rust `forward_response_to` pattern), so a pending request never blocks + * later messages such as `session/cancel`. + * * Messages can be observed, rewritten, answered, or dropped before they are * forwarded by passing `fromClient` / `fromAgent` handlers; see * {@link ProxyOptions}. @@ -93,12 +100,10 @@ export type ProxyHandle = { */ export function proxy(options: ProxyOptions): ProxyHandle { const client: Connection = new Connection(options.client, [ - ...(options.fromClient ?? []), - forwardTo(() => agent), + serialDispatch([...(options.fromClient ?? []), forwardTo(() => agent)]), ]); const agent: Connection = new Connection(options.agent, [ - ...(options.fromAgent ?? []), - forwardTo(() => client), + serialDispatch([...(options.fromAgent ?? []), forwardTo(() => client)]), ]); linkClosed(client, agent); @@ -113,26 +118,72 @@ export function proxy(options: ProxyOptions): ProxyHandle { }; } +/** + * Runs one side's handler chain one message at a time, in arrival order. + * + * The underlying `Connection` dispatches each incoming message as its own + * async task, which would let chains with slow handlers overtake each other. + * The Rust SDK guarantees sequential dispatch, so the proxy provides the + * same: the next message is not dispatched until the previous chain settles. + * A handler that throws fails only its own message (the connection converts + * the error to a response or log line); the queue continues. + */ +function serialDispatch(handlers: JsonRpcHandler[]): JsonRpcHandler { + let queue: Promise = Promise.resolve(); + return { + handleMessage(message, cx) { + const result = queue.then(async (): Promise => { + let current = message; + for (const handler of handlers) { + const outcome = + (await handler.handleMessage(current, cx)) ?? Handled.yes(); + if (outcome.handled) { + return Handled.yes(); + } + current = outcome.message ?? current; + } + + // Unreachable: the forwarder at the end of the chain always handles. + return Handled.yes(); + }); + queue = result.catch(() => {}); + return result; + }, + describe: () => "proxy:serial-dispatch", + }; +} + /** * Terminal handler for one proxy side: re-issues the incoming message on the * opposite connection and relays the response back. The two connections * reference each other, so the target is resolved lazily. + * + * Requests are forwarded split-phase: the outgoing request is sent + * synchronously (preserving send order), the response continuation is + * registered, and the dispatch queue is released immediately so the round + * trip never blocks later messages. */ function forwardTo(target: () => Connection): JsonRpcHandler { return { - handleMessage: async (message) => { - if (message.kind === "request") { - const result = await target().sendRequest( - message.method, - message.params, - undefined, - { cancellationSignal: message.signal }, - ); - await message.responder.respond(result); - } else { - await target().sendNotification(message.method, message.params); + handleMessage(message) { + if (message.kind !== "request") { + return target() + .sendNotification(message.method, message.params) + .then(() => Handled.yes()); } + const { responder } = message; + target() + .sendRequest(message.method, message.params, undefined, { + cancellationSignal: message.signal, + }) + .then( + (result) => responder.respond(result), + (error) => responder.respondWithResult(errorToResult(error)), + ) + // The response cannot be delivered when the caller's side is already + // closed; there is nowhere left to report it. + .catch(() => {}); return Handled.yes(); }, describe: () => "proxy:forward", From c44180e50610d7687d358a94f4e7baf7821226ed Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 10:39:03 -0400 Subject: [PATCH 05/21] feat: typed per-method proxy registration via side builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxy authors previously got raw JsonRpcHandlers only — params: unknown and hand-rolled method switches — while Rust proxy handlers are always typed (type-driven dispatch). proxy() now returns a builder whose client/agent sides register onRequest/onNotification with the same shapes as the fluent apps: built-in literals typed from the ByMethod maps, a parser form for extensions, and '*' as a most-specific-wins fallback (Rust's UntypedMessage registered last). Handlers receive forward(params) and return the response; typed literals do not runtime-parse on the relay path so unknown extension fields survive forwarding. Raw handlers remain via withHandler as the see-everything escape. Co-Authored-By: Claude Fable 5 --- README.md | 2 +- src/acp.ts | 11 +- src/examples/proxy.ts | 10 +- src/proxy.test.ts | 268 ++++++++++++++++++------- src/proxy.ts | 452 ++++++++++++++++++++++++++++++++++++------ 5 files changed, 600 insertions(+), 143 deletions(-) diff --git a/README.md b/README.md index 01ae2e1e..7a512ebd 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ If you're building an [Agent](https://agentclientprotocol.com/protocol/overview# If you're building a [Client](https://agentclientprotocol.com/protocol/overview#client), start with `client({ name })`, register client-side handlers such as `requestPermission(...)` and `sessionUpdate(...)`, then run your agent workflow with `connectWith(stream, async (ctx) => ...)`. -If you're building something that sits between the two — a logger, an authorization gate, a message transformer — start with `proxy({ client, agent })`. It forwards all traffic in both directions by default, and handlers can observe, rewrite, answer, or drop messages before they're forwarded. +If you're building something that sits between the two — a logger, an authorization gate, a message transformer — start with `proxy()`. Register typed handlers on `p.client` (traffic from the client) and `p.agent` (traffic from the agent) just like the agent/client builders, then call `p.connect({ client, agent })`. Anything you don't claim is forwarded untouched in both directions; handlers can rewrite, answer, or drop messages before they cross. ### Study a Production Implementation diff --git a/src/acp.ts b/src/acp.ts index e2de3d1d..23a468ae 100644 --- a/src/acp.ts +++ b/src/acp.ts @@ -52,8 +52,15 @@ export function ndJsonStream( return createJsonStream(output, input); } -export { proxy } from "./proxy.js"; -export type { ProxyHandle, ProxyOptions } from "./proxy.js"; +export { proxy, ProxyBuilder, ProxySideBuilder } from "./proxy.js"; +export type { + ProxyHandle, + ProxyNotificationContext, + ProxyNotificationHandler, + ProxyRequestContext, + ProxyRequestHandler, + ProxyStreams, +} from "./proxy.js"; // The lower-level JSON-RPC peer layer. `agent(...)` and `client(...)` are // typed sugar over `Connection`; it is exported for integrations that need // message-level dispatch — proxies, routers, and middleware that intercept diff --git a/src/examples/proxy.ts b/src/examples/proxy.ts index bec1eaa7..18046fcc 100644 --- a/src/examples/proxy.ts +++ b/src/examples/proxy.ts @@ -37,7 +37,13 @@ const agentProcess = spawn(command, args, { stdio: ["pipe", "pipe", "inherit"], }); -const handle = acp.proxy({ +const p = acp.proxy(); +// Raw handlers see every message; typed interception is also available, e.g. +// p.client.onRequest("session/prompt", async ({ params, forward }) => ...). +p.client.withHandler(snoop("client → agent")); +p.agent.withHandler(snoop("agent → client")); + +const handle = p.connect({ client: acp.ndJsonStream( Writable.toWeb(process.stdout), Readable.toWeb(process.stdin) as ReadableStream, @@ -46,8 +52,6 @@ const handle = acp.proxy({ Writable.toWeb(agentProcess.stdin!), Readable.toWeb(agentProcess.stdout!) as ReadableStream, ), - fromClient: [snoop("client → agent")], - fromAgent: [snoop("agent → client")], }); agentProcess.once("exit", () => handle.close()); diff --git a/src/proxy.test.ts b/src/proxy.test.ts index 200291b9..53c034a7 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it, vi } from "vitest"; import { agent, client, inMemoryStreamPair } from "./acp.js"; import { Connection, Handled, RequestError } from "./jsonrpc.js"; -import type { JsonRpcHandler, RequestResponder } from "./jsonrpc.js"; +import type { RequestResponder } from "./jsonrpc.js"; import { proxy } from "./proxy.js"; -import type { ProxyHandle } from "./proxy.js"; +import type { ProxyBuilder, ProxyHandle } from "./proxy.js"; import type { Stream } from "./stream.js"; type ProxySetup = { @@ -13,17 +13,12 @@ type ProxySetup = { handle: ProxyHandle; }; -function setupProxy(options?: { - fromClient?: JsonRpcHandler[]; - fromAgent?: JsonRpcHandler[]; -}): ProxySetup { +function setupProxy(configure?: (p: ProxyBuilder) => void): ProxySetup { const [clientStream, proxyClientSide] = inMemoryStreamPair(); const [proxyAgentSide, agentStream] = inMemoryStreamPair(); - const handle = proxy({ - client: proxyClientSide, - agent: proxyAgentSide, - ...options, - }); + const p = proxy(); + configure?.(p); + const handle = p.connect({ client: proxyClientSide, agent: proxyAgentSide }); return { clientStream, agentStream, handle }; } @@ -140,19 +135,15 @@ describe("proxy forwarding", () => { }); it("preserves notification order when an earlier handler is slow", async () => { - const { clientStream, agentStream } = setupProxy({ - fromAgent: [ - { - async handleMessage(message) { - if (message.kind === "notification") { - const { delay } = message.params as { delay: number }; - await new Promise((resolve) => setTimeout(resolve, delay)); - } - - return Handled.no(message); - }, + const { clientStream, agentStream } = setupProxy((p) => { + p.agent.onNotification( + "update", + (params) => params as { tag: string; delay: number }, + async ({ params, forward }) => { + await new Promise((resolve) => setTimeout(resolve, params.delay)); + await forward(params); }, - ], + ); }); const received: string[] = []; const gotBoth = Promise.withResolvers(); @@ -180,7 +171,15 @@ describe("proxy forwarding", () => { }); it("does not block later messages behind a pending request round trip", async () => { - const { clientStream, agentStream } = setupProxy(); + // `slow` goes through a typed handler that awaits forward(...), which + // must release the dispatch loop at the send, not the response. + const { clientStream, agentStream } = setupProxy((p) => { + p.client.onRequest( + "slow", + (params) => params, + async ({ params, forward }) => forward(params), + ); + }); const slowArrived = Promise.withResolvers>(); Connection.builder() .onReceiveRequest( @@ -228,23 +227,14 @@ describe("proxy forwarding", () => { }); }); -describe("proxy interception", () => { +describe("proxy typed registration", () => { it("rewrites request params before forwarding", async () => { - const { clientStream, agentStream } = setupProxy({ - fromClient: [ - { - handleMessage(message) { - if (message.kind !== "request" || message.method !== "echo") { - return Handled.no(message); - } - - return Handled.no({ - ...message, - params: { rewritten: true }, - }); - }, - }, - ], + const { clientStream, agentStream } = setupProxy((p) => { + p.client.onRequest( + "echo", + (params) => params as Record, + async ({ forward }) => forward({ rewritten: true }), + ); }); Connection.builder() .onReceiveRequest( @@ -260,23 +250,41 @@ describe("proxy interception", () => { expect(response).toEqual({ echoed: { rewritten: true } }); }); + it("rewrites responses on the way back", async () => { + const { clientStream, agentStream } = setupProxy((p) => { + p.client.onRequest( + "echo", + (params) => params, + async ({ params, forward }) => { + const response = (await forward(params)) as Record; + return { ...response, stamped: true }; + }, + ); + }); + Connection.builder() + .onReceiveRequest( + "echo", + (params) => params, + (request, responder) => responder.respond({ echoed: request }), + ) + .connect(agentStream); + const clientEnd = new Connection(clientStream, []); + + const response = await clientEnd.sendRequest("echo", { value: 1 }); + + expect(response).toEqual({ echoed: { value: 1 }, stamped: true }); + }); + it("answers intercepted requests without the agent seeing them", async () => { const agentSaw = vi.fn(); - const { clientStream, agentStream } = setupProxy({ - fromClient: [ - { - async handleMessage(message) { - if (message.kind !== "request" || message.method !== "denied") { - return Handled.no(message); - } - - await message.responder.respondWithError( - new RequestError(-32001, "not allowed"), - ); - return Handled.yes(); - }, + const { clientStream, agentStream } = setupProxy((p) => { + p.client.onRequest( + "denied", + (params) => params, + () => { + throw new RequestError(-32001, "not allowed"); }, - ], + ); }); Connection.builder() .onReceiveMessage((message) => { @@ -295,45 +303,150 @@ describe("proxy interception", () => { expect(agentSaw).not.toHaveBeenCalledWith("denied"); }); - it("intercepts agent-to-client traffic with fromAgent handlers", async () => { - const { clientStream, agentStream } = setupProxy({ - fromAgent: [ - { - handleMessage(message) { - if ( - message.kind !== "notification" || - message.method !== "update" - ) { - return Handled.no(message); - } - - return Handled.no({ - ...message, - params: { filtered: true }, - }); - }, + it("answers without forwarding when the handler returns its own response", async () => { + const { clientStream } = setupProxy((p) => { + p.client.onRequest( + "cached", + (params) => params, + () => ({ fromProxy: true }), + ); + }); + const clientEnd = new Connection(clientStream, []); + + const response = await clientEnd.sendRequest("cached", {}); + + expect(response).toEqual({ fromProxy: true }); + }); + + it("drops notifications when the handler skips forward", async () => { + const { clientStream, agentStream } = setupProxy((p) => { + p.agent.onNotification( + "update", + (params) => params as { secret: boolean }, + async ({ params, forward }) => { + if (!params.secret) { + await forward(params); + } }, - ], + ); }); - const received = Promise.withResolvers(); + const received = vi.fn(); + const gotPublic = Promise.withResolvers(); Connection.builder() .onReceiveNotification( "update", (params) => params, - (notification) => received.resolve(notification), + (notification) => { + received(notification); + gotPublic.resolve(); + }, ) .connect(clientStream); const agentEnd = new Connection(agentStream, []); - await agentEnd.sendNotification("update", { secret: "value" }); + await agentEnd.sendNotification("update", { secret: true }); + await agentEnd.sendNotification("update", { secret: false }); + await gotPublic.promise; + + expect(received).toHaveBeenCalledTimes(1); + expect(received).toHaveBeenCalledWith({ secret: false }); + }); + + it("routes unclaimed traffic to '*' with exact registrations winning", async () => { + const wildcardSaw: string[] = []; + const { clientStream, agentStream } = setupProxy((p) => { + p.client + .onRequest( + "specific", + (params) => params, + () => ({ via: "specific" }), + ) + .onRequest("*", ({ method, params, forward }) => { + wildcardSaw.push(method); + return forward(params); + }); + }); + Connection.builder() + .onReceiveRequest( + "other", + (params) => params, + (_request, responder) => responder.respond({ via: "agent" }), + ) + .connect(agentStream); + const clientEnd = new Connection(clientStream, []); + + await expect(clientEnd.sendRequest("specific", {})).resolves.toEqual({ + via: "specific", + }); + await expect(clientEnd.sendRequest("other", {})).resolves.toEqual({ + via: "agent", + }); + + expect(wildcardSaw).toEqual(["other"]); + }); + + it("rejects duplicate registrations for the same method", () => { + const p = proxy(); + p.client.onRequest( + "echo", + (params) => params, + async ({ params, forward }) => forward(params), + ); - await expect(received.promise).resolves.toEqual({ filtered: true }); + expect(() => + p.client.onRequest( + "echo", + (params) => params, + async ({ params, forward }) => forward(params), + ), + ).toThrow("already registered"); + }); + + it("intercepts everything through raw withHandler before typed handlers", async () => { + const rawSaw: string[] = []; + const { clientStream, agentStream } = setupProxy((p) => { + p.client + .withHandler({ + handleMessage(message) { + rawSaw.push(message.method); + return Handled.no(message); + }, + }) + .onRequest( + "claimed", + (params) => params, + () => ({ via: "typed" }), + ); + }); + Connection.builder() + .onReceiveRequest( + "unclaimed", + (params) => params, + (_request, responder) => responder.respond({ via: "agent" }), + ) + .connect(agentStream); + const clientEnd = new Connection(clientStream, []); + + await clientEnd.sendRequest("claimed", {}); + await clientEnd.sendRequest("unclaimed", {}); + + expect(rawSaw).toEqual(["claimed", "unclaimed"]); }); }); describe("proxy with fluent apps", () => { it("connects a client app to an agent app through the proxy", async () => { - const { clientStream, agentStream } = setupProxy(); + const promptsSeen: string[] = []; + const { clientStream, agentStream } = setupProxy((p) => { + p.client.onRequest( + "_test/echo", + (params) => params as { value: number }, + async ({ params, forward }) => { + promptsSeen.push(`echo:${params.value}`); + return forward(params); + }, + ); + }); agent({ name: "test-agent" }) .onRequest( "_test/echo", @@ -350,6 +463,7 @@ describe("proxy with fluent apps", () => { ); expect(response).toEqual({ doubled: 42 }); + expect(promptsSeen).toEqual(["echo:21"]); } finally { connection.close(); } diff --git a/src/proxy.ts b/src/proxy.ts index 7ef3f490..f85fead7 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,11 +1,232 @@ import { Connection, Handled, errorToResult, linkClosed } from "./jsonrpc.js"; -import type { HandleResult, JsonRpcHandler } from "./jsonrpc.js"; +import type { + HandleResult, + IncomingNotification, + IncomingRequest, + JsonRpcHandler, + MaybePromise, +} from "./jsonrpc.js"; import type { Stream } from "./stream.js"; +import type { + AgentNotificationParamsByMethod, + AgentRequestParamsByMethod, + AgentRequestResponsesByMethod, + ClientNotificationParamsByMethod, + ClientRequestParamsByMethod, + ClientRequestResponsesByMethod, + ParamsParser, +} from "./acp.js"; /** - * Options for {@link proxy}. + * Context passed to a typed proxy request handler. */ -export type ProxyOptions = { +export type ProxyRequestContext = { + /** + * Method name of the intercepted request. + */ + method: string; + /** + * Request params as received on the wire. + * + * Built-in method literals type the params for convenience, but the proxy + * does not validate or normalize them — schema validation happens at the + * destination app, and a proxy that re-parsed params would strip unknown + * extension fields from traffic it relays. Register with a parser when you + * want runtime validation. + */ + params: Params; + /** + * Aborts when the caller cancels this request or the connection closes. + */ + signal: AbortSignal; + /** + * Forwards the request to the other side and resolves with its response. + * + * Pass the received params to forward unchanged, or a modified copy to + * rewrite the request in flight. Cancellation by the original caller is + * propagated automatically. + */ + forward(params: Params): Promise; +}; + +/** + * Typed proxy request handler: return the response for the intercepted + * request — usually `forward(params)`'s result, possibly modified, or your + * own response without calling `forward` at all. Throw a `RequestError` to + * answer with a specific JSON-RPC error. + */ +export type ProxyRequestHandler = ( + context: ProxyRequestContext, +) => MaybePromise; + +/** + * Context passed to a typed proxy notification handler. + */ +export type ProxyNotificationContext = { + /** + * Method name of the intercepted notification. + */ + method: string; + /** + * Notification params as received on the wire (see + * {@link ProxyRequestContext.params} on validation). + */ + params: Params; + /** + * Forwards the notification to the other side. + */ + forward(params: Params): Promise; +}; + +/** + * Typed proxy notification handler: call `forward(params)` to deliver the + * notification (possibly rewritten); returning without forwarding drops it. + */ +export type ProxyNotificationHandler = ( + context: ProxyNotificationContext, +) => MaybePromise; + +type Registration = { + parse?: (params: unknown) => unknown; + handler: (context: never) => MaybePromise; +}; + +/** + * Registration surface for one side of a proxy. + * + * `proxy().client` registers handlers for traffic arriving from the client + * (agent-bound methods such as `session/prompt`); `proxy().agent` registers + * handlers for traffic arriving from the agent (client-bound methods such as + * `session/request_permission`). The type parameters select the matching + * ByMethod maps so built-in method literals infer their params and response + * types, mirroring `agent(...)`/`client(...)` registration. + * + * Handlers claim their method: at most one typed handler runs per message. + * Register `"*"` to catch traffic no exact registration claims + * (most-specific wins); anything still unclaimed is forwarded untouched. + */ +export class ProxySideBuilder< + RequestParams extends Record, + RequestResponses extends Record, + NotificationParams extends Record, +> { + private readonly rawHandlers: JsonRpcHandler[] = []; + private readonly requests = new Map(); + private readonly notifications = new Map(); + + /** @internal */ + constructor() {} + + /** + * Registers a typed handler for requests arriving from this side's peer. + * + * Built-in method literals infer their params and response types from + * `method`. Pass a parser as the second argument for custom extension + * methods or to opt into runtime validation. Register `"*"` to catch any + * request no exact registration claims. + */ + onRequest( + method: Method, + handler: ProxyRequestHandler< + RequestParams[Method], + Method extends keyof RequestResponses ? RequestResponses[Method] : never + >, + ): this; + onRequest(method: "*", handler: ProxyRequestHandler): this; + onRequest( + method: string, + params: ParamsParser, + handler: ProxyRequestHandler, + ): this; + onRequest( + method: string, + handlerOrParams: + ((context: never) => MaybePromise) | ParamsParser, + handler?: (context: never) => MaybePromise, + ): this { + this.register(this.requests, "request", method, handlerOrParams, handler); + return this; + } + + /** + * Registers a typed handler for notifications arriving from this side's + * peer. Same registration forms as `onRequest`. + */ + onNotification( + method: Method, + handler: ProxyNotificationHandler, + ): this; + onNotification(method: "*", handler: ProxyNotificationHandler): this; + onNotification( + method: string, + params: ParamsParser, + handler: ProxyNotificationHandler, + ): this; + onNotification( + method: string, + handlerOrParams: + ((context: never) => MaybePromise) | ParamsParser, + handler?: (context: never) => MaybePromise, + ): this { + this.register( + this.notifications, + "notification", + method, + handlerOrParams, + handler, + ); + return this; + } + + /** + * Adds a raw JSON-RPC handler that sees every message from this side's + * peer before typed handlers run. Raw handlers use `Handled` semantics: + * pass messages through (possibly replaced) with `Handled.no(message)` or + * consume them with `Handled.yes()`. + */ + withHandler(handler: JsonRpcHandler): this { + this.rawHandlers.push(handler); + return this; + } + + private register( + table: Map, + kind: "request" | "notification", + method: string, + handlerOrParams: object | ((context: never) => MaybePromise), + handler?: (context: never) => MaybePromise, + ): void { + if (table.has(method)) { + throw new Error(`Proxy ${kind} handler already registered: ${method}`); + } + + if (handler) { + const parser = handlerOrParams as ParamsParser; + const parse = + typeof parser === "function" ? parser : parser.parse.bind(parser); + table.set(method, { parse, handler }); + return; + } + + table.set(method, { + handler: handlerOrParams as (context: never) => MaybePromise, + }); + } + + /** @internal */ + buildChain(target: () => Connection): JsonRpcHandler[] { + return [ + ...this.rawHandlers, + typedDispatch(this.requests, this.notifications, target), + forwardTo(target), + ]; + } +} + +/** + * Streams accepted by `ProxyBuilder.connect(...)`. + */ +export type ProxyStreams = { /** * Stream connected to the client side. The proxy presents as an agent on * this stream. @@ -16,26 +237,10 @@ export type ProxyOptions = { * this stream. */ agent: Stream; - /** - * Handlers run on messages arriving from the client, in order, before the - * proxy forwards them to the agent. - * - * Return `Handled.no(message)` with a replacement message to rewrite a - * request or notification in flight. Return `Handled.yes()` after - * responding through `message.responder` to intercept a request without - * the agent ever seeing it (for example to deny it). Handlers that return - * `Handled.no()` without a replacement pass the message through untouched. - */ - fromClient?: JsonRpcHandler[]; - /** - * Handlers run on messages arriving from the agent, in order, before the - * proxy forwards them to the client. Same contract as `fromClient`. - */ - fromAgent?: JsonRpcHandler[]; }; /** - * Handle to a running proxy returned by {@link proxy}. + * Handle to a running proxy returned by `ProxyBuilder.connect(...)`. */ export type ProxyHandle = { /** @@ -57,18 +262,70 @@ export type ProxyHandle = { }; /** - * Runs an ACP proxy between a client stream and an agent stream. + * Builder for an ACP proxy between a client stream and an agent stream. + * + * Register handlers on `client` (traffic from the client) and `agent` + * (traffic from the agent), then call `connect(...)`. + */ +export class ProxyBuilder { + /** + * Registration surface for traffic arriving from the client — agent-bound + * methods, typed by the agent request/notification maps. + */ + readonly client = new ProxySideBuilder< + AgentRequestParamsByMethod, + AgentRequestResponsesByMethod, + AgentNotificationParamsByMethod + >(); + + /** + * Registration surface for traffic arriving from the agent — client-bound + * methods, typed by the client request/notification maps. + */ + readonly agent = new ProxySideBuilder< + ClientRequestParamsByMethod, + ClientRequestResponsesByMethod, + ClientNotificationParamsByMethod + >(); + + /** + * Connects the proxy between the two streams and starts relaying. + */ + connect(streams: ProxyStreams): ProxyHandle { + const client: Connection = new Connection(streams.client, [ + serialDispatch(this.client.buildChain(() => agent)), + ]); + const agent: Connection = new Connection(streams.agent, [ + serialDispatch(this.agent.buildChain(() => client)), + ]); + linkClosed(client, agent); + + return { + client, + agent, + closed: Promise.all([client.closed, agent.closed]).then(() => {}), + close(error?: unknown): void { + client.close(error); + agent.close(error); + }, + }; + } +} + +/** + * Creates an ACP proxy builder. * - * The proxy terminates the protocol on both sides: each side is a full - * JSON-RPC connection, so forwarded requests are re-issued with the proxy's - * own request ids and their responses are correlated back to the original - * caller automatically. This works in both directions — client-initiated - * requests such as `session/prompt` flow toward the agent, and - * agent-initiated requests such as `session/request_permission` flow toward - * the client. + * A proxy sits between a client and an agent, intercepting messages in both + * directions — this mirrors the Rust SDK's `Proxy` role. The proxy + * terminates the protocol on both sides: each side is a full JSON-RPC + * connection, so forwarded requests are re-issued with the proxy's own + * request ids and their responses are correlated back to the original + * caller automatically. Client-initiated requests such as `session/prompt` + * flow toward the agent, and agent-initiated requests such as + * `session/request_permission` flow toward the client. * - * Forwarding preserves the observable protocol behavior of a direct - * connection: + * Messages that no handler claims are forwarded untouched, preserving the + * observable protocol behavior of a direct connection: * * - Error responses pass through with their original code, message, and data. * - `$/cancel_request` from the original caller is propagated to the side @@ -79,42 +336,117 @@ export type ProxyHandle = { * * Each side processes messages one at a time in arrival order, matching the * Rust SDK's dispatch loop: the next message is not dispatched until the - * previous handler chain completes. A forwarded request releases the loop - * once it has been sent rather than holding it across the round trip (the - * Rust `forward_response_to` pattern), so a pending request never blocks - * later messages such as `session/cancel`. + * previous handler completes. Request handlers release the loop as soon as + * they forward (or settle without forwarding) rather than holding it across + * the round trip — the Rust `forward_response_to` pattern — so a pending + * request never blocks later messages such as `session/cancel`. * - * Messages can be observed, rewritten, answered, or dropped before they are - * forwarded by passing `fromClient` / `fromAgent` handlers; see - * {@link ProxyOptions}. + * The `_proxy/successor` envelope protocol used by the Rust conductor to + * chain proxy processes over a single pipe is not needed here — this proxy + * owns a real stream per side, so proxies chain by connecting one proxy's + * agent stream to the next proxy's client stream. * - * This mirrors the Rust SDK's `Proxy` role: a proxy sits between a client - * and an agent, intercepting messages in both directions; messages it does - * not handle are forwarded by default, and handlers intercept traffic from - * an explicit peer (`fromClient` / `fromAgent`, like the Rust builder's - * `on_receive_request_from(Client | Agent, ...)`). The - * `_proxy/successor` envelope protocol used by the Rust conductor to chain - * proxy processes over a single pipe is not needed here — this proxy owns a - * real stream per side, so proxies chain by connecting one proxy's agent - * stream to the next proxy's client stream. + * @example + * ```ts + * const p = proxy(); + * p.client.onRequest("session/prompt", async ({ params, forward }) => { + * audit(params); + * return forward(params); + * }); + * p.agent.onNotification("session/update", async ({ params, forward }) => { + * if (!redacted(params)) await forward(params); + * }); + * const handle = p.connect({ client: clientStream, agent: agentStream }); + * ``` */ -export function proxy(options: ProxyOptions): ProxyHandle { - const client: Connection = new Connection(options.client, [ - serialDispatch([...(options.fromClient ?? []), forwardTo(() => agent)]), - ]); - const agent: Connection = new Connection(options.agent, [ - serialDispatch([...(options.fromAgent ?? []), forwardTo(() => client)]), - ]); - linkClosed(client, agent); +export function proxy(): ProxyBuilder { + return new ProxyBuilder(); +} + +/** + * Chain handler dispatching typed registrations and the fallback. + * + * Unregistered traffic returns `Handled.no` so the terminal forwarder + * relays it untouched. Request handlers run detached from the dispatch + * queue once they forward: the returned promise resolves when the request + * has been forwarded or answered — not when the handler finishes waiting on + * the round trip — so the loop keeps its ordering guarantee without a + * pending request blocking later messages. + */ +function typedDispatch( + requests: Map, + notifications: Map, + target: () => Connection, +): JsonRpcHandler { + const runRequest = ( + message: IncomingRequest, + run: ( + context: ProxyRequestContext, + ) => MaybePromise, + parse?: (params: unknown) => unknown, + ): Promise => { + const released = Promise.withResolvers(); + void (async () => { + try { + const response = await run({ + method: message.method, + params: parse ? parse(message.params) : message.params, + signal: message.signal, + forward: (params: unknown) => { + const sent = target().sendRequest( + message.method, + params, + undefined, + { cancellationSignal: message.signal }, + ); + released.resolve(); + return sent; + }, + }); + await message.responder.respond(response ?? null); + } catch (error) { + await message.responder + .respondWithResult(errorToResult(error)) + .catch(() => {}); + } finally { + released.resolve(); + } + })(); + return released.promise.then(() => Handled.yes()); + }; + + const runNotification = async ( + message: IncomingNotification, + run: (context: ProxyNotificationContext) => MaybePromise, + parse?: (params: unknown) => unknown, + ): Promise => { + await run({ + method: message.method, + params: parse ? parse(message.params) : message.params, + forward: (params: unknown) => + target().sendNotification(message.method, params), + }); + return Handled.yes(); + }; return { - client, - agent, - closed: Promise.all([client.closed, agent.closed]).then(() => {}), - close(error?: unknown): void { - client.close(error); - agent.close(error); + handleMessage(message) { + const table = message.kind === "request" ? requests : notifications; + // Most-specific wins: an exact method registration beats "*", and "*" + // catches only otherwise-unclaimed traffic. + const registration = table.get(message.method) ?? table.get("*"); + if (!registration) { + return Handled.no(message); + } + + const run = registration.handler as ( + context: unknown, + ) => MaybePromise; + return message.kind === "request" + ? runRequest(message, run, registration.parse) + : runNotification(message, run, registration.parse); }, + describe: () => "proxy:typed-dispatch", }; } From 55b145badf6080bb8004cae8d7d085a6cd12d0b8 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 10:51:10 -0400 Subject: [PATCH 06/21] =?UTF-8?q?refactor:=20expose=20proxy()=20only=20?= =?UTF-8?q?=E2=80=94=20retract=20the=20raw=20peer=20layer=20from=20the=20p?= =?UTF-8?q?ublic=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The typed proxy surface (method literals, parser form, '*' fallback) covers every use case this PR set out to enable, so the raw JsonRpcHandler escape (withHandler), the Connection/ConnectionBuilder root exports, and the handlers option on fluent connect() come back out of the contract: each was semver load that a later PR can add if a real consumer needs it, and none can be removed once shipped. This also lands closer to the Rust SDK, which exposes typed registration with an untyped catch-all rather than a raw connection class. ProxyHandle sides narrow to closed/close so the Connection class stays internal. Co-Authored-By: Claude Fable 5 --- src/acp.ts | 91 ++++++++++--------------------------------- src/examples/proxy.ts | 30 ++++++++------ src/proxy.test.ts | 36 +++++++---------- src/proxy.ts | 39 ++++++++++--------- 4 files changed, 76 insertions(+), 120 deletions(-) diff --git a/src/acp.ts b/src/acp.ts index 23a468ae..1377cce0 100644 --- a/src/acp.ts +++ b/src/acp.ts @@ -52,48 +52,29 @@ export function ndJsonStream( return createJsonStream(output, input); } -export { proxy, ProxyBuilder, ProxySideBuilder } from "./proxy.js"; +export { proxy } from "./proxy.js"; +// ProxyBuilder and ProxySideBuilder are type-only on purpose: proxy() is the +// sole factory, so their constructors are not part of the public contract. export type { + ProxyBuilder, ProxyHandle, ProxyNotificationContext, ProxyNotificationHandler, ProxyRequestContext, ProxyRequestHandler, + ProxySideBuilder, + ProxySideConnection, ProxyStreams, } from "./proxy.js"; -// The lower-level JSON-RPC peer layer. `agent(...)` and `client(...)` are -// typed sugar over `Connection`; it is exported for integrations that need -// message-level dispatch — proxies, routers, and middleware that intercept -// traffic before a typed handler would see it. -export { - Connection, - ConnectionBuilder, - ConnectionContext, - Handled, - HandlerRegistration, - RequestError, - RequestResponder, - isJsonRpcMessage, - isNotificationMessage, - isRequestMessage, - isResponseMessage, -} from "./jsonrpc.js"; +export { RequestError } from "./jsonrpc.js"; export type { AnyMessage, AnyNotification, AnyRequest, AnyResponse, - ConnectionOptions, ErrorResponse, - HandleResult, - IncomingMessage, - IncomingNotification, - IncomingRequest, - JsonRpcHandler, JsonRpcId, MaybePromise, - NotificationCallback, - RequestCallback, Result, SendRequestOptions, } from "./jsonrpc.js"; @@ -1850,22 +1831,8 @@ const runAgentConnectHandlers = Symbol("runAgentConnectHandlers"); const runClientConnectHandlers = Symbol("runClientConnectHandlers"); const stableConnectionOptions: ConnectionOptions = { allowBatches: false }; -/** - * Options accepted by `AgentApp.connect(...)` and `ClientApp.connect(...)`. - */ -export type AppConnectOptions = { - /** @internal */ +type AppConnectOptions = { readonly deferConnectHandlers?: boolean; - /** - * Raw JSON-RPC handlers to run before this app's typed handlers. - * - * Handlers see every incoming request and notification and can observe - * them, rewrite them with `Handled.no(message)`, or consume them with - * `Handled.yes()` before a typed handler runs. Use this for - * connection-level middleware — logging, authorization, or parameter - * rewriting — without changing the app's registered handlers. - */ - readonly handlers?: JsonRpcHandler[]; }; type AgentConnectionState = { @@ -1919,9 +1886,9 @@ export class AgentApp { /** * Connects this agent app to a transport stream. */ - connect(stream: Stream, options?: AppConnectOptions): AgentConnection; + connect(stream: Stream): AgentConnection; /** @internal */ - connect(stream: WireStream, options?: AppConnectOptions): AgentConnection; + connect(stream: WireStream, options: AppConnectOptions): AgentConnection; /** * Connects this agent app directly to a client app. * @@ -2094,7 +2061,7 @@ export class AgentApp { options: AppConnectOptions = {}, ): AgentConnectionState { if (isStream(target)) { - const state = this.openStreamConnection(target, options.handlers); + const state = this.openStreamConnection(target); if (!options.deferConnectHandlers) { this[runAgentConnectHandlers](state.connection); } @@ -2107,7 +2074,7 @@ export class AgentApp { stableConnectionOptions, ); const peerConnection = clientConnection(peerRawConnection); - const state = this.openStreamConnection(thisStream, options.handlers); + const state = this.openStreamConnection(thisStream); linkClosed(state.rawConnection, peerRawConnection); try { target[runClientConnectHandlers](peerConnection); @@ -2120,14 +2087,8 @@ export class AgentApp { return state; } - private openStreamConnection( - stream: WireStream, - handlers?: JsonRpcHandler[], - ): AgentConnectionState { - const rawConnection = this.builder.connect(stream, { - ...stableConnectionOptions, - handlers, - }); + private openStreamConnection(stream: WireStream): AgentConnectionState { + const rawConnection = this.builder.connect(stream, stableConnectionOptions); return { rawConnection, connection: agentConnection(rawConnection, this.connectHandlers), @@ -2181,7 +2142,7 @@ export class ClientApp { /** * Connects this client app to a transport stream. */ - connect(stream: Stream, options?: AppConnectOptions): ClientConnection; + connect(stream: Stream): ClientConnection; /** * Connects this client app directly to an agent app. * @@ -2189,11 +2150,8 @@ export class ClientApp { * transport. */ connect(agent: AgentApp): ClientConnection; - connect( - target: WireStream | AgentApp, - options: AppConnectOptions = {}, - ): ClientConnection { - return this.connectConnection(target, options).connection; + connect(target: WireStream | AgentApp): ClientConnection { + return this.connectConnection(target).connection; } /** @@ -2351,10 +2309,9 @@ export class ClientApp { private connectConnection( target: WireStream | AgentApp, - options: AppConnectOptions = {}, ): ClientConnectionState { if (isStream(target)) { - const state = this.openStreamConnection(target, options.handlers); + const state = this.openStreamConnection(target); this[runClientConnectHandlers](state.connection); return state; } @@ -2365,7 +2322,7 @@ export class ClientApp { stableConnectionOptions, ); const peerConnection = agentConnection(peerRawConnection); - const state = this.openStreamConnection(thisStream, options.handlers); + const state = this.openStreamConnection(thisStream); linkClosed(state.rawConnection, peerRawConnection); try { target[runAgentConnectHandlers](peerConnection); @@ -2378,14 +2335,8 @@ export class ClientApp { return state; } - private openStreamConnection( - stream: WireStream, - handlers?: JsonRpcHandler[], - ): ClientConnectionState { - const rawConnection = this.builder.connect(stream, { - ...stableConnectionOptions, - handlers, - }); + private openStreamConnection(stream: WireStream): ClientConnectionState { + const rawConnection = this.builder.connect(stream, stableConnectionOptions); return { rawConnection, connection: clientConnection(rawConnection, this.connectHandlers), diff --git a/src/examples/proxy.ts b/src/examples/proxy.ts index 18046fcc..77278a45 100644 --- a/src/examples/proxy.ts +++ b/src/examples/proxy.ts @@ -16,14 +16,21 @@ import * as acp from "../acp.js"; // Usage: proxy.ts [command args...] // Runs the example agent from this directory when no command is given. -/** Logs each request and notification crossing the proxy, then passes it on. */ -function snoop(direction: string): acp.JsonRpcHandler { - return { - handleMessage(message) { - console.error(`[proxy] ${direction} ${message.kind}: ${message.method}`); - return acp.Handled.no(message); - }, - }; +/** Registers wildcard handlers that log traffic from one peer, then forward. */ +function snoop< + R extends Record, + S extends Record, + N extends Record, +>(side: acp.ProxySideBuilder, direction: string): void { + side + .onRequest("*", ({ method, params, forward }) => { + console.error(`[proxy] ${direction} request: ${method}`); + return forward(params); + }) + .onNotification("*", async ({ method, params, forward }) => { + console.error(`[proxy] ${direction} notification: ${method}`); + await forward(params); + }); } // Spawn the wrapped agent: the command from argv, or the example agent. @@ -38,10 +45,11 @@ const agentProcess = spawn(command, args, { }); const p = acp.proxy(); -// Raw handlers see every message; typed interception is also available, e.g. +// "*" catches anything without an exact registration; typed interception is +// also available, e.g. // p.client.onRequest("session/prompt", async ({ params, forward }) => ...). -p.client.withHandler(snoop("client → agent")); -p.agent.withHandler(snoop("agent → client")); +snoop(p.client, "client → agent"); +snoop(p.agent, "agent → client"); const handle = p.connect({ client: acp.ndJsonStream( diff --git a/src/proxy.test.ts b/src/proxy.test.ts index 53c034a7..fc1bdcbf 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -213,7 +213,8 @@ describe("proxy forwarding", () => { handle.client.close(new Error("client side went away")); await handle.closed; - await expect(handle.agent.sendRequest("anything", {})).rejects.toThrow( + const agentSide = handle.agent as Connection; + await expect(agentSide.sendRequest("anything", {})).rejects.toThrow( "client side went away", ); }); @@ -402,35 +403,28 @@ describe("proxy typed registration", () => { ).toThrow("already registered"); }); - it("intercepts everything through raw withHandler before typed handlers", async () => { - const rawSaw: string[] = []; + it("routes unclaimed notifications to '*'", async () => { + const wildcardSaw: string[] = []; const { clientStream, agentStream } = setupProxy((p) => { - p.client - .withHandler({ - handleMessage(message) { - rawSaw.push(message.method); - return Handled.no(message); - }, - }) - .onRequest( - "claimed", - (params) => params, - () => ({ via: "typed" }), - ); + p.client.onNotification("*", async ({ method, params, forward }) => { + wildcardSaw.push(method); + await forward(params); + }); }); + const seen = Promise.withResolvers(); Connection.builder() - .onReceiveRequest( - "unclaimed", + .onReceiveNotification( + "log", (params) => params, - (_request, responder) => responder.respond({ via: "agent" }), + () => seen.resolve(), ) .connect(agentStream); const clientEnd = new Connection(clientStream, []); - await clientEnd.sendRequest("claimed", {}); - await clientEnd.sendRequest("unclaimed", {}); + await clientEnd.sendNotification("log", { line: "hi" }); + await seen.promise; - expect(rawSaw).toEqual(["claimed", "unclaimed"]); + expect(wildcardSaw).toEqual(["log"]); }); }); diff --git a/src/proxy.ts b/src/proxy.ts index f85fead7..b5394d9c 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -110,7 +110,6 @@ export class ProxySideBuilder< RequestResponses extends Record, NotificationParams extends Record, > { - private readonly rawHandlers: JsonRpcHandler[] = []; private readonly requests = new Map(); private readonly notifications = new Map(); @@ -178,17 +177,6 @@ export class ProxySideBuilder< return this; } - /** - * Adds a raw JSON-RPC handler that sees every message from this side's - * peer before typed handlers run. Raw handlers use `Handled` semantics: - * pass messages through (possibly replaced) with `Handled.no(message)` or - * consume them with `Handled.yes()`. - */ - withHandler(handler: JsonRpcHandler): this { - this.rawHandlers.push(handler); - return this; - } - private register( table: Map, kind: "request" | "notification", @@ -216,7 +204,6 @@ export class ProxySideBuilder< /** @internal */ buildChain(target: () => Connection): JsonRpcHandler[] { return [ - ...this.rawHandlers, typedDispatch(this.requests, this.notifications, target), forwardTo(target), ]; @@ -239,20 +226,36 @@ export type ProxyStreams = { agent: Stream; }; +/** + * One side of a running proxy. + */ +export type ProxySideConnection = { + /** + * Promise that resolves when this side's connection closes. + */ + readonly closed: Promise; + /** + * Closes this side. The other side is closed with the same reason. + */ + close(error?: unknown): void; +}; + /** * Handle to a running proxy returned by `ProxyBuilder.connect(...)`. */ export type ProxyHandle = { /** - * Connection facing the client stream. Requests sent here go to the client. + * The side facing the client stream. */ - readonly client: Connection; + readonly client: ProxySideConnection; /** - * Connection facing the agent stream. Requests sent here go to the agent. + * The side facing the agent stream. */ - readonly agent: Connection; + readonly agent: ProxySideConnection; /** - * Promise that resolves once both sides of the proxy have closed. + * Promise that resolves once both sides of the proxy have closed. Either + * side closing (for example the client stream ending) closes the other, + * propagating the close reason to its pending requests. */ readonly closed: Promise; /** From c927842cfe9252a44d2b04a2d631fc0c62a4c1ba Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 10:56:00 -0400 Subject: [PATCH 07/21] docs: spell out the request vs notification handler contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one-line JSDoc summaries left the key asymmetry implicit: requests must always be answered (the caller is blocked waiting), while notifications are fire-and-forget so skipping forward() silently drops them with no signal to either side — which is the intentional filtering mechanism, not an error. Co-Authored-By: Claude Fable 5 --- src/proxy.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/proxy.ts b/src/proxy.ts index b5394d9c..9fdf5259 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -50,10 +50,13 @@ export type ProxyRequestContext = { }; /** - * Typed proxy request handler: return the response for the intercepted - * request — usually `forward(params)`'s result, possibly modified, or your - * own response without calling `forward` at all. Throw a `RequestError` to - * answer with a specific JSON-RPC error. + * Typed proxy request handler. + * + * The caller is waiting on a response, so the handler must produce one: + * return `forward(params)`'s result (unchanged or modified) to relay the + * request, return your own response without calling `forward` to answer in + * the proxy, or throw a `RequestError` to reject with a specific JSON-RPC + * error. Unlike notifications, a request can never be silently dropped. */ export type ProxyRequestHandler = ( context: ProxyRequestContext, @@ -79,8 +82,13 @@ export type ProxyNotificationContext = { }; /** - * Typed proxy notification handler: call `forward(params)` to deliver the - * notification (possibly rewritten); returning without forwarding drops it. + * Typed proxy notification handler. + * + * Call `forward(params)` to deliver the notification to the other side, + * unchanged or rewritten. Returning without calling `forward` drops the + * notification: it is never delivered, and — as with any JSON-RPC + * notification — neither side is told, which makes skipping `forward` the + * intentional way to filter traffic. */ export type ProxyNotificationHandler = ( context: ProxyNotificationContext, From 79bfe2a425d227ec62d5efc7e1bc04d27c753ca2 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 11:02:19 -0400 Subject: [PATCH 08/21] feat: document and pin live append-only registration after connect Registration after connect already worked because dispatch reads the builder's maps per message, but as accidental behavior it could be broken by a refactor. It is genuinely useful (interceptors that depend on values learned mid-session), so make it contract: live, append-only, shared across every stream pair the builder is connected to. Co-Authored-By: Claude Fable 5 --- src/proxy.test.ts | 39 +++++++++++++++++++++++++++++++++++---- src/proxy.ts | 6 ++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/proxy.test.ts b/src/proxy.test.ts index fc1bdcbf..6010fd34 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -10,16 +10,20 @@ import type { Stream } from "./stream.js"; type ProxySetup = { clientStream: Stream; agentStream: Stream; + builder: ProxyBuilder; handle: ProxyHandle; }; function setupProxy(configure?: (p: ProxyBuilder) => void): ProxySetup { const [clientStream, proxyClientSide] = inMemoryStreamPair(); const [proxyAgentSide, agentStream] = inMemoryStreamPair(); - const p = proxy(); - configure?.(p); - const handle = p.connect({ client: proxyClientSide, agent: proxyAgentSide }); - return { clientStream, agentStream, handle }; + const builder = proxy(); + configure?.(builder); + const handle = builder.connect({ + client: proxyClientSide, + agent: proxyAgentSide, + }); + return { clientStream, agentStream, builder, handle }; } describe("proxy forwarding", () => { @@ -386,6 +390,33 @@ describe("proxy typed registration", () => { expect(wildcardSaw).toEqual(["other"]); }); + it("applies handlers registered after connect to subsequent messages", async () => { + const { clientStream, agentStream, builder } = setupProxy(); + Connection.builder() + .onReceiveRequest( + "echo", + (params) => params, + (request, responder) => responder.respond({ echoed: request }), + ) + .connect(agentStream); + const clientEnd = new Connection(clientStream, []); + + // Before registration the request forwards untouched. + await expect(clientEnd.sendRequest("echo", { n: 1 })).resolves.toEqual({ + echoed: { n: 1 }, + }); + + builder.client.onRequest( + "echo", + (params) => params as Record, + async ({ forward }) => forward({ late: true }), + ); + + await expect(clientEnd.sendRequest("echo", { n: 2 })).resolves.toEqual({ + echoed: { late: true }, + }); + }); + it("rejects duplicate registrations for the same method", () => { const p = proxy(); p.client.onRequest( diff --git a/src/proxy.ts b/src/proxy.ts index 9fdf5259..5a872d2f 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -112,6 +112,12 @@ type Registration = { * Handlers claim their method: at most one typed handler runs per message. * Register `"*"` to catch traffic no exact registration claims * (most-specific wins); anything still unclaimed is forwarded untouched. + * + * Registration is live and append-only: handlers may be added after + * `connect(...)` and apply to messages dispatched from then on (useful for + * interceptors that depend on values learned mid-session), registering a + * method twice throws, and a builder connected to multiple stream pairs + * shares its registrations across all of them. */ export class ProxySideBuilder< RequestParams extends Record, From fae559ec4a4057ec92d0f0eed1e98cf63fbb426d Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 11:13:05 -0400 Subject: [PATCH 09/21] refactor: snapshot registrations at connect, matching the fluent builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinning live post-connect registration was wrong twice over: it mutated every connection sharing the builder, and honest dynamism would demand de-registration handles too. Sealing was wrong differently — the fluent agent()/client() builders don't throw; their connect() copies the handler list, so late registrations silently apply to future connections only. The proxy now has exactly those semantics: connect(...) snapshots the registration maps, so a live proxy's handlers are fixed (the guarantee Rust gets from its consumed builder) without diverging from how every other builder in this package behaves. Co-Authored-By: Claude Fable 5 --- src/proxy.test.ts | 13 +++++-------- src/proxy.ts | 18 ++++++++++++------ 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/proxy.test.ts b/src/proxy.test.ts index 6010fd34..d51b1e91 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -390,7 +390,7 @@ describe("proxy typed registration", () => { expect(wildcardSaw).toEqual(["other"]); }); - it("applies handlers registered after connect to subsequent messages", async () => { + it("snapshots registrations at connect", async () => { const { clientStream, agentStream, builder } = setupProxy(); Connection.builder() .onReceiveRequest( @@ -401,19 +401,16 @@ describe("proxy typed registration", () => { .connect(agentStream); const clientEnd = new Connection(clientStream, []); - // Before registration the request forwards untouched. - await expect(clientEnd.sendRequest("echo", { n: 1 })).resolves.toEqual({ - echoed: { n: 1 }, - }); - + // Registering after connect is allowed but must not affect the + // already-connected proxy — same semantics as the fluent app builders. builder.client.onRequest( "echo", (params) => params as Record, async ({ forward }) => forward({ late: true }), ); - await expect(clientEnd.sendRequest("echo", { n: 2 })).resolves.toEqual({ - echoed: { late: true }, + await expect(clientEnd.sendRequest("echo", { n: 1 })).resolves.toEqual({ + echoed: { n: 1 }, }); }); diff --git a/src/proxy.ts b/src/proxy.ts index 5a872d2f..6f94ee20 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -113,11 +113,11 @@ type Registration = { * Register `"*"` to catch traffic no exact registration claims * (most-specific wins); anything still unclaimed is forwarded untouched. * - * Registration is live and append-only: handlers may be added after - * `connect(...)` and apply to messages dispatched from then on (useful for - * interceptors that depend on values learned mid-session), registering a - * method twice throws, and a builder connected to multiple stream pairs - * shares its registrations across all of them. + * Each `connect(...)` snapshots the registrations, exactly like the + * `agent(...)`/`client(...)` builders: registering afterwards is allowed but + * applies only to subsequent connects, never to already-connected proxies. + * For behavior that changes mid-session, keep the changing state in your + * handler's closure instead of changing the registrations. */ export class ProxySideBuilder< RequestParams extends Record, @@ -217,8 +217,14 @@ export class ProxySideBuilder< /** @internal */ buildChain(target: () => Connection): JsonRpcHandler[] { + // Snapshot so registrations made after connect(...) apply only to + // subsequent connects — the same semantics as the fluent app builders. return [ - typedDispatch(this.requests, this.notifications, target), + typedDispatch( + new Map(this.requests), + new Map(this.notifications), + target, + ), forwardTo(target), ]; } From 22b690a177831a2f5ef5d99c74a3500fe8f274d0 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 11:32:03 -0400 Subject: [PATCH 10/21] refactor: collapse proxy dispatch to one handler with synchronous fast paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings from the second /simplify pass: - The chain protocol (typedDispatch + forwardTo + serialDispatch's loop) was dead generality once the chain became fixed at two entries; dispatch is now a single function where unclaimed traffic takes a fully synchronous pass-through (send enqueued in arrival order, loop never held, no queue engagement) and the serializer bypasses its queue whenever dispatch completes synchronously. - Detached catches now respond via RequestResponder.respondWithThrown, the same abort-aware mapping Connection's own catch uses, restoring -32800 request-cancelled parity that the plain errorToResult copies downgraded to internal errors; errorToResult goes back to being private. - ParamsParser and parseParams move to jsonrpc.ts so the proxy shares the apps' parser normalization instead of re-encoding it (and without an acp<->proxy runtime import cycle). - New ProxyTap type: the wildcard-only view of a side, so side-agnostic taps (the headline logger/auth use case) don't need ProxySideBuilder's three-generic incantation — the example's snoop drops its type params. - The reference test no longer casts ProxySideConnection back to Connection; close-reason propagation is asserted at its own layer with a linkClosed unit test, and jsonrpc.test.ts drops its private copy of the now-exported stream pair helper. Co-Authored-By: Claude Fable 5 --- src/acp.ts | 27 +--- src/examples/proxy.ts | 6 +- src/jsonrpc.test.ts | 18 +++ src/jsonrpc.ts | 55 ++++++-- src/proxy.test.ts | 4 - src/proxy.ts | 285 ++++++++++++++++++++++-------------------- 6 files changed, 220 insertions(+), 175 deletions(-) diff --git a/src/acp.ts b/src/acp.ts index 1377cce0..b879ade2 100644 --- a/src/acp.ts +++ b/src/acp.ts @@ -65,6 +65,7 @@ export type { ProxySideBuilder, ProxySideConnection, ProxyStreams, + ProxyTap, } from "./proxy.js"; export { RequestError } from "./jsonrpc.js"; export type { @@ -75,6 +76,7 @@ export type { ErrorResponse, JsonRpcId, MaybePromise, + ParamsParser, Result, SendRequestOptions, } from "./jsonrpc.js"; @@ -85,6 +87,7 @@ import { Handled, HandlerRegistration, linkClosed, + parseParams, } from "./jsonrpc.js"; import type { ConnectionBuilder, @@ -93,6 +96,7 @@ import type { HandleResult, IncomingMessage, JsonRpcId, + ParamsParser, JsonRpcHandler, MaybePromise, SendRequestOptions, @@ -968,14 +972,6 @@ export type AppOptions = { * A Zod schema can be passed directly because schemas expose a compatible * `parse(...)` method. */ -export type ParamsParser = - | { - /** - * Parses raw JSON-RPC params into the handler's typed params. - */ - parse: (params: unknown) => Params; - } - | ((params: unknown) => Params); /** * Common context passed to agent-side handlers. @@ -1091,21 +1087,6 @@ export type ClientConnectHandler = ( connection: ClientConnection, ) => MaybePromise; -function parseParams( - parser: ParamsParser | undefined, - params: unknown, -): Params { - if (!parser) { - return params as Params; - } - - if (typeof parser === "function") { - return parser(params); - } - - return parser.parse(params); -} - type AcpRequestSpec = { method: string; params?: ParamsParser; diff --git a/src/examples/proxy.ts b/src/examples/proxy.ts index 77278a45..5014e19b 100644 --- a/src/examples/proxy.ts +++ b/src/examples/proxy.ts @@ -17,11 +17,7 @@ import * as acp from "../acp.js"; // Runs the example agent from this directory when no command is given. /** Registers wildcard handlers that log traffic from one peer, then forward. */ -function snoop< - R extends Record, - S extends Record, - N extends Record, ->(side: acp.ProxySideBuilder, direction: string): void { +function snoop(side: acp.ProxyTap, direction: string): void { side .onRequest("*", ({ method, params, forward }) => { console.error(`[proxy] ${direction} request: ${method}`); diff --git a/src/jsonrpc.test.ts b/src/jsonrpc.test.ts index 0313a285..95c99fa1 100644 --- a/src/jsonrpc.test.ts +++ b/src/jsonrpc.test.ts @@ -8,6 +8,7 @@ import { isJsonRpcBatchMessage, isJsonRpcMessage, isJsonRpcWireMessage, + linkClosed, } from "./jsonrpc.js"; import type { AnyMessage, @@ -16,6 +17,7 @@ import type { RequestResponder, } from "./jsonrpc.js"; import type { Stream } from "./stream.js"; +import { inMemoryStreamPair } from "./acp.js"; type ConnectionInternals = { pendingResponses: Map; @@ -1152,3 +1154,19 @@ function memoryStreamPair(): [Stream, Stream] { }, ]; } + +describe("linkClosed", () => { + it("closes the paired connection with the same reason", async () => { + const [aStream] = inMemoryStreamPair(); + const [bStream] = inMemoryStreamPair(); + const a = new Connection(aStream, []); + const b = new Connection(bStream, []); + linkClosed(a, b); + + const pendingOnB = b.sendRequest("anything", {}); + a.close(new Error("a went away")); + + await expect(pendingOnB).rejects.toThrow("a went away"); + await b.closed; + }); +}); diff --git a/src/jsonrpc.ts b/src/jsonrpc.ts index 3f66b711..34b7dc90 100644 --- a/src/jsonrpc.ts +++ b/src/jsonrpc.ts @@ -432,6 +432,40 @@ type PreparedRequest = { */ export type MaybePromise = T | Promise; +/** + * Parser converting raw JSON-RPC params into a handler's typed params. + * + * Accepts either a plain function or an object with a `parse` method (such + * as a Zod schema). + */ +export type ParamsParser = + | { + /** + * Parses raw JSON-RPC params into the handler's typed params. + */ + parse: (params: unknown) => Params; + } + | ((params: unknown) => Params); + +/** + * Runs a {@link ParamsParser} over raw params; without a parser the params + * pass through untouched. + */ +export function parseParams( + parser: ParamsParser | undefined, + params: unknown, +): Params { + if (!parser) { + return params as Params; + } + + if (typeof parser === "function") { + return parser(params); + } + + return parser.parse(params); +} + /** * Incoming request passed to JSON-RPC handlers. */ @@ -605,12 +639,7 @@ function isZodError(error: unknown): error is { format(): unknown } { ); } -/** - * Maps a thrown error to a JSON-RPC result payload: `RequestError` keeps its - * code/message/data, Zod errors become invalid-params, anything else becomes - * a generic internal error. - */ -export function errorToResult(error: unknown): Result { +function errorToResult(error: unknown): Result { if (error instanceof RequestError) { return error.toResult(); } @@ -714,6 +743,16 @@ export class RequestResponder { return this.respondWithResult({ error: errorResponse }); } + /** + * Sends the JSON-RPC response for an error thrown while handling the + * request: `RequestError` keeps its code/message/data, an abort after + * cancellation maps to request-cancelled, anything else becomes a generic + * internal error. + */ + respondWithThrown(error: unknown): Promise { + return this.respondWithResult(errorToRequestResult(error, this.signal)); + } + /** * Sends a complete JSON-RPC result payload. */ @@ -1424,9 +1463,7 @@ export class Connection { } if (current.kind === "request" && !current.responder.responded) { - await current.responder.respondWithResult( - errorToRequestResult(error, current.responder.signal), - ); + await current.responder.respondWithThrown(error); } else { const response = errorToResult(error); if ("error" in response) { diff --git a/src/proxy.test.ts b/src/proxy.test.ts index d51b1e91..26607fed 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -217,10 +217,6 @@ describe("proxy forwarding", () => { handle.client.close(new Error("client side went away")); await handle.closed; - const agentSide = handle.agent as Connection; - await expect(agentSide.sendRequest("anything", {})).rejects.toThrow( - "client side went away", - ); }); it("closes both sides through the handle", async () => { diff --git a/src/proxy.ts b/src/proxy.ts index 6f94ee20..9832621d 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,10 +1,12 @@ -import { Connection, Handled, errorToResult, linkClosed } from "./jsonrpc.js"; +import { Connection, Handled, linkClosed, parseParams } from "./jsonrpc.js"; import type { HandleResult, + IncomingMessage, IncomingNotification, IncomingRequest, JsonRpcHandler, MaybePromise, + ParamsParser, } from "./jsonrpc.js"; import type { Stream } from "./stream.js"; import type { @@ -14,7 +16,6 @@ import type { ClientNotificationParamsByMethod, ClientRequestParamsByMethod, ClientRequestResponsesByMethod, - ParamsParser, } from "./acp.js"; /** @@ -94,9 +95,15 @@ export type ProxyNotificationHandler = ( context: ProxyNotificationContext, ) => MaybePromise; +/** + * Handler with its context type erased for storage; dispatch restores the + * matching context shape per registration kind. + */ +type ErasedHandler = (context: never) => MaybePromise; + type Registration = { - parse?: (params: unknown) => unknown; - handler: (context: never) => MaybePromise; + params?: ParamsParser; + handler: ErasedHandler; }; /** @@ -127,9 +134,6 @@ export class ProxySideBuilder< private readonly requests = new Map(); private readonly notifications = new Map(); - /** @internal */ - constructor() {} - /** * Registers a typed handler for requests arriving from this side's peer. * @@ -153,9 +157,8 @@ export class ProxySideBuilder< ): this; onRequest( method: string, - handlerOrParams: - ((context: never) => MaybePromise) | ParamsParser, - handler?: (context: never) => MaybePromise, + handlerOrParams: ErasedHandler | ParamsParser, + handler?: ErasedHandler, ): this { this.register(this.requests, "request", method, handlerOrParams, handler); return this; @@ -177,9 +180,8 @@ export class ProxySideBuilder< ): this; onNotification( method: string, - handlerOrParams: - ((context: never) => MaybePromise) | ParamsParser, - handler?: (context: never) => MaybePromise, + handlerOrParams: ErasedHandler | ParamsParser, + handler?: ErasedHandler, ): this { this.register( this.notifications, @@ -195,41 +197,50 @@ export class ProxySideBuilder< table: Map, kind: "request" | "notification", method: string, - handlerOrParams: object | ((context: never) => MaybePromise), - handler?: (context: never) => MaybePromise, + handlerOrParams: ErasedHandler | ParamsParser, + handler?: ErasedHandler, ): void { if (table.has(method)) { throw new Error(`Proxy ${kind} handler already registered: ${method}`); } if (handler) { - const parser = handlerOrParams as ParamsParser; - const parse = - typeof parser === "function" ? parser : parser.parse.bind(parser); - table.set(method, { parse, handler }); + table.set(method, { + params: handlerOrParams as ParamsParser, + handler, + }); return; } - table.set(method, { - handler: handlerOrParams as (context: never) => MaybePromise, - }); + table.set(method, { handler: handlerOrParams as ErasedHandler }); } /** @internal */ - buildChain(target: () => Connection): JsonRpcHandler[] { + buildHandler(target: () => Connection): JsonRpcHandler { // Snapshot so registrations made after connect(...) apply only to // subsequent connects — the same semantics as the fluent app builders. - return [ - typedDispatch( - new Map(this.requests), - new Map(this.notifications), - target, - ), - forwardTo(target), - ]; + return serialize( + dispatcher(new Map(this.requests), new Map(this.notifications), target), + ); } } +/** + * The wildcard-only view of a proxy side — what a side-agnostic tap + * (logger, metrics, authorization gate) needs. Both `proxy().client` and + * `proxy().agent` are assignable to it. + */ +export type ProxyTap = { + onRequest( + method: "*", + handler: ProxyRequestHandler, + ): ProxyTap; + onNotification( + method: "*", + handler: ProxyNotificationHandler, + ): ProxyTap; +}; + /** * Streams accepted by `ProxyBuilder.connect(...)`. */ @@ -316,10 +327,10 @@ export class ProxyBuilder { */ connect(streams: ProxyStreams): ProxyHandle { const client: Connection = new Connection(streams.client, [ - serialDispatch(this.client.buildChain(() => agent)), + this.client.buildHandler(() => agent), ]); const agent: Connection = new Connection(streams.agent, [ - serialDispatch(this.agent.buildChain(() => client)), + this.agent.buildHandler(() => client), ]); linkClosed(client, agent); @@ -387,33 +398,44 @@ export function proxy(): ProxyBuilder { } /** - * Chain handler dispatching typed registrations and the fallback. + * Builds the dispatch function for one proxy side. + * + * Each message runs the most specific registration — exact method, then + * `"*"` — or, unclaimed, is forwarded untouched on a fully synchronous fast + * path (the send is enqueued in arrival order and the loop is never held). * - * Unregistered traffic returns `Handled.no` so the terminal forwarder - * relays it untouched. Request handlers run detached from the dispatch - * queue once they forward: the returned promise resolves when the request - * has been forwarded or answered — not when the handler finishes waiting on - * the round trip — so the loop keeps its ordering guarantee without a - * pending request blocking later messages. + * Request registrations run detached from the dispatch loop once they + * forward or settle: the loop resumes when the request has been sent (or + * answered), not when the handler finishes waiting on the round trip, so a + * pending request never blocks later messages. Notification registrations + * hold the loop until the handler settles, which is what preserves + * delivery ordering across async handlers. */ -function typedDispatch( +function dispatcher( requests: Map, notifications: Map, target: () => Connection, -): JsonRpcHandler { +): (message: IncomingMessage) => MaybePromise { const runRequest = ( message: IncomingRequest, - run: ( + registration: Registration, + ): MaybePromise => { + const { responder } = message; + let released = false; + let resolveReleased: (() => void) | undefined; + const release = () => { + released = true; + resolveReleased?.(); + }; + const run = registration.handler as ( context: ProxyRequestContext, - ) => MaybePromise, - parse?: (params: unknown) => unknown, - ): Promise => { - const released = Promise.withResolvers(); + ) => MaybePromise; + void (async () => { try { const response = await run({ method: message.method, - params: parse ? parse(message.params) : message.params, + params: parseParams(registration.params, message.params), signal: message.signal, forward: (params: unknown) => { const sent = target().sendRequest( @@ -422,125 +444,120 @@ function typedDispatch( undefined, { cancellationSignal: message.signal }, ); - released.resolve(); + release(); return sent; }, }); - await message.responder.respond(response ?? null); + await responder.respond(response ?? null); } catch (error) { - await message.responder - .respondWithResult(errorToResult(error)) - .catch(() => {}); + await responder.respondWithThrown(error).catch(() => {}); } finally { - released.resolve(); + release(); } })(); - return released.promise.then(() => Handled.yes()); + + // A handler that forwards before its first await has already released; + // skip the promise entirely so the loop continues on the same tick. + if (released) { + return Handled.yes(); + } + + return new Promise((resolve) => { + resolveReleased = () => resolve(Handled.yes()); + }); }; const runNotification = async ( message: IncomingNotification, - run: (context: ProxyNotificationContext) => MaybePromise, - parse?: (params: unknown) => unknown, + registration: Registration, ): Promise => { + const run = registration.handler as ( + context: ProxyNotificationContext, + ) => MaybePromise; await run({ method: message.method, - params: parse ? parse(message.params) : message.params, + params: parseParams(registration.params, message.params), forward: (params: unknown) => target().sendNotification(message.method, params), }); return Handled.yes(); }; - return { - handleMessage(message) { - const table = message.kind === "request" ? requests : notifications; - // Most-specific wins: an exact method registration beats "*", and "*" - // catches only otherwise-unclaimed traffic. - const registration = table.get(message.method) ?? table.get("*"); - if (!registration) { - return Handled.no(message); + return (message) => { + const table = message.kind === "request" ? requests : notifications; + // Most-specific wins: an exact method registration beats "*", and "*" + // catches only otherwise-unclaimed traffic. + const registration = table.get(message.method) ?? table.get("*"); + + if (!registration) { + // Pass-through: the send is enqueued synchronously (so send order is + // arrival order) and the loop is never held. + if (message.kind === "request") { + const { responder } = message; + target() + .sendRequest(message.method, message.params, undefined, { + cancellationSignal: message.signal, + }) + .then( + (result) => responder.respond(result), + (error) => responder.respondWithThrown(error), + ) + // The response cannot be delivered when the caller's side is + // already closed; there is nowhere left to report it. + .catch(() => {}); + } else { + // Write failures close the connection via the write queue; there is + // no per-notification error to report. + void target() + .sendNotification(message.method, message.params) + .catch(() => {}); } - const run = registration.handler as ( - context: unknown, - ) => MaybePromise; - return message.kind === "request" - ? runRequest(message, run, registration.parse) - : runNotification(message, run, registration.parse); - }, - describe: () => "proxy:typed-dispatch", + return Handled.yes(); + } + + return message.kind === "request" + ? runRequest(message, registration) + : runNotification(message, registration); }; } /** - * Runs one side's handler chain one message at a time, in arrival order. - * - * The underlying `Connection` dispatches each incoming message as its own - * async task, which would let chains with slow handlers overtake each other. - * The Rust SDK guarantees sequential dispatch, so the proxy provides the - * same: the next message is not dispatched until the previous chain settles. - * A handler that throws fails only its own message (the connection converts - * the error to a response or log line); the queue continues. + * Serializes one side's dispatch: messages run one at a time, in arrival + * order, matching the Rust SDK's dispatch loop. The underlying `Connection` + * dispatches each message as its own async task, which would let slow + * handlers be overtaken. Synchronous dispatches (pass-through traffic, + * handlers that forward immediately) bypass the queue entirely; a rejected + * dispatch fails only its own message. */ -function serialDispatch(handlers: JsonRpcHandler[]): JsonRpcHandler { - let queue: Promise = Promise.resolve(); - return { - handleMessage(message, cx) { - const result = queue.then(async (): Promise => { - let current = message; - for (const handler of handlers) { - const outcome = - (await handler.handleMessage(current, cx)) ?? Handled.yes(); - if (outcome.handled) { - return Handled.yes(); - } - current = outcome.message ?? current; - } - - // Unreachable: the forwarder at the end of the chain always handles. - return Handled.yes(); - }); - queue = result.catch(() => {}); - return result; - }, - describe: () => "proxy:serial-dispatch", +function serialize( + dispatch: (message: IncomingMessage) => MaybePromise, +): JsonRpcHandler { + let pending = 0; + let tail: Promise = Promise.resolve(); + + const track = (result: Promise): Promise => { + pending++; + tail = result.then( + () => { + pending--; + }, + () => { + pending--; + }, + ); + return result; }; -} -/** - * Terminal handler for one proxy side: re-issues the incoming message on the - * opposite connection and relays the response back. The two connections - * reference each other, so the target is resolved lazily. - * - * Requests are forwarded split-phase: the outgoing request is sent - * synchronously (preserving send order), the response continuation is - * registered, and the dispatch queue is released immediately so the round - * trip never blocks later messages. - */ -function forwardTo(target: () => Connection): JsonRpcHandler { return { handleMessage(message) { - if (message.kind !== "request") { - return target() - .sendNotification(message.method, message.params) - .then(() => Handled.yes()); + if (pending === 0) { + const result = dispatch(message); + return result instanceof Promise ? track(result) : result; } - const { responder } = message; - target() - .sendRequest(message.method, message.params, undefined, { - cancellationSignal: message.signal, - }) - .then( - (result) => responder.respond(result), - (error) => responder.respondWithResult(errorToResult(error)), - ) - // The response cannot be delivered when the caller's side is already - // closed; there is nowhere left to report it. - .catch(() => {}); - return Handled.yes(); + return track(tail.then(() => dispatch(message))); }, - describe: () => "proxy:forward", + describe: () => "proxy:dispatch", }; } From a7b6b8d43798c95402f4dcc73e05f84853d4d6dd Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 12:01:13 -0400 Subject: [PATCH 11/21] docs: describe proxy guarantees on their own terms A TS consumer needs the ordering and forwarding guarantees themselves, not which sibling SDK they were modeled on. One line noting parity with the Proxy role in ACP's other SDKs replaces the per-mechanism Rust citations; the cross-SDK design rationale stays in the PR discussion where it serves reviewers. Co-Authored-By: Claude Fable 5 --- src/proxy.ts | 39 ++++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/src/proxy.ts b/src/proxy.ts index 9832621d..82607e9c 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -350,13 +350,13 @@ export class ProxyBuilder { * Creates an ACP proxy builder. * * A proxy sits between a client and an agent, intercepting messages in both - * directions — this mirrors the Rust SDK's `Proxy` role. The proxy - * terminates the protocol on both sides: each side is a full JSON-RPC - * connection, so forwarded requests are re-issued with the proxy's own - * request ids and their responses are correlated back to the original - * caller automatically. Client-initiated requests such as `session/prompt` - * flow toward the agent, and agent-initiated requests such as - * `session/request_permission` flow toward the client. + * directions. The proxy terminates the protocol on both sides: each side is + * a full JSON-RPC connection, so forwarded requests are re-issued with the + * proxy's own request ids and their responses are correlated back to the + * original caller automatically. Client-initiated requests such as + * `session/prompt` flow toward the agent, and agent-initiated requests such + * as `session/request_permission` flow toward the client. The design + * matches the `Proxy` role in ACP's other SDKs. * * Messages that no handler claims are forwarded untouched, preserving the * observable protocol behavior of a direct connection: @@ -368,17 +368,14 @@ export class ProxyBuilder { * - When either side closes, the other side is closed and its pending * requests are rejected. * - * Each side processes messages one at a time in arrival order, matching the - * Rust SDK's dispatch loop: the next message is not dispatched until the - * previous handler completes. Request handlers release the loop as soon as - * they forward (or settle without forwarding) rather than holding it across - * the round trip — the Rust `forward_response_to` pattern — so a pending + * Each side processes messages one at a time in arrival order: the next + * message is not dispatched until the previous handler completes. Request + * handlers release the loop as soon as they forward (or settle without + * forwarding) rather than holding it across the round trip, so a pending * request never blocks later messages such as `session/cancel`. * - * The `_proxy/successor` envelope protocol used by the Rust conductor to - * chain proxy processes over a single pipe is not needed here — this proxy - * owns a real stream per side, so proxies chain by connecting one proxy's - * agent stream to the next proxy's client stream. + * Proxies chain by connecting one proxy's agent stream to the next proxy's + * client stream. * * @example * ```ts @@ -524,11 +521,11 @@ function dispatcher( /** * Serializes one side's dispatch: messages run one at a time, in arrival - * order, matching the Rust SDK's dispatch loop. The underlying `Connection` - * dispatches each message as its own async task, which would let slow - * handlers be overtaken. Synchronous dispatches (pass-through traffic, - * handlers that forward immediately) bypass the queue entirely; a rejected - * dispatch fails only its own message. + * order. The underlying `Connection` dispatches each message as its own + * async task, which would let slow handlers be overtaken. Synchronous + * dispatches (pass-through traffic, handlers that forward immediately) + * bypass the queue entirely; a rejected dispatch fails only its own + * message. */ function serialize( dispatch: (message: IncomingMessage) => MaybePromise, From c1168e1bd4ec11e01a61044f00e46ab3b051b228 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 12:20:23 -0400 Subject: [PATCH 12/21] refactor: flatten registration to direction-named methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit p.client.onRequest read as a false friend of the fluent client().onRequest while being typed as its opposite (agent-bound methods — traffic FROM the client). Naming the direction in the method itself removes the ambiguity and shrinks the API: onRequestFromClient / onNotificationFromClient / onRequestFromAgent / onNotificationFromAgent live directly on ProxyBuilder, so the ProxySideBuilder class, its three generic parameters, and the ProxyTap type all leave the public surface, and registration chains as a single fluent expression like the sibling builders. Co-Authored-By: Claude Fable 5 --- README.md | 2 +- src/acp.ts | 2 - src/examples/proxy.ts | 59 ++++---- src/proxy.test.ts | 42 +++--- src/proxy.ts | 341 ++++++++++++++++++++++-------------------- 5 files changed, 233 insertions(+), 213 deletions(-) diff --git a/README.md b/README.md index 7a512ebd..5b606d17 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ If you're building an [Agent](https://agentclientprotocol.com/protocol/overview# If you're building a [Client](https://agentclientprotocol.com/protocol/overview#client), start with `client({ name })`, register client-side handlers such as `requestPermission(...)` and `sessionUpdate(...)`, then run your agent workflow with `connectWith(stream, async (ctx) => ...)`. -If you're building something that sits between the two — a logger, an authorization gate, a message transformer — start with `proxy()`. Register typed handlers on `p.client` (traffic from the client) and `p.agent` (traffic from the agent) just like the agent/client builders, then call `p.connect({ client, agent })`. Anything you don't claim is forwarded untouched in both directions; handlers can rewrite, answer, or drop messages before they cross. +If you're building something that sits between the two — a logger, an authorization gate, a message transformer — start with `proxy()`. Register typed handlers with `onRequestFromClient(...)` / `onNotificationFromAgent(...)` (the method names say which direction they intercept) just like the agent/client builders, then call `connect({ client, agent })`. Anything you don't claim is forwarded untouched in both directions; handlers can rewrite, answer, or drop messages before they cross. ### Study a Production Implementation diff --git a/src/acp.ts b/src/acp.ts index b879ade2..effbe49b 100644 --- a/src/acp.ts +++ b/src/acp.ts @@ -62,10 +62,8 @@ export type { ProxyNotificationHandler, ProxyRequestContext, ProxyRequestHandler, - ProxySideBuilder, ProxySideConnection, ProxyStreams, - ProxyTap, } from "./proxy.js"; export { RequestError } from "./jsonrpc.js"; export type { diff --git a/src/examples/proxy.ts b/src/examples/proxy.ts index 5014e19b..f7030889 100644 --- a/src/examples/proxy.ts +++ b/src/examples/proxy.ts @@ -16,17 +16,22 @@ import * as acp from "../acp.js"; // Usage: proxy.ts [command args...] // Runs the example agent from this directory when no command is given. -/** Registers wildcard handlers that log traffic from one peer, then forward. */ -function snoop(side: acp.ProxyTap, direction: string): void { - side - .onRequest("*", ({ method, params, forward }) => { - console.error(`[proxy] ${direction} request: ${method}`); - return forward(params); - }) - .onNotification("*", async ({ method, params, forward }) => { - console.error(`[proxy] ${direction} notification: ${method}`); - await forward(params); - }); +function logAndForward( + direction: string, +): acp.ProxyRequestHandler { + return ({ method, params, forward }) => { + console.error(`[proxy] ${direction} request: ${method}`); + return forward(params); + }; +} + +function logAndForwardNotification( + direction: string, +): acp.ProxyNotificationHandler { + return async ({ method, params, forward }) => { + console.error(`[proxy] ${direction} notification: ${method}`); + await forward(params); + }; } // Spawn the wrapped agent: the command from argv, or the example agent. @@ -40,23 +45,25 @@ const agentProcess = spawn(command, args, { stdio: ["pipe", "pipe", "inherit"], }); -const p = acp.proxy(); // "*" catches anything without an exact registration; typed interception is // also available, e.g. -// p.client.onRequest("session/prompt", async ({ params, forward }) => ...). -snoop(p.client, "client → agent"); -snoop(p.agent, "agent → client"); - -const handle = p.connect({ - client: acp.ndJsonStream( - Writable.toWeb(process.stdout), - Readable.toWeb(process.stdin) as ReadableStream, - ), - agent: acp.ndJsonStream( - Writable.toWeb(agentProcess.stdin!), - Readable.toWeb(agentProcess.stdout!) as ReadableStream, - ), -}); +// .onRequestFromClient("session/prompt", async ({ params, forward }) => ...). +const handle = acp + .proxy() + .onRequestFromClient("*", logAndForward("client → agent")) + .onNotificationFromClient("*", logAndForwardNotification("client → agent")) + .onRequestFromAgent("*", logAndForward("agent → client")) + .onNotificationFromAgent("*", logAndForwardNotification("agent → client")) + .connect({ + client: acp.ndJsonStream( + Writable.toWeb(process.stdout), + Readable.toWeb(process.stdin) as ReadableStream, + ), + agent: acp.ndJsonStream( + Writable.toWeb(agentProcess.stdin!), + Readable.toWeb(agentProcess.stdout!) as ReadableStream, + ), + }); agentProcess.once("exit", () => handle.close()); await handle.closed; diff --git a/src/proxy.test.ts b/src/proxy.test.ts index 26607fed..5bcd968f 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -140,7 +140,7 @@ describe("proxy forwarding", () => { it("preserves notification order when an earlier handler is slow", async () => { const { clientStream, agentStream } = setupProxy((p) => { - p.agent.onNotification( + p.onNotificationFromAgent( "update", (params) => params as { tag: string; delay: number }, async ({ params, forward }) => { @@ -178,7 +178,7 @@ describe("proxy forwarding", () => { // `slow` goes through a typed handler that awaits forward(...), which // must release the dispatch loop at the send, not the response. const { clientStream, agentStream } = setupProxy((p) => { - p.client.onRequest( + p.onRequestFromClient( "slow", (params) => params, async ({ params, forward }) => forward(params), @@ -231,7 +231,7 @@ describe("proxy forwarding", () => { describe("proxy typed registration", () => { it("rewrites request params before forwarding", async () => { const { clientStream, agentStream } = setupProxy((p) => { - p.client.onRequest( + p.onRequestFromClient( "echo", (params) => params as Record, async ({ forward }) => forward({ rewritten: true }), @@ -253,7 +253,7 @@ describe("proxy typed registration", () => { it("rewrites responses on the way back", async () => { const { clientStream, agentStream } = setupProxy((p) => { - p.client.onRequest( + p.onRequestFromClient( "echo", (params) => params, async ({ params, forward }) => { @@ -279,7 +279,7 @@ describe("proxy typed registration", () => { it("answers intercepted requests without the agent seeing them", async () => { const agentSaw = vi.fn(); const { clientStream, agentStream } = setupProxy((p) => { - p.client.onRequest( + p.onRequestFromClient( "denied", (params) => params, () => { @@ -306,7 +306,7 @@ describe("proxy typed registration", () => { it("answers without forwarding when the handler returns its own response", async () => { const { clientStream } = setupProxy((p) => { - p.client.onRequest( + p.onRequestFromClient( "cached", (params) => params, () => ({ fromProxy: true }), @@ -321,7 +321,7 @@ describe("proxy typed registration", () => { it("drops notifications when the handler skips forward", async () => { const { clientStream, agentStream } = setupProxy((p) => { - p.agent.onNotification( + p.onNotificationFromAgent( "update", (params) => params as { secret: boolean }, async ({ params, forward }) => { @@ -356,16 +356,14 @@ describe("proxy typed registration", () => { it("routes unclaimed traffic to '*' with exact registrations winning", async () => { const wildcardSaw: string[] = []; const { clientStream, agentStream } = setupProxy((p) => { - p.client - .onRequest( - "specific", - (params) => params, - () => ({ via: "specific" }), - ) - .onRequest("*", ({ method, params, forward }) => { - wildcardSaw.push(method); - return forward(params); - }); + p.onRequestFromClient( + "specific", + (params) => params, + () => ({ via: "specific" }), + ).onRequestFromClient("*", ({ method, params, forward }) => { + wildcardSaw.push(method); + return forward(params); + }); }); Connection.builder() .onReceiveRequest( @@ -399,7 +397,7 @@ describe("proxy typed registration", () => { // Registering after connect is allowed but must not affect the // already-connected proxy — same semantics as the fluent app builders. - builder.client.onRequest( + builder.onRequestFromClient( "echo", (params) => params as Record, async ({ forward }) => forward({ late: true }), @@ -412,14 +410,14 @@ describe("proxy typed registration", () => { it("rejects duplicate registrations for the same method", () => { const p = proxy(); - p.client.onRequest( + p.onRequestFromClient( "echo", (params) => params, async ({ params, forward }) => forward(params), ); expect(() => - p.client.onRequest( + p.onRequestFromClient( "echo", (params) => params, async ({ params, forward }) => forward(params), @@ -430,7 +428,7 @@ describe("proxy typed registration", () => { it("routes unclaimed notifications to '*'", async () => { const wildcardSaw: string[] = []; const { clientStream, agentStream } = setupProxy((p) => { - p.client.onNotification("*", async ({ method, params, forward }) => { + p.onNotificationFromClient("*", async ({ method, params, forward }) => { wildcardSaw.push(method); await forward(params); }); @@ -456,7 +454,7 @@ describe("proxy with fluent apps", () => { it("connects a client app to an agent app through the proxy", async () => { const promptsSeen: string[] = []; const { clientStream, agentStream } = setupProxy((p) => { - p.client.onRequest( + p.onRequestFromClient( "_test/echo", (params) => params as { value: number }, async ({ params, forward }) => { diff --git a/src/proxy.ts b/src/proxy.ts index 82607e9c..01cb7fd6 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -10,10 +10,14 @@ import type { } from "./jsonrpc.js"; import type { Stream } from "./stream.js"; import type { + AgentNotificationMethod, AgentNotificationParamsByMethod, + AgentRequestMethod, AgentRequestParamsByMethod, AgentRequestResponsesByMethod, + ClientNotificationMethod, ClientNotificationParamsByMethod, + ClientRequestMethod, ClientRequestParamsByMethod, ClientRequestResponsesByMethod, } from "./acp.js"; @@ -106,141 +110,6 @@ type Registration = { handler: ErasedHandler; }; -/** - * Registration surface for one side of a proxy. - * - * `proxy().client` registers handlers for traffic arriving from the client - * (agent-bound methods such as `session/prompt`); `proxy().agent` registers - * handlers for traffic arriving from the agent (client-bound methods such as - * `session/request_permission`). The type parameters select the matching - * ByMethod maps so built-in method literals infer their params and response - * types, mirroring `agent(...)`/`client(...)` registration. - * - * Handlers claim their method: at most one typed handler runs per message. - * Register `"*"` to catch traffic no exact registration claims - * (most-specific wins); anything still unclaimed is forwarded untouched. - * - * Each `connect(...)` snapshots the registrations, exactly like the - * `agent(...)`/`client(...)` builders: registering afterwards is allowed but - * applies only to subsequent connects, never to already-connected proxies. - * For behavior that changes mid-session, keep the changing state in your - * handler's closure instead of changing the registrations. - */ -export class ProxySideBuilder< - RequestParams extends Record, - RequestResponses extends Record, - NotificationParams extends Record, -> { - private readonly requests = new Map(); - private readonly notifications = new Map(); - - /** - * Registers a typed handler for requests arriving from this side's peer. - * - * Built-in method literals infer their params and response types from - * `method`. Pass a parser as the second argument for custom extension - * methods or to opt into runtime validation. Register `"*"` to catch any - * request no exact registration claims. - */ - onRequest( - method: Method, - handler: ProxyRequestHandler< - RequestParams[Method], - Method extends keyof RequestResponses ? RequestResponses[Method] : never - >, - ): this; - onRequest(method: "*", handler: ProxyRequestHandler): this; - onRequest( - method: string, - params: ParamsParser, - handler: ProxyRequestHandler, - ): this; - onRequest( - method: string, - handlerOrParams: ErasedHandler | ParamsParser, - handler?: ErasedHandler, - ): this { - this.register(this.requests, "request", method, handlerOrParams, handler); - return this; - } - - /** - * Registers a typed handler for notifications arriving from this side's - * peer. Same registration forms as `onRequest`. - */ - onNotification( - method: Method, - handler: ProxyNotificationHandler, - ): this; - onNotification(method: "*", handler: ProxyNotificationHandler): this; - onNotification( - method: string, - params: ParamsParser, - handler: ProxyNotificationHandler, - ): this; - onNotification( - method: string, - handlerOrParams: ErasedHandler | ParamsParser, - handler?: ErasedHandler, - ): this { - this.register( - this.notifications, - "notification", - method, - handlerOrParams, - handler, - ); - return this; - } - - private register( - table: Map, - kind: "request" | "notification", - method: string, - handlerOrParams: ErasedHandler | ParamsParser, - handler?: ErasedHandler, - ): void { - if (table.has(method)) { - throw new Error(`Proxy ${kind} handler already registered: ${method}`); - } - - if (handler) { - table.set(method, { - params: handlerOrParams as ParamsParser, - handler, - }); - return; - } - - table.set(method, { handler: handlerOrParams as ErasedHandler }); - } - - /** @internal */ - buildHandler(target: () => Connection): JsonRpcHandler { - // Snapshot so registrations made after connect(...) apply only to - // subsequent connects — the same semantics as the fluent app builders. - return serialize( - dispatcher(new Map(this.requests), new Map(this.notifications), target), - ); - } -} - -/** - * The wildcard-only view of a proxy side — what a side-agnostic tap - * (logger, metrics, authorization gate) needs. Both `proxy().client` and - * `proxy().agent` are assignable to it. - */ -export type ProxyTap = { - onRequest( - method: "*", - handler: ProxyRequestHandler, - ): ProxyTap; - onNotification( - method: "*", - handler: ProxyNotificationHandler, - ): ProxyTap; -}; - /** * Streams accepted by `ProxyBuilder.connect(...)`. */ @@ -298,39 +167,166 @@ export type ProxyHandle = { /** * Builder for an ACP proxy between a client stream and an agent stream. * - * Register handlers on `client` (traffic from the client) and `agent` - * (traffic from the agent), then call `connect(...)`. + * The four registration methods name the traffic they intercept: requests + * and notifications arriving **from the client** (agent-bound methods such + * as `session/prompt`) or **from the agent** (client-bound methods such as + * `session/request_permission`). Built-in method literals infer their + * params and response types from the same ByMethod maps the + * `agent(...)`/`client(...)` builders use. + * + * Handlers claim their method: at most one runs per message. Register `"*"` + * to catch traffic no exact registration claims (most-specific wins); + * anything still unclaimed is forwarded untouched. + * + * Each `connect(...)` snapshots the registrations, exactly like the + * `agent(...)`/`client(...)` builders: registering afterwards is allowed but + * applies only to subsequent connects, never to already-connected proxies. + * For behavior that changes mid-session, keep the changing state in your + * handler's closure instead of changing the registrations. */ export class ProxyBuilder { + private readonly clientRequests = new Map(); + private readonly clientNotifications = new Map(); + private readonly agentRequests = new Map(); + private readonly agentNotifications = new Map(); + + /** + * Registers a typed handler for requests arriving from the client. + * + * Built-in method literals infer their params and response types from + * `method`. Pass a parser as the second argument for custom extension + * methods or to opt into runtime validation. Register `"*"` to catch any + * request no exact registration claims. + */ + onRequestFromClient( + method: Method, + handler: ProxyRequestHandler< + AgentRequestParamsByMethod[Method], + AgentRequestResponsesByMethod[Method] + >, + ): this; + onRequestFromClient( + method: "*", + handler: ProxyRequestHandler, + ): this; + onRequestFromClient( + method: string, + params: ParamsParser, + handler: ProxyRequestHandler, + ): this; + onRequestFromClient( + method: string, + handlerOrParams: ErasedHandler | ParamsParser, + handler?: ErasedHandler, + ): this { + register(this.clientRequests, method, handlerOrParams, handler); + return this; + } + /** - * Registration surface for traffic arriving from the client — agent-bound - * methods, typed by the agent request/notification maps. + * Registers a typed handler for notifications arriving from the client. + * Same registration forms as `onRequestFromClient`. */ - readonly client = new ProxySideBuilder< - AgentRequestParamsByMethod, - AgentRequestResponsesByMethod, - AgentNotificationParamsByMethod - >(); + onNotificationFromClient( + method: Method, + handler: ProxyNotificationHandler, + ): this; + onNotificationFromClient( + method: "*", + handler: ProxyNotificationHandler, + ): this; + onNotificationFromClient( + method: string, + params: ParamsParser, + handler: ProxyNotificationHandler, + ): this; + onNotificationFromClient( + method: string, + handlerOrParams: ErasedHandler | ParamsParser, + handler?: ErasedHandler, + ): this { + register(this.clientNotifications, method, handlerOrParams, handler); + return this; + } /** - * Registration surface for traffic arriving from the agent — client-bound - * methods, typed by the client request/notification maps. + * Registers a typed handler for requests arriving from the agent. + * Same registration forms as `onRequestFromClient`. */ - readonly agent = new ProxySideBuilder< - ClientRequestParamsByMethod, - ClientRequestResponsesByMethod, - ClientNotificationParamsByMethod - >(); + onRequestFromAgent( + method: Method, + handler: ProxyRequestHandler< + ClientRequestParamsByMethod[Method], + ClientRequestResponsesByMethod[Method] + >, + ): this; + onRequestFromAgent( + method: "*", + handler: ProxyRequestHandler, + ): this; + onRequestFromAgent( + method: string, + params: ParamsParser, + handler: ProxyRequestHandler, + ): this; + onRequestFromAgent( + method: string, + handlerOrParams: ErasedHandler | ParamsParser, + handler?: ErasedHandler, + ): this { + register(this.agentRequests, method, handlerOrParams, handler); + return this; + } + + /** + * Registers a typed handler for notifications arriving from the agent. + * Same registration forms as `onRequestFromClient`. + */ + onNotificationFromAgent( + method: Method, + handler: ProxyNotificationHandler, + ): this; + onNotificationFromAgent( + method: "*", + handler: ProxyNotificationHandler, + ): this; + onNotificationFromAgent( + method: string, + params: ParamsParser, + handler: ProxyNotificationHandler, + ): this; + onNotificationFromAgent( + method: string, + handlerOrParams: ErasedHandler | ParamsParser, + handler?: ErasedHandler, + ): this { + register(this.agentNotifications, method, handlerOrParams, handler); + return this; + } /** * Connects the proxy between the two streams and starts relaying. */ connect(streams: ProxyStreams): ProxyHandle { + // Snapshot so registrations made after connect(...) apply only to + // subsequent connects — the same semantics as the fluent app builders. const client: Connection = new Connection(streams.client, [ - this.client.buildHandler(() => agent), + serialize( + dispatcher( + new Map(this.clientRequests), + new Map(this.clientNotifications), + () => agent, + ), + ), ]); const agent: Connection = new Connection(streams.agent, [ - this.agent.buildHandler(() => client), + serialize( + dispatcher( + new Map(this.agentRequests), + new Map(this.agentNotifications), + () => client, + ), + ), ]); linkClosed(client, agent); @@ -346,6 +342,27 @@ export class ProxyBuilder { } } +function register( + table: Map, + method: string, + handlerOrParams: ErasedHandler | ParamsParser, + handler?: ErasedHandler, +): void { + if (table.has(method)) { + throw new Error(`Proxy handler already registered: ${method}`); + } + + if (handler) { + table.set(method, { + params: handlerOrParams as ParamsParser, + handler, + }); + return; + } + + table.set(method, { handler: handlerOrParams as ErasedHandler }); +} + /** * Creates an ACP proxy builder. * @@ -379,15 +396,15 @@ export class ProxyBuilder { * * @example * ```ts - * const p = proxy(); - * p.client.onRequest("session/prompt", async ({ params, forward }) => { - * audit(params); - * return forward(params); - * }); - * p.agent.onNotification("session/update", async ({ params, forward }) => { - * if (!redacted(params)) await forward(params); - * }); - * const handle = p.connect({ client: clientStream, agent: agentStream }); + * const handle = proxy() + * .onRequestFromClient("session/prompt", async ({ params, forward }) => { + * audit(params); + * return forward(params); + * }) + * .onNotificationFromAgent("session/update", async ({ params, forward }) => { + * if (!redacted(params)) await forward(params); + * }) + * .connect({ client: clientStream, agent: agentStream }); * ``` */ export function proxy(): ProxyBuilder { From 39e9d3be4a3c5f61014edf720e6d032117b16b1a Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 12:44:59 -0400 Subject: [PATCH 13/21] refactor: shrink the footprint on existing files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything the proxy borrowed from core turned out to be re-creatable in place for less churn than sharing it: parser normalization is four inline lines (so ParamsParser/parseParams stay in acp.ts untouched), abort-aware error mapping comes from exporting the existing errorToRequestResult instead of adding a responder method and rewriting Connection's catch, and cross-close wiring is two lines in connect() (so linkClosed and the acp.ts in-memory connect changes revert). Existing-file changes are now: the export blocks in acp.ts, one function made public in jsonrpc.ts, and README pointers — no behavior changes outside the new module. Co-Authored-By: Claude Fable 5 --- src/acp.ts | 43 ++++++++++++++++++++--------- src/jsonrpc.test.ts | 18 ------------- src/jsonrpc.ts | 66 +++++++-------------------------------------- src/proxy.ts | 31 ++++++++++++++------- 4 files changed, 61 insertions(+), 97 deletions(-) diff --git a/src/acp.ts b/src/acp.ts index effbe49b..4797aa32 100644 --- a/src/acp.ts +++ b/src/acp.ts @@ -53,8 +53,8 @@ export function ndJsonStream( } export { proxy } from "./proxy.js"; -// ProxyBuilder and ProxySideBuilder are type-only on purpose: proxy() is the -// sole factory, so their constructors are not part of the public contract. +// ProxyBuilder is type-only on purpose: proxy() is the sole factory, so its +// constructor is not part of the public contract. export type { ProxyBuilder, ProxyHandle, @@ -74,19 +74,12 @@ export type { ErrorResponse, JsonRpcId, MaybePromise, - ParamsParser, Result, SendRequestOptions, } from "./jsonrpc.js"; import type { WireStream } from "./stream.js"; -import { - Connection, - Handled, - HandlerRegistration, - linkClosed, - parseParams, -} from "./jsonrpc.js"; +import { Connection, Handled, HandlerRegistration } from "./jsonrpc.js"; import type { ConnectionBuilder, ConnectionContext, @@ -94,7 +87,6 @@ import type { HandleResult, IncomingMessage, JsonRpcId, - ParamsParser, JsonRpcHandler, MaybePromise, SendRequestOptions, @@ -970,6 +962,14 @@ export type AppOptions = { * A Zod schema can be passed directly because schemas expose a compatible * `parse(...)` method. */ +export type ParamsParser = + | { + /** + * Parses raw JSON-RPC params into the handler's typed params. + */ + parse: (params: unknown) => Params; + } + | ((params: unknown) => Params); /** * Common context passed to agent-side handlers. @@ -1085,6 +1085,21 @@ export type ClientConnectHandler = ( connection: ClientConnection, ) => MaybePromise; +function parseParams( + parser: ParamsParser | undefined, + params: unknown, +): Params { + if (!parser) { + return params as Params; + } + + if (typeof parser === "function") { + return parser(params); + } + + return parser.parse(params); +} + type AcpRequestSpec = { method: string; params?: ParamsParser; @@ -2054,7 +2069,8 @@ export class AgentApp { ); const peerConnection = clientConnection(peerRawConnection); const state = this.openStreamConnection(thisStream); - linkClosed(state.rawConnection, peerRawConnection); + void state.rawConnection.closed.then(() => peerConnection.close()); + void peerRawConnection.closed.then(() => state.connection.close()); try { target[runClientConnectHandlers](peerConnection); this[runAgentConnectHandlers](state.connection); @@ -2302,7 +2318,8 @@ export class ClientApp { ); const peerConnection = agentConnection(peerRawConnection); const state = this.openStreamConnection(thisStream); - linkClosed(state.rawConnection, peerRawConnection); + void state.rawConnection.closed.then(() => peerConnection.close()); + void peerRawConnection.closed.then(() => state.connection.close()); try { target[runAgentConnectHandlers](peerConnection); this[runClientConnectHandlers](state.connection); diff --git a/src/jsonrpc.test.ts b/src/jsonrpc.test.ts index 95c99fa1..0313a285 100644 --- a/src/jsonrpc.test.ts +++ b/src/jsonrpc.test.ts @@ -8,7 +8,6 @@ import { isJsonRpcBatchMessage, isJsonRpcMessage, isJsonRpcWireMessage, - linkClosed, } from "./jsonrpc.js"; import type { AnyMessage, @@ -17,7 +16,6 @@ import type { RequestResponder, } from "./jsonrpc.js"; import type { Stream } from "./stream.js"; -import { inMemoryStreamPair } from "./acp.js"; type ConnectionInternals = { pendingResponses: Map; @@ -1154,19 +1152,3 @@ function memoryStreamPair(): [Stream, Stream] { }, ]; } - -describe("linkClosed", () => { - it("closes the paired connection with the same reason", async () => { - const [aStream] = inMemoryStreamPair(); - const [bStream] = inMemoryStreamPair(); - const a = new Connection(aStream, []); - const b = new Connection(bStream, []); - linkClosed(a, b); - - const pendingOnB = b.sendRequest("anything", {}); - a.close(new Error("a went away")); - - await expect(pendingOnB).rejects.toThrow("a went away"); - await b.closed; - }); -}); diff --git a/src/jsonrpc.ts b/src/jsonrpc.ts index 34b7dc90..81aa50b2 100644 --- a/src/jsonrpc.ts +++ b/src/jsonrpc.ts @@ -432,40 +432,6 @@ type PreparedRequest = { */ export type MaybePromise = T | Promise; -/** - * Parser converting raw JSON-RPC params into a handler's typed params. - * - * Accepts either a plain function or an object with a `parse` method (such - * as a Zod schema). - */ -export type ParamsParser = - | { - /** - * Parses raw JSON-RPC params into the handler's typed params. - */ - parse: (params: unknown) => Params; - } - | ((params: unknown) => Params); - -/** - * Runs a {@link ParamsParser} over raw params; without a parser the params - * pass through untouched. - */ -export function parseParams( - parser: ParamsParser | undefined, - params: unknown, -): Params { - if (!parser) { - return params as Params; - } - - if (typeof parser === "function") { - return parser(params); - } - - return parser.parse(params); -} - /** * Incoming request passed to JSON-RPC handlers. */ @@ -667,7 +633,12 @@ function requestCancelledError(reason?: unknown): RequestError { return RequestError.requestCancelled(reason); } -function errorToRequestResult( +/** + * Maps an error thrown while handling a request to a JSON-RPC result: + * `RequestError` keeps its code/message/data, an abort after cancellation + * maps to request-cancelled, anything else becomes a generic internal error. + */ +export function errorToRequestResult( error: unknown, signal: AbortSignal, ): Result { @@ -743,16 +714,6 @@ export class RequestResponder { return this.respondWithResult({ error: errorResponse }); } - /** - * Sends the JSON-RPC response for an error thrown while handling the - * request: `RequestError` keeps its code/message/data, an abort after - * cancellation maps to request-cancelled, anything else becomes a generic - * internal error. - */ - respondWithThrown(error: unknown): Promise { - return this.respondWithResult(errorToRequestResult(error, this.signal)); - } - /** * Sends a complete JSON-RPC result payload. */ @@ -1463,7 +1424,9 @@ export class Connection { } if (current.kind === "request" && !current.responder.responded) { - await current.responder.respondWithThrown(error); + await current.responder.respondWithResult( + errorToRequestResult(error, current.responder.signal), + ); } else { const response = errorToResult(error); if ("error" in response) { @@ -1597,17 +1560,6 @@ export class Connection { } } -/** - * Closes each connection when the other closes. - * - * The close reason is propagated so pending requests on the surviving side - * reject with the true cause rather than a generic connection-closed error. - */ -export function linkClosed(a: Connection, b: Connection): void { - void a.closed.then(() => b.close(a.signal.reason)); - void b.closed.then(() => a.close(b.signal.reason)); -} - /** * Builder for a lower-level handler-based JSON-RPC connection. */ diff --git a/src/proxy.ts b/src/proxy.ts index 01cb7fd6..9a900577 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,4 +1,4 @@ -import { Connection, Handled, linkClosed, parseParams } from "./jsonrpc.js"; +import { Connection, Handled, errorToRequestResult } from "./jsonrpc.js"; import type { HandleResult, IncomingMessage, @@ -6,7 +6,6 @@ import type { IncomingRequest, JsonRpcHandler, MaybePromise, - ParamsParser, } from "./jsonrpc.js"; import type { Stream } from "./stream.js"; import type { @@ -20,6 +19,7 @@ import type { ClientRequestMethod, ClientRequestParamsByMethod, ClientRequestResponsesByMethod, + ParamsParser, } from "./acp.js"; /** @@ -106,7 +106,7 @@ export type ProxyNotificationHandler = ( type ErasedHandler = (context: never) => MaybePromise; type Registration = { - params?: ParamsParser; + parse?: (params: unknown) => unknown; handler: ErasedHandler; }; @@ -328,7 +328,10 @@ export class ProxyBuilder { ), ), ]); - linkClosed(client, agent); + // When either side closes, close the other with the same reason so its + // pending requests reject with the true cause. + void client.closed.then(() => agent.close(client.signal.reason)); + void agent.closed.then(() => client.close(agent.signal.reason)); return { client, @@ -353,8 +356,9 @@ function register( } if (handler) { + const parser = handlerOrParams as ParamsParser; table.set(method, { - params: handlerOrParams as ParamsParser, + parse: typeof parser === "function" ? parser : (p) => parser.parse(p), handler, }); return; @@ -449,7 +453,9 @@ function dispatcher( try { const response = await run({ method: message.method, - params: parseParams(registration.params, message.params), + params: registration.parse + ? registration.parse(message.params) + : message.params, signal: message.signal, forward: (params: unknown) => { const sent = target().sendRequest( @@ -464,7 +470,9 @@ function dispatcher( }); await responder.respond(response ?? null); } catch (error) { - await responder.respondWithThrown(error).catch(() => {}); + await responder + .respondWithResult(errorToRequestResult(error, message.signal)) + .catch(() => {}); } finally { release(); } @@ -490,7 +498,9 @@ function dispatcher( ) => MaybePromise; await run({ method: message.method, - params: parseParams(registration.params, message.params), + params: registration.parse + ? registration.parse(message.params) + : message.params, forward: (params: unknown) => target().sendNotification(message.method, params), }); @@ -514,7 +524,10 @@ function dispatcher( }) .then( (result) => responder.respond(result), - (error) => responder.respondWithThrown(error), + (error) => + responder.respondWithResult( + errorToRequestResult(error, message.signal), + ), ) // The response cannot be delivered when the caller's side is // already closed; there is nowhere left to report it. From 90bdc8b0723020ed86852e0af823c4d10e68883e Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 12:54:41 -0400 Subject: [PATCH 14/21] refactor: keep the in-memory stream pair private Nothing in the shipped surface needs it: the example uses ndJsonStream over stdio, proxy() takes whatever streams the caller has, and the only consumer was our own test file, which now carries its own twelve-line helper the way jsonrpc.test.ts already does. Anyone sandwiching an in-process app can hand-roll the pair; exporting it later is free, un-exporting it never is. Co-Authored-By: Claude Fable 5 --- src/acp.ts | 13 +++---------- src/proxy.test.ts | 12 +++++++++++- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/acp.ts b/src/acp.ts index 4797aa32..7f77c82c 100644 --- a/src/acp.ts +++ b/src/acp.ts @@ -105,14 +105,7 @@ function isStream(value: unknown): value is WireStream { ); } -/** - * Creates a pair of in-memory streams wired back to back. - * - * Messages written to one stream are read from the other. Use this to - * connect ACP endpoints in the same process — for example an app to a - * `proxy(...)` side, or endpoints under test — without a transport. - */ -export function inMemoryStreamPair(): [Stream, Stream] { +function memoryStreamPair(): [Stream, Stream] { const leftToRight = new TransformStream(); const rightToLeft = new TransformStream(); return [ @@ -2062,7 +2055,7 @@ export class AgentApp { return state; } - const [thisStream, peerStream] = inMemoryStreamPair(); + const [thisStream, peerStream] = memoryStreamPair(); const peerRawConnection = target[appBuilder]().connect( peerStream, stableConnectionOptions, @@ -2311,7 +2304,7 @@ export class ClientApp { return state; } - const [thisStream, peerStream] = inMemoryStreamPair(); + const [thisStream, peerStream] = memoryStreamPair(); const peerRawConnection = target[appBuilder]().connect( peerStream, stableConnectionOptions, diff --git a/src/proxy.test.ts b/src/proxy.test.ts index 5bcd968f..d0c5bab7 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -1,12 +1,22 @@ import { describe, expect, it, vi } from "vitest"; -import { agent, client, inMemoryStreamPair } from "./acp.js"; +import { agent, client } from "./acp.js"; import { Connection, Handled, RequestError } from "./jsonrpc.js"; import type { RequestResponder } from "./jsonrpc.js"; import { proxy } from "./proxy.js"; import type { ProxyBuilder, ProxyHandle } from "./proxy.js"; +import type { AnyMessage } from "./jsonrpc.js"; import type { Stream } from "./stream.js"; +function inMemoryStreamPair(): [Stream, Stream] { + const leftToRight = new TransformStream(); + const rightToLeft = new TransformStream(); + return [ + { readable: rightToLeft.readable, writable: leftToRight.writable }, + { readable: leftToRight.readable, writable: rightToLeft.writable }, + ]; +} + type ProxySetup = { clientStream: Stream; agentStream: Stream; From 71b2ef72de8c31fa096bcc398260ec3e56cd07e2 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 13:22:26 -0400 Subject: [PATCH 15/21] test: cover the proxy's error, cancellation, ordering, and composition edges A gap review against the behavior surface found the -32800 parity fix had shipped untested, and several core relay properties were only implied: - cancellation through a registered handler's forward, and a handler abort after cancellation mapping to request-cancelled rather than internal error - generic handler throws answering -32603, parser failures answering -32602, and a throwing notification handler dropping only its own message while the dispatch queue keeps going - request SEND order preserved through an async registered handler, and the synchronous pass-through fast path not overtaking a busy queue - concurrent round trips answered out of order still correlating to their callers - two chained proxies rewriting in flight, one builder connected to multiple stream pairs, and a full initialize/new/prompt fluent flow with a typed session/prompt interceptor rewriting response _meta Co-Authored-By: Claude Fable 5 --- src/proxy.test.ts | 354 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 354 insertions(+) diff --git a/src/proxy.test.ts b/src/proxy.test.ts index d0c5bab7..12492764 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -238,6 +238,306 @@ describe("proxy forwarding", () => { }); }); +describe("proxy error and cancellation edges", () => { + it("propagates cancellation through a registered handler's forward", async () => { + const { clientStream, agentStream } = setupProxy((p) => { + p.onRequestFromClient( + "slow", + (params) => params, + async ({ params, forward }) => forward(params), + ); + }); + Connection.builder() + .onReceiveRequest( + "slow", + (params) => params, + (_request, responder) => { + responder.signal.addEventListener("abort", () => { + void responder.respondWithError(RequestError.requestCancelled()); + }); + }, + ) + .connect(agentStream); + const clientEnd = new Connection(clientStream, []); + + const canceller = new AbortController(); + const failure = clientEnd.sendRequest("slow", {}, undefined, { + cancellationSignal: canceller.signal, + }); + canceller.abort(); + + await expect(failure).rejects.toMatchObject({ code: -32800 }); + }); + + it("maps a handler's abort after cancellation to request-cancelled", async () => { + const { clientStream } = setupProxy((p) => { + p.onRequestFromClient( + "watch", + (params) => params, + ({ signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + const abortError = new Error("The operation was aborted"); + abortError.name = "AbortError"; + reject(abortError); + }); + }), + ); + }); + const clientEnd = new Connection(clientStream, []); + + const canceller = new AbortController(); + const failure = clientEnd.sendRequest("watch", {}, undefined, { + cancellationSignal: canceller.signal, + }); + canceller.abort(); + + await expect(failure).rejects.toMatchObject({ code: -32800 }); + }); + + it("answers a generic handler throw as an internal error", async () => { + const { clientStream } = setupProxy((p) => { + p.onRequestFromClient( + "boom", + (params) => params, + () => { + throw new Error("kaput"); + }, + ); + }); + const clientEnd = new Connection(clientStream, []); + + await expect(clientEnd.sendRequest("boom", {})).rejects.toMatchObject({ + code: -32603, + }); + }); + + it("answers a parser failure as invalid params", async () => { + class FakeZodError extends Error { + name = "ZodError"; + issues: unknown[] = []; + format(): unknown { + return { _errors: ["expected a number"] }; + } + } + const { clientStream } = setupProxy((p) => { + p.onRequestFromClient( + "strict", + () => { + throw new FakeZodError("invalid"); + }, + async ({ params, forward }) => forward(params), + ); + }); + const clientEnd = new Connection(clientStream, []); + + await expect( + clientEnd.sendRequest("strict", { n: "not-a-number" }), + ).rejects.toMatchObject({ code: -32602 }); + }); + + it("drops a throwing notification handler's message and keeps dispatching", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const { clientStream, agentStream } = setupProxy((p) => { + p.onNotificationFromClient( + "log", + (params) => params as { fail: boolean }, + async ({ params, forward }) => { + if (params.fail) { + throw new Error("handler exploded"); + } + await forward(params); + }, + ); + }); + const received = vi.fn(); + const gotSecond = Promise.withResolvers(); + Connection.builder() + .onReceiveNotification( + "log", + (params) => params, + (notification) => { + received(notification); + gotSecond.resolve(); + }, + ) + .connect(agentStream); + const clientEnd = new Connection(clientStream, []); + + await clientEnd.sendNotification("log", { fail: true }); + await clientEnd.sendNotification("log", { fail: false }); + await gotSecond.promise; + + expect(received).toHaveBeenCalledTimes(1); + expect(received).toHaveBeenCalledWith({ fail: false }); + consoleError.mockRestore(); + }); +}); + +describe("proxy ordering and correlation", () => { + it("preserves request send order through an async registered handler", async () => { + const { clientStream, agentStream } = setupProxy((p) => { + p.onRequestFromClient( + "echo", + (params) => params as { n: number }, + async ({ params, forward }) => { + // Delay before forwarding; serialization must keep send order. + await new Promise((resolve) => setTimeout(resolve, 15 - params.n)); + return forward(params); + }, + ); + }); + const arrivals: number[] = []; + Connection.builder() + .onReceiveRequest( + "echo", + (params) => params as { n: number }, + (request, responder) => { + arrivals.push(request.n); + return responder.respond({ echoed: request.n }); + }, + ) + .connect(agentStream); + const clientEnd = new Connection(clientStream, []); + + const [first, second] = await Promise.all([ + clientEnd.sendRequest("echo", { n: 1 }), + clientEnd.sendRequest("echo", { n: 2 }), + ]); + + expect(arrivals).toEqual([1, 2]); + expect(first).toEqual({ echoed: 1 }); + expect(second).toEqual({ echoed: 2 }); + }); + + it("correlates concurrent round trips answered out of order", async () => { + const { clientStream, agentStream } = setupProxy(); + const held: Array<{ n: number; responder: RequestResponder }> = []; + const gotBoth = Promise.withResolvers(); + Connection.builder() + .onReceiveRequest( + "hold", + (params) => params as { n: number }, + (request, responder) => { + held.push({ n: request.n, responder }); + if (held.length === 2) { + gotBoth.resolve(); + } + }, + ) + .connect(agentStream); + const clientEnd = new Connection(clientStream, []); + + const first = clientEnd.sendRequest("hold", { n: 1 }); + const second = clientEnd.sendRequest("hold", { n: 2 }); + await gotBoth.promise; + + // Answer in reverse order; each caller must still get its own response. + await held[1]!.responder.respond({ answered: 2 }); + await held[0]!.responder.respond({ answered: 1 }); + + await expect(second).resolves.toEqual({ answered: 2 }); + await expect(first).resolves.toEqual({ answered: 1 }); + }); + + it("keeps pass-through traffic ordered behind a busy registered handler", async () => { + const { clientStream, agentStream } = setupProxy((p) => { + p.onNotificationFromAgent( + "update", + (params) => params, + async ({ params, forward }) => { + await new Promise((resolve) => setTimeout(resolve, 20)); + await forward(params); + }, + ); + }); + const received: string[] = []; + const gotBoth = Promise.withResolvers(); + Connection.builder() + .onReceiveMessage((message) => { + received.push(message.method); + if (received.length === 2) { + gotBoth.resolve(); + } + return Handled.yes(); + }) + .connect(clientStream); + const agentEnd = new Connection(agentStream, []); + + // "update" is registered (slow); "other" is unregistered pass-through. + // The fast path must not let "other" overtake the busy queue. + await agentEnd.sendNotification("update", {}); + await agentEnd.sendNotification("other", {}); + await gotBoth.promise; + + expect(received).toEqual(["update", "other"]); + }); +}); + +describe("proxy composition", () => { + it("chains two proxies, each rewriting in flight", async () => { + const [clientStream, firstClientSide] = inMemoryStreamPair(); + const [firstAgentSide, secondClientSide] = inMemoryStreamPair(); + const [secondAgentSide, agentStream] = inMemoryStreamPair(); + proxy() + .onRequestFromClient( + "echo", + (params) => params as Record, + async ({ params, forward }) => forward({ ...params, hop1: true }), + ) + .connect({ client: firstClientSide, agent: firstAgentSide }); + proxy() + .onRequestFromClient( + "echo", + (params) => params as Record, + async ({ params, forward }) => forward({ ...params, hop2: true }), + ) + .connect({ client: secondClientSide, agent: secondAgentSide }); + Connection.builder() + .onReceiveRequest( + "echo", + (params) => params, + (request, responder) => responder.respond({ echoed: request }), + ) + .connect(agentStream); + const clientEnd = new Connection(clientStream, []); + + const response = await clientEnd.sendRequest("echo", { original: true }); + + expect(response).toEqual({ + echoed: { original: true, hop1: true, hop2: true }, + }); + }); + + it("connects one configured builder to multiple stream pairs independently", async () => { + const builder = proxy().onRequestFromClient( + "echo", + (params) => params as Record, + async ({ params, forward }) => forward({ ...params, stamped: true }), + ); + + for (const label of ["a", "b"]) { + const [clientStream, proxyClientSide] = inMemoryStreamPair(); + const [proxyAgentSide, agentStream] = inMemoryStreamPair(); + builder.connect({ client: proxyClientSide, agent: proxyAgentSide }); + Connection.builder() + .onReceiveRequest( + "echo", + (params) => params, + (request, responder) => responder.respond({ echoed: request }), + ) + .connect(agentStream); + const clientEnd = new Connection(clientStream, []); + + await expect( + clientEnd.sendRequest("echo", { via: label }), + ).resolves.toEqual({ echoed: { via: label, stamped: true } }); + } + }); +}); + describe("proxy typed registration", () => { it("rewrites request params before forwarding", async () => { const { clientStream, agentStream } = setupProxy((p) => { @@ -461,6 +761,60 @@ describe("proxy typed registration", () => { }); describe("proxy with fluent apps", () => { + it("relays a full initialize/new/prompt flow with a typed interceptor", async () => { + const promptTexts: string[] = []; + const { clientStream, agentStream } = setupProxy((p) => { + p.onRequestFromClient("session/prompt", async ({ params, forward }) => { + // Typed literal: params is PromptRequest, response is PromptResponse. + for (const block of params.prompt) { + if (block.type === "text") { + promptTexts.push(block.text); + } + } + const response = await forward(params); + return { ...response, _meta: { ...response._meta, proxied: true } }; + }); + }); + + agent({ name: "e2e-agent" }) + .onRequest("initialize", ({ params }) => ({ + protocolVersion: params.protocolVersion, + agentCapabilities: {}, + })) + .onRequest("session/new", () => ({ sessionId: "s-1" })) + .onRequest("session/prompt", ({ params }) => ({ + stopReason: "end_turn" as const, + _meta: { sawSession: params.sessionId }, + })) + .connect(agentStream); + + const connection = client({ name: "e2e-client" }).connect(clientStream); + try { + const init = await connection.agent.request("initialize", { + protocolVersion: 1, + clientCapabilities: {}, + }); + expect(init.protocolVersion).toBe(1); + + const session = await connection.agent.request("session/new", { + cwd: "/tmp", + mcpServers: [], + }); + expect(session.sessionId).toBe("s-1"); + + const result = await connection.agent.request("session/prompt", { + sessionId: session.sessionId, + prompt: [{ type: "text", text: "hello through the proxy" }], + }); + + expect(result.stopReason).toBe("end_turn"); + expect(result._meta).toEqual({ sawSession: "s-1", proxied: true }); + expect(promptTexts).toEqual(["hello through the proxy"]); + } finally { + connection.close(); + } + }); + it("connects a client app to an agent app through the proxy", async () => { const promptsSeen: string[] = []; const { clientStream, agentStream } = setupProxy((p) => { From e0a0da56953512e5d8eba487b3cfd012c4a827fa Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 13:38:38 -0400 Subject: [PATCH 16/21] test: EOF-driven teardown over a real byte transport The suite's in-memory object streams can't express what real transports do on disconnect: EOF on the read loop rather than a close() call. ndJsonStream over byte pipes reproduces it, pinning that a client hangup mid-request closes both proxy sides. Co-Authored-By: Claude Fable 5 --- src/proxy.test.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/proxy.test.ts b/src/proxy.test.ts index 12492764..d161baf7 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -1,6 +1,8 @@ +import { PassThrough, Readable, Writable } from "node:stream"; + import { describe, expect, it, vi } from "vitest"; -import { agent, client } from "./acp.js"; +import { agent, client, ndJsonStream } from "./acp.js"; import { Connection, Handled, RequestError } from "./jsonrpc.js"; import type { RequestResponder } from "./jsonrpc.js"; import { proxy } from "./proxy.js"; @@ -229,6 +231,31 @@ describe("proxy forwarding", () => { await handle.closed; }); + it("closes both sides when the client transport reaches EOF", async () => { + // Over a real byte transport (stdio, sockets) a disconnect arrives as + // EOF on the read loop, not as a close() call. Byte pipes reproduce + // that, unlike the in-memory object streams used elsewhere. + const clientToProxy = new PassThrough(); + const proxyToClient = new PassThrough(); + const [proxyAgentSide, agentStream] = inMemoryStreamPair(); + const handle = proxy().connect({ + client: ndJsonStream( + Writable.toWeb(proxyToClient), + Readable.toWeb(clientToProxy) as ReadableStream, + ), + agent: proxyAgentSide, + }); + const agentEnd = new Connection(agentStream, []); + // In-flight traffic when the transport dies; over the in-memory agent + // pair this pending request can never settle, so it is not awaited. + void agentEnd.sendRequest("confirm", {}).catch(() => {}); + + // The client goes away mid-request. + clientToProxy.end(); + + await handle.closed; + }); + it("closes both sides through the handle", async () => { const { handle } = setupProxy(); From 239cf4467f30af9b173e2ae21141ac698bcd4b29 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 14:15:02 -0400 Subject: [PATCH 17/21] test: assert close outcomes explicitly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three teardown tests passed by not timing out — awaiting handle.closed with no expectation. An explicit resolves assertion states the intent and reads as a test rather than a wait. Co-Authored-By: Claude Fable 5 --- src/proxy.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/proxy.test.ts b/src/proxy.test.ts index d161baf7..0c285fba 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -228,7 +228,7 @@ describe("proxy forwarding", () => { handle.client.close(new Error("client side went away")); - await handle.closed; + await expect(handle.closed).resolves.toBeUndefined(); }); it("closes both sides when the client transport reaches EOF", async () => { @@ -253,7 +253,7 @@ describe("proxy forwarding", () => { // The client goes away mid-request. clientToProxy.end(); - await handle.closed; + await expect(handle.closed).resolves.toBeUndefined(); }); it("closes both sides through the handle", async () => { @@ -261,7 +261,7 @@ describe("proxy forwarding", () => { handle.close(); - await handle.closed; + await expect(handle.closed).resolves.toBeUndefined(); }); }); From b6d2ded3836ef5ab51c1bbb6d1deed0177e133cf Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 14:19:29 -0400 Subject: [PATCH 18/21] feat: expose signal on proxy sides, assert close reasons in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The teardown tests could only observe that closing happened, not why — ProxySideConnection had no synchronous state and no reason access. Adding signal makes the side match the fluent AcpConnection shape ({signal, closed, close}) and lets the tests assert the documented reason propagation (signal.reason on the surviving side) through public surface instead of casting to Connection. Co-Authored-By: Claude Fable 5 --- src/proxy.test.ts | 9 +++++++++ src/proxy.ts | 8 +++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/proxy.test.ts b/src/proxy.test.ts index 0c285fba..321f0109 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -229,6 +229,11 @@ describe("proxy forwarding", () => { handle.client.close(new Error("client side went away")); await expect(handle.closed).resolves.toBeUndefined(); + expect(handle.agent.signal.aborted).toBe(true); + // The close reason crosses to the other side's pending requests. + expect(handle.agent.signal.reason).toMatchObject({ + message: "client side went away", + }); }); it("closes both sides when the client transport reaches EOF", async () => { @@ -254,6 +259,8 @@ describe("proxy forwarding", () => { clientToProxy.end(); await expect(handle.closed).resolves.toBeUndefined(); + expect(handle.client.signal.aborted).toBe(true); + expect(handle.agent.signal.aborted).toBe(true); }); it("closes both sides through the handle", async () => { @@ -262,6 +269,8 @@ describe("proxy forwarding", () => { handle.close(); await expect(handle.closed).resolves.toBeUndefined(); + expect(handle.client.signal.aborted).toBe(true); + expect(handle.agent.signal.aborted).toBe(true); }); }); diff --git a/src/proxy.ts b/src/proxy.ts index 9a900577..76a3ead5 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -127,9 +127,15 @@ export type ProxyStreams = { }; /** - * One side of a running proxy. + * One side of a running proxy. Matches the shape of the fluent + * `AcpConnection` interface. */ export type ProxySideConnection = { + /** + * Aborts when this side's connection closes; `signal.reason` carries the + * close reason. + */ + readonly signal: AbortSignal; /** * Promise that resolves when this side's connection closes. */ From 15db0d40c8ff3f5fa12f753891149b96cd6ff11a Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 14:29:38 -0400 Subject: [PATCH 19/21] test: cover agent-side registration wiring and object-form parsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage measurement showed onRequestFromAgent was never exercised — the four registration methods share their implementation but each wires a different map to a different side, so a wiring swap would have passed every test — and all parser tests used the function form, leaving the schema-style { parse } object branch unexecuted. proxy.ts now sits at 98.8% statements / 96.4% branches, with the sole uncovered line a diagnostic describe() label. Co-Authored-By: Claude Fable 5 --- src/proxy.test.ts | 65 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/proxy.test.ts b/src/proxy.test.ts index 321f0109..e806edbf 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -512,6 +512,71 @@ describe("proxy ordering and correlation", () => { }); }); +describe("proxy agent-side interception", () => { + it("intercepts agent-initiated requests with onRequestFromAgent", async () => { + const clientSaw = vi.fn(); + const { clientStream, agentStream } = setupProxy((p) => { + p.onRequestFromAgent( + "confirm", + (params) => params as { action: string }, + // Answer in the proxy; the client must never be consulted. + ({ params }) => ({ approved: params.action === "safe" }), + ); + }); + Connection.builder() + .onReceiveMessage((message) => { + clientSaw(message.method); + return Handled.no(message); + }) + .connect(clientStream); + const agentEnd = new Connection(agentStream, []); + + await expect( + agentEnd.sendRequest("confirm", { action: "safe" }), + ).resolves.toEqual({ + approved: true, + }); + await expect( + agentEnd.sendRequest("confirm", { action: "risky" }), + ).resolves.toEqual({ approved: false }); + + expect(clientSaw).not.toHaveBeenCalled(); + }); + + it("parses params with an object-form (schema-style) parser", async () => { + const parsed: unknown[] = []; + const { clientStream, agentStream } = setupProxy((p) => { + p.onRequestFromClient( + "strict", + // Object form, like a zod schema: { parse(input) { ... } }. + { + parse: (input: unknown) => { + const record = input as { n: number }; + return { n: record.n * 10 }; + }, + }, + async ({ params, forward }) => { + parsed.push(params); + return forward(params); + }, + ); + }); + Connection.builder() + .onReceiveRequest( + "strict", + (params) => params, + (request, responder) => responder.respond({ echoed: request }), + ) + .connect(agentStream); + const clientEnd = new Connection(clientStream, []); + + const response = await clientEnd.sendRequest("strict", { n: 4 }); + + expect(parsed).toEqual([{ n: 40 }]); + expect(response).toEqual({ echoed: { n: 40 } }); + }); +}); + describe("proxy composition", () => { it("chains two proxies, each rewriting in flight", async () => { const [clientStream, firstClientSide] = inMemoryStreamPair(); From 811a8e3e3f9d6be00dcdfa027f43bff2203f7749 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Thu, 20 Aug 2026 17:18:23 -0400 Subject: [PATCH 20/21] fix: reference the documented v1 Stream type from ProxyStreams TypeDoc flagged ProxyStreams.client pointing at the internal generic Stream in stream.ts, which is not part of the documented v1 surface after the v1/v2 split on main. A type-only import of the acp.ts Stream resolves the docs reference without a runtime cycle. Co-Authored-By: Claude Fable 5 --- src/proxy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/proxy.ts b/src/proxy.ts index 76a3ead5..cdf05b5d 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -7,7 +7,7 @@ import type { JsonRpcHandler, MaybePromise, } from "./jsonrpc.js"; -import type { Stream } from "./stream.js"; +import type { Stream } from "./acp.js"; import type { AgentNotificationMethod, AgentNotificationParamsByMethod, From 5cfc1c32b1d3e06d13992cdb24e3800049a2d43b Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Thu, 20 Aug 2026 17:38:10 -0400 Subject: [PATCH 21/21] feat: scope proxy() to stable v1 by rejecting batch wire messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both proxy sides now pass allowBatches: false, matching every other stable v1 connection. Without this, a batch reaching the proxy would be silently relayed entry-by-entry — unspecified behavior that would harden into a contract and constrain the future v2 batch-relay design. Closing with a clear TypeError keeps v2 support an additive change on the same export. Co-Authored-By: Claude Fable 5 --- src/proxy.test.ts | 19 +++++++++++++++++++ src/proxy.ts | 48 +++++++++++++++++++++++++++++++---------------- 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/src/proxy.test.ts b/src/proxy.test.ts index e806edbf..29694a94 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -950,3 +950,22 @@ describe("proxy with fluent apps", () => { } }); }); + +describe("proxy batch rejection", () => { + it("closes both sides when a batch wire message arrives", async () => { + const { clientStream, handle } = setupProxy(); + const writer = clientStream.writable.getWriter(); + + await writer.write([ + { jsonrpc: "2.0", id: 1, method: "example/one" }, + { jsonrpc: "2.0", method: "example/notify" }, + ] as unknown as AnyMessage); + + await handle.closed; + expect(handle.client.signal.reason).toBeInstanceOf(TypeError); + expect(String(handle.client.signal.reason)).toContain( + "batches are not supported", + ); + expect(handle.agent.signal.reason).toBe(handle.client.signal.reason); + }); +}); diff --git a/src/proxy.ts b/src/proxy.ts index cdf05b5d..6c38aaa4 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -316,24 +316,35 @@ export class ProxyBuilder { connect(streams: ProxyStreams): ProxyHandle { // Snapshot so registrations made after connect(...) apply only to // subsequent connects — the same semantics as the fluent app builders. - const client: Connection = new Connection(streams.client, [ - serialize( - dispatcher( - new Map(this.clientRequests), - new Map(this.clientNotifications), - () => agent, + // Batches are rejected on both sides (as on every stable v1 connection): + // relaying batch entries individually would silently drop batch framing, + // and batch relay is not part of this proxy's contract. + const client: Connection = new Connection( + streams.client, + [ + serialize( + dispatcher( + new Map(this.clientRequests), + new Map(this.clientNotifications), + () => agent, + ), ), - ), - ]); - const agent: Connection = new Connection(streams.agent, [ - serialize( - dispatcher( - new Map(this.agentRequests), - new Map(this.agentNotifications), - () => client, + ], + { allowBatches: false }, + ); + const agent: Connection = new Connection( + streams.agent, + [ + serialize( + dispatcher( + new Map(this.agentRequests), + new Map(this.agentNotifications), + () => client, + ), ), - ), - ]); + ], + { allowBatches: false }, + ); // When either side closes, close the other with the same reason so its // pending requests reject with the true cause. void client.closed.then(() => agent.close(client.signal.reason)); @@ -385,6 +396,11 @@ function register( * as `session/request_permission` flow toward the client. The design * matches the `Proxy` role in ACP's other SDKs. * + * The proxy is scoped to stable ACP v1 connections: like every v1 + * connection, its sides reject JSON-RPC batch wire messages by closing with + * an error. Proxying the experimental batch-capable v2 transport is not + * supported. + * * Messages that no handler claims are forwarded untouched, preserving the * observable protocol behavior of a direct connection: *