From ed7aa43236291fc85aad8726358beef5c9231363 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Tue, 14 Jul 2026 17:15:45 -0400 Subject: [PATCH 01/19] 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 | 100 ++++++++++++---- src/proxy.test.ts | 288 ++++++++++++++++++++++++++++++++++++++++++++++ src/proxy.ts | 137 ++++++++++++++++++++++ 3 files changed, 505 insertions(+), 20 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 0fb42c4..0226f27 100644 --- a/src/acp.ts +++ b/src/acp.ts @@ -21,15 +21,40 @@ export { PROTOCOL_VERSION, } from "./schema/index.js"; export * from "./stream.js"; -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"; @@ -61,7 +86,14 @@ function isStream(value: unknown): value is Stream { ); } -function memoryStreamPair(): [Stream, Stream] { +/** + * 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 [ @@ -1774,8 +1806,22 @@ const appBuilder = Symbol("appBuilder"); const runAgentConnectHandlers = Symbol("runAgentConnectHandlers"); const runClientConnectHandlers = Symbol("runClientConnectHandlers"); -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 = { @@ -1829,9 +1875,7 @@ export class AgentApp { /** * Connects this agent app to a transport stream. */ - connect(stream: Stream): AgentConnection; - /** @internal */ - connect(stream: Stream, options: AppConnectOptions): AgentConnection; + connect(stream: Stream, options?: AppConnectOptions): AgentConnection; /** * Connects this agent app directly to a client app. * @@ -2004,17 +2048,17 @@ 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); 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 { @@ -2028,8 +2072,13 @@ export class AgentApp { return state; } - private openStreamConnection(stream: Stream): AgentConnectionState { - const rawConnection = this.builder.connect(stream); + private openStreamConnection( + stream: Stream, + options: AppConnectOptions = {}, + ): AgentConnectionState { + const rawConnection = this.builder.connect(stream, { + handlers: options.handlers, + }); return { rawConnection, connection: agentConnection(rawConnection, this.connectHandlers), @@ -2083,7 +2132,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. * @@ -2091,8 +2140,11 @@ export class ClientApp { * transport. */ connect(agent: AgentApp): ClientConnection; - connect(target: Stream | AgentApp): ClientConnection { - return this.connectConnection(target).connection; + connect( + target: Stream | AgentApp, + options: AppConnectOptions = {}, + ): ClientConnection { + return this.connectConnection(target, options).connection; } /** @@ -2248,17 +2300,20 @@ export class ClientApp { return this; } - private connectConnection(target: Stream | AgentApp): ClientConnectionState { + private connectConnection( + target: Stream | 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); 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 { @@ -2272,8 +2327,13 @@ export class ClientApp { return state; } - private openStreamConnection(stream: Stream): ClientConnectionState { - const rawConnection = this.builder.connect(stream); + private openStreamConnection( + stream: Stream, + options: AppConnectOptions = {}, + ): ClientConnectionState { + const rawConnection = this.builder.connect(stream, { + 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 0000000..7308644 --- /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 0000000..3a75289 --- /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 4685e052b64da7e62fe7626390e113e2fded0820 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Tue, 14 Jul 2026 17:25:48 -0400 Subject: [PATCH 02/19] 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 | 36 ++++++++++++++++---------------- src/jsonrpc.ts | 11 ++++++++++ src/proxy.ts | 56 +++++++++++++++++++++++++------------------------- 3 files changed, 57 insertions(+), 46 deletions(-) diff --git a/src/acp.ts b/src/acp.ts index 0226f27..a738953 100644 --- a/src/acp.ts +++ b/src/acp.ts @@ -21,7 +21,8 @@ export { PROTOCOL_VERSION, } from "./schema/index.js"; export * from "./stream.js"; -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 @@ -60,7 +61,12 @@ export type { } from "./jsonrpc.js"; import type { Stream } from "./stream.js"; -import { Connection, Handled, HandlerRegistration } from "./jsonrpc.js"; +import { + Connection, + Handled, + HandlerRegistration, + linkClosed, +} from "./jsonrpc.js"; import type { AnyMessage, ConnectionBuilder, @@ -2048,7 +2054,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); } @@ -2058,9 +2064,8 @@ export class AgentApp { const [thisStream, peerStream] = inMemoryStreamPair(); const peerRawConnection = target[appBuilder]().connect(peerStream); 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); @@ -2074,11 +2079,9 @@ export class AgentApp { private openStreamConnection( stream: Stream, - options: AppConnectOptions = {}, + handlers?: JsonRpcHandler[], ): AgentConnectionState { - const rawConnection = this.builder.connect(stream, { - handlers: options.handlers, - }); + const rawConnection = this.builder.connect(stream, { handlers }); return { rawConnection, connection: agentConnection(rawConnection, this.connectHandlers), @@ -2305,7 +2308,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; } @@ -2313,9 +2316,8 @@ export class ClientApp { const [thisStream, peerStream] = inMemoryStreamPair(); const peerRawConnection = target[appBuilder]().connect(peerStream); 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); @@ -2329,11 +2331,9 @@ export class ClientApp { private openStreamConnection( stream: Stream, - options: AppConnectOptions = {}, + handlers?: JsonRpcHandler[], ): ClientConnectionState { - const rawConnection = this.builder.connect(stream, { - handlers: options.handlers, - }); + const rawConnection = this.builder.connect(stream, { handlers }); return { rawConnection, connection: clientConnection(rawConnection, this.connectHandlers), diff --git a/src/jsonrpc.ts b/src/jsonrpc.ts index 504e0b1..5fe455e 100644 --- a/src/jsonrpc.ts +++ b/src/jsonrpc.ts @@ -1114,6 +1114,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 3a75289..9cc5ba4 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 aad2d35f55005b1c102722a40362861e04062554 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Tue, 14 Jul 2026 17:36:37 -0400 Subject: [PATCH 03/19] 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 6587f7e..33bde13 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,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 f00c3cd..58c5254 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 0000000..bec1eaa --- /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 7308644..88f887e 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 9cc5ba4..1e8fdfb 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 b6e3ea5e1f2453e906722b8c82e3e73e04ae5b3f Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 10:08:12 -0400 Subject: [PATCH 04/19] 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 5fe455e..281a4c5 100644 --- a/src/jsonrpc.ts +++ b/src/jsonrpc.ts @@ -398,7 +398,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 88f887e..200291b 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 1e8fdfb..7ef3f49 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 2ad5933ae0cbf3f1e579334a1b80ca5d70b74575 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 10:39:03 -0400 Subject: [PATCH 05/19] 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 33bde13..a6d4837 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,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 a738953..8364bc2 100644 --- a/src/acp.ts +++ b/src/acp.ts @@ -21,8 +21,15 @@ export { PROTOCOL_VERSION, } from "./schema/index.js"; export * from "./stream.js"; -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 bec1eaa..18046fc 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 200291b..53c034a 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 7ef3f49..f85fead 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 ea314d7a9d063044ebcf5007044dde141a9a8504 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 10:51:10 -0400 Subject: [PATCH 06/19] =?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 | 89 +++++++++++-------------------------------- src/examples/proxy.ts | 30 +++++++++------ src/proxy.test.ts | 36 ++++++++--------- src/proxy.ts | 39 ++++++++++--------- 4 files changed, 78 insertions(+), 116 deletions(-) diff --git a/src/acp.ts b/src/acp.ts index 8364bc2..5617dfa 100644 --- a/src/acp.ts +++ b/src/acp.ts @@ -21,48 +21,29 @@ export { PROTOCOL_VERSION, } from "./schema/index.js"; export * from "./stream.js"; -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"; @@ -1819,22 +1800,8 @@ const appBuilder = Symbol("appBuilder"); const runAgentConnectHandlers = Symbol("runAgentConnectHandlers"); const runClientConnectHandlers = Symbol("runClientConnectHandlers"); -/** - * 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 = { @@ -1888,7 +1855,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: Stream, options: AppConnectOptions): AgentConnection; /** * Connects this agent app directly to a client app. * @@ -2061,7 +2030,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); } @@ -2071,7 +2040,7 @@ export class AgentApp { const [thisStream, peerStream] = inMemoryStreamPair(); const peerRawConnection = target[appBuilder]().connect(peerStream); const peerConnection = clientConnection(peerRawConnection); - const state = this.openStreamConnection(thisStream, options.handlers); + const state = this.openStreamConnection(thisStream); linkClosed(state.rawConnection, peerRawConnection); try { target[runClientConnectHandlers](peerConnection); @@ -2084,11 +2053,8 @@ export class AgentApp { return state; } - private openStreamConnection( - stream: Stream, - handlers?: JsonRpcHandler[], - ): AgentConnectionState { - const rawConnection = this.builder.connect(stream, { handlers }); + private openStreamConnection(stream: Stream): AgentConnectionState { + const rawConnection = this.builder.connect(stream); return { rawConnection, connection: agentConnection(rawConnection, this.connectHandlers), @@ -2142,7 +2108,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. * @@ -2150,11 +2116,8 @@ export class ClientApp { * transport. */ connect(agent: AgentApp): ClientConnection; - connect( - target: Stream | AgentApp, - options: AppConnectOptions = {}, - ): ClientConnection { - return this.connectConnection(target, options).connection; + connect(target: Stream | AgentApp): ClientConnection { + return this.connectConnection(target).connection; } /** @@ -2310,12 +2273,9 @@ export class ClientApp { return this; } - private connectConnection( - target: Stream | AgentApp, - options: AppConnectOptions = {}, - ): ClientConnectionState { + private connectConnection(target: Stream | AgentApp): ClientConnectionState { if (isStream(target)) { - const state = this.openStreamConnection(target, options.handlers); + const state = this.openStreamConnection(target); this[runClientConnectHandlers](state.connection); return state; } @@ -2323,7 +2283,7 @@ export class ClientApp { const [thisStream, peerStream] = inMemoryStreamPair(); const peerRawConnection = target[appBuilder]().connect(peerStream); const peerConnection = agentConnection(peerRawConnection); - const state = this.openStreamConnection(thisStream, options.handlers); + const state = this.openStreamConnection(thisStream); linkClosed(state.rawConnection, peerRawConnection); try { target[runAgentConnectHandlers](peerConnection); @@ -2336,11 +2296,8 @@ export class ClientApp { return state; } - private openStreamConnection( - stream: Stream, - handlers?: JsonRpcHandler[], - ): ClientConnectionState { - const rawConnection = this.builder.connect(stream, { handlers }); + private openStreamConnection(stream: Stream): ClientConnectionState { + const rawConnection = this.builder.connect(stream); return { rawConnection, connection: clientConnection(rawConnection, this.connectHandlers), diff --git a/src/examples/proxy.ts b/src/examples/proxy.ts index 18046fc..77278a4 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 53c034a..fc1bdcb 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 f85fead..b5394d9 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 ddaa81a8b3b2883faa96ba16c4b0f94ad70502ef Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 10:56:00 -0400 Subject: [PATCH 07/19] 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 b5394d9..9fdf525 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 2ce3b297e225da232bb3add9aff7309a7d12bae5 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 11:02:19 -0400 Subject: [PATCH 08/19] 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 fc1bdcb..6010fd3 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 9fdf525..5a872d2 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 c38eac881e162491e3b3a5b797ea5015c2a7cb37 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 11:13:05 -0400 Subject: [PATCH 09/19] 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 6010fd3..d51b1e9 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 5a872d2..6f94ee2 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 90b9e15f4791ea455998a0a0f8c556598dcd9225 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 11:32:03 -0400 Subject: [PATCH 10/19] 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 | 40 +++--- src/jsonrpc.ts | 55 ++++++-- src/proxy.test.ts | 4 - src/proxy.ts | 285 ++++++++++++++++++++++-------------------- 6 files changed, 226 insertions(+), 191 deletions(-) diff --git a/src/acp.ts b/src/acp.ts index 5617dfa..6202714 100644 --- a/src/acp.ts +++ b/src/acp.ts @@ -34,6 +34,7 @@ export type { ProxySideBuilder, ProxySideConnection, ProxyStreams, + ProxyTap, } from "./proxy.js"; export { RequestError } from "./jsonrpc.js"; export type { @@ -44,6 +45,7 @@ export type { ErrorResponse, JsonRpcId, MaybePromise, + ParamsParser, Result, SendRequestOptions, } from "./jsonrpc.js"; @@ -54,6 +56,7 @@ import { Handled, HandlerRegistration, linkClosed, + parseParams, } from "./jsonrpc.js"; import type { AnyMessage, @@ -62,6 +65,7 @@ import type { HandleResult, IncomingMessage, JsonRpcId, + ParamsParser, JsonRpcHandler, MaybePromise, SendRequestOptions, @@ -937,14 +941,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. @@ -1060,21 +1056,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 77278a4..5014e19 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 0a9b0a8..836e1f1 100644 --- a/src/jsonrpc.test.ts +++ b/src/jsonrpc.test.ts @@ -1,8 +1,13 @@ import { describe, expect, it, vi } from "vitest"; -import { Connection, RequestError, isJsonRpcMessage } from "./jsonrpc.js"; +import { + Connection, + RequestError, + isJsonRpcMessage, + linkClosed, +} from "./jsonrpc.js"; import type { AnyMessage, RequestResponder } from "./jsonrpc.js"; -import type { Stream } from "./stream.js"; +import { inMemoryStreamPair } from "./acp.js"; type ConnectionInternals = { pendingResponses: Map; @@ -466,17 +471,20 @@ describe("JSON-RPC malformed peer messages", () => { }); }); -function memoryStreamPair(): [Stream, Stream] { - const leftToRight = new TransformStream(); - const rightToLeft = new TransformStream(); - return [ - { - readable: rightToLeft.readable, - writable: leftToRight.writable, - }, - { - readable: leftToRight.readable, - writable: rightToLeft.writable, - }, - ]; -} +const memoryStreamPair = inMemoryStreamPair; + +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 281a4c5..cb942ef 100644 --- a/src/jsonrpc.ts +++ b/src/jsonrpc.ts @@ -225,6 +225,40 @@ type ConnectionPendingResponse = { */ 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. */ @@ -398,12 +432,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(); } @@ -507,6 +536,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. */ @@ -992,9 +1031,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 d51b1e9..26607fe 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 6f94ee2..9832621 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 070aaa34a46814688651ab3be8ebacc20aa4fc47 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 12:01:13 -0400 Subject: [PATCH 11/19] 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 9832621..82607e9 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 8eb7a6e2dc4df8aeb29fc7cbf2e1dbf9d3801f3b Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 12:20:23 -0400 Subject: [PATCH 12/19] 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 a6d4837..1059407 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,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 6202714..e2830ff 100644 --- a/src/acp.ts +++ b/src/acp.ts @@ -31,10 +31,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 5014e19..f703088 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 26607fe..5bcd968 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 82607e9..01cb7fd 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 29e828c14037dd3e6f505d89bcb690b6dee6d79e Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 12:44:59 -0400 Subject: [PATCH 13/19] 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 | 40 +++++++++++---------------- src/jsonrpc.ts | 66 +++++++-------------------------------------- src/proxy.ts | 31 ++++++++++++++------- 4 files changed, 77 insertions(+), 103 deletions(-) diff --git a/src/acp.ts b/src/acp.ts index e2830ff..1e14206 100644 --- a/src/acp.ts +++ b/src/acp.ts @@ -22,8 +22,8 @@ export { } from "./schema/index.js"; export * from "./stream.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. +// 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, @@ -43,19 +43,12 @@ export type { ErrorResponse, JsonRpcId, MaybePromise, - ParamsParser, Result, SendRequestOptions, } from "./jsonrpc.js"; import type { Stream } from "./stream.js"; -import { - Connection, - Handled, - HandlerRegistration, - linkClosed, - parseParams, -} from "./jsonrpc.js"; +import { Connection, Handled, HandlerRegistration } from "./jsonrpc.js"; import type { AnyMessage, ConnectionBuilder, @@ -63,7 +56,6 @@ import type { HandleResult, IncomingMessage, JsonRpcId, - ParamsParser, JsonRpcHandler, MaybePromise, SendRequestOptions, @@ -939,6 +931,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. @@ -1054,6 +1054,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; @@ -2020,7 +2035,8 @@ export class AgentApp { const peerRawConnection = target[appBuilder]().connect(peerStream); 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); @@ -2263,7 +2279,8 @@ export class ClientApp { const peerRawConnection = target[appBuilder]().connect(peerStream); 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 836e1f1..0a9b0a8 100644 --- a/src/jsonrpc.test.ts +++ b/src/jsonrpc.test.ts @@ -1,13 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import { - Connection, - RequestError, - isJsonRpcMessage, - linkClosed, -} from "./jsonrpc.js"; +import { Connection, RequestError, isJsonRpcMessage } from "./jsonrpc.js"; import type { AnyMessage, RequestResponder } from "./jsonrpc.js"; -import { inMemoryStreamPair } from "./acp.js"; +import type { Stream } from "./stream.js"; type ConnectionInternals = { pendingResponses: Map; @@ -471,20 +466,17 @@ describe("JSON-RPC malformed peer messages", () => { }); }); -const memoryStreamPair = inMemoryStreamPair; - -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; - }); -}); +function memoryStreamPair(): [Stream, Stream] { + const leftToRight = new TransformStream(); + const rightToLeft = new TransformStream(); + return [ + { + readable: rightToLeft.readable, + writable: leftToRight.writable, + }, + { + readable: leftToRight.readable, + writable: rightToLeft.writable, + }, + ]; +} diff --git a/src/jsonrpc.ts b/src/jsonrpc.ts index cb942ef..536cd74 100644 --- a/src/jsonrpc.ts +++ b/src/jsonrpc.ts @@ -225,40 +225,6 @@ type ConnectionPendingResponse = { */ 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. */ @@ -460,7 +426,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 { @@ -536,16 +507,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. */ @@ -1031,7 +992,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) { @@ -1156,17 +1119,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 01cb7fd..9a90057 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 1856054c0fbd5e360a8f7ec0c83c12d963d16590 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 12:54:41 -0400 Subject: [PATCH 14/19] 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 1e14206..29ac3bc 100644 --- a/src/acp.ts +++ b/src/acp.ts @@ -74,14 +74,7 @@ function isStream(value: unknown): value is Stream { ); } -/** - * 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 [ @@ -2031,7 +2024,7 @@ export class AgentApp { return state; } - const [thisStream, peerStream] = inMemoryStreamPair(); + const [thisStream, peerStream] = memoryStreamPair(); const peerRawConnection = target[appBuilder]().connect(peerStream); const peerConnection = clientConnection(peerRawConnection); const state = this.openStreamConnection(thisStream); @@ -2275,7 +2268,7 @@ export class ClientApp { return state; } - const [thisStream, peerStream] = inMemoryStreamPair(); + const [thisStream, peerStream] = memoryStreamPair(); const peerRawConnection = target[appBuilder]().connect(peerStream); const peerConnection = agentConnection(peerRawConnection); const state = this.openStreamConnection(thisStream); diff --git a/src/proxy.test.ts b/src/proxy.test.ts index 5bcd968..d0c5bab 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 42d557e3620d4da0e945ffd9771df3a8fd5032c5 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 13:22:26 -0400 Subject: [PATCH 15/19] 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 d0c5bab..1249276 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 07e600b53dbb46e582d7e13538e2584002035194 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 13:38:38 -0400 Subject: [PATCH 16/19] 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 1249276..d161baf 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 1b30af1864b6cf61d530498f7606ead80c3d3490 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 14:15:02 -0400 Subject: [PATCH 17/19] 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 d161baf..0c285fb 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 1cc8ab0c67c02d8c62cd88c748199b981355ef70 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 14:19:29 -0400 Subject: [PATCH 18/19] 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 0c285fb..321f010 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 9a90057..76a3ead 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 5330af31c1cc066d077a3827511bc263990a2da4 Mon Sep 17 00:00:00 2001 From: Charles Covey-Brandt Date: Wed, 15 Jul 2026 14:29:38 -0400 Subject: [PATCH 19/19] 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 321f010..e806edb 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();