From 2ec0047e33dcb0a545dbb960ff60cc39bd7fe3b9 Mon Sep 17 00:00:00 2001 From: Federico Ciner Date: Sun, 17 May 2026 15:55:10 +1000 Subject: [PATCH 01/20] Add session methods and SSE stream functions --- src/protocol.test.ts | 109 ++++++++++++++++++++++++++++++++++ src/protocol.ts | 59 ++++++++++++++++++ src/sse.test.ts | 138 +++++++++++++++++++++++++++++++++++++++++++ src/sse.ts | 105 ++++++++++++++++++++++++++++++++ 4 files changed, 411 insertions(+) create mode 100644 src/protocol.test.ts create mode 100644 src/protocol.ts create mode 100644 src/sse.test.ts create mode 100644 src/sse.ts diff --git a/src/protocol.test.ts b/src/protocol.test.ts new file mode 100644 index 00000000..d27199b9 --- /dev/null +++ b/src/protocol.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; + +import { AGENT_METHODS } from "./schema/index.js"; +import { + methodRequiresSessionHeader, + sessionIdFromParams, + isInitializeRequest, + messageIdKey, + HEADER_CONNECTION_ID, + HEADER_SESSION_ID, + EVENT_STREAM_MIME_TYPE, + JSON_MIME_TYPE, +} from "./protocol.js"; + +import type { AnyMessage } from "./jsonrpc.js"; + +describe("protocol transport helpers", () => { + it("exports HTTP transport constants", () => { + expect(HEADER_CONNECTION_ID).toBe("Acp-Connection-Id"); + expect(HEADER_SESSION_ID).toBe("Acp-Session-Id"); + expect(EVENT_STREAM_MIME_TYPE).toBe("text/event-stream"); + expect(JSON_MIME_TYPE).toBe("application/json"); + }); + + it("requires a session header for existing-session methods", () => { + expect(methodRequiresSessionHeader(AGENT_METHODS.session_cancel)).toBe( + true, + ); + expect(methodRequiresSessionHeader(AGENT_METHODS.session_close)).toBe(true); + expect(methodRequiresSessionHeader(AGENT_METHODS.session_load)).toBe(true); + expect(methodRequiresSessionHeader(AGENT_METHODS.session_prompt)).toBe( + true, + ); + expect(methodRequiresSessionHeader(AGENT_METHODS.session_resume)).toBe( + true, + ); + expect( + methodRequiresSessionHeader(AGENT_METHODS.session_set_config_option), + ).toBe(true); + expect(methodRequiresSessionHeader(AGENT_METHODS.session_set_mode)).toBe( + true, + ); + expect(methodRequiresSessionHeader(AGENT_METHODS.session_set_model)).toBe( + true, + ); + }); + + it("does not require a session header for connection-level or unsupported methods", () => { + expect(methodRequiresSessionHeader(AGENT_METHODS.initialize)).toBe(false); + expect(methodRequiresSessionHeader(AGENT_METHODS.session_new)).toBe(false); + expect(methodRequiresSessionHeader(AGENT_METHODS.session_list)).toBe(false); + expect(methodRequiresSessionHeader(AGENT_METHODS.session_fork)).toBe(false); + expect(methodRequiresSessionHeader(AGENT_METHODS.nes_start)).toBe(false); + expect(methodRequiresSessionHeader(AGENT_METHODS.nes_suggest)).toBe(false); + expect(methodRequiresSessionHeader(AGENT_METHODS.nes_close)).toBe(false); + }); + + it("extracts a top-level string session ID from params", () => { + expect(sessionIdFromParams({ sessionId: "session-1" })).toBe("session-1"); + }); + + it("returns undefined when params do not contain a top-level string session ID", () => { + expect(sessionIdFromParams(undefined)).toBeUndefined(); + expect(sessionIdFromParams(null)).toBeUndefined(); + expect(sessionIdFromParams("session-1")).toBeUndefined(); + expect(sessionIdFromParams({})).toBeUndefined(); + expect(sessionIdFromParams({ sessionId: 1 })).toBeUndefined(); + expect( + sessionIdFromParams({ nested: { sessionId: "session-1" } }), + ).toBeUndefined(); + }); + + it("detects initialize requests", () => { + const request: AnyMessage = { + jsonrpc: "2.0", + id: 1, + method: AGENT_METHODS.initialize, + params: { protocolVersion: 1, clientCapabilities: {} }, + }; + + expect(isInitializeRequest(request)).toBe(true); + }); + + it("rejects non-initialize messages", () => { + const notification: AnyMessage = { + jsonrpc: "2.0", + method: AGENT_METHODS.initialize, + params: { protocolVersion: 1, clientCapabilities: {} }, + }; + const response: AnyMessage = { jsonrpc: "2.0", id: 1, result: {} }; + const otherRequest: AnyMessage = { + jsonrpc: "2.0", + id: 1, + method: AGENT_METHODS.session_new, + params: { cwd: "/tmp", mcpServers: [] }, + }; + + expect(isInitializeRequest(notification)).toBe(false); + expect(isInitializeRequest(response)).toBe(false); + expect(isInitializeRequest(otherRequest)).toBe(false); + }); + + it("normalizes JSON-RPC request IDs for map keys", () => { + expect(messageIdKey("foo")).toBe("string:foo"); + expect(messageIdKey(1)).toBe("number:1"); + expect(messageIdKey(null)).toBeUndefined(); + expect(messageIdKey(undefined)).toBeUndefined(); + }); +}); diff --git a/src/protocol.ts b/src/protocol.ts new file mode 100644 index 00000000..a88cb955 --- /dev/null +++ b/src/protocol.ts @@ -0,0 +1,59 @@ +import { AGENT_METHODS } from "./schema/index.js"; + +import type { AnyMessage } from "./jsonrpc.js"; + +export const HEADER_CONNECTION_ID = "Acp-Connection-Id"; +export const HEADER_SESSION_ID = "Acp-Session-Id"; +export const EVENT_STREAM_MIME_TYPE = "text/event-stream"; +export const JSON_MIME_TYPE = "application/json"; + +const SESSION_SCOPED_METHODS = new Set([ + AGENT_METHODS.session_cancel, + AGENT_METHODS.session_close, + AGENT_METHODS.session_load, + AGENT_METHODS.session_prompt, + AGENT_METHODS.session_resume, + AGENT_METHODS.session_set_config_option, + AGENT_METHODS.session_set_mode, + AGENT_METHODS.session_set_model, +]); + +export function methodRequiresSessionHeader(method: string): boolean { + return SESSION_SCOPED_METHODS.has(method); +} + +export function sessionIdFromParams(params: unknown): string | undefined { + if (!isRecord(params)) { + return undefined; + } + + const sessionId = params["sessionId"]; + return typeof sessionId === "string" ? sessionId : undefined; +} + +export function isInitializeRequest(msg: AnyMessage): boolean { + return ( + msg.jsonrpc === "2.0" && + "id" in msg && + "method" in msg && + msg.method === AGENT_METHODS.initialize + ); +} + +export function messageIdKey( + id: string | number | null | undefined, +): string | undefined { + if (typeof id === "string") { + return `string:${id}`; + } + + if (typeof id === "number") { + return `number:${id}`; + } + + return undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/src/sse.test.ts b/src/sse.test.ts new file mode 100644 index 00000000..6af3758e --- /dev/null +++ b/src/sse.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + parseSseStream, + serializeSseEvent, + serializeSseKeepAlive, +} from "./sse.js"; + +import type { AnyMessage } from "./jsonrpc.js"; + +const encoder = new TextEncoder(); + +function streamFromChunks(chunks: string[]): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + }, + }); +} + +async function collectMessages( + body: ReadableStream, +): Promise { + const messages: AnyMessage[] = []; + for await (const message of parseSseStream(body)) { + messages.push(message); + } + return messages; +} + +describe("SSE transport helpers", () => { + it("serializes a message event", () => { + const message: AnyMessage = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: 1 }, + }; + + expect(serializeSseEvent(message)).toBe( + `data: ${JSON.stringify(message)}\n\n`, + ); + }); + + it("serializes a keepalive comment", () => { + expect(serializeSseKeepAlive()).toBe(":\n\n"); + }); + + it("parses one event", async () => { + const message: AnyMessage = { jsonrpc: "2.0", id: 1, result: { ok: true } }; + + await expect( + collectMessages(streamFromChunks([serializeSseEvent(message)])), + ).resolves.toEqual([message]); + }); + + it("parses multiple events in one chunk", async () => { + const first: AnyMessage = { jsonrpc: "2.0", id: 1, result: { ok: true } }; + const second: AnyMessage = { + jsonrpc: "2.0", + method: "session/update", + params: { sessionId: "s1" }, + }; + + await expect( + collectMessages( + streamFromChunks([ + serializeSseEvent(first) + serializeSseEvent(second), + ]), + ), + ).resolves.toEqual([first, second]); + }); + + it("parses events split across chunk boundaries", async () => { + const message: AnyMessage = { + jsonrpc: "2.0", + id: "abc", + result: { ok: true }, + }; + const serialized = serializeSseEvent(message); + + await expect( + collectMessages( + streamFromChunks([ + serialized.slice(0, 7), + serialized.slice(7, 18), + serialized.slice(18), + ]), + ), + ).resolves.toEqual([message]); + }); + + it("ignores comments, keepalives, and non-data fields", async () => { + const message: AnyMessage = { jsonrpc: "2.0", id: 1, result: { ok: true } }; + + await expect( + collectMessages( + streamFromChunks([ + `:\n\nevent: message\nid: 1\ndata: ${JSON.stringify(message)}\nretry: 1000\n\n`, + ]), + ), + ).resolves.toEqual([message]); + }); + + it("joins multiline data fields", async () => { + const expected: AnyMessage = { + jsonrpc: "2.0", + id: 1, + result: { ok: true }, + }; + const body = [ + 'data: {"jsonrpc":"2.0",\n', + 'data: "id":1,\n', + 'data: "result":{"ok":true}}\n\n', + ]; + + await expect(collectMessages(streamFromChunks(body))).resolves.toEqual([ + expected, + ]); + }); + + it("skips malformed JSON without throwing", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const message: AnyMessage = { jsonrpc: "2.0", id: 1, result: { ok: true } }; + + await expect( + collectMessages( + streamFromChunks(["data: {not-json}\n\n", serializeSseEvent(message)]), + ), + ).resolves.toEqual([message]); + expect(warn).toHaveBeenCalledOnce(); + + warn.mockRestore(); + }); +}); diff --git a/src/sse.ts b/src/sse.ts new file mode 100644 index 00000000..9b7b8cb2 --- /dev/null +++ b/src/sse.ts @@ -0,0 +1,105 @@ +import type { AnyMessage } from "./jsonrpc.js"; + +export function serializeSseEvent(msg: AnyMessage): string { + return `data: ${JSON.stringify(msg)}\n\n`; +} + +export function serializeSseKeepAlive(): string { + return ":\n\n"; +} + +export async function* parseSseStream( + body: ReadableStream, +): AsyncIterable { + const decoder = new TextDecoder(); + const reader = body.getReader(); + let buffer = ""; + + try { + while (true) { + const chunk = await reader.read(); + + if (chunk.done) { + buffer += decoder.decode(); + yield* parseBufferedEvents(buffer); + return; + } + + buffer += decoder.decode(chunk.value, { stream: true }); + const eventParts = buffer.split(/\r?\n\r?\n/); + buffer = eventParts.pop() ?? ""; + + for (const eventPart of eventParts) { + const msg = parseSseEvent(eventPart); + if (msg) { + yield msg; + } + } + } + } finally { + reader.releaseLock(); + } +} + +function* parseBufferedEvents(buffer: string): Iterable { + if (!buffer.trim()) { + return; + } + + const eventParts = buffer.split(/\r?\n\r?\n/); + + for (const eventPart of eventParts) { + const msg = parseSseEvent(eventPart); + if (msg) { + yield msg; + } + } +} + +function parseSseEvent(eventPart: string): AnyMessage | undefined { + const dataLines = eventPart + .split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => { + const value = line.slice("data:".length); + return value.startsWith(" ") ? value.slice(1) : value; + }); + + if (dataLines.length === 0) { + return undefined; + } + + const data = dataLines.join("\n"); + if (!data.trim()) { + return undefined; + } + + try { + const parsed: unknown = JSON.parse(data); + if (isAnyMessage(parsed)) { + return parsed; + } + + console.warn("Skipping SSE payload that is not a JSON-RPC message"); + return undefined; + } catch (error) { + console.warn("Failed to parse SSE JSON payload:", error); + return undefined; + } +} + +function isAnyMessage(value: unknown): value is AnyMessage { + if (!isRecord(value) || value["jsonrpc"] !== "2.0") { + return false; + } + + if ("method" in value) { + return typeof value["method"] === "string"; + } + + return "id" in value; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} From 3d1964b0df9d44a93d9ab578d06c74c42ce85a5e Mon Sep 17 00:00:00 2001 From: Federico Ciner Date: Mon, 18 May 2026 15:25:41 +1000 Subject: [PATCH 02/20] Add server implementation with Node HTTP adapter, only support POST and initialize --- package.json | 15 ++ src/connection.test.ts | 75 ++++++++ src/connection.ts | 99 ++++++++++ src/node-adapter.test.ts | 167 ++++++++++++++++ src/node-adapter.ts | 138 ++++++++++++++ src/server.test.ts | 274 +++++++++++++++++++++++++++ src/server.ts | 172 +++++++++++++++++ src/test-support/test-agent.ts | 85 +++++++++ src/test-support/test-http-server.ts | 74 ++++++++ 9 files changed, 1099 insertions(+) create mode 100644 src/connection.test.ts create mode 100644 src/connection.ts create mode 100644 src/node-adapter.test.ts create mode 100644 src/node-adapter.ts create mode 100644 src/server.test.ts create mode 100644 src/server.ts create mode 100644 src/test-support/test-agent.ts create mode 100644 src/test-support/test-http-server.ts diff --git a/package.json b/package.json index df8cfec4..97d3fe85 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,21 @@ "type": "module", "main": "dist/acp.js", "types": "dist/acp.d.ts", + "exports": { + ".": { + "types": "./dist/acp.d.ts", + "default": "./dist/acp.js" + }, + "./server": { + "types": "./dist/server.d.ts", + "default": "./dist/server.js" + }, + "./node": { + "types": "./dist/node-adapter.d.ts", + "default": "./dist/node-adapter.js" + }, + "./schema/schema.json": "./schema/schema.json" + }, "directories": { "example": "examples" }, diff --git a/src/connection.test.ts b/src/connection.test.ts new file mode 100644 index 00000000..f07bdb0b --- /dev/null +++ b/src/connection.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { ConnectionRegistry } from "./connection.js"; +import { TestAgent } from "./test-support/test-agent.js"; + +import type { AgentSideConnection } from "./acp.js"; + +const initializeRequest = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: 1, + clientCapabilities: {}, + }, +} as const; + +describe("ConnectionRegistry", () => { + it("creates retrievable connections with unique UUID connection IDs", () => { + const registry = new ConnectionRegistry(); + const first = registry.createConnection( + (conn: AgentSideConnection) => new TestAgent(conn), + ); + const second = registry.createConnection( + (conn: AgentSideConnection) => new TestAgent(conn), + ); + + expect(first.connectionId).toMatch(/^[0-9a-f-]{36}$/); + expect(second.connectionId).toMatch(/^[0-9a-f-]{36}$/); + expect(first.connectionId).not.toBe(second.connectionId); + expect(registry.get(first.connectionId)).toBe(first); + expect(registry.get(second.connectionId)).toBe(second); + + registry.closeAll(); + }); + + it("removes connections", () => { + const registry = new ConnectionRegistry(); + const connection = registry.createConnection( + (conn: AgentSideConnection) => new TestAgent(conn), + ); + + expect(registry.remove(connection.connectionId)).toBe(connection); + expect(registry.get(connection.connectionId)).toBeUndefined(); + expect(registry.remove(connection.connectionId)).toBeUndefined(); + }); + + it("receives the initialize response directly from the agent", async () => { + const registry = new ConnectionRegistry(); + const connection = registry.createConnection( + (conn: AgentSideConnection) => new TestAgent(conn), + ); + const writer = connection.inboundTx.getWriter(); + + try { + await writer.write(initializeRequest); + } finally { + writer.releaseLock(); + } + + const response = await connection.recvInitial(initializeRequest.id); + + expect(response).toMatchObject({ + jsonrpc: "2.0", + id: initializeRequest.id, + result: { + protocolVersion: 1, + agentCapabilities: { + loadSession: false, + }, + }, + }); + + registry.closeAll(); + }); +}); diff --git a/src/connection.ts b/src/connection.ts new file mode 100644 index 00000000..1bc9babb --- /dev/null +++ b/src/connection.ts @@ -0,0 +1,99 @@ +import { AgentSideConnection } from "./acp.js"; + +import type { Agent } from "./acp.js"; +import type { AnyMessage, AnyResponse } from "./jsonrpc.js"; +import type { Stream } from "./stream.js"; + +export class ConnectionState { + readonly connectionId: string; + readonly inboundTx: WritableStream; + readonly outboundRx: ReadableStream; + readonly agentConnection: AgentSideConnection; + + constructor(agentFactory: (conn: AgentSideConnection) => Agent) { + this.connectionId = globalThis.crypto.randomUUID(); + const inbound = new TransformStream(); + const outbound = new TransformStream(); + + this.inboundTx = inbound.writable; + this.outboundRx = outbound.readable; + + const stream: Stream = { + readable: inbound.readable, + writable: outbound.writable, + }; + + this.agentConnection = new AgentSideConnection(agentFactory, stream); + } + + async recvInitial(initializeId: string | number): Promise { + const reader = this.outboundRx.getReader(); + + try { + const result = await reader.read(); + + if ( + result.done || + !result.value || + !isMatchingResponse(result.value, initializeId) + ) { + await this.shutdown(); + throw new Error("Expected initialize response from agent"); + } + + return result.value; + } finally { + reader.releaseLock(); + } + } + + async shutdown() { + await Promise.allSettled([ + this.inboundTx.close(), + this.outboundRx.cancel(), + ]); + } +} + +export class ConnectionRegistry { + private readonly connections = new Map(); + + createConnection( + agentFactory: (conn: AgentSideConnection) => Agent, + ): ConnectionState { + const connection = new ConnectionState(agentFactory); + this.connections.set(connection.connectionId, connection); + return connection; + } + + get(connectionId: string): ConnectionState | undefined { + return this.connections.get(connectionId); + } + + remove(connectionId: string): ConnectionState | undefined { + const connection = this.get(connectionId); + + if (!connection) { + return undefined; + } + + this.connections.delete(connectionId); + void connection.shutdown(); + return connection; + } + + closeAll(): void { + for (const connection of this.connections.values()) { + void connection.shutdown(); + } + + this.connections.clear(); + } +} + +function isMatchingResponse( + msg: AnyMessage, + id: string | number, +): msg is AnyResponse { + return "id" in msg && !("method" in msg) && msg.id === id; +} diff --git a/src/node-adapter.test.ts b/src/node-adapter.test.ts new file mode 100644 index 00000000..629d7584 --- /dev/null +++ b/src/node-adapter.test.ts @@ -0,0 +1,167 @@ +import http from "node:http"; + +import { describe, expect, it } from "vitest"; +import { AcpServer } from "./server.js"; +import { createNodeHttpHandler } from "./node-adapter.js"; +import { TestAgent } from "./test-support/test-agent.js"; + +import type { AgentSideConnection } from "./acp.js"; + +interface RunningServer { + readonly url: string; + readonly close: () => Promise; +} + +describe("createNodeHttpHandler", () => { + it("forwards method, URL, headers, and body to AcpServer.handleRequest", async () => { + const acpServer = new AcpServer({ + createAgent: (conn: AgentSideConnection) => new TestAgent(conn), + }); + const seenRequests: Request[] = []; + const seenBodies: string[] = []; + acpServer.handleRequest = async (req) => { + seenRequests.push(req); + seenBodies.push(await req.text()); + return new Response("created", { + status: 201, + headers: { + "X-Adapter-Test": "ok", + }, + }); + }; + + const server = await startNodeServer(acpServer); + + try { + const response = await fetch(`${server.url}/acp?hello=world`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Client-Test": "forwarded", + }, + body: JSON.stringify({ ok: true }), + }); + + expect(response.status).toBe(201); + expect(response.headers.get("X-Adapter-Test")).toBe("ok"); + expect(await response.text()).toBe("created"); + expect(seenRequests).toHaveLength(1); + expect(seenRequests[0]?.method).toBe("POST"); + expect(seenRequests[0]?.url).toBe(`${server.url}/acp?hello=world`); + expect(seenRequests[0]?.headers.get("Content-Type")).toBe( + "application/json", + ); + expect(seenRequests[0]?.headers.get("X-Client-Test")).toBe("forwarded"); + expect(seenBodies).toEqual([JSON.stringify({ ok: true })]); + } finally { + await server.close(); + } + }); + + it("streams response bodies to ServerResponse", async () => { + const acpServer = new AcpServer({ + createAgent: (conn: AgentSideConnection) => new TestAgent(conn), + }); + acpServer.handleRequest = () => + Promise.resolve( + new Response( + new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + controller.enqueue(encoder.encode("data: one\n\n")); + controller.enqueue(encoder.encode("data: two\n\n")); + controller.close(); + }, + }), + { + status: 200, + headers: { + "Content-Type": "text/event-stream", + }, + }, + ), + ); + + const server = await startNodeServer(acpServer); + + try { + const response = await fetch(server.url, { method: "POST" }); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toContain( + "text/event-stream", + ); + expect(await response.text()).toBe("data: one\n\ndata: two\n\n"); + } finally { + await server.close(); + } + }); + + it("handles empty response bodies", async () => { + const acpServer = new AcpServer({ + createAgent: (conn: AgentSideConnection) => new TestAgent(conn), + }); + acpServer.handleRequest = () => + Promise.resolve( + new Response(null, { + status: 202, + headers: { + "X-Empty-Body": "yes", + }, + }), + ); + + const server = await startNodeServer(acpServer); + + try { + const response = await fetch(server.url, { method: "POST" }); + + expect(response.status).toBe(202); + expect(response.headers.get("X-Empty-Body")).toBe("yes"); + expect(await response.text()).toBe(""); + } finally { + await server.close(); + } + }); +}); + +async function startNodeServer(acpServer: AcpServer): Promise { + const server = http.createServer(createNodeHttpHandler(acpServer)); + + await new Promise((resolve, reject) => { + const onError = (error: Error): void => { + server.off("listening", onListening); + reject(error); + }; + + const onListening = (): void => { + server.off("error", onError); + resolve(); + }; + + server.once("error", onError); + server.once("listening", onListening); + server.listen(0, "127.0.0.1"); + }); + + const address = server.address(); + + if (typeof address !== "object" || address === null) { + throw new Error("Node test server did not bind to a TCP port"); + } + + return { + url: `http://127.0.0.1:${address.port}`, + close: () => + new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }), + }; +} diff --git a/src/node-adapter.ts b/src/node-adapter.ts new file mode 100644 index 00000000..8a3d91d4 --- /dev/null +++ b/src/node-adapter.ts @@ -0,0 +1,138 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { AcpServer } from "./server.js"; + +export function createNodeHttpHandler( + server: AcpServer, +): (req: IncomingMessage, res: ServerResponse) => void { + return (req, res) => { + void handleNodeRequest(server, req, res); + }; +} + +async function handleNodeRequest( + server: AcpServer, + req: IncomingMessage, + res: ServerResponse, +): Promise { + try { + await writeNodeResponse( + res, + await server.handleRequest(await toWebRequest(req)), + ); + } catch (error) { + if (!res.headersSent) { + res.statusCode = 500; + res.setHeader("Content-Type", "text/plain"); + } + + res.end(error instanceof Error ? error.message : "Internal Server Error"); + } +} + +async function toWebRequest(req: IncomingMessage): Promise { + return new Request(nodeRequestUrl(req), { + method: req.method ?? "GET", + headers: nodeHeaders(req), + body: hasRequestBody(req) ? await readRequestBody(req) : undefined, + }); +} + +function hasRequestBody(req: IncomingMessage): boolean { + return req.method !== "GET" && req.method !== "HEAD"; +} + +async function readRequestBody(req: IncomingMessage): Promise { + const chunks: string[] = []; + + for await (const chunk of req) { + chunks.push( + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"), + ); + } + + return chunks.join(""); +} + +function nodeRequestUrl(req: IncomingMessage): string { + const host = req.headers.host ?? "localhost"; + return `http://${host}${req.url ?? "/"}`; +} + +function nodeHeaders(req: IncomingMessage): Headers { + const headers = new Headers(); + + for (const [name, value] of Object.entries(req.headers)) { + if (Array.isArray(value)) { + for (const item of value) { + headers.append(name, item); + } + + continue; + } + + if (value !== undefined) { + headers.set(name, value); + } + } + + return headers; +} + +async function writeNodeResponse( + res: ServerResponse, + response: Response, +): Promise { + res.statusCode = response.status; + + response.headers.forEach((value, name) => { + res.setHeader(name, value); + }); + + const responseBody = response.body; + + if (!responseBody) { + res.end(); + return; + } + + const reader = responseBody.getReader(); + + try { + while (true) { + const result = await reader.read(); + + if (result.done) { + res.end(); + return; + } + + await writeChunk(res, result.value); + } + } finally { + reader.releaseLock(); + } +} + +function writeChunk(res: ServerResponse, chunk: Uint8Array): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error): void => { + res.off("drain", onDrain); + reject(error); + }; + + const onDrain = (): void => { + res.off("error", onError); + resolve(); + }; + + res.once("error", onError); + + if (res.write(chunk)) { + res.off("error", onError); + resolve(); + return; + } + + res.once("drain", onDrain); + }); +} diff --git a/src/server.test.ts b/src/server.test.ts new file mode 100644 index 00000000..9268338b --- /dev/null +++ b/src/server.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from "vitest"; +import { HEADER_CONNECTION_ID, JSON_MIME_TYPE } from "./protocol.js"; +import { startTestServer } from "./test-support/test-http-server.js"; +import { TestAgent } from "./test-support/test-agent.js"; + +import type { AgentSideConnection } from "./acp.js"; + +const initializeRequest = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: 1, + clientCapabilities: {}, + }, +}; + +describe("AcpServer", () => { + it("handles initialize over HTTP and returns a connection ID", async () => { + const server = await startTestServer(); + + try { + const response = await postJson(server.url, initializeRequest); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(response.headers.get(HEADER_CONNECTION_ID)).toMatch( + /^[0-9a-f-]{36}$/, + ); + expect(body).toMatchObject({ + jsonrpc: "2.0", + id: 1, + result: { + protocolVersion: 1, + agentCapabilities: { + loadSession: false, + }, + }, + }); + } finally { + await server.close(); + } + }); + + it.each(["GET", "PUT", "PATCH", "DELETE"])( + "rejects %s requests in Phase 1", + async (method) => { + const server = await startTestServer(); + + try { + const response = await fetch(server.url, { method }); + + expect(response.status).toBe(405); + } finally { + await server.close(); + } + }, + ); + + it("rejects POST without application/json Content-Type", async () => { + const server = await startTestServer(); + + try { + const response = await fetch(server.url, { + method: "POST", + headers: { + "Content-Type": "text/plain", + }, + body: JSON.stringify(initializeRequest), + }); + + expect(response.status).toBe(415); + } finally { + await server.close(); + } + }); + + it("rejects invalid JSON", async () => { + const server = await startTestServer(); + + try { + const response = await fetch(server.url, { + method: "POST", + headers: { + "Content-Type": JSON_MIME_TYPE, + }, + body: "{ nope", + }); + + expect(response.status).toBe(400); + } finally { + await server.close(); + } + }); + + it("rejects JSON-RPC batches", async () => { + const server = await startTestServer(); + + try { + const response = await postJson(server.url, [initializeRequest]); + + expect(response.status).toBe(501); + } finally { + await server.close(); + } + }); + + it.each([ + null, + "initialize", + 1, + {}, + { jsonrpc: "1.0", method: "initialize" }, + ])("rejects invalid JSON-RPC messages", async (body) => { + const server = await startTestServer(); + + try { + const response = await postJson(server.url, body); + + expect(response.status).toBe(400); + } finally { + await server.close(); + } + }); + + it("rejects non-initialize requests without a connection ID", async () => { + const server = await startTestServer(); + + try { + const response = await postJson(server.url, { + jsonrpc: "2.0", + id: 2, + method: "session/new", + params: { + cwd: "/tmp", + mcpServers: [], + }, + }); + + expect(response.status).toBe(400); + } finally { + await server.close(); + } + }); + + it("rejects unknown connection IDs", async () => { + const server = await startTestServer(); + + try { + const response = await postJson( + server.url, + { + jsonrpc: "2.0", + id: 2, + method: "session/new", + params: { + cwd: "/tmp", + mcpServers: [], + }, + }, + { + [HEADER_CONNECTION_ID]: globalThis.crypto.randomUUID(), + }, + ); + + expect(response.status).toBe(404); + } finally { + await server.close(); + } + }); + + it("rejects connected POSTs after initialize in Phase 1", async () => { + const server = await startTestServer(); + + try { + const initializeResponse = await postJson(server.url, initializeRequest); + const connectionId = initializeResponse.headers.get(HEADER_CONNECTION_ID); + + expect(connectionId).toBeTruthy(); + + const response = await postJson( + server.url, + { + jsonrpc: "2.0", + id: 2, + method: "session/new", + params: { + cwd: "/tmp", + mcpServers: [], + }, + }, + { + [HEADER_CONNECTION_ID]: connectionId ?? "", + }, + ); + + expect(response.status).toBe(400); + } finally { + await server.close(); + } + }); + + it("returns an error response when agent creation fails", async () => { + const server = await startTestServer(() => { + throw new Error("agent factory failed"); + }); + + try { + const response = await postJson(server.url, initializeRequest); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(response.headers.get(HEADER_CONNECTION_ID)).toBeNull(); + expect(body).toMatchObject({ + jsonrpc: "2.0", + id: 1, + error: { + code: -32603, + message: "Initialize failed", + data: "agent factory failed", + }, + }); + } finally { + await server.close(); + } + }); + + it("returns JSON-RPC initialize errors as the initialize response", async () => { + class FailingInitializeAgent extends TestAgent { + initialize() { + return Promise.reject(new Error("initialize failed")); + } + } + + const server = await startTestServer( + (conn: AgentSideConnection) => new FailingInitializeAgent(conn), + ); + + try { + const response = await postJson(server.url, initializeRequest); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(response.headers.get(HEADER_CONNECTION_ID)).toMatch( + /^[0-9a-f-]{36}$/, + ); + expect(body).toMatchObject({ + jsonrpc: "2.0", + id: 1, + error: { + code: -32603, + message: "Internal error", + }, + }); + } finally { + await server.close(); + } + }); +}); + +function postJson( + url: string, + body: unknown, + headers: Record = {}, +): Promise { + return fetch(url, { + method: "POST", + headers: { + "Content-Type": JSON_MIME_TYPE, + ...headers, + }, + body: JSON.stringify(body), + }); +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 00000000..9d2bee86 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,172 @@ +import { ConnectionRegistry } from "./connection.js"; +import { + HEADER_CONNECTION_ID, + JSON_MIME_TYPE, + isInitializeRequest, +} from "./protocol.js"; + +import type { Agent, AgentSideConnection } from "./acp.js"; +import type { AnyMessage } from "./jsonrpc.js"; + +export interface AcpServerOptions { + createAgent: (conn: AgentSideConnection) => Agent; +} + +export class AcpServer { + private readonly createAgent: (conn: AgentSideConnection) => Agent; + private readonly registry = new ConnectionRegistry(); + + constructor(options: AcpServerOptions) { + this.createAgent = options.createAgent; + } + + async handleRequest(req: Request): Promise { + if (req.method !== "POST") { + return textResponse("Method Not Allowed", 405); + } + + const contentType = req.headers.get("Content-Type"); + + if (!contentType?.startsWith(JSON_MIME_TYPE)) { + return textResponse("Unsupported Media Type", 415); + } + + const body = await readJson(req); + + if (!body.ok) { + return textResponse("Invalid JSON", 400); + } + + if (Array.isArray(body.value)) { + return textResponse("Batch JSON-RPC requests are not implemented", 501); + } + + if (!isJsonRpcMessage(body.value)) { + return textResponse("Invalid JSON-RPC message", 400); + } + + const connectionId = req.headers.get(HEADER_CONNECTION_ID); + + if (isInitializeRequest(body.value) && !connectionId) { + return await this.handleInitialize(body.value); + } + + if (!connectionId) { + return textResponse("Missing Acp-Connection-Id", 400); + } + + if (!this.registry.get(connectionId)) { + return textResponse("Unknown Acp-Connection-Id", 404); + } + + return textResponse( + "Connected POST handling is not implemented in Phase 1", + 400, + ); + } + + async close(): Promise { + this.registry.closeAll(); + } + + private async handleInitialize(message: AnyMessage): Promise { + if (!("id" in message) || message.id === null) { + return textResponse("Initialize request must include an ID", 400); + } + + let connection: + | ReturnType + | undefined; + + try { + connection = this.registry.createConnection(this.createAgent); + const writer = connection.inboundTx.getWriter(); + + try { + await writer.write(message); + } finally { + writer.releaseLock(); + } + + const initialResponse = await connection.recvInitial(message.id); + + return jsonResponse(initialResponse, 200, { + [HEADER_CONNECTION_ID]: connection.connectionId, + }); + } catch (error) { + if (connection) { + this.registry.remove(connection.connectionId); + } + + return jsonResponse( + { + jsonrpc: "2.0", + id: message.id, + error: { + code: -32603, + message: "Initialize failed", + data: error instanceof Error ? error.message : undefined, + }, + }, + 500, + ); + } + } +} + +type JsonResult = + | { + ok: true; + value: unknown; + } + | { + ok: false; + }; + +async function readJson(req: Request): Promise { + try { + return { + ok: true, + value: await req.json(), + }; + } catch { + return { + ok: false, + }; + } +} + +function isJsonRpcMessage(value: unknown): value is AnyMessage { + return ( + isRecord(value) && + value.jsonrpc === "2.0" && + ("method" in value || "id" in value) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function jsonResponse( + value: unknown, + status: number, + headers?: HeadersInit, +): Response { + return new Response(JSON.stringify(value), { + status, + headers: { + "Content-Type": JSON_MIME_TYPE, + ...headers, + }, + }); +} + +function textResponse(body: string, status: number): Response { + return new Response(body, { + status, + headers: { + "Content-Type": "text/plain", + }, + }); +} diff --git a/src/test-support/test-agent.ts b/src/test-support/test-agent.ts new file mode 100644 index 00000000..a1fcc87e --- /dev/null +++ b/src/test-support/test-agent.ts @@ -0,0 +1,85 @@ +import { PROTOCOL_VERSION } from "../schema/index.js"; + +import type { + Agent, + AgentSideConnection, + AuthenticateRequest, + AuthenticateResponse, + CancelNotification, + InitializeRequest, + InitializeResponse, + NewSessionRequest, + NewSessionResponse, + PromptRequest, + PromptResponse, +} from "../acp.js"; + +export interface TestAgentOptions { + readonly chunkCount?: number; + readonly chunkDelayMs?: number; +} + +export class TestAgent implements Agent { + private readonly connection: AgentSideConnection; + private readonly chunkCount: number; + private readonly chunkDelayMs: number; + + constructor(connection: AgentSideConnection, options: TestAgentOptions = {}) { + this.connection = connection; + this.chunkCount = options.chunkCount ?? 1; + this.chunkDelayMs = options.chunkDelayMs ?? 0; + } + + initialize(_params: InitializeRequest): Promise { + return Promise.resolve({ + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { + loadSession: false, + }, + }); + } + + newSession(_params: NewSessionRequest): Promise { + return Promise.resolve({ sessionId: globalThis.crypto.randomUUID() }); + } + + authenticate( + _params: AuthenticateRequest, + ): Promise { + return Promise.resolve(); + } + + async prompt(params: PromptRequest): Promise { + for (const index of Array.from( + { length: this.chunkCount }, + (_, item) => item, + )) { + if (this.chunkDelayMs > 0) { + await delay(this.chunkDelayMs); + } + + await this.connection.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: `chunk-${index + 1}`, + }, + }, + }); + } + + return { stopReason: "end_turn" }; + } + + cancel(_params: CancelNotification): Promise { + return Promise.resolve(); + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/src/test-support/test-http-server.ts b/src/test-support/test-http-server.ts new file mode 100644 index 00000000..de29c5ef --- /dev/null +++ b/src/test-support/test-http-server.ts @@ -0,0 +1,74 @@ +import http from "node:http"; + +import { AcpServer } from "../server.js"; +import { createNodeHttpHandler } from "../node-adapter.js"; +import { TestAgent } from "./test-agent.js"; + +import type { AddressInfo } from "node:net"; +import type { Agent, AgentSideConnection } from "../acp.js"; + +export interface TestHttpServer { + readonly url: string; + readonly close: () => Promise; +} + +export async function startTestServer( + agentFactory: (conn: AgentSideConnection) => Agent = (conn) => + new TestAgent(conn), + options: { port?: number } = {}, +): Promise { + const acpServer = new AcpServer({ createAgent: agentFactory }); + const httpServer = http.createServer(createNodeHttpHandler(acpServer)); + + await listen(httpServer, options.port ?? 0); + + const address = httpServer.address(); + + if (!isAddressInfo(address)) { + throw new Error("Test HTTP server did not bind to a TCP port"); + } + + return { + url: `http://127.0.0.1:${address.port}`, + close: async () => { + await Promise.all([acpServer.close(), closeHttpServer(httpServer)]); + }, + }; +} + +function listen(server: http.Server, port: number): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error): void => { + server.off("listening", onListening); + reject(error); + }; + + const onListening = (): void => { + server.off("error", onError); + resolve(); + }; + + server.once("error", onError); + server.once("listening", onListening); + server.listen(port, "127.0.0.1"); + }); +} + +function closeHttpServer(server: http.Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); +} + +function isAddressInfo( + address: ReturnType, +): address is AddressInfo { + return typeof address === "object" && address !== null; +} From ae4a1991105fb95222797b09d98b37c54be2ddc0 Mon Sep 17 00:00:00 2001 From: Federico Ciner Date: Mon, 18 May 2026 16:46:22 +1000 Subject: [PATCH 03/20] Add SSE routing and session/new --- src/connection.test.ts | 196 ++++++++++++++++++++++- src/connection.ts | 240 +++++++++++++++++++++++++++- src/node-adapter.ts | 2 + src/server.test.ts | 348 +++++++++++++++++++++++++++++++++-------- src/server.ts | 219 ++++++++++++++++++++++++-- 5 files changed, 910 insertions(+), 95 deletions(-) diff --git a/src/connection.test.ts b/src/connection.test.ts index f07bdb0b..e0f52ecf 100644 --- a/src/connection.test.ts +++ b/src/connection.test.ts @@ -1,8 +1,14 @@ -import { describe, expect, it } from "vitest"; -import { ConnectionRegistry } from "./connection.js"; +import { describe, expect, it, vi } from "vitest"; +import { + ConnectionRegistry, + OutboundStream, + type ResponseRoute, +} from "./connection.js"; +import { messageIdKey } from "./protocol.js"; import { TestAgent } from "./test-support/test-agent.js"; import type { AgentSideConnection } from "./acp.js"; +import type { AnyMessage } from "./jsonrpc.js"; const initializeRequest = { jsonrpc: "2.0", @@ -14,6 +20,21 @@ const initializeRequest = { }, } as const; +const sessionNewRequest = { + jsonrpc: "2.0", + id: 2, + method: "session/new", + params: { + cwd: "/tmp", + mcpServers: [], + }, +} as const; + +const messageOne = { jsonrpc: "2.0", id: 1, result: "one" } as const; +const messageTwo = { jsonrpc: "2.0", id: 2, result: "two" } as const; +const messageThree = { jsonrpc: "2.0", id: 3, result: "three" } as const; +const messageFour = { jsonrpc: "2.0", id: 4, result: "four" } as const; + describe("ConnectionRegistry", () => { it("creates retrievable connections with unique UUID connection IDs", () => { const registry = new ConnectionRegistry(); @@ -49,13 +70,8 @@ describe("ConnectionRegistry", () => { const connection = registry.createConnection( (conn: AgentSideConnection) => new TestAgent(conn), ); - const writer = connection.inboundTx.getWriter(); - try { - await writer.write(initializeRequest); - } finally { - writer.releaseLock(); - } + await writeInbound(connection.inboundTx, initializeRequest); const response = await connection.recvInitial(initializeRequest.id); @@ -72,4 +88,168 @@ describe("ConnectionRegistry", () => { registry.closeAll(); }); + + it("routes pending responses to the connection stream and all outbound stream", async () => { + const registry = new ConnectionRegistry(); + const connection = registry.createConnection( + (conn: AgentSideConnection) => new TestAgent(conn), + ); + + await initializeConnection(connection); + + const connectionSubscription = connection.connectionStream.subscribe(); + const allOutboundSubscription = connection.allOutbound.subscribe(); + const key = messageIdKey(sessionNewRequest.id); + + expect(key).toBe("number:2"); + connection.pendingRoutes.set(key ?? "", "connection"); + + await writeInbound(connection.inboundTx, sessionNewRequest); + + const connectionMessage = await readNext(connectionSubscription.stream); + const allOutboundMessage = await readNext(allOutboundSubscription.stream); + + expect(connectionMessage).toMatchObject({ + jsonrpc: "2.0", + id: sessionNewRequest.id, + result: { + sessionId: expect.stringMatching(/^[0-9a-f-]{36}$/), + }, + }); + expect(allOutboundMessage).toMatchObject(connectionMessage); + expect(connection.pendingRoutes.has(key ?? "")).toBe(false); + + registry.closeAll(); + }); + + it("falls back to the connection stream for responses without a pending route", async () => { + const registry = new ConnectionRegistry(); + const connection = registry.createConnection( + (conn: AgentSideConnection) => new TestAgent(conn), + ); + + await initializeConnection(connection); + + const subscription = connection.connectionStream.subscribe(); + + await writeInbound(connection.inboundTx, sessionNewRequest); + + expect(await readNext(subscription.stream)).toMatchObject({ + jsonrpc: "2.0", + id: sessionNewRequest.id, + result: { + sessionId: expect.stringMatching(/^[0-9a-f-]{36}$/), + }, + }); + + registry.closeAll(); + }); +}); + +describe("OutboundStream", () => { + it("replays buffered messages to the first subscriber", () => { + const stream = new OutboundStream(); + + stream.push(messageOne); + stream.push(messageTwo); + + expect(stream.subscribe().replay).toEqual([messageOne, messageTwo]); + }); + + it("does not replay buffered messages to later subscribers", async () => { + const stream = new OutboundStream(); + + stream.push(messageOne); + + const first = stream.subscribe(); + const second = stream.subscribe(); + + expect(first.replay).toEqual([messageOne]); + expect(second.replay).toEqual([]); + + stream.push(messageTwo); + + expect(await readNext(first.stream)).toEqual(messageTwo); + expect(await readNext(second.stream)).toEqual(messageTwo); + }); + + it("evicts oldest replay messages when capacity is exceeded", () => { + const stream = new OutboundStream(2); + + stream.push(messageOne); + stream.push(messageTwo); + stream.push(messageThree); + + expect(stream.subscribe().replay).toEqual([messageTwo, messageThree]); + }); + + it("drops oldest queued live messages for lagging subscribers", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const stream = new OutboundStream(2); + const subscription = stream.subscribe(); + + stream.push(messageOne); + stream.push(messageTwo); + stream.push(messageThree); + stream.push(messageFour); + + expect(await readNext(subscription.stream)).toEqual(messageOne); + expect(await readNext(subscription.stream)).toEqual(messageThree); + expect(await readNext(subscription.stream)).toEqual(messageFour); + expect(warn).toHaveBeenCalledOnce(); + + warn.mockRestore(); + }); + + it("closes subscriber streams", async () => { + const stream = new OutboundStream(); + const reader = stream.subscribe().stream.getReader(); + + stream.close(); + + expect(await reader.read()).toEqual({ done: true, value: undefined }); + reader.releaseLock(); + }); }); + +type TestConnection = ReturnType; + +async function initializeConnection(connection: TestConnection): Promise { + await writeInbound(connection.inboundTx, initializeRequest); + await connection.recvInitial(initializeRequest.id); + connection.startRouter(); +} + +async function writeInbound( + stream: WritableStream, + message: AnyMessage, +): Promise { + const writer = stream.getWriter(); + + try { + await writer.write(message); + } finally { + writer.releaseLock(); + } +} + +async function readNext( + stream: ReadableStream, +): Promise { + const reader = stream.getReader(); + + try { + const result = await reader.read(); + + if (result.done) { + throw new Error("Expected stream message"); + } + + return result.value; + } finally { + reader.releaseLock(); + } +} + +const routeShapeCheck = "connection" satisfies ResponseRoute; +void routeShapeCheck; diff --git a/src/connection.ts b/src/connection.ts index 1bc9babb..c02994af 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -1,14 +1,93 @@ import { AgentSideConnection } from "./acp.js"; +import { messageIdKey } from "./protocol.js"; import type { Agent } from "./acp.js"; import type { AnyMessage, AnyResponse } from "./jsonrpc.js"; import type { Stream } from "./stream.js"; +export type ResponseRoute = "connection" | { readonly session: string }; + +export interface OutboundSubscription { + readonly replay: readonly AnyMessage[]; + readonly stream: ReadableStream; +} + +export class OutboundStream { + private readonly subscribers = new Set(); + private replayBuffer: AnyMessage[] = []; + private hasSubscriber = false; + private isClosed = false; + + constructor(private readonly capacity = 1024) {} + + push(message: AnyMessage): void { + if (this.isClosed) { + return; + } + + if (!this.hasSubscriber) { + this.replayBuffer.push(message); + + if (this.replayBuffer.length > this.capacity) { + this.replayBuffer.shift(); + } + + return; + } + + for (const subscriber of this.subscribers) { + subscriber.push(message); + } + } + + subscribe(): OutboundSubscription { + const replay = this.hasSubscriber ? [] : [...this.replayBuffer]; + this.replayBuffer = []; + this.hasSubscriber = true; + + const subscriber = new OutboundSubscriber(this.capacity, (item) => { + this.subscribers.delete(item); + }); + + this.subscribers.add(subscriber); + + if (this.isClosed) { + subscriber.close(); + } + + return { + replay, + stream: subscriber.stream, + }; + } + + close(): void { + if (this.isClosed) { + return; + } + + this.isClosed = true; + this.replayBuffer = []; + + for (const subscriber of this.subscribers) { + subscriber.close(); + } + + this.subscribers.clear(); + } +} + export class ConnectionState { readonly connectionId: string; readonly inboundTx: WritableStream; readonly outboundRx: ReadableStream; readonly agentConnection: AgentSideConnection; + readonly connectionStream = new OutboundStream(); + readonly allOutbound = new OutboundStream(); + readonly pendingRoutes = new Map(); + + private hasStartedRouter = false; + private outboundReader: ReadableStreamDefaultReader | undefined; constructor(agentFactory: (conn: AgentSideConnection) => Agent) { this.connectionId = globalThis.crypto.randomUUID(); @@ -47,12 +126,79 @@ export class ConnectionState { } } - async shutdown() { + startRouter(): void { + if (this.hasStartedRouter) { + return; + } + + this.hasStartedRouter = true; + void this.runRouter(); + } + + async shutdown(): Promise { + this.connectionStream.close(); + this.allOutbound.close(); + this.pendingRoutes.clear(); + await Promise.allSettled([ this.inboundTx.close(), - this.outboundRx.cancel(), + this.outboundReader?.cancel() ?? this.outboundRx.cancel(), ]); } + + private async runRouter(): Promise { + const reader = this.outboundRx.getReader(); + this.outboundReader = reader; + + try { + while (true) { + const result = await reader.read(); + + if (result.done) { + return; + } + + this.routeOutbound(result.value); + } + } catch (error) { + console.error("ACP connection router stopped unexpectedly:", error); + } finally { + if (this.outboundReader === reader) { + this.outboundReader = undefined; + } + + reader.releaseLock(); + this.connectionStream.close(); + this.allOutbound.close(); + } + } + + private routeOutbound(message: AnyMessage): void { + this.allOutbound.push(message); + + if (isResponse(message)) { + const key = messageIdKey(message.id); + const route = key ? this.pendingRoutes.get(key) : undefined; + + if (key) { + this.pendingRoutes.delete(key); + } + + this.pushToRoute(route ?? "connection", message); + return; + } + + this.connectionStream.push(message); + } + + private pushToRoute(route: ResponseRoute, message: AnyMessage): void { + if (route === "connection") { + this.connectionStream.push(message); + return; + } + + this.connectionStream.push(message); + } } export class ConnectionRegistry { @@ -91,9 +237,99 @@ export class ConnectionRegistry { } } +class OutboundSubscriber { + readonly stream: ReadableStream; + + private controller: ReadableStreamDefaultController | undefined; + private queue: AnyMessage[] = []; + private isClosed = false; + private hasWarnedAboutOverflow = false; + + constructor( + private readonly capacity: number, + private readonly onCancel: (subscriber: OutboundSubscriber) => void, + ) { + this.stream = new ReadableStream({ + start: (controller) => { + this.controller = controller; + this.flush(); + }, + pull: () => { + this.flush(); + }, + cancel: () => { + this.cancel(); + }, + }); + } + + push(message: AnyMessage): void { + if (this.isClosed) { + return; + } + + this.queue.push(message); + + if (this.queue.length > this.capacity) { + this.queue.shift(); + + if (!this.hasWarnedAboutOverflow) { + console.warn("ACP outbound subscriber lagged; dropping oldest message"); + this.hasWarnedAboutOverflow = true; + } + } + + this.flush(); + } + + close(): void { + if (this.isClosed) { + return; + } + + this.isClosed = true; + this.queue = []; + this.controller?.close(); + } + + private cancel(): void { + this.isClosed = true; + this.queue = []; + this.onCancel(this); + } + + private flush(): void { + if (!this.controller) { + return; + } + + while ( + this.queue.length > 0 && + this.controller.desiredSize !== null && + this.controller.desiredSize > 0 + ) { + const message = this.queue.shift(); + + if (!message) { + return; + } + + this.controller.enqueue(message); + } + + if (this.queue.length === 0) { + this.hasWarnedAboutOverflow = false; + } + } +} + function isMatchingResponse( msg: AnyMessage, id: string | number, ): msg is AnyResponse { return "id" in msg && !("method" in msg) && msg.id === id; } + +function isResponse(msg: AnyMessage): msg is AnyResponse { + return "id" in msg && !("method" in msg); +} diff --git a/src/node-adapter.ts b/src/node-adapter.ts index 8a3d91d4..a92d7c03 100644 --- a/src/node-adapter.ts +++ b/src/node-adapter.ts @@ -88,6 +88,8 @@ async function writeNodeResponse( res.setHeader(name, value); }); + res.flushHeaders(); + const responseBody = response.body; if (!responseBody) { diff --git a/src/server.test.ts b/src/server.test.ts index 9268338b..50727eed 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1,9 +1,17 @@ import { describe, expect, it } from "vitest"; -import { HEADER_CONNECTION_ID, JSON_MIME_TYPE } from "./protocol.js"; -import { startTestServer } from "./test-support/test-http-server.js"; +import { + EVENT_STREAM_MIME_TYPE, + HEADER_CONNECTION_ID, + HEADER_SESSION_ID, + JSON_MIME_TYPE, +} from "./protocol.js"; +import { AcpServer } from "./server.js"; +import { parseSseStream } from "./sse.js"; import { TestAgent } from "./test-support/test-agent.js"; +import { startTestServer } from "./test-support/test-http-server.js"; import type { AgentSideConnection } from "./acp.js"; +import type { AnyMessage } from "./jsonrpc.js"; const initializeRequest = { jsonrpc: "2.0", @@ -15,6 +23,16 @@ const initializeRequest = { }, }; +const sessionNewRequest = { + jsonrpc: "2.0", + id: 2, + method: "session/new", + params: { + cwd: "/tmp", + mcpServers: [], + }, +}; + describe("AcpServer", () => { it("handles initialize over HTTP and returns a connection ID", async () => { const server = await startTestServer(); @@ -42,20 +60,223 @@ describe("AcpServer", () => { } }); - it.each(["GET", "PUT", "PATCH", "DELETE"])( - "rejects %s requests in Phase 1", - async (method) => { - const server = await startTestServer(); + it("streams session/new responses over the connection SSE stream", async () => { + const server = await startTestServer(); - try { - const response = await fetch(server.url, { method }); + try { + const connectionId = await initialize(server.url); + const sseResponse = await openConnectionSse(server.url, connectionId); - expect(response.status).toBe(405); - } finally { - await server.close(); - } - }, - ); + expect(sseResponse.status).toBe(200); + expect(sseResponse.headers.get("Content-Type")).toContain( + EVENT_STREAM_MIME_TYPE, + ); + + const accepted = await postJson(server.url, sessionNewRequest, { + [HEADER_CONNECTION_ID]: connectionId, + }); + + expect(accepted.status).toBe(202); + expect(await accepted.text()).toBe(""); + expect(await readFirstSseMessage(sseResponse)).toMatchObject({ + jsonrpc: "2.0", + id: sessionNewRequest.id, + result: { + sessionId: expect.stringMatching(/^[0-9a-f-]{36}$/), + }, + }); + } finally { + await server.close(); + } + }); + + it("replays buffered connection messages when SSE attaches after POST", async () => { + const server = await startTestServer(); + + try { + const connectionId = await initialize(server.url); + const accepted = await postJson(server.url, sessionNewRequest, { + [HEADER_CONNECTION_ID]: connectionId, + }); + + expect(accepted.status).toBe(202); + + const sseResponse = await openConnectionSse(server.url, connectionId); + + expect(await readFirstSseMessage(sseResponse)).toMatchObject({ + jsonrpc: "2.0", + id: sessionNewRequest.id, + result: { + sessionId: expect.stringMatching(/^[0-9a-f-]{36}$/), + }, + }); + } finally { + await server.close(); + } + }); + + it.each(["PUT", "PATCH"])("rejects %s requests", async (method) => { + const server = await startTestServer(); + + try { + const response = await fetch(server.url, { method }); + + expect(response.status).toBe(405); + } finally { + await server.close(); + } + }); + + it("rejects GET without Accept: text/event-stream", async () => { + const server = await startTestServer(); + + try { + const response = await fetch(server.url, { + method: "GET", + headers: { + [HEADER_CONNECTION_ID]: globalThis.crypto.randomUUID(), + }, + }); + + expect(response.status).toBe(406); + } finally { + await server.close(); + } + }); + + it("rejects GET without a connection ID", async () => { + const server = await startTestServer(); + + try { + const response = await fetch(server.url, { + method: "GET", + headers: { + Accept: EVENT_STREAM_MIME_TYPE, + }, + }); + + expect(response.status).toBe(400); + } finally { + await server.close(); + } + }); + + it("rejects GET with an unknown connection ID", async () => { + const server = await startTestServer(); + + try { + const response = await openConnectionSse( + server.url, + globalThis.crypto.randomUUID(), + ); + + expect(response.status).toBe(404); + } finally { + await server.close(); + } + }); + + it("rejects session-scoped GETs until session SSE is implemented", async () => { + const server = await startTestServer(); + + try { + const connectionId = await initialize(server.url); + const response = await fetch(server.url, { + method: "GET", + headers: { + Accept: EVENT_STREAM_MIME_TYPE, + [HEADER_CONNECTION_ID]: connectionId, + [HEADER_SESSION_ID]: globalThis.crypto.randomUUID(), + }, + }); + + expect(response.status).toBe(404); + } finally { + await server.close(); + } + }); + + it("returns 426 for WebSocket upgrade GETs", async () => { + const server = new AcpServer({ + createAgent: (conn: AgentSideConnection) => new TestAgent(conn), + }); + + try { + const response = await server.handleRequest( + new Request("http://127.0.0.1/acp", { + method: "GET", + headers: { + Accept: EVENT_STREAM_MIME_TYPE, + Upgrade: "websocket", + }, + }), + ); + + expect(response.status).toBe(426); + } finally { + await server.close(); + } + }); + + it("deletes connections and closes SSE streams", async () => { + const server = await startTestServer(); + + try { + const connectionId = await initialize(server.url); + const sseResponse = await openConnectionSse(server.url, connectionId); + const reader = sseResponse.body?.getReader(); + + expect(reader).toBeDefined(); + + const deleted = await fetch(server.url, { + method: "DELETE", + headers: { + [HEADER_CONNECTION_ID]: connectionId, + }, + }); + + expect(deleted.status).toBe(202); + expect(await reader?.read()).toEqual({ done: true, value: undefined }); + reader?.releaseLock(); + + const postAfterDelete = await postJson(server.url, sessionNewRequest, { + [HEADER_CONNECTION_ID]: connectionId, + }); + + expect(postAfterDelete.status).toBe(404); + } finally { + await server.close(); + } + }); + + it("rejects DELETE without a connection ID", async () => { + const server = await startTestServer(); + + try { + const response = await fetch(server.url, { method: "DELETE" }); + + expect(response.status).toBe(400); + } finally { + await server.close(); + } + }); + + it("rejects DELETE with an unknown connection ID", async () => { + const server = await startTestServer(); + + try { + const response = await fetch(server.url, { + method: "DELETE", + headers: { + [HEADER_CONNECTION_ID]: globalThis.crypto.randomUUID(), + }, + }); + + expect(response.status).toBe(404); + } finally { + await server.close(); + } + }); it("rejects POST without application/json Content-Type", async () => { const server = await startTestServer(); @@ -127,15 +348,7 @@ describe("AcpServer", () => { const server = await startTestServer(); try { - const response = await postJson(server.url, { - jsonrpc: "2.0", - id: 2, - method: "session/new", - params: { - cwd: "/tmp", - mcpServers: [], - }, - }); + const response = await postJson(server.url, sessionNewRequest); expect(response.status).toBe(400); } finally { @@ -147,21 +360,9 @@ describe("AcpServer", () => { const server = await startTestServer(); try { - const response = await postJson( - server.url, - { - jsonrpc: "2.0", - id: 2, - method: "session/new", - params: { - cwd: "/tmp", - mcpServers: [], - }, - }, - { - [HEADER_CONNECTION_ID]: globalThis.crypto.randomUUID(), - }, - ); + const response = await postJson(server.url, sessionNewRequest, { + [HEADER_CONNECTION_ID]: globalThis.crypto.randomUUID(), + }); expect(response.status).toBe(404); } finally { @@ -169,37 +370,6 @@ describe("AcpServer", () => { } }); - it("rejects connected POSTs after initialize in Phase 1", async () => { - const server = await startTestServer(); - - try { - const initializeResponse = await postJson(server.url, initializeRequest); - const connectionId = initializeResponse.headers.get(HEADER_CONNECTION_ID); - - expect(connectionId).toBeTruthy(); - - const response = await postJson( - server.url, - { - jsonrpc: "2.0", - id: 2, - method: "session/new", - params: { - cwd: "/tmp", - mcpServers: [], - }, - }, - { - [HEADER_CONNECTION_ID]: connectionId ?? "", - }, - ); - - expect(response.status).toBe(400); - } finally { - await server.close(); - } - }); - it("returns an error response when agent creation fails", async () => { const server = await startTestServer(() => { throw new Error("agent factory failed"); @@ -258,6 +428,46 @@ describe("AcpServer", () => { }); }); +async function initialize(url: string): Promise { + const response = await postJson(url, initializeRequest); + const connectionId = response.headers.get(HEADER_CONNECTION_ID); + + expect(response.status).toBe(200); + expect(connectionId).toMatch(/^[0-9a-f-]{36}$/); + + return connectionId ?? ""; +} + +function openConnectionSse( + url: string, + connectionId: string, +): Promise { + return fetch(url, { + method: "GET", + headers: { + Accept: EVENT_STREAM_MIME_TYPE, + [HEADER_CONNECTION_ID]: connectionId, + }, + }); +} + +async function readFirstSseMessage(response: Response): Promise { + if (!response.body) { + throw new Error("Expected SSE response body"); + } + + const iterator = parseSseStream(response.body)[Symbol.asyncIterator](); + const result = await iterator.next(); + await iterator.return?.(); + await response.body.cancel(); + + if (result.done) { + throw new Error("Expected SSE message"); + } + + return result.value; +} + function postJson( url: string, body: unknown, diff --git a/src/server.ts b/src/server.ts index 9d2bee86..f7587364 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,10 +1,19 @@ import { ConnectionRegistry } from "./connection.js"; import { + EVENT_STREAM_MIME_TYPE, HEADER_CONNECTION_ID, + HEADER_SESSION_ID, JSON_MIME_TYPE, isInitializeRequest, + messageIdKey, } from "./protocol.js"; +import { serializeSseEvent, serializeSseKeepAlive } from "./sse.js"; +import type { + ConnectionState, + OutboundSubscription, + ResponseRoute, +} from "./connection.js"; import type { Agent, AgentSideConnection } from "./acp.js"; import type { AnyMessage } from "./jsonrpc.js"; @@ -21,10 +30,26 @@ export class AcpServer { } async handleRequest(req: Request): Promise { - if (req.method !== "POST") { - return textResponse("Method Not Allowed", 405); + if (req.method === "POST") { + return await this.handlePost(req); } + if (req.method === "GET") { + return this.handleGet(req); + } + + if (req.method === "DELETE") { + return this.handleDelete(req); + } + + return textResponse("Method Not Allowed", 405); + } + + async close(): Promise { + this.registry.closeAll(); + } + + private async handlePost(req: Request): Promise { const contentType = req.headers.get("Content-Type"); if (!contentType?.startsWith(JSON_MIME_TYPE)) { @@ -55,18 +80,58 @@ export class AcpServer { return textResponse("Missing Acp-Connection-Id", 400); } - if (!this.registry.get(connectionId)) { + const connection = this.registry.get(connectionId); + + if (!connection) { return textResponse("Unknown Acp-Connection-Id", 404); } - return textResponse( - "Connected POST handling is not implemented in Phase 1", - 400, - ); + await this.forwardConnectedMessage(connection, body.value); + return emptyResponse(202); } - async close(): Promise { - this.registry.closeAll(); + private handleGet(req: Request): Response { + if (req.headers.get("Upgrade")?.toLowerCase() === "websocket") { + return textResponse("WebSocket upgrade is not implemented", 426); + } + + const accept = req.headers.get("Accept")?.toLowerCase(); + + if (!accept?.includes(EVENT_STREAM_MIME_TYPE)) { + return textResponse("Not Acceptable", 406); + } + + const connectionId = req.headers.get(HEADER_CONNECTION_ID); + + if (!connectionId) { + return textResponse("Missing Acp-Connection-Id", 400); + } + + const connection = this.registry.get(connectionId); + + if (!connection) { + return textResponse("Unknown Acp-Connection-Id", 404); + } + + if (req.headers.get(HEADER_SESSION_ID)) { + return textResponse("Unknown Acp-Session-Id", 404); + } + + return sseResponse(connection.connectionStream.subscribe()); + } + + private handleDelete(req: Request): Response { + const connectionId = req.headers.get(HEADER_CONNECTION_ID); + + if (!connectionId) { + return textResponse("Missing Acp-Connection-Id", 400); + } + + if (!this.registry.remove(connectionId)) { + return textResponse("Unknown Acp-Connection-Id", 404); + } + + return emptyResponse(202); } private async handleInitialize(message: AnyMessage): Promise { @@ -80,15 +145,10 @@ export class AcpServer { try { connection = this.registry.createConnection(this.createAgent); - const writer = connection.inboundTx.getWriter(); - - try { - await writer.write(message); - } finally { - writer.releaseLock(); - } + await writeInbound(connection, message); const initialResponse = await connection.recvInitial(message.id); + connection.startRouter(); return jsonResponse(initialResponse, 200, { [HEADER_CONNECTION_ID]: connection.connectionId, @@ -112,6 +172,21 @@ export class AcpServer { ); } } + + private async forwardConnectedMessage( + connection: ConnectionState, + message: AnyMessage, + ): Promise { + if (isRequestMessage(message)) { + const key = messageIdKey(message.id); + + if (key) { + connection.pendingRoutes.set(key, determineRoute()); + } + } + + await writeInbound(connection, message); + } } type JsonResult = @@ -136,6 +211,23 @@ async function readJson(req: Request): Promise { } } +async function writeInbound( + connection: ConnectionState, + message: AnyMessage, +): Promise { + const writer = connection.inboundTx.getWriter(); + + try { + await writer.write(message); + } finally { + writer.releaseLock(); + } +} + +function determineRoute(): ResponseRoute { + return "connection"; +} + function isJsonRpcMessage(value: unknown): value is AnyMessage { return ( isRecord(value) && @@ -144,10 +236,101 @@ function isJsonRpcMessage(value: unknown): value is AnyMessage { ); } +function isRequestMessage( + message: AnyMessage, +): message is AnyMessage & { readonly id: string | number | null } { + return "method" in message && "id" in message; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +function sseResponse(subscription: OutboundSubscription): Response { + return new Response(createSseBody(subscription), { + status: 200, + headers: { + "Content-Type": EVENT_STREAM_MIME_TYPE, + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} + +function createSseBody( + subscription: OutboundSubscription, +): ReadableStream { + const encoder = new TextEncoder(); + let keepAliveTimer: ReturnType | undefined; + let reader: ReadableStreamDefaultReader | undefined; + + const clearKeepAlive = (): void => { + if (keepAliveTimer) { + clearInterval(keepAliveTimer); + keepAliveTimer = undefined; + } + }; + + const enqueueText = ( + controller: ReadableStreamDefaultController, + text: string, + ): boolean => { + try { + controller.enqueue(encoder.encode(text)); + return true; + } catch { + return false; + } + }; + + return new ReadableStream({ + async start(controller) { + for (const message of subscription.replay) { + if (!enqueueText(controller, serializeSseEvent(message))) { + return; + } + } + + reader = subscription.stream.getReader(); + + keepAliveTimer = setInterval(() => { + if (!enqueueText(controller, serializeSseKeepAlive())) { + clearKeepAlive(); + } + }, 15_000); + + try { + while (true) { + const result = await reader.read(); + + if (result.done) { + return; + } + + if (!enqueueText(controller, serializeSseEvent(result.value))) { + return; + } + } + } catch (error) { + controller.error(error); + } finally { + clearKeepAlive(); + reader.releaseLock(); + + try { + controller.close(); + } catch { + // Stream may already be cancelled by the consumer. + } + } + }, + cancel() { + clearKeepAlive(); + void reader?.cancel(); + }, + }); +} + function jsonResponse( value: unknown, status: number, @@ -170,3 +353,7 @@ function textResponse(body: string, status: number): Response { }, }); } + +function emptyResponse(status: number): Response { + return new Response(null, { status }); +} From a68df2648ae92d37453798b4df253c234eaf0241 Mon Sep 17 00:00:00 2001 From: Federico Ciner Date: Mon, 18 May 2026 18:57:02 +1000 Subject: [PATCH 04/20] Add session SSE and prompt streaming --- src/connection.test.ts | 97 +++++++ src/connection.ts | 55 +++- src/server-session-sse.test.ts | 455 +++++++++++++++++++++++++++++++++ src/server.test.ts | 30 ++- src/server.ts | 107 +++++++- 5 files changed, 723 insertions(+), 21 deletions(-) create mode 100644 src/server-session-sse.test.ts diff --git a/src/connection.test.ts b/src/connection.test.ts index e0f52ecf..60ea0e5d 100644 --- a/src/connection.test.ts +++ b/src/connection.test.ts @@ -30,6 +30,18 @@ const sessionNewRequest = { }, } as const; +function createPromptRequest(id: number, sessionId: string) { + return { + jsonrpc: "2.0", + id, + method: "session/prompt", + params: { + sessionId, + prompt: [{ type: "text", text: "Hello" }], + }, + } as const; +} + const messageOne = { jsonrpc: "2.0", id: 1, result: "one" } as const; const messageTwo = { jsonrpc: "2.0", id: 2, result: "two" } as const; const messageThree = { jsonrpc: "2.0", id: 3, result: "three" } as const; @@ -144,6 +156,70 @@ describe("ConnectionRegistry", () => { registry.closeAll(); }); + + it("returns the same session stream for repeated ensureSession calls", () => { + const registry = new ConnectionRegistry(); + const connection = registry.createConnection( + (conn: AgentSideConnection) => new TestAgent(conn), + ); + const sessionId = globalThis.crypto.randomUUID(); + + expect(connection.ensureSession(sessionId)).toBe( + connection.ensureSession(sessionId), + ); + expect(connection.sessionStreams.get(sessionId)).toBe( + connection.ensureSession(sessionId), + ); + + registry.closeAll(); + }); + + it("routes session responses and notifications to the session stream", async () => { + const registry = new ConnectionRegistry(); + const connection = registry.createConnection( + (conn: AgentSideConnection) => new TestAgent(conn, { chunkCount: 1 }), + ); + const sessionId = globalThis.crypto.randomUUID(); + const promptRequest = createPromptRequest(3, sessionId); + + await initializeConnection(connection); + + const sessionSubscription = connection.ensureSession(sessionId).subscribe(); + const connectionSubscription = connection.connectionStream.subscribe(); + const key = messageIdKey(promptRequest.id); + + expect(key).toBe("number:3"); + connection.pendingRoutes.set(key ?? "", { session: sessionId }); + + await writeInbound(connection.inboundTx, promptRequest); + + expect(await readNext(sessionSubscription.stream)).toMatchObject({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + text: "chunk-1", + }, + }, + }, + }); + expect(await readNext(sessionSubscription.stream)).toMatchObject({ + jsonrpc: "2.0", + id: promptRequest.id, + result: { + stopReason: "end_turn", + }, + }); + expect(connection.pendingRoutes.has(key ?? "")).toBe(false); + expect( + await readNextOrUndefined(connectionSubscription.stream), + ).toBeUndefined(); + + registry.closeAll(); + }); }); describe("OutboundStream", () => { @@ -251,5 +327,26 @@ async function readNext( } } +async function readNextOrUndefined( + stream: ReadableStream, +): Promise { + const reader = stream.getReader(); + + try { + return await Promise.race([ + reader.read().then((result) => (result.done ? undefined : result.value)), + delay(50).then(() => undefined), + ]); + } finally { + reader.releaseLock(); + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + const routeShapeCheck = "connection" satisfies ResponseRoute; void routeShapeCheck; diff --git a/src/connection.ts b/src/connection.ts index c02994af..098faaa7 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -1,5 +1,5 @@ import { AgentSideConnection } from "./acp.js"; -import { messageIdKey } from "./protocol.js"; +import { messageIdKey, sessionIdFromParams } from "./protocol.js"; import type { Agent } from "./acp.js"; import type { AnyMessage, AnyResponse } from "./jsonrpc.js"; @@ -84,6 +84,7 @@ export class ConnectionState { readonly agentConnection: AgentSideConnection; readonly connectionStream = new OutboundStream(); readonly allOutbound = new OutboundStream(); + readonly sessionStreams = new Map(); readonly pendingRoutes = new Map(); private hasStartedRouter = false; @@ -135,9 +136,27 @@ export class ConnectionState { void this.runRouter(); } + ensureSession(sessionId: string): OutboundStream { + const existing = this.sessionStreams.get(sessionId); + if (existing) { + return existing; + } + + const stream = new OutboundStream(); + this.sessionStreams.set(sessionId, stream); + + return stream; + } + async shutdown(): Promise { this.connectionStream.close(); this.allOutbound.close(); + + for (const stream of this.sessionStreams.values()) { + stream.close(); + } + + this.sessionStreams.clear(); this.pendingRoutes.clear(); await Promise.allSettled([ @@ -170,6 +189,10 @@ export class ConnectionState { reader.releaseLock(); this.connectionStream.close(); this.allOutbound.close(); + + for (const stream of this.sessionStreams.values()) { + stream.close(); + } } } @@ -179,6 +202,13 @@ export class ConnectionState { if (isResponse(message)) { const key = messageIdKey(message.id); const route = key ? this.pendingRoutes.get(key) : undefined; + const sessionId = sessionIdFromResult( + "result" in message ? message.result : undefined, + ); + + if (sessionId) { + this.ensureSession(sessionId); + } if (key) { this.pendingRoutes.delete(key); @@ -188,6 +218,14 @@ export class ConnectionState { return; } + if ("method" in message) { + const sessionId = sessionIdFromParams(message.params); + if (sessionId) { + this.ensureSession(sessionId).push(message); + return; + } + } + this.connectionStream.push(message); } @@ -197,7 +235,7 @@ export class ConnectionState { return; } - this.connectionStream.push(message); + this.ensureSession(route.session).push(message); } } @@ -333,3 +371,16 @@ function isMatchingResponse( function isResponse(msg: AnyMessage): msg is AnyResponse { return "id" in msg && !("method" in msg); } + +function sessionIdFromResult(result: unknown): string | undefined { + if (!isRecord(result)) { + return undefined; + } + + const sessionId = result["sessionId"]; + return typeof sessionId === "string" ? sessionId : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/src/server-session-sse.test.ts b/src/server-session-sse.test.ts new file mode 100644 index 00000000..c30c7759 --- /dev/null +++ b/src/server-session-sse.test.ts @@ -0,0 +1,455 @@ +import { describe, expect, it } from "vitest"; +import { + EVENT_STREAM_MIME_TYPE, + HEADER_CONNECTION_ID, + HEADER_SESSION_ID, + JSON_MIME_TYPE, +} from "./protocol.js"; +import { parseSseStream } from "./sse.js"; +import { TestAgent } from "./test-support/test-agent.js"; +import { startTestServer } from "./test-support/test-http-server.js"; + +import type { AgentSideConnection } from "./acp.js"; +import type { AnyMessage } from "./jsonrpc.js"; + +const initializeRequest = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: 1, + clientCapabilities: {}, + }, +}; + +const sessionNewRequest = { + jsonrpc: "2.0", + id: 2, + method: "session/new", + params: { + cwd: "/tmp", + mcpServers: [], + }, +}; + +function createPromptRequest(id: number, sessionId?: string) { + return { + jsonrpc: "2.0", + id, + method: "session/prompt", + params: { + ...(sessionId === undefined ? {} : { sessionId }), + prompt: [{ type: "text", text: "Hello" }], + }, + }; +} + +describe("AcpServer session SSE", () => { + it("streams prompt updates and responses on the session SSE stream", async () => { + const server = await startTestServer( + (conn: AgentSideConnection) => new TestAgent(conn, { chunkCount: 2 }), + ); + + try { + const connectionId = await initialize(server.url); + const sessionId = await createSession(server.url, connectionId); + const sessionSse = await openSessionSse( + server.url, + connectionId, + sessionId, + ); + + expect(sessionSse.status).toBe(200); + + const accepted = await postJson( + server.url, + createPromptRequest(3, sessionId), + { + [HEADER_CONNECTION_ID]: connectionId, + [HEADER_SESSION_ID]: sessionId, + }, + ); + + expect(accepted.status).toBe(202); + expect(await readSseMessages(sessionSse, 3)).toMatchObject([ + { + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + text: "chunk-1", + }, + }, + }, + }, + { + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + text: "chunk-2", + }, + }, + }, + }, + { + jsonrpc: "2.0", + id: 3, + result: { + stopReason: "end_turn", + }, + }, + ]); + expect( + await readNextConnectionSseMessage(server.url, connectionId), + ).toBeUndefined(); + } finally { + await server.close(); + } + }); + + it("routes session prompts using params.sessionId when the session header is absent", async () => { + const server = await startTestServer(); + + try { + const connectionId = await initialize(server.url); + const sessionId = await createSession(server.url, connectionId); + const sessionSse = await openSessionSse( + server.url, + connectionId, + sessionId, + ); + const accepted = await postJson( + server.url, + createPromptRequest(3, sessionId), + { + [HEADER_CONNECTION_ID]: connectionId, + }, + ); + + expect(accepted.status).toBe(202); + expect(await readSseMessages(sessionSse, 2)).toMatchObject([ + { + jsonrpc: "2.0", + method: "session/update", + params: { sessionId }, + }, + { + jsonrpc: "2.0", + id: 3, + result: { stopReason: "end_turn" }, + }, + ]); + } finally { + await server.close(); + } + }); + + it("rejects session-scoped requests without a session identifier", async () => { + const server = await startTestServer(); + + try { + const connectionId = await initialize(server.url); + const response = await postJson(server.url, createPromptRequest(3), { + [HEADER_CONNECTION_ID]: connectionId, + }); + + expect(response.status).toBe(400); + } finally { + await server.close(); + } + }); + + it("replays buffered session messages when session SSE attaches after prompt", async () => { + const server = await startTestServer(); + + try { + const connectionId = await initialize(server.url); + const sessionId = await createSession(server.url, connectionId); + const accepted = await postJson( + server.url, + createPromptRequest(3, sessionId), + { + [HEADER_CONNECTION_ID]: connectionId, + [HEADER_SESSION_ID]: sessionId, + }, + ); + const sessionSse = await openSessionSse( + server.url, + connectionId, + sessionId, + ); + + expect(accepted.status).toBe(202); + expect(await readSseMessages(sessionSse, 2)).toMatchObject([ + { + jsonrpc: "2.0", + method: "session/update", + params: { sessionId }, + }, + { + jsonrpc: "2.0", + id: 3, + result: { stopReason: "end_turn" }, + }, + ]); + } finally { + await server.close(); + } + }); + + it("isolates prompt events for multiple sessions on the same connection", async () => { + const server = await startTestServer(); + + try { + const connectionId = await initialize(server.url); + const connectionSse = await openConnectionSse(server.url, connectionId); + const connectionEvents = createSseMessageIterator(connectionSse); + const firstSessionId = await createSessionFromConnectionEvents( + server.url, + connectionId, + connectionEvents, + ); + const secondSessionId = await createSessionFromConnectionEvents( + server.url, + connectionId, + connectionEvents, + ); + const firstSse = await openSessionSse( + server.url, + connectionId, + firstSessionId, + ); + const secondSse = await openSessionSse( + server.url, + connectionId, + secondSessionId, + ); + + expect( + await postJson(server.url, createPromptRequest(3, firstSessionId), { + [HEADER_CONNECTION_ID]: connectionId, + [HEADER_SESSION_ID]: firstSessionId, + }), + ).toMatchObject({ status: 202 }); + expect( + await postJson(server.url, createPromptRequest(4, secondSessionId), { + [HEADER_CONNECTION_ID]: connectionId, + [HEADER_SESSION_ID]: secondSessionId, + }), + ).toMatchObject({ status: 202 }); + + expect(await readSseMessages(firstSse, 2)).toMatchObject([ + { method: "session/update", params: { sessionId: firstSessionId } }, + { id: 3, result: { stopReason: "end_turn" } }, + ]); + expect(await readSseMessages(secondSse, 2)).toMatchObject([ + { method: "session/update", params: { sessionId: secondSessionId } }, + { id: 4, result: { stopReason: "end_turn" } }, + ]); + } finally { + await server.close(); + } + }); +}); + +async function initialize(url: string): Promise { + const response = await postJson(url, initializeRequest); + const connectionId = response.headers.get(HEADER_CONNECTION_ID); + + expect(response.status).toBe(200); + expect(connectionId).toMatch(/^[0-9a-f-]{36}$/); + + return connectionId ?? ""; +} + +async function createSession( + url: string, + connectionId: string, +): Promise { + return createSessionFromConnectionSse( + url, + connectionId, + await openConnectionSse(url, connectionId), + ); +} + +async function createSessionFromConnectionSse( + url: string, + connectionId: string, + response: Response, +): Promise { + return createSessionFromConnectionEvents( + url, + connectionId, + createSseMessageIterator(response), + ); +} + +async function createSessionFromConnectionEvents( + url: string, + connectionId: string, + events: AsyncIterator, +): Promise { + const accepted = await postJson(url, sessionNewRequest, { + [HEADER_CONNECTION_ID]: connectionId, + }); + + expect(accepted.status).toBe(202); + + return readSessionId(await readNextSseMessage(events)); +} + +function openConnectionSse( + url: string, + connectionId: string, + signal?: AbortSignal, +): Promise { + return fetch(url, { + method: "GET", + headers: { + Accept: EVENT_STREAM_MIME_TYPE, + [HEADER_CONNECTION_ID]: connectionId, + }, + signal, + }); +} + +function openSessionSse( + url: string, + connectionId: string, + sessionId: string, +): Promise { + return fetch(url, { + method: "GET", + headers: { + Accept: EVENT_STREAM_MIME_TYPE, + [HEADER_CONNECTION_ID]: connectionId, + [HEADER_SESSION_ID]: sessionId, + }, + }); +} + +function createSseMessageIterator( + response: Response, +): AsyncIterator { + if (!response.body) { + throw new Error("Expected SSE response body"); + } + + return parseSseStream(response.body)[Symbol.asyncIterator](); +} + +async function readNextSseMessage( + iterator: AsyncIterator, +): Promise { + const result = await iterator.next(); + + if (result.done) { + throw new Error("Expected SSE message"); + } + + return result.value; +} + +async function readSseMessages( + response: Response, + count: number, +): Promise { + if (!response.body) { + throw new Error("Expected SSE response body"); + } + + const iterator = parseSseStream(response.body)[Symbol.asyncIterator](); + + try { + const messages: AnyMessage[] = []; + + for (const __unused of Array.from({ length: count })) { + void __unused; + const result = await iterator.next(); + + if (result.done) { + throw new Error("Expected SSE message"); + } + + messages.push(result.value); + } + + return messages; + } finally { + await iterator.return?.(); + await response.body.cancel(); + } +} + +async function readNextConnectionSseMessage( + url: string, + connectionId: string, +): Promise { + const abort = new AbortController(); + const response = await openConnectionSse(url, connectionId, abort.signal); + + if (!response.body) { + throw new Error("Expected SSE response body"); + } + + const iterator = parseSseStream(response.body)[Symbol.asyncIterator](); + + try { + const result = await Promise.race([ + iterator.next(), + delay(50).then(() => ({ done: true, value: undefined })), + ]); + + return result.done ? undefined : result.value; + } finally { + abort.abort(); + await iterator.return?.(); + } +} + +function readSessionId(message: AnyMessage): string { + if (!("result" in message) || !isRecord(message.result)) { + throw new Error("Expected session/new response result"); + } + + const sessionId = message.result["sessionId"]; + + if (typeof sessionId !== "string") { + throw new Error("Expected session ID"); + } + + return sessionId; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function postJson( + url: string, + body: unknown, + headers: Record = {}, +): Promise { + return fetch(url, { + method: "POST", + headers: { + "Content-Type": JSON_MIME_TYPE, + ...headers, + }, + body: JSON.stringify(body), + }); +} diff --git a/src/server.test.ts b/src/server.test.ts index 50727eed..e2b37bc7 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -176,19 +176,16 @@ describe("AcpServer", () => { } }); - it("rejects session-scoped GETs until session SSE is implemented", async () => { + it("rejects session-scoped GETs for unknown sessions", async () => { const server = await startTestServer(); try { const connectionId = await initialize(server.url); - const response = await fetch(server.url, { - method: "GET", - headers: { - Accept: EVENT_STREAM_MIME_TYPE, - [HEADER_CONNECTION_ID]: connectionId, - [HEADER_SESSION_ID]: globalThis.crypto.randomUUID(), - }, - }); + const response = await openSessionSse( + server.url, + connectionId, + globalThis.crypto.randomUUID(), + ); expect(response.status).toBe(404); } finally { @@ -451,6 +448,21 @@ function openConnectionSse( }); } +function openSessionSse( + url: string, + connectionId: string, + sessionId: string, +): Promise { + return fetch(url, { + method: "GET", + headers: { + Accept: EVENT_STREAM_MIME_TYPE, + [HEADER_CONNECTION_ID]: connectionId, + [HEADER_SESSION_ID]: sessionId, + }, + }); +} + async function readFirstSseMessage(response: Response): Promise { if (!response.body) { throw new Error("Expected SSE response body"); diff --git a/src/server.ts b/src/server.ts index f7587364..5a74208f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,7 +6,10 @@ import { JSON_MIME_TYPE, isInitializeRequest, messageIdKey, + methodRequiresSessionHeader, + sessionIdFromParams, } from "./protocol.js"; + import { serializeSseEvent, serializeSseKeepAlive } from "./sse.js"; import type { @@ -86,7 +89,15 @@ export class AcpServer { return textResponse("Unknown Acp-Connection-Id", 404); } - await this.forwardConnectedMessage(connection, body.value); + const forwarded = await this.forwardConnectedMessage( + connection, + body.value, + req.headers, + ); + if (!forwarded.ok) { + return textResponse(forwarded.message, forwarded.status); + } + return emptyResponse(202); } @@ -113,8 +124,14 @@ export class AcpServer { return textResponse("Unknown Acp-Connection-Id", 404); } - if (req.headers.get(HEADER_SESSION_ID)) { - return textResponse("Unknown Acp-Session-Id", 404); + const sessionId = req.headers.get(HEADER_SESSION_ID); + if (sessionId) { + const sessionStream = connection.sessionStreams.get(sessionId); + if (!sessionStream) { + return textResponse("Unknown Acp-Session-Id", 404); + } + + return sseResponse(sessionStream.subscribe()); } return sseResponse(connection.connectionStream.subscribe()); @@ -176,19 +193,41 @@ export class AcpServer { private async forwardConnectedMessage( connection: ConnectionState, message: AnyMessage, - ): Promise { + headers: Headers, + ): Promise { if (isRequestMessage(message)) { + const route = determineRoute(message, headers); + + if (!route.ok) { + return route; + } + + if (route.value !== "connection") { + connection.ensureSession(route.value.session); + } + const key = messageIdKey(message.id); if (key) { - connection.pendingRoutes.set(key, determineRoute()); + connection.pendingRoutes.set(key, route.value); } } await writeInbound(connection, message); + return { ok: true }; } } +type ForwardResult = + | { + ok: true; + } + | { + ok: false; + status: number; + message: string; + }; + type JsonResult = | { ok: true; @@ -198,6 +237,17 @@ type JsonResult = ok: false; }; +type RouteResult = + | { + ok: true; + value: ResponseRoute; + } + | { + ok: false; + status: number; + message: string; + }; + async function readJson(req: Request): Promise { try { return { @@ -224,8 +274,43 @@ async function writeInbound( } } -function determineRoute(): ResponseRoute { - return "connection"; +function determineRoute( + message: AnyMessage & { + readonly method: string; + readonly params?: unknown; + }, + headers: Headers, +): RouteResult { + const headerSessionId = headers.get(HEADER_SESSION_ID); + + if (headerSessionId) { + return { + ok: true, + value: { session: headerSessionId }, + }; + } + + const paramsSessionId = sessionIdFromParams(message.params); + + if (paramsSessionId) { + return { + ok: true, + value: { session: paramsSessionId }, + }; + } + + if (methodRequiresSessionHeader(message.method)) { + return { + ok: false, + status: 400, + message: "Missing Acp-Session-Id", + }; + } + + return { + ok: true, + value: "connection", + }; } function isJsonRpcMessage(value: unknown): value is AnyMessage { @@ -236,9 +321,11 @@ function isJsonRpcMessage(value: unknown): value is AnyMessage { ); } -function isRequestMessage( - message: AnyMessage, -): message is AnyMessage & { readonly id: string | number | null } { +function isRequestMessage(message: AnyMessage): message is AnyMessage & { + readonly id: string | number | null; + readonly method: string; + readonly params?: unknown; +} { return "method" in message && "id" in message; } From a78318c516bfc0874826d44061cd87daf7c44278 Mon Sep 17 00:00:00 2001 From: Federico Ciner Date: Mon, 18 May 2026 20:40:47 +1000 Subject: [PATCH 05/20] Add tool permission request support --- src/connection.ts | 34 ++-- src/server-permission.test.ts | 305 +++++++++++++++++++++++++++++++++ src/server.ts | 87 +++++++--- src/test-support/test-agent.ts | 39 +++++ 4 files changed, 426 insertions(+), 39 deletions(-) create mode 100644 src/server-permission.test.ts diff --git a/src/connection.ts b/src/connection.ts index 098faaa7..f67df332 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -200,24 +200,32 @@ export class ConnectionState { this.allOutbound.push(message); if (isResponse(message)) { - const key = messageIdKey(message.id); - const route = key ? this.pendingRoutes.get(key) : undefined; - const sessionId = sessionIdFromResult( - "result" in message ? message.result : undefined, - ); + this.routeOutboundResponse(message); + return; + } - if (sessionId) { - this.ensureSession(sessionId); - } + this.routeOutboundRequestOrNotification(message); + } - if (key) { - this.pendingRoutes.delete(key); - } + private routeOutboundResponse(message: AnyResponse): void { + const key = messageIdKey(message.id); + const route = key ? this.pendingRoutes.get(key) : undefined; + const sessionId = sessionIdFromResult( + "result" in message ? message.result : undefined, + ); - this.pushToRoute(route ?? "connection", message); - return; + if (sessionId) { + this.ensureSession(sessionId); + } + + if (key) { + this.pendingRoutes.delete(key); } + this.pushToRoute(route ?? "connection", message); + } + + private routeOutboundRequestOrNotification(message: AnyMessage): void { if ("method" in message) { const sessionId = sessionIdFromParams(message.params); if (sessionId) { diff --git a/src/server-permission.test.ts b/src/server-permission.test.ts new file mode 100644 index 00000000..0ec3a9f6 --- /dev/null +++ b/src/server-permission.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from "vitest"; +import { + EVENT_STREAM_MIME_TYPE, + HEADER_CONNECTION_ID, + HEADER_SESSION_ID, + JSON_MIME_TYPE, +} from "./protocol.js"; +import { parseSseStream } from "./sse.js"; +import { TestAgent } from "./test-support/test-agent.js"; +import { startTestServer } from "./test-support/test-http-server.js"; + +import type { AgentSideConnection } from "./acp.js"; +import type { AnyMessage } from "./jsonrpc.js"; + +const initializeRequest = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: 1, + clientCapabilities: {}, + }, +}; + +const sessionNewRequest = { + jsonrpc: "2.0", + id: 2, + method: "session/new", + params: { + cwd: "/tmp", + mcpServers: [], + }, +}; + +function createPromptRequest(id: number, sessionId: string) { + return { + jsonrpc: "2.0", + id, + method: "session/prompt", + params: { + sessionId, + prompt: [{ type: "text", text: "Hello" }], + }, + }; +} + +describe("AcpServer permission requests over HTTP", () => { + it("routes permission requests over session SSE and accepts client responses", async () => { + const server = await startTestServer( + (conn: AgentSideConnection) => + new TestAgent(conn, { enablePermission: true }), + ); + + try { + const connectionId = await initialize(server.url); + const sessionId = await createSession(server.url, connectionId); + const connectionAbort = new AbortController(); + const connectionSse = await openConnectionSse( + server.url, + connectionId, + connectionAbort.signal, + ); + const sessionSse = await openSessionSse( + server.url, + connectionId, + sessionId, + ); + const sessionEvents = createSseMessageIterator(sessionSse); + + expect( + await postJson(server.url, createPromptRequest(3, sessionId), { + [HEADER_CONNECTION_ID]: connectionId, + [HEADER_SESSION_ID]: sessionId, + }), + ).toMatchObject({ status: 202 }); + + expect(await readNextSseMessage(sessionEvents)).toMatchObject({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { text: "chunk-1" }, + }, + }, + }); + + const permissionRequest = await readNextSseMessage(sessionEvents); + expect(permissionRequest).toMatchObject({ + jsonrpc: "2.0", + id: expect.any(Number), + method: "session/request_permission", + params: { + sessionId, + toolCall: { + toolCallId: "permission-tool", + title: "Permission tool", + }, + options: expect.arrayContaining([ + expect.objectContaining({ + kind: "allow_once", + optionId: "allow", + }), + ]), + }, + }); + expect( + await readNextMessageOrUndefined(connectionSse, connectionAbort), + ).toBeUndefined(); + + expect( + await postJson( + server.url, + { + jsonrpc: "2.0", + id: readMessageId(permissionRequest), + result: { + outcome: { + outcome: "selected", + optionId: "allow", + }, + }, + }, + { [HEADER_CONNECTION_ID]: connectionId }, + ), + ).toMatchObject({ status: 202 }); + + expect(await readNextSseMessage(sessionEvents)).toMatchObject({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { text: "permission-selected-allow" }, + }, + }, + }); + expect(await readNextSseMessage(sessionEvents)).toMatchObject({ + jsonrpc: "2.0", + id: 3, + result: { stopReason: "end_turn" }, + }); + + await sessionEvents.return?.(); + await sessionSse.body?.cancel(); + } finally { + await server.close(); + } + }, 10_000); +}); + +async function initialize(url: string): Promise { + const response = await postJson(url, initializeRequest); + const connectionId = response.headers.get(HEADER_CONNECTION_ID); + + expect(response.status).toBe(200); + expect(connectionId).toMatch(/^[0-9a-f-]{36}$/); + + return connectionId ?? ""; +} + +async function createSession( + url: string, + connectionId: string, +): Promise { + const response = await openConnectionSse(url, connectionId); + const events = createSseMessageIterator(response); + + try { + expect( + await postJson(url, sessionNewRequest, { + [HEADER_CONNECTION_ID]: connectionId, + }), + ).toMatchObject({ status: 202 }); + + return readSessionId(await readNextSseMessage(events)); + } finally { + await events.return?.(); + await response.body?.cancel(); + } +} + +function openConnectionSse( + url: string, + connectionId: string, + signal?: AbortSignal, +): Promise { + return fetch(url, { + method: "GET", + headers: { + Accept: EVENT_STREAM_MIME_TYPE, + [HEADER_CONNECTION_ID]: connectionId, + }, + signal, + }); +} + +function openSessionSse( + url: string, + connectionId: string, + sessionId: string, +): Promise { + return fetch(url, { + method: "GET", + headers: { + Accept: EVENT_STREAM_MIME_TYPE, + [HEADER_CONNECTION_ID]: connectionId, + [HEADER_SESSION_ID]: sessionId, + }, + }); +} + +function createSseMessageIterator( + response: Response, +): AsyncIterator { + if (!response.body) { + throw new Error("Expected SSE response body"); + } + + return parseSseStream(response.body)[Symbol.asyncIterator](); +} + +async function readNextSseMessage( + iterator: AsyncIterator, +): Promise { + const result = await iterator.next(); + + if (result.done) { + throw new Error("Expected SSE message"); + } + + return result.value; +} + +async function readNextMessageOrUndefined( + response: Response, + abort: AbortController, +): Promise { + if (!response.body) { + throw new Error("Expected SSE response body"); + } + + const iterator = parseSseStream(response.body)[Symbol.asyncIterator](); + + try { + const result = await Promise.race([ + iterator.next(), + delay(50).then(() => ({ done: true, value: undefined })), + ]); + + return result.done ? undefined : result.value; + } finally { + abort.abort(); + await iterator.return?.(); + } +} + +function readMessageId(message: AnyMessage): string | number | null { + if (!("id" in message)) { + throw new Error("Expected message ID"); + } + + return message.id; +} + +function readSessionId(message: AnyMessage): string { + if (!("result" in message) || !isRecord(message.result)) { + throw new Error("Expected session/new response result"); + } + + const sessionId = message.result["sessionId"]; + + if (typeof sessionId !== "string") { + throw new Error("Expected session ID"); + } + + return sessionId; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function postJson( + url: string, + body: unknown, + headers: Record = {}, +): Promise { + return fetch(url, { + method: "POST", + headers: { + "Content-Type": JSON_MIME_TYPE, + ...headers, + }, + body: JSON.stringify(body), + }); +} diff --git a/src/server.ts b/src/server.ts index 5a74208f..d6ae5d84 100644 --- a/src/server.ts +++ b/src/server.ts @@ -18,7 +18,7 @@ import type { ResponseRoute, } from "./connection.js"; import type { Agent, AgentSideConnection } from "./acp.js"; -import type { AnyMessage } from "./jsonrpc.js"; +import type { AnyMessage, AnyResponse } from "./jsonrpc.js"; export interface AcpServerOptions { createAgent: (conn: AgentSideConnection) => Agent; @@ -196,25 +196,14 @@ export class AcpServer { headers: Headers, ): Promise { if (isRequestMessage(message)) { - const route = determineRoute(message, headers); - - if (!route.ok) { - return route; - } - - if (route.value !== "connection") { - connection.ensureSession(route.value.session); - } - - const key = messageIdKey(message.id); + return await forwardClientRequest(connection, message, headers); + } - if (key) { - connection.pendingRoutes.set(key, route.value); - } + if (isResponseMessage(message)) { + return await forwardClientResponse(connection, message); } - await writeInbound(connection, message); - return { ok: true }; + return await forwardClientNotification(connection, message); } } @@ -248,6 +237,12 @@ type RouteResult = message: string; }; +type ClientRequestMessage = AnyMessage & { + readonly id: string | number | null; + readonly method: string; + readonly params?: unknown; +}; + async function readJson(req: Request): Promise { try { return { @@ -274,11 +269,49 @@ async function writeInbound( } } +async function forwardClientRequest( + connection: ConnectionState, + message: ClientRequestMessage, + headers: Headers, +): Promise { + const route = determineRoute(message, headers); + + if (!route.ok) { + return route; + } + + if (route.value !== "connection") { + connection.ensureSession(route.value.session); + } + + const key = messageIdKey(message.id); + + if (key) { + connection.pendingRoutes.set(key, route.value); + } + + await writeInbound(connection, message); + return { ok: true }; +} + +async function forwardClientResponse( + connection: ConnectionState, + message: AnyResponse, +): Promise { + await writeInbound(connection, message); + return { ok: true }; +} + +async function forwardClientNotification( + connection: ConnectionState, + message: AnyMessage, +): Promise { + await writeInbound(connection, message); + return { ok: true }; +} + function determineRoute( - message: AnyMessage & { - readonly method: string; - readonly params?: unknown; - }, + message: ClientRequestMessage, headers: Headers, ): RouteResult { const headerSessionId = headers.get(HEADER_SESSION_ID); @@ -321,14 +354,16 @@ function isJsonRpcMessage(value: unknown): value is AnyMessage { ); } -function isRequestMessage(message: AnyMessage): message is AnyMessage & { - readonly id: string | number | null; - readonly method: string; - readonly params?: unknown; -} { +function isRequestMessage( + message: AnyMessage, +): message is ClientRequestMessage { return "method" in message && "id" in message; } +function isResponseMessage(message: AnyMessage): message is AnyResponse { + return "id" in message && !("method" in message); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } diff --git a/src/test-support/test-agent.ts b/src/test-support/test-agent.ts index a1fcc87e..265e7833 100644 --- a/src/test-support/test-agent.ts +++ b/src/test-support/test-agent.ts @@ -17,17 +17,20 @@ import type { export interface TestAgentOptions { readonly chunkCount?: number; readonly chunkDelayMs?: number; + readonly enablePermission?: boolean; } export class TestAgent implements Agent { private readonly connection: AgentSideConnection; private readonly chunkCount: number; private readonly chunkDelayMs: number; + private readonly enablePermission: boolean; constructor(connection: AgentSideConnection, options: TestAgentOptions = {}) { this.connection = connection; this.chunkCount = options.chunkCount ?? 1; this.chunkDelayMs = options.chunkDelayMs ?? 0; + this.enablePermission = options.enablePermission ?? false; } initialize(_params: InitializeRequest): Promise { @@ -70,6 +73,42 @@ export class TestAgent implements Agent { }); } + if (this.enablePermission) { + const permission = await this.connection.requestPermission({ + sessionId: params.sessionId, + toolCall: { + toolCallId: "permission-tool", + title: "Permission tool", + }, + options: [ + { + kind: "allow_once", + name: "Allow once", + optionId: "allow", + }, + { + kind: "reject_once", + name: "Reject once", + optionId: "reject", + }, + ], + }); + + await this.connection.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: + permission.outcome.outcome === "selected" + ? `permission-selected-${permission.outcome.optionId}` + : "permission-cancelled", + }, + }, + }); + } + return { stopReason: "end_turn" }; } From 74a74a4b8c14207d2a9c522215b53d5d26a037ef Mon Sep 17 00:00:00 2001 From: Federico Ciner Date: Mon, 18 May 2026 21:40:22 +1000 Subject: [PATCH 06/20] Add ACP HTTP client transport --- package.json | 4 + src/connection.ts | 40 +--- src/http-stream.test.ts | 487 ++++++++++++++++++++++++++++++++++++++++ src/http-stream.ts | 303 +++++++++++++++++++++++++ src/jsonrpc.ts | 30 +++ src/protocol.ts | 27 ++- src/server.ts | 35 +-- src/sse.ts | 19 +- 8 files changed, 867 insertions(+), 78 deletions(-) create mode 100644 src/http-stream.test.ts create mode 100644 src/http-stream.ts diff --git a/package.json b/package.json index 97d3fe85..094bae22 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,10 @@ "types": "./dist/acp.d.ts", "default": "./dist/acp.js" }, + "./http-client": { + "types": "./dist/http-stream.d.ts", + "default": "./dist/http-stream.js" + }, "./server": { "types": "./dist/server.d.ts", "default": "./dist/server.js" diff --git a/src/connection.ts b/src/connection.ts index f67df332..cee4b778 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -1,5 +1,10 @@ import { AgentSideConnection } from "./acp.js"; -import { messageIdKey, sessionIdFromParams } from "./protocol.js"; +import { isResponseMessage } from "./jsonrpc.js"; +import { + messageIdKey, + sessionIdFromMessageParams, + sessionIdFromResponseResult, +} from "./protocol.js"; import type { Agent } from "./acp.js"; import type { AnyMessage, AnyResponse } from "./jsonrpc.js"; @@ -199,7 +204,7 @@ export class ConnectionState { private routeOutbound(message: AnyMessage): void { this.allOutbound.push(message); - if (isResponse(message)) { + if (isResponseMessage(message)) { this.routeOutboundResponse(message); return; } @@ -210,9 +215,7 @@ export class ConnectionState { private routeOutboundResponse(message: AnyResponse): void { const key = messageIdKey(message.id); const route = key ? this.pendingRoutes.get(key) : undefined; - const sessionId = sessionIdFromResult( - "result" in message ? message.result : undefined, - ); + const sessionId = sessionIdFromResponseResult(message); if (sessionId) { this.ensureSession(sessionId); @@ -226,12 +229,10 @@ export class ConnectionState { } private routeOutboundRequestOrNotification(message: AnyMessage): void { - if ("method" in message) { - const sessionId = sessionIdFromParams(message.params); - if (sessionId) { - this.ensureSession(sessionId).push(message); - return; - } + const sessionId = sessionIdFromMessageParams(message); + if (sessionId) { + this.ensureSession(sessionId).push(message); + return; } this.connectionStream.push(message); @@ -375,20 +376,3 @@ function isMatchingResponse( ): msg is AnyResponse { return "id" in msg && !("method" in msg) && msg.id === id; } - -function isResponse(msg: AnyMessage): msg is AnyResponse { - return "id" in msg && !("method" in msg); -} - -function sessionIdFromResult(result: unknown): string | undefined { - if (!isRecord(result)) { - return undefined; - } - - const sessionId = result["sessionId"]; - return typeof sessionId === "string" ? sessionId : undefined; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} diff --git a/src/http-stream.test.ts b/src/http-stream.test.ts new file mode 100644 index 00000000..d74c5bba --- /dev/null +++ b/src/http-stream.test.ts @@ -0,0 +1,487 @@ +import { describe, expect, it } from "vitest"; +import { ClientSideConnection, PROTOCOL_VERSION } from "./acp.js"; +import { createHttpStream } from "./http-stream.js"; +import { + EVENT_STREAM_MIME_TYPE, + HEADER_CONNECTION_ID, + HEADER_SESSION_ID, + JSON_MIME_TYPE, +} from "./protocol.js"; +import { serializeSseEvent } from "./sse.js"; +import { TestAgent } from "./test-support/test-agent.js"; +import { startTestServer } from "./test-support/test-http-server.js"; + +import type { + AgentSideConnection, + Client, + RequestPermissionRequest, + RequestPermissionResponse, + SessionNotification, +} from "./acp.js"; +import type { AnyMessage } from "./jsonrpc.js"; + +const initializeRequest = { + jsonrpc: "2.0", + id: 0, + method: "initialize", + params: { + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + }, +} satisfies AnyMessage; + +const initializeResponse = { + jsonrpc: "2.0", + id: 0, + result: { + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { + loadSession: false, + }, + }, +} satisfies AnyMessage; + +const sessionNewResponse = { + jsonrpc: "2.0", + id: 1, + result: { + sessionId: "session-1", + }, +} satisfies AnyMessage; + +const promptRequest = { + jsonrpc: "2.0", + id: 2, + method: "session/prompt", + params: { + sessionId: "session-1", + prompt: [{ type: "text", text: "Hello" }], + }, +} satisfies AnyMessage; + +describe("createHttpStream", () => { + it("posts initialize with custom headers, opens connection SSE, and emits the initialize response", async () => { + const controlledFetch = createControlledFetch(); + const stream = createHttpStream("https://agent.example/acp", { + fetch: controlledFetch.fetch, + headers: { + Authorization: "Bearer token", + "X-Test-Header": "phase-5", + }, + }); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + try { + await writer.write(initializeRequest); + + expect(await readMessage(reader)).toEqual(initializeResponse); + expect(controlledFetch.requests).toHaveLength(2); + + const initializePost = requestAt(controlledFetch.requests, 0); + expect(initializePost.url).toBe("https://agent.example/acp"); + expect(initializePost.method).toBe("POST"); + expect(initializePost.headers.get("Authorization")).toBe("Bearer token"); + expect(initializePost.headers.get("X-Test-Header")).toBe("phase-5"); + expect(initializePost.headers.get("Content-Type")).toBe(JSON_MIME_TYPE); + expect(initializePost.headers.get(HEADER_CONNECTION_ID)).toBeNull(); + expect(JSON.parse(initializePost.body)).toEqual(initializeRequest); + + const connectionGet = requestAt(controlledFetch.requests, 1); + expect(connectionGet.method).toBe("GET"); + expect(connectionGet.headers.get("Authorization")).toBe("Bearer token"); + expect(connectionGet.headers.get("Accept")).toBe(EVENT_STREAM_MIME_TYPE); + expect(connectionGet.headers.get(HEADER_CONNECTION_ID)).toBe( + "connection-1", + ); + expect(connectionGet.headers.get(HEADER_SESSION_ID)).toBeNull(); + } finally { + reader.releaseLock(); + writer.releaseLock(); + await stream.writable.close(); + } + }); + + it("opens session SSE after session creation and includes the session header on session-scoped POSTs", async () => { + const controlledFetch = createControlledFetch(); + const stream = createHttpStream("https://agent.example/acp", { + fetch: controlledFetch.fetch, + }); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + try { + await writer.write(initializeRequest); + await readMessage(reader); + await controlledFetch.sendSse(0, sessionNewResponse); + + expect(await readMessage(reader)).toEqual(sessionNewResponse); + expect(controlledFetch.requests).toHaveLength(3); + + const sessionGet = requestAt(controlledFetch.requests, 2); + expect(sessionGet.method).toBe("GET"); + expect(sessionGet.headers.get(HEADER_CONNECTION_ID)).toBe("connection-1"); + expect(sessionGet.headers.get(HEADER_SESSION_ID)).toBe("session-1"); + + await writer.write(promptRequest); + + const promptPost = requestAt(controlledFetch.requests, 3); + expect(promptPost.method).toBe("POST"); + expect(promptPost.headers.get(HEADER_CONNECTION_ID)).toBe("connection-1"); + expect(promptPost.headers.get(HEADER_SESSION_ID)).toBe("session-1"); + expect(JSON.parse(promptPost.body)).toEqual(promptRequest); + } finally { + reader.releaseLock(); + writer.releaseLock(); + await stream.writable.close(); + } + }); + + it("sends DELETE and aborts SSE requests when closed", async () => { + const controlledFetch = createControlledFetch(); + const stream = createHttpStream("https://agent.example/acp", { + fetch: controlledFetch.fetch, + }); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + try { + await writer.write(initializeRequest); + await readMessage(reader); + await writer.close(); + + const deleteRequest = requestAt(controlledFetch.requests, 2); + expect(deleteRequest.method).toBe("DELETE"); + expect(deleteRequest.headers.get(HEADER_CONNECTION_ID)).toBe( + "connection-1", + ); + expect(sseAt(controlledFetch.sseRequests, 0).signal.aborted).toBe(true); + } finally { + reader.releaseLock(); + writer.releaseLock(); + } + }); + + it("runs initialize, newSession, and prompt through ClientSideConnection", async () => { + const updates: SessionNotification[] = []; + const server = await startTestServer( + (conn: AgentSideConnection) => new TestAgent(conn, { chunkCount: 2 }), + ); + const stream = createHttpStream(server.url); + const conn = new ClientSideConnection( + () => createTestClient({ updates }), + stream, + ); + + try { + expect( + await conn.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + }), + ).toMatchObject({ + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { loadSession: false }, + }); + + const session = await conn.newSession({ cwd: "/tmp", mcpServers: [] }); + expect(session.sessionId).toMatch(/^[0-9a-f-]{36}$/); + + await expect( + conn.prompt({ + sessionId: session.sessionId, + prompt: [{ type: "text", text: "Hello" }], + }), + ).resolves.toEqual({ stopReason: "end_turn" }); + expect(updates).toHaveLength(2); + expect(updates).toMatchObject([ + { + sessionId: session.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { text: "chunk-1" }, + }, + }, + { + sessionId: session.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { text: "chunk-2" }, + }, + }, + ]); + } finally { + await closeStream(stream); + await server.close(); + } + }); + + it("round-trips permission requests through ClientSideConnection", async () => { + const updates: SessionNotification[] = []; + const permissionRequests: RequestPermissionRequest[] = []; + const server = await startTestServer( + (conn: AgentSideConnection) => + new TestAgent(conn, { enablePermission: true }), + ); + const stream = createHttpStream(server.url); + const conn = new ClientSideConnection( + () => createTestClient({ updates, permissionRequests }), + stream, + ); + + try { + await conn.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + }); + const session = await conn.newSession({ cwd: "/tmp", mcpServers: [] }); + + await expect( + conn.prompt({ + sessionId: session.sessionId, + prompt: [{ type: "text", text: "Hello" }], + }), + ).resolves.toEqual({ stopReason: "end_turn" }); + + expect(permissionRequests).toHaveLength(1); + expect(permissionRequests[0]).toMatchObject({ + sessionId: session.sessionId, + toolCall: { + toolCallId: "permission-tool", + title: "Permission tool", + }, + }); + expect(updates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sessionId: session.sessionId, + update: expect.objectContaining({ + sessionUpdate: "agent_message_chunk", + content: expect.objectContaining({ + text: "permission-selected-allow", + }), + }), + }), + ]), + ); + } finally { + await closeStream(stream); + await server.close(); + } + }); + + it("keeps multiple sessions isolated through the SDK client abstraction", async () => { + const updates: SessionNotification[] = []; + const server = await startTestServer(); + const stream = createHttpStream(server.url); + const conn = new ClientSideConnection( + () => createTestClient({ updates }), + stream, + ); + + try { + await conn.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + }); + const firstSession = await conn.newSession({ + cwd: "/tmp", + mcpServers: [], + }); + const secondSession = await conn.newSession({ + cwd: "/tmp/other", + mcpServers: [], + }); + + await Promise.all([ + conn.prompt({ + sessionId: firstSession.sessionId, + prompt: [{ type: "text", text: "First" }], + }), + conn.prompt({ + sessionId: secondSession.sessionId, + prompt: [{ type: "text", text: "Second" }], + }), + ]); + + expect(updates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ sessionId: firstSession.sessionId }), + expect.objectContaining({ sessionId: secondSession.sessionId }), + ]), + ); + expect( + updates.filter((update) => update.sessionId === firstSession.sessionId), + ).toHaveLength(1); + expect( + updates.filter( + (update) => update.sessionId === secondSession.sessionId, + ), + ).toHaveLength(1); + } finally { + await closeStream(stream); + await server.close(); + } + }); +}); + +interface RecordedRequest { + readonly url: string; + readonly method: string; + readonly headers: Headers; + readonly body: string; +} + +interface RecordedSseRequest { + readonly signal: AbortSignal; + readonly writer: WritableStreamDefaultWriter; +} + +interface ControlledFetch { + readonly fetch: typeof globalThis.fetch; + readonly requests: RecordedRequest[]; + readonly sseRequests: RecordedSseRequest[]; + readonly sendSse: (index: number, message: AnyMessage) => Promise; +} + +interface TestClientState { + readonly updates: SessionNotification[]; + readonly permissionRequests?: RequestPermissionRequest[]; +} + +function createControlledFetch(): ControlledFetch { + const requests: RecordedRequest[] = []; + const sseRequests: RecordedSseRequest[] = []; + const encoder = new TextEncoder(); + + return { + requests, + sseRequests, + fetch: async (input, init) => { + const method = init?.method ?? "GET"; + const headers = new Headers(init?.headers); + requests.push({ + url: String(input), + method, + headers, + body: bodyToString(init?.body), + }); + + if (method === "POST" && !headers.has(HEADER_CONNECTION_ID)) { + return jsonResponse(initializeResponse, 200, { + [HEADER_CONNECTION_ID]: "connection-1", + }); + } + + if (method === "POST" || method === "DELETE") { + return new Response(null, { status: 202 }); + } + + if (method === "GET") { + const stream = new TransformStream(); + const writer = stream.writable.getWriter(); + const signal = init?.signal; + + if (signal) { + signal.addEventListener("abort", () => { + void writer.close(); + }); + } + + sseRequests.push({ + signal: signal ?? new AbortController().signal, + writer, + }); + + return new Response(stream.readable, { + status: 200, + headers: { "Content-Type": EVENT_STREAM_MIME_TYPE }, + }); + } + + return new Response("Unexpected method", { status: 405 }); + }, + sendSse: async (index, message) => { + await sseAt(sseRequests, index).writer.write( + encoder.encode(serializeSseEvent(message)), + ); + }, + }; +} + +function createTestClient(state: TestClientState): Client { + return { + requestPermission: (params): Promise => { + state.permissionRequests?.push(params); + return Promise.resolve({ + outcome: { + outcome: "selected", + optionId: "allow", + }, + }); + }, + sessionUpdate: (params): Promise => { + state.updates.push(params); + return Promise.resolve(); + }, + }; +} + +async function closeStream(stream: { + writable: WritableStream; +}): Promise { + await stream.writable.close().catch(() => undefined); +} + +async function readMessage( + reader: ReadableStreamDefaultReader, +): Promise { + const result = await reader.read(); + if (result.done) { + throw new Error("Expected a message"); + } + + return result.value; +} + +function requestAt( + requests: readonly RecordedRequest[], + index: number, +): RecordedRequest { + const request = requests[index]; + if (!request) { + throw new Error(`Expected request at index ${index}`); + } + + return request; +} + +function sseAt( + requests: readonly RecordedSseRequest[], + index: number, +): RecordedSseRequest { + const request = requests[index]; + if (!request) { + throw new Error(`Expected SSE request at index ${index}`); + } + + return request; +} + +function bodyToString(body: BodyInit | null | undefined): string { + return typeof body === "string" ? body : ""; +} + +function jsonResponse( + body: AnyMessage, + status: number, + headers: Record, +): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "Content-Type": JSON_MIME_TYPE, + ...headers, + }, + }); +} diff --git a/src/http-stream.ts b/src/http-stream.ts new file mode 100644 index 00000000..f473735e --- /dev/null +++ b/src/http-stream.ts @@ -0,0 +1,303 @@ +import { isJsonRpcMessage } from "./jsonrpc.js"; +import { + EVENT_STREAM_MIME_TYPE, + HEADER_CONNECTION_ID, + HEADER_SESSION_ID, + JSON_MIME_TYPE, + isInitializeRequest, + sessionIdFromMessageParams, + sessionIdFromResponseResult, +} from "./protocol.js"; +import { parseSseStream } from "./sse.js"; + +import type { AnyMessage } from "./jsonrpc.js"; +import type { Stream } from "./stream.js"; + +export interface HttpStreamOptions { + readonly fetch?: typeof globalThis.fetch; + readonly headers?: Record; +} + +/** + * Creates an ACP Stream that speaks the Streamable HTTP transport. + * + * The transport uses HTTP POST for client-to-agent messages and SSE GET streams for agent-to-client messages. + * Cookie management is intentionally not built in; pass a cookie-aware fetch implementation when needed. + */ +export function createHttpStream( + serverUrl: string, + options: HttpStreamOptions = {}, +): Stream { + return new HttpStreamTransport(serverUrl, options).stream; +} + +class HttpStreamTransport { + readonly stream: Stream; + + private readonly fetchImpl: typeof globalThis.fetch; + private readonly headers: Record; + private readonly abortController = new AbortController(); + private readonly knownSessions = new Set(); + + private readableController: + | ReadableStreamDefaultController + | undefined; + private connectionId: string | undefined; + private isClosed = false; + private writeChain: Promise = Promise.resolve(); + + constructor( + private readonly serverUrl: string, + options: HttpStreamOptions, + ) { + this.fetchImpl = resolveFetch(options.fetch); + this.headers = options.headers ?? {}; + + this.stream = { + readable: new ReadableStream({ + start: (controller) => { + this.readableController = controller; + }, + cancel: () => { + void this.close(); + }, + }), + writable: new WritableStream({ + write: (message) => { + this.writeChain = this.writeChain.then(() => + this.writeMessage(message), + ); + return this.writeChain; + }, + close: () => this.close(), + abort: () => this.close(), + }), + }; + } + + private async writeMessage(message: AnyMessage): Promise { + if (this.isClosed) { + throw new Error("ACP HTTP stream is closed"); + } + + if (!this.connectionId) { + await this.postInitialize(message); + return; + } + + await this.postConnectedMessage(message); + } + + private async postInitialize(message: AnyMessage): Promise { + if (!isInitializeRequest(message)) { + throw new Error("ACP HTTP stream first message must be initialize"); + } + + const response = await this.fetchImpl(this.serverUrl, { + method: "POST", + headers: { + ...this.headers, + "Content-Type": JSON_MIME_TYPE, + }, + body: JSON.stringify(message), + }); + + if (!response.ok) { + throw await httpError("ACP initialize failed", response); + } + + const connectionId = response.headers.get(HEADER_CONNECTION_ID); + if (!connectionId) { + throw new Error("ACP initialize response missing Acp-Connection-Id"); + } + + const body: unknown = await response.json(); + if (!isJsonRpcMessage(body)) { + throw new Error("ACP initialize response was not a JSON-RPC message"); + } + + this.connectionId = connectionId; + this.openConnectionSse(); + this.enqueue(body); + } + + private async postConnectedMessage(message: AnyMessage): Promise { + const connectionId = this.connectionId; + if (!connectionId) { + throw new Error("ACP HTTP stream is not initialized"); + } + + const sessionId = sessionIdFromMessageParams(message); + const response = await this.fetchImpl(this.serverUrl, { + method: "POST", + headers: { + ...this.headers, + "Content-Type": JSON_MIME_TYPE, + [HEADER_CONNECTION_ID]: connectionId, + ...(sessionId ? { [HEADER_SESSION_ID]: sessionId } : {}), + }, + body: JSON.stringify(message), + }); + + if (!response.ok) { + throw await httpError("ACP POST failed", response); + } + } + + private openConnectionSse(): void { + const connectionId = this.connectionId; + if (!connectionId) { + return; + } + + void this.openSse({ + [HEADER_CONNECTION_ID]: connectionId, + }); + } + + private openSessionSse(sessionId: string): void { + if (this.knownSessions.has(sessionId)) { + return; + } + + const connectionId = this.connectionId; + if (!connectionId) { + return; + } + + this.knownSessions.add(sessionId); + + void this.openSse({ + [HEADER_CONNECTION_ID]: connectionId, + [HEADER_SESSION_ID]: sessionId, + }); + } + + private async openSse(headers: Record): Promise { + try { + const response = await this.fetchImpl(this.serverUrl, { + method: "GET", + headers: { + ...this.headers, + Accept: EVENT_STREAM_MIME_TYPE, + ...headers, + }, + signal: this.abortController.signal, + }); + + if (!response.ok) { + throw await httpError("ACP SSE connection failed", response); + } + + if (!response.body) { + throw new Error("ACP SSE response missing body"); + } + + for await (const message of parseSseStream(response.body)) { + if (this.isClosed) { + return; + } + + const sessionId = sessionIdFromResponseResult(message); + if (sessionId) { + this.openSessionSse(sessionId); + } + + this.enqueue(message); + } + } catch (error) { + if (this.isClosed || this.abortController.signal.aborted) { + return; + } + + this.errorReadable(error); + } + } + + private async close(): Promise { + if (this.isClosed) { + return; + } + + this.isClosed = true; + + const connectionId = this.connectionId; + if (connectionId) { + const response = await this.fetchImpl(this.serverUrl, { + method: "DELETE", + headers: { + ...this.headers, + [HEADER_CONNECTION_ID]: connectionId, + }, + }); + + if (!response.ok) { + this.abortController.abort(); + this.closeReadable(); + throw await httpError("ACP DELETE failed", response); + } + } + + this.abortController.abort(); + this.closeReadable(); + } + + private enqueue(message: AnyMessage): void { + try { + this.readableController?.enqueue(message); + } catch (error) { + this.errorReadable(error); + } + } + + private errorReadable(error: unknown): void { + if (this.isClosed) { + return; + } + + this.isClosed = true; + this.abortController.abort(); + + try { + this.readableController?.error(error); + } catch { + // The readable side may already be closed or cancelled. + } + } + + private closeReadable(): void { + try { + this.readableController?.close(); + } catch { + // The readable side may already be closed, cancelled, or errored. + } + } +} + +function resolveFetch( + fetchImpl: typeof globalThis.fetch | undefined, +): typeof globalThis.fetch { + if (fetchImpl) { + return fetchImpl; + } + + if (typeof globalThis.fetch === "function") { + return (input, init) => globalThis.fetch(input, init); + } + + throw new Error( + "createHttpStream requires globalThis.fetch or options.fetch", + ); +} + +async function httpError(prefix: string, response: Response): Promise { + const text = await response.text().catch(() => ""); + + if (text) { + return new Error( + `${prefix}: ${response.status} ${response.statusText}: ${text}`, + ); + } + + return new Error(`${prefix}: ${response.status} ${response.statusText}`); +} diff --git a/src/jsonrpc.ts b/src/jsonrpc.ts index 6f556d67..31ee0eed 100644 --- a/src/jsonrpc.ts +++ b/src/jsonrpc.ts @@ -44,3 +44,33 @@ export type NotificationHandler = ( method: string, params: unknown, ) => Promise; + +export function isJsonRpcMessage(value: unknown): value is AnyMessage { + if (!isRecord(value) || value["jsonrpc"] !== "2.0") { + return false; + } + + if ("method" in value) { + return typeof value["method"] === "string"; + } + + return "id" in value; +} + +export function isRequestMessage(message: AnyMessage): message is AnyRequest { + return "id" in message && "method" in message; +} + +export function isResponseMessage(message: AnyMessage): message is AnyResponse { + return "id" in message && !("method" in message); +} + +export function isNotificationMessage( + message: AnyMessage, +): message is AnyNotification { + return "method" in message && !("id" in message); +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/src/protocol.ts b/src/protocol.ts index a88cb955..61c99e7d 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -1,5 +1,5 @@ import { AGENT_METHODS } from "./schema/index.js"; - +import { isRecord, isResponseMessage } from "./jsonrpc.js"; import type { AnyMessage } from "./jsonrpc.js"; export const HEADER_CONNECTION_ID = "Acp-Connection-Id"; @@ -31,6 +31,27 @@ export function sessionIdFromParams(params: unknown): string | undefined { return typeof sessionId === "string" ? sessionId : undefined; } +export function sessionIdFromMessageParams( + message: AnyMessage, +): string | undefined { + return "method" in message ? sessionIdFromParams(message.params) : undefined; +} + +export function sessionIdFromResponseResult( + message: AnyMessage, +): string | undefined { + if (!isResponseMessage(message) || !("result" in message)) { + return undefined; + } + + if (!isRecord(message.result)) { + return undefined; + } + + const sessionId = message.result["sessionId"]; + return typeof sessionId === "string" ? sessionId : undefined; +} + export function isInitializeRequest(msg: AnyMessage): boolean { return ( msg.jsonrpc === "2.0" && @@ -53,7 +74,3 @@ export function messageIdKey( return undefined; } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} diff --git a/src/server.ts b/src/server.ts index d6ae5d84..a8748c78 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,6 +9,11 @@ import { methodRequiresSessionHeader, sessionIdFromParams, } from "./protocol.js"; +import { + isJsonRpcMessage, + isRequestMessage, + isResponseMessage, +} from "./jsonrpc.js"; import { serializeSseEvent, serializeSseKeepAlive } from "./sse.js"; @@ -18,7 +23,7 @@ import type { ResponseRoute, } from "./connection.js"; import type { Agent, AgentSideConnection } from "./acp.js"; -import type { AnyMessage, AnyResponse } from "./jsonrpc.js"; +import type { AnyMessage, AnyRequest, AnyResponse } from "./jsonrpc.js"; export interface AcpServerOptions { createAgent: (conn: AgentSideConnection) => Agent; @@ -237,11 +242,7 @@ type RouteResult = message: string; }; -type ClientRequestMessage = AnyMessage & { - readonly id: string | number | null; - readonly method: string; - readonly params?: unknown; -}; +type ClientRequestMessage = AnyRequest; async function readJson(req: Request): Promise { try { @@ -346,28 +347,6 @@ function determineRoute( }; } -function isJsonRpcMessage(value: unknown): value is AnyMessage { - return ( - isRecord(value) && - value.jsonrpc === "2.0" && - ("method" in value || "id" in value) - ); -} - -function isRequestMessage( - message: AnyMessage, -): message is ClientRequestMessage { - return "method" in message && "id" in message; -} - -function isResponseMessage(message: AnyMessage): message is AnyResponse { - return "id" in message && !("method" in message); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - function sseResponse(subscription: OutboundSubscription): Response { return new Response(createSseBody(subscription), { status: 200, diff --git a/src/sse.ts b/src/sse.ts index 9b7b8cb2..7f52388b 100644 --- a/src/sse.ts +++ b/src/sse.ts @@ -1,4 +1,5 @@ import type { AnyMessage } from "./jsonrpc.js"; +import { isJsonRpcMessage } from "./jsonrpc.js"; export function serializeSseEvent(msg: AnyMessage): string { return `data: ${JSON.stringify(msg)}\n\n`; @@ -76,7 +77,7 @@ function parseSseEvent(eventPart: string): AnyMessage | undefined { try { const parsed: unknown = JSON.parse(data); - if (isAnyMessage(parsed)) { + if (isJsonRpcMessage(parsed)) { return parsed; } @@ -87,19 +88,3 @@ function parseSseEvent(eventPart: string): AnyMessage | undefined { return undefined; } } - -function isAnyMessage(value: unknown): value is AnyMessage { - if (!isRecord(value) || value["jsonrpc"] !== "2.0") { - return false; - } - - if ("method" in value) { - return typeof value["method"] === "string"; - } - - return "id" in value; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} From 0feffce71b28378f035fd8afc1c60510bb832fa8 Mon Sep 17 00:00:00 2001 From: Federico Ciner Date: Tue, 19 May 2026 09:50:32 +1000 Subject: [PATCH 07/20] Add WebSocket server impl --- src/server.ts | 10 +- src/ws-server.ts | 425 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 434 insertions(+), 1 deletion(-) create mode 100644 src/ws-server.ts diff --git a/src/server.ts b/src/server.ts index a8748c78..85353fe2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,8 +14,9 @@ import { isRequestMessage, isResponseMessage, } from "./jsonrpc.js"; - import { serializeSseEvent, serializeSseKeepAlive } from "./sse.js"; +import { handleWebSocketConnection } from "./ws-server.js"; +import type { WebSocketServerSocket } from "./ws-server.js"; import type { ConnectionState, @@ -53,6 +54,13 @@ export class AcpServer { return textResponse("Method Not Allowed", 405); } + handleWebSocket(socket: WebSocketServerSocket): void { + handleWebSocketConnection(socket, { + registry: this.registry, + createAgent: this.createAgent, + }); + } + async close(): Promise { this.registry.closeAll(); } diff --git a/src/ws-server.ts b/src/ws-server.ts new file mode 100644 index 00000000..2dc07575 --- /dev/null +++ b/src/ws-server.ts @@ -0,0 +1,425 @@ +import { + isJsonRpcMessage, + isRequestMessage, + isResponseMessage, +} from "./jsonrpc.js"; +import { + isInitializeRequest, + messageIdKey, + sessionIdFromParams, +} from "./protocol.js"; + +import type { Agent, AgentSideConnection } from "./acp.js"; +import type { + ConnectionRegistry, + ConnectionState, + ResponseRoute, +} from "./connection.js"; +import type { AnyMessage, AnyRequest } from "./jsonrpc.js"; + +type ForwardResult = + | { + ok: true; + } + | { + ok: false; + message: string; + }; + +export interface WebSocketServerSocket { + readonly readyState?: number; + send(data: string): void; + close(code?: number, reason?: string): void; + addEventListener?(type: string, listener: (event: unknown) => void): void; + removeEventListener?(type: string, listener: (event: unknown) => void): void; + on?(type: string, listener: (...args: unknown[]) => void): unknown; + off?(type: string, listener: (...args: unknown[]) => void): unknown; + removeListener?( + type: string, + listener: (...args: unknown[]) => void, + ): unknown; +} + +export interface WebSocketConnectionOptions { + readonly registry: ConnectionRegistry; + readonly createAgent: (conn: AgentSideConnection) => Agent; +} + +export function handleWebSocketConnection( + socket: WebSocketServerSocket, + options: WebSocketConnectionOptions, +): void { + const session = new WebSocketServerSession(socket, options); + session.start(); +} + +class WebSocketServerSession { + private connection: ConnectionState | undefined; + private outboundReader: ReadableStreamDefaultReader | undefined; + private isClosed = false; + private readonly detachListeners: Array<() => void> = []; + + constructor( + private readonly socket: WebSocketServerSocket, + private readonly options: WebSocketConnectionOptions, + ) {} + + start(): void { + this.detachListeners.push( + onSocket(this.socket, "message", (...args) => { + void this.handleSocketMessage(args); + }), + ); + + this.detachListeners.push( + onSocket(this.socket, "close", () => { + void this.closeSession(); + }), + ); + + this.detachListeners.push( + onSocket(this.socket, "error", () => { + void this.shutdown(1011, "WebSocket error"); + }), + ); + } + + private async handleSocketMessage(args: unknown[]): Promise { + if (this.isClosed) { + return; + } + + const text = socketMessageToString(args); + if (text === undefined) { + console.warn("Ignoring non-text ACP WebSocket frame"); + return; + } + + let value: unknown; + try { + value = JSON.parse(text); + } catch (error) { + console.warn("Ignoring malformed ACP WebSocket JSON message:", error); + await this.shutdownIfUninitialized(1007, "Malformed JSON"); + + return; + } + + if (Array.isArray(value)) { + console.warn("Ignoring ACP WebSocket JSON-RPC batch message"); + await this.shutdownIfUninitialized( + 1002, + "JSON-RPC batch messages are not supported", + ); + return; + } + + if (!isJsonRpcMessage(value)) { + console.warn("Ignoring non-JSON-RPC ACP WebSocket message:", value); + await this.shutdownIfUninitialized(1002, "Invalid JSON-RPC message"); + return; + } + + if (!this.connection) { + await this.handleInitialize(value); + return; + } + + const forwarded = await this.forwardMessage(value); + if (!forwarded.ok) { + console.warn("Ignoring ACP WebSocket message:", forwarded.message); + } + } + + private async handleInitialize(message: AnyMessage): Promise { + if (!isInitializeRequest(message)) { + console.warn("First ACP WebSocket message must be initialize"); + await this.shutdown(1002, "First message must be initialize"); + return; + } + + if (!("id" in message) || message.id === null) { + console.warn("ACP WebSocket initialize request must include an ID"); + await this.shutdown(1002, "Initialize request must include an ID"); + return; + } + + let connection: ConnectionState | undefined; + + try { + connection = this.options.registry.createConnection( + this.options.createAgent, + ); + + await writeInbound(connection, message); + + const initialResponse = await connection.recvInitial(message.id); + + this.connection = connection; + connection.startRouter(); + + this.send(initialResponse); + this.startOutboundPump(connection); + } catch (error) { + if (connection) { + this.options.registry.remove(connection.connectionId); + } + + this.send({ + jsonrpc: "2.0", + id: message.id, + error: { + code: -32603, + message: "Initialize failed", + data: error instanceof Error ? error.message : undefined, + }, + }); + + await this.shutdown(1011, "Initialize failed"); + } + } + + private async forwardMessage(message: AnyMessage): Promise { + const connection = this.connection; + + if (!connection) { + return { + ok: false, + message: "ACP WebSocket connection is not initialized", + }; + } + + if (isRequestMessage(message)) { + const route = determineWebSocketRoute(message); + + if (route !== "connection") { + connection.ensureSession(route.session); + } + + const key = messageIdKey(message.id); + + if (key) { + connection.pendingRoutes.set(key, route); + } + + await writeInbound(connection, message); + return { ok: true }; + } + + if (isResponseMessage(message)) { + await writeInbound(connection, message); + return { ok: true }; + } + + await writeInbound(connection, message); + return { ok: true }; + } + + private startOutboundPump(connection: ConnectionState): void { + const subscription = connection.allOutbound.subscribe(); + const reader = subscription.stream.getReader(); + this.outboundReader = reader; + + void (async () => { + try { + for (const message of subscription.replay) { + if (!this.send(message)) { + return; + } + } + + while (!this.isClosed) { + const result = await reader.read(); + + if (result.done) { + return; + } + + if (!this.send(result.value)) { + return; + } + } + } catch (error) { + if (!this.isClosed) { + console.error("ACP WebSocket outbound pump failed:", error); + } + } finally { + if (this.outboundReader === reader) { + this.outboundReader = undefined; + } + + reader.releaseLock(); + + if (!this.isClosed) { + void this.shutdown(); + } + } + })(); + } + + private send(message: AnyMessage): boolean { + if (this.isClosed) { + return false; + } + + try { + this.socket.send(JSON.stringify(message)); + return true; + } catch (error) { + console.warn("Failed to send ACP WebSocket message:", error); + void this.shutdown(1011, "Failed to send message"); + return false; + } + } + + private async shutdownIfUninitialized( + code?: number, + reason?: string, + ): Promise { + if (this.connection) { + return; + } + + await this.shutdown(code, reason); + } + + private async shutdown(code?: number, reason?: string): Promise { + this.closeSocket(code, reason); + await this.closeSession(); + } + + private closeSocket(code?: number, reason?: string): void { + try { + this.socket.close(code, reason); + } catch (error) { + console.warn("Failed to close ACP WebSocket:", error); + } + } + + private async closeSession(): Promise { + if (this.isClosed) { + return; + } + + this.isClosed = true; + + for (const detach of this.detachListeners.splice(0)) { + detach(); + } + + const outboundReader = this.outboundReader; + this.outboundReader = undefined; + + if (outboundReader) { + await outboundReader.cancel(); + } + + if (this.connection) { + this.options.registry.remove(this.connection.connectionId); + this.connection = undefined; + } + } +} + +async function writeInbound( + connection: ConnectionState, + message: AnyMessage, +): Promise { + const writer = connection.inboundTx.getWriter(); + + try { + await writer.write(message); + } finally { + writer.releaseLock(); + } +} + +function determineWebSocketRoute(message: AnyRequest): ResponseRoute { + const sessionId = sessionIdFromParams(message.params); + + if (sessionId) { + return { + session: sessionId, + }; + } + + return "connection"; +} + +function onSocket( + socket: WebSocketServerSocket, + type: string, + listener: (...args: unknown[]) => void, +): () => void { + if (socket.addEventListener) { + const eventListener = (event: unknown) => listener(event); + socket.addEventListener(type, eventListener); + + return () => { + socket.removeEventListener?.(type, eventListener); + }; + } + + if (socket.on) { + socket.on(type, listener); + + return () => { + if (socket.off) { + socket.off(type, listener); + return; + } + + socket.removeListener?.(type, listener); + }; + } + + throw new Error("WebSocket object does not support event listeners"); +} + +function socketMessageToString(args: unknown[]): string | undefined { + const data = extractMessageData(args); + + if (typeof data === "string") { + return data; + } + + if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) { + return new TextDecoder().decode(data); + } + + if (Array.isArray(data) && data.every(ArrayBuffer.isView)) { + return decodeArrayBufferViews(data); + } + + return undefined; +} + +function extractMessageData(args: unknown[]): unknown { + const [first] = args; + + if (isMessageEventLike(first)) { + return first.data; + } + + return first; +} + +function isMessageEventLike(value: unknown): value is { data: unknown } { + return typeof value === "object" && value !== null && "data" in value; +} + +function decodeArrayBufferViews(views: ArrayBufferView[]): string { + const totalLength = views.reduce((sum, view) => sum + view.byteLength, 0); + const combined = new Uint8Array(totalLength); + let offset = 0; + + for (const view of views) { + combined.set( + new Uint8Array(view.buffer, view.byteOffset, view.byteLength), + offset, + ); + offset += view.byteLength; + } + + return new TextDecoder().decode(combined); +} From 47537ac08504100b93abe33d1762474470941d19 Mon Sep 17 00:00:00 2001 From: Federico Ciner Date: Tue, 19 May 2026 10:27:08 +1000 Subject: [PATCH 08/20] Add WebSocket client SDK and split out shared methods into ws-utils, add tests --- package-lock.json | 34 +++ package.json | 6 + src/test-support/test-http-server.ts | 36 ++- src/ws-server.ts | 130 ++------- src/ws-stream.test.ts | 411 +++++++++++++++++++++++++++ src/ws-stream.ts | 239 ++++++++++++++++ src/ws-utils.ts | 108 +++++++ 7 files changed, 861 insertions(+), 103 deletions(-) create mode 100644 src/ws-stream.test.ts create mode 100644 src/ws-stream.ts create mode 100644 src/ws-utils.ts diff --git a/package-lock.json b/package-lock.json index 673ba2d5..1a1c64f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@eslint/js": "^10.0.1", "@hey-api/openapi-ts": "^0.98.0", "@types/node": "^25.5.0", + "@types/ws": "^8.5.13", "@typescript-eslint/eslint-plugin": "^8.57.1", "@typescript-eslint/parser": "^8.57.1", "concurrently": "^10.0.0", @@ -25,6 +26,7 @@ "typedoc-github-theme": "^0.4.0", "typescript": "^6.0.2", "vitest": "^4.1.0", + "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0" }, "peerDependencies": { @@ -1263,6 +1265,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://packages.atlassian.com/api/npm/npm-remote/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.60.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", @@ -4561,6 +4573,28 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://packages.atlassian.com/api/npm/npm-remote/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/wsl-utils": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", diff --git a/package.json b/package.json index 094bae22..940b126b 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,10 @@ "types": "./dist/http-stream.d.ts", "default": "./dist/http-stream.js" }, + "./ws-client": { + "types": "./dist/ws-stream.d.ts", + "default": "./dist/ws-stream.js" + }, "./server": { "types": "./dist/server.d.ts", "default": "./dist/server.js" @@ -69,6 +73,7 @@ "@eslint/js": "^10.0.1", "@hey-api/openapi-ts": "^0.98.0", "@types/node": "^25.5.0", + "@types/ws": "^8.5.13", "@typescript-eslint/eslint-plugin": "^8.57.1", "@typescript-eslint/parser": "^8.57.1", "concurrently": "^10.0.0", @@ -82,6 +87,7 @@ "typedoc-github-theme": "^0.4.0", "typescript": "^6.0.2", "vitest": "^4.1.0", + "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0" } } diff --git a/src/test-support/test-http-server.ts b/src/test-support/test-http-server.ts index de29c5ef..96b746b8 100644 --- a/src/test-support/test-http-server.ts +++ b/src/test-support/test-http-server.ts @@ -1,4 +1,5 @@ import http from "node:http"; +import { WebSocketServer } from "ws"; import { AcpServer } from "../server.js"; import { createNodeHttpHandler } from "../node-adapter.js"; @@ -9,6 +10,7 @@ import type { Agent, AgentSideConnection } from "../acp.js"; export interface TestHttpServer { readonly url: string; + readonly wsUrl: string; readonly close: () => Promise; } @@ -19,6 +21,13 @@ export async function startTestServer( ): Promise { const acpServer = new AcpServer({ createAgent: agentFactory }); const httpServer = http.createServer(createNodeHttpHandler(acpServer)); + const webSocketServer = new WebSocketServer({ noServer: true }); + + httpServer.on("upgrade", (req, socket, head) => { + webSocketServer.handleUpgrade(req, socket, head, (webSocket) => { + acpServer.handleWebSocket(webSocket); + }); + }); await listen(httpServer, options.port ?? 0); @@ -30,8 +39,14 @@ export async function startTestServer( return { url: `http://127.0.0.1:${address.port}`, + wsUrl: `ws://127.0.0.1:${address.port}`, close: async () => { - await Promise.all([acpServer.close(), closeHttpServer(httpServer)]); + terminateWebSockets(webSocketServer); + await Promise.all([ + acpServer.close(), + closeWebSocketServer(webSocketServer), + closeHttpServer(httpServer), + ]); }, }; } @@ -54,6 +69,25 @@ function listen(server: http.Server, port: number): Promise { }); } +function terminateWebSockets(server: WebSocketServer): void { + for (const client of server.clients) { + client.terminate(); + } +} + +function closeWebSocketServer(server: WebSocketServer): Promise { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); +} + function closeHttpServer(server: http.Server): Promise { return new Promise((resolve, reject) => { server.close((error) => { diff --git a/src/ws-server.ts b/src/ws-server.ts index 2dc07575..fe7cc920 100644 --- a/src/ws-server.ts +++ b/src/ws-server.ts @@ -8,7 +8,7 @@ import { messageIdKey, sessionIdFromParams, } from "./protocol.js"; - +import { onWebSocket, webSocketMessageToString } from "./ws-utils.js"; import type { Agent, AgentSideConnection } from "./acp.js"; import type { ConnectionRegistry, @@ -16,6 +16,9 @@ import type { ResponseRoute, } from "./connection.js"; import type { AnyMessage, AnyRequest } from "./jsonrpc.js"; +import type { WebSocketLike } from "./ws-utils.js"; + +export type WebSocketServerSocket = WebSocketLike; type ForwardResult = | { @@ -26,27 +29,13 @@ type ForwardResult = message: string; }; -export interface WebSocketServerSocket { - readonly readyState?: number; - send(data: string): void; - close(code?: number, reason?: string): void; - addEventListener?(type: string, listener: (event: unknown) => void): void; - removeEventListener?(type: string, listener: (event: unknown) => void): void; - on?(type: string, listener: (...args: unknown[]) => void): unknown; - off?(type: string, listener: (...args: unknown[]) => void): unknown; - removeListener?( - type: string, - listener: (...args: unknown[]) => void, - ): unknown; -} - export interface WebSocketConnectionOptions { readonly registry: ConnectionRegistry; readonly createAgent: (conn: AgentSideConnection) => Agent; } export function handleWebSocketConnection( - socket: WebSocketServerSocket, + socket: WebSocketLike, options: WebSocketConnectionOptions, ): void { const session = new WebSocketServerSession(socket, options); @@ -56,29 +45,30 @@ export function handleWebSocketConnection( class WebSocketServerSession { private connection: ConnectionState | undefined; private outboundReader: ReadableStreamDefaultReader | undefined; + private inboundWriteChain: Promise = Promise.resolve(); private isClosed = false; private readonly detachListeners: Array<() => void> = []; constructor( - private readonly socket: WebSocketServerSocket, + private readonly socket: WebSocketLike, private readonly options: WebSocketConnectionOptions, ) {} start(): void { this.detachListeners.push( - onSocket(this.socket, "message", (...args) => { + onWebSocket(this.socket, "message", (...args) => { void this.handleSocketMessage(args); }), ); this.detachListeners.push( - onSocket(this.socket, "close", () => { + onWebSocket(this.socket, "close", () => { void this.closeSession(); }), ); this.detachListeners.push( - onSocket(this.socket, "error", () => { + onWebSocket(this.socket, "error", () => { void this.shutdown(1011, "WebSocket error"); }), ); @@ -89,7 +79,7 @@ class WebSocketServerSession { return; } - const text = socketMessageToString(args); + const text = webSocketMessageToString(args); if (text === undefined) { console.warn("Ignoring non-text ACP WebSocket frame"); return; @@ -202,19 +192,33 @@ class WebSocketServerSession { connection.pendingRoutes.set(key, route); } - await writeInbound(connection, message); + await this.writeInbound(message); return { ok: true }; } if (isResponseMessage(message)) { - await writeInbound(connection, message); + await this.writeInbound(message); return { ok: true }; } - await writeInbound(connection, message); + await this.writeInbound(message); return { ok: true }; } + private async writeInbound(message: AnyMessage): Promise { + const connection = this.connection; + + if (!connection) { + throw new Error("ACP WebSocket connection is not initialized"); + } + + const write = this.inboundWriteChain.then(() => + writeInbound(connection, message), + ); + this.inboundWriteChain = write.catch(() => undefined); + await write; + } + private startOutboundPump(connection: ConnectionState): void { const subscription = connection.allOutbound.subscribe(); const reader = subscription.stream.getReader(); @@ -345,81 +349,3 @@ function determineWebSocketRoute(message: AnyRequest): ResponseRoute { return "connection"; } - -function onSocket( - socket: WebSocketServerSocket, - type: string, - listener: (...args: unknown[]) => void, -): () => void { - if (socket.addEventListener) { - const eventListener = (event: unknown) => listener(event); - socket.addEventListener(type, eventListener); - - return () => { - socket.removeEventListener?.(type, eventListener); - }; - } - - if (socket.on) { - socket.on(type, listener); - - return () => { - if (socket.off) { - socket.off(type, listener); - return; - } - - socket.removeListener?.(type, listener); - }; - } - - throw new Error("WebSocket object does not support event listeners"); -} - -function socketMessageToString(args: unknown[]): string | undefined { - const data = extractMessageData(args); - - if (typeof data === "string") { - return data; - } - - if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) { - return new TextDecoder().decode(data); - } - - if (Array.isArray(data) && data.every(ArrayBuffer.isView)) { - return decodeArrayBufferViews(data); - } - - return undefined; -} - -function extractMessageData(args: unknown[]): unknown { - const [first] = args; - - if (isMessageEventLike(first)) { - return first.data; - } - - return first; -} - -function isMessageEventLike(value: unknown): value is { data: unknown } { - return typeof value === "object" && value !== null && "data" in value; -} - -function decodeArrayBufferViews(views: ArrayBufferView[]): string { - const totalLength = views.reduce((sum, view) => sum + view.byteLength, 0); - const combined = new Uint8Array(totalLength); - let offset = 0; - - for (const view of views) { - combined.set( - new Uint8Array(view.buffer, view.byteOffset, view.byteLength), - offset, - ); - offset += view.byteLength; - } - - return new TextDecoder().decode(combined); -} diff --git a/src/ws-stream.test.ts b/src/ws-stream.test.ts new file mode 100644 index 00000000..44a6b7dc --- /dev/null +++ b/src/ws-stream.test.ts @@ -0,0 +1,411 @@ +import { describe, expect, it } from "vitest"; +import { WebSocket } from "ws"; + +import { ClientSideConnection, PROTOCOL_VERSION } from "./acp.js"; +import { createWebSocketStream } from "./ws-stream.js"; +import { TestAgent } from "./test-support/test-agent.js"; +import { startTestServer } from "./test-support/test-http-server.js"; + +import type { + AgentSideConnection, + Client, + RequestPermissionRequest, + RequestPermissionResponse, + SessionNotification, +} from "./acp.js"; +import type { AnyMessage } from "./jsonrpc.js"; +import type { Stream } from "./stream.js"; +import type { WebSocketConstructor } from "./ws-stream.js"; + +const nodeWebSocket = WebSocket as unknown as WebSocketConstructor; + +const initializeRequest = { + jsonrpc: "2.0", + id: 0, + method: "initialize", + params: { + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + }, +} satisfies AnyMessage; + +const initializeResponse = { + jsonrpc: "2.0", + id: 0, + result: { + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { + loadSession: false, + }, + }, +} satisfies AnyMessage; + +describe("createWebSocketStream", () => { + it("uses the custom WebSocket constructor and queues writes until the socket opens", async () => { + const instances: FakeWebSocket[] = []; + const stream = createWebSocketStream("ws://agent.example/acp", { + WebSocket: createFakeWebSocketConstructor(instances), + protocols: ["acp"], + headers: { Authorization: "Bearer token" }, + }); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + try { + const socket = fakeSocketAt(instances, 0); + expect(socket.url).toBe("ws://agent.example/acp"); + expect(socket.protocols).toEqual(["acp"]); + expect(socket.options).toEqual({ + headers: { Authorization: "Bearer token" }, + }); + + const write = writer.write(initializeRequest); + await Promise.resolve(); + expect(socket.sent).toEqual([]); + + socket.open(); + await write; + expect(socket.sent).toEqual([JSON.stringify(initializeRequest)]); + + socket.receive(JSON.stringify(initializeResponse)); + expect(await readMessage(reader)).toEqual(initializeResponse); + } finally { + reader.releaseLock(); + await writer.close().catch(() => undefined); + writer.releaseLock(); + } + }); + + it("ignores binary, malformed JSON, and non-JSON-RPC messages", async () => { + const instances: FakeWebSocket[] = []; + const stream = createWebSocketStream("ws://agent.example/acp", { + WebSocket: createFakeWebSocketConstructor(instances), + }); + const reader = stream.readable.getReader(); + + try { + const socket = fakeSocketAt(instances, 0); + socket.open(); + socket.receive(new Uint8Array([1, 2, 3]), true); + socket.receive("not json"); + socket.receive(JSON.stringify({ hello: "world" })); + socket.receive(JSON.stringify(initializeResponse)); + + expect(await readMessage(reader)).toEqual(initializeResponse); + } finally { + reader.releaseLock(); + await closeStream(stream); + } + }); + + it("closes the readable stream when the socket closes", async () => { + const instances: FakeWebSocket[] = []; + const stream = createWebSocketStream("ws://agent.example/acp", { + WebSocket: createFakeWebSocketConstructor(instances), + }); + const reader = stream.readable.getReader(); + + try { + const socket = fakeSocketAt(instances, 0); + socket.open(); + socket.close(); + + expect(await reader.read()).toEqual({ done: true, value: undefined }); + } finally { + reader.releaseLock(); + } + }); + + it("runs initialize, newSession, and prompt through ClientSideConnection", async () => { + const updates: SessionNotification[] = []; + const server = await startTestServer( + (conn: AgentSideConnection) => new TestAgent(conn, { chunkCount: 2 }), + ); + const stream = createWebSocketStream(server.wsUrl, { + WebSocket: nodeWebSocket, + }); + const conn = new ClientSideConnection( + () => createTestClient({ updates }), + stream, + ); + + try { + expect( + await conn.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + }), + ).toMatchObject({ + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { loadSession: false }, + }); + + const session = await conn.newSession({ cwd: "/tmp", mcpServers: [] }); + expect(session.sessionId).toMatch(/^[0-9a-f-]{36}$/); + + await expect( + conn.prompt({ + sessionId: session.sessionId, + prompt: [{ type: "text", text: "Hello" }], + }), + ).resolves.toEqual({ stopReason: "end_turn" }); + expect(updates).toHaveLength(2); + expect(updates).toMatchObject([ + { + sessionId: session.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { text: "chunk-1" }, + }, + }, + { + sessionId: session.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { text: "chunk-2" }, + }, + }, + ]); + } finally { + await closeStream(stream); + await server.close(); + } + }); + + it("round-trips permission requests through ClientSideConnection", async () => { + const updates: SessionNotification[] = []; + const permissionRequests: RequestPermissionRequest[] = []; + const server = await startTestServer( + (conn: AgentSideConnection) => + new TestAgent(conn, { enablePermission: true }), + ); + const stream = createWebSocketStream(server.wsUrl, { + WebSocket: nodeWebSocket, + }); + const conn = new ClientSideConnection( + () => createTestClient({ updates, permissionRequests }), + stream, + ); + + try { + await conn.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + }); + const session = await conn.newSession({ cwd: "/tmp", mcpServers: [] }); + + await expect( + conn.prompt({ + sessionId: session.sessionId, + prompt: [{ type: "text", text: "Hello" }], + }), + ).resolves.toEqual({ stopReason: "end_turn" }); + + expect(permissionRequests).toHaveLength(1); + expect(permissionRequests[0]).toMatchObject({ + sessionId: session.sessionId, + toolCall: { + toolCallId: "permission-tool", + title: "Permission tool", + }, + }); + expect(updates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sessionId: session.sessionId, + update: expect.objectContaining({ + sessionUpdate: "agent_message_chunk", + content: expect.objectContaining({ + text: "permission-selected-allow", + }), + }), + }), + ]), + ); + } finally { + await closeStream(stream); + await server.close(); + } + }); + + it("keeps multiple sessions isolated through the SDK client abstraction", async () => { + const updates: SessionNotification[] = []; + const server = await startTestServer(); + const stream = createWebSocketStream(server.wsUrl, { + WebSocket: nodeWebSocket, + }); + const conn = new ClientSideConnection( + () => createTestClient({ updates }), + stream, + ); + + try { + await conn.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + }); + const firstSession = await conn.newSession({ + cwd: "/tmp", + mcpServers: [], + }); + const secondSession = await conn.newSession({ + cwd: "/tmp/other", + mcpServers: [], + }); + + await Promise.all([ + conn.prompt({ + sessionId: firstSession.sessionId, + prompt: [{ type: "text", text: "First" }], + }), + conn.prompt({ + sessionId: secondSession.sessionId, + prompt: [{ type: "text", text: "Second" }], + }), + ]); + + expect(updates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ sessionId: firstSession.sessionId }), + expect.objectContaining({ sessionId: secondSession.sessionId }), + ]), + ); + expect( + updates.filter((update) => update.sessionId === firstSession.sessionId), + ).toHaveLength(1); + expect( + updates.filter( + (update) => update.sessionId === secondSession.sessionId, + ), + ).toHaveLength(1); + } finally { + await closeStream(stream); + await server.close(); + } + }); +}); + +interface TestClientState { + readonly updates: SessionNotification[]; + readonly permissionRequests?: RequestPermissionRequest[]; +} + +function createTestClient(state: TestClientState): Client { + return { + requestPermission: (params): Promise => { + state.permissionRequests?.push(params); + return Promise.resolve({ + outcome: { + outcome: "selected", + optionId: "allow", + }, + }); + }, + sessionUpdate: (params): Promise => { + state.updates.push(params); + return Promise.resolve(); + }, + }; +} + +async function closeStream(stream: Stream): Promise { + await stream.writable.close().catch(() => undefined); +} + +async function readMessage( + reader: ReadableStreamDefaultReader, +): Promise { + const result = await reader.read(); + if (result.done) { + throw new Error("Expected a message"); + } + + return result.value; +} + +function createFakeWebSocketConstructor( + instances: FakeWebSocket[], +): WebSocketConstructor { + return class extends FakeWebSocket { + constructor( + url: string, + protocols?: string | string[], + options?: { headers?: Record }, + ) { + super(url, protocols, options); + instances.push(this); + } + }; +} + +function fakeSocketAt( + instances: readonly FakeWebSocket[], + index: number, +): FakeWebSocket { + const socket = instances[index]; + + if (!socket) { + throw new Error(`Expected fake WebSocket at index ${index}`); + } + + return socket; +} + +class FakeWebSocket { + readonly sent: string[] = []; + readonly listeners = new Map void>>(); + readyState = 0; + + constructor( + readonly url: string, + readonly protocols?: string | string[], + readonly options?: { headers?: Record }, + ) {} + + send(data: string): void { + if (this.readyState !== 1) { + throw new Error("Fake WebSocket is not open"); + } + + this.sent.push(data); + } + + close(): void { + if (this.readyState === 3) { + return; + } + + this.readyState = 3; + this.emit("close", {}); + } + + addEventListener(type: string, listener: (event: unknown) => void): void { + let listeners = this.listeners.get(type); + + if (!listeners) { + listeners = new Set(); + this.listeners.set(type, listeners); + } + + listeners.add(listener); + } + + removeEventListener(type: string, listener: (event: unknown) => void): void { + this.listeners.get(type)?.delete(listener); + } + + open(): void { + this.readyState = 1; + this.emit("open", {}); + } + + receive(data: unknown, isBinary = false): void { + this.emit("message", { data, isBinary }); + } + + private emit(type: string, event: unknown): void { + for (const listener of this.listeners.get(type) ?? []) { + listener(event); + } + } +} diff --git a/src/ws-stream.ts b/src/ws-stream.ts new file mode 100644 index 00000000..9d650124 --- /dev/null +++ b/src/ws-stream.ts @@ -0,0 +1,239 @@ +import { isJsonRpcMessage } from "./jsonrpc.js"; +import { onWebSocket, webSocketMessageToString } from "./ws-utils.js"; +import type { WebSocketLike } from "./ws-utils.js"; +import type { AnyMessage } from "./jsonrpc.js"; +import type { Stream } from "./stream.js"; + +export interface WebSocketStreamOptions { + /** WebSocket subprotocols. */ + readonly protocols?: string[]; + /** + * Custom headers for runtimes/constructors that support them, such as Node.js + * `ws`. Browsers ignore custom headers because the browser WebSocket API does + * not expose a headers option. + */ + readonly headers?: Record; + /** Custom WebSocket constructor, for example `ws.WebSocket` in Node.js. */ + readonly WebSocket?: WebSocketConstructor; +} + +export interface WebSocketConstructor { + new ( + url: string, + protocols?: string | string[], + options?: { headers?: Record }, + ): WebSocketLike; +} + +const SOCKET_OPEN = 1; + +/** + * Creates an ACP Stream that speaks JSON-RPC over WebSocket text frames. + * + * Browser WebSocket constructors do not support custom headers. The `headers` + * option is passed as a best-effort third constructor argument for runtimes such + * as Node.js `ws` that accept it. + */ +export function createWebSocketStream( + serverUrl: string, + options: WebSocketStreamOptions = {}, +): Stream { + return new WebSocketStreamTransport(serverUrl, options).stream; +} + +class WebSocketStreamTransport { + readonly stream: Stream; + + private readonly socket: WebSocketLike; + private readableController: + | ReadableStreamDefaultController + | undefined; + private isClosed = false; + private openPromise: Promise | undefined; + private resolveOpen: (() => void) | undefined; + private rejectOpen: ((error: unknown) => void) | undefined; + private readonly detachListeners: Array<() => void> = []; + + constructor(serverUrl: string, options: WebSocketStreamOptions) { + const WebSocketCtor = resolveWebSocket(options.WebSocket); + this.socket = new WebSocketCtor(serverUrl, options.protocols, { + headers: options.headers, + }); + + this.openPromise = new Promise((resolve, reject) => { + this.resolveOpen = resolve; + this.rejectOpen = reject; + }); + + this.detachListeners.push( + onWebSocket(this.socket, "open", () => { + this.resolveOpen?.(); + this.resolveOpen = undefined; + this.rejectOpen = undefined; + this.openPromise = undefined; + }), + ); + + this.detachListeners.push( + onWebSocket(this.socket, "message", (...args) => { + this.handleSocketMessage(args); + }), + ); + + this.detachListeners.push( + onWebSocket(this.socket, "close", () => { + this.closeReadable(); + }), + ); + + this.detachListeners.push( + onWebSocket(this.socket, "error", (error) => { + this.errorReadable(error); + }), + ); + + this.stream = { + readable: new ReadableStream({ + start: (controller) => { + this.readableController = controller; + }, + cancel: () => { + this.close(); + }, + }), + writable: new WritableStream({ + write: async (message) => { + await this.sendMessage(message); + }, + close: () => { + this.close(); + }, + abort: () => { + this.close(); + }, + }), + }; + } + + private async sendMessage(message: AnyMessage): Promise { + if (this.isClosed) { + throw new Error("ACP WebSocket stream is closed"); + } + + await this.waitForOpen(); + + if (this.isClosed) { + throw new Error("ACP WebSocket stream is closed"); + } + + this.socket.send(JSON.stringify(message)); + } + + private async waitForOpen(): Promise { + if ( + this.socket.readyState === undefined || + this.socket.readyState === SOCKET_OPEN + ) { + return; + } + + await this.openPromise; + } + + private handleSocketMessage(args: unknown[]): void { + if (this.isClosed) { + return; + } + + const text = webSocketMessageToString(args); + if (text === undefined) { + return; + } + + let value: unknown; + try { + value = JSON.parse(text); + } catch (error) { + console.warn("Ignoring malformed ACP WebSocket JSON message:", error); + return; + } + + if (!isJsonRpcMessage(value)) { + console.warn("Ignoring non-JSON-RPC ACP WebSocket message:", value); + return; + } + + this.readableController?.enqueue(value); + } + + private close(): void { + this.closeSocket(); + this.closeReadable(); + } + + private closeSocket(): void { + try { + this.socket.close(); + } catch (error) { + console.warn("Failed to close ACP WebSocket:", error); + } + } + + private closeReadable(): void { + if (this.isClosed) { + return; + } + + this.isClosed = true; + + for (const detach of this.detachListeners.splice(0)) { + detach(); + } + + this.rejectOpen?.(new Error("ACP WebSocket stream closed before open")); + this.rejectOpen = undefined; + this.resolveOpen = undefined; + this.openPromise = undefined; + + try { + this.readableController?.close(); + } catch { + // Stream may already be closed/cancelled. + } + } + + private errorReadable(error: unknown): void { + if (this.isClosed) { + return; + } + + this.isClosed = true; + + for (const detach of this.detachListeners.splice(0)) { + detach(); + } + + this.rejectOpen?.(error); + this.rejectOpen = undefined; + this.resolveOpen = undefined; + this.openPromise = undefined; + + this.readableController?.error(error); + } +} + +function resolveWebSocket( + WebSocketCtor: WebSocketConstructor | undefined, +): WebSocketConstructor { + if (WebSocketCtor) { + return WebSocketCtor; + } + + if (typeof globalThis.WebSocket === "function") { + return globalThis.WebSocket as unknown as WebSocketConstructor; + } + + throw new Error( + "createWebSocketStream requires globalThis.WebSocket or options.WebSocket", + ); +} diff --git a/src/ws-utils.ts b/src/ws-utils.ts new file mode 100644 index 00000000..31256a6a --- /dev/null +++ b/src/ws-utils.ts @@ -0,0 +1,108 @@ +export interface WebSocketLike { + readonly readyState?: number; + send(data: string): void; + close(code?: number, reason?: string): void; + addEventListener?(type: string, listener: (event: unknown) => void): void; + removeEventListener?(type: string, listener: (event: unknown) => void): void; + on?(type: string, listener: (...args: unknown[]) => void): unknown; + off?(type: string, listener: (...args: unknown[]) => void): unknown; + removeListener?( + type: string, + listener: (...args: unknown[]) => void, + ): unknown; +} + +export function onWebSocket( + socket: WebSocketLike, + type: string, + listener: (...args: unknown[]) => void, +): () => void { + if (socket.addEventListener) { + const eventListener = (event: unknown): void => listener(event); + socket.addEventListener(type, eventListener); + + return () => { + socket.removeEventListener?.(type, eventListener); + }; + } + + if (socket.on) { + socket.on(type, listener); + + return () => { + if (socket.off) { + socket.off(type, listener); + return; + } + + socket.removeListener?.(type, listener); + }; + } + + throw new Error("WebSocket object does not support event listeners"); +} + +export function webSocketMessageToString(args: unknown[]): string | undefined { + if (args[1] === true || isBinaryMessageEvent(args[0])) { + return undefined; + } + + const data = extractMessageData(args); + + if (typeof data === "string") { + return data; + } + + if (data instanceof ArrayBuffer) { + return new TextDecoder().decode(data); + } + + if (ArrayBuffer.isView(data)) { + return new TextDecoder().decode(data); + } + + if (Array.isArray(data) && data.every(ArrayBuffer.isView)) { + return decodeArrayBufferViews(data); + } + + return undefined; +} + +function extractMessageData(args: unknown[]): unknown { + const [first] = args; + + if (isMessageEventLike(first)) { + return first.data; + } + + return first; +} + +function isMessageEventLike(value: unknown): value is { data: unknown } { + return typeof value === "object" && value !== null && "data" in value; +} + +function isBinaryMessageEvent(value: unknown): boolean { + return ( + typeof value === "object" && + value !== null && + "isBinary" in value && + value.isBinary === true + ); +} + +function decodeArrayBufferViews(views: ArrayBufferView[]): string { + const totalLength = views.reduce((sum, view) => sum + view.byteLength, 0); + const combined = new Uint8Array(totalLength); + let offset = 0; + + for (const view of views) { + combined.set( + new Uint8Array(view.buffer, view.byteOffset, view.byteLength), + offset, + ); + offset += view.byteLength; + } + + return new TextDecoder().decode(combined); +} From e1add64ada41a21473ba930f5b15253f3ee9bf60 Mon Sep 17 00:00:00 2001 From: Federico Ciner Date: Tue, 19 May 2026 10:32:57 +1000 Subject: [PATCH 09/20] Add docstrings --- src/http-stream.ts | 8 +++++--- src/server.ts | 12 ++++++++++++ src/ws-server.ts | 1 + src/ws-stream.ts | 19 ++++++++++--------- src/ws-utils.ts | 1 + 5 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/http-stream.ts b/src/http-stream.ts index f473735e..ab432c63 100644 --- a/src/http-stream.ts +++ b/src/http-stream.ts @@ -14,15 +14,17 @@ import type { AnyMessage } from "./jsonrpc.js"; import type { Stream } from "./stream.js"; export interface HttpStreamOptions { + /** Fetch implementation to use. Defaults to `globalThis.fetch`. */ readonly fetch?: typeof globalThis.fetch; + /** Headers to include on every HTTP/SSE request. */ readonly headers?: Record; } /** - * Creates an ACP Stream that speaks the Streamable HTTP transport. + * Creates an ACP Stream over Streamable HTTP. * - * The transport uses HTTP POST for client-to-agent messages and SSE GET streams for agent-to-client messages. - * Cookie management is intentionally not built in; pass a cookie-aware fetch implementation when needed. + * Uses POST for client messages and SSE GET streams for server messages. + * Pass a custom `fetch` for cookies, auth, proxies, or non-browser runtimes. */ export function createHttpStream( serverUrl: string, diff --git a/src/server.ts b/src/server.ts index 85353fe2..daf36025 100644 --- a/src/server.ts +++ b/src/server.ts @@ -26,10 +26,19 @@ import type { import type { Agent, AgentSideConnection } from "./acp.js"; import type { AnyMessage, AnyRequest, AnyResponse } from "./jsonrpc.js"; +/** Options for creating an ACP server transport. */ export interface AcpServerOptions { + /** Creates the agent implementation for each accepted ACP connection. */ createAgent: (conn: AgentSideConnection) => Agent; } +/** + * ACP server transport for Streamable HTTP and WebSocket connections. + * + * Route HTTP requests to {@link handleRequest}. For WebSocket upgrades, let your + * framework perform the upgrade and pass the accepted socket to + * {@link handleWebSocket}. + */ export class AcpServer { private readonly createAgent: (conn: AgentSideConnection) => Agent; private readonly registry = new ConnectionRegistry(); @@ -38,6 +47,7 @@ export class AcpServer { this.createAgent = options.createAgent; } + /** Handles one Streamable HTTP ACP request. */ async handleRequest(req: Request): Promise { if (req.method === "POST") { return await this.handlePost(req); @@ -54,6 +64,7 @@ export class AcpServer { return textResponse("Method Not Allowed", 405); } + /** Handles one accepted ACP WebSocket connection. */ handleWebSocket(socket: WebSocketServerSocket): void { handleWebSocketConnection(socket, { registry: this.registry, @@ -61,6 +72,7 @@ export class AcpServer { }); } + /** Closes all active ACP connections owned by this server. */ async close(): Promise { this.registry.closeAll(); } diff --git a/src/ws-server.ts b/src/ws-server.ts index fe7cc920..1490f282 100644 --- a/src/ws-server.ts +++ b/src/ws-server.ts @@ -18,6 +18,7 @@ import type { import type { AnyMessage, AnyRequest } from "./jsonrpc.js"; import type { WebSocketLike } from "./ws-utils.js"; +/** WebSocket shape accepted by `AcpServer.handleWebSocket`. */ export type WebSocketServerSocket = WebSocketLike; type ForwardResult = diff --git a/src/ws-stream.ts b/src/ws-stream.ts index 9d650124..0ebea76f 100644 --- a/src/ws-stream.ts +++ b/src/ws-stream.ts @@ -5,18 +5,18 @@ import type { AnyMessage } from "./jsonrpc.js"; import type { Stream } from "./stream.js"; export interface WebSocketStreamOptions { - /** WebSocket subprotocols. */ + /** WebSocket subprotocols to request. */ readonly protocols?: string[]; /** - * Custom headers for runtimes/constructors that support them, such as Node.js - * `ws`. Browsers ignore custom headers because the browser WebSocket API does - * not expose a headers option. + * Headers for WebSocket constructors that support them, such as Node `ws`. + * Browser WebSocket constructors ignore custom headers. */ readonly headers?: Record; - /** Custom WebSocket constructor, for example `ws.WebSocket` in Node.js. */ + /** WebSocket constructor to use. Defaults to `globalThis.WebSocket`. */ readonly WebSocket?: WebSocketConstructor; } +/** Constructor shape used by `createWebSocketStream`. */ export interface WebSocketConstructor { new ( url: string, @@ -25,14 +25,15 @@ export interface WebSocketConstructor { ): WebSocketLike; } +export type { WebSocketLike }; + const SOCKET_OPEN = 1; /** - * Creates an ACP Stream that speaks JSON-RPC over WebSocket text frames. + * Creates an ACP Stream over WebSocket. * - * Browser WebSocket constructors do not support custom headers. The `headers` - * option is passed as a best-effort third constructor argument for runtimes such - * as Node.js `ws` that accept it. + * Sends and receives ACP JSON-RPC messages as WebSocket text frames. In Node, + * pass a WebSocket constructor such as `ws.WebSocket` via `options.WebSocket`. */ export function createWebSocketStream( serverUrl: string, diff --git a/src/ws-utils.ts b/src/ws-utils.ts index 31256a6a..947586ee 100644 --- a/src/ws-utils.ts +++ b/src/ws-utils.ts @@ -1,3 +1,4 @@ +/** Minimal browser/Node-compatible WebSocket shape used by ACP transports. */ export interface WebSocketLike { readonly readyState?: number; send(data: string): void; From 42715172cd91bc383b9fc1ba0870bfeeffdd1772 Mon Sep 17 00:00:00 2001 From: Federico Ciner Date: Tue, 19 May 2026 19:20:21 +1000 Subject: [PATCH 10/20] Add examples and update imports --- package.json | 11 ++++ src/examples/README.md | 25 ++++++++ src/examples/http-client.ts | 69 ++++++++++++++++++++++ src/examples/http-server.ts | 115 ++++++++++++++++++++++++++++++++++++ src/examples/ws-client.ts | 72 ++++++++++++++++++++++ 5 files changed, 292 insertions(+) create mode 100644 src/examples/http-client.ts create mode 100644 src/examples/http-server.ts create mode 100644 src/examples/ws-client.ts diff --git a/package.json b/package.json index 940b126b..f66ef301 100644 --- a/package.json +++ b/package.json @@ -27,22 +27,27 @@ "exports": { ".": { "types": "./dist/acp.d.ts", + "import": "./dist/acp.js", "default": "./dist/acp.js" }, "./http-client": { "types": "./dist/http-stream.d.ts", + "import": "./dist/http-stream.js", "default": "./dist/http-stream.js" }, "./ws-client": { "types": "./dist/ws-stream.d.ts", + "import": "./dist/ws-stream.js", "default": "./dist/ws-stream.js" }, "./server": { "types": "./dist/server.d.ts", + "import": "./dist/server.js", "default": "./dist/server.js" }, "./node": { "types": "./dist/node-adapter.d.ts", + "import": "./dist/node-adapter.js", "default": "./dist/node-adapter.js" }, "./schema/schema.json": "./schema/schema.json" @@ -67,8 +72,14 @@ "docs:ts:verify": "cd src && typedoc --emit none && echo 'TypeDoc verification passed'" }, "peerDependencies": { + "ws": ">=8.0.0", "zod": "^3.25.0 || ^4.0.0" }, + "peerDependenciesMeta": { + "ws": { + "optional": true + } + }, "devDependencies": { "@eslint/js": "^10.0.1", "@hey-api/openapi-ts": "^0.98.0", diff --git a/src/examples/README.md b/src/examples/README.md index 2fe4f8a1..d1c19a31 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -4,6 +4,9 @@ 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 +- [`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` ## Running the Agent @@ -75,3 +78,25 @@ npx tsx src/examples/client.ts ``` This client will spawn the example agent as a subprocess, send a message, and print the content it receives from it. + +## Running the HTTP and WebSocket Examples + +Start the Streamable HTTP server with WebSocket upgrade support: + +```bash +npx tsx src/examples/http-server.ts +``` + +In another terminal, run the HTTP client: + +```bash +npx tsx src/examples/http-client.ts +``` + +Or run the WebSocket client: + +```bash +npx tsx src/examples/ws-client.ts +``` + +The HTTP example sends a bearer token through custom request headers. The WebSocket example passes the Node `ws` constructor so custom headers can be sent during the WebSocket handshake. Browser WebSocket clients can use `createWebSocketStream` too, but browsers do not allow custom WebSocket headers. diff --git a/src/examples/http-client.ts b/src/examples/http-client.ts new file mode 100644 index 00000000..514a276a --- /dev/null +++ b/src/examples/http-client.ts @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +import * as acp from "@agentclientprotocol/sdk"; +import { createHttpStream } from "@agentclientprotocol/sdk/http-client"; + +class HttpExampleClient implements acp.Client { + async requestPermission( + params: acp.RequestPermissionRequest, + ): Promise { + return { + outcome: { + outcome: "selected", + optionId: params.options[0]?.optionId ?? "allow", + }, + }; + } + + async sessionUpdate(params: acp.SessionNotification): Promise { + const update = params.update; + + if (update.sessionUpdate === "agent_message_chunk") { + process.stdout.write( + update.content.type === "text" ? update.content.text : "", + ); + return; + } + + console.log(`[${update.sessionUpdate}]`); + } +} + +const serverUrl = process.env.ACP_HTTP_URL ?? "http://127.0.0.1:7331/acp"; +const stream = createHttpStream(serverUrl, { + headers: { + Authorization: "Bearer example-token", + }, + // To use cookies, pass a cookie-aware fetch implementation here instead of relying on a built-in cookie jar. + // fetch: cookieAwareFetch, +}); +const connection = new acp.ClientSideConnection( + (_agent) => new HttpExampleClient(), + stream, +); + +try { + await connection.initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {}, + }); + + const session = await connection.newSession({ + cwd: process.cwd(), + mcpServers: [], + }); + + const result = await connection.prompt({ + sessionId: session.sessionId, + prompt: [ + { + type: "text", + text: "Hello over Streamable HTTP", + }, + ], + }); + + console.log(`\nDone: ${result.stopReason}`); +} finally { + await stream.writable.close(); +} diff --git a/src/examples/http-server.ts b/src/examples/http-server.ts new file mode 100644 index 00000000..ad72c928 --- /dev/null +++ b/src/examples/http-server.ts @@ -0,0 +1,115 @@ +#!/usr/bin/env node + +import { createServer } from "node:http"; + +import { WebSocketServer } from "ws"; + +import * as acp from "@agentclientprotocol/sdk"; +import { createNodeHttpHandler } from "@agentclientprotocol/sdk/node"; +import { AcpServer } from "@agentclientprotocol/sdk/server"; + +class HttpExampleAgent implements acp.Agent { + private readonly connection: acp.AgentSideConnection; + private readonly sessions = new Set(); + + constructor(connection: acp.AgentSideConnection) { + this.connection = connection; + } + + async initialize( + _params: acp.InitializeRequest, + ): Promise { + return { + protocolVersion: acp.PROTOCOL_VERSION, + agentCapabilities: { + loadSession: false, + }, + }; + } + + async newSession( + _params: acp.NewSessionRequest, + ): Promise { + const sessionId = crypto.randomUUID(); + this.sessions.add(sessionId); + return { sessionId }; + } + + async authenticate( + _params: acp.AuthenticateRequest, + ): Promise { + return {}; + } + + async prompt(params: acp.PromptRequest): Promise { + if (!this.sessions.has(params.sessionId)) { + throw new Error(`Session ${params.sessionId} not found`); + } + + await this.connection.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "Hello from the ACP HTTP/WebSocket example server.", + }, + }, + }); + + return { stopReason: "end_turn" }; + } + + async cancel(_params: acp.CancelNotification): Promise {} +} + +const acpServer = new AcpServer({ + createAgent: (connection) => new HttpExampleAgent(connection), +}); +const acpHttpHandler = createNodeHttpHandler(acpServer); +const webSocketServer = new WebSocketServer({ noServer: true }); +const port = Number.parseInt(process.env.PORT ?? "7331", 10); + +const httpServer = createServer((req, res) => { + if (!isAcpPath(req.url)) { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found"); + return; + } + + // Put authentication or tenant-selection middleware here before routing to AcpServer. + // For example, validate `req.headers.authorization` and reject unauthorized requests. + if (!isAuthorized(req.headers.authorization)) { + res.writeHead(401, { "Content-Type": "text/plain" }); + res.end("Unauthorized"); + return; + } + + acpHttpHandler(req, res); +}); + +httpServer.on("upgrade", (req, socket, head) => { + if (!isAcpPath(req.url) || !isAuthorized(req.headers.authorization)) { + socket.destroy(); + return; + } + + webSocketServer.handleUpgrade(req, socket, head, (ws) => { + acpServer.handleWebSocket(ws); + }); +}); + +httpServer.listen(port, () => { + console.log(`ACP HTTP endpoint listening at http://127.0.0.1:${port}/acp`); + console.log(`ACP WebSocket endpoint listening at ws://127.0.0.1:${port}/acp`); +}); + +function isAcpPath(url: string | undefined): boolean { + return new URL(url ?? "/", "http://127.0.0.1").pathname === "/acp"; +} + +function isAuthorized(authorization: string | undefined): boolean { + return ( + authorization === undefined || authorization === "Bearer example-token" + ); +} diff --git a/src/examples/ws-client.ts b/src/examples/ws-client.ts new file mode 100644 index 00000000..ddddd0ea --- /dev/null +++ b/src/examples/ws-client.ts @@ -0,0 +1,72 @@ +#!/usr/bin/env node + +import { WebSocket } from "ws"; + +import * as acp from "@agentclientprotocol/sdk"; +import { createWebSocketStream } from "@agentclientprotocol/sdk/ws-client"; +import type { WebSocketConstructor } from "@agentclientprotocol/sdk/ws-client"; + +class WebSocketExampleClient implements acp.Client { + async requestPermission( + params: acp.RequestPermissionRequest, + ): Promise { + return { + outcome: { + outcome: "selected", + optionId: params.options[0]?.optionId ?? "allow", + }, + }; + } + + async sessionUpdate(params: acp.SessionNotification): Promise { + const update = params.update; + + if (update.sessionUpdate === "agent_message_chunk") { + process.stdout.write( + update.content.type === "text" ? update.content.text : "", + ); + return; + } + + console.log(`[${update.sessionUpdate}]`); + } +} + +const serverUrl = process.env.ACP_WS_URL ?? "ws://127.0.0.1:7331/acp"; +const stream = createWebSocketStream(serverUrl, { + WebSocket: WebSocket satisfies WebSocketConstructor, + // Custom headers work with Node's `ws` constructor. Browser WebSocket does not support custom headers. + headers: { + Authorization: "Bearer example-token", + }, +}); +const connection = new acp.ClientSideConnection( + (_agent) => new WebSocketExampleClient(), + stream, +); + +try { + await connection.initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {}, + }); + + const session = await connection.newSession({ + cwd: process.cwd(), + mcpServers: [], + }); + + const result = await connection.prompt({ + sessionId: session.sessionId, + prompt: [ + { + type: "text", + text: "Hello over WebSocket", + }, + ], + }); + + console.log(`\nDone: ${result.stopReason}`); +} finally { + await stream.writable.close(); +} From 96c42203eaf25e8c63c1edd4a4fffdb0521b6be5 Mon Sep 17 00:00:00 2001 From: Federico Ciner Date: Tue, 19 May 2026 20:04:46 +1000 Subject: [PATCH 11/20] Add RFD-compliant websocket upgrade handling --- src/examples/http-server.ts | 13 +++-- src/node-adapter.ts | 52 ++++++++++++++++++++ src/server.ts | 46 ++++++++++++++---- src/test-support/test-http-server.ts | 14 +++--- src/ws-server.ts | 33 +++++++++---- src/ws-stream.test.ts | 71 ++++++++++++++++++++++++++++ 6 files changed, 200 insertions(+), 29 deletions(-) diff --git a/src/examples/http-server.ts b/src/examples/http-server.ts index ad72c928..6fb85b03 100644 --- a/src/examples/http-server.ts +++ b/src/examples/http-server.ts @@ -5,7 +5,10 @@ import { createServer } from "node:http"; import { WebSocketServer } from "ws"; import * as acp from "@agentclientprotocol/sdk"; -import { createNodeHttpHandler } from "@agentclientprotocol/sdk/node"; +import { + createNodeHttpHandler, + createNodeWebSocketUpgradeHandler, +} from "@agentclientprotocol/sdk/node"; import { AcpServer } from "@agentclientprotocol/sdk/server"; class HttpExampleAgent implements acp.Agent { @@ -68,6 +71,10 @@ const acpServer = new AcpServer({ }); const acpHttpHandler = createNodeHttpHandler(acpServer); const webSocketServer = new WebSocketServer({ noServer: true }); +const acpWebSocketUpgradeHandler = createNodeWebSocketUpgradeHandler( + acpServer, + webSocketServer, +); const port = Number.parseInt(process.env.PORT ?? "7331", 10); const httpServer = createServer((req, res) => { @@ -94,9 +101,7 @@ httpServer.on("upgrade", (req, socket, head) => { return; } - webSocketServer.handleUpgrade(req, socket, head, (ws) => { - acpServer.handleWebSocket(ws); - }); + acpWebSocketUpgradeHandler(req, socket, head); }); httpServer.listen(port, () => { diff --git a/src/node-adapter.ts b/src/node-adapter.ts index a92d7c03..f4a68010 100644 --- a/src/node-adapter.ts +++ b/src/node-adapter.ts @@ -1,5 +1,8 @@ +import { HEADER_CONNECTION_ID } from "./protocol.js"; import type { IncomingMessage, ServerResponse } from "node:http"; +import type { Duplex } from "node:stream"; import type { AcpServer } from "./server.js"; +import type { WebSocketServer } from "ws"; export function createNodeHttpHandler( server: AcpServer, @@ -9,6 +12,55 @@ export function createNodeHttpHandler( }; } +export function createNodeWebSocketUpgradeHandler( + server: AcpServer, + webSocketServer: WebSocketServer, +): (req: IncomingMessage, socket: Duplex, head: Buffer) => void { + return (req, socket, head) => { + const upgrade = server.prepareWebSocketUpgrade(); + let hasAccepted = false; + + const cleanup = (): void => { + webSocketServer.off("headers", onHeaders); + socket.off("close", onUpgradeFailed); + socket.off("error", onUpgradeFailed); + }; + + const onHeaders = (headers: string[], request: IncomingMessage): void => { + if (request !== req) { + return; + } + + headers.push(`${HEADER_CONNECTION_ID}: ${upgrade.connectionId}`); + }; + + const onUpgradeFailed = (): void => { + if (hasAccepted) { + return; + } + + cleanup(); + upgrade.reject(); + }; + + webSocketServer.on("headers", onHeaders); + socket.once("close", onUpgradeFailed); + socket.once("error", onUpgradeFailed); + + try { + webSocketServer.handleUpgrade(req, socket, head, (webSocket) => { + hasAccepted = true; + cleanup(); + upgrade.accept(webSocket); + }); + } catch (error) { + cleanup(); + upgrade.reject(); + throw error; + } + }; +} + async function handleNodeRequest( server: AcpServer, req: IncomingMessage, diff --git a/src/server.ts b/src/server.ts index daf36025..cd327a5d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -32,12 +32,18 @@ export interface AcpServerOptions { createAgent: (conn: AgentSideConnection) => Agent; } +export interface PreparedWebSocketUpgrade { + readonly connectionId: string; + accept(socket: WebSocketServerSocket): void; + reject(): void; +} + /** * ACP server transport for Streamable HTTP and WebSocket connections. * - * Route HTTP requests to {@link handleRequest}. For WebSocket upgrades, let your - * framework perform the upgrade and pass the accepted socket to - * {@link handleWebSocket}. + * Route HTTP requests to {@link handleRequest}. For WebSocket upgrades, use + * {@link prepareWebSocketUpgrade} so adapters can attach `Acp-Connection-Id` to + * the `101 Switching Protocols` response. */ export class AcpServer { private readonly createAgent: (conn: AgentSideConnection) => Agent; @@ -64,12 +70,34 @@ export class AcpServer { return textResponse("Method Not Allowed", 405); } - /** Handles one accepted ACP WebSocket connection. */ - handleWebSocket(socket: WebSocketServerSocket): void { - handleWebSocketConnection(socket, { - registry: this.registry, - createAgent: this.createAgent, - }); + /** Creates a WebSocket connection before accepting the HTTP upgrade. */ + prepareWebSocketUpgrade(): PreparedWebSocketUpgrade { + const connection = this.registry.createConnection(this.createAgent); + let isSettled = false; + + return { + connectionId: connection.connectionId, + accept: (socket) => { + if (isSettled) { + throw new Error("ACP WebSocket upgrade has already been settled"); + } + + isSettled = true; + handleWebSocketConnection(socket, { + registry: this.registry, + createAgent: this.createAgent, + connection, + }); + }, + reject: () => { + if (isSettled) { + return; + } + + isSettled = true; + this.registry.remove(connection.connectionId); + }, + }; } /** Closes all active ACP connections owned by this server. */ diff --git a/src/test-support/test-http-server.ts b/src/test-support/test-http-server.ts index 96b746b8..ccb3e42f 100644 --- a/src/test-support/test-http-server.ts +++ b/src/test-support/test-http-server.ts @@ -2,7 +2,10 @@ import http from "node:http"; import { WebSocketServer } from "ws"; import { AcpServer } from "../server.js"; -import { createNodeHttpHandler } from "../node-adapter.js"; +import { + createNodeHttpHandler, + createNodeWebSocketUpgradeHandler, +} from "../node-adapter.js"; import { TestAgent } from "./test-agent.js"; import type { AddressInfo } from "node:net"; @@ -23,11 +26,10 @@ export async function startTestServer( const httpServer = http.createServer(createNodeHttpHandler(acpServer)); const webSocketServer = new WebSocketServer({ noServer: true }); - httpServer.on("upgrade", (req, socket, head) => { - webSocketServer.handleUpgrade(req, socket, head, (webSocket) => { - acpServer.handleWebSocket(webSocket); - }); - }); + httpServer.on( + "upgrade", + createNodeWebSocketUpgradeHandler(acpServer, webSocketServer), + ); await listen(httpServer, options.port ?? 0); diff --git a/src/ws-server.ts b/src/ws-server.ts index 1490f282..1cc0a307 100644 --- a/src/ws-server.ts +++ b/src/ws-server.ts @@ -18,7 +18,7 @@ import type { import type { AnyMessage, AnyRequest } from "./jsonrpc.js"; import type { WebSocketLike } from "./ws-utils.js"; -/** WebSocket shape accepted by `AcpServer.handleWebSocket`. */ +/** WebSocket shape accepted by prepared ACP WebSocket upgrades. */ export type WebSocketServerSocket = WebSocketLike; type ForwardResult = @@ -33,6 +33,7 @@ type ForwardResult = export interface WebSocketConnectionOptions { readonly registry: ConnectionRegistry; readonly createAgent: (conn: AgentSideConnection) => Agent; + readonly connection?: ConnectionState; } export function handleWebSocketConnection( @@ -45,6 +46,7 @@ export function handleWebSocketConnection( class WebSocketServerSession { private connection: ConnectionState | undefined; + private preparedConnection: ConnectionState | undefined; private outboundReader: ReadableStreamDefaultReader | undefined; private inboundWriteChain: Promise = Promise.resolve(); private isClosed = false; @@ -53,7 +55,9 @@ class WebSocketServerSession { constructor( private readonly socket: WebSocketLike, private readonly options: WebSocketConnectionOptions, - ) {} + ) { + this.preparedConnection = options.connection; + } start(): void { this.detachListeners.push( @@ -135,26 +139,30 @@ class WebSocketServerSession { return; } - let connection: ConnectionState | undefined; + const connection = + this.preparedConnection ?? + this.options.registry.createConnection(this.options.createAgent); + this.preparedConnection = connection; try { - connection = this.options.registry.createConnection( - this.options.createAgent, - ); - await writeInbound(connection, message); const initialResponse = await connection.recvInitial(message.id); + if (this.isClosed) { + this.options.registry.remove(connection.connectionId); + return; + } + + this.preparedConnection = undefined; this.connection = connection; connection.startRouter(); this.send(initialResponse); this.startOutboundPump(connection); } catch (error) { - if (connection) { - this.options.registry.remove(connection.connectionId); - } + this.preparedConnection = undefined; + this.options.registry.remove(connection.connectionId); this.send({ jsonrpc: "2.0", @@ -323,6 +331,11 @@ class WebSocketServerSession { this.options.registry.remove(this.connection.connectionId); this.connection = undefined; } + + if (this.preparedConnection) { + this.options.registry.remove(this.preparedConnection.connectionId); + this.preparedConnection = undefined; + } } } diff --git a/src/ws-stream.test.ts b/src/ws-stream.test.ts index 44a6b7dc..01acbd65 100644 --- a/src/ws-stream.test.ts +++ b/src/ws-stream.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from "vitest"; import { WebSocket } from "ws"; import { ClientSideConnection, PROTOCOL_VERSION } from "./acp.js"; +import { HEADER_CONNECTION_ID } from "./protocol.js"; import { createWebSocketStream } from "./ws-stream.js"; import { TestAgent } from "./test-support/test-agent.js"; import { startTestServer } from "./test-support/test-http-server.js"; +import type { IncomingMessage } from "node:http"; import type { AgentSideConnection, Client, @@ -40,7 +42,76 @@ const initializeResponse = { }, } satisfies AnyMessage; +const sessionNewRequest = { + jsonrpc: "2.0", + id: 1, + method: "session/new", + params: { + cwd: "/tmp", + mcpServers: [], + }, +} satisfies AnyMessage; + describe("createWebSocketStream", () => { + it("exposes the ACP connection ID during the WebSocket handshake", async () => { + const server = await startTestServer(); + const socket = new WebSocket(server.wsUrl); + const upgrade = new Promise((resolve, reject) => { + socket.once("upgrade", resolve); + socket.once("error", reject); + }); + + try { + const request = await upgrade; + expect(request.headers[HEADER_CONNECTION_ID.toLowerCase()]).toMatch( + /^[0-9a-f-]{36}$/, + ); + } finally { + socket.close(); + await server.close(); + } + }); + + it("closes pre-created WebSocket connections when the first frame is not initialize", async () => { + const server = await startTestServer(); + const socket = new WebSocket(server.wsUrl); + const upgrade = new Promise((resolve, reject) => { + socket.once("upgrade", resolve); + socket.once("error", reject); + }); + const close = new Promise<{ code: number; reason: string }>((resolve) => { + socket.once("close", (code: number, reason: Buffer) => { + resolve({ code, reason: reason.toString("utf8") }); + }); + }); + + try { + const request = await upgrade; + const connectionId = request.headers[HEADER_CONNECTION_ID.toLowerCase()]; + expect(connectionId).toMatch(/^[0-9a-f-]{36}$/); + + socket.send(JSON.stringify(sessionNewRequest)); + + await expect(close).resolves.toEqual({ + code: 1002, + reason: "First message must be initialize", + }); + + const response = await fetch(server.url, { + method: "GET", + headers: { + Accept: "text/event-stream", + [HEADER_CONNECTION_ID]: String(connectionId), + }, + }); + + expect(response.status).toBe(404); + } finally { + socket.close(); + await server.close(); + } + }); + it("uses the custom WebSocket constructor and queues writes until the socket opens", async () => { const instances: FakeWebSocket[] = []; const stream = createWebSocketStream("ws://agent.example/acp", { From 445d56d5d37d981c9256cffb8de55c9412dccd16 Mon Sep 17 00:00:00 2001 From: Federico Ciner Date: Tue, 19 May 2026 20:47:51 +1000 Subject: [PATCH 12/20] Only accept text frames or Node-style buffers with isBinary --- src/ws-stream.test.ts | 49 +++++++++++++++++++++++++++++++ src/ws-utils.test.ts | 67 +++++++++++++++++++++++++++++++++++++++++++ src/ws-utils.ts | 61 +++++++++++++++++++++++++-------------- 3 files changed, 155 insertions(+), 22 deletions(-) create mode 100644 src/ws-utils.test.ts diff --git a/src/ws-stream.test.ts b/src/ws-stream.test.ts index 01acbd65..cf71e265 100644 --- a/src/ws-stream.test.ts +++ b/src/ws-stream.test.ts @@ -112,6 +112,49 @@ describe("createWebSocketStream", () => { } }); + it("ignores binary WebSocket initialize frames", async () => { + const server = await startTestServer(); + const socket = new WebSocket(server.wsUrl); + const upgrade = new Promise((resolve, reject) => { + socket.once("upgrade", resolve); + socket.once("error", reject); + }); + const close = new Promise<{ code: number; reason: string }>((resolve) => { + socket.once("close", (code: number, reason: Buffer) => { + resolve({ code, reason: reason.toString("utf8") }); + }); + }); + + try { + const request = await upgrade; + const connectionId = request.headers[HEADER_CONNECTION_ID.toLowerCase()]; + expect(connectionId).toMatch(/^[0-9a-f-]{36}$/); + + socket.send(Buffer.from(JSON.stringify(initializeRequest)), { + binary: true, + }); + socket.send(JSON.stringify(sessionNewRequest)); + + await expect(close).resolves.toEqual({ + code: 1002, + reason: "First message must be initialize", + }); + + const response = await fetch(server.url, { + method: "GET", + headers: { + Accept: "text/event-stream", + [HEADER_CONNECTION_ID]: String(connectionId), + }, + }); + + expect(response.status).toBe(404); + } finally { + socket.close(); + await server.close(); + } + }); + it("uses the custom WebSocket constructor and queues writes until the socket opens", async () => { const instances: FakeWebSocket[] = []; const stream = createWebSocketStream("ws://agent.example/acp", { @@ -158,6 +201,12 @@ describe("createWebSocketStream", () => { const socket = fakeSocketAt(instances, 0); socket.open(); socket.receive(new Uint8Array([1, 2, 3]), true); + socket.receive( + new TextEncoder().encode(JSON.stringify(initializeResponse)), + ); + socket.receive( + new TextEncoder().encode(JSON.stringify(initializeResponse)).buffer, + ); socket.receive("not json"); socket.receive(JSON.stringify({ hello: "world" })); socket.receive(JSON.stringify(initializeResponse)); diff --git a/src/ws-utils.test.ts b/src/ws-utils.test.ts new file mode 100644 index 00000000..af40e7f5 --- /dev/null +++ b/src/ws-utils.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { onWebSocket, webSocketMessageToString } from "./ws-utils.js"; + +describe("webSocketMessageToString", () => { + it("accepts only WebSocket text message payloads", () => { + expect(webSocketMessageToString(["text"])).toBe("text"); + expect(webSocketMessageToString([{ data: "event text" }])).toBe( + "event text", + ); + expect( + webSocketMessageToString([new TextEncoder().encode("binary text")]), + ).toBe(undefined); + expect( + webSocketMessageToString([ + new TextEncoder().encode("binary text").buffer, + ]), + ).toBe(undefined); + expect( + webSocketMessageToString([[new TextEncoder().encode("binary text")]]), + ).toBe(undefined); + }); +}); + +describe("onWebSocket", () => { + it("normalizes Node ws text frames before shared message parsing", () => { + const socket = new EventEmitterWebSocket(); + const messages: Array = []; + + onWebSocket(socket, "message", (...args) => { + messages.push(webSocketMessageToString(args)); + }); + + socket.emit("message", new TextEncoder().encode("text frame"), false); + socket.emit("message", new TextEncoder().encode("binary frame"), true); + + expect(messages).toEqual(["text frame", undefined]); + }); +}); + +class EventEmitterWebSocket { + private readonly listeners = new Map< + string, + Set<(...args: unknown[]) => void> + >(); + + send(): void {} + + close(): void {} + + on(type: string, listener: (...args: unknown[]) => void): void { + this.listeners.set( + type, + (this.listeners.get(type) ?? new Set()).add(listener), + ); + } + + off(type: string, listener: (...args: unknown[]) => void): void { + this.listeners.get(type)?.delete(listener); + } + + emit(type: string, ...args: unknown[]): void { + this.listeners.get(type)?.forEach((listener) => { + listener(...args); + }); + } +} diff --git a/src/ws-utils.ts b/src/ws-utils.ts index 947586ee..a77082b2 100644 --- a/src/ws-utils.ts +++ b/src/ws-utils.ts @@ -18,6 +18,22 @@ export function onWebSocket( type: string, listener: (...args: unknown[]) => void, ): () => void { + if (socket.on) { + const eventListener = (...args: unknown[]): void => { + listener(...normalizeEventEmitterMessageArgs(type, args)); + }; + socket.on(type, eventListener); + + return () => { + if (socket.off) { + socket.off(type, eventListener); + return; + } + + socket.removeListener?.(type, eventListener); + }; + } + if (socket.addEventListener) { const eventListener = (event: unknown): void => listener(event); socket.addEventListener(type, eventListener); @@ -27,29 +43,35 @@ export function onWebSocket( }; } - if (socket.on) { - socket.on(type, listener); + throw new Error("WebSocket object does not support event listeners"); +} - return () => { - if (socket.off) { - socket.off(type, listener); - return; - } +export function webSocketMessageToString(args: unknown[]): string | undefined { + const data = extractMessageData(args); - socket.removeListener?.(type, listener); - }; + if (typeof data === "string") { + return data; } - throw new Error("WebSocket object does not support event listeners"); + return undefined; } -export function webSocketMessageToString(args: unknown[]): string | undefined { - if (args[1] === true || isBinaryMessageEvent(args[0])) { - return undefined; +function normalizeEventEmitterMessageArgs( + type: string, + args: unknown[], +): unknown[] { + if (type !== "message" || typeof args[1] !== "boolean") { + return args; } - const data = extractMessageData(args); + if (args[1]) { + return [undefined]; + } + + return [decodeWebSocketTextData(args[0])]; +} +function decodeWebSocketTextData(data: unknown): string | undefined { if (typeof data === "string") { return data; } @@ -62,7 +84,7 @@ export function webSocketMessageToString(args: unknown[]): string | undefined { return new TextDecoder().decode(data); } - if (Array.isArray(data) && data.every(ArrayBuffer.isView)) { + if (isArrayBufferViewArray(data)) { return decodeArrayBufferViews(data); } @@ -83,13 +105,8 @@ function isMessageEventLike(value: unknown): value is { data: unknown } { return typeof value === "object" && value !== null && "data" in value; } -function isBinaryMessageEvent(value: unknown): boolean { - return ( - typeof value === "object" && - value !== null && - "isBinary" in value && - value.isBinary === true - ); +function isArrayBufferViewArray(value: unknown): value is ArrayBufferView[] { + return Array.isArray(value) && value.every(ArrayBuffer.isView); } function decodeArrayBufferViews(views: ArrayBufferView[]): string { From 58007d9b8cbd1f87a7a6c2ed9169bd1e76875eb0 Mon Sep 17 00:00:00 2001 From: Federico Ciner Date: Tue, 19 May 2026 20:56:37 +1000 Subject: [PATCH 13/20] Tighten up POST request validation and JSON content-type validation --- src/server-session-sse.test.ts | 75 +++++++++++++++++++++++++--------- src/server.test.ts | 36 +++++++++++++++- src/server.ts | 37 ++++++++++++----- 3 files changed, 116 insertions(+), 32 deletions(-) diff --git a/src/server-session-sse.test.ts b/src/server-session-sse.test.ts index c30c7759..37141d02 100644 --- a/src/server-session-sse.test.ts +++ b/src/server-session-sse.test.ts @@ -44,6 +44,19 @@ function createPromptRequest(id: number, sessionId?: string) { }; } +function createForkRequest(id: number, sessionId: string) { + return { + jsonrpc: "2.0", + id, + method: "session/fork", + params: { + cwd: "/tmp", + mcpServers: [], + sessionId, + }, + }; +} + describe("AcpServer session SSE", () => { it("streams prompt updates and responses on the session SSE stream", async () => { const server = await startTestServer( @@ -114,18 +127,13 @@ describe("AcpServer session SSE", () => { } }); - it("routes session prompts using params.sessionId when the session header is absent", async () => { + it("rejects session-scoped requests without a session header", async () => { const server = await startTestServer(); try { const connectionId = await initialize(server.url); const sessionId = await createSession(server.url, connectionId); - const sessionSse = await openSessionSse( - server.url, - connectionId, - sessionId, - ); - const accepted = await postJson( + const response = await postJson( server.url, createPromptRequest(3, sessionId), { @@ -133,25 +141,34 @@ describe("AcpServer session SSE", () => { }, ); - expect(accepted.status).toBe(202); - expect(await readSseMessages(sessionSse, 2)).toMatchObject([ - { - jsonrpc: "2.0", - method: "session/update", - params: { sessionId }, - }, + expect(response.status).toBe(400); + } finally { + await server.close(); + } + }); + + it("rejects session-scoped requests with mismatched session header and params", async () => { + const server = await startTestServer(); + + try { + const connectionId = await initialize(server.url); + const sessionId = await createSession(server.url, connectionId); + const response = await postJson( + server.url, + createPromptRequest(3, "other-session"), { - jsonrpc: "2.0", - id: 3, - result: { stopReason: "end_turn" }, + [HEADER_CONNECTION_ID]: connectionId, + [HEADER_SESSION_ID]: sessionId, }, - ]); + ); + + expect(response.status).toBe(400); } finally { await server.close(); } }); - it("rejects session-scoped requests without a session identifier", async () => { + it("rejects session-scoped requests without any session identifier", async () => { const server = await startTestServer(); try { @@ -166,6 +183,26 @@ describe("AcpServer session SSE", () => { } }); + it("routes non-required session methods using params.sessionId when the session header is absent", async () => { + const server = await startTestServer(); + + try { + const connectionId = await initialize(server.url); + const sessionId = await createSession(server.url, connectionId); + const response = await postJson( + server.url, + createForkRequest(3, sessionId), + { + [HEADER_CONNECTION_ID]: connectionId, + }, + ); + + expect(response.status).toBe(202); + } finally { + await server.close(); + } + }); + it("replays buffered session messages when session SSE attaches after prompt", async () => { const server = await startTestServer(); diff --git a/src/server.test.ts b/src/server.test.ts index e2b37bc7..14458f99 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -275,18 +275,50 @@ describe("AcpServer", () => { } }); - it("rejects POST without application/json Content-Type", async () => { + it("accepts POST with application/json Content-Type parameters", async () => { const server = await startTestServer(); try { const response = await fetch(server.url, { method: "POST", headers: { - "Content-Type": "text/plain", + "Content-Type": "application/json; charset=utf-8", }, body: JSON.stringify(initializeRequest), }); + expect(response.status).toBe(200); + } finally { + await server.close(); + } + }); + + it("rejects POST without Content-Type", async () => { + const server = await startTestServer(); + + try { + const response = await fetch(server.url, { method: "POST" }); + + expect(response.status).toBe(415); + } finally { + await server.close(); + } + }); + + it.each([ + "text/plain", + "application/jsonfoobar", + "application/json-patch+json", + ])("rejects POST with %s Content-Type", async (contentType) => { + const server = await startTestServer(); + + try { + const response = await fetch(server.url, { + method: "POST", + headers: { "Content-Type": contentType }, + body: JSON.stringify(initializeRequest), + }); + expect(response.status).toBe(415); } finally { await server.close(); diff --git a/src/server.ts b/src/server.ts index cd327a5d..5277f50d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -108,7 +108,7 @@ export class AcpServer { private async handlePost(req: Request): Promise { const contentType = req.headers.get("Content-Type"); - if (!contentType?.startsWith(JSON_MIME_TYPE)) { + if (!isJsonContentType(contentType)) { return textResponse("Unsupported Media Type", 415); } @@ -364,28 +364,39 @@ function determineRoute( headers: Headers, ): RouteResult { const headerSessionId = headers.get(HEADER_SESSION_ID); + const paramsSessionId = sessionIdFromParams(message.params); - if (headerSessionId) { + if (methodRequiresSessionHeader(message.method) && !headerSessionId) { return { - ok: true, - value: { session: headerSessionId }, + ok: false, + status: 400, + message: "Missing Acp-Session-Id", }; } - const paramsSessionId = sessionIdFromParams(message.params); + if ( + headerSessionId !== null && + paramsSessionId !== undefined && + headerSessionId !== paramsSessionId + ) { + return { + ok: false, + status: 400, + message: "Mismatched Acp-Session-Id", + }; + } - if (paramsSessionId) { + if (headerSessionId) { return { ok: true, - value: { session: paramsSessionId }, + value: { session: headerSessionId }, }; } - if (methodRequiresSessionHeader(message.method)) { + if (paramsSessionId) { return { - ok: false, - status: 400, - message: "Missing Acp-Session-Id", + ok: true, + value: { session: paramsSessionId }, }; } @@ -395,6 +406,10 @@ function determineRoute( }; } +function isJsonContentType(contentType: string | null): boolean { + return contentType?.split(";", 1)[0]?.trim().toLowerCase() === JSON_MIME_TYPE; +} + function sseResponse(subscription: OutboundSubscription): Response { return new Response(createSseBody(subscription), { status: 200, From 8aed9deed1d9460e5a2e0961c7f3a6193d035d86 Mon Sep 17 00:00:00 2001 From: Federico Ciner Date: Tue, 19 May 2026 21:08:36 +1000 Subject: [PATCH 14/20] fix: route session/load resume streams correctly --- src/server-session-sse.test.ts | 104 ++++++++++++++++++++++++++++++++- src/server.test.ts | 4 +- src/server.ts | 20 ++++--- 3 files changed, 118 insertions(+), 10 deletions(-) diff --git a/src/server-session-sse.test.ts b/src/server-session-sse.test.ts index 37141d02..048665d2 100644 --- a/src/server-session-sse.test.ts +++ b/src/server-session-sse.test.ts @@ -5,11 +5,18 @@ import { HEADER_SESSION_ID, JSON_MIME_TYPE, } from "./protocol.js"; +import { PROTOCOL_VERSION } from "./schema/index.js"; import { parseSseStream } from "./sse.js"; import { TestAgent } from "./test-support/test-agent.js"; import { startTestServer } from "./test-support/test-http-server.js"; -import type { AgentSideConnection } from "./acp.js"; +import type { + AgentSideConnection, + InitializeRequest, + InitializeResponse, + LoadSessionRequest, + LoadSessionResponse, +} from "./acp.js"; import type { AnyMessage } from "./jsonrpc.js"; const initializeRequest = { @@ -57,6 +64,49 @@ function createForkRequest(id: number, sessionId: string) { }; } +function createLoadSessionRequest(id: number, sessionId: string) { + return { + jsonrpc: "2.0", + id, + method: "session/load", + params: { + cwd: "/tmp", + mcpServers: [], + sessionId, + }, + }; +} + +class LoadSessionAgent extends TestAgent { + constructor(private readonly agentConnection: AgentSideConnection) { + super(agentConnection); + } + + initialize(_params: InitializeRequest): Promise { + return Promise.resolve({ + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { + loadSession: true, + }, + }); + } + + async loadSession(params: LoadSessionRequest): Promise { + await this.agentConnection.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "replayed-session-history", + }, + }, + }); + + return {}; + } +} + describe("AcpServer session SSE", () => { it("streams prompt updates and responses on the session SSE stream", async () => { const server = await startTestServer( @@ -203,6 +253,58 @@ describe("AcpServer session SSE", () => { } }); + it("routes session/load replay updates to session SSE and final response to connection SSE", async () => { + const server = await startTestServer( + (conn: AgentSideConnection) => new LoadSessionAgent(conn), + ); + + try { + const connectionId = await initialize(server.url); + const sessionId = "existing-session"; + const connectionSse = await openConnectionSse(server.url, connectionId); + const sessionSse = await openSessionSse( + server.url, + connectionId, + sessionId, + ); + const accepted = await postJson( + server.url, + createLoadSessionRequest(3, sessionId), + { + [HEADER_CONNECTION_ID]: connectionId, + [HEADER_SESSION_ID]: sessionId, + }, + ); + + expect(sessionSse.status).toBe(200); + expect(accepted.status).toBe(202); + expect(await readSseMessages(sessionSse, 1)).toMatchObject([ + { + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + text: "replayed-session-history", + }, + }, + }, + }, + ]); + expect(await readSseMessages(connectionSse, 1)).toMatchObject([ + { + jsonrpc: "2.0", + id: 3, + result: {}, + }, + ]); + } finally { + await server.close(); + } + }); + it("replays buffered session messages when session SSE attaches after prompt", async () => { const server = await startTestServer(); diff --git a/src/server.test.ts b/src/server.test.ts index 14458f99..a78757e9 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -176,7 +176,7 @@ describe("AcpServer", () => { } }); - it("rejects session-scoped GETs for unknown sessions", async () => { + it("opens session-scoped GETs for sessions without local streams", async () => { const server = await startTestServer(); try { @@ -187,7 +187,7 @@ describe("AcpServer", () => { globalThis.crypto.randomUUID(), ); - expect(response.status).toBe(404); + expect(response.status).toBe(200); } finally { await server.close(); } diff --git a/src/server.ts b/src/server.ts index 5277f50d..ca4ca72d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,6 +14,7 @@ import { isRequestMessage, isResponseMessage, } from "./jsonrpc.js"; +import { AGENT_METHODS } from "./schema/index.js"; import { serializeSseEvent, serializeSseKeepAlive } from "./sse.js"; import { handleWebSocketConnection } from "./ws-server.js"; import type { WebSocketServerSocket } from "./ws-server.js"; @@ -179,12 +180,7 @@ export class AcpServer { const sessionId = req.headers.get(HEADER_SESSION_ID); if (sessionId) { - const sessionStream = connection.sessionStreams.get(sessionId); - if (!sessionStream) { - return textResponse("Unknown Acp-Session-Id", 404); - } - - return sseResponse(sessionStream.subscribe()); + return sseResponse(connection.ensureSession(sessionId).subscribe()); } return sseResponse(connection.connectionStream.subscribe()); @@ -336,7 +332,10 @@ async function forwardClientRequest( const key = messageIdKey(message.id); if (key) { - connection.pendingRoutes.set(key, route.value); + connection.pendingRoutes.set( + key, + pendingResponseRoute(message, route.value), + ); } await writeInbound(connection, message); @@ -359,6 +358,13 @@ async function forwardClientNotification( return { ok: true }; } +function pendingResponseRoute( + message: ClientRequestMessage, + route: ResponseRoute, +): ResponseRoute { + return message.method === AGENT_METHODS.session_load ? "connection" : route; +} + function determineRoute( message: ClientRequestMessage, headers: Headers, From 49f8a836c3a093fcdcf766da42ef696a25e93118 Mon Sep 17 00:00:00 2001 From: Federico Ciner Date: Tue, 19 May 2026 21:21:32 +1000 Subject: [PATCH 15/20] Add connection-scoped cookie support --- src/http-stream.test.ts | 99 ++++++++++++++++++++- src/http-stream.ts | 191 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 279 insertions(+), 11 deletions(-) diff --git a/src/http-stream.test.ts b/src/http-stream.test.ts index d74c5bba..4e70b7c5 100644 --- a/src/http-stream.test.ts +++ b/src/http-stream.test.ts @@ -137,6 +137,82 @@ describe("createHttpStream", () => { } }); + it("propagates cookies across initialize, SSE, session POST, and DELETE", async () => { + const controlledFetch = createControlledFetch({ + initializeCookies: ["transport=alpha; Path=/"], + getCookies: ["route=bravo; Path=/"], + }); + const stream = createHttpStream("https://agent.example/acp", { + fetch: controlledFetch.fetch, + headers: { + Cookie: "caller=custom; transport=caller", + }, + }); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + try { + await writer.write(initializeRequest); + await readMessage(reader); + await controlledFetch.sendSse(0, sessionNewResponse); + await readMessage(reader); + await writer.write(promptRequest); + await writer.close(); + + expect(requestAt(controlledFetch.requests, 0).credentials).toBe( + "include", + ); + expect(requestAt(controlledFetch.requests, 0).headers.get("Cookie")).toBe( + "caller=custom; transport=caller", + ); + expect(requestAt(controlledFetch.requests, 1).headers.get("Cookie")).toBe( + "transport=caller; caller=custom", + ); + expect(requestAt(controlledFetch.requests, 2).headers.get("Cookie")).toBe( + "transport=caller; route=bravo; caller=custom", + ); + expect(requestAt(controlledFetch.requests, 3).headers.get("Cookie")).toBe( + "transport=caller; route=bravo; caller=custom", + ); + expect(requestAt(controlledFetch.requests, 4).headers.get("Cookie")).toBe( + "transport=caller; route=bravo; caller=custom", + ); + expect( + controlledFetch.requests.map((request) => request.credentials), + ).toEqual(["include", "include", "include", "include", "include"]); + } finally { + reader.releaseLock(); + writer.releaseLock(); + } + }); + + it("omits managed cookies when cookie handling is disabled", async () => { + const controlledFetch = createControlledFetch({ + initializeCookies: ["transport=alpha; Path=/"], + }); + const stream = createHttpStream("https://agent.example/acp", { + fetch: controlledFetch.fetch, + cookies: "omit", + }); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + try { + await writer.write(initializeRequest); + await readMessage(reader); + + expect(requestAt(controlledFetch.requests, 0).credentials).toBe("omit"); + expect(requestAt(controlledFetch.requests, 1).credentials).toBe("omit"); + expect( + requestAt(controlledFetch.requests, 1).headers.get("Cookie"), + ).toBeNull(); + } finally { + reader.releaseLock(); + writer.releaseLock(); + await stream.writable.close(); + } + }); + it("sends DELETE and aborts SSE requests when closed", async () => { const controlledFetch = createControlledFetch(); const stream = createHttpStream("https://agent.example/acp", { @@ -330,6 +406,7 @@ interface RecordedRequest { readonly method: string; readonly headers: Headers; readonly body: string; + readonly credentials: RequestCredentials | undefined; } interface RecordedSseRequest { @@ -349,7 +426,14 @@ interface TestClientState { readonly permissionRequests?: RequestPermissionRequest[]; } -function createControlledFetch(): ControlledFetch { +interface ControlledFetchOptions { + readonly initializeCookies?: readonly string[]; + readonly getCookies?: readonly string[]; +} + +function createControlledFetch( + options: ControlledFetchOptions = {}, +): ControlledFetch { const requests: RecordedRequest[] = []; const sseRequests: RecordedSseRequest[] = []; const encoder = new TextEncoder(); @@ -365,11 +449,13 @@ function createControlledFetch(): ControlledFetch { method, headers, body: bodyToString(init?.body), + credentials: init?.credentials, }); if (method === "POST" && !headers.has(HEADER_CONNECTION_ID)) { return jsonResponse(initializeResponse, 200, { [HEADER_CONNECTION_ID]: "connection-1", + ...setCookieResponseHeaders(options.initializeCookies), }); } @@ -395,7 +481,10 @@ function createControlledFetch(): ControlledFetch { return new Response(stream.readable, { status: 200, - headers: { "Content-Type": EVENT_STREAM_MIME_TYPE }, + headers: { + "Content-Type": EVENT_STREAM_MIME_TYPE, + ...setCookieResponseHeaders(options.getCookies), + }, }); } @@ -485,3 +574,9 @@ function jsonResponse( }, }); } + +function setCookieResponseHeaders( + cookies: readonly string[] | undefined, +): Record { + return cookies ? { "Set-Cookie": cookies.join(", ") } : {}; +} diff --git a/src/http-stream.ts b/src/http-stream.ts index ab432c63..665e6a50 100644 --- a/src/http-stream.ts +++ b/src/http-stream.ts @@ -18,13 +18,15 @@ export interface HttpStreamOptions { readonly fetch?: typeof globalThis.fetch; /** Headers to include on every HTTP/SSE request. */ readonly headers?: Record; + /** Cookie handling policy for transport requests. Defaults to `include`. */ + readonly cookies?: "include" | "omit"; } /** * Creates an ACP Stream over Streamable HTTP. * * Uses POST for client messages and SSE GET streams for server messages. - * Pass a custom `fetch` for cookies, auth, proxies, or non-browser runtimes. + * Cookies are included by default for the lifetime of one stream. */ export function createHttpStream( serverUrl: string, @@ -38,6 +40,8 @@ class HttpStreamTransport { private readonly fetchImpl: typeof globalThis.fetch; private readonly headers: Record; + private readonly cookiePolicy: RequestCredentials; + private readonly cookieJar = new ConnectionCookieJar(); private readonly abortController = new AbortController(); private readonly knownSessions = new Set(); @@ -54,6 +58,7 @@ class HttpStreamTransport { ) { this.fetchImpl = resolveFetch(options.fetch); this.headers = options.headers ?? {}; + this.cookiePolicy = options.cookies ?? "include"; this.stream = { readable: new ReadableStream({ @@ -95,10 +100,9 @@ class HttpStreamTransport { throw new Error("ACP HTTP stream first message must be initialize"); } - const response = await this.fetchImpl(this.serverUrl, { + const response = await this.fetchRequest({ method: "POST", headers: { - ...this.headers, "Content-Type": JSON_MIME_TYPE, }, body: JSON.stringify(message), @@ -130,10 +134,9 @@ class HttpStreamTransport { } const sessionId = sessionIdFromMessageParams(message); - const response = await this.fetchImpl(this.serverUrl, { + const response = await this.fetchRequest({ method: "POST", headers: { - ...this.headers, "Content-Type": JSON_MIME_TYPE, [HEADER_CONNECTION_ID]: connectionId, ...(sessionId ? { [HEADER_SESSION_ID]: sessionId } : {}), @@ -177,10 +180,9 @@ class HttpStreamTransport { private async openSse(headers: Record): Promise { try { - const response = await this.fetchImpl(this.serverUrl, { + const response = await this.fetchRequest({ method: "GET", headers: { - ...this.headers, Accept: EVENT_STREAM_MIME_TYPE, ...headers, }, @@ -216,6 +218,35 @@ class HttpStreamTransport { } } + private async fetchRequest(init: RequestInit): Promise { + const response = await this.fetchImpl(this.serverUrl, { + ...init, + credentials: this.cookiePolicy, + headers: this.createRequestHeaders(init.headers), + }); + + if (this.cookiePolicy === "include") { + this.cookieJar.store(response.headers); + } + + return response; + } + + private createRequestHeaders(headers: HeadersInit | undefined): Headers { + const requestHeaders = new Headers(this.headers); + const transportHeaders = new Headers(headers); + + transportHeaders.forEach((value, key) => { + requestHeaders.set(key, value); + }); + + if (this.cookiePolicy === "include") { + this.cookieJar.apply(requestHeaders); + } + + return requestHeaders; + } + private async close(): Promise { if (this.isClosed) { return; @@ -225,22 +256,23 @@ class HttpStreamTransport { const connectionId = this.connectionId; if (connectionId) { - const response = await this.fetchImpl(this.serverUrl, { + const response = await this.fetchRequest({ method: "DELETE", headers: { - ...this.headers, [HEADER_CONNECTION_ID]: connectionId, }, }); if (!response.ok) { this.abortController.abort(); + this.cookieJar.clear(); this.closeReadable(); throw await httpError("ACP DELETE failed", response); } } this.abortController.abort(); + this.cookieJar.clear(); this.closeReadable(); } @@ -259,6 +291,7 @@ class HttpStreamTransport { this.isClosed = true; this.abortController.abort(); + this.cookieJar.clear(); try { this.readableController?.error(error); @@ -276,6 +309,48 @@ class HttpStreamTransport { } } +class ConnectionCookieJar { + private readonly cookies = new Map(); + + store(headers: Headers): void { + for (const value of setCookieHeaders(headers)) { + const cookie = parseSetCookie(value); + if (!cookie) { + continue; + } + + this.cookies.set(cookie.name, cookie.value); + } + } + + apply(headers: Headers): void { + const merged = mergeCookieHeaders( + this.cookieHeader(), + headers.get("Cookie"), + ); + if (merged) { + headers.set("Cookie", merged); + } + } + + clear(): void { + this.cookies.clear(); + } + + private cookieHeader(): string | undefined { + return this.cookies.size === 0 + ? undefined + : Array.from(this.cookies) + .map(([name, value]) => `${name}=${value}`) + .join("; "); + } +} + +interface CookiePair { + readonly name: string; + readonly value: string; +} + function resolveFetch( fetchImpl: typeof globalThis.fetch | undefined, ): typeof globalThis.fetch { @@ -292,6 +367,104 @@ function resolveFetch( ); } +function setCookieHeaders(headers: Headers): string[] { + const getSetCookie = headers.getSetCookie; + if (typeof getSetCookie === "function") { + return getSetCookie.call(headers); + } + + const setCookie = headers.get("Set-Cookie"); + return setCookie ? splitSetCookieHeader(setCookie) : []; +} + +function splitSetCookieHeader(header: string): string[] { + const result: string[] = []; + let start = 0; + let isInExpires = false; + + for (let index = 0; index < header.length; index += 1) { + const char = header[index]; + + if (char === "," && !isInExpires) { + result.push(header.slice(start, index).trim()); + start = index + 1; + continue; + } + + if (header.slice(index, index + 8).toLowerCase() === "expires=") { + isInExpires = true; + index += 7; + continue; + } + + if (char === ";" && isInExpires) { + isInExpires = false; + } + } + + result.push(header.slice(start).trim()); + return result.filter((value) => value.length > 0); +} + +function parseSetCookie(header: string): CookiePair | undefined { + const pair = header.split(";", 1)[0]; + const separator = pair.indexOf("="); + + if (separator <= 0) { + return undefined; + } + + return { + name: pair.slice(0, separator).trim(), + value: pair.slice(separator + 1).trim(), + }; +} + +function mergeCookieHeaders( + jarCookieHeader: string | undefined, + callerCookieHeader: string | null, +): string | undefined { + const cookies = new Map(); + + for (const cookie of parseCookieHeader(jarCookieHeader)) { + cookies.set(cookie.name, cookie.value); + } + + for (const cookie of parseCookieHeader(callerCookieHeader ?? undefined)) { + cookies.set(cookie.name, cookie.value); + } + + return cookies.size === 0 + ? undefined + : Array.from(cookies) + .map(([name, value]) => `${name}=${value}`) + .join("; "); +} + +function parseCookieHeader(header: string | undefined): CookiePair[] { + if (!header) { + return []; + } + + return header + .split(";") + .map(parseCookiePair) + .filter((cookie): cookie is CookiePair => cookie !== undefined); +} + +function parseCookiePair(value: string): CookiePair | undefined { + const separator = value.indexOf("="); + + if (separator <= 0) { + return undefined; + } + + return { + name: value.slice(0, separator).trim(), + value: value.slice(separator + 1).trim(), + }; +} + async function httpError(prefix: string, response: Response): Promise { const text = await response.text().catch(() => ""); From 2dc3a832156863bdefbbf9ae73294436d339670c Mon Sep 17 00:00:00 2001 From: Federico Ciner Date: Tue, 19 May 2026 21:26:20 +1000 Subject: [PATCH 16/20] Update examples and README --- src/examples/README.md | 8 +++++++- src/examples/http-client.ts | 3 +-- src/examples/http-server.ts | 1 + 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/examples/README.md b/src/examples/README.md index d1c19a31..f00c3cd7 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -99,4 +99,10 @@ Or run the WebSocket client: npx tsx src/examples/ws-client.ts ``` -The HTTP example sends a bearer token through custom request headers. The WebSocket example passes the Node `ws` constructor so custom headers can be sent during the WebSocket handshake. Browser WebSocket clients can use `createWebSocketStream` too, but browsers do not allow custom WebSocket headers. +The HTTP example sends a bearer token through custom request headers. `createHttpStream` includes cookies by default for the lifetime of one stream: it sends credentials on fetch requests, captures exposed `Set-Cookie` headers, merges them with caller-provided `Cookie` headers, and reuses them for connection SSE, session SSE, POST, and DELETE requests. Pass `cookies: "omit"` to disable this behavior for stateless transports. + +The WebSocket server example uses `createNodeWebSocketUpgradeHandler`, which creates the ACP connection before the upgrade completes and adds `Acp-Connection-Id` to the `101 Switching Protocols` response. Frameworks that only expose an already-upgraded WebSocket socket cannot add that response header, so prefer an upgrade hook when building compliant servers. + +The WebSocket client example passes the Node `ws` constructor so custom headers can be sent during the WebSocket handshake. Browser WebSocket clients can use `createWebSocketStream` too, but browsers do not allow custom WebSocket headers. Use cookies or URL-level authentication for browser WebSocket authentication instead of relying on custom handshake headers. + +The included Node HTTP server is an HTTP/1.1 compatibility adapter. HTTP/2 deployment guidance is still tracked separately in the transport hardening plan. diff --git a/src/examples/http-client.ts b/src/examples/http-client.ts index 514a276a..f0c61a52 100644 --- a/src/examples/http-client.ts +++ b/src/examples/http-client.ts @@ -34,8 +34,7 @@ const stream = createHttpStream(serverUrl, { headers: { Authorization: "Bearer example-token", }, - // To use cookies, pass a cookie-aware fetch implementation here instead of relying on a built-in cookie jar. - // fetch: cookieAwareFetch, + // Cookies are included by default and scoped to this stream. Use `cookies: "omit"` for stateless requests. }); const connection = new acp.ClientSideConnection( (_agent) => new HttpExampleClient(), diff --git a/src/examples/http-server.ts b/src/examples/http-server.ts index 6fb85b03..2ba3962a 100644 --- a/src/examples/http-server.ts +++ b/src/examples/http-server.ts @@ -71,6 +71,7 @@ const acpServer = new AcpServer({ }); const acpHttpHandler = createNodeHttpHandler(acpServer); const webSocketServer = new WebSocketServer({ noServer: true }); +// Use the ACP upgrade helper so the 101 response includes Acp-Connection-Id. const acpWebSocketUpgradeHandler = createNodeWebSocketUpgradeHandler( acpServer, webSocketServer, From 99df92269e6f0f7a3c0f99d124e2d0085fbb20b4 Mon Sep 17 00:00:00 2001 From: Federico Ciner Date: Tue, 19 May 2026 21:42:23 +1000 Subject: [PATCH 17/20] Enforce ACP transport routing validation --- src/server-session-sse.test.ts | 63 ++++++++++++++++++++++++++++++---- src/server.test.ts | 19 ++++++++-- src/server.ts | 54 ++++++++++++++--------------- 3 files changed, 100 insertions(+), 36 deletions(-) diff --git a/src/server-session-sse.test.ts b/src/server-session-sse.test.ts index 048665d2..1ae524be 100644 --- a/src/server-session-sse.test.ts +++ b/src/server-session-sse.test.ts @@ -77,6 +77,16 @@ function createLoadSessionRequest(id: number, sessionId: string) { }; } +function createCancelNotification(sessionId: string) { + return { + jsonrpc: "2.0", + method: "session/cancel", + params: { + sessionId, + }, + }; +} + class LoadSessionAgent extends TestAgent { constructor(private readonly agentConnection: AgentSideConnection) { super(agentConnection); @@ -218,6 +228,47 @@ describe("AcpServer session SSE", () => { } }); + it("rejects session-scoped notifications without a session header", async () => { + const server = await startTestServer(); + + try { + const connectionId = await initialize(server.url); + const sessionId = await createSession(server.url, connectionId); + const response = await postJson( + server.url, + createCancelNotification(sessionId), + { + [HEADER_CONNECTION_ID]: connectionId, + }, + ); + + expect(response.status).toBe(400); + } finally { + await server.close(); + } + }); + + it("rejects session-scoped notifications with mismatched session header and params", async () => { + const server = await startTestServer(); + + try { + const connectionId = await initialize(server.url); + const sessionId = await createSession(server.url, connectionId); + const response = await postJson( + server.url, + createCancelNotification("other-session"), + { + [HEADER_CONNECTION_ID]: connectionId, + [HEADER_SESSION_ID]: sessionId, + }, + ); + + expect(response.status).toBe(400); + } finally { + await server.close(); + } + }); + it("rejects session-scoped requests without any session identifier", async () => { const server = await startTestServer(); @@ -262,11 +313,6 @@ describe("AcpServer session SSE", () => { const connectionId = await initialize(server.url); const sessionId = "existing-session"; const connectionSse = await openConnectionSse(server.url, connectionId); - const sessionSse = await openSessionSse( - server.url, - connectionId, - sessionId, - ); const accepted = await postJson( server.url, createLoadSessionRequest(3, sessionId), @@ -275,9 +321,14 @@ describe("AcpServer session SSE", () => { [HEADER_SESSION_ID]: sessionId, }, ); + const sessionSse = await openSessionSse( + server.url, + connectionId, + sessionId, + ); - expect(sessionSse.status).toBe(200); expect(accepted.status).toBe(202); + expect(sessionSse.status).toBe(200); expect(await readSseMessages(sessionSse, 1)).toMatchObject([ { jsonrpc: "2.0", diff --git a/src/server.test.ts b/src/server.test.ts index a78757e9..4977baf6 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -176,7 +176,7 @@ describe("AcpServer", () => { } }); - it("opens session-scoped GETs for sessions without local streams", async () => { + it("rejects session-scoped GETs for unknown sessions", async () => { const server = await startTestServer(); try { @@ -187,7 +187,7 @@ describe("AcpServer", () => { globalThis.crypto.randomUUID(), ); - expect(response.status).toBe(200); + expect(response.status).toBe(404); } finally { await server.close(); } @@ -385,6 +385,21 @@ describe("AcpServer", () => { } }); + it("rejects initialize requests on existing connections", async () => { + const server = await startTestServer(); + + try { + const connectionId = await initialize(server.url); + const response = await postJson(server.url, initializeRequest, { + [HEADER_CONNECTION_ID]: connectionId, + }); + + expect(response.status).toBe(400); + } finally { + await server.close(); + } + }); + it("rejects unknown connection IDs", async () => { const server = await startTestServer(); diff --git a/src/server.ts b/src/server.ts index ca4ca72d..1a09aa31 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,11 +9,7 @@ import { methodRequiresSessionHeader, sessionIdFromParams, } from "./protocol.js"; -import { - isJsonRpcMessage, - isRequestMessage, - isResponseMessage, -} from "./jsonrpc.js"; +import { isJsonRpcMessage, isResponseMessage } from "./jsonrpc.js"; import { AGENT_METHODS } from "./schema/index.js"; import { serializeSseEvent, serializeSseKeepAlive } from "./sse.js"; import { handleWebSocketConnection } from "./ws-server.js"; @@ -25,7 +21,12 @@ import type { ResponseRoute, } from "./connection.js"; import type { Agent, AgentSideConnection } from "./acp.js"; -import type { AnyMessage, AnyRequest, AnyResponse } from "./jsonrpc.js"; +import type { + AnyMessage, + AnyNotification, + AnyRequest, + AnyResponse, +} from "./jsonrpc.js"; /** Options for creating an ACP server transport. */ export interface AcpServerOptions { @@ -129,8 +130,12 @@ export class AcpServer { const connectionId = req.headers.get(HEADER_CONNECTION_ID); - if (isInitializeRequest(body.value) && !connectionId) { - return await this.handleInitialize(body.value); + if (isInitializeRequest(body.value)) { + if (!connectionId) { + return await this.handleInitialize(body.value); + } + + return textResponse("Initialize not allowed on existing connection", 400); } if (!connectionId) { @@ -180,7 +185,12 @@ export class AcpServer { const sessionId = req.headers.get(HEADER_SESSION_ID); if (sessionId) { - return sseResponse(connection.ensureSession(sessionId).subscribe()); + const sessionStream = connection.sessionStreams.get(sessionId); + if (!sessionStream) { + return textResponse("Unknown Acp-Session-Id", 404); + } + + return sseResponse(sessionStream.subscribe()); } return sseResponse(connection.connectionStream.subscribe()); @@ -244,15 +254,11 @@ export class AcpServer { message: AnyMessage, headers: Headers, ): Promise { - if (isRequestMessage(message)) { - return await forwardClientRequest(connection, message, headers); - } - if (isResponseMessage(message)) { return await forwardClientResponse(connection, message); } - return await forwardClientNotification(connection, message); + return await forwardClientMethodMessage(connection, message, headers); } } @@ -286,7 +292,7 @@ type RouteResult = message: string; }; -type ClientRequestMessage = AnyRequest; +type ClientMethodMessage = AnyRequest | AnyNotification; async function readJson(req: Request): Promise { try { @@ -314,9 +320,9 @@ async function writeInbound( } } -async function forwardClientRequest( +async function forwardClientMethodMessage( connection: ConnectionState, - message: ClientRequestMessage, + message: ClientMethodMessage, headers: Headers, ): Promise { const route = determineRoute(message, headers); @@ -329,7 +335,7 @@ async function forwardClientRequest( connection.ensureSession(route.value.session); } - const key = messageIdKey(message.id); + const key = "id" in message ? messageIdKey(message.id) : undefined; if (key) { connection.pendingRoutes.set( @@ -350,23 +356,15 @@ async function forwardClientResponse( return { ok: true }; } -async function forwardClientNotification( - connection: ConnectionState, - message: AnyMessage, -): Promise { - await writeInbound(connection, message); - return { ok: true }; -} - function pendingResponseRoute( - message: ClientRequestMessage, + message: ClientMethodMessage, route: ResponseRoute, ): ResponseRoute { return message.method === AGENT_METHODS.session_load ? "connection" : route; } function determineRoute( - message: ClientRequestMessage, + message: ClientMethodMessage, headers: Headers, ): RouteResult { const headerSessionId = headers.get(HEADER_SESSION_ID); From e56a6f9481130b7cb591dce2b9cc0b118b504652 Mon Sep 17 00:00:00 2001 From: Federico Ciner Date: Tue, 19 May 2026 21:58:07 +1000 Subject: [PATCH 18/20] fix: Align HTTP session routing with RFD --- src/connection.ts | 18 ++++++ src/http-stream.test.ts | 104 ++++++++++++++++++++++++++++++++++ src/http-stream.ts | 37 +++++++++++- src/server-permission.test.ts | 63 +++++++++++++++++++- src/server.ts | 27 ++++++++- 5 files changed, 246 insertions(+), 3 deletions(-) diff --git a/src/connection.ts b/src/connection.ts index cee4b778..871b8084 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -91,6 +91,7 @@ export class ConnectionState { readonly allOutbound = new OutboundStream(); readonly sessionStreams = new Map(); readonly pendingRoutes = new Map(); + readonly clientResponseRoutes = new Map(); private hasStartedRouter = false; private outboundReader: ReadableStreamDefaultReader | undefined; @@ -163,6 +164,7 @@ export class ConnectionState { this.sessionStreams.clear(); this.pendingRoutes.clear(); + this.clientResponseRoutes.clear(); await Promise.allSettled([ this.inboundTx.close(), @@ -231,13 +233,29 @@ export class ConnectionState { private routeOutboundRequestOrNotification(message: AnyMessage): void { const sessionId = sessionIdFromMessageParams(message); if (sessionId) { + this.trackClientResponseRoute(message, { session: sessionId }); this.ensureSession(sessionId).push(message); return; } + this.trackClientResponseRoute(message, "connection"); this.connectionStream.push(message); } + private trackClientResponseRoute( + message: AnyMessage, + route: ResponseRoute, + ): void { + if (!("id" in message) || !("method" in message)) { + return; + } + + const key = messageIdKey(message.id); + if (key) { + this.clientResponseRoutes.set(key, route); + } + } + private pushToRoute(route: ResponseRoute, message: AnyMessage): void { if (route === "connection") { this.connectionStream.push(message); diff --git a/src/http-stream.test.ts b/src/http-stream.test.ts index 4e70b7c5..63be5c9c 100644 --- a/src/http-stream.test.ts +++ b/src/http-stream.test.ts @@ -59,6 +59,48 @@ const promptRequest = { }, } satisfies AnyMessage; +const loadSessionRequest = { + jsonrpc: "2.0", + id: 3, + method: "session/load", + params: { + cwd: "/tmp", + mcpServers: [], + sessionId: "existing-session", + }, +} satisfies AnyMessage; + +const permissionRequest = { + jsonrpc: "2.0", + id: 99, + method: "session/request_permission", + params: { + sessionId: "session-1", + toolCall: { + toolCallId: "permission-tool", + title: "Permission tool", + }, + options: [ + { + kind: "allow_once", + name: "Allow once", + optionId: "allow", + }, + ], + }, +} satisfies AnyMessage; + +const permissionResponse = { + jsonrpc: "2.0", + id: 99, + result: { + outcome: { + outcome: "selected", + optionId: "allow", + }, + }, +} satisfies AnyMessage; + describe("createHttpStream", () => { it("posts initialize with custom headers, opens connection SSE, and emits the initialize response", async () => { const controlledFetch = createControlledFetch(); @@ -137,6 +179,68 @@ describe("createHttpStream", () => { } }); + it("opens session SSE before posting session/load for an existing session", async () => { + const controlledFetch = createControlledFetch(); + const stream = createHttpStream("https://agent.example/acp", { + fetch: controlledFetch.fetch, + }); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + try { + await writer.write(initializeRequest); + await readMessage(reader); + await writer.write(loadSessionRequest); + + const sessionGet = requestAt(controlledFetch.requests, 2); + const loadPost = requestAt(controlledFetch.requests, 3); + + expect(sessionGet.method).toBe("GET"); + expect(sessionGet.headers.get(HEADER_CONNECTION_ID)).toBe("connection-1"); + expect(sessionGet.headers.get(HEADER_SESSION_ID)).toBe( + "existing-session", + ); + expect(loadPost.method).toBe("POST"); + expect(loadPost.headers.get(HEADER_CONNECTION_ID)).toBe("connection-1"); + expect(loadPost.headers.get(HEADER_SESSION_ID)).toBe("existing-session"); + } finally { + reader.releaseLock(); + writer.releaseLock(); + await stream.writable.close(); + } + }); + + it("includes the session header on responses to session-scoped server requests", async () => { + const controlledFetch = createControlledFetch(); + const stream = createHttpStream("https://agent.example/acp", { + fetch: controlledFetch.fetch, + }); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + try { + await writer.write(initializeRequest); + await readMessage(reader); + await controlledFetch.sendSse(0, sessionNewResponse); + await readMessage(reader); + await controlledFetch.sendSse(1, permissionRequest); + await readMessage(reader); + await writer.write(permissionResponse); + + const responsePost = requestAt(controlledFetch.requests, 3); + expect(responsePost.method).toBe("POST"); + expect(responsePost.headers.get(HEADER_CONNECTION_ID)).toBe( + "connection-1", + ); + expect(responsePost.headers.get(HEADER_SESSION_ID)).toBe("session-1"); + expect(JSON.parse(responsePost.body)).toEqual(permissionResponse); + } finally { + reader.releaseLock(); + writer.releaseLock(); + await stream.writable.close(); + } + }); + it("propagates cookies across initialize, SSE, session POST, and DELETE", async () => { const controlledFetch = createControlledFetch({ initializeCookies: ["transport=alpha; Path=/"], diff --git a/src/http-stream.ts b/src/http-stream.ts index 665e6a50..3df6b7a6 100644 --- a/src/http-stream.ts +++ b/src/http-stream.ts @@ -5,6 +5,7 @@ import { HEADER_SESSION_ID, JSON_MIME_TYPE, isInitializeRequest, + messageIdKey, sessionIdFromMessageParams, sessionIdFromResponseResult, } from "./protocol.js"; @@ -44,6 +45,7 @@ class HttpStreamTransport { private readonly cookieJar = new ConnectionCookieJar(); private readonly abortController = new AbortController(); private readonly knownSessions = new Set(); + private readonly pendingResponseSessions = new Map(); private readableController: | ReadableStreamDefaultController @@ -133,7 +135,11 @@ class HttpStreamTransport { throw new Error("ACP HTTP stream is not initialized"); } - const sessionId = sessionIdFromMessageParams(message); + const sessionId = this.sessionIdForOutboundMessage(message); + if (sessionId) { + this.openSessionSse(sessionId); + } + const response = await this.fetchRequest({ method: "POST", headers: { @@ -149,6 +155,20 @@ class HttpStreamTransport { } } + private sessionIdForOutboundMessage(message: AnyMessage): string | undefined { + const paramsSessionId = sessionIdFromMessageParams(message); + if (paramsSessionId) { + return paramsSessionId; + } + + if (!("id" in message) || "method" in message) { + return undefined; + } + + const key = messageIdKey(message.id); + return key ? this.pendingResponseSessions.get(key) : undefined; + } + private openConnectionSse(): void { const connectionId = this.connectionId; if (!connectionId) { @@ -207,6 +227,7 @@ class HttpStreamTransport { this.openSessionSse(sessionId); } + this.trackServerRequestRoute(message, headers[HEADER_SESSION_ID]); this.enqueue(message); } } catch (error) { @@ -218,6 +239,20 @@ class HttpStreamTransport { } } + private trackServerRequestRoute( + message: AnyMessage, + streamSessionId: string | undefined, + ): void { + if (!streamSessionId || !("method" in message) || !("id" in message)) { + return; + } + + const key = messageIdKey(message.id); + if (key) { + this.pendingResponseSessions.set(key, streamSessionId); + } + } + private async fetchRequest(init: RequestInit): Promise { const response = await this.fetchImpl(this.serverUrl, { ...init, diff --git a/src/server-permission.test.ts b/src/server-permission.test.ts index 0ec3a9f6..7cea5b0c 100644 --- a/src/server-permission.test.ts +++ b/src/server-permission.test.ts @@ -45,6 +45,64 @@ function createPromptRequest(id: number, sessionId: string) { } describe("AcpServer permission requests over HTTP", () => { + it("rejects session-scoped client responses without a session header", async () => { + const server = await startTestServer( + (conn: AgentSideConnection) => + new TestAgent(conn, { enablePermission: true }), + ); + + try { + const connectionId = await initialize(server.url); + const sessionId = await createSession(server.url, connectionId); + const sessionSse = await openSessionSse( + server.url, + connectionId, + sessionId, + ); + const sessionEvents = createSseMessageIterator(sessionSse); + + expect( + await postJson(server.url, createPromptRequest(3, sessionId), { + [HEADER_CONNECTION_ID]: connectionId, + [HEADER_SESSION_ID]: sessionId, + }), + ).toMatchObject({ status: 202 }); + + await readNextSseMessage(sessionEvents); + const permissionRequest = await readNextSseMessage(sessionEvents); + + const permissionResponse = { + jsonrpc: "2.0", + id: readMessageId(permissionRequest), + result: { + outcome: { + outcome: "selected", + optionId: "allow", + }, + }, + }; + + expect( + await postJson(server.url, permissionResponse, { + [HEADER_CONNECTION_ID]: connectionId, + }), + ).toMatchObject({ status: 400 }); + expect( + await postJson(server.url, permissionResponse, { + [HEADER_CONNECTION_ID]: connectionId, + [HEADER_SESSION_ID]: sessionId, + }), + ).toMatchObject({ status: 202 }); + + await readNextSseMessage(sessionEvents); + await readNextSseMessage(sessionEvents); + await sessionEvents.return?.(); + await sessionSse.body?.cancel(); + } finally { + await server.close(); + } + }, 10_000); + it("routes permission requests over session SSE and accepts client responses", async () => { const server = await startTestServer( (conn: AgentSideConnection) => @@ -122,7 +180,10 @@ describe("AcpServer permission requests over HTTP", () => { }, }, }, - { [HEADER_CONNECTION_ID]: connectionId }, + { + [HEADER_CONNECTION_ID]: connectionId, + [HEADER_SESSION_ID]: sessionId, + }, ), ).toMatchObject({ status: 202 }); diff --git a/src/server.ts b/src/server.ts index 1a09aa31..e50aabcb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -255,7 +255,7 @@ export class AcpServer { headers: Headers, ): Promise { if (isResponseMessage(message)) { - return await forwardClientResponse(connection, message); + return await forwardClientResponse(connection, message, headers); } return await forwardClientMethodMessage(connection, message, headers); @@ -351,7 +351,32 @@ async function forwardClientMethodMessage( async function forwardClientResponse( connection: ConnectionState, message: AnyResponse, + headers: Headers, ): Promise { + const key = messageIdKey(message.id); + const route = key ? connection.clientResponseRoutes.get(key) : undefined; + const headerSessionId = headers.get(HEADER_SESSION_ID); + + if (route && route !== "connection" && !headerSessionId) { + return { + ok: false, + status: 400, + message: "Missing Acp-Session-Id", + }; + } + + if (route && route !== "connection" && headerSessionId !== route.session) { + return { + ok: false, + status: 400, + message: "Mismatched Acp-Session-Id", + }; + } + + if (key) { + connection.clientResponseRoutes.delete(key); + } + await writeInbound(connection, message); return { ok: true }; } From 19bc47a488f0c67b8918485022199fdff5fd9bab Mon Sep 17 00:00:00 2001 From: Federico Ciner Date: Wed, 20 May 2026 13:44:37 +1000 Subject: [PATCH 19/20] Support per-connection agent factories --- src/server-websocket-upgrade.test.ts | 243 +++++++++++++++++++++++++++ src/server.test.ts | 211 +++++++++++++++++++++++ src/server.ts | 46 +++-- 3 files changed, 489 insertions(+), 11 deletions(-) create mode 100644 src/server-websocket-upgrade.test.ts diff --git a/src/server-websocket-upgrade.test.ts b/src/server-websocket-upgrade.test.ts new file mode 100644 index 00000000..098d1b86 --- /dev/null +++ b/src/server-websocket-upgrade.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from "vitest"; + +import { PROTOCOL_VERSION } from "./acp.js"; +import { HEADER_CONNECTION_ID } from "./protocol.js"; +import { AcpServer } from "./server.js"; +import { TestAgent } from "./test-support/test-agent.js"; + +import type { Agent, AgentSideConnection } from "./acp.js"; +import type { AnyMessage } from "./jsonrpc.js"; +import type { WebSocketServerSocket } from "./ws-server.js"; + +const initializeRequest = { + jsonrpc: "2.0", + id: 0, + method: "initialize", + params: { + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + }, +} satisfies AnyMessage; + +describe("AcpServer prepared WebSocket upgrades", () => { + it("uses the default factory when no per-upgrade override is provided", async () => { + const createdBy: string[] = []; + const server = new AcpServer({ + createAgent: recordingFactory(createdBy, "default"), + }); + const socket = new FakeServerSocket(); + + try { + server.prepareWebSocketUpgrade().accept(socket); + socket.receive(JSON.stringify(initializeRequest)); + + await expect(readSentMessage(socket)).resolves.toMatchObject({ + jsonrpc: "2.0", + id: initializeRequest.id, + result: { + protocolVersion: PROTOCOL_VERSION, + }, + }); + expect(createdBy).toEqual(["default"]); + } finally { + socket.close(); + await server.close(); + } + }); + + it("uses a per-upgrade factory override for that WebSocket connection", async () => { + const createdBy: string[] = []; + const server = new AcpServer({ + createAgent: recordingFactory(createdBy, "default"), + }); + const socket = new FakeServerSocket(); + + try { + server + .prepareWebSocketUpgrade({ + createAgent: recordingFactory(createdBy, "override"), + }) + .accept(socket); + socket.receive(JSON.stringify(initializeRequest)); + + await readSentMessage(socket); + expect(createdBy).toEqual(["override"]); + } finally { + socket.close(); + await server.close(); + } + }); + + it("does not leak WebSocket factory overrides to later prepared upgrades", async () => { + const createdBy: string[] = []; + const server = new AcpServer({ + createAgent: recordingFactory(createdBy, "default"), + }); + const overrideSocket = new FakeServerSocket(); + const defaultSocket = new FakeServerSocket(); + + try { + server + .prepareWebSocketUpgrade({ + createAgent: recordingFactory(createdBy, "override"), + }) + .accept(overrideSocket); + server.prepareWebSocketUpgrade().accept(defaultSocket); + + overrideSocket.receive(JSON.stringify(initializeRequest)); + defaultSocket.receive(JSON.stringify({ ...initializeRequest, id: 1 })); + + await Promise.all([ + readSentMessage(overrideSocket), + readSentMessage(defaultSocket), + ]); + expect(createdBy).toEqual(["override", "default"]); + } finally { + overrideSocket.close(); + defaultSocket.close(); + await server.close(); + } + }); + + it("keeps concurrent WebSocket factory overrides isolated", async () => { + const createdBy: string[] = []; + const server = new AcpServer({ + createAgent: recordingFactory(createdBy, "default"), + }); + const firstSocket = new FakeServerSocket(); + const secondSocket = new FakeServerSocket(); + + try { + const first = server.prepareWebSocketUpgrade({ + createAgent: recordingFactory(createdBy, "first"), + }); + const second = server.prepareWebSocketUpgrade({ + createAgent: recordingFactory(createdBy, "second"), + }); + + second.accept(secondSocket); + first.accept(firstSocket); + secondSocket.receive(JSON.stringify({ ...initializeRequest, id: 2 })); + firstSocket.receive(JSON.stringify({ ...initializeRequest, id: 1 })); + + await Promise.all([ + readSentMessage(firstSocket), + readSentMessage(secondSocket), + ]); + expect(createdBy).toEqual(expect.arrayContaining(["first", "second"])); + expect(createdBy).toHaveLength(2); + } finally { + firstSocket.close(); + secondSocket.close(); + await server.close(); + } + }); + + it("removes rejected prepared WebSocket connections", async () => { + const server = new AcpServer({ + createAgent: (conn) => new TestAgent(conn), + }); + const prepared = server.prepareWebSocketUpgrade(); + + try { + prepared.reject(); + const response = await server.handleRequest( + new Request("http://127.0.0.1/acp", { + method: "GET", + headers: { + Accept: "text/event-stream", + [HEADER_CONNECTION_ID]: prepared.connectionId, + }, + }), + ); + + expect(response.status).toBe(404); + } finally { + await server.close(); + } + }); + + it("keeps existing double-settle behavior for prepared WebSocket upgrades", async () => { + const server = new AcpServer({ + createAgent: (conn) => new TestAgent(conn), + }); + const rejected = server.prepareWebSocketUpgrade(); + const accepted = server.prepareWebSocketUpgrade(); + const socket = new FakeServerSocket(); + + try { + rejected.reject(); + expect(() => rejected.accept(new FakeServerSocket())).toThrow( + "ACP WebSocket upgrade has already been settled", + ); + + accepted.accept(socket); + expect(() => accepted.accept(new FakeServerSocket())).toThrow( + "ACP WebSocket upgrade has already been settled", + ); + expect(() => accepted.reject()).not.toThrow(); + } finally { + socket.close(); + await server.close(); + } + }); +}); + +function recordingFactory( + createdBy: string[], + label: string, +): (conn: AgentSideConnection) => Agent { + return (conn) => { + createdBy.push(label); + return new TestAgent(conn); + }; +} + +function readSentMessage(socket: FakeServerSocket): Promise { + const message = socket.sent.shift(); + + if (message) { + return Promise.resolve(JSON.parse(message)); + } + + return new Promise((resolve) => { + socket.onSend = (data) => { + resolve(JSON.parse(data)); + }; + }); +} + +class FakeServerSocket implements WebSocketServerSocket { + readonly sent: string[] = []; + readonly listeners = new Map void>>(); + onSend: ((data: string) => void) | undefined; + + send(data: string): void { + this.sent.push(data); + this.onSend?.(data); + this.onSend = undefined; + } + + close(_code?: number, _reason?: string): void { + this.emit("close", {}); + } + + addEventListener(type: string, listener: (event: unknown) => void): void { + this.listeners.set(type, this.listeners.get(type) ?? new Set()); + this.listeners.get(type)?.add(listener); + } + + removeEventListener(type: string, listener: (event: unknown) => void): void { + this.listeners.get(type)?.delete(listener); + } + + receive(data: string): void { + this.emit("message", { data }); + } + + private emit(type: string, event: unknown): void { + for (const listener of this.listeners.get(type) ?? []) { + listener(event); + } + } +} diff --git a/src/server.test.ts b/src/server.test.ts index 4977baf6..e2081dd6 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -60,6 +60,193 @@ describe("AcpServer", () => { } }); + it("uses the default factory for direct HTTP initialize requests", async () => { + const createdBy: string[] = []; + const server = new AcpServer({ + createAgent: (conn: AgentSideConnection) => { + createdBy.push("default"); + return new TestAgent(conn); + }, + }); + + try { + const response = await server.handleRequest( + jsonRequest(initializeRequest), + ); + + expect(response.status).toBe(200); + expect(response.headers.get(HEADER_CONNECTION_ID)).toMatch( + /^[0-9a-f-]{36}$/, + ); + expect(createdBy).toEqual(["default"]); + } finally { + await server.close(); + } + }); + + it("uses per-request factory overrides for direct HTTP initialize requests", async () => { + const createdBy: string[] = []; + const server = new AcpServer({ + createAgent: (conn: AgentSideConnection) => { + createdBy.push("default"); + return new TestAgent(conn); + }, + }); + + try { + const response = await server.handleRequest( + jsonRequest(initializeRequest), + { + createAgent: (conn) => { + createdBy.push("override"); + return new TestAgent(conn); + }, + }, + ); + + expect(response.status).toBe(200); + expect(response.headers.get(HEADER_CONNECTION_ID)).toMatch( + /^[0-9a-f-]{36}$/, + ); + expect(createdBy).toEqual(["override"]); + } finally { + await server.close(); + } + }); + + it("does not leak HTTP factory overrides to later initialize requests", async () => { + const createdBy: string[] = []; + const server = new AcpServer({ + createAgent: (conn: AgentSideConnection) => { + createdBy.push("default"); + return new TestAgent(conn); + }, + }); + + try { + await server.handleRequest(jsonRequest(initializeRequest), { + createAgent: (conn) => { + createdBy.push("override"); + return new TestAgent(conn); + }, + }); + await server.handleRequest(jsonRequest({ ...initializeRequest, id: 2 })); + + expect(createdBy).toEqual(["override", "default"]); + } finally { + await server.close(); + } + }); + + it("keeps concurrent HTTP initialize factory overrides isolated", async () => { + const createdBy: string[] = []; + const server = new AcpServer({ + createAgent: (conn: AgentSideConnection) => { + createdBy.push("default"); + return new TestAgent(conn); + }, + }); + + try { + const first = server.handleRequest(jsonRequest(initializeRequest), { + createAgent: (conn) => { + createdBy.push("first"); + return new TestAgent(conn); + }, + }); + const second = server.handleRequest( + jsonRequest({ ...initializeRequest, id: 2 }), + { + createAgent: (conn) => { + createdBy.push("second"); + return new TestAgent(conn); + }, + }, + ); + + await Promise.all([first, second]); + + expect(createdBy).toEqual(expect.arrayContaining(["first", "second"])); + expect(createdBy).toHaveLength(2); + } finally { + await server.close(); + } + }); + + it("ignores HTTP factory overrides for existing-connection POST requests", async () => { + const createdBy: string[] = []; + const server = new AcpServer({ + createAgent: (conn: AgentSideConnection) => { + createdBy.push("default"); + return new TestAgent(conn); + }, + }); + + try { + const connectionId = await initializeDirect(server); + const response = await server.handleRequest( + jsonRequest(sessionNewRequest, { + [HEADER_CONNECTION_ID]: connectionId, + }), + { + createAgent: (conn) => { + createdBy.push("override"); + return new TestAgent(conn); + }, + }, + ); + + expect(response.status).toBe(202); + expect(createdBy).toEqual(["default"]); + } finally { + await server.close(); + } + }); + + it("ignores HTTP factory overrides for GET and DELETE requests", async () => { + const createdBy: string[] = []; + const server = new AcpServer({ + createAgent: (conn: AgentSideConnection) => { + createdBy.push("default"); + return new TestAgent(conn); + }, + }); + + try { + const connectionId = await initializeDirect(server); + const createAgent = (conn: AgentSideConnection): TestAgent => { + createdBy.push("override"); + return new TestAgent(conn); + }; + const getResponse = await server.handleRequest( + new Request("http://127.0.0.1/acp", { + method: "GET", + headers: { + Accept: EVENT_STREAM_MIME_TYPE, + [HEADER_CONNECTION_ID]: connectionId, + }, + }), + { createAgent }, + ); + + expect(getResponse.status).toBe(200); + await getResponse.body?.cancel(); + + const deleteResponse = await server.handleRequest( + new Request("http://127.0.0.1/acp", { + method: "DELETE", + headers: { [HEADER_CONNECTION_ID]: connectionId }, + }), + { createAgent }, + ); + + expect(deleteResponse.status).toBe(202); + expect(createdBy).toEqual(["default"]); + } finally { + await server.close(); + } + }); + it("streams session/new responses over the connection SSE stream", async () => { const server = await startTestServer(); @@ -472,6 +659,30 @@ describe("AcpServer", () => { }); }); +async function initializeDirect(server: AcpServer): Promise { + const response = await server.handleRequest(jsonRequest(initializeRequest)); + const connectionId = response.headers.get(HEADER_CONNECTION_ID); + + expect(response.status).toBe(200); + expect(connectionId).toMatch(/^[0-9a-f-]{36}$/); + + return connectionId ?? ""; +} + +function jsonRequest( + body: unknown, + headers: Record = {}, +): Request { + return new Request("http://127.0.0.1/acp", { + method: "POST", + headers: { + "Content-Type": JSON_MIME_TYPE, + ...headers, + }, + body: JSON.stringify(body), + }); +} + async function initialize(url: string): Promise { const response = await postJson(url, initializeRequest); const connectionId = response.headers.get(HEADER_CONNECTION_ID); diff --git a/src/server.ts b/src/server.ts index e50aabcb..b7aa0e26 100644 --- a/src/server.ts +++ b/src/server.ts @@ -28,10 +28,20 @@ import type { AnyResponse, } from "./jsonrpc.js"; +export type AgentFactory = (conn: AgentSideConnection) => Agent; + /** Options for creating an ACP server transport. */ export interface AcpServerOptions { /** Creates the agent implementation for each accepted ACP connection. */ - createAgent: (conn: AgentSideConnection) => Agent; + createAgent: AgentFactory; +} + +export interface HandleRequestOptions { + readonly createAgent?: AgentFactory; +} + +export interface PrepareWebSocketUpgradeOptions { + readonly createAgent?: AgentFactory; } export interface PreparedWebSocketUpgrade { @@ -48,7 +58,7 @@ export interface PreparedWebSocketUpgrade { * the `101 Switching Protocols` response. */ export class AcpServer { - private readonly createAgent: (conn: AgentSideConnection) => Agent; + private readonly createAgent: AgentFactory; private readonly registry = new ConnectionRegistry(); constructor(options: AcpServerOptions) { @@ -56,9 +66,12 @@ export class AcpServer { } /** Handles one Streamable HTTP ACP request. */ - async handleRequest(req: Request): Promise { + async handleRequest( + req: Request, + options: HandleRequestOptions = {}, + ): Promise { if (req.method === "POST") { - return await this.handlePost(req); + return await this.handlePost(req, options); } if (req.method === "GET") { @@ -73,8 +86,11 @@ export class AcpServer { } /** Creates a WebSocket connection before accepting the HTTP upgrade. */ - prepareWebSocketUpgrade(): PreparedWebSocketUpgrade { - const connection = this.registry.createConnection(this.createAgent); + prepareWebSocketUpgrade( + options: PrepareWebSocketUpgradeOptions = {}, + ): PreparedWebSocketUpgrade { + const createAgent = options.createAgent ?? this.createAgent; + const connection = this.registry.createConnection(createAgent); let isSettled = false; return { @@ -87,7 +103,7 @@ export class AcpServer { isSettled = true; handleWebSocketConnection(socket, { registry: this.registry, - createAgent: this.createAgent, + createAgent, connection, }); }, @@ -107,7 +123,10 @@ export class AcpServer { this.registry.closeAll(); } - private async handlePost(req: Request): Promise { + private async handlePost( + req: Request, + options: HandleRequestOptions, + ): Promise { const contentType = req.headers.get("Content-Type"); if (!isJsonContentType(contentType)) { @@ -132,7 +151,7 @@ export class AcpServer { if (isInitializeRequest(body.value)) { if (!connectionId) { - return await this.handleInitialize(body.value); + return await this.handleInitialize(body.value, options); } return textResponse("Initialize not allowed on existing connection", 400); @@ -210,7 +229,10 @@ export class AcpServer { return emptyResponse(202); } - private async handleInitialize(message: AnyMessage): Promise { + private async handleInitialize( + message: AnyMessage, + options: HandleRequestOptions, + ): Promise { if (!("id" in message) || message.id === null) { return textResponse("Initialize request must include an ID", 400); } @@ -220,7 +242,9 @@ export class AcpServer { | undefined; try { - connection = this.registry.createConnection(this.createAgent); + connection = this.registry.createConnection( + options.createAgent ?? this.createAgent, + ); await writeInbound(connection, message); const initialResponse = await connection.recvInitial(message.id); From c8a84c55ccd4ea543c6bda54f4e211505c7fcdf2 Mon Sep 17 00:00:00 2001 From: Federico Ciner Date: Tue, 9 Jun 2026 10:44:38 -0700 Subject: [PATCH 20/20] Add session affinity using cookie store --- acp-web-transport/design.md | 986 ++++++++++++++++++ acp-web-transport/rfd.md | 402 ++++++++ acp-web-transport/tasks.md | 1701 ++++++++++++++++++++++++++++++++ acp-web-transport/testing.md | 833 ++++++++++++++++ src/cookie-store.test.ts | 117 +++ src/cookie-store.ts | 147 +++ src/examples/http-client.ts | 61 +- src/examples/http-server.ts | 49 +- src/examples/ws-client.ts | 62 +- src/http-stream.test.ts | 393 +++++++- src/http-stream.ts | 182 +--- src/protocol.test.ts | 3 - src/protocol.ts | 1 - src/server-session-sse.test.ts | 12 +- src/server.test.ts | 5 +- src/server.ts | 7 +- src/ws-stream.test.ts | 310 +++++- src/ws-stream.ts | 125 ++- 18 files changed, 5194 insertions(+), 202 deletions(-) create mode 100644 acp-web-transport/design.md create mode 100644 acp-web-transport/rfd.md create mode 100644 acp-web-transport/tasks.md create mode 100644 acp-web-transport/testing.md create mode 100644 src/cookie-store.test.ts create mode 100644 src/cookie-store.ts diff --git a/acp-web-transport/design.md b/acp-web-transport/design.md new file mode 100644 index 00000000..48c629be --- /dev/null +++ b/acp-web-transport/design.md @@ -0,0 +1,986 @@ +# Design: Streamable HTTP & WebSocket Transport for the ACP TypeScript SDK + +## Context + +The [Agent Client Protocol](https://agentclientprotocol.com) TypeScript SDK (`@agentclientprotocol/sdk`) currently supports only `stdio` transport — the client spawns the agent as a subprocess and communicates over stdin/stdout using newline-delimited JSON. There is no standard remote transport, which causes fragmentation as implementers invent their own HTTP layers (e.g., Goose built `createHttpStream` externally in `goose/ui/sdk/src/http-stream.ts`). + +This spec defines the implementation of Streamable HTTP and WebSocket transports as described in the [ACP RFD: Streamable HTTP & WebSocket Transport](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/rfds/streamable-http-websocket-transport.mdx). + +The design is closely aligned with the [Rust SDK's HTTP/WS transport implementation](https://github.com/agentclientprotocol/rust-sdk/pull/162) (`agent-client-protocol-http` crate), which is already shipping. We follow the same architectural patterns — `ConnectionRegistry`, `OutboundStream` with bounded replay buffers, `pendingRoutes` routing, `session_id_from_params` / `method_requires_session_header` helpers — and diverge only where the TypeScript ecosystem warrants it (Web Standard `Request`/`Response` handlers instead of axum coupling, subpath exports instead of a separate crate). + +### Updated v1 Durability and Affinity Scope + +PR [agentclientprotocol/agent-client-protocol#1376](https://github.com/agentclientprotocol/agent-client-protocol/pull/1376) clarifies the v1 remote-transport contract: + +- **Sessions survive disconnects** when the agent supports `agentCapabilities.loadSession` and stores session state outside the per-connection transport/agent instance. A reconnect creates a new `Acp-Connection-Id`; the client resumes by calling `session/load` with the existing `sessionId`. The SDK should make this flow possible and testable, but it must not own agent conversation persistence. +- **Session affinity is preserved across reconnects** by carrying required client cookies across reconnect attempts so a load balancer or external affinity layer can route the reconnecting client to the backend that can serve the session. Deployments without native sticky sessions must provide equivalent affinity or shared durable state themselves, for example Redis/Postgres/object storage keyed by session ID and authorized principal. +- **No v1 in-flight replay/resumability.** SSE event IDs, `Last-Event-ID`, protocol-defined reconnect semantics, and transport-level message replay remain v2 work. `session/load` may replay conversation history at the agent/application layer; it is not stream resumption. +- **Security boundary:** `Acp-Connection-Id`, `Acp-Session-Id`, and affinity cookies are routing identifiers, not authorization. Servers/agents must authorize `session/load` against the authenticated principal that owns the session. + +### Reference Materials + +| Resource | Location | +| ------------------------------ | ------------------------------------------------------------------------- | +| ACP Transport RFD | `agent-client-protocol/docs/rfds/streamable-http-websocket-transport.mdx` | +| TypeScript SDK | `github.com/agentclientprotocol/typescript-sdk` | +| **Rust SDK HTTP transport PR** | `agentclientprotocol/rust-sdk` PR #162 — primary reference implementation | +| Goose HTTP client (reference) | `goose/ui/sdk/src/http-stream.ts` | +| Alta ACP service | `alta-two/services/alta-service/src/acp/service.ts` | + +--- + +## 1. TypeScript SDK Architecture Analysis + +### 1.1 SDK Structure + +The TypeScript SDK is a standalone, pure TypeScript implementation with zero hard dependencies (only `zod` as a peer dependency): + +``` +src/ +├── acp.ts # AgentSideConnection, ClientSideConnection, Connection (~2700 lines) +├── stream.ts # Stream interface + ndJsonStream factory (99 lines) +├── jsonrpc.ts # AnyMessage, AnyRequest, AnyResponse type definitions (46 lines) +├── schema/ +│ ├── index.ts # Re-exports + AGENT_METHODS/CLIENT_METHODS constants +│ ├── types.gen.ts # Auto-generated types from OpenAPI schema +│ └── zod.gen.ts # Auto-generated Zod validators +└── examples/ + ├── agent.ts # Example agent (stdio) + └── client.ts # Example client (stdio) +``` + +**Key classes:** + +- **`Stream`** — The transport abstraction: `{ writable: WritableStream; readable: ReadableStream }`. This is the only interface transport implementations need to satisfy. +- **`Connection`** (private) — Handles JSON-RPC message routing. Reads from `stream.readable`, writes to `stream.writable`. Manages pending response promises keyed by request ID. +- **`AgentSideConnection`** — Wraps `Connection` for the agent side. Routes incoming client requests/notifications to the `Agent` interface methods. +- **`ClientSideConnection`** — Wraps `Connection` for the client side. Routes incoming agent notifications/requests to the `Client` interface methods. + +### 1.2 The Stream Interface — Transport Abstraction + +```typescript +type Stream = { + writable: WritableStream; // outgoing messages + readable: ReadableStream; // incoming messages +}; +``` + +Any transport implementation produces a `Stream`. The `Connection` class reads and writes `AnyMessage` objects without knowing or caring about the underlying transport. The Rust SDK's `Channel` type (`{ tx, rx }` over `mpsc::unbounded`) serves the identical role. + +On the **server side**, the HTTP transport creates a synthetic `Stream` internally — the agent writes to a single `writable` which a router task demuxes to connection-scoped or session-scoped SSE streams. This is architecturally impure (a fan-out fabric pretending to be a 1:1 pipe), but the Rust SDK validates that this pattern works correctly in practice and avoids modifications to the core `AgentSideConnection` class. + +### 1.3 Bidirectional Complexity for HTTP Transport + +ACP is **bidirectional** — the server sends requests TO the client (not just responses). Over HTTP, server→client requests travel on GET SSE streams, and the client POSTs back responses: + +**Permission flow over HTTP:** + +1. Client POSTs `session/prompt` (id: 3) → 202 Accepted +2. Agent sends `session/request_permission` (id: 99) → arrives on session-scoped GET SSE stream +3. Client POSTs response `{ id: 99, result: { outcome: "allow_once" } }` → 202 Accepted +4. Agent sends prompt response (id: 3) → arrives on session-scoped GET SSE stream + +**Session suspension/resumption:** + +1. Client establishes new connection (POST `initialize` → 200, open connection-scoped GET) +2. Client opens session-scoped GET stream with `Acp-Connection-Id` + `Acp-Session-Id` +3. Client POSTs `session/load` with the existing session ID +4. Agent replays history as notifications on the session-scoped GET stream + +--- + +## 2. Transport Interaction Diagrams + +### 2.1 STDIO Transport (Current) + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ STDIO Transport │ +│ │ +│ Client Process Agent Process │ +│ ┌────────────────┐ spawn() ┌────────────────────┐ │ +│ │ │──────────────►│ │ │ +│ │ ClientSide │ │ AgentSide │ │ +│ │ Connection │ │ Connection │ │ +│ │ │ │ │ │ +│ │ ┌──────────┐ │ stdin │ ┌──────────────┐ │ │ +│ │ │ writable ├──┼──────────────►│──┤ readable │ │ │ +│ │ └──────────┘ │ (ndjson) │ └──────────────┘ │ │ +│ │ │ │ │ │ +│ │ ┌──────────┐ │ stdout │ ┌──────────────┐ │ │ +│ │ │ readable │◄─┼───────────────┼──┤ writable │ │ │ +│ │ └──────────┘ │ (ndjson) │ └──────────────┘ │ │ +│ │ │ │ │ │ +│ └────────────────┘ └────────────────────┘ │ +│ │ +│ • 1:1 relationship — one client, one agent │ +│ • Symmetric: both sides use ndJsonStream() │ +│ • Agent lifecycle tied to process lifecycle │ +│ • No connection/session ID headers needed │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 Streamable HTTP Transport + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Streamable HTTP Transport │ +│ │ +│ Client (e.g. Zed, Goose UI) Server (Agent Host) │ +│ ┌─────────────────────┐ ┌──────────────────────────┐ │ +│ │ ClientSideConnection│ │ AcpServer │ │ +│ │ + createHttpStream() │ │ ┌──────────────────────┐│ │ +│ │ │ │ │ ConnectionRegistry ││ │ +│ │ │ │ │ ┌──────────────────┐ ││ │ +│ │ │ │ │ │ Connection "c1" │ ││ │ +│ │ │ │ │ │ ┌──────────────┐ │ ││ │ +│ │ │ │ │ │ │Agent Instance│ │ ││ │ +│ │ │ │ │ │ │(from factory)│ │ ││ │ +│ │ │ │ │ │ └──────────────┘ │ ││ │ +│ │ │ │ │ │ connStream: SSE │ ││ │ +│ │ │ │ │ │ sessions: { │ ││ │ +│ │ │ │ │ │ "s1": SSE, │ ││ │ +│ │ │ │ │ │ "s2": SSE │ ││ │ +│ │ │ │ │ │ } │ ││ │ +│ │ │ │ │ └──────────────────┘ ││ │ +│ │ │ │ └──────────────────────┘│ │ +│ └─────────────────────┘ └──────────────────────────┘ │ +│ │ +│ ═══ Flow ═══ │ +│ │ +│ 1. POST /acp {initialize} │ +│ ──────────────────────────────► Create Connection + Agent │ +│ ◄────── 200 OK + JSON body ──── Acp-Connection-Id: c1 │ +│ │ +│ 2. GET /acp (Acp-Connection-Id: c1) │ +│ ──────────────────────────────► Open connection-scoped SSE │ +│ ◄═══════ SSE stream open ══════ (long-lived) │ +│ │ +│ 3. POST /acp {session/new} │ +│ ──────────────────────────────► Agent creates session │ +│ ◄────── 202 Accepted ───────── │ +│ ◄═══ SSE event ═══════════════ {id:2, result:{sessionId:"s1"}} │ +│ (on connection-scoped stream) │ +│ │ +│ 4. GET /acp (Acp-Connection-Id: c1, Acp-Session-Id: s1) │ +│ ──────────────────────────────► Open session-scoped SSE │ +│ ◄═══════ SSE stream open ══════ (long-lived) │ +│ │ +│ 5. POST /acp {session/prompt} │ +│ ──────────────────────────────► Agent processes prompt │ +│ ◄────── 202 Accepted ───────── │ +│ ◄═══ SSE: session/update ═════ AgentMessageChunk │ +│ ◄═══ SSE: request_permission ═ {id:99, method:"session/request_ │ +│ permission", params:{...}} │ +│ 6. POST /acp {id:99, result:{...}} │ +│ ──────────────────────────────► Client responds to permission │ +│ ◄────── 202 Accepted ───────── │ +│ ◄═══ SSE: response ═══════════ {id:3, result:{stopReason:"end_turn"}} │ +│ │ +│ 7. DELETE /acp (Acp-Connection-Id: c1) │ +│ ──────────────────────────────► Terminate connection │ +│ ◄────── 202 Accepted ───────── All SSE streams close │ +│ │ +│ • 1:N — one server, many connections, each with many sessions │ +│ • POST returns 202 immediately (except initialize → 200) │ +│ • Responses arrive on GET SSE streams, correlated by JSON-RPC id │ +│ • Two-header model: Acp-Connection-Id + Acp-Session-Id │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 2.3 WebSocket Transport + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ WebSocket Transport │ +│ │ +│ Client Server │ +│ ┌─────────────────────┐ ┌──────────────────────────┐ │ +│ │ ClientSideConnection │ │ AcpServer │ │ +│ │ + createWebSocketStream │ │ ┌──────────────────────┐│ │ +│ │ │ │ │ ConnectionRegistry ││ │ +│ │ │ │ │ (same as HTTP) ││ │ +│ │ │ │ └──────────────────────┘│ │ +│ └─────────────────────┘ └──────────────────────────┘ │ +│ │ +│ ═══ Flow ═══ │ +│ │ +│ 1. GET /acp (Upgrade: websocket) │ +│ ──────────────────────────────► WebSocket upgrade │ +│ ◄── 101 Switching Protocols ─── WebSocket open │ +│ ═══════ WebSocket open ═══════ (full-duplex) │ +│ │ +│ 2. WS frame: {initialize} │ +│ ──────────────────────────────► Same handler as stdio │ +│ ◄────────────────────────────── WS frame: {result: capabilities} │ +│ │ +│ 3. WS frame: {session/new} │ +│ ──────────────────────────────► │ +│ ◄────────────────────────────── WS frame: {result: {sessionId:"s1"}} │ +│ │ +│ 4. WS frame: {session/prompt} │ +│ ──────────────────────────────► │ +│ ◄────────────────────────────── WS frame: session/update notification │ +│ ◄────────────────────────────── WS frame: request_permission request │ +│ 5. WS frame: {id:99, result:{...}} │ +│ ──────────────────────────────► Client responds to permission │ +│ ◄────────────────────────────── WS frame: prompt response │ +│ │ +│ 6. Close WebSocket │ +│ ──────────────────────────────► Connection cleanup │ +│ │ +│ • Simplest transport — bidirectional by nature │ +│ • No SSE streams, no header routing, no 202 dance │ +│ • All messages are JSON-RPC text frames │ +│ • Most similar to stdio semantics (just over a network) │ +│ • Multiple sessions share one WebSocket (demuxed by sessionId in body) │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. Design Decisions + +### D1: Ship in the SDK with Subpath Exports + +**Choice:** All transport implementations live in `@agentclientprotocol/sdk` as new modules. Each transport is exposed via `package.json#exports` subpaths so consumers only import what they need. + +**Rationale:** The Rust SDK ships HTTP/WS transport as a separate crate (`agent-client-protocol-http`). The npm-idiomatic equivalent is subpath exports within the same package — same semantic separation without multi-package coordination overhead. This ensures browser/Workers consumers who only use the client transport don't pull in server-side code or Node.js types. + +```json +{ + "exports": { + ".": "./dist/acp.js", + "./http-client": "./dist/http-stream.js", + "./ws-client": "./dist/ws-stream.js", + "./server": "./dist/server.js", + "./node": "./dist/node-adapter.js" + } +} +``` + +### D2: Web Standard Request/Response Server Handler + +**Choice:** The server-side handler uses `handleRequest(req: Request): Promise` — the Web Fetch API standard. + +**Rationale:** The Rust SDK couples to axum (the dominant Rust web framework). In the TS ecosystem, frameworks are fragmented (Hono, Express, Fastify, Deno.serve, Bun.serve), so framework-agnostic Web Standard handlers are strictly better. SSE is a `Response` with a `ReadableStream` body. Consumers wire it in with a one-liner. MCP TypeScript SDK validated this pattern. The handler ignores the request path and dispatches solely on HTTP method + headers — consumers mount it at whatever path they choose. + +### D3: `ws` as Optional Peer Dependency + +**Choice:** `ws` is an optional peer dependency for Node.js WebSocket server support. + +**Rationale:** Node.js has no built-in `WebSocketServer`. `ws` is the de facto standard (75M+ weekly downloads), has zero hard dependencies. As an _optional peer dep_, it's never installed unless the consumer explicitly uses the Node.js WebSocket adapter. Deno, Bun, and Workers users use their runtime's built-in WebSocket upgrade. + +### D4: Simple Agent Factory (Aligned with Rust SDK) + +**Choice:** Consumers provide a synchronous agent factory `(conn: AgentSideConnection) => Agent`. No HTTP `Request` parameter, no async support. + +**Rationale:** The Rust SDK's `AgentFactory` uses `Fn() -> C` — sync, no request context. We follow the same principle: keep the factory simple. Consumers who need per-request auth context close over it when constructing the factory, or perform auth validation in middleware before the request reaches the transport. This avoids the sync-vs-async factory debate and keeps the SDK's responsibility clear: transport plumbing, not auth. + +### D5: Separate HTTP and WebSocket Code Paths + +**Choice:** `handleRequest(req: Request)` handles POST/GET/DELETE. WebSocket server support uses a pre-upgrade path (`prepareWebSocketUpgrade()` plus runtime/node adapter wiring) so the server can create a connection and attach `Acp-Connection-Id` to the `101 Switching Protocols` response before the socket is accepted. + +**Rationale:** HTTP needs connection-scoped and session-scoped SSE streams, 202 responses, and header-based routing. WebSocket is a simple bidirectional channel like stdio after upgrade, but the RFD requires `Acp-Connection-Id` during the upgrade response. The Rust SDK uses the same pre-upgrade split (`http_server.rs` + `websocket_server.rs`). Keeping them separate avoids conflating two fundamentally different communication models. + +### D6: Bounded Outbound Streams (From Rust SDK) + +**Choice:** All outbound streams (connection-scoped, session-scoped, all-outbound) use bounded broadcast channels with a capacity of 1024 messages. + +**Rationale:** The Rust SDK uses `broadcast::channel(1024)` with oldest-first eviction when the replay buffer is full, and `Lagged(n)` errors for slow subscribers. This prevents unbounded memory growth from buffered messages when session streams aren't attached yet or clients are slow. We implement the same bounded semantics using a `BroadcastChannel` abstraction over Web Standard APIs. + +### D7: Reconnect-Scoped Cookie Store for Client Affinity + +**Choice:** The SDK provides a small, explicit cookie store abstraction (`AcpCookieStore`) plus an in-memory implementation (`MemoryAcpCookieStore`) that can be shared across `createHttpStream()` and `createWebSocketStream()` instances. The client transports use cookie support by default. If a caller passes a store, the SDK does **not** clear it automatically on stream close/error; the caller owns its reconnect lifetime. + +**Rationale:** The updated v1 RFD requires client cookie support because affinity cookies are the building block for reconnect routing. The existing per-stream HTTP cookie jar satisfies only "duration of the connection" and fails the reconnect-affinity use case because a new stream loses the cookie. A reusable store gives hosts a simple, dependency-free way to preserve affinity across dropped connections while still avoiding a full RFC6265 implementation in the SDK. Browser fetch/WebSocket continue to rely on the platform cookie jar where appropriate; Node/custom transports use the SDK store. + +**Limits:** `MemoryAcpCookieStore` is deliberately minimal: it stores cookie name/value pairs from `Set-Cookie`, merges them into outbound `Cookie` headers, and lets caller-provided `Cookie` values override managed values with the same name. It does not implement domain/path matching, expiry, SameSite, or Secure enforcement. Production servers remain responsible for setting hardened cookies (`Secure`, `HttpOnly` where possible, appropriate `SameSite`) and for treating cookies as routing hints, not auth tokens. + +### D8: Durable Session State Belongs to the Agent/Deployment + +**Choice:** The SDK does not introduce a built-in durable session store. `AcpServer` continues to own only per-connection transport state (`ConnectionState`, SSE streams, pending routes). Agent implementations that advertise `loadSession` must persist/reconstruct session state outside the per-connection agent instance. + +**Rationale:** ACP session state is application-specific: conversation history, model/task state, MCP connections, file-system roots, auth ownership, and retention policy all live above the transport layer. A single-process demo can keep a `Map` outside `createAgent`; a multi-node deployment without sticky sessions needs a shared store such as Redis/Postgres/object storage or an external affinity layer that routes by a retained cookie. The SDK should provide examples/tests that prove the transport supports `session/load` on a fresh connection, not prescribe storage semantics. + +### D9: v1 Reconnect Semantics Are Host-Managed + +**Choice:** The SDK exposes primitives for reconnect (`ClientSideConnection`, reusable cookie store, `session/load` routing), but it does not implement automatic reconnect/retry loops or liveness detection in v1. + +**Rationale:** PR #1376 explicitly leaves reconnect/retry and liveness detection to the implementer for v1. Hosts decide when to reconnect, whether to retry an in-flight prompt, how to surface unknown in-flight state, and when to call `session/load`. The SDK should document that server→client messages emitted while disconnected are not replayed by the transport. + +--- + +## 4. Proposed API Surface + +### 4.1 Client-Side Transports + +```typescript +// ── Shared Cookie/Affinity State ───────────────────────────────── + +/** + * Minimal transport cookie store used for ACP affinity cookies. + * Implementations may be shared across reconnecting HTTP and WebSocket streams. + */ +export interface AcpCookieStore { + /** Capture cookies from response/upgrade headers. */ + store(headers: Headers): void; + /** Merge managed cookies into an outgoing request header set. */ + apply(headers: Headers): void; + /** Explicitly clear caller-owned affinity state, e.g. on logout. */ + clear(): void; +} + +/** + * Dependency-free name/value cookie store suitable for ACP affinity cookies. + * Not a complete browser-grade RFC6265 jar. + */ +export class MemoryAcpCookieStore implements AcpCookieStore {} + + +// ── HTTP Client Transport ────────────────────────────────────── + +/** + * Creates a Stream that speaks the ACP Streamable HTTP transport. + * Uses fetch() and SSE — zero external dependencies. + * + * Cookie support defaults to "include". Pass the same cookieStore to a + * replacement stream after disconnect to preserve load-balancer/session affinity. + */ +export function createHttpStream(serverUrl: string, options?: { + /** Custom fetch function (e.g., for auth headers). */ + fetch?: typeof globalThis.fetch; + /** Custom headers to include on all requests. */ + headers?: Record; + /** Cookie handling policy for transport requests. Defaults to "include". */ + cookies?: "include" | "omit"; + /** Optional caller-owned cookie store reused across reconnects. */ + cookieStore?: AcpCookieStore; +}): Stream; + +// Usage with reconnect affinity: +const cookieStore = new MemoryAcpCookieStore(); +let stream = createHttpStream("https://agent.example.com/acp", { cookieStore }); +let conn = new ClientSideConnection((agent) => myClient, stream); +await conn.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {...} }); + +// If the stream drops, create a fresh connection with the same cookie store. +stream = createHttpStream("https://agent.example.com/acp", { cookieStore }); +conn = new ClientSideConnection((agent) => myClient, stream); +await conn.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {...} }); +await conn.loadSession({ sessionId, cwd, mcpServers }); + + +// ── WebSocket Client Transport ───────────────────────────────── + +/** + * Creates a Stream that speaks ACP over WebSocket. + * Uses the standard WebSocket API — zero external dependencies. + * + * Browser WebSocket cannot set Cookie/custom headers manually; browser runtimes + * rely on the platform cookie jar. Node-compatible constructors such as `ws` + * receive managed cookies through the constructor `headers` option. + */ +export function createWebSocketStream(serverUrl: string, options?: { + /** WebSocket subprotocols. */ + protocols?: string[]; + /** Custom headers (Node.js/Deno/Bun only — not available in browsers). */ + headers?: Record; + /** Custom WebSocket constructor (e.g., `ws.WebSocket` in Node). */ + WebSocket?: typeof globalThis.WebSocket; + /** Cookie handling policy for Node/custom constructors. Defaults to "include". */ + cookies?: "include" | "omit"; + /** Optional caller-owned cookie store reused across reconnects. */ + cookieStore?: AcpCookieStore; +}): Stream; + +// Usage: +const stream = createWebSocketStream("wss://agent.example.com/acp", { + WebSocket: NodeWebSocket, + cookieStore, +}); +const conn = new ClientSideConnection((agent) => myClient, stream); +``` + +### 4.2 Server-Side Transport + +```typescript +// ── Server Transport ─────────────────────────────────────────── + +export interface AcpServerOptions { + /** + * Factory function called once per new connection to create an Agent. + * Called synchronously during initialize handling. + * + * For per-request auth context, close over it: + * createAgent: (conn) => new MyAgent(conn, { token: extractedToken }) + * + * Or perform auth in middleware before the request reaches the transport. + */ + createAgent: (conn: AgentSideConnection) => Agent; +} + +export interface PreparedWebSocketUpgrade { + /** Connection ID to include in the `101 Switching Protocols` response. */ + connectionId: string; + /** Accept the upgraded socket and bind it to the pre-created connection. */ + accept(socket: WebSocketLike): void; + /** Reject/cleanup the pre-created connection if the upgrade fails. */ + reject(): void; +} + +/** + * ACP server transport that handles Streamable HTTP and WebSocket + * connections. Framework-agnostic via Web Standard Request/Response for HTTP; + * WebSocket uses a pre-upgrade helper so `Acp-Connection-Id` can be attached + * to the upgrade response. + */ +export class AcpServer { + constructor(options: AcpServerOptions); + + /** Handles incoming HTTP POST/GET/DELETE requests. */ + handleRequest(req: Request): Promise; + + /** + * Creates a connection before accepting a WebSocket upgrade. + * Consumers/adapters add `connectionId` as the `Acp-Connection-Id` response + * header on the 101 response, then call `accept(socket)` after upgrade. + */ + prepareWebSocketUpgrade(options?: { + createAgent?: (conn: AgentSideConnection) => Agent; + }): PreparedWebSocketUpgrade; + + /** Shuts down all connections and cleans up resources. */ + close(): Promise; +} + +// ── Node.js Convenience Adapter ──────────────────────────────── + +/** + * Wraps an AcpServer for use with node:http or node:http2 servers. + * Converts IncomingMessage/ServerResponse ↔ Web Standard Request/Response. + */ +export function createNodeHttpHandler( + server: AcpServer, +): (req: IncomingMessage, res: ServerResponse) => void; + +/** + * Creates a Node `upgrade` handler for `ws.WebSocketServer({ noServer: true })`. + * The adapter pre-creates the ACP connection, injects `Acp-Connection-Id` into + * the 101 response headers, accepts the socket, and cleans up on failed upgrade. + */ +export function createNodeWebSocketUpgradeHandler( + server: AcpServer, + webSocketServer: WebSocketServer, +): (req: IncomingMessage, socket: Duplex, head: Buffer) => void; + +// Usage with node:http: +import http from "node:http"; +import { WebSocketServer } from "ws"; +import { AcpServer } from "@agentclientprotocol/sdk/server"; +import { + createNodeHttpHandler, + createNodeWebSocketUpgradeHandler, +} from "@agentclientprotocol/sdk/node"; + +const acpServer = new AcpServer({ + createAgent: (conn) => new MyAgent(conn), +}); + +const httpServer = http.createServer(createNodeHttpHandler(acpServer)); +const wss = new WebSocketServer({ noServer: true }); +httpServer.on("upgrade", createNodeWebSocketUpgradeHandler(acpServer, wss)); +httpServer.listen(3000); +``` + +### 4.3 File Layout + +``` +src/ +├── acp.ts # EXISTING — AgentSideConnection, ClientSideConnection, Connection +├── stream.ts # EXISTING — Stream interface, ndJsonStream +├── jsonrpc.ts # EXISTING — AnyMessage types +├── schema/ # EXISTING — auto-generated types +│ +├── protocol.ts # NEW — header/method/session helpers (internal) +├── cookie-store.ts # NEW — shared minimal cookie/affinity store (public via client modules) +├── sse.ts # NEW — SSE serialization/deserialization utilities +├── connection.ts # NEW — ConnectionState, OutboundStream, ConnectionRegistry (internal) +├── server.ts # NEW — AcpServer class + handleRequest/prepareWebSocketUpgrade +├── node-adapter.ts # NEW — Node HTTP + compliant WebSocket upgrade adapters +├── http-stream.ts # NEW — createHttpStream() client transport +├── ws-stream.ts # NEW — createWebSocketStream() client transport +│ +├── protocol.test.ts # NEW — protocol helper unit tests +├── cookie-store.test.ts # NEW — cookie parsing/merge/reconnect store unit tests +├── sse.test.ts # NEW — SSE utility tests +├── connection.test.ts # NEW — connection registry + routing tests +├── node-adapter.test.ts # NEW — Node adapter tests +├── server.test.ts # NEW — HTTP server transport integration tests (Phases 1–4) +├── http-stream.test.ts # NEW — HTTP client transport tests + E2E +├── ws-stream.test.ts # NEW — WebSocket client transport tests + E2E +│ +├── test-support/ +│ ├── test-agent.ts # NEW — self-contained configurable test agent +│ └── test-http-server.ts # NEW — local HTTP server helper, later with WS upgrade support +│ +└── examples/ + ├── http-server.ts # NEW — Node HTTP server + WebSocket upgrade example + ├── http-client.ts # NEW — HTTP client example + └── ws-client.ts # NEW — WebSocket client example +``` + +External integration work called out by the task plan: + +| Area | Files | Purpose | +| ---------------------- | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| Alta bridge adapter | `alta/packages/acp-transport-stdio/src/http-entrypoint.ts` | Wrap `AcpBridgeAgent` in `AcpServer`; add WS upgrade wiring later. | +| Relay client adapter | `relay/src/acp/http-client.ts` | Port Relay from STDIO process management to `createHttpStream`. | +| Alta service migration | `alta-two/services/alta-service/src/acp/rivet-agent.ts`, `src/effect/api.ts`, `src/effect/api-app-layer.ts` | Replace bespoke `/api/v1/acp` HTTP dispatch with SDK `AcpServer` + `RivetAgent`. | + +File names are aligned with the Rust SDK structure: + +- `server.ts` ↔ Rust `server.rs` + `http_server.rs` + `websocket_server.rs` +- `connection.ts` ↔ Rust `connection.rs` +- `protocol.ts` ↔ Rust `protocol.rs` +- `client.rs` → split into `http-stream.ts` + `ws-stream.ts` (TS consumers choose transport explicitly) + +--- + +## 5. Internal Architecture + +### 5.1 Protocol Helpers (from Rust SDK) + +`protocol.ts` is internal to the transport implementation. It centralizes header constants, method classification, session ID extraction, initialize detection, and JSON-RPC ID normalization. + +```typescript +export const HEADER_CONNECTION_ID = "Acp-Connection-Id"; +export const HEADER_SESSION_ID = "Acp-Session-Id"; +export const EVENT_STREAM_MIME_TYPE = "text/event-stream"; +export const JSON_MIME_TYPE = "application/json"; + +export function methodRequiresSessionHeader(method: string): boolean; +export function sessionIdFromParams(params: unknown): string | undefined; +export function isInitializeRequest(msg: AnyMessage): boolean; +export function messageIdKey( + id: string | number | null | undefined, +): string | undefined; +``` + +`methodRequiresSessionHeader` mirrors the Rust SDK list and includes the stable TypeScript SDK session-scoped methods: + +- `session/prompt` +- `session/cancel` +- `session/load` +- `session/set_mode` +- `session/set_model` +- `session/close` +- `session/set_config_option` +- `session/resume` + +`sessionIdFromParams` only inspects top-level `params.sessionId` and returns it when it is a string. Nested or non-string session IDs are ignored. + +`messageIdKey` normalizes JSON-RPC IDs as `string:` or `number:` and returns `undefined` for `null` or `undefined`. + +### 5.2 OutboundStream — Bounded Broadcast with Replay + +Direct port of the Rust SDK's `OutboundStream`. Messages are buffered in a replay queue until the first subscriber attaches, then switch to live broadcast mode. + +``` +OutboundStream (capacity: 1024) + ├── State A: Buffering (no subscriber yet) + │ └── push(msg) → append to replay VecDeque (evict oldest if full) + │ + └── State B: Broadcasting (after first subscribe()) + └── push(msg) → broadcast to all subscribers + └── Slow subscribers receive a "lagged" warning and miss messages + +subscribe() → returns (replayedMessages[], liveReceiver) + - First call: drains the replay buffer, transitions to broadcast mode + - Subsequent calls: returns empty replay, gets only live messages +``` + +### 5.3 Connection and Registry + +``` +AcpServer + └── ConnectionRegistry + └── Map + +Connection + ├── connectionId: string (crypto.randomUUID()) + ├── inboundTx: write end of agent's Stream.readable + ├── outboundRx: read end of agent's Stream.writable (consumed by router) + ├── connectionStream: OutboundStream (connection-scoped SSE) + ├── sessionStreams: Map + ├── allOutbound: OutboundStream (all messages — used by WebSocket) + ├── pendingRoutes: Map + └── routerTask: background loop that reads outboundRx and routes +``` + +**IdKey normalization** (from Rust SDK): JSON-RPC IDs can be strings or numbers. `messageIdKey` normalizes them for map lookup (`string:` or `number:`), and returns `undefined` for `null`/`undefined`: + +```typescript +type IdKey = `string:${string}` | `number:${number}`; +``` + +**ResponseRoute**: `"connection" | { session: string }` + +### 5.4 Message Routing — HTTP (from Rust SDK) + +**Outbound routing** (agent writes → router classifies → SSE stream): + +``` +Agent writes message to Stream.writable + │ + ├── Request? (has method + id — server→client request) + │ └── sessionIdFromParams(params) → session stream or connection stream + │ + └── Response? (has id, no method) + └── pendingRoutes.remove(id) → route to recorded destination + └── Fallback: connection stream +``` + +**Inbound routing** (client POST → classify → forward to agent): + +``` +Client POST /acp + │ + ├── initialize? (no Acp-Connection-Id header) + │ └── Create connection, send to agent, recv initial response, return 200 + JSON + │ + ├── Has Acp-Connection-Id? + │ ├── Request? → Determine route from Acp-Session-Id header OR sessionIdFromParams + │ │ Record pendingRoutes[id] = route + │ │ If session-scoped: ensure session OutboundStream exists + │ │ Forward to agent, return 202 + │ │ + │ ├── Response? (client responding to server request) → Forward to agent, return 202 + │ └── Notification? → Forward to agent, return 202 + │ + └── No connection ID + not initialize → 400 +``` + +**Route determination for requests** (aligned with Rust SDK `handle_post`): + +1. If `Acp-Session-Id` header is present → route to that session +2. Else if `sessionIdFromParams(params)` returns a session ID → route to that session +3. Else if `methodRequiresSessionHeader(method)` → reject with 400 +4. Else → route to connection stream + +### 5.5 Message Routing — WebSocket (from Rust SDK) + +WebSocket subscribes to `allOutbound` — a unified stream of all messages regardless of scope. No per-session routing needed because WebSocket is a single bidirectional channel. + +``` +Client sends WS text frame + └── Parse as JSON-RPC + ├── Request with sessionId in params? + │ → ensureSession(sessionId) + │ → recordPendingRoute(id, Session(sessionId)) + │ → Forward to agent + └── Otherwise → Forward to agent + +Agent writes message + └── Router pushes to allOutbound (and connection/session streams for HTTP) + └── WS subscriber reads from allOutbound → send as text frame +``` + +### 5.6 SSE Stream Lifecycle + +``` +Connection-scoped SSE stream: + - Opened: client GETs with Acp-Connection-Id only + - Carries: session/new responses, session/load responses, connection-level notifications + - Keepalive: empty SSE comment every 15 seconds (matches Rust SDK) + - Closed: on DELETE or connection close + +Session-scoped SSE stream: + - Opened: client GETs with Acp-Connection-Id + Acp-Session-Id + - Carries: session/update notifications, request_permission requests, prompt responses + - Closed: on session close, connection DELETE, or stream error +``` + +### 5.7 Initialize Flow (from Rust SDK) + +The `initialize` request is special — its response is returned as the HTTP 200 body (not via SSE). The Rust SDK handles this by reading the first outbound message directly from the agent channel before starting the router task: + +1. `POST /acp` with `initialize` request (no `Acp-Connection-Id` header) +2. Create connection + agent via factory +3. Send `initialize` message to agent +4. `recvInitial()` — read the first response directly from the outbound channel +5. Start the router task (begins consuming outbound messages for SSE routing) +6. Return 200 with the initialize response as JSON body + `Acp-Connection-Id` header + +--- + +## 6. Design Notes & Gotchas + +### 6.1 HTTP/2 for Multiplexing + +The RFD specifies HTTP/2 for the Streamable HTTP transport. HTTP/2 is important for multiplexing — a client may have one connection-scoped GET stream, multiple session-scoped GET streams, and concurrent POST requests. On HTTP/1.1, browser per-origin connection limits (typically 6) can cause issues. + +**Decision:** The SDK's Web Standard handler works with any HTTP version. HTTP/2 is a deployment concern documented as SHOULD for server operators. The Rust SDK takes the same approach. + +### 6.2 SSE Event Format + +Minimal SSE format (no event IDs, no resumability — matches Rust SDK): + +``` +data: {"jsonrpc":"2.0",...}\n\n +``` + +SSE keepalive comment every 15 seconds to prevent proxy/load-balancer timeouts: + +``` +:\n\n +``` + +### 6.3 Error Responses + +HTTP status codes per the RFD: + +| Status | Condition | +| ------ | ----------------------------------------------------------------------------- | +| 200 | `initialize` response (JSON body) | +| 202 | All other POST requests accepted | +| 400 | Missing required headers, missing `Acp-Session-Id` for session-scoped methods | +| 404 | Unknown `Acp-Connection-Id` or `Acp-Session-Id` | +| 406 | GET without `Accept: text/event-stream` | +| 415 | POST without `Content-Type: application/json` | +| 501 | Batch JSON-RPC requests (array body) | + +### 6.4 Connection IDs + +Generated with `crypto.randomUUID()` (UUIDv4 — 122 bits of entropy). Matches the Rust SDK's `uuid::Uuid::new_v4()`. Unguessable by design. + +### 6.5 Multiple SSE Subscribers per Scope + +Following the Rust SDK's `OutboundStream` semantics: the first `subscribe()` call drains the replay buffer. Subsequent subscribers to the same scope get only live messages. This handles reconnect scenarios gracefully — if a client's SSE stream drops and reconnects, it subscribes again and gets live messages from that point forward. + +### 6.6 Agent Factory Isolation + +Each connection gets its own agent instance from the factory. If the factory throws, the `initialize` request fails with an error response and no connection is registered. The SDK does not enforce cross-connection isolation — that's the consumer's responsibility. + +### 6.7 Connection Cleanup + +When a connection is terminated (DELETE, WebSocket close), all associated resources are cleaned up: agent's stream is closed, router task is stopped, all SSE OutboundStreams are closed. Matches the Rust SDK's `Connection::shutdown()`. + +### 6.8 Session Survival, Affinity, and Reconnect Flow + +A v1 reconnect creates a **new** transport connection. The new `Acp-Connection-Id` is independent of the old one; the stable application identity is the ACP `sessionId` passed to `session/load`. + +Recommended host flow: + +1. Client keeps reconnect state outside the dropped stream: `sessionId`, `cwd`, `mcpServers`, auth headers/token, and an `AcpCookieStore`/platform cookie jar. +2. Client creates a fresh HTTP or WebSocket stream with the same cookie store and auth headers. +3. Client calls `initialize`; the server returns a new `Acp-Connection-Id`. +4. If `agentCapabilities.loadSession` is true, client calls `session/load` with the existing `sessionId`. +5. Agent authorizes the caller, restores session state from its own durable/external store, emits replay/history updates as `session/update` notifications, and returns the `session/load` response. + +The SDK guarantees only the transport plumbing for that flow: cookie state can be retained across stream instances, the HTTP server can open a session SSE for an existing session before `session/load`, and `session/load` final responses route to the connection stream. It does **not** guarantee automatic retry, liveness detection, stream resumption, or redelivery of messages emitted while the client was disconnected. + +### 6.9 Durable Session Store Example Boundary + +Examples/tests may use an in-memory `Map` outside `createAgent` to model durable state, but that is illustrative only. Production multi-node deployments must use one of: + +- sticky sessions plus process-local state, accepting restart/drain limitations; +- sticky sessions plus a durable shared store; +- no sticky sessions plus a shared durable store that any backend can load; or +- an external affinity/proxy layer that routes by a retained opaque cookie or equivalent route token. + +If a reconnect arrives without affinity and session state is only process-local, `session/load` may legitimately fail. The SDK should document this instead of hiding it behind a fake built-in persistence layer. + +### 6.10 Runtime Compatibility + +| Runtime | HTTP Server | WebSocket Server | HTTP Client | WebSocket Client | +| ------------------ | ---------------------- | ---------------- | -------------- | ---------------------- | +| Node.js 18+ | ✅ via node-adapter | ✅ via `ws` | ✅ | ✅ | +| Deno | ✅ native | ✅ native | ✅ | ✅ | +| Bun | ✅ native | ✅ native | ✅ | ✅ | +| Cloudflare Workers | ❌ no persistent state | ❌ | ✅ client only | ✅ client only | +| Browsers | N/A | N/A | ✅ | ✅ (no custom headers) | + +### 6.11 Origin Validation + +WebSocket origin validation is deferred, consistent with the Rust SDK. Origin validation is typically handled at the reverse proxy or framework middleware level. If added later, it's a backwards-compatible new option on `AcpServerOptions`. Consumers using cookie-based auth MUST enforce origin checks in their middleware — the SDK documents this requirement. + +--- + +## 7. Testing Plan + +### 7.1 Testing Approach: Standalone Test Server + +Create self-contained tests within the SDK repo using an in-process test agent: + +1. Start an ACP server using `AcpServer` with a simple test agent +2. Exercise the progressive HTTP server phases directly with `fetch` and SSE parsing +3. Exercise `createHttpStream` / `createWebSocketStream` through `ClientSideConnection` +4. Shut down every local HTTP/WebSocket server after each test + +``` +src/ + ├── protocol.test.ts # Protocol helper unit tests + ├── cookie-store.test.ts # Cookie parsing/merge/reconnect store unit tests + ├── sse.test.ts # SSE serialization/parsing unit tests + ├── connection.test.ts # Connection registry + routing unit tests + ├── node-adapter.test.ts # Node adapter conversion/streaming tests + ├── server.test.ts # HTTP server phases 1–4 + ├── http-stream.test.ts # HTTP client transport unit tests + E2E + ├── ws-stream.test.ts # WebSocket client transport unit tests + E2E + └── test-support/ + ├── test-agent.ts # Simple in-process agent + └── test-http-server.ts # Starts AcpServer + node:http server, later with WS upgrades +``` + +### 7.2 Test Scenarios + +The task plan is the source of truth for exact test ownership. The high-level coverage is: + +**HTTP server phases:** + +1. Initialize over HTTP → 200 JSON response + `Acp-Connection-Id` +2. Connection-scoped SSE + `session/new` → 202 POST response + JSON-RPC response on connection SSE +3. Session-scoped SSE + `session/prompt` → streaming `session/update` events and final response on session SSE +4. Bidirectional permission flow → agent request on session SSE, client response via POST, prompt continues + +**HTTP client transport:** 5. `createHttpStream` initializes, opens connection SSE, opens session SSE after session creation, includes custom headers, manages cookies by default, supports caller-owned cookie stores across reconnects, and sends DELETE on close 6. Full `ClientSideConnection` flow over HTTP: initialize, new session, prompt, permission, cancel, load session, and multiple sessions + +**WebSocket transport:** 7. `prepareWebSocketUpgrade` exposes `Acp-Connection-Id` on the `101` response 8. `createWebSocketStream` queues writes until open, parses JSON text frames, ignores binary frames, closes stream ends on socket close/error, applies managed cookies to Node/custom constructor headers, and captures `Set-Cookie` from upgrade response headers when exposed 9. Full `ClientSideConnection` flow over WebSocket: initialize, new session, prompt, permission, load session, and multiple sessions + +**v1 durability/affinity:** 10. A test agent backed by a store outside the per-connection agent instance can create a session, drop the first transport, initialize a new connection, call `session/load`, and replay history on the new connection 11. Shared cookie stores preserve affinity cookies across HTTP and Node WebSocket reconnect attempts + +**Error handling:** 12. Unknown connection/session → 404 13. Missing required headers → 400/406/415 as appropriate 14. Batch JSON-RPC request arrays → 501 15. POST before initialize or non-initialize without connection ID → 400 + +**External/gated validation:** 16. Real `AcpBridgeAgent` initialize, prompt streaming, permission flow, SDK client flow, reconnect/load flow, and WebSocket flow behind `ACP_INTEGRATION_TEST` 17. Relay HTTP client adapter works against the real agent 18. Alta service migration replaces bespoke HTTP dispatch with `AcpServer` + `RivetAgent` while preserving Rivet actor behavior + +--- + +## 8. Implementation Plan + +The task plan is the source of truth and intentionally decomposes the work into small vertical slices. This design follows the same phase boundaries: + +### Phase 0: Protocol and SSE Utilities + +- Create `src/protocol.ts` with internal constants and helpers: `HEADER_CONNECTION_ID`, `HEADER_SESSION_ID`, MIME constants, `methodRequiresSessionHeader`, `sessionIdFromParams`, `isInitializeRequest`, and `messageIdKey`. +- Create `src/sse.ts` with event serialization, keepalive serialization, and robust SSE parsing across chunk boundaries, comments, non-data fields, multiline data, and malformed JSON. +- Add isolated unit tests in `protocol.test.ts` and `sse.test.ts`. + +### Phase 1: Minimal Server — Initialize Over HTTP + +- Create minimal `ConnectionState` and `ConnectionRegistry` in `connection.ts`. +- Create `AcpServer` with initialize-only POST handling: validate JSON, reject batches, create the agent, forward initialize, call `recvInitial`, and return 200 JSON with `Acp-Connection-Id`. +- Create the Node HTTP adapter and local test server helper. +- Add a self-contained `TestAgent`, node adapter tests, HTTP initialize E2E tests, an Alta `AcpBridgeAgent` HTTP entrypoint, and a gated real-agent initialize test. + +### Phase 2: Connection SSE + `session/new` + +- Add `OutboundStream`, `connectionStream`, `allOutbound`, `pendingRoutes`, and the router task. +- Add GET SSE handling, POST non-initialize handling, DELETE cleanup, keepalives, replay drain, and 202 accepted responses. +- Add `OutboundStream`/routing unit tests and connection SSE + `session/new` E2E tests. + +### Phase 3: Session SSE + Prompt Streaming + +- Add `sessionStreams` and `ensureSession`. +- Route requests, notifications, and responses to session streams using header-first routing and `sessionIdFromParams` fallback. +- Reject session-scoped methods missing a session identifier. +- Add prompt streaming E2E tests, multi-session isolation tests, session buffering tests, and gated real-agent prompt streaming. + +### Phase 4: Bidirectional Permission Requests + +- Route server→client requests such as `session/request_permission` to the session stream when params include `sessionId`. +- Forward client response POSTs directly to the agent without recording pending routes. +- Add full permission flow E2E tests and gated real-agent permission validation. + +### Phase 5: HTTP Client Transport (`createHttpStream`) + +- Create `src/http-stream.ts` exported as `@agentclientprotocol/sdk/http-client`. +- Implement initialize POST, connection SSE, session SSE auto-open after session creation, session header forwarding, custom `fetch`, custom headers, DELETE-on-close, and non-blocking POST writes. +- Add HTTP client unit tests and `ClientSideConnection` E2E tests. +- Add Relay HTTP client adapter and gated real-agent SDK client tests. + +### Phase 6: WebSocket Transport + +- Add WebSocket server support with browser/Node `ws` compatibility, initialize-first protocol, `allOutbound` subscription, JSON text frame I/O, session route recording, and cleanup on close/error. +- Use the compliant pre-upgrade path (`prepareWebSocketUpgrade()` and Node upgrade adapter) so `Acp-Connection-Id` is available in the WebSocket `101` response. +- Add WebSocket upgrade support to test helpers and the Alta HTTP entrypoint. +- Create `src/ws-stream.ts` exported as `@agentclientprotocol/sdk/ws-client`, including `protocols`, best-effort `headers`, and a custom `WebSocket` constructor option. +- Add WebSocket unit/E2E tests and gated real-agent WebSocket validation. + +### Phase 7: Package Configuration and Examples + +- Add package subpath exports for `.`, `./http-client`, `./ws-client`, `./server`, `./node`, and `./schema/schema.json`. +- Add optional `ws` peer dependency and dev dependencies for `ws`/`@types/ws`. +- Verify the main entry remains browser-safe and Node-only imports stay isolated to `node-adapter.ts`. +- Add HTTP server, HTTP client, and WebSocket client examples. + +### Phase 8: Alta Service Migration — Replace Bespoke HTTP API + +- Create `alta-two/services/alta-service/src/acp/rivet-agent.ts` implementing the SDK `Agent` interface over the existing `AcpService` Effect service. +- Mount `AcpServer.handleRequest` at `/api/v1/acp` via Effect `handleRaw` for POST/GET/DELETE. +- Delete the bespoke `effect/handlers/v1.ts` dispatch layer and `acp/schemas.ts` custom request schemas. +- Wire `RivetAgent` + `AcpServer` into the Effect service graph while preserving `AcpService` as the business logic layer. +- Add Alta service E2E validation for initialize, session creation/list/load, prompt streaming, permission requests, cancellation, and cleanup. + +### Phase 9: Protocol Compliance Hardening from PR Review + +- Keep the compliant Node WebSocket upgrade helper that pre-creates a connection and adds `Acp-Connection-Id` to the `101` response. +- Enforce initialize-first WebSocket semantics and strict text-only frame parsing. +- Require `Acp-Session-Id` for session-scoped HTTP POSTs and reject mismatches with `params.sessionId`. +- Keep RFD-compatible `session/load` routing: open/ensure the session stream for replay/update notifications, but route the final `session/load` response to the connection-scoped stream. +- Keep exact JSON content-type validation and the documented HTTP/2 policy decision. + +### Phase 10: v1 Durability and Affinity Support + +- Create `src/cookie-store.ts` with `AcpCookieStore` and `MemoryAcpCookieStore`; move the existing private HTTP cookie parsing/merge logic there and re-export it from `@agentclientprotocol/sdk/http-client` and `@agentclientprotocol/sdk/ws-client`. +- Update `createHttpStream` so `cookieStore` may be caller-owned and reused across streams. The transport uses cookies by default, stores/applies cookies only when `cookies !== "omit"`, and clears only SDK-owned ephemeral stores automatically. +- Update `createWebSocketStream` so Node/custom WebSocket constructors receive managed cookies in constructor headers and upgrade `Set-Cookie` response headers are captured when the constructor exposes them. Document that browser WebSocket relies on the platform cookie jar. +- Add tests for shared cookie-store reuse across HTTP reconnects, WebSocket cookie header application and upgrade cookie capture, `cookies: "omit"`, and caller `Cookie` header override semantics. +- Add an SDK-only durable-session example/test where session state lives in a `Map` outside the per-connection agent instance, a first connection creates a session, a second connection calls `session/load`, and history replay arrives on the new connection. +- Update examples/JSDoc to show reconnect state (`sessionId`, auth headers, cookie store) living outside stream instances and to state that in-flight transport messages are not replayed in v1. + +### Cross-Cutting Hardening + +- Preserve the explicit HTTP-vs-WebSocket transport selection model; no auto-negotiation. +- Keep full RFC6265 cookie behavior out of scope; the SDK cookie store is a minimal affinity helper, not a browser-grade jar. +- Keep durable session persistence out of the SDK; agents/deployments own the store and retention policy. +- Keep server-side Cloudflare Workers support out of scope until an external connection registry/store exists. +- Reject batch JSON-RPC request arrays with 501. +- Keep buffers bounded at 1024 messages per stream with oldest-first eviction. + +--- + +## 9. Resolved Design Questions + +1. **Should `createHttpStream` auto-detect and prefer WebSocket?** + **No.** Separate explicit factory functions. The RFD requires clients to _support_ both, not auto-negotiate. + +2. **Authentication header forwarding.** + Client-side: the `headers` option covers Bearer tokens. Server-side: consumers close over auth context in the factory, or validate in middleware before the transport. + +3. **Should the factory receive the HTTP `Request`?** + **No.** Aligned with the Rust SDK's `Fn() -> C` pattern. Auth validation belongs in middleware, not in the transport's factory. This also eliminates the sync-vs-async factory debate. + +4. **Should the SDK build a cookie jar?** + **Yes, narrowly.** The SDK provides a minimal `AcpCookieStore` / `MemoryAcpCookieStore` for ACP affinity cookies and reconnect continuity. It is not a full RFC6265 jar; consumers that need domain/path/expiry enforcement can still provide platform/native cookie handling or a custom `AcpCookieStore`. + +5. **Should we support Cloudflare Workers?** + **Client-side only.** Workers lack persistent in-memory state for the connection registry. Server-side Workers support would require an externalized state store — out of scope for v1. + +6. **Batch JSON-RPC.** + **Actively reject with 501**, as the RFD specifies. Matches Rust SDK behavior. + +7. **Origin validation on WebSocket upgrades.** + **Deferred.** Not implemented in the Rust SDK either. Document that cookie-authenticated WebSocket requires origin checks in middleware/proxy. + +8. **Should we name it `AcpHttpTransport` or `AcpHttpServer`?** + **`AcpServer`.** It handles both HTTP and WebSocket — the transport type is an implementation detail, not part of the name. The Rust SDK uses `AcpHttpServer` but we improve on that. + +9. **How should outbound message buffering work?** + **Bounded at 1024 messages per stream, oldest-first eviction.** Directly ported from Rust SDK's `broadcast::channel(1024)` + `VecDeque` replay pattern. Prevents unbounded memory growth. + +10. **How should `session/load` responses be routed?** + **Replay/update messages route to the session stream; the final `session/load` response routes to the connection stream.** The client may open the session-scoped SSE stream for an existing `sessionId` before POSTing `session/load`; the server ensures that stream exists on the new connection. This matches the updated RFD even though it differs from Rust's current pending-route behavior. + +11. **Should the SDK own durable session storage?** + **No.** The SDK owns transport connection state only. Agents that advertise `loadSession` must persist/reconstruct their own session state and authorize `session/load` against the authenticated principal. SDK tests can use an external `Map` to prove the transport flow, but that map represents agent/deployment storage. diff --git a/acp-web-transport/rfd.md b/acp-web-transport/rfd.md new file mode 100644 index 00000000..194fab06 --- /dev/null +++ b/acp-web-transport/rfd.md @@ -0,0 +1,402 @@ +> ## Documentation Index +> +> Fetch the complete documentation index at: https://agentclientprotocol.com/llms.txt +> Use this file to discover all available pages before exploring further. + +# Streamable HTTP & WebSocket Transport + +- Author(s): [alexhancock](https://github.com/alexhancock), [jh-block](https://github.com/jh-block) +- Champion: [anna239](https://github.com/anna239) + +## Elevator pitch + +> What are you proposing to change? + +ACP needs a standard remote transport. We propose **long-lived GET streams** for server→client messages (one connection-scoped plus one per session), with **POST** for client→server messages, and **WebSocket upgrade** as an alternative on the same endpoint. A single `/acp` endpoint supports two connectivity profiles: + +- **Streamable HTTP (POST/GET/DELETE)** — Long-lived SSE streams per connection: one connection-scoped stream for connection-level server→client messages, plus one session-scoped stream per session for session-level messages. POST requests return immediately (202 Accepted, except `initialize`). Requires HTTP/2. +- **WebSocket upgrade (GET with `Upgrade: websocket`)** — persistent, full-duplex, low-latency bidirectional messaging. + +Clients that support remote ACP over HTTP MUST support both Streamable HTTP and WebSocket. This allows servers to support only WebSocket if they choose, simplifying deployment. + +Both profiles share the same JSON-RPC message format and ACP lifecycle as the existing **stdio** local subprocess transport. + +## Status quo + +> How do things work today and what problems does this cause? Why would we change things? + +ACP only has stdio. There is no standard remote transport, which causes fragmentation as implementers invent their own HTTP layers, leading to incompatible SDKs and deployments. + +## What we propose to do about it + +> What are you proposing to improve the situation? + +### 1. Adds an HTTP Transport + +ACP adopts a streamable HTTP transport with three key characteristics: + +1. **Long-lived GET streams (one connection-scoped, one per session)** — All server→client messages (responses to requests and unsolicited notifications) are delivered via SSE streams opened with GET. The **connection-scoped stream** (scoped to `Acp-Connection-Id`) carries connection-level messages: responses to `session/new` and `session/load` (which the client cannot receive on a session-scoped stream because it does not yet have a `sessionId`), and any server-initiated messages not tied to a specific session. The **session-scoped stream** (scoped to `Acp-Connection-Id` + `Acp-Session-Id`) carries all messages for a single session: session update notifications, server-to-client requests like `request_permission`, and responses to session-scoped POSTs like `session/prompt` and `session/cancel`. Responses are correlated to the POST that originated them by JSON-RPC `id`. + +2. **POST requests return immediately (except initialize)** — Client→server messages are sent via POST. Most POST requests return `202 Accepted` immediately with an empty body. The actual response comes later on the appropriate GET stream, correlated by JSON-RPC `id`. The `initialize` request is special: it returns `200 OK` with a JSON response body containing capabilities and the `Acp-Connection-Id`. The `Acp-Connection-Id` is also included in the response header. + +3. **Requires HTTP/2** — Streamable HTTP transport MUST use HTTP/2. This provides multiplexing for concurrent POST requests while maintaining long-lived GET streams (one connection-scoped plus one per session), and improves efficiency for high-frequency message exchanges. + +### 4. Adds WebSocket as a first-class upgrade on the same endpoint + +A GET with `Upgrade: websocket` upgrades to a persistent bidirectional channel — same endpoint, same lifecycle model. + +This is important for ACP, as it is more bidirectional in its nature as a protocol. + +### 5. Requires cookie support on HTTP transports + +Clients MUST accept, store, and return cookies set by the server on all HTTP-based transports (Streamable HTTP and WebSocket). Cookies MUST be sent on subsequent requests to the server for the duration of the connection. Clients MAY discard all cookies when a connection is terminated. This allows servers to rely on cookies for session affinity (e.g., sticky sessions behind a load balancer) and other small amounts of per-connection state. + +### 6. Defines a unified routing model + +| Method | Upgrade Header? | Behavior | +| -------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `POST` | — | Send JSON-RPC message. `initialize` returns 200 with JSON body. All others return 202 Accepted immediately. | +| `GET` | No | Open SSE stream. `Acp-Connection-Id` alone → connection-scoped stream. `Acp-Connection-Id` + `Acp-Session-Id` → session-scoped stream. | +| `GET` | `Upgrade: websocket` | Upgrade to WebSocket for full-duplex messaging | +| `DELETE` | — | Terminate the connection | + +### 7. Preserves the full ACP lifecycle + +The `initialize` → `initialized` → messages → close lifecycle is identical regardless of transport. The `Acp-Connection-Id` binds requests to the initialized connection and its negotiated capabilities. Session identity is carried in JSON-RPC message bodies via the `sessionId` field. + +## Shiny future + +> How will things play out once this feature exists? + +- **SDK implementers** get a clear, testable transport spec — Rust, TypeScript, and Python SDKs can all interoperate. +- **Desktop clients** use WebSocket for low-latency streaming; all clients support it as a baseline. +- **Cloud deployments** expose agents behind standard HTTP load balancers using the stateless-friendly HTTP mode, with cookie-based sticky sessions guaranteed by client support. +- **Proxy chains** can route ACP traffic over HTTP for multi-hop agent topologies. + +## Implementation details and plan + +> Tell me more about your implementation. What is your detailed implementation plan? + +### Transport Architecture + +``` + ┌─────────────────────────────────┐ + │ /acp endpoint │ + └──────┬──────────┬───────────────┘ + │ │ + ┌───────────▼──┐ ┌────▼──────────────┐ + │ HTTP State │ │ WebSocket State │ + │(connections) │ │ (connections) │ + └───────┬──────┘ └────┬──────────────┘ + │ │ + ┌───────▼──────────────▼───────────────┐ + │ ACP Agent (JSON-RPC handler) │ + │ serve(agent, read, write) │ + └─────────────────────────────────────┘ +``` + +### Identity Model + +ACP over Streamable HTTP uses two HTTP headers for connection and session identity, plus JSON-RPC message fields: + +- **`Acp-Connection-Id`** (HTTP header) — Transport-level identifier returned by the server in the `initialize` response. Required on all HTTP requests after `initialize` and on every GET stream (both connection-scoped and session-scoped). Binds requests to an initialized connection and its negotiated capabilities. +- **`Acp-Session-Id`** (HTTP header) — Session-level identifier returned in the `session/new` response body. Required on all session-scoped POST requests (`session/prompt`, `session/cancel`, permission responses, etc.) and on the session-scoped GET stream. +- **`sessionId`** (JSON-RPC field) — Session-level identifier also included in JSON-RPC `params` for session-scoped methods and in responses on the GET streams. A single connection may host multiple sessions, each with its own `sessionId` and its own session-scoped GET stream. + +### Streamable HTTP Message Flow + +``` +Client Server + │ │ + │ ═══ Connection Initialization ═══ │ + │ │ + │─── POST /acp ─────────────────────>│ { method: "initialize", id: 1 } + │ Content-Type: application/json │ (no Acp-Connection-Id header) + │ │ + │<────── 200 OK ─────────────────────│ { id: 1, result: { capabilities, connectionId } } + │ Acp-Connection-Id: │ Response includes Acp-Connection-Id header + │ Content-Type: application/json │ + │ │ + │ ═══ Open Connection-Scoped GET ═══│ + │ │ + │─── GET /acp ──────────────────────>│ Open long-lived connection-scoped SSE stream + │ Acp-Connection-Id: │ for connection-level server→client messages + │ Accept: text/event-stream │ (no Acp-Session-Id header) + │ ┌─────────────────────│ (SSE stream open) + │ │ │ + │ │ │ + │ ═══ Session Creation ═══ │ + │ │ + │─── POST /acp ─────────────────────>│ { method: "session/new", id: 2, + │ Acp-Connection-Id: │ params: { cwd, mcpServers } } + │ │ + │<────── 202 Accepted ───────────────│ (returns immediately) + │ │ │ + │<─────────────│─ SSE event ─────────│ { id: 2, result: { sessionId: "sess_abc123" } } + │ │ │ (response on connection-scoped stream) + │ │ │ + │ ═══ Open Session-Scoped GET ═══ │ + │ │ + │─── GET /acp ──────────────────────>│ Open long-lived session-scoped SSE stream + │ Acp-Connection-Id: │ for sess_abc123 + │ Acp-Session-Id: sess_abc123 │ + │ Accept: text/event-stream │ + │ ┌─────────────────────│ (SSE stream open) + │ │ │ + │ ═══ Prompt Flow ═══ │ (all events below arrive on the session-scoped stream) + │ │ + │─── POST /acp ─────────────────────>│ { method: "session/prompt", id: 3, + │ Acp-Connection-Id: │ params: { sessionId: "sess_abc123", prompt } } + │ Acp-Session-Id: sess_abc123 │ + │ │ + │<────── 202 Accepted ───────────────│ (returns immediately) + │ │ │ + │<─────────────│─ SSE event ─────────│ notification: AgentMessageChunk (sessionId: "sess_abc123") + │<─────────────│─ SSE event ─────────│ notification: AgentThoughtChunk (sessionId: "sess_abc123") + │<─────────────│─ SSE event ─────────│ notification: ToolCall (sessionId: "sess_abc123") + │<─────────────│─ SSE event ─────────│ notification: ToolCallUpdate (sessionId: "sess_abc123") + │<─────────────│─ SSE event ─────────│ notification: AgentMessageChunk (sessionId: "sess_abc123") + │<─────────────│─ SSE event ─────────│ { id: 3, result: { sessionId: "sess_abc123", ... } } + │ │ │ (response comes on GET stream) + │ │ │ + │ ═══ Permission Flow ═══ │ + │ (when tool requires confirmation) │ + │ │ + │─── POST /acp ─────────────────────>│ { method: "session/prompt", id: 4, ... } + │ Acp-Connection-Id: │ + │ Acp-Session-Id: sess_abc123 │ + │ │ + │<────── 202 Accepted ───────────────│ + │ │ │ + │<─────────────│─ SSE event ─────────│ notification: ToolCall (sessionId: "sess_abc123") + │<─────────────│─ SSE event ─────────│ { method: "request_permission", id: 99, + │ │ │ params: { sessionId: "sess_abc123", ... } } + │ │ │ (server-to-client request) + │─── POST /acp ────────────────────>│ { id: 99, result: { outcome: "allow_once" } } + │ Acp-Connection-Id: │ (client response) + │ Acp-Session-Id: sess_abc123 │ + │ │ + │<────── 202 Accepted ───────────────│ + │ │ │ + │<─────────────│─ SSE event ─────────│ notification: ToolCallUpdate (sessionId: "sess_abc123") + │<─────────────│─ SSE event ─────────│ { id: 4, result: { sessionId: "sess_abc123", ... } } + │ │ │ (response comes on GET stream) + │ │ │ + │ ═══ Cancel Flow ═══ │ + │ │ + │─── POST /acp ─────────────────────>│ { method: "session/prompt", id: 5, ... } + │ Acp-Connection-Id: │ + │ Acp-Session-Id: sess_abc123 │ + │ │ + │<────── 202 Accepted ───────────────│ + │ │ │ + │<─────────────│─ SSE event ─────────│ notification: AgentMessageChunk (sessionId: "sess_abc123") + │ │ │ + │─── POST /acp ─────────────────────>│ { method: "session/cancel", + │ Acp-Connection-Id: │ params: { sessionId: "sess_abc123" } } + │ Acp-Session-Id: sess_abc123 │ + │ │ + │<────── 202 Accepted ───────────────│ (notification, no id) + │ │ │ + │<─────────────│─ SSE event ─────────│ { id: 5, result: { sessionId: "sess_abc123", ... } } + │ │ │ (response comes on GET stream) + │ │ │ + │ ═══ Resume Session Flow ═══ │ + │ (new connection, existing session)│ + │ │ + │─── POST /acp ─────────────────────>│ { method: "initialize", id: 1 } + │ (no Acp-Connection-Id) │ New connection + │<────── 200 OK ─────────────────────│ { id: 1, result: { capabilities, connectionId } } + │ Acp-Connection-Id: │ + │ │ + │─── GET /acp ──────────────────────>│ Open new connection-scoped GET stream + │ Acp-Connection-Id: │ + │ ┌─────────────────────│ (SSE stream open) + │ │ │ + │─── GET /acp ──────────────────────>│ Open session-scoped GET stream for sess_abc123 + │ Acp-Connection-Id: │ + │ Acp-Session-Id: sess_abc123 │ + │ ┌─────────────────────│ (SSE stream open) + │ │ │ + │─── POST /acp ─────────────────────>│ { method: "session/load", id: 2, + │ Acp-Connection-Id: │ params: { sessionId: "sess_abc123", cwd } } + │ Acp-Session-Id: sess_abc123 │ + │ │ + │<────── 202 Accepted ───────────────│ + │ │ │ + │<─────────────│─ SSE event ─────────│ notification: UserMessageChunk (on session-scoped stream) + │<─────────────│─ SSE event ─────────│ notification: AgentMessageChunk (on session-scoped stream) + │<─────────────│─ SSE event ─────────│ notification: ToolCall (on session-scoped stream) + │<─────────────│─ SSE event ─────────│ notification: ToolCallUpdate (on session-scoped stream) + │<─────────────│─ SSE event ─────────│ { id: 2, result: { sessionId: "sess_abc123" } } + │ │ │ (response on connection-scoped stream) + │ │ │ + │ ═══ Connection Termination ═══ │ + │ │ + │─── DELETE /acp ───────────────────>│ Terminate connection + │ Acp-Connection-Id: │ + │<────────── 202 Accepted ───────────│ + │ ▼ │ (GET stream closes) +``` + +#### Content Negotiation and Validation + +- POST `Content-Type` **MUST** be `application/json` (415 otherwise). +- GET `Accept` **MUST** include `text/event-stream` (406 otherwise). +- POST requests for session-scoped operations **MUST** include both `Acp-Connection-Id` and `Acp-Session-Id` headers. +- GET requests without `Upgrade: websocket` **MUST** include `Acp-Connection-Id`. If `Acp-Session-Id` is also present, the stream is session-scoped; otherwise it is connection-scoped. An unknown `Acp-Session-Id` for the given connection returns 404. +- Batch JSON-RPC requests return 501. +- HTTP/2 is **REQUIRED** for Streamable HTTP transport. + +### WebSocket Request Flow + +#### Connection Establishment (GET with Upgrade) + +``` +Client Server + │ GET /acp │ + │ Upgrade: websocket │ + │────────────────────────────────────────►│ + │ HTTP 101 Switching Protocols │ + │ Acp-Connection-Id: │ + │◄────────────────────────────────────────│ + │ ══════ WebSocket Channel ══════════════│ +``` + +A new connection is created on upgrade. The `Acp-Connection-Id` is returned in the upgrade response headers. The client must still send `initialize` as the first JSON-RPC message over the WebSocket to negotiate capabilities before creating sessions. + +#### Bidirectional Messaging + +All messages are WebSocket text frames containing JSON-RPC. Binary frames are ignored. On disconnect, the server cleans up the connection and any associated sessions. + +### Unified Endpoint Routing + +``` +GET /acp + ├── Has Upgrade: websocket? → WebSocket handler + └── No → SSE stream handler + ├── Missing Acp-Connection-Id? → 400 Bad Request + ├── Unknown Acp-Connection-Id? → 404 Not Found + ├── Has Acp-Session-Id unknown for this connection? → 404 Not Found + ├── Has Acp-Session-Id → Open session-scoped SSE stream + └── No Acp-Session-Id → Open connection-scoped SSE stream + +POST /acp + ├── Initialize request (no Acp-Connection-Id)? → Create connection, return 200 with JSON + ├── No Acp-Connection-Id? → 400 Bad Request + ├── Unknown Acp-Connection-Id? → 404 Not Found + ├── Session-scoped request missing Acp-Session-Id? → 400 Bad Request + └── Has valid Acp-Connection-Id (and Acp-Session-Id if required) → Forward to agent, return 202 Accepted + +DELETE /acp + ├── Has Acp-Connection-Id? → Terminate connection and all associated sessions, return 202 + └── No Acp-Connection-Id? → 400 Bad Request +``` + +### Connection and Session Model + +``` +Connection { + connection_id: String, // Acp-Connection-Id + capabilities: NegotiatedCapabilities, + sessions: HashMap, // keyed by sessionId (JSON-RPC field) + get_stream: Option, // Connection-scoped GET stream + to_agent_tx: mpsc::Sender, + from_agent_rx: Arc>>, + handle: JoinHandle<()>, +} + +Session { + session_id: String, // sessionId (JSON-RPC field) + get_stream: Option, // Session-scoped GET stream + // session-specific state +} +``` + +The agent task is spawned once per connection. Server→client messages are routed to either the connection-scoped GET stream or the appropriate session-scoped GET stream based on whether the message is tied to a specific session. Sessions are identified by the `sessionId` field in JSON-RPC messages. The transport layer adapts channels to the wire format (SSE events for HTTP, text frames for WebSocket). + +### Comparing to MCP Streamable HTTP + +| MCP Requirement | ACP Implementation | Status | +| ------------------------------------------ | ------------------------------------------- | -------------------- | +| POST for all client→server messages | ✅ | Compliant | +| Accept header validation (406) | ✅ | Compliant | +| Notifications/responses return 202 | ✅ (except `initialize` returns 200) | Mostly compliant | +| Requests return SSE stream | ❌ (long-lived GET streams instead) | Documented deviation | +| Session ID on initialize response | ✅ (`Acp-Connection-Id`) | Compliant (renamed) | +| Session ID required on subsequent requests | ✅ (`Acp-Connection-Id` + `Acp-Session-Id`) | Compliant (extended) | +| GET opens SSE stream | ✅ (connection-scoped + session-scoped) | Compliant (extended) | +| DELETE terminates session | ✅ (terminates connection) | Compliant | +| 404 for unknown sessions | ✅ (unknown connection IDs) | Compliant | +| Batch requests | ❌ (returns 501) | Documented deviation | +| Resumability (Last-Event-ID) | ❌ | Future work | +| Protocol version header | ❌ | Future work | + +### Deviations from MCP Streamable HTTP + +1. **Long-lived GET streams (connection-scoped + per-session)**: MCP opens a new SSE stream for each request response. ACP uses long-lived GET streams per connection — one connection-scoped stream plus one session-scoped stream per session. POST requests (except `initialize`) return 202 Accepted immediately, and responses arrive on the appropriate GET stream correlated by JSON-RPC `id`. +2. **Initialize returns JSON directly**: MCP's `initialize` returns an SSE stream. ACP's `initialize` returns `200 OK` with a JSON response body containing capabilities and `connectionId`. The `Acp-Connection-Id` is also included in the response header. +3. **HTTP/2 required**: ACP requires HTTP/2 for multiplexing concurrent POST requests alongside the long-lived GET stream. +4. **Two-header model**: ACP uses both `Acp-Connection-Id` (for connection identity) and `Acp-Session-Id` (for session identity on POST requests and on the session-scoped GET stream). MCP only uses `Mcp-Session-Id`. This allows ACP to distinguish connection-level state from session-level operations while supporting multiple concurrent sessions on one connection. +5. **WebSocket extension**: MCP doesn't define WebSocket. ACP adds it as a required client capability. Clients MUST support WebSocket, and servers MAY choose to only support WebSocket connections. +6. **Cookie support required**: Clients MUST handle cookies on HTTP transports for the duration of the connection, enabling sticky sessions and per-connection server state. +7. **No batch requests**: Returns 501. May be added later. +8. **No resumability yet in reference implementation**: SSE event IDs and `Last-Event-ID` resumption planned as follow-up. + +### Implementation Plan + +1. **Phase 1 — Specification** (this RFD): Define the transport spec and align terminology. +2. **Phase 2 — Reference Implementation** (in progress): Working implementation in Goose (`block/goose`). +3. **Phase 3 — SDK Support**: Add Streamable HTTP and WebSocket client support to Rust SDK (`sacp`), then TypeScript SDK. +4. **Phase 4 — Hardening**: Origin validation, `Acp-Protocol-Version`, SSE resumability, batch requests, security audit. + +## Frequently asked questions + +> What questions have arisen over the course of authoring this document or during subsequent discussions? + +### Why not just use MCP Streamable HTTP as-is? + +MCP opens a new SSE stream for each request response, which creates many short-lived connections and complicates load balancing. ACP uses long-lived GET streams per connection (one connection-scoped plus one per session), dramatically reducing connection count and simplifying sticky session routing. This is better suited for ACP's bidirectional, multi-session nature. + +### How are sessions identified? + +ACP uses `Acp-Connection-Id` in HTTP headers to identify the connection, and `Acp-Session-Id` (plus the `sessionId` JSON-RPC field) to identify sessions. A single connection may host multiple sessions. The connection-scoped GET stream delivers connection-level messages; each session-scoped GET stream delivers messages for exactly one session. + +### Why add WebSocket support? + +ACP is highly bidirectional with frequent streaming updates. WebSocket provides true bidirectional messaging with lower per-message overhead than HTTP. Clients MUST support WebSocket so that servers can choose to only support WebSocket connections, simplifying deployment. Streamable HTTP remains available as an additional option for environments where WebSocket is not viable on the server side (e.g., serverless). + +### How does the server distinguish WebSocket from SSE on GET? + +By inspecting the `Upgrade: websocket` header. This is standard HTTP behavior. + +### Can a client have multiple sessions on one connection? + +Yes. A client may call `session/new` multiple times within a single `Acp-Connection-Id`. Each returns a distinct `sessionId` in the response body (delivered on the connection-scoped GET stream). For each session, the client opens a separate session-scoped GET stream using `Acp-Connection-Id` + `Acp-Session-Id`. + +### What alternative approaches did you consider, and why did you settle on this one? + +- **Per-request SSE streams (like MCP)**: Rejected — creates too many long-lived connections, complicates load balancing, and wastes resources. +- **Separate endpoints** (`/acp/http`, `/acp/ws`): Rejected — single endpoint is simpler; WebSocket upgrade is natural HTTP. +- **WebSocket only**: Rejected — doesn't work through all proxies. +- **Single connection-scoped GET stream with JSON-RPC demuxing**: Rejected — forces both server and client to parse JSON-RPC bodies to route by session, couples all sessions' backpressure together, and makes per-session resume/reconnect awkward. Splitting into a connection-scoped stream plus per-session streams keeps all session-level routing on HTTP headers. + +### How does this interact with authentication? + +Authentication (see auth-methods RFD) is orthogonal and layered on top via HTTP headers, query parameters, or WebSocket subprotocols. `Acp-Connection-Id` and `Acp-Session-Id` are transport-level identifiers, not auth tokens. + +### What about the `Acp-Protocol-Version` header? + +Clients SHOULD include it on all requests after initialization. Not yet implemented; part of Phase 4 hardening. + +### Why require HTTP/2? + +HTTP/2 provides multiplexing, allowing many concurrent POST requests alongside the long-lived GET streams (one connection-scoped plus one per active session) on a single TCP connection. This is essential for efficient operation with the long-lived-stream model. HTTP/1.1 would require separate TCP connections for each concurrent POST and each GET stream, defeating the efficiency gains. + +## Revision history + +- **2025-03-10**: Initial draft based on the RFC template and goose reference implementation. +- **2026-04-01**: Introduced a two-header identity model: `Acp-Connection-Id` (returned at `initialize`, binds to the connection) and `Acp-Session-Id` (returned at `session/new`, scopes to a session). This addresses feedback that the original single `Acp-Session-Id` conflated transport binding with ACP session identity, and enables session-scoped GET listener streams for targeted server-to-client event delivery. Removed connection-scoped GET streams — all GET SSE listeners now require both `Acp-Connection-Id` and `Acp-Session-Id`. +- **2026-04-15**: Minor edits +- **2026-04-23**: Major revision to single long-lived GET stream model. Changed from per-request SSE streams to a single connection-scoped GET stream for all server→client messages. POST requests (except `initialize`) now return 202 Accepted immediately. `initialize` returns 200 OK with JSON response body. Required HTTP/2 for multiplexing. This change makes the HTTP usage more similar to WebSocket and supports better the bidirectional nature of ACP. +- **2026-05-04**: Split the single GET stream into two: a connection-scoped stream (GET with `Acp-Connection-Id`) for connection-level messages such as responses to `session/new` and `session/load`, and session-scoped streams (GET with `Acp-Connection-Id` + `Acp-Session-Id`) for session updates, server-to-client requests like `request_permission`, and responses to session-scoped POSTs. Routing happens on HTTP headers rather than JSON-RPC body inspection; per-session streams have independent lifetimes. diff --git a/acp-web-transport/tasks.md b/acp-web-transport/tasks.md new file mode 100644 index 00000000..01c1ce55 --- /dev/null +++ b/acp-web-transport/tasks.md @@ -0,0 +1,1701 @@ +--- +Status: IN PROGRESS +Author: Federico Ciner +Date: 2026-05-18 +Last-Audited: 2026-06-08 +--- + +# Streamable HTTP & WebSocket Transport for ACP TypeScript SDK + +## Problem Statement + +The ACP TypeScript SDK (`@agentclientprotocol/sdk`) only supports stdio transport via `ndJsonStream`. There is no standard remote transport, forcing consumers like Goose to build their own HTTP client implementations externally. The [ACP RFD for Streamable HTTP & WebSocket Transport](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/rfds/streamable-http-websocket-transport.mdx) defines a standard remote transport covering both client-side transports for connecting to remote agents and server-side transport for hosting agents over HTTP/WebSocket. + +## Technical Context + +### SDK Architecture + +The TypeScript SDK is a standalone, pure TypeScript implementation. The core transport abstraction is `Stream` in `src/stream.ts`: + +```typescript +type Stream = { + writable: WritableStream; + readable: ReadableStream; +}; +``` + +`AgentSideConnection` and `ClientSideConnection` accept a `Stream`. Transport implementations only need to produce a `Stream`; the core `Connection` class remains transport-agnostic. + +### Updated Design Direction + +This task plan is aligned with `.plan/changes/acp-web-transport/design.md`, which now intentionally follows the Rust SDK HTTP/WS implementation patterns unless the TypeScript ecosystem benefits from a different approach. + +Key decisions: + +1. **Rust-aligned architecture** — use a `ConnectionRegistry`, per-connection router task, `pendingRoutes`, bounded `OutboundStream`s, `sessionIdFromParams`, and `methodRequiresSessionHeader`. +2. **Server API is `AcpServer`** — not `AcpHttpTransport`; it handles both Streamable HTTP and WebSocket. +3. **Simple factory first** — `createAgent: (conn: AgentSideConnection) => Agent`. No `Request` parameter and no async factory for v1. Auth/tenant selection happens in middleware or by choosing/constructing the appropriate `AcpServer` before routing to it. +4. **Minimal reconnect-scoped cookie store** — the SDK exposes `AcpCookieStore` / `MemoryAcpCookieStore` so HTTP and Node/custom WebSocket clients can preserve affinity cookies across reconnects. This is a narrow ACP routing helper, not a full RFC6265 browser-grade jar. +5. **No Cloudflare Workers server support in v1** — server-side transport requires persistent in-memory connection state. Workers are client-only unless/until an external registry/store is designed. +6. **Subpath exports only for new transports** — do not re-export server/node transports from the main `@agentclientprotocol/sdk` entry point. +7. **Bounded buffering** — per-stream replay buffers hold at most 1024 messages and evict oldest messages on overflow. +8. **Explicit transport selection** — `createHttpStream` and `createWebSocketStream` remain separate; no auto-negotiation. + +### Shared vs. Transport-Specific Architecture + +HTTP and WebSocket transports share ~65-70% of server-side infrastructure. The transport-specific code is a thin I/O adapter on top. + +**Shared infrastructure (both transports):** + +- `protocol.ts` — constants, method classification, session ID extraction. +- `ConnectionRegistry` — connection lifecycle management. +- `ConnectionState` — synthetic `Stream` pair connecting `AgentSideConnection` to transport, agent lifecycle. +- `OutboundStream` — bounded broadcast with replay buffer (1024 messages). +- Router task — classifies outbound messages and pushes to appropriate streams. +- `pendingRoutes` — maps JSON-RPC request IDs to route destinations. +- `recvInitial()` — reads first outbound message for initialize response. +- `ensureSession()` — lazily creates session-scoped `OutboundStream`s. + +**HTTP-specific (thin layer):** + +- SSE serialization (`sse.ts`). +- `handleRequest()` — POST/GET/DELETE dispatch, HTTP headers, status codes, SSE response bodies. +- Two-stream model: connection-scoped SSE + per-session SSE. + +**WebSocket-specific (thinner layer):** + +- `prepareWebSocketUpgrade()` / Node upgrade adapter — pre-creates a connection and exposes `Acp-Connection-Id` on the `101` response. +- `ws-server.ts` — text frame I/O, subscribes to `allOutbound` (one unified stream). +- WS adapter normalizing browser `WebSocket` vs Node `ws` event APIs. + +The router always fans out to all three streams regardless of transport: + +``` +Router → connectionStream (HTTP subscribes) + → sessionStreams[id] (HTTP subscribes) + → allOutbound (WebSocket subscribes) +``` + +### External E2E Test References + +| Component | Location | Role | +| ------------------------ | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| ACP STDIO server (agent) | `alta/packages/acp-transport-stdio` | Existing `Agent` implementation (`AcpBridgeAgent`) for real-agent E2E testing | +| Relay STDIO client | `relay/src/acp/client.ts` | Reference client implementation to port from STDIO to HTTP | +| Relay SDK wrapper | `relay/src/acp/sdk.ts` | Lightweight `ClientSideConnection` wrapper over STDIO ndjson | +| SDK ExampleAgent | `typescript-sdk/src/examples/agent.ts` | Self-contained test agent with no external dependencies | +| SDK ExampleClient | `typescript-sdk/src/examples/client.ts` | Self-contained test client over STDIO | +| STDIO E2E test | `alta/packages/acp-transport-stdio/tests/integration/stdio-e2e.test.ts` | Reference pattern for gated integration tests | + +## Dependencies + +- **Existing:** `zod` peer dependency. +- **New optional peer dependency:** `ws` for Node.js WebSocket server support only. +- **New dev dependencies:** `ws` and `@types/ws` for tests/type-checking only. +- **Runtime APIs:** `Request`, `Response`, `ReadableStream`, `WritableStream`, `WebSocket`, `fetch`, `TextEncoder`, `TextDecoder`, `crypto.randomUUID`. +- **Node.js built-ins:** `node:stream`, `node:http` types for `node-adapter.ts` only. + +## Out of Scope + +- SSE resumability via `Last-Event-ID` and protocol-level message replay. +- `Acp-Protocol-Version` header. +- Built-in WebSocket origin validation. +- Batch JSON-RPC support — reject with 501. +- Rate limiting / max connections — consumer middleware responsibility. +- Async/per-request agent factory. +- Server-side Cloudflare Workers support. +- Alta-specific integration beyond E2E validation (production deployment patterns, multi-tenant routing, etc.). +- Built-in durable session persistence. Agents/deployments own session state, retention, and authorization for `session/load`. +- Full RFC6265 cookie semantics (domain/path matching, expiry, Secure/SameSite enforcement). The SDK cookie store is a minimal affinity helper only. + +## Current Progress + +As of 2026-06-08: + +- SDK Phases 0–4 are implemented in source/tests for the server-side HTTP transport. +- Phase 3 session SSE coverage lives in `src/server-session-sse.test.ts` to keep behavior tests separate from the earlier server tests. +- The Node adapter flushes response headers before streaming SSE bodies so clients can attach before the first event arrives. +- Manual Alta CLI HTTP smoke passed against a dev-published `@atlassian/agentclientprotocol-sdk` build. The smoke validated: + - `initialize` over HTTP, + - connection SSE, + - `session/new` response on connection SSE, + - session SSE, + - `session/prompt` updates and final response on session SSE, + - `params.sessionId` fallback routing, + - replay of prompt events produced before session SSE attaches, + - tool permission requests over session SSE, + - permission approval responses POSTed back to the agent, + - prompt continuation after permission approval, + - connection cleanup via `DELETE`. +- Manual Alta CLI HTTP smoke has now validated Phase 4 tool permission requests end-to-end; current Alta validation is documented in `SMOKE_TEST.md`, including the permission-flow curl recipe. +- Real-agent Alta HTTP and WebSocket E2E validation is complete via manual smoke testing; automated gated integration tests remain optional future hardening. +- SDK-only Phase 5 is implemented for `createHttpStream`, including the `@agentclientprotocol/sdk/http-client` export and local unit/E2E coverage for initialize, connection SSE, session SSE, close cleanup, prompt streaming, permission requests, and multiple sessions. +- Relay Phase 5.4 is implemented in `/Users/fciner/code/atlassian/relay` using `@agentclientprotocol/sdk/http-client#createHttpStream` from the dev-published `@atlassian/agentclientprotocol-sdk@0.21.1-dev.fciner.20260518214959` package. The Relay HTTP client path passed automated Relay checks and manual Alta HTTP agent ↔ Relay E2E validation. +- SDK Phase 6 WebSocket transport is implemented for WebSocket server/client paths and `createWebSocketStream`, including the `@agentclientprotocol/sdk/ws-client` export, shared WebSocket compatibility utilities, test HTTP server upgrade wiring, and local unit/E2E coverage for initialize, prompt streaming, permission requests, binary/malformed frame handling, queued writes before open, socket close handling, and multiple sessions over one WebSocket. The initial `AcpServer.handleWebSocket` entrypoint was later superseded by the Phase 9 pre-upgrade helper. +- Alta Phase 6.6 WebSocket upgrade wiring is implemented in `/Users/fciner/code/atlassian/alta/packages/acp-transport-stdio/src/http-entrypoint.ts` with `ws.WebSocketServer({ noServer: true })`, `/acp` upgrade routing, WebSocket shutdown cleanup, and CLI logging for the `ws://.../acp` endpoint. Package typecheck passed for `@atlassian/alta-acp-transport-stdio`; real-agent WS E2E passed via manual smoke validation. +- Relay Phase 6.8 WebSocket support is implemented in `/Users/fciner/code/atlassian/relay` using `@agentclientprotocol/sdk/ws-client#createWebSocketStream` from the dev-published `@atlassian/agentclientprotocol-sdk@0.21.1-dev.fciner.20260519140108` package. Relay now accepts `transport = "ws"` remote runners, converts `http://`/`https://` URLs to `ws://`/`wss://` for WebSocket configs, and passed focused WebSocket config/client tests plus the full Relay check. +- SDK Phase 7 package configuration and examples are implemented locally: package exports now include explicit `import` conditions while keeping `default` fallbacks, `ws` is an optional peer dependency, the browser-safe main entry point and TypeScript configuration were verified, and HTTP server/client plus WebSocket client examples were added. +- SDK Phase 9 was audited against the repository on 2026-06-08 and is complete. Completed evidence in the current repo includes `prepareWebSocketUpgrade()` in `src/server.ts`, `createNodeWebSocketUpgradeHandler()` in `src/node-adapter.ts`, strict text-frame handling in `src/ws-server.ts` / `src/ws-utils.ts`, session-header validation and `session/load` final-response routing in `src/server.ts`, connection-scoped HTTP cookie support in `src/http-stream.ts`, exact JSON content-type parsing, and regression tests in `src/server-websocket-upgrade.test.ts`, `src/ws-stream.test.ts`, `src/server-session-sse.test.ts`, and `src/http-stream.test.ts`. +- PR `agentclientprotocol/agent-client-protocol#1376` updates the v1 RFD durability/affinity expectations: sessions can survive disconnects via `session/load` when the agent supports durable session state, client cookie support must preserve affinity across reconnects, reconnect/retry/liveness remain implementer-owned, and in-flight transport messages are not replayed. SDK Phase 10 is planned but not started in the current repo: there is no `src/cookie-store.ts`, `HttpStreamOptions` has no `cookieStore`, `WebSocketStreamOptions` has no `cookies` / `cookieStore`, and examples do not yet document reconnect + `session/load`. + +### Repo Audit Snapshot — 2026-06-08 + +| Area | Status | Repo evidence / pending work | +| -------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Phase 0 | ✅ Complete | `src/protocol.ts`, `src/sse.ts`, and focused tests exist. | +| Phase 1 | ✅ Complete | `ConnectionState` / `ConnectionRegistry`, `AcpServer`, Node HTTP adapter, test agent, test server, and initialize E2E are present. | +| Phase 2 | ✅ Complete | Connection SSE, non-initialize POST handling, `OutboundStream`, routing tests, and `session/new` E2E are present. | +| Phase 3 | ✅ Complete | Session SSE streams, prompt routing, buffered replay, and multiple-session isolation tests are present. | +| Phase 4 | ✅ Complete | Server-to-client permission routing and client response POST handling are implemented and tested. | +| Phase 5 | ✅ Complete | `createHttpStream` exists with local tests for initialize, SSE, session routing, close cleanup, cookies, and client E2E. | +| Phase 6 | ✅ Complete | WebSocket client/server support exists with compliant upgrade helper, Node adapter, text-frame validation, custom constructor tests, and WebSocket E2E. | +| Phase 7 | ✅ Complete | Package exports, optional `ws` peer dependency, TypeScript config, and HTTP/WS examples are present. | +| Phase 9 | ✅ Complete | 9/9 remaining Phase 9 tasks are complete after deleting the deferred policy task from this plan. | +| Phase 10 | ⏳ Pending | 0/10 tasks complete. Missing shared cookie store, reconnect-scoped HTTP cookies, WS cookie support, durable-session reconnect E2E, and reconnect examples. | + +Previous full verification performed locally before this audit: + +```bash +npm run lint +npm run format:check +npm run build +npm run test +npm run docs:ts:verify +``` + +Those passed. A later full `npm run check` also passed after `typos-cli` became available. + +Latest audit-only verification performed on 2026-06-08: + +```bash +npx prettier --check acp-web-transport/tasks.md +``` + +This passed after formatting the updated task document. + +## Implementation Tasks + +### Phase 0: Protocol and SSE Utilities + +Foundation utilities with zero external dependencies. Unit-testable in isolation. No E2E yet — everything downstream imports these. + +- [x] 0.1. `src/protocol.ts` — create — Internal HTTP transport protocol helpers. + + Not exported from the package. + + Exports for internal use: + + ```typescript + export const HEADER_CONNECTION_ID = "Acp-Connection-Id"; + export const HEADER_SESSION_ID = "Acp-Session-Id"; + export const EVENT_STREAM_MIME_TYPE = "text/event-stream"; + export const JSON_MIME_TYPE = "application/json"; + + export function methodRequiresSessionHeader(method: string): boolean; + export function sessionIdFromParams(params: unknown): string | undefined; + export function isInitializeRequest(msg: AnyMessage): boolean; + export function messageIdKey( + id: string | number | null | undefined, + ): string | undefined; + ``` + + Implementation notes: + - `methodRequiresSessionHeader` mirrors Rust SDK `protocol.rs`: `session/prompt`, `session/cancel`, `session/load`, `session/set_mode`, `session/set_model`. + - Include stable SDK methods that are session-scoped: `session/close`, `session/set_config_option`, `session/resume`. + - `sessionIdFromParams` only inspects top-level `params.sessionId`. + - `messageIdKey` normalizes JSON-RPC IDs as `string:` or `number:` and returns `undefined` for `null`/`undefined`. + +- [x] 0.2. `src/protocol.test.ts` — create — Unit tests for protocol helpers. + + Test cases: + - `methodRequiresSessionHeader` returns `true` for all session-scoped methods. + - `methodRequiresSessionHeader` returns `false` for `initialize`, `session/new`, `session/list`. + - `sessionIdFromParams` extracts from `{ sessionId: "abc" }`. + - `sessionIdFromParams` returns `undefined` for missing, non-string, or nested `sessionId`. + - `isInitializeRequest` matches `{ jsonrpc: "2.0", id: 1, method: "initialize" }`. + - `isInitializeRequest` rejects notifications (no `id`), responses, other methods. + - `messageIdKey` normalizes string IDs as `"string:foo"`, number IDs as `"number:1"`. + - `messageIdKey` returns `undefined` for `null` and `undefined`. + +- [x] 0.3. `src/sse.ts` — create — SSE serialization/deserialization utilities. + + Exports: + - `serializeSseEvent(msg: AnyMessage): string` — `data: {json}\n\n`. + - `serializeSseKeepAlive(): string` — `:\n\n` heartbeat comment. + - `parseSseStream(body: ReadableStream): AsyncIterable` — parse SSE byte stream into JSON-RPC messages. + + Implementation notes: + - Use `TextDecoder` with `{ stream: true }`. + - Support chunk boundaries, multiple events per chunk, comments, non-data fields, and multiline `data:` fields. + - Join multiline `data:` values with `\n`. + - Skip empty keepalive events. + - Skip malformed JSON payloads with `console.warn`; do not throw. + +- [x] 0.4. `src/sse.test.ts` — create — Unit tests for SSE utilities. + + Test cases: + - Serialize message event produces `data: {json}\n\n`. + - Serialize keepalive produces `:\n\n`. + - Parse one event. + - Parse multiple events in one chunk. + - Parse events split across chunk boundaries. + - Ignore comments and non-data fields. + - Join multiline `data:` fields. + - Skip malformed JSON without throwing. + +--- + +### Phase 1: Minimal Server — Initialize Over HTTP + +The smallest possible E2E vertical slice: a client POSTs `initialize` and gets a 200 JSON response with the agent's capabilities. No SSE, no sessions, no streaming yet. + +- [x] 1.1. `src/connection.ts` — create — Minimal connection state and registry. + + Not exported from package subpaths. Phase 1 scope only — expanded in Phases 2 and 3. + + ```typescript + class ConnectionState { + readonly connectionId: string; + /** Write end of agent's Stream.readable — sends inbound messages to the agent. */ + readonly inboundTx: WritableStream; + /** Read end of agent's Stream.writable — reads outbound messages from the agent. */ + readonly outboundRx: ReadableStream; + /** The AgentSideConnection instance. */ + readonly agentConnection: AgentSideConnection; + + /** Read exactly the first outbound message (for initialize 200 response). */ + recvInitial(initializeId: string | number): Promise; + shutdown(): void; + } + + class ConnectionRegistry { + createConnection( + agentFactory: (conn: AgentSideConnection) => Agent, + ): ConnectionState; + get(connectionId: string): ConnectionState | undefined; + remove(connectionId: string): ConnectionState | undefined; + closeAll(): void; + } + ``` + + Implementation requirements: + - `connectionId` uses `crypto.randomUUID()`. + - Each connection creates an in-memory `Stream` pair (paired `ReadableStream`/`WritableStream`) connecting `AgentSideConnection` to the transport. + - `recvInitial()` reads exactly one outbound message from `outboundRx`, validates it is a response matching the given initialize request ID, and returns it. If it's not a matching response, treat as protocol error and clean up the connection. + - `shutdown()` closes both ends of the stream pair. + - No `OutboundStream`, router, or session logic in this phase — added in Phase 2. + +- [x] 1.2. `src/server.ts` — create — Minimal `AcpServer` with initialize-only POST handler. + + Public export from `@agentclientprotocol/sdk/server`. + + Phase 1 scope: + + ```typescript + export interface AcpServerOptions { + createAgent: (conn: AgentSideConnection) => Agent; + } + + export class AcpServer { + constructor(options: AcpServerOptions); + handleRequest(req: Request): Promise; + close(): Promise; + } + ``` + + `handleRequest` Phase 1 behavior: + + **POST:** + 1. Require `Content-Type` starting with `application/json`; otherwise 415. + 2. Parse JSON body; invalid JSON → 400. + 3. Array body → 501. + 4. If `isInitializeRequest(msg)` and no `Acp-Connection-Id` header: create connection via registry, send initialize to agent via `inboundTx`, call `recvInitial()`, start router (no-op in Phase 1), return 200 JSON with `Acp-Connection-Id` header. + 5. POST without `Acp-Connection-Id` and not initialize → 400. + 6. POST with unknown `Acp-Connection-Id` → 404. + 7. All other POST requests → 400 (expanded in Phase 2). + + **All other HTTP methods:** → 405. + + **`close()`:** calls `registry.closeAll()`. + +- [x] 1.3. `src/node-adapter.ts` — create — Node HTTP adapter. + + Public export from `@agentclientprotocol/sdk/node`. + + ```typescript + export function createNodeHttpHandler( + server: AcpServer, + ): (req: IncomingMessage, res: ServerResponse) => void; + ``` + + Requirements: + - Convert `IncomingMessage` to Web Standard `Request` (method, URL, headers, body). + - Convert Web Standard `Response` back to `ServerResponse`, preserving streaming SSE bodies. + - Use `Readable.toWeb` from `node:stream` for request body conversion. + - Pipe `Response.body` (if present) to `ServerResponse` via `Readable.fromWeb` or manual streaming. + - Keep Node-specific imports isolated to this file. + +- [x] 1.4. `src/test-support/test-agent.ts` — create — Self-contained test agent. + + Requirements: + - Implements `Agent` interface from `@agentclientprotocol/sdk`. + - `initialize`: returns `protocolVersion: 1` and `agentCapabilities: { loadSession: false }`. + - `newSession`: generates random session ID, returns `{ sessionId }`. + - `prompt`: streams configurable number of text chunks via `sessionUpdate`, optionally triggers `requestPermission` flow, returns `{ stopReason: "end_turn" }`. + - `cancel`: aborts pending prompt. + - Configurable behavior via constructor options (e.g., `enablePermission: boolean`, `chunkCount: number`, `chunkDelayMs: number`). + - No external dependencies — no Rivet, no MCP, no real LLM. + +- [x] 1.5. `src/test-support/test-http-server.ts` — create — E2E test helper that starts a local HTTP server. + + ```typescript + export async function startTestServer( + agentFactory?: (conn: AgentSideConnection) => Agent, + options?: { port?: number }, + ): Promise<{ url: string; close: () => Promise }>; + ``` + + Requirements: + - Creates `AcpServer` with the given agent factory (defaults to `TestAgent`). + - Starts `node:http` server via `createNodeHttpHandler`. + - Listens on port 0 (random available port) by default. + - Returns base URL (e.g., `http://localhost:54321`) and async cleanup function. + - Used by all E2E/integration tests across Phases 1–6. + +- [x] 1.6. `src/node-adapter.test.ts` — create — Node adapter unit tests. + + Test cases: + - Forwards method, URL, headers, and body to `AcpServer.handleRequest`. + - Streams SSE response body to `ServerResponse`. + - Handles empty response body (e.g., 202 with no body). + - Preserves status code and response headers. + +- [x] 1.7. E2E test — Initialize over HTTP. + + Test file: `src/server.test.ts` (started in this phase, expanded in Phases 2–4). + + ``` + E2E scenario — Phase 1: Initialize + ┌────────────────────────────────────────────────────────────────────┐ + │ 1. Start test HTTP server with TestAgent │ + │ 2. POST /acp │ + │ Headers: Content-Type: application/json │ + │ Body: { jsonrpc: "2.0", id: 1, method: "initialize", │ + │ params: { protocolVersion: 1, clientCapabilities: {} } }│ + │ 3. Assert: HTTP 200 OK │ + │ 4. Assert: Response body is JSON with protocolVersion, │ + │ agentCapabilities │ + │ 5. Assert: Acp-Connection-Id header is present and non-empty │ + │ 6. Shut down server │ + └────────────────────────────────────────────────────────────────────┘ + ``` + + Additional error case tests: + - POST without `Content-Type: application/json` → 415. + - POST with invalid JSON body → 400. + - POST with JSON array body → 501. + - POST non-initialize request without `Acp-Connection-Id` → 400. + - GET/PUT/PATCH → 405. + - Initialize with agent factory that throws → error response, no connection leaked. + +- [x] 1.8. Alta HTTP server adapter — create — Wrap `AcpBridgeAgent` in an HTTP server. + + Location: `alta/packages/acp-transport-stdio/src/http-entrypoint.ts` (or a new `alta/packages/acp-transport-http` package — decide based on dependency graph). + + ```typescript + import { AcpServer } from "@agentclientprotocol/sdk/server"; + import { createNodeHttpHandler } from "@agentclientprotocol/sdk/node"; + import { AcpBridgeAgent } from "./agent.js"; + import http from "node:http"; + + export function startAcpHttpBridge(config: AcpBridgeConfig): Promise { + const acpServer = new AcpServer({ + createAgent: (conn) => new AcpBridgeAgent(conn, config), + }); + + const handler = createNodeHttpHandler(acpServer); + const httpServer = http.createServer(handler); + httpServer.listen(3000); + // ... global error handlers ported from startAcpBridge + } + ``` + + Notes: + - The `AcpBridgeAgent` constructor takes `(conn: AgentSideConnection, config: AcpBridgeConfig)` — fits the factory pattern naturally. + - Port global error handlers (`uncaughtException`, `unhandledRejection`) from `startAcpBridge` in `alta/packages/acp-transport-stdio/src/index.ts`. + - No stdout guard needed (unlike STDIO, HTTP doesn't use stdout for the protocol stream). + - This adapter stays stable across all phases — `AcpServer` gains capabilities underneath, the adapter code doesn't change. + - WebSocket upgrade wiring added in Phase 6. + - Implemented smoke entrypoint in `packages/acp-transport-stdio/src/http-entrypoint.ts` and CLI selector via `ALTA_ACP_TRANSPORT=http`. + +- [x] 1.9. Gated E2E test — Real agent initialize over HTTP. + + Status: complete via manual Alta smoke validation for initialize/session/prompt over HTTP; see `acp-web-transport/SMOKE_TEST.md`. Automated gated coverage can still be added later as optional hardening in `alta/packages/acp-transport-stdio/tests/integration/http-e2e.test.ts` behind `ACP_INTEGRATION_TEST`. + + ``` + Gated E2E — Phase 1: Real agent initialize + ┌────────────────────────────────────────────────────────────────────┐ + │ Prerequisites: │ + │ - Running Rivet actor backend │ + │ - Valid agent credentials / configuration │ + │ - ACP_INTEGRATION_TEST=true environment variable │ + │ │ + │ 1. Start HTTP server with AcpBridgeAgent + real AcpBridgeConfig │ + │ 2. POST /acp { initialize } │ + │ → Assert: 200 OK │ + │ → Assert: protocolVersion present │ + │ → Assert: agentCapabilities includes loadSession: true │ + │ → Assert: Acp-Connection-Id header present │ + │ 3. Shut down server │ + └────────────────────────────────────────────────────────────────────┘ + ``` + +--- + +### Phase 2: Connection SSE + `session/new` + +Add connection-scoped SSE streams and the POST→202→SSE response flow. After this phase, the client can open a long-lived event stream and create sessions. + +- [x] 2.1. `src/connection.ts` — modify — Add `OutboundStream`, connection-scoped routing, and router task. + + New types added to `ConnectionState`: + + ```typescript + type ResponseRoute = "connection" | { session: string }; + + class OutboundStream { + constructor(options?: { capacity?: number }); // default 1024 + push(msg: AnyMessage): void; + close(): void; + subscribe(): { replay: AnyMessage[]; stream: ReadableStream }; + } + + // Added to ConnectionState: + connectionStream: OutboundStream; + allOutbound: OutboundStream; + pendingRoutes: Map; + startRouter(): void; + ``` + + `OutboundStream` implementation: + - Before first subscriber: `push` appends to replay buffer (bounded `Array` or ring buffer). + - Replay buffer evicts oldest message if capacity (default 1024) exceeded, increments dropped counter. + - First `subscribe()` drains replay buffer, transitions to live broadcast mode. + - Subsequent subscribers get empty replay and only live messages. + - Slow live subscriber overflow drops oldest messages, `console.warn` once per overflow burst. + - Overflow does not close the connection or synthesize JSON-RPC errors. + + Router task: + - Background async loop reads messages from `outboundRx`. + - Response messages (has `id`, no `method`): look up `pendingRoutes.remove(messageIdKey(id))`, push to that destination's `OutboundStream`. Fallback: connection stream. + - Request/notification messages: push to connection stream (session routing added in Phase 3). + - Every outbound message is also pushed to `allOutbound`. + - `startRouter()` is called immediately after `recvInitial()` returns in the initialize flow. + +- [x] 2.2. `src/server.ts` — modify — Add GET SSE handler and POST non-initialize handler. + + **GET handler:** + 1. If `Upgrade: websocket` header present → return 426 with explanatory body (WS upgrade handled separately). + 2. Require `Accept` header containing `text/event-stream`; otherwise 406. + 3. Require `Acp-Connection-Id`; missing → 400. + 4. Unknown connection → 404. + 5. No `Acp-Session-Id` header → subscribe to connection stream (session SSE added in Phase 3). + 6. Return `Response` with `Content-Type: text/event-stream`, `Cache-Control: no-cache`, and a streaming body. + 7. Drain replayed messages as SSE events, then stream live messages. + 8. Send keepalive comments (`:\n\n`) every 15 seconds. + 9. Close response stream when `OutboundStream` closes. + + **POST non-initialize handler (updated from Phase 1):** + 1. Require `Acp-Connection-Id`; missing → 400. + 2. Unknown connection → 404. + 3. For client requests (has `method` + `id`): record `pendingRoutes[messageIdKey(id)] = "connection"` (session routing added in Phase 3). + 4. For client responses (has `id`, no `method`): forward to agent (Phase 4 handles this properly). + 5. For client notifications (has `method`, no `id`): forward to agent. + 6. Forward message to agent via `inboundTx`. + 7. Return 202 Accepted. + + **DELETE handler:** + 1. Require `Acp-Connection-Id`; missing → 400. + 2. Unknown connection → 404. + 3. Remove connection from registry and shut it down (closes all streams, stops router). + 4. Return 202 Accepted. + +- [x] 2.3. `src/connection.test.ts` — create — Unit tests for `OutboundStream` and routing. + + Test cases: + - `ConnectionRegistry` produces unique UUID connection IDs. + - `get` returns connection, `remove` returns and deletes connection. + - `remove` and `closeAll` call `shutdown` on connections. + - `OutboundStream`: first subscriber receives full replay buffer. + - `OutboundStream`: second subscriber receives no replay, only live messages. + - `OutboundStream`: replay buffer evicts oldest message after capacity (1024). + - `OutboundStream`: live subscriber overflow drops oldest messages and warns (connection stays open). + - `OutboundStream`: `close()` closes all subscriber streams. + - Outbound response routes via `pendingRoutes` and deletes the route entry. + - Outbound response without pending route falls back to connection stream. + - Every outbound message also reaches `allOutbound`. + +- [x] 2.4. E2E test — Connection SSE + `session/new`. + + Test file: `src/server.test.ts` (expanded). + + ``` + E2E scenario — Phase 2: Connection SSE + session/new + ┌────────────────────────────────────────────────────────────────────┐ + │ 1. Start test HTTP server with TestAgent │ + │ 2. POST /acp { initialize } │ + │ → 200, capture Acp-Connection-Id: "c1" │ + │ 3. GET /acp │ + │ Headers: Acp-Connection-Id: c1, Accept: text/event-stream │ + │ → 200, SSE stream opens │ + │ 4. POST /acp │ + │ Headers: Acp-Connection-Id: c1, Content-Type: application/json │ + │ Body: { jsonrpc: "2.0", id: 2, method: "session/new", │ + │ params: { cwd: "/tmp" } } │ + │ → 202 Accepted │ + │ 5. Read SSE stream: expect event containing │ + │ { jsonrpc: "2.0", id: 2, result: { sessionId: "..." } } │ + │ 6. Assert: sessionId is a non-empty string │ + │ 7. Shut down server │ + └────────────────────────────────────────────────────────────────────┘ + ``` + + Additional tests: + - GET without `Accept: text/event-stream` → 406. + - GET without `Acp-Connection-Id` → 400. + - GET with unknown `Acp-Connection-Id` → 404. + - SSE keepalive comment arrives within ~15 seconds. + - DELETE with `Acp-Connection-Id` → 202, SSE stream closes. + - DELETE with unknown `Acp-Connection-Id` → 404. + +--- + +### Phase 3: Session SSE + Prompt Streaming + +Add session-scoped SSE streams and the full prompt flow with streaming `session/update` notifications. After this phase, the client can create sessions, open session-specific SSE, send prompts, and receive real-time streaming updates. + +- [x] 3.1. `src/connection.ts` — modify — Add session-scoped streams and routing. + + Add to `ConnectionState`: + + ```typescript + sessionStreams: Map; + ensureSession(sessionId: string): OutboundStream; + ``` + + Router updates: + - Request/notification messages (has `method`): check `sessionIdFromParams(params)`. If present, push to session stream via `ensureSession(sessionId)`. Otherwise push to connection stream. + - Response messages: `pendingRoutes` values are now `ResponseRoute = "connection" | { session: string }`. Route to the appropriate stream based on the stored route. Successful responses with `result.sessionId` ensure a session stream so session SSE can attach after `session/new`. + +- [x] 3.2. `src/server.ts` — modify — Add session-scoped SSE and session request routing. + + **GET updates:** + - If `Acp-Session-Id` header present: subscribe to that session's `OutboundStream`. If session stream does not exist (never ensured) → 404. + - Otherwise: subscribe to connection stream (unchanged from Phase 2). + + **POST route determination for client requests** (header-first, aligned with Rust SDK `handle_post`): + 1. If `Acp-Session-Id` header present → route = `{ session: sessionId }`. + 2. Else if `sessionIdFromParams(params)` returns a session ID → route = `{ session: sessionId }`. + 3. Else if `methodRequiresSessionHeader(method)` → reject with 400 (missing required session identifier). + 4. Else → route = `"connection"`. + - If route is session, call `ensureSession(sessionId)` before forwarding. + - Record `pendingRoutes[messageIdKey(id)] = route` for request messages. + +- [x] 3.3. E2E test — Session SSE + prompt streaming. + + Test file: `src/server.test.ts` (expanded). + + ``` + E2E scenario — Phase 3: Prompt streaming + ┌────────────────────────────────────────────────────────────────────┐ + │ 1. Start test HTTP server with TestAgent │ + │ 2. POST { initialize } → 200, capture connection-id: "c1" │ + │ 3. GET (Acp-Connection-Id: c1) → connection SSE opens │ + │ 4. POST { session/new, params: { cwd: "/tmp" } } │ + │ → 202 Accepted │ + │ 5. Read connection SSE: { id: 2, result: { sessionId: "s1" } } │ + │ 6. GET (Acp-Connection-Id: c1, Acp-Session-Id: s1) │ + │ → session SSE opens │ + │ 7. POST (Acp-Connection-Id: c1, Acp-Session-Id: s1) │ + │ { method: "session/prompt", params: { sessionId: "s1", │ + │ prompt: [{ type: "text", text: "Hello" }] } } │ + │ → 202 Accepted │ + │ 8. Read session SSE events in order: │ + │ a. session/update notification — agent_message_chunk │ + │ b. session/update notification — tool_call (status: pending) │ + │ c. session/update notification — tool_call_update (completed) │ + │ d. { id: 3, result: { stopReason: "end_turn" } } │ + │ 9. Assert: session events did NOT appear on connection SSE │ + │ 10. Shut down server │ + └────────────────────────────────────────────────────────────────────┘ + ``` + + Additional tests: + - POST `session/prompt` without `Acp-Session-Id` header but with `sessionId` in params → routes correctly to session stream. + - POST `session/prompt` without any session identifier → 400. + - GET with unknown `Acp-Session-Id` (never created) → 404. + - Session SSE is independent from connection SSE (session events don't leak to connection stream). + - Two sessions on one connection with interleaved prompts → each session SSE receives only its own events. + - Session `OutboundStream` buffers messages produced before session SSE GET attaches, up to capacity 1024. + +- [x] 3.4. Gated E2E test — Real agent prompt streaming over HTTP. + + Status: complete via manual Alta smoke validation for real-agent session prompt streaming over HTTP; see `acp-web-transport/SMOKE_TEST.md`. Automated gated coverage can still be added later as optional hardening behind `ACP_INTEGRATION_TEST`. + + ``` + Gated E2E — Phase 3: Real agent prompt streaming + ┌────────────────────────────────────────────────────────────────────┐ + │ Prerequisites: same as Phase 1 gated E2E │ + │ │ + │ 1. Start HTTP server with AcpBridgeAgent │ + │ 2. POST { initialize } → 200, capture connection-id │ + │ 3. GET (connection-id) → connection SSE opens │ + │ 4. POST { session/new } → 202 │ + │ 5. Read connection SSE: session response with sessionId │ + │ 6. GET (connection-id, session-id) → session SSE opens │ + │ 7. POST { session/prompt, text: "What is 2+2?" } → 202 │ + │ 8. Read session SSE: streaming session/update events arrive │ + │ → Assert: agent_message_chunk events contain text │ + │ → Assert: prompt response arrives with stopReason │ + │ 9. Shut down server │ + └────────────────────────────────────────────────────────────────────┘ + ``` + +--- + +### Phase 4: Bidirectional — Permission Requests + +Add server→client request flow over session SSE and client→server response POSTs. This is the most complex HTTP transport feature: the agent sends a JSON-RPC _request_ TO the client (e.g., `session/request_permission`), and the client must POST back a JSON-RPC _response_. + +- [x] 4.1. `src/connection.ts` — modify — Route server→client requests to session streams. + + Router updates: + - Agent outbound requests (has `method` + `id` — server→client requests like `session/request_permission`): route to session stream if `sessionIdFromParams(params)` returns a session ID, otherwise connection stream. + - These are distinguished from client outbound requests by direction: they originate from the agent writing to its `Stream.writable`. + +- [x] 4.2. `src/server.ts` — modify — Handle client response POSTs. + + **POST updates:** + - Detect client responses: message has `id` and (`result` or `error`) but no `method`. + - Forward client responses directly to agent via `inboundTx`. + - Return 202 Accepted. + - No `pendingRoutes` recording needed — responses don't generate further responses. + +- [x] 4.3. E2E test — Full bidirectional permission flow. + + Test file: `src/server-permission.test.ts`. + + Configure `TestAgent` with `enablePermission: true` for this test. + + ``` + E2E scenario — Phase 4: Permission request flow + ┌────────────────────────────────────────────────────────────────────┐ + │ 1-6. (same as Phase 3 — initialize, session/new, open SSEs) │ + │ 7. POST { session/prompt } → 202 │ + │ 8. Read session SSE events: │ + │ a. session/update — agent_message_chunk │ + │ b. session/update — tool_call (status: pending) │ + │ 9. Read session SSE: server→client request │ + │ { jsonrpc: "2.0", id: 99, │ + │ method: "session/request_permission", │ + │ params: { sessionId: "s1", toolCall: {...}, │ + │ options: [{ kind: "allow_once", ... }] } } │ + │ 10. POST (Acp-Connection-Id: c1) │ + │ Body: { jsonrpc: "2.0", id: 99, │ + │ result: { outcome: { outcome: "selected", │ + │ optionId: "allow" } } } │ + │ → 202 Accepted (client responds to permission request) │ + │ 11. Read session SSE events: │ + │ a. session/update — tool_call_update (status: completed) │ + │ b. session/update — agent_message_chunk │ + │ c. { id: 3, result: { stopReason: "end_turn" } } │ + │ 12. Shut down server │ + └────────────────────────────────────────────────────────────────────┘ + ``` + + Additional tests: + - Permission request with `reject` outcome → agent handles rejection, prompt completes. + - Multiple sequential permission requests within one prompt. + - Client response POST with unknown/mismatched ID → no error, no crash, agent not affected. + - Permission request `params.sessionId` correctly routes the request to the right session SSE (not connection SSE). + +- [x] 4.4. Gated E2E test — Real agent permission flow over HTTP. + + Status: complete via manual Alta smoke validation for the real-agent permission flow over HTTP. Automated gated coverage can still be added later as optional hardening behind `ACP_INTEGRATION_TEST`. + + Note: This test requires prompting the real agent in a way that triggers a tool requiring permission approval. The exact prompt depends on the agent configuration — may need a purpose-built agent config or a known tool that always requests permission. + + ``` + Gated E2E — Phase 4: Real agent permission flow + ┌────────────────────────────────────────────────────────────────────┐ + │ Prerequisites: same as Phase 1 gated E2E │ + │ │ + │ 1-6. (same as Phase 3 gated E2E — initialize, session, open SSEs) │ + │ 7. POST { session/prompt } with a prompt that triggers a tool │ + │ requiring permission → 202 │ + │ 8. Read session SSE: session/request_permission request arrives │ + │ → Assert: request includes toolCall and options │ + │ 9. POST client response with allow_once outcome → 202 │ + │ 10. Read session SSE: prompt continues after permission granted │ + │ → Assert: prompt completes with stopReason │ + │ 11. Shut down server │ + └────────────────────────────────────────────────────────────────────┘ + ``` + +--- + +### Phase 5: HTTP Client Transport (`createHttpStream`) + +Wrap all client-side HTTP/SSE logic into the SDK's `Stream` interface. After this phase, consumers use `ClientSideConnection` + `createHttpStream` with the full SDK abstraction — identical developer experience to the existing STDIO transport. + +- [x] 5.1. `src/http-stream.ts` — create — Client-side HTTP/SSE transport. + + Public export from `@agentclientprotocol/sdk/http-client`. + + ```typescript + export function createHttpStream( + serverUrl: string, + options?: { + /** Custom fetch function (e.g., for auth headers, cookies). */ + fetch?: typeof globalThis.fetch; + /** Custom headers to include on all requests. */ + headers?: Record; + }, + ): Stream; + ``` + + Requirements: + - Phase 5 initial implementation had no built-in cookie jar; Phase 9 added connection-scoped cookies and Phase 10 upgrades this to a reconnect-scoped `cookieStore`. + - Use `options.fetch` or `globalThis.fetch`. + - Send `options.headers` on all POST, GET, and DELETE requests. + - First outgoing message must be `initialize`; POST it without `Acp-Connection-Id` and expect 200 JSON response plus `Acp-Connection-Id` header. + - Deliver initialize response to the `stream.readable`. + - Open connection-scoped SSE (GET with `Acp-Connection-Id`) after initialize succeeds. + - Subsequent writes: POST with `Acp-Connection-Id` and optional `Acp-Session-Id`. + - For requests where `sessionIdFromParams(params)` returns a session ID, include `Acp-Session-Id` header. + - When a response `result` contains `sessionId`, open the session SSE stream (GET with both headers) before delivering that response to the consumer. + - POST writes resolve after the immediate HTTP response (usually 202), NOT after JSON-RPC response over SSE. + - Writes may be serialized for v1 simplicity, but must not block on SSE responses. + - Incoming SSE events (from connection and session streams) are parsed and enqueued to `stream.readable`. + - Server→client requests (e.g., `session/request_permission`) arrive on session SSE and are delivered to `stream.readable`; the `ClientSideConnection` handles routing them to the `Client` callback, and the client's response is written to `stream.writable`, which the transport POSTs back. + - On `stream.writable` close: send DELETE with `Acp-Connection-Id` if connection was initialized, abort all SSE fetch requests. + +- [x] 5.2. `src/http-stream.test.ts` — create — HTTP client transport unit and integration tests. + + Unit/mock tests: + - Initialize POST sends correct headers and body, captures `Acp-Connection-Id` from response. + - Connection SSE opens after initialize with correct headers. + - Session-scoped POST includes `Acp-Session-Id` header. + - Custom headers are sent on all requests. + - Custom fetch function is used instead of `globalThis.fetch`. + - Close sends DELETE and aborts SSE fetches. + +- [x] 5.3. E2E test — Full flow with `ClientSideConnection` + `createHttpStream`. + + Test file: `src/http-stream.test.ts` (E2E section). + + ``` + E2E scenario — Phase 5: SDK client abstraction over HTTP + ┌────────────────────────────────────────────────────────────────────┐ + │ 1. Start test HTTP server with TestAgent │ + │ 2. const stream = createHttpStream(serverUrl); │ + │ 3. const conn = new ClientSideConnection( │ + │ (agent) => testClient, stream); │ + │ 4. const init = await conn.initialize({ │ + │ protocolVersion: 1, │ + │ clientCapabilities: {} }); │ + │ → Assert: init.protocolVersion === 1 │ + │ → Assert: init.agentCapabilities present │ + │ 5. const session = await conn.newSession({ cwd: "/tmp" }); │ + │ → Assert: session.sessionId is non-empty string │ + │ 6. const result = await conn.prompt({ │ + │ sessionId: session.sessionId, │ + │ prompt: [{ type: "text", text: "Hello" }] }); │ + │ → Assert: result.stopReason === "end_turn" │ + │ → Assert: testClient.sessionUpdate was called ≥1 times │ + │ 7. Shut down server │ + └────────────────────────────────────────────────────────────────────┘ + ``` + + Additional E2E tests: + - Permission flow: `TestAgent` with `enablePermission: true` → `testClient.requestPermission` callback is invoked, client returns allow, prompt completes. + - Cancel during prompt: `conn.cancel()` mid-prompt → prompt returns `stopReason: "cancelled"`. + - Multiple sessions on one connection: create two sessions, prompt both, responses arrive independently. + - Custom headers: assert they reach the server (use `TestAgent` that echoes headers). + +- [x] 5.4. Relay HTTP client adapter — create — Port Relay's `AcpClient` from STDIO to HTTP. + + Location: new file `relay/src/acp/http-client.ts` (or modify `relay/src/acp/client.ts` to support both transports). + + Reference: `relay/src/acp/client.ts` (STDIO client), `relay/src/acp/sdk.ts` (STDIO `ClientSideConnection` wrapper). + + ```typescript + // Replace spawn() + stdin/stdout with createHttpStream: + import { createHttpStream } from "@agentclientprotocol/sdk/http-client"; + import { ClientSideConnection } from "@agentclientprotocol/sdk"; + + const stream = createHttpStream("http://localhost:3000/acp"); + const conn = new ClientSideConnection((agent) => acpClient, stream); + ``` + + Implemented in Relay as a parallel HTTP client selected by runner config, keeping the existing STDIO client path intact: + - `relay/package.json` aliases `@agentclientprotocol/sdk` to `npm:@atlassian/agentclientprotocol-sdk@0.21.1-dev.fciner.20260518214959` and pins the same package in overrides. + - `relay/src/acp/http-client.ts` creates `AcpHttpClient`, which uses `ClientSideConnection` plus `createHttpStream(url)` and implements Relay's existing client surface. + - `relay/src/config/index.ts` supports `transport = "http"` with `url = "http://127.0.0.1:/acp"`; `transport` defaults to STDIO for existing runners. + - `relay/src/ui/app.tsx` creates `AcpHttpClient` for HTTP runners and the existing `AcpClient` otherwise. + - `relay/test/acp/http-client.test.ts` starts an SDK `AcpServer` and verifies Relay callbacks, prompt, and permission flow through the SDK HTTP transport. + - `relay/test/config/config.test.ts` covers HTTP runner validation. + + Example Relay config: + + ```toml + [agents.runners.brovodev-http] + transport = "http" + url = "http://127.0.0.1:7331/acp" + renderer = "alta" + ``` + + Manual E2E validated with an Alta HTTP ACP agent started from source on port `7331`; when running Alta from its source repo, pass `--workspace /path/to/target/repo` or run with an absolute CLI path from the target repo so the agent workspace is not accidentally the Alta source checkout. + +- [x] 5.5. Real agent full flow with SDK client abstraction. + + Status: complete via manual Relay + Alta HTTP E2E validation. Automated gated coverage is intentionally not required for Phase 5. + + ``` + Manual E2E — Phase 5: Real agent + SDK ClientSideConnection over HTTP + ┌────────────────────────────────────────────────────────────────────┐ + │ Prerequisites: same as Phase 1 gated E2E │ + │ │ + │ 1. Start HTTP server with AcpBridgeAgent │ + │ 2. const stream = createHttpStream(serverUrl); │ + │ 3. const conn = new ClientSideConnection( │ + │ (agent) => testClient, stream); │ + │ 4. await conn.initialize(...) │ + │ → Assert: real agent capabilities (loadSession: true) │ + │ 5. await conn.newSession(...) │ + │ → Assert: real sessionId │ + │ 6. await conn.prompt(...) │ + │ → Assert: streaming updates arrive via testClient │ + │ → Assert: stopReason is "end_turn" │ + │ 7. await conn.loadSession({ sessionId }) (if supported) │ + │ → Assert: session history replay arrives │ + │ 8. Cancel mid-prompt │ + │ → Assert: stopReason is "cancelled" │ + │ 9. Close stream → Assert: server cleans up │ + └────────────────────────────────────────────────────────────────────┘ + ``` + + Optional future automation: + - Session mode/model switching over HTTP (if agent supports). + +--- + +### Phase 6: WebSocket Transport + +WebSocket shares all connection/routing infrastructure from Phases 1–4. The server adds `handleWebSocket()` which subscribes to `allOutbound`. The client creates a `Stream` over WebSocket text frames. WebSocket is architecturally simpler than HTTP — it's bidirectional by nature, like STDIO over a network. + +- [x] 6.1. `src/server.ts` — modify — Add initial WebSocket server support (`handleWebSocket(socket)`, superseded by Phase 9 pre-upgrade helper). + + ```typescript + // Added to AcpServer: + handleWebSocket(socket: WebSocket): void; + ``` + + Requirements: + - Support standard browser `WebSocket` and Node `ws` objects via an internal compatibility adapter. + - Adapter normalizes: + - `addEventListener("message", ...)` vs `on("message", ...)` event binding styles. + - Message payloads: `string`, `Buffer`, `ArrayBuffer` → always decode to string. + - `close` and `error` events. + - On socket open/ready: wait for first message. + - First message must be `initialize` request: create connection via registry, send initialize to agent, `recvInitial()`, send response as JSON text frame, `startRouter()`. + - After initialize: subscribe to `allOutbound`, send every outbound message as a JSON text frame. + - Incoming client text frames: parse as JSON-RPC, forward to agent via `inboundTx`. + - For client requests with `sessionIdFromParams(params)`: call `ensureSession(sessionId)`, record `pendingRoutes`. + - Ignore binary frames that cannot be decoded to text; `console.warn` and continue. + - On socket close/error: remove connection from registry and shut down. + +- [x] 6.2. `src/test-support/test-http-server.ts` — modify — Add WebSocket upgrade support. + + Requirements: + - Add optional `ws.WebSocketServer` wiring to the test HTTP server. + - Handle `upgrade` event on `node:http` server. + - Call `acpServer.handleWebSocket(ws)` for upgraded connections. + - Return both `httpUrl` and `wsUrl` from `startTestServer`. + +- [x] 6.3. `src/ws-stream.ts` — create — Client-side WebSocket transport. + + Public export from `@agentclientprotocol/sdk/ws-client`. + + ```typescript + export function createWebSocketStream( + serverUrl: string, + options?: { + /** WebSocket subprotocols. */ + protocols?: string[]; + /** Custom headers (Node.js/Deno/Bun only — browser WebSocket ignores). */ + headers?: Record; + /** Custom WebSocket constructor (e.g., `ws.WebSocket` in Node). */ + WebSocket?: typeof globalThis.WebSocket; + }, + ): Stream; + ``` + + Requirements: + - Use `options.WebSocket` or `globalThis.WebSocket`. + - `protocols` passed to WebSocket constructor. + - `headers` are best-effort; only runtimes/constructors that accept headers will use them. Browser `WebSocket` API does not support custom headers — document this in JSDoc. + - Queue writes until socket is open, then send JSON text frames. + - Read text frames, parse JSON, enqueue `AnyMessage` to `stream.readable`. + - Ignore binary frames. + - Close `stream.readable` and `stream.writable` on socket close/error. + +- [x] 6.4. `src/ws-stream.test.ts` — create — WebSocket client transport unit and integration tests. + + Unit tests: + - Connects and sends/receives JSON-RPC text frames. + - Writes before socket open are queued until open event. + - Binary frames are ignored. + - Malformed JSON is warned/skipped, not thrown. + - Socket close/error closes both stream ends. + - Custom `WebSocket` constructor option is used. + +- [x] 6.5. E2E test — Full flow over WebSocket. + + Test file: `src/ws-stream.test.ts` (E2E section). + + ``` + E2E scenario — Phase 6: WebSocket E2E + ┌────────────────────────────────────────────────────────────────────┐ + │ 1. Start test HTTP server with WebSocket upgrade support │ + │ 2. const stream = createWebSocketStream(wsUrl); │ + │ 3. const conn = new ClientSideConnection( │ + │ (agent) => testClient, stream); │ + │ 4. await conn.initialize({ protocolVersion: 1, ... }) │ + │ → Assert: protocolVersion, agentCapabilities │ + │ 5. await conn.newSession({ cwd: "/tmp" }) │ + │ → Assert: sessionId present │ + │ 6. await conn.prompt({ sessionId, prompt: [...] }) │ + │ → Assert: stopReason === "end_turn" │ + │ → Assert: streaming session updates arrived via testClient │ + │ 7. Permission flow (enablePermission: true) │ + │ → Assert: testClient.requestPermission invoked │ + │ → Assert: bidirectional request/response works over WS │ + │ 8. Close WebSocket │ + │ → Assert: server cleaned up connection state │ + └────────────────────────────────────────────────────────────────────┘ + ``` + + Additional tests: + - Node `ws.WebSocket` compatibility via adapter (pass `ws.WebSocket` as `options.WebSocket`). + - WebSocket close event triggers server connection cleanup. + - Multiple sessions over one WebSocket, interleaved prompts. + +- [x] 6.6. Alta HTTP server adapter — modify — Add WebSocket upgrade wiring. + + Update `alta/packages/acp-transport-stdio/src/http-entrypoint.ts` (created in Phase 1.8). + + ```typescript + import { WebSocketServer } from "ws"; + + const wss = new WebSocketServer({ noServer: true }); + httpServer.on("upgrade", (req, socket, head) => { + wss.handleUpgrade(req, socket, head, (ws) => { + acpServer.handleWebSocket(ws); + }); + }); + ``` + +- [x] 6.7. Gated E2E test — Real agent over WebSocket. + + Status: complete via manual Alta real-agent WebSocket smoke validation. Automated gated coverage can still be added later as optional hardening behind `ACP_INTEGRATION_TEST`. + + ``` + Gated E2E — Phase 6: Real agent over WebSocket + ┌────────────────────────────────────────────────────────────────────┐ + │ Prerequisites: same as Phase 1 gated E2E │ + │ │ + │ 1. Start HTTP server with AcpBridgeAgent + WebSocket upgrade │ + │ 2. const stream = createWebSocketStream(wsUrl, │ + │ { WebSocket: ws.WebSocket }); │ + │ 3. const conn = new ClientSideConnection( │ + │ (agent) => testClient, stream); │ + │ 4. await conn.initialize(...) │ + │ → Assert: real agent capabilities │ + │ 5. await conn.newSession(...) │ + │ → Assert: real sessionId │ + │ 6. await conn.prompt(...) │ + │ → Assert: streaming updates, stopReason │ + │ 7. Permission flow (if applicable) │ + │ → Assert: bidirectional request/response over WS │ + │ 8. Close WebSocket → Assert: server cleanup │ + └────────────────────────────────────────────────────────────────────┘ + ``` + + Additional gated tests: + - Multiple concurrent sessions over one WebSocket with real agent. + +- [x] 6.8. Relay WebSocket client support — modify — Add config-selected WebSocket transport. + + Update `/Users/fciner/code/atlassian/relay` so Relay can use the SDK WebSocket client transport against Alta's upgraded HTTP bridge. + + Requirements: + - Update Relay's SDK dependency/override to the Phase 6 dev-published `@atlassian/agentclientprotocol-sdk` build that includes `@agentclientprotocol/sdk/ws-client` and the SDK WebSocket server support available at that phase. + - Extend Relay config validation to accept `transport = "ws"` with `url`. + - Extend Relay launch/resolved runner types from remote `"http"` only to `"http" | "ws"`. + - Update the SDK-backed Relay remote ACP client to choose `createHttpStream(url)` for HTTP and `createWebSocketStream(wsUrl)` for WebSocket. + - Convert `http://` → `ws://` and `https://` → `wss://` for `transport = "ws"` configs, while also accepting explicit `ws://`/`wss://` URLs. + - Update Relay UI/client creation to instantiate the remote ACP client for both `"http"` and `"ws"` runners. + - Add/extend Relay config and ACP client tests for WebSocket initialize/session/prompt/permission flow. + - Run focused Relay tests and full `bun run check` before marking complete. + + Implemented in Relay with the SDK WebSocket transport: + - `relay/package.json` aliases `@agentclientprotocol/sdk` to `npm:@atlassian/agentclientprotocol-sdk@0.21.1-dev.fciner.20260519140108` and pins the same package in overrides. + - `relay/src/config/index.ts` supports remote `transport = "ws"` with `url`, alongside the existing `"http"` remote transport and STDIO default. + - `relay/src/acp/client.ts` and `relay/src/ui/app.tsx` carry resolved remote runner types for both `"http"` and `"ws"`. + - `relay/src/acp/http-client.ts` selects `createHttpStream(url)` for HTTP and `createWebSocketStream(toWebSocketUrl(url))` for WebSocket. + - WebSocket URL conversion accepts explicit `ws://`/`wss://` and converts `http://` → `ws://`, `https://` → `wss://`. + - Relay config and ACP client tests cover WebSocket validation plus initialize/session/prompt/permission flow. + + Relay verification performed locally: + + ```bash + env -C /Users/fciner/code/atlassian/relay bun test test/acp/http-client.test.ts test/config/config.test.ts + env -C /Users/fciner/code/atlassian/relay bun run check + ``` + + These passed with focused tests reporting `21 pass`, `0 fail`, and full check reporting `141 pass`, `0 fail`, with build success. + +--- + +### Phase 7: Package Configuration and Examples + +Configure `package.json` subpath exports, verify type isolation, and create documentation examples. + +- [x] 7.1. `package.json` — modify — Add subpath exports and optional `ws` peer dependency. + + Add `exports` map while preserving existing `main`/`types` behavior: + + ```json + { + "main": "dist/acp.js", + "types": "dist/acp.d.ts", + "exports": { + ".": { + "types": "./dist/acp.d.ts", + "import": "./dist/acp.js" + }, + "./http-client": { + "types": "./dist/http-stream.d.ts", + "import": "./dist/http-stream.js" + }, + "./ws-client": { + "types": "./dist/ws-stream.d.ts", + "import": "./dist/ws-stream.js" + }, + "./server": { + "types": "./dist/server.d.ts", + "import": "./dist/server.js" + }, + "./node": { + "types": "./dist/node-adapter.d.ts", + "import": "./dist/node-adapter.js" + }, + "./schema/schema.json": "./schema/schema.json" + } + } + ``` + + Add optional peer dependency: + + ```json + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0", + "ws": ">=8.0.0" + }, + "peerDependenciesMeta": { + "ws": { "optional": true } + } + ``` + + Add dev dependencies: `"@types/ws": "^8.0.0"`, `"ws": "^8.0.0"`. + +- [x] 7.2. `src/acp.ts` — verify — Keep main export browser-safe. + + Requirements: + - Do **not** re-export `server.ts`, `node-adapter.ts`, `http-stream.ts`, or `ws-stream.ts` from the main entry point. + - Existing core exports (`AgentSideConnection`, `ClientSideConnection`, `ndJsonStream`, etc.) remain unchanged. + - Browser consumers who import `@agentclientprotocol/sdk` must not pull in Node-only types or `ws`. + +- [x] 7.3. `tsconfig.json` — verify — Ensure Web API and Node types available for build. + + Requirements: + - Existing `lib` includes `ESNext`, `DOM`, `DOM.Iterable`. + - `@types/node` remains dev dependency for `node-adapter.ts`. + - No source file outside `node-adapter.ts` should import `node:*` modules. + +- [x] 7.4. `src/examples/http-server.ts` — create — Example Node HTTP server. + + Requirements: + - Uses `AcpServer` from `@agentclientprotocol/sdk/server`. + - Uses `createNodeHttpHandler` from `@agentclientprotocol/sdk/node`. + - Uses `node:http` (not `node:http2`) to keep WebSocket upgrade examples clear. + - Shows manual `ws.WebSocketServer` upgrade wiring pattern. + - Shows auth middleware pattern (comment/skeleton before routing to `AcpServer`). + +- [x] 7.5. `src/examples/http-client.ts` — create — Example HTTP client. + + Requirements: + - Uses `createHttpStream` from `@agentclientprotocol/sdk/http-client`. + - Uses `ClientSideConnection` from `@agentclientprotocol/sdk`. + - Demonstrates custom headers for Bearer token auth. + - Documents the reconnect-scoped `MemoryAcpCookieStore` pattern in comments after Phase 10. + +- [x] 7.6. `src/examples/ws-client.ts` — create — Example WebSocket client. + + Requirements: + - Uses `createWebSocketStream` from `@agentclientprotocol/sdk/ws-client`. + - Documents browser WebSocket header limitation. + - Shows how to pass `ws.WebSocket` constructor in Node environments. + +### Phase 9: Protocol Compliance Hardening from PR Review + +Phase 9 closes the protocol-compliance gaps found during PR review of `agentclientprotocol/typescript-sdk#155` against `acp-web-transport/rfd.md`. It also records how the Rust reference branch (`agentclientprotocol/rust-sdk` compare `main...alexhancock:rust-sdk:feat/http-ws-transport`) handles each area so the TypeScript implementation can copy the good patterns without inheriting the same spec drift. + +#### Rust Reference Comparison + +| Area | RFD requirement | Rust branch status | Rust implementation notes | TypeScript Phase 9 decision | +| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| WebSocket upgrade `Acp-Connection-Id` | Create a connection during `GET /acp` WebSocket upgrade and return `Acp-Connection-Id` on the `101 Switching Protocols` response. | ✅ Implemented | `websocket_server.rs#handle_ws_upgrade` calls `registry.create_connection()` before `ws.on_upgrade(...)`, then inserts `HEADER_CONNECTION_ID` into the upgrade response headers. | Copy this pattern for Node `ws` by adding an upgrade helper that creates the connection before `handleUpgrade()` completes and passes it into the WebSocket session. | +| First WebSocket frame | Client must still send `initialize` as the first JSON-RPC text frame after upgrade. | ❌ Missing | Rust starts the router during upgrade and forwards any parsed JSON-RPC text frame to the agent in `run_ws`; it does not reject non-`initialize` first frames. | Keep the current TypeScript first-frame validation, but adapt it to a pre-created connection. | +| Binary WebSocket frames | Binary frames are ignored. | ✅ Implemented | Server and client match only `WsMessage::Text`; `WsMessage::Binary(_)` is logged and ignored. | Copy the strict behavior: accept only string/text events, never decode `ArrayBuffer`, typed arrays, or Buffer arrays into JSON-RPC. | +| Session header requirement | Session-scoped POSTs must include `Acp-Connection-Id` and `Acp-Session-Id`. | ❌ Missing / relaxed | Rust routes by `Acp-Session-Id` **or** `params.sessionId` and its docs say either is accepted. | Tighten TypeScript to the RFD: require `Acp-Session-Id` for session-scoped methods; optionally validate it matches `params.sessionId`. | +| Session SSE before `session/load` | Resume flow opens a session-scoped GET for an existing session before POSTing `session/load`. | ✅ Mostly implemented | `connection.subscribe_session_stream(session_id)` calls `session_stream(session_id)`, which creates a stream if missing. | Adopt this behavior for GET: create/subscribe the session stream instead of returning 404 when the session stream is not already known, while still preserving unknown connection 404s. | +| `session/load` response route | RFD shows `session/load` final response on connection-scoped stream; replay/session updates go to session-scoped stream. | ❌ Missing | Rust records pending route from the request's session ID, so the final `session/load` response routes to the session stream. | Special-case `session/load`: ensure the session stream exists, route updates to the session stream, but record the request's pending response route as `connection`. | +| Cookie support | HTTP clients must handle cookies for the duration of the connection. | ❌ Missing | Rust uses `reqwest::Client::new()` and workspace `reqwest` features do not include `cookies`; no cookie jar handling found. | Implement first-class TypeScript cookie behavior instead of delegating entirely to user-provided fetch. | +| Exact POST content type | POST `Content-Type` must be `application/json` (parameters allowed); otherwise 415. | ❌ Too permissive | Rust uses `ct.starts_with(JSON_MIME_TYPE)`, which accepts `application/jsonfoobar`. | Parse the media type before `;` and require exact `application/json`. | + +#### Phase 9 Tasks + +- [x] 9.1. `src/server.ts` / `src/ws-server.ts` — refactor WebSocket server entrypoints to support pre-upgrade connection creation. + + Requirements: + - Remove `AcpServer.handleWebSocket(socket)` because already-upgraded sockets cannot attach `Acp-Connection-Id` to the upgrade response and the Rust reference exposes only the pre-upgrade path. + - Add a Node-oriented upgrade helper, e.g. `createNodeWebSocketUpgradeHandler(acpServer, webSocketServer)` or `AcpServer.prepareWebSocketUpgrade()`, that: + 1. creates a `ConnectionState` before the upgrade is accepted, + 2. returns the generated `connectionId` so the adapter can include `Acp-Connection-Id` in the `101` response headers, + 3. passes the pre-created connection into the WebSocket session after upgrade, + 4. cleans up the pre-created connection if the upgrade fails or the socket closes before `initialize`. + - Use the Rust `handle_ws_upgrade` pattern as the guide for connection creation and header insertion. + - Preserve TypeScript's current stricter first-frame rule: the first text frame on the pre-created connection must be `initialize`; otherwise close the socket and remove the connection. + +- [x] 9.2. `src/node-adapter.ts` / new Node upgrade adapter — add a compliant WebSocket upgrade path. + + Requirements: + - For Node `ws`, use the `headers` callback or equivalent `handleUpgrade` hook to add `Acp-Connection-Id` to the switching-protocols response. + - Update `src/test-support/test-http-server.ts` and `src/examples/http-server.ts` to use the compliant upgrade helper instead of directly calling `wss.handleUpgrade(..., ws => acpServer.handleWebSocket(ws))`. + - Add tests that assert the WebSocket handshake response includes `Acp-Connection-Id`. + +- [x] 9.3. `src/ws-utils.ts` / `src/ws-stream.ts` / `src/ws-server.ts` — enforce text-only WebSocket messages. + + Requirements: + - Treat browser `MessageEvent.data` values that are `ArrayBuffer`, `Blob`, typed arrays, or other binary-like objects as binary and ignore them. + - Treat Node `ws` binary frames (`isBinary === true`, `Buffer`, `Array`) as binary and ignore them. + - Only parse JSON-RPC from actual string text frames. + - Add unit tests for browser-style `ArrayBuffer` binary frames containing valid JSON to prove they are ignored. + - Use Rust's `WsMessage::Text` / `WsMessage::Binary` split as the behavioral guide. + +- [x] 9.4. `src/server.ts` / `src/protocol.ts` — tighten session-scoped HTTP POST validation. + + Requirements: + - For methods where `methodRequiresSessionHeader(method)` is true, require `Acp-Session-Id`; do not allow `params.sessionId` alone to satisfy the transport requirement. + - If both `Acp-Session-Id` and `params.sessionId` are present and differ, return 400. + - For non-session-scoped methods, continue routing connection-level requests to the connection stream. + - Decide whether non-required methods that include `params.sessionId` may still route to a session stream; document the decision in tests. + - Add regression tests for missing header, matching header/params, and mismatched header/params. + - Note: Rust intentionally accepts header **or** params; do **not** copy that behavior if the RFD remains the acceptance criteria. + +- [x] 9.5. `src/server.ts` / `src/connection.ts` — implement RFD-compatible `session/load` resume routing. + + Requirements: + - Allow session-scoped GET to open for a session ID that has not yet produced a local stream on the new connection, matching Rust's `subscribe_session_stream()` behavior. + - Special-case `session/load` pending response routing so the final response goes to the connection-scoped stream. + - Ensure replayed session updates and session-scoped server→client requests still go to the session stream based on `params.sessionId`. + - Add E2E coverage for the RFD resume flow: initialize new connection, open connection SSE, open session SSE with existing `Acp-Session-Id`, POST `session/load`, receive replay/update events on session SSE, receive final response on connection SSE. + - Document any limits if the SDK test agent cannot fully emulate durable cross-connection session history. + +- [x] 9.6. `src/http-stream.ts` — implement first-class cookie support for HTTP clients. + + Phase 9 status: complete as an initial connection-scoped implementation. Phase 10 supersedes the lifetime model to support reconnect-scoped caller-owned stores. + + Requirements implemented in Phase 9: + - Browser fetch path sends credentials by default for transport requests where the platform supports it. + - Node/custom fetch path uses a minimal per-stream cookie jar that captures `Set-Cookie` and sends `Cookie` on subsequent initialize/GET/POST/DELETE requests. + - Cookies are scoped to one `createHttpStream()` instance and are cleared when that stream closes. + - Caller-provided headers and fetch implementations are preserved. + - Tests cover `Set-Cookie` on initialize and cookie propagation to connection SSE, session SSE, POST, and DELETE. + - Note: Rust does not implement this; do not treat Rust as a reference for cookie behavior. + + Phase 10 follow-up: + - Move this cookie logic into shared `src/cookie-store.ts`. + - Allow callers to pass a reusable `cookieStore` that survives stream close/error and can be shared across HTTP and WebSocket reconnects. + +- [x] 9.7. `src/server.ts` — parse POST `Content-Type` exactly. + + Requirements: + - Accept `application/json` and `application/json; charset=utf-8`. + - Reject `application/jsonfoobar`, `application/json-patch+json`, missing content type, and non-JSON content types with 415. + - Add focused tests. + - Note: Rust uses `starts_with`; this is a known non-reference behavior. + +- [x] 9.9. `src/http-stream.test.ts` / `src/server-session-sse.test.ts` / `src/ws-stream.test.ts` — add protocol compliance regression tests. + + Required coverage: + - WebSocket handshake exposes `Acp-Connection-Id`. + - First WebSocket frame must be `initialize`. + - Binary WebSocket frames are ignored even if their decoded bytes look like JSON-RPC. + - Session-scoped POST without `Acp-Session-Id` returns 400. + - Mismatched `Acp-Session-Id` and `params.sessionId` returns 400. + - `session/load` resume flow routing matches the RFD. + - Cookie propagation across the HTTP connection lifecycle. + - Exact `Content-Type` validation. + +- [x] 9.10. Documentation/examples — update public guidance after hardening. + + Requirements: + - Update HTTP and WebSocket examples to use the compliant Node upgrade helper and cookie-aware HTTP client behavior. + - Document that the compliant WebSocket server path is the pre-upgrade helper (`prepareWebSocketUpgrade` / Node adapter) because already-upgraded sockets cannot attach `Acp-Connection-Id` to the `101` response. + - Document browser WebSocket header limitations separately from cookie behavior. + - Update `acp-web-transport/rfd.md` or open a follow-up RFD issue if Phase 9 chooses to align with Rust's relaxed behavior instead of the current RFD wording. + +--- + +### Phase 10: v1 Durability and Reconnect Affinity from Updated RFD + +Phase 10 updates the SDK plan for the v1 durability/affinity language added in `agentclientprotocol/agent-client-protocol#1376`. The goal is to provide transport primitives and examples, not automatic reconnect or SDK-owned session persistence. + +#### Updated RFD interpretation for implementation + +- A reconnect creates a fresh transport connection and therefore a new `Acp-Connection-Id`. +- The stable application identity is the ACP `sessionId`; a client resumes by calling `session/load` after `initialize` on the new connection. +- `session/load` only works when the agent advertises `agentCapabilities.loadSession` and can restore state from agent/deployment-owned storage. +- Affinity cookies are routing hints used by a load balancer or external affinity layer. They are not authentication or authorization tokens. +- Servers/agents must authorize `session/load` against the authenticated principal. The SDK examples/tests should mention this but do not add a generic auth framework. +- No v1 transport-level message replay: server-to-client messages emitted while disconnected are lost unless the agent chooses to replay history through `session/load`. + +#### Phase 10 Tasks + +- [ ] 10.1. `src/cookie-store.ts` — create — Shared minimal cookie/affinity store. + + Public API, re-exported from both `@agentclientprotocol/sdk/http-client` and `@agentclientprotocol/sdk/ws-client`; no new package subpath is required for v1. + + ```typescript + export interface AcpCookieStore { + store(headers: Headers): void; + apply(headers: Headers): void; + clear(): void; + } + + export class MemoryAcpCookieStore implements AcpCookieStore { + store(headers: Headers): void; + apply(headers: Headers): void; + clear(): void; + } + ``` + + Implementation notes: + - Move the existing private `ConnectionCookieJar` logic out of `src/http-stream.ts`. + - Preserve the existing `Set-Cookie` handling: prefer `Headers.getSetCookie()` when available, otherwise split a combined `Set-Cookie` header while respecting `Expires=...` commas. + - Store only cookie name/value pairs. Same-name later cookies overwrite earlier values. + - `apply(headers)` merges managed cookies into the outgoing `Cookie` header. + - If the caller already supplied `Cookie`, caller values override managed values with the same name. + - Do not implement domain/path matching, expiry, `Secure`, `HttpOnly`, or `SameSite`; document this as a minimal ACP affinity helper. + - Keep the module browser-safe: no Node imports and no `ws` dependency. + +- [ ] 10.2. `src/cookie-store.test.ts` — create — Unit tests for the shared cookie store. + + Required coverage: + - Stores single and multiple `Set-Cookie` values. + - Splits combined `Set-Cookie` headers containing `Expires=` correctly. + - Ignores malformed cookie headers. + - Later cookies with the same name overwrite earlier values. + - `apply()` writes a `Cookie` header when managed cookies exist. + - Managed cookies merge with caller-provided `Cookie` headers. + - Caller-provided cookie values override managed values for duplicate names. + - `clear()` removes all managed cookies. + +- [ ] 10.3. `src/http-stream.ts` — modify — Replace per-stream private cookie jar with optional caller-owned reconnect store. + + Public options: + + ```typescript + export interface HttpStreamOptions { + readonly fetch?: typeof globalThis.fetch; + readonly headers?: Record; + readonly cookies?: "include" | "omit"; + readonly cookieStore?: AcpCookieStore; + } + ``` + + Requirements: + - Import and use `AcpCookieStore` / `MemoryAcpCookieStore` from `src/cookie-store.ts`. + - Re-export `AcpCookieStore` and `MemoryAcpCookieStore` from `src/http-stream.ts`. + - Default `cookies` to `"include"` for RFD-compliant cookie support. + - If `options.cookieStore` is omitted, create an SDK-owned `MemoryAcpCookieStore` for this stream instance. + - If `options.cookieStore` is provided, use it as caller-owned reconnect state and do **not** clear it automatically on close/error. + - Store/apply cookies only when `cookies === "include"`. + - Continue to set fetch `credentials` to the selected cookie policy so browser/native fetch cookie jars participate. + - On stream close/error, clear only SDK-owned ephemeral stores; caller-owned stores are cleared only when the caller invokes `cookieStore.clear()`. + - Preserve custom header behavior and caller `Cookie` override semantics. + +- [ ] 10.4. `src/http-stream.test.ts` — modify — Add HTTP reconnect cookie coverage. + + Required coverage: + - Existing per-stream cookie lifecycle tests continue to pass with the shared store implementation. + - A caller-owned `MemoryAcpCookieStore` preserves cookies across two separate `createHttpStream()` instances. + - The second stream's `initialize` POST includes cookies captured by the first stream. + - Closing/erroring the first stream does not clear a caller-owned store. + - SDK-owned ephemeral stores are still cleared on close/error. + - `cookies: "omit"` neither stores nor applies managed cookies and sends fetch `credentials: "omit"`. + - Caller-provided `headers.Cookie` overrides managed values with the same cookie name. + +- [ ] 10.5. `src/ws-stream.ts` / `src/ws-utils.ts` — modify — Add reconnect cookie support for Node/custom WebSocket clients. + + Public options: + + ```typescript + export interface WebSocketStreamOptions { + readonly protocols?: string[]; + readonly headers?: Record; + readonly WebSocket?: WebSocketConstructor; + readonly cookies?: "include" | "omit"; + readonly cookieStore?: AcpCookieStore; + } + ``` + + Requirements: + - Import and use `AcpCookieStore` / `MemoryAcpCookieStore` from `src/cookie-store.ts`. + - Re-export `AcpCookieStore` and `MemoryAcpCookieStore` from `src/ws-stream.ts`. + - Default `cookies` to `"include"`. + - Build constructor headers from `options.headers`, apply the cookie store when cookies are included, then pass the merged headers to constructors that support `{ headers }` (Node `ws`). + - Preserve caller `Cookie` override semantics. + - Subscribe to a Node-style WebSocket `upgrade` event when available and call `cookieStore.store(...)` for upgrade response headers containing `Set-Cookie`. + - If upgrade headers are exposed as `IncomingMessage.headers`, convert them to a Web `Headers` object before storing. + - If the constructor/runtime ignores headers or does not expose upgrade headers (browser WebSocket), do not throw; document that the platform cookie jar handles browser cookies. + - Do not send cookies when `cookies === "omit"`. + +- [ ] 10.6. `src/ws-stream.test.ts` — modify — Add WebSocket cookie/affinity tests. + + Required coverage: + - A fake/custom WebSocket constructor receives a `Cookie` header from a caller-owned `MemoryAcpCookieStore`. + - Custom non-cookie headers still pass through. + - Caller-provided `headers.Cookie` overrides managed duplicate cookie names. + - A fake Node-style `upgrade` event with `Set-Cookie` stores cookies into the shared store. + - A second `createWebSocketStream()` using the same store includes the cookie captured from the first upgrade. + - `cookies: "omit"` does not apply managed cookies or store upgrade cookies. + - Existing text-frame, binary-ignore, close/error, and E2E behavior remains unchanged. + +- [ ] 10.7. `src/server-session-sse.test.ts` / `src/http-stream.test.ts` — modify — Add HTTP session-survives-disconnect E2E with external durable state. + + Test model: + - Define a `DurableSessionAgent` whose constructor receives an external `Map` outside `createAgent`. + - Each connection gets a fresh agent instance, proving state is not process-local to the transport connection or agent object. + - `initialize()` advertises `agentCapabilities.loadSession: true`. + - `newSession()` creates a high-entropy `sessionId`, stores durable state in the external map, and returns the ID. + - A prompt or explicit test helper adds replayable history to the stored session. + - The first HTTP stream is closed/dropped, deleting only the transport connection. + - A second HTTP stream initializes with a new `Acp-Connection-Id`, calls `loadSession({ sessionId, cwd, mcpServers })`, receives replayed `session/update` notifications, and receives the final `session/load` response. + + Assertions: + - First and second connection IDs differ. + - The durable session ID is the same across connections. + - Replay/update notifications arrive on the new connection's session path. + - Closing/deleting the first transport connection does not remove durable session state. + - The test comments explicitly state that production agents must authorize `session/load`; the test omits auth only because the SDK has no generic auth layer. + +- [ ] 10.8. `src/ws-stream.test.ts` — modify — Add WebSocket session-survives-disconnect E2E with external durable state. + + Requirements: + - Reuse the same durable-agent pattern as 10.7. + - First WebSocket connection initializes and creates a session. + - First WebSocket closes, removing only transport connection state. + - Second WebSocket connection initializes and calls `session/load` for the existing `sessionId`. + - Replay updates and the `session/load` response arrive over the second WebSocket. + - If a caller-owned `MemoryAcpCookieStore` is used in the test, the same store is passed to both WebSocket stream instances. + +- [ ] 10.9. Examples and JSDoc — update public guidance for v1 reconnect semantics. + + Files: + - `src/examples/http-client.ts` + - `src/examples/ws-client.ts` + - `src/examples/http-server.ts` + - JSDoc in `src/http-stream.ts`, `src/ws-stream.ts`, and `src/cookie-store.ts` + + Requirements: + - Show `const cookieStore = new MemoryAcpCookieStore()` living outside individual stream instances. + - Show reconnect flow at a high level: save `sessionId`, create a new stream with the same cookie store and auth headers, call `initialize`, check `agentCapabilities.loadSession`, then call `loadSession`. + - State that `session/load` is capability-gated and application-authz-gated. + - State that v1 does not replay in-flight transport messages emitted while disconnected. + - For server examples, use an external `Map` only as illustrative durable state and explain that multi-node deployments should replace it with Redis/Postgres/shared storage or rely on sticky sessions. + - For WebSocket examples, document that browser WebSocket uses the platform cookie jar and Node `ws` uses constructor headers. + +- [ ] 10.10. Verification — run SDK checks after Phase 10. + + Required commands: + + ```bash + npm run lint + npm run format:check + npm run build + npm run test + npm run docs:ts:verify + ``` + + If `typos-cli` is available, also run: + + ```bash + npm run check + ``` + +--- + +## Acceptance Criteria + +### HTTP Client Transport + +**Given** a running ACP HTTP server +**When** a client creates a stream with `createHttpStream(url)` +**Then** it can initialize, create sessions, send prompts, and receive streaming responses over HTTP/SSE + +**Given** a client connected over HTTP +**When** the agent sends `session/request_permission` +**Then** the request arrives on the session SSE stream and the client can POST a response that the agent receives + +**Given** a client configured with custom headers +**When** it sends initialize, GET SSE, POST, and DELETE requests +**Then** the custom headers are present on every request + +**Given** a server sets cookies during the HTTP transport lifecycle +**When** a client uses `createHttpStream(url)` +**Then** cookies are preserved for that stream's initialize, SSE GET, connected POST, session SSE, and DELETE requests + +**Given** a caller-owned `MemoryAcpCookieStore` +**When** a client creates a second `createHttpStream(url, { cookieStore })` after the first stream closes or drops +**Then** cookies captured by the first stream are sent on the second stream's `initialize` request and are not cleared unless the caller invokes `cookieStore.clear()` + +### WebSocket Client Transport + +**Given** a running ACP WebSocket server +**When** a client creates a stream with `createWebSocketStream(url)` +**Then** it can initialize, create sessions, send prompts, and receive streaming responses over text frames + +**Given** a browser WebSocket environment +**When** custom headers are provided +**Then** the limitation is documented and headers are not assumed to work + +**Given** a Node/custom WebSocket constructor and a caller-owned `MemoryAcpCookieStore` +**When** a client creates a second `createWebSocketStream(url, { WebSocket, cookieStore })` after the first stream captures upgrade cookies +**Then** managed cookies are sent in the second constructor's `headers.Cookie`, while browser WebSocket relies on the platform cookie jar instead + +### Server HTTP Transport + +**Given** an `AcpServer` +**When** a client POSTs `initialize` without `Acp-Connection-Id` +**Then** the server returns 200 with JSON initialize response and `Acp-Connection-Id` + +**Given** an initialized connection +**When** a client GETs with `Acp-Connection-Id` and `Accept: text/event-stream` +**Then** the server returns an SSE stream with keepalive comments + +**Given** an initialized connection +**When** a client POSTs `session/new` +**Then** the server returns 202 and the session response arrives on the connection SSE stream + +**Given** an active session +**When** a client POSTs `session/prompt` +**Then** updates and the final response arrive on the session SSE stream + +**Given** a session stream is not attached yet +**When** session messages are produced +**Then** up to 1024 messages are buffered, oldest messages are evicted on overflow, and the connection remains open + +**Given** a session-scoped HTTP method such as `session/prompt` +**When** the POST omits `Acp-Session-Id` +**Then** the server returns 400 even if `params.sessionId` is present + +**Given** `Acp-Session-Id` and `params.sessionId` are both present +**When** their values differ +**Then** the server returns 400 and does not forward the request to the agent + +**Given** a client resumes an existing session on a new connection +**When** it opens session SSE before POSTing `session/load` +**Then** the session SSE opens successfully, session updates route to it, and the `session/load` response arrives on the connection SSE stream + +**Given** an agent backed by durable state outside per-connection agent instances +**When** the first HTTP or WebSocket connection closes and a second connection calls `session/load` with the same `sessionId` +**Then** the SDK transport supports replay/history notifications and the final `session/load` response on the new connection without deleting durable session state + +### Server WebSocket Transport + +**Given** an `AcpServer` and the compliant Node WebSocket upgrade helper +**When** a consumer accepts an upgrade through `prepareWebSocketUpgrade()` / `createNodeWebSocketUpgradeHandler()` +**Then** the server processes JSON-RPC messages bidirectionally and cleans up on disconnect + +**Given** a Node `ws.WebSocket` +**When** it is accepted through the Node upgrade helper +**Then** the compatibility adapter handles message/close/error events correctly + +**Given** a client performs a WebSocket upgrade through the Node helper +**When** the server accepts the upgrade +**Then** the `101 Switching Protocols` response includes `Acp-Connection-Id` + +**Given** a WebSocket connection has just opened +**When** the first text frame is not an `initialize` request +**Then** the server closes the WebSocket and removes the connection + +**Given** a binary WebSocket frame contains bytes that decode to valid JSON-RPC +**When** the frame is received by client or server transport code +**Then** the frame is ignored and not delivered to ACP handlers + +### Error Handling + +**Given** an `AcpServer` +**When** a POST lacks JSON content type +**Then** the server returns 415 + +**Given** a POST uses `Content-Type: application/json; charset=utf-8` +**When** it is received +**Then** the server accepts it as JSON + +**Given** a POST uses `Content-Type: application/jsonfoobar` or `application/json-patch+json` +**When** it is received +**Then** the server returns 415 + +**Given** a POST body is a JSON array +**When** it is received +**Then** the server returns 501 + +**Given** a GET lacks `Accept: text/event-stream` +**When** it is received +**Then** the server returns 406 + +**Given** a request references an unknown connection or session +**When** it is received +**Then** the server returns 404 + +### Packaging + +**Given** a browser client imports `@agentclientprotocol/sdk` +**When** it type-checks/bundles +**Then** Node-only modules and `ws` types are not pulled in through the main entry + +**Given** a server imports `@agentclientprotocol/sdk/server` +**When** it type-checks +**Then** `AcpServer` is available without importing Node adapter code + +**Given** a Node app imports `@agentclientprotocol/sdk/node` +**When** it type-checks +**Then** `createNodeHttpHandler` is available and Node types are isolated to that subpath + +## Testing Strategy + +All SDK tests run with existing `vitest` via `npm run test`. + +### SDK tests (`typescript-sdk`) + +| File | Phase | Scope | +| -------------------------------- | -------- | ------------------------------------------------------------------------------------------------- | +| `src/protocol.test.ts` | 0 | Protocol helper unit tests | +| `src/cookie-store.test.ts` | 10 | Shared cookie store parsing, merging, clear, and reconnect-affinity behavior | +| `src/sse.test.ts` | 0 | SSE serialization/parsing unit tests | +| `src/connection.test.ts` | 2–3 | OutboundStream, registry, connection/session router unit tests | +| `src/node-adapter.test.ts` | 1 | Node adapter conversion/streaming unit tests | +| `src/server.test.ts` | 1–2, 9 | Server HTTP behavior, error handling, initialize, connection SSE, content-type/session validation | +| `src/server-session-sse.test.ts` | 3, 9, 10 | Session SSE routing, prompt streaming, replay, isolation, resume/load, durable reconnect | +| `src/http-stream.test.ts` | 5, 9, 10 | HTTP client transport unit tests + E2E, cookie lifecycle and reconnect cookie-store coverage | +| `src/ws-stream.test.ts` | 6, 9, 10 | WebSocket client/server E2E, upgrade headers, strict binary handling, reconnect cookies/load | + +SDK integration and E2E tests start in-process local HTTP/WebSocket servers via `test-support/test-http-server.ts` and shut them down after each test. No external services required. + +### Gated real-agent tests (Alta) + +| File | Phase | Scope | +| --------------------------- | ----- | ---------------------------------------------------- | +| `alta/.../http-e2e.test.ts` | 1 | Real agent initialize over HTTP | +| `alta/.../http-e2e.test.ts` | 3 | Real agent prompt streaming over HTTP | +| `alta/.../http-e2e.test.ts` | 4 | Real agent permission flow over HTTP | +| `alta/.../http-e2e.test.ts` | 5 | Real agent full flow with SDK `ClientSideConnection` | +| `alta/.../http-e2e.test.ts` | 6 | Real agent over WebSocket | +| `alta/.../http-e2e.test.ts` | 10 | Optional real agent reconnect/load smoke | + +Gated behind `ACP_INTEGRATION_TEST` environment variable. Require a running Rivet actor backend and valid agent credentials. Follows the pattern established by `alta/packages/acp-transport-stdio/tests/integration/stdio-e2e.test.ts`. The test file accumulates scenarios across phases — each phase adds new test cases to the same file. + +## Deferred Future Work + +- SSE resumability via `Last-Event-ID` and protocol-level in-flight message replay. +- `Acp-Protocol-Version` header. +- Built-in WebSocket origin validation. +- Batch JSON-RPC support. +- Async/per-request agent factory. +- Server-side Cloudflare Workers support via external state store. +- HTTP ↔ WebSocket auto-negotiation/fallback. +- Full RFC6265 cookie jar semantics beyond the Phase 10 minimal affinity store. +- Built-in durable session store or automatic reconnect/retry manager. + +## Phase Summary + +| Phase | Status | What You Build | E2E Validation | Real Agent E2E | Complexity | +| ------ | ----------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------ | ---------- | +| **0** | ✅ Complete | `protocol.ts` + `sse.ts` | Unit tests only | — | 🟢 Low | +| **1** | ✅ Complete | Initialize over HTTP + Alta adapter | `fetch` → 200 JSON | Gated: real agent initialize | 🟢 Low | +| **2** | ✅ Complete | Connection SSE + `session/new` | POST → 202 → SSE response | — | 🟡 Medium | +| **3** | ✅ Complete | Session SSE + prompt streaming | Full streaming prompt flow | Gated: real agent prompt | 🟡 Medium | +| **4** | ✅ Complete | Bidirectional (permissions) | Server→client requests over SSE | Gated: real agent permission flow | 🔴 Hard | +| **5** | ✅ Complete | `createHttpStream` + Relay adapter | SDK `ClientSideConnection` over HTTP | Gated: full flow with SDK client | 🟡 Medium | +| **6** | ✅ Complete | WebSocket transport + Alta WS wiring | Same flows over WS | Gated: real agent over WS | 🟡 Medium | +| **7** | ✅ Complete | Package config + examples | Subpath exports, docs, examples | — | 🟢 Low | +| **9** | ✅ Complete | Protocol compliance hardening | RFD regression tests for WS upgrade, cookies, strict headers, resume/load, binary frames, and content type | Gated rerun for HTTP + WS real-agent flows | 🔴 Hard | +| **10** | ⏳ Pending | v1 durability + reconnect affinity | Shared cookie store across reconnects; durable-session `session/load` E2E over HTTP/WS | Optional gated reconnect/load smoke | 🟡 Medium | + +## File Summary + +### SDK files (`typescript-sdk`) + +| File | Phase | Action | Description | +| -------------------------------------- | --------- | ------------- | -------------------------------------------------------------------------- | +| `src/protocol.ts` | 0 | Create | Internal header/method/session helpers | +| `src/protocol.test.ts` | 0 | Create | Protocol helper unit tests | +| `src/cookie-store.ts` | 10 | Create | Shared minimal cookie/affinity store for HTTP and WS reconnects | +| `src/cookie-store.test.ts` | 10 | Create | Cookie parsing, merging, clear, and reconnect reuse tests | +| `src/sse.ts` | 0 | Create | SSE serialization/deserialization utilities | +| `src/sse.test.ts` | 0 | Create | SSE unit tests | +| `src/connection.ts` | 1–3 | Create | Connection registry, OutboundStream, router (built incrementally) | +| `src/connection.test.ts` | 2–3 | Create | Registry/routing unit tests, including session stream routing | +| `src/server.ts` | 1–4, 6, 9 | Create/Modify | `AcpServer` transport — HTTP + WebSocket, strict Phase 9 protocol routing | +| `src/server.test.ts` | 1–2 | Create | Server HTTP tests + initialize/connection SSE E2E scenarios | +| `src/server-session-sse.test.ts` | 3, 9, 10 | Create/Modify | Session SSE prompt streaming, replay, resume/load, durable reconnect tests | +| `src/node-adapter.ts` | 1–2, 9 | Create/Modify | Node HTTP adapter; add compliant WS upgrade helper/header support | +| `src/node-adapter.test.ts` | 1 | Create | Node adapter unit tests | +| `src/http-stream.ts` | 5, 9, 10 | Create/Modify | Client HTTP/SSE stream with caller-owned reconnect cookie store | +| `src/http-stream.test.ts` | 5, 9, 10 | Create/Modify | HTTP client tests + reconnect cookie-store and durable load E2E coverage | +| `src/ws-server.ts` | 6, 9 | Create/Modify | Internal server-side WebSocket adapter with pre-created connection support | +| `src/ws-stream.ts` | 6, 9, 10 | Create/Modify | Client WebSocket stream with strict text frames and reconnect cookies | +| `src/ws-utils.ts` | 6, 9, 10 | Create/Modify | Shared WebSocket helpers; strict binary/text and upgrade-cookie utilities | +| `src/ws-stream.test.ts` | 6, 9, 10 | Create/Modify | WebSocket tests + upgrade, binary-frame, cookie, durable load regressions | +| `src/test-support/test-agent.ts` | 1 | Create | Self-contained configurable test agent | +| `src/test-support/test-http-server.ts` | 1, 6, 9 | Create/Modify | E2E test HTTP/WS server helper using compliant WS upgrade path | +| `src/examples/http-server.ts` | 7, 10 | Create/Modify | Node HTTP server example with WS upgrade and durable session-state notes | +| `src/examples/http-client.ts` | 7, 10 | Create/Modify | HTTP client example with reconnect cookie-store/loadSession guidance | +| `src/examples/ws-client.ts` | 7, 10 | Create/Modify | WebSocket client example with reconnect cookie-store/loadSession guidance | +| `package.json` | 7 | Modify | Subpath exports, optional `ws` peer dep | +| `src/acp.ts` | 7 | Verify | Keep main entry browser-safe | +| `tsconfig.json` | 7 | Verify | Required Web/Node types | + +### External files (Alta / Relay) + +| File | Phase | Action | Description | +| ------------------------------ | ------------- | ------ | ------------------------------------------------------------------------------------------------------ | +| `alta/.../http-entrypoint.ts` | 1–3 | Create | HTTP server adapter wrapping `AcpBridgeAgent` in `AcpServer`; manually smoked through prompt streaming | +| `alta/.../http-entrypoint.ts` | 6 | Modify | Add WebSocket upgrade wiring | +| `alta/.../http-e2e.test.ts` | 1, 3, 4, 5, 6 | Create | Gated real-agent E2E tests (expanded each phase) | +| `relay/src/acp/http-client.ts` | 5, 6.8 | Modify | SDK-backed Relay remote client using `createHttpStream`; extend to `createWebSocketStream` for WS | diff --git a/acp-web-transport/testing.md b/acp-web-transport/testing.md new file mode 100644 index 00000000..b2e3607d --- /dev/null +++ b/acp-web-transport/testing.md @@ -0,0 +1,833 @@ +# Alta CLI ACP HTTP and WebSocket Smoke Test + +## Rebuild SDK and publish to NPM + +Totally, bro — you publish, I’ll stay hands-off. Here’s the clean manual flow that **does not modify the SDK repo package.json** and publishes a temp `@atlassian`-scoped tarball. + +From the SDK repo: + +```bash +cd /Users/fciner/code/public/typescript-sdk +npm run build +``` + +Create a temp publish staging dir: + +```bash +TMPDIR="$(mktemp -d)" +npm pack --pack-destination "$TMPDIR" +cd "$TMPDIR" +tar -xzf agentclientprotocol-sdk-*.tgz +cd package +``` + +Rewrite only the staged package metadata: + +```bash +DEV_VERSION="0.21.1-dev.fciner.$(date +%Y%m%d%H%M%S)" + +npm pkg set name="@atlassian/agentclientprotocol-sdk" +npm pkg set version="$DEV_VERSION" +npm pkg set publishConfig.registry="https://packages.atlassian.com/api/npm/atlassian-npm/" +npm pkg set publishConfig.access="restricted" + +cat package.json | grep -E '"name"|"version"|"registry"|"access"' +``` + +Publish it: + +```bash +npm publish \ + --registry "https://packages.atlassian.com/api/npm/atlassian-npm/" \ + --tag fciner-dev \ + --access restricted +``` + +Verify: + +```bash +npm view "@atlassian/agentclientprotocol-sdk@$DEV_VERSION" \ + --registry "https://packages.atlassian.com/api/npm/atlassian-npm/" +``` + +Then in Alta, consume it while keeping imports unchanged via pnpm override: + +```yaml +overrides: + "@agentclientprotocol/sdk": "npm:@atlassian/agentclientprotocol-sdk@0.21.1-dev.fciner." +``` + +## Run smoke test + +This guide smoke-tests the Alta CLI ACP HTTP transport against the SDK Streamable HTTP server transport. + +It validates: + +- initialize over HTTP +- connection-scoped SSE +- `session/new` response delivery on connection SSE +- session-scoped SSE +- `session/prompt` streaming on session SSE +- session routing via `Acp-Session-Id` +- session routing fallback via `params.sessionId` +- rejection of session-scoped requests without a session identifier +- unknown session handling +- replay of buffered session events +- connection cleanup via `DELETE` +- WebSocket upgrade at `/acp` +- initialize/session/prompt over WebSocket text frames +- optional WebSocket permission request/response flow + +## 0. Start the Alta CLI ACP HTTP server + +From the Alta repo, start the CLI in HTTP mode: + +```bash +ALTA_ACP_TRANSPORT=http \ +ALTA_ACP_HTTP_PORT=7331 \ +pnpm exec tsx services/alta-cli/bin/alta.ts agent run /Users/fciner/code/atlassian/alta-contrib/users/fciner/agents/brovodev/agent.yml \ + --workspace "$PWD" \ + --server-port "$(jq -r '.port' ~/.alta/server.lock)" +``` + +Use the equivalent local CLI command if your dev setup differs. The important environment variables are: + +```bash +ALTA_ACP_TRANSPORT=http +ALTA_ACP_HTTP_PORT=7331 +``` + +Expected log output should include a URL like: + +```text +ACP HTTP bridge listening at http://127.0.0.1:7331/acp +``` + +In the shell where you run `curl` commands: + +```bash +BASE_URL="http://127.0.0.1:7331/acp" +``` + +## 1. Initialize a connection + +```bash +curl -i \ + -X POST "$BASE_URL" \ + -H 'Content-Type: application/json' \ + --data '{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": 1, + "clientCapabilities": {}, + "clientInfo": { + "name": "curl-smoke", + "version": "0.0.0" + } + } + }' +``` + +Expected response: + +```http +HTTP/1.1 200 OK +Acp-Connection-Id: +content-type: application/json +``` + +Expected body shape: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": 1, + "agentCapabilities": {} + } +} +``` + +Copy the `Acp-Connection-Id` response header: + +```bash +CONNECTION_ID="" +``` + +## 2. Open connection-scoped SSE + +In a second terminal, leave this running: + +```bash +curl -N -i \ + -X GET "$BASE_URL" \ + -H 'Accept: text/event-stream' \ + -H "Acp-Connection-Id: $CONNECTION_ID" +``` + +Expected response headers: + +```http +HTTP/1.1 200 OK +cache-control: no-cache +connection: keep-alive +content-type: text/event-stream +Transfer-Encoding: chunked +``` + +This stream receives connection-level events, including `session/new` responses. + +## 3. Create a session + +In the first terminal: + +```bash +curl -i \ + -X POST "$BASE_URL" \ + -H 'Content-Type: application/json' \ + -H "Acp-Connection-Id: $CONNECTION_ID" \ + --data '{ + "jsonrpc": "2.0", + "id": 2, + "method": "session/new", + "params": { + "cwd": "'"$PWD"'", + "mcpServers": [] + } + }' +``` + +Expected immediate HTTP response: + +```http +HTTP/1.1 202 Accepted +``` + +Expected event on the connection SSE terminal: + +```text +data: {"jsonrpc":"2.0","id":2,"result":{"sessionId":""}} +``` + +Copy the session ID: + +```bash +SESSION_ID="" +``` + +## 4. Open session-scoped SSE + +In a third terminal, leave this running: + +```bash +curl -N -i \ + -X GET "$BASE_URL" \ + -H 'Accept: text/event-stream' \ + -H "Acp-Connection-Id: $CONNECTION_ID" \ + -H "Acp-Session-Id: $SESSION_ID" +``` + +Expected response headers: + +```http +HTTP/1.1 200 OK +cache-control: no-cache +connection: keep-alive +content-type: text/event-stream +Transfer-Encoding: chunked +``` + +This stream should receive `session/update` notifications and prompt responses for this session. + +## 5. Send a prompt with `Acp-Session-Id` + +```bash +curl -i \ + -X POST "$BASE_URL" \ + -H 'Content-Type: application/json' \ + -H "Acp-Connection-Id: $CONNECTION_ID" \ + -H "Acp-Session-Id: $SESSION_ID" \ + --data '{ + "jsonrpc": "2.0", + "id": 3, + "method": "session/prompt", + "params": { + "sessionId": "'"$SESSION_ID"'", + "prompt": [ + { + "type": "text", + "text": "Say hello in one short sentence." + } + ] + } + }' +``` + +Expected immediate HTTP response: + +```http +HTTP/1.1 202 Accepted +``` + +Expected events on the session SSE terminal: + +```text +data: {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"...","update":...}} +data: {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} +``` + +Phase 3 validation: + +- `session/update` should appear on session SSE. +- the prompt response with `"id":3` should appear on session SSE. +- session prompt events should not appear on connection SSE. + +## 6. Send a prompt without `Acp-Session-Id`, using `params.sessionId` + +This validates session route fallback from the JSON-RPC body. + +```bash +curl -i \ + -X POST "$BASE_URL" \ + -H 'Content-Type: application/json' \ + -H "Acp-Connection-Id: $CONNECTION_ID" \ + --data '{ + "jsonrpc": "2.0", + "id": 4, + "method": "session/prompt", + "params": { + "sessionId": "'"$SESSION_ID"'", + "prompt": [ + { + "type": "text", + "text": "Reply with exactly: param routed" + } + ] + } + }' +``` + +Expected immediate response: + +```http +HTTP/1.1 202 Accepted +``` + +Expected events on session SSE: + +```text +data: {"jsonrpc":"2.0","method":"session/update",...} +data: {"jsonrpc":"2.0","id":4,"result":...} +``` + +## 7. Permission flow: respond to `session/request_permission` + +This validates the bidirectional HTTP flow where the agent sends a JSON-RPC request to the client over session SSE and the client POSTs a JSON-RPC response back to the agent. + +This step requires an Alta configuration and prompt that trigger a tool permission request. Use a prompt/tool that is known in your local setup to require approval. Keep the session SSE terminal open and watch for a request shaped like this: + +```text +data: {"jsonrpc":"2.0","id":,"method":"session/request_permission","params":{"sessionId":"...","toolCall":...,"options":[...]}} +``` + +From that event, copy: + +```bash +PERMISSION_REQUEST_ID="" +PERMISSION_OPTION_ID="" +``` + +If the SSE event is long, copy the JSON payload after `data: ` into a temporary file and extract useful fields: + +```bash +PERMISSION_EVENT_JSON="$(mktemp)" +cat > "$PERMISSION_EVENT_JSON" <<'JSON' +{"jsonrpc":"2.0","id":123,"method":"session/request_permission","params":{"sessionId":"...","toolCall":{},"options":[{"kind":"allow_once","name":"Allow once","optionId":"allow_once"}]}} +JSON + +PERMISSION_REQUEST_ID="$(node -pe 'JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).id' "$PERMISSION_EVENT_JSON")" +PERMISSION_OPTION_ID="$(node -pe 'JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).params.options.find((option) => option.kind.startsWith("allow"))?.optionId' "$PERMISSION_EVENT_JSON")" + +echo "$PERMISSION_REQUEST_ID" +echo "$PERMISSION_OPTION_ID" +``` + +### 7.1 Trigger a permission request + +Use a prompt that invokes a permission-gated tool in your local Alta configuration. For example, if file writes require approval in your setup: + +```bash +curl -i \ + -X POST "$BASE_URL" \ + -H 'Content-Type: application/json' \ + -H "Acp-Connection-Id: $CONNECTION_ID" \ + -H "Acp-Session-Id: $SESSION_ID" \ + --data '{ + "jsonrpc": "2.0", + "id": 8, + "method": "session/prompt", + "params": { + "sessionId": "'"$SESSION_ID"'", + "prompt": [ + { + "type": "text", + "text": "Use a permission-gated tool in this workspace so the client must approve it." + } + ] + } + }' +``` + +Expected immediate response: + +```http +HTTP/1.1 202 Accepted +``` + +Expected event on the session SSE terminal: + +```text +data: {"jsonrpc":"2.0","id":,"method":"session/request_permission",...} +``` + +Phase 4 routing validation: + +- the `session/request_permission` request should appear on session SSE. +- the request should include `params.sessionId` matching `$SESSION_ID`. +- the request should not appear on connection SSE. + +### 7.2 Approve the permission request + +POST a JSON-RPC response using the permission request ID from session SSE and an allow option ID from `params.options`: + +```bash +curl -i \ + -X POST "$BASE_URL" \ + -H 'Content-Type: application/json' \ + -H "Acp-Connection-Id: $CONNECTION_ID" \ + --data '{ + "jsonrpc": "2.0", + "id": '"$PERMISSION_REQUEST_ID"', + "result": { + "outcome": { + "outcome": "selected", + "optionId": "'"$PERMISSION_OPTION_ID"'" + } + } + }' +``` + +Expected immediate response: + +```http +HTTP/1.1 202 Accepted +``` + +Expected follow-up events on the session SSE terminal: + +```text +data: {"jsonrpc":"2.0","method":"session/update",...} +data: {"jsonrpc":"2.0","id":8,"result":...} +``` + +### 7.3 Reject or cancel instead of approving + +To validate the rejection path, choose a reject option from `params.options` and POST the same response shape with that option ID. To validate cancellation, POST: + +```bash +curl -i \ + -X POST "$BASE_URL" \ + -H 'Content-Type: application/json' \ + -H "Acp-Connection-Id: $CONNECTION_ID" \ + --data '{ + "jsonrpc": "2.0", + "id": '"$PERMISSION_REQUEST_ID"', + "result": { + "outcome": { + "outcome": "cancelled" + } + } + }' +``` + +Expected response: + +```http +HTTP/1.1 202 Accepted +``` + +The prompt should continue or complete according to the agent's permission handling policy. + +## 8. Negative test: prompt missing a session identifier + +```bash +curl -i \ + -X POST "$BASE_URL" \ + -H 'Content-Type: application/json' \ + -H "Acp-Connection-Id: $CONNECTION_ID" \ + --data '{ + "jsonrpc": "2.0", + "id": 5, + "method": "session/prompt", + "params": { + "prompt": [ + { + "type": "text", + "text": "This should be rejected." + } + ] + } + }' +``` + +Expected response: + +```http +HTTP/1.1 400 Bad Request +``` + +Expected body: + +```text +Missing Acp-Session-Id +``` + +## 9. Negative test: unknown session SSE + +```bash +curl -i \ + -X GET "$BASE_URL" \ + -H 'Accept: text/event-stream' \ + -H "Acp-Connection-Id: $CONNECTION_ID" \ + -H "Acp-Session-Id: 00000000-0000-4000-8000-000000000000" +``` + +Expected response: + +```http +HTTP/1.1 404 Not Found +``` + +Expected body: + +```text +Unknown Acp-Session-Id +``` + +## 10. Replay test: prompt before opening session SSE + +This validates that session-scoped outbound messages are buffered before the session SSE stream attaches. + +### 10.1 Create a second session + +```bash +curl -i \ + -X POST "$BASE_URL" \ + -H 'Content-Type: application/json' \ + -H "Acp-Connection-Id: $CONNECTION_ID" \ + --data '{ + "jsonrpc": "2.0", + "id": 6, + "method": "session/new", + "params": { + "cwd": "'"$PWD"'", + "mcpServers": [] + } + }' +``` + +Read the second session ID from the connection SSE terminal: + +```bash +SESSION_ID_2="" +``` + +### 10.2 Send a prompt before opening session SSE for the second session + +```bash +curl -i \ + -X POST "$BASE_URL" \ + -H 'Content-Type: application/json' \ + -H "Acp-Connection-Id: $CONNECTION_ID" \ + -H "Acp-Session-Id: $SESSION_ID_2" \ + --data '{ + "jsonrpc": "2.0", + "id": 7, + "method": "session/prompt", + "params": { + "sessionId": "'"$SESSION_ID_2"'", + "prompt": [ + { + "type": "text", + "text": "This should be replayed after SSE attaches." + } + ] + } + }' +``` + +Expected immediate response: + +```http +HTTP/1.1 202 Accepted +``` + +### 10.3 Open session SSE for the second session + +```bash +curl -N -i \ + -X GET "$BASE_URL" \ + -H 'Accept: text/event-stream' \ + -H "Acp-Connection-Id: $CONNECTION_ID" \ + -H "Acp-Session-Id: $SESSION_ID_2" +``` + +Expected replayed events: + +```text +data: {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"..."}} +data: {"jsonrpc":"2.0","id":7,"result":...} +``` + +## 11. Delete the connection + +```bash +curl -i \ + -X DELETE "$BASE_URL" \ + -H "Acp-Connection-Id: $CONNECTION_ID" +``` + +Expected response: + +```http +HTTP/1.1 202 Accepted +``` + +Expected behavior: + +- connection SSE closes +- session SSE streams close +- later POSTs with the same connection ID return `404` + +Verify cleanup: + +```bash +curl -i \ + -X POST "$BASE_URL" \ + -H 'Content-Type: application/json' \ + -H "Acp-Connection-Id: $CONNECTION_ID" \ + --data '{ + "jsonrpc": "2.0", + "id": 99, + "method": "session/new", + "params": { + "cwd": "'"$PWD"'", + "mcpServers": [] + } + }' +``` + +Expected response: + +```http +HTTP/1.1 404 Not Found +``` + +## 12. WebSocket smoke test + +WebSocket uses the same `/acp` route, but the HTTP client must perform a WebSocket upgrade and then send ACP JSON-RPC messages as WebSocket text frames. + +::: {.callout} +Plain HTTP `curl` can validate the WebSocket upgrade handshake, but it is not enough for full ACP WebSocket E2E unless your local `curl` build includes WebSocket protocol support and can send framed text messages. The macOS system `curl` commonly does not list `ws`/`wss` in `curl --version`. Use the Node script below for the full framed E2E flow. +::: + +### 12.0 Start a WebSocket-capable Alta ACP server + +This requires the Alta HTTP entrypoint to wire WebSocket upgrades to `acpServer.handleWebSocket(ws)`. + +```bash +ALTA_ACP_TRANSPORT=http \ +ALTA_ACP_HTTP_PORT=7331 \ +pnpm exec tsx services/alta-cli/bin/alta.ts agent run /Users/fciner/code/atlassian/alta-contrib/users/fciner/agents/brovodev/agent.yml \ + --workspace "$PWD" \ + --server-port "$(jq -r '.port' ~/.alta/server.lock)" +``` + +In the shell where you run smoke commands: + +```bash +BASE_URL="http://127.0.0.1:7331/acp" +WS_URL="ws://127.0.0.1:7331/acp" +``` + +### 12.1 Validate the WebSocket upgrade with `curl` + +This checks that the server accepts WebSocket upgrade requests at `/acp`. + +```bash +WS_KEY="$(openssl rand -base64 16)" + +curl -i -N --http1.1 "$BASE_URL" \ + -H 'Connection: Upgrade' \ + -H 'Upgrade: websocket' \ + -H 'Sec-WebSocket-Version: 13' \ + -H "Sec-WebSocket-Key: $WS_KEY" +``` + +Expected response: + +```http +HTTP/1.1 101 Switching Protocols +Connection: Upgrade +Upgrade: websocket +Sec-WebSocket-Accept: +``` + +After the `101`, the connection is upgraded and `curl` may appear to hang. Stop it with `Ctrl-C`. This validates upgrade wiring only; it does not validate ACP JSON-RPC frame exchange. + +### 12.2 Full WebSocket ACP E2E from the command line + +Run this from the SDK repo after `npm run build`. It uses the SDK WebSocket client transport and Node `ws` to send real WebSocket text frames. + +```bash +cd /Users/fciner/code/public/typescript-sdk +npm run build + +WS_URL="ws://127.0.0.1:7331/acp" node --input-type=module <<'NODE' +import { WebSocket } from "ws"; +import { ClientSideConnection, PROTOCOL_VERSION } from "./dist/acp.js"; +import { createWebSocketStream } from "./dist/ws-stream.js"; + +const wsUrl = process.env.WS_URL; +if (!wsUrl) { + throw new Error("WS_URL is required"); +} + +const updates = []; +const permissionRequests = []; + +const client = { + async sessionUpdate(params) { + updates.push(params); + console.log("session/update", JSON.stringify(params)); + }, + async requestPermission(params) { + permissionRequests.push(params); + console.log("session/request_permission", JSON.stringify(params)); + const allow = params.options.find((option) => option.kind?.startsWith("allow")); + return { + outcome: allow + ? { outcome: "selected", optionId: allow.optionId } + : { outcome: "cancelled" }, + }; + }, +}; + +const stream = createWebSocketStream(wsUrl, { WebSocket }); +const conn = new ClientSideConnection(() => client, stream); + +try { + const initialize = await conn.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + clientInfo: { name: "ws-smoke", version: "0.0.0" }, + }); + console.log("initialize", JSON.stringify(initialize)); + + const session = await conn.newSession({ cwd: process.cwd(), mcpServers: [] }); + console.log("session/new", JSON.stringify(session)); + + const prompt = await conn.prompt({ + sessionId: session.sessionId, + prompt: [{ type: "text", text: "Say hello over WebSocket in one short sentence." }], + }); + console.log("session/prompt", JSON.stringify(prompt)); + + console.log("updates", updates.length); + console.log("permissionRequests", permissionRequests.length); + + if (prompt.stopReason !== "end_turn") { + throw new Error(`Unexpected stopReason: ${prompt.stopReason}`); + } +} finally { + await stream.writable.close().catch(() => undefined); +} +NODE +``` + +Expected output includes: + +```text +initialize {"protocolVersion":1,...} +session/new {"sessionId":"..."} +session/update {...} +session/prompt {"stopReason":"end_turn"} +updates +``` + +### 12.3 Optional: permission flow over WebSocket + +Use a prompt that is known to trigger a permission-gated tool in your local Alta configuration. Replace the prompt text in the script above with something like: + +```text +Use a permission-gated tool in this workspace so the client must approve it. +``` + +Expected output includes: + +```text +session/request_permission {...} +session/update {...} +session/prompt {"stopReason":"end_turn"} +permissionRequests 1 +``` + +This validates bidirectional server-to-client JSON-RPC requests and client-to-server JSON-RPC responses over the same WebSocket connection. + +## Optional: extract connection ID automatically + +```bash +INIT_HEADERS="$(mktemp)" +INIT_BODY="$(mktemp)" + +curl -sS \ + -D "$INIT_HEADERS" \ + -o "$INIT_BODY" \ + -X POST "$BASE_URL" \ + -H 'Content-Type: application/json' \ + --data '{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": 1, + "clientCapabilities": {}, + "clientInfo": { + "name": "curl-smoke", + "version": "0.0.0" + } + } + }' + +cat "$INIT_BODY" +CONNECTION_ID="$(awk 'BEGIN{IGNORECASE=1} /^Acp-Connection-Id:/ {gsub(/\r/, "", $2); print $2}' "$INIT_HEADERS")" +echo "$CONNECTION_ID" +``` + +## Optional: extract session ID from an SSE JSON payload + +If your SSE terminal prints a line like this: + +```text +data: {"jsonrpc":"2.0","id":2,"result":{"sessionId":"abc"}} +``` + +Copy just the JSON payload and run: + +```bash +echo '{"jsonrpc":"2.0","id":2,"result":{"sessionId":"abc"}}' \ + | node -pe 'JSON.parse(require("node:fs").readFileSync(0, "utf8")).result.sessionId' +``` diff --git a/src/cookie-store.test.ts b/src/cookie-store.test.ts new file mode 100644 index 00000000..c43068c6 --- /dev/null +++ b/src/cookie-store.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; + +import { MemoryAcpCookieStore } from "./cookie-store.js"; + +describe("MemoryAcpCookieStore", () => { + it("stores single and multiple Set-Cookie values", () => { + const store = new MemoryAcpCookieStore(); + store.store(headersWithSetCookie(["transport=alpha; Path=/"])); + store.store( + headersWithSetCookie(["route=bravo; Path=/", "affinity=charlie"]), + ); + + const headers = new Headers(); + store.apply(headers); + + expect(headers.get("Cookie")).toBe( + "transport=alpha; route=bravo; affinity=charlie", + ); + }); + + it("splits combined Set-Cookie headers with Expires commas", () => { + const store = new MemoryAcpCookieStore(); + store.store( + new Headers({ + "Set-Cookie": + "transport=alpha; Expires=Wed, 21 Oct 2030 07:28:00 GMT, route=bravo; Path=/", + }), + ); + + const headers = new Headers(); + store.apply(headers); + + expect(headers.get("Cookie")).toBe("transport=alpha; route=bravo"); + }); + + it("ignores malformed cookie headers", () => { + const store = new MemoryAcpCookieStore(); + store.store( + headersWithSetCookie([ + "missing-separator", + "=empty-name", + " =blank", + "ok=value", + ]), + ); + + const headers = new Headers(); + store.apply(headers); + + expect(headers.get("Cookie")).toBe("ok=value"); + }); + + it("lets later cookies overwrite earlier cookies with the same name", () => { + const store = new MemoryAcpCookieStore(); + store.store(headersWithSetCookie(["route=alpha", "route=bravo"])); + + const headers = new Headers(); + store.apply(headers); + + expect(headers.get("Cookie")).toBe("route=bravo"); + }); + + it("writes a Cookie header when managed cookies exist", () => { + const store = new MemoryAcpCookieStore(); + store.store(headersWithSetCookie(["transport=alpha"])); + + const headers = new Headers(); + store.apply(headers); + + expect(headers.get("Cookie")).toBe("transport=alpha"); + }); + + it("merges managed cookies with caller-provided Cookie headers", () => { + const store = new MemoryAcpCookieStore(); + store.store(headersWithSetCookie(["transport=alpha", "route=bravo"])); + + const headers = new Headers({ Cookie: "caller=custom" }); + store.apply(headers); + + expect(headers.get("Cookie")).toBe( + "transport=alpha; route=bravo; caller=custom", + ); + }); + + it("lets caller-provided cookie values override managed duplicate names", () => { + const store = new MemoryAcpCookieStore(); + store.store(headersWithSetCookie(["transport=alpha", "route=bravo"])); + + const headers = new Headers({ Cookie: "route=caller; caller=custom" }); + store.apply(headers); + + expect(headers.get("Cookie")).toBe( + "transport=alpha; route=caller; caller=custom", + ); + }); + + it("clears managed cookies", () => { + const store = new MemoryAcpCookieStore(); + store.store(headersWithSetCookie(["transport=alpha"])); + store.clear(); + + const headers = new Headers(); + store.apply(headers); + + expect(headers.get("Cookie")).toBeNull(); + }); +}); + +function headersWithSetCookie(values: readonly string[]): Headers { + const headers = new Headers(); + + Object.defineProperty(headers, "getSetCookie", { + value: () => values, + }); + + return headers; +} diff --git a/src/cookie-store.ts b/src/cookie-store.ts new file mode 100644 index 00000000..1b9d5789 --- /dev/null +++ b/src/cookie-store.ts @@ -0,0 +1,147 @@ +/** + * Minimal ACP affinity cookie store. + * + * This helper stores cookie name/value pairs from `Set-Cookie` response + * headers and applies them to outgoing `Cookie` request headers. It is meant + * for ACP routing affinity across reconnects, not authentication or + * authorization, and not as a general-purpose browser cookie jar: it + * intentionally does not implement domain/path matching, + * expiry, `Secure`, `HttpOnly`, or `SameSite` handling. + */ +export interface AcpCookieStore { + /** Stores cookies from response headers. */ + store(headers: Headers): void; + /** Applies stored cookies to outgoing request headers. */ + apply(headers: Headers): void; + /** Clears all stored cookies. */ + clear(): void; +} + +/** In-memory implementation of {@link AcpCookieStore}. */ +export class MemoryAcpCookieStore implements AcpCookieStore { + private readonly cookies = new Map(); + + store(headers: Headers): void { + for (const value of setCookieHeaders(headers)) { + const cookie = parseSetCookie(value); + if (!cookie) { + continue; + } + + this.cookies.set(cookie.name, cookie.value); + } + } + + apply(headers: Headers): void { + const merged = mergeCookieHeaders( + this.cookieHeader(), + headers.get("Cookie"), + ); + if (merged) { + headers.set("Cookie", merged); + } + } + + clear(): void { + this.cookies.clear(); + } + + private cookieHeader(): string | undefined { + return this.cookies.size === 0 + ? undefined + : Array.from(this.cookies) + .map(([name, value]) => `${name}=${value}`) + .join("; "); + } +} + +interface CookiePair { + readonly name: string; + readonly value: string; +} + +function setCookieHeaders(headers: Headers): string[] { + const getSetCookie = headers.getSetCookie; + if (typeof getSetCookie === "function") { + return getSetCookie.call(headers).flatMap(splitSetCookieHeader); + } + + const setCookie = headers.get("Set-Cookie"); + return setCookie ? splitSetCookieHeader(setCookie) : []; +} + +function splitSetCookieHeader(header: string): string[] { + return header + .split(/,(?=\s*[^;,\s]+=)/) + .map((value) => value.trim()) + .filter((value) => value.length > 0); +} + +function parseSetCookie(header: string): CookiePair | undefined { + const pair = header.split(";", 1)[0]; + const separator = pair.indexOf("="); + + if (separator <= 0) { + return undefined; + } + + const name = pair.slice(0, separator).trim(); + if (!name) { + return undefined; + } + + return { + name, + value: pair.slice(separator + 1).trim(), + }; +} + +function mergeCookieHeaders( + managedCookieHeader: string | undefined, + callerCookieHeader: string | null, +): string | undefined { + const cookies = new Map(); + + for (const cookie of parseCookieHeader(managedCookieHeader)) { + cookies.set(cookie.name, cookie.value); + } + + for (const cookie of parseCookieHeader(callerCookieHeader ?? undefined)) { + cookies.set(cookie.name, cookie.value); + } + + return cookies.size === 0 + ? undefined + : Array.from(cookies) + .map(([name, value]) => `${name}=${value}`) + .join("; "); +} + +function parseCookieHeader(header: string | undefined): CookiePair[] { + if (!header) { + return []; + } + + return header + .split(";") + .map(parseCookiePair) + .filter((cookie): cookie is CookiePair => cookie !== undefined); +} + +function parseCookiePair(value: string): CookiePair | undefined { + const separator = value.indexOf("="); + + if (separator <= 0) { + return undefined; + } + + const name = value.slice(0, separator).trim(); + if (!name) { + return undefined; + } + + return { + name, + value: value.slice(separator + 1).trim(), + }; +} diff --git a/src/examples/http-client.ts b/src/examples/http-client.ts index f0c61a52..fde698d4 100644 --- a/src/examples/http-client.ts +++ b/src/examples/http-client.ts @@ -1,7 +1,10 @@ #!/usr/bin/env node import * as acp from "@agentclientprotocol/sdk"; -import { createHttpStream } from "@agentclientprotocol/sdk/http-client"; +import { + MemoryAcpCookieStore, + createHttpStream, +} from "@agentclientprotocol/sdk/http-client"; class HttpExampleClient implements acp.Client { async requestPermission( @@ -30,19 +33,36 @@ class HttpExampleClient implements acp.Client { } const serverUrl = process.env.ACP_HTTP_URL ?? "http://127.0.0.1:7331/acp"; -const stream = createHttpStream(serverUrl, { - headers: { - Authorization: "Bearer example-token", - }, - // Cookies are included by default and scoped to this stream. Use `cookies: "omit"` for stateless requests. -}); -const connection = new acp.ClientSideConnection( - (_agent) => new HttpExampleClient(), - stream, -); +const authHeaders = { + Authorization: "Bearer example-token", +}; + +// Keep reconnect state outside individual stream instances. Reuse this store +// across fresh streams so an external affinity layer can route reconnects. +const cookieStore = new MemoryAcpCookieStore(); +let savedSessionId: string | undefined; + +function connect(): { + readonly stream: acp.Stream; + readonly connection: acp.ClientSideConnection; +} { + const stream = createHttpStream(serverUrl, { + headers: authHeaders, + cookieStore, + // Cookies are included by default. Use `cookies: "omit"` for stateless requests. + }); + const connection = new acp.ClientSideConnection( + (_agent) => new HttpExampleClient(), + stream, + ); + + return { stream, connection }; +} + +const { stream, connection } = connect(); try { - await connection.initialize({ + const initialized = await connection.initialize({ protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: {}, }); @@ -51,6 +71,7 @@ try { cwd: process.cwd(), mcpServers: [], }); + savedSessionId = session.sessionId; const result = await connection.prompt({ sessionId: session.sessionId, @@ -63,6 +84,22 @@ try { }); console.log(`\nDone: ${result.stopReason}`); + + console.log( + `Saved session ${savedSessionId}; loadSession=${initialized.agentCapabilities?.loadSession === true}`, + ); + + // Reconnect flow sketch: + // 1. Save `sessionId`, auth headers, cwd, MCP servers, and `cookieStore`. + // 2. Create a fresh stream with the same auth headers and cookie store. + // 3. Call initialize and require `agentCapabilities.loadSession`. + // 4. Call session/load for the saved session ID. + // Production agents must authorize session/load for the authenticated user. + // ACP v1 does not replay in-flight transport messages emitted while disconnected. + // Example: + // const next = connect(); + // await next.connection.initialize({ protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: {} }); + // await next.connection.loadSession({ sessionId: savedSessionId, cwd: process.cwd(), mcpServers: [] }); } finally { await stream.writable.close(); } diff --git a/src/examples/http-server.ts b/src/examples/http-server.ts index 2ba3962a..c0ea730e 100644 --- a/src/examples/http-server.ts +++ b/src/examples/http-server.ts @@ -11,9 +11,18 @@ import { } from "@agentclientprotocol/sdk/node"; import { AcpServer } from "@agentclientprotocol/sdk/server"; +interface DurableSessionState { + readonly cwd: string; + readonly history: acp.SessionNotification[]; +} + +// Illustrative durable state outside per-connection agent instances. For +// production multi-node deployments, replace this with Redis, Postgres, shared +// storage, or rely on sticky sessions with clear restart/drain semantics. +const durableSessions = new Map(); + class HttpExampleAgent implements acp.Agent { private readonly connection: acp.AgentSideConnection; - private readonly sessions = new Set(); constructor(connection: acp.AgentSideConnection) { this.connection = connection; @@ -25,19 +34,40 @@ class HttpExampleAgent implements acp.Agent { return { protocolVersion: acp.PROTOCOL_VERSION, agentCapabilities: { - loadSession: false, + loadSession: true, }, }; } async newSession( - _params: acp.NewSessionRequest, + params: acp.NewSessionRequest, ): Promise { const sessionId = crypto.randomUUID(); - this.sessions.add(sessionId); + durableSessions.set(sessionId, { + cwd: params.cwd, + history: [], + }); return { sessionId }; } + async loadSession( + params: acp.LoadSessionRequest, + ): Promise { + const session = durableSessions.get(params.sessionId); + if (!session) { + throw new Error(`Session ${params.sessionId} not found`); + } + + // Production agents must authorize session/load against the authenticated + // principal before replaying durable state. This example's auth check lives + // in HTTP middleware and is intentionally minimal. + for (const update of session.history) { + await this.connection.sessionUpdate(update); + } + + return {}; + } + async authenticate( _params: acp.AuthenticateRequest, ): Promise { @@ -45,20 +75,23 @@ class HttpExampleAgent implements acp.Agent { } async prompt(params: acp.PromptRequest): Promise { - if (!this.sessions.has(params.sessionId)) { + const session = durableSessions.get(params.sessionId); + if (!session) { throw new Error(`Session ${params.sessionId} not found`); } - await this.connection.sessionUpdate({ + const update: acp.SessionNotification = { sessionId: params.sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", - text: "Hello from the ACP HTTP/WebSocket example server.", + text: `Hello from the ACP HTTP/WebSocket example server at ${session.cwd}.`, }, }, - }); + }; + session.history.push(update); + await this.connection.sessionUpdate(update); return { stopReason: "end_turn" }; } diff --git a/src/examples/ws-client.ts b/src/examples/ws-client.ts index ddddd0ea..433008a1 100644 --- a/src/examples/ws-client.ts +++ b/src/examples/ws-client.ts @@ -3,7 +3,10 @@ import { WebSocket } from "ws"; import * as acp from "@agentclientprotocol/sdk"; -import { createWebSocketStream } from "@agentclientprotocol/sdk/ws-client"; +import { + MemoryAcpCookieStore, + createWebSocketStream, +} from "@agentclientprotocol/sdk/ws-client"; import type { WebSocketConstructor } from "@agentclientprotocol/sdk/ws-client"; class WebSocketExampleClient implements acp.Client { @@ -33,20 +36,37 @@ class WebSocketExampleClient implements acp.Client { } const serverUrl = process.env.ACP_WS_URL ?? "ws://127.0.0.1:7331/acp"; -const stream = createWebSocketStream(serverUrl, { - WebSocket: WebSocket satisfies WebSocketConstructor, - // Custom headers work with Node's `ws` constructor. Browser WebSocket does not support custom headers. - headers: { - Authorization: "Bearer example-token", - }, -}); -const connection = new acp.ClientSideConnection( - (_agent) => new WebSocketExampleClient(), - stream, -); +const authHeaders = { + Authorization: "Bearer example-token", +}; + +// Keep reconnect state outside individual stream instances. Browser WebSocket +// uses the platform cookie jar; Node's `ws` uses constructor headers populated +// from this store. +const cookieStore = new MemoryAcpCookieStore(); +let savedSessionId: string | undefined; + +function connect(): { + readonly stream: acp.Stream; + readonly connection: acp.ClientSideConnection; +} { + const stream = createWebSocketStream(serverUrl, { + WebSocket: WebSocket satisfies WebSocketConstructor, + headers: authHeaders, + cookieStore, + }); + const connection = new acp.ClientSideConnection( + (_agent) => new WebSocketExampleClient(), + stream, + ); + + return { stream, connection }; +} + +const { stream, connection } = connect(); try { - await connection.initialize({ + const initialized = await connection.initialize({ protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: {}, }); @@ -55,6 +75,7 @@ try { cwd: process.cwd(), mcpServers: [], }); + savedSessionId = session.sessionId; const result = await connection.prompt({ sessionId: session.sessionId, @@ -67,6 +88,21 @@ try { }); console.log(`\nDone: ${result.stopReason}`); + console.log( + `Saved session ${savedSessionId}; loadSession=${initialized.agentCapabilities?.loadSession === true}`, + ); + + // Reconnect flow sketch: + // 1. Save `sessionId`, auth headers, cwd, MCP servers, and `cookieStore`. + // 2. Create a fresh WebSocket stream with the same auth headers and cookie store. + // 3. Call initialize and require `agentCapabilities.loadSession`. + // 4. Call session/load for the saved session ID. + // Production agents must authorize session/load for the authenticated user. + // ACP v1 does not replay in-flight transport messages emitted while disconnected. + // Example: + // const next = connect(); + // await next.connection.initialize({ protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: {} }); + // await next.connection.loadSession({ sessionId: savedSessionId, cwd: process.cwd(), mcpServers: [] }); } finally { await stream.writable.close(); } diff --git a/src/http-stream.test.ts b/src/http-stream.test.ts index 63be5c9c..405ee26e 100644 --- a/src/http-stream.test.ts +++ b/src/http-stream.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { ClientSideConnection, PROTOCOL_VERSION } from "./acp.js"; -import { createHttpStream } from "./http-stream.js"; +import { MemoryAcpCookieStore, createHttpStream } from "./http-stream.js"; import { EVENT_STREAM_MIME_TYPE, HEADER_CONNECTION_ID, @@ -12,8 +12,17 @@ import { TestAgent } from "./test-support/test-agent.js"; import { startTestServer } from "./test-support/test-http-server.js"; import type { + Agent, AgentSideConnection, Client, + InitializeRequest, + InitializeResponse, + LoadSessionRequest, + LoadSessionResponse, + NewSessionRequest, + NewSessionResponse, + PromptRequest, + PromptResponse, RequestPermissionRequest, RequestPermissionResponse, SessionNotification, @@ -317,6 +326,193 @@ describe("createHttpStream", () => { } }); + it("clears SDK-owned ephemeral cookies when the stream errors", async () => { + const clearSpy = vi.spyOn(MemoryAcpCookieStore.prototype, "clear"); + const controlledFetch = createControlledFetch({ + initializeCookies: ["transport=alpha; Path=/"], + getStatus: 500, + }); + const stream = createHttpStream("https://agent.example/acp", { + fetch: controlledFetch.fetch, + }); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + try { + await writer.write(initializeRequest); + expect(await readMessage(reader)).toEqual(initializeResponse); + + await expect(reader.read()).rejects.toThrow("ACP SSE connection failed"); + expect(clearSpy).toHaveBeenCalledTimes(1); + } finally { + clearSpy.mockRestore(); + reader.releaseLock(); + writer.releaseLock(); + await stream.writable.close(); + } + }); + + it("preserves caller-owned cookies when the stream errors", async () => { + const cookieStore = new MemoryAcpCookieStore(); + const clearSpy = vi.spyOn(cookieStore, "clear"); + const firstFetch = createControlledFetch({ + initializeCookies: ["transport=alpha; Path=/"], + getStatus: 500, + }); + const firstStream = createHttpStream("https://agent.example/acp", { + fetch: firstFetch.fetch, + cookieStore, + }); + const firstWriter = firstStream.writable.getWriter(); + const firstReader = firstStream.readable.getReader(); + + try { + await firstWriter.write(initializeRequest); + expect(await readMessage(firstReader)).toEqual(initializeResponse); + await expect(firstReader.read()).rejects.toThrow( + "ACP SSE connection failed", + ); + expect(clearSpy).not.toHaveBeenCalled(); + } finally { + clearSpy.mockRestore(); + firstReader.releaseLock(); + firstWriter.releaseLock(); + await firstStream.writable.close(); + } + + const secondFetch = createControlledFetch(); + const secondStream = createHttpStream("https://agent.example/acp", { + fetch: secondFetch.fetch, + cookieStore, + }); + const secondWriter = secondStream.writable.getWriter(); + const secondReader = secondStream.readable.getReader(); + + try { + await secondWriter.write(initializeRequest); + await readMessage(secondReader); + + expect(requestAt(secondFetch.requests, 0).headers.get("Cookie")).toBe( + "transport=alpha", + ); + } finally { + secondReader.releaseLock(); + secondWriter.releaseLock(); + await secondStream.writable.close(); + } + }); + + it("preserves caller-owned cookies across separate stream instances", async () => { + const cookieStore = new MemoryAcpCookieStore(); + const firstFetch = createControlledFetch({ + initializeCookies: ["transport=alpha; Path=/"], + }); + const firstStream = createHttpStream("https://agent.example/acp", { + fetch: firstFetch.fetch, + cookieStore, + }); + const firstWriter = firstStream.writable.getWriter(); + const firstReader = firstStream.readable.getReader(); + + try { + await firstWriter.write(initializeRequest); + await readMessage(firstReader); + await firstWriter.close(); + } finally { + firstReader.releaseLock(); + firstWriter.releaseLock(); + } + + const secondFetch = createControlledFetch(); + const secondStream = createHttpStream("https://agent.example/acp", { + fetch: secondFetch.fetch, + cookieStore, + }); + const secondWriter = secondStream.writable.getWriter(); + const secondReader = secondStream.readable.getReader(); + + try { + await secondWriter.write(initializeRequest); + await readMessage(secondReader); + + expect(requestAt(secondFetch.requests, 0).headers.get("Cookie")).toBe( + "transport=alpha", + ); + } finally { + secondReader.releaseLock(); + secondWriter.releaseLock(); + await secondStream.writable.close(); + } + }); + + it("clears SDK-owned ephemeral cookies when the stream closes", async () => { + const firstFetch = createControlledFetch({ + initializeCookies: ["transport=alpha; Path=/"], + }); + const firstStream = createHttpStream("https://agent.example/acp", { + fetch: firstFetch.fetch, + }); + const firstWriter = firstStream.writable.getWriter(); + const firstReader = firstStream.readable.getReader(); + + try { + await firstWriter.write(initializeRequest); + await readMessage(firstReader); + await firstWriter.close(); + } finally { + firstReader.releaseLock(); + firstWriter.releaseLock(); + } + + const secondFetch = createControlledFetch(); + const secondStream = createHttpStream("https://agent.example/acp", { + fetch: secondFetch.fetch, + }); + const secondWriter = secondStream.writable.getWriter(); + const secondReader = secondStream.readable.getReader(); + + try { + await secondWriter.write(initializeRequest); + await readMessage(secondReader); + + expect( + requestAt(secondFetch.requests, 0).headers.get("Cookie"), + ).toBeNull(); + } finally { + secondReader.releaseLock(); + secondWriter.releaseLock(); + await secondStream.writable.close(); + } + }); + + it("lets caller Cookie headers override caller-owned store values", async () => { + const cookieStore = new MemoryAcpCookieStore(); + cookieStore.store(headersWithSetCookie(["transport=alpha", "route=bravo"])); + const controlledFetch = createControlledFetch(); + const stream = createHttpStream("https://agent.example/acp", { + fetch: controlledFetch.fetch, + cookieStore, + headers: { + Cookie: "route=caller; caller=custom", + }, + }); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + try { + await writer.write(initializeRequest); + await readMessage(reader); + + expect(requestAt(controlledFetch.requests, 0).headers.get("Cookie")).toBe( + "transport=alpha; route=caller; caller=custom", + ); + } finally { + reader.releaseLock(); + writer.releaseLock(); + await stream.writable.close(); + } + }); + it("sends DELETE and aborts SSE requests when closed", async () => { const controlledFetch = createControlledFetch(); const stream = createHttpStream("https://agent.example/acp", { @@ -450,6 +646,75 @@ describe("createHttpStream", () => { } }); + it("loads durable sessions after an HTTP reconnect", async () => { + const durableSessions = new Map(); + const connectionIds: string[] = []; + const fetch = recordInitializeConnectionIds(connectionIds); + const server = await startTestServer( + (conn: AgentSideConnection) => + new DurableSessionAgent(conn, durableSessions), + ); + + try { + const firstUpdates: SessionNotification[] = []; + const firstStream = createHttpStream(server.url, { fetch }); + const firstConn = new ClientSideConnection( + () => createTestClient({ updates: firstUpdates }), + firstStream, + ); + const initialized = await firstConn.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + }); + expect(initialized.agentCapabilities?.loadSession).toBe(true); + const session = await firstConn.newSession({ + cwd: "/tmp", + mcpServers: [], + }); + await expect( + firstConn.prompt({ + sessionId: session.sessionId, + prompt: [{ type: "text", text: "Remember this" }], + }), + ).resolves.toEqual({ stopReason: "end_turn" }); + await waitForUpdates(firstUpdates, 1); + await closeStream(firstStream); + + expect(durableSessions.has(session.sessionId)).toBe(true); + + const secondUpdates: SessionNotification[] = []; + const secondStream = createHttpStream(server.url, { fetch }); + const secondConn = new ClientSideConnection( + () => createTestClient({ updates: secondUpdates }), + secondStream, + ); + const reinitialized = await secondConn.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + }); + expect(reinitialized.agentCapabilities?.loadSession).toBe(true); + + // Production agents must authorize session/load against the authenticated + // principal. This SDK transport test omits auth because there is no + // generic auth layer in the SDK. + await expect( + secondConn.loadSession({ + sessionId: session.sessionId, + cwd: "/tmp", + mcpServers: [], + }), + ).resolves.toEqual({}); + await waitForUpdates(secondUpdates, firstUpdates.length); + await closeStream(secondStream); + + expect(connectionIds).toHaveLength(2); + expect(connectionIds[0]).not.toBe(connectionIds[1]); + expect(secondUpdates).toEqual(firstUpdates); + } finally { + await server.close(); + } + }); + it("keeps multiple sessions isolated through the SDK client abstraction", async () => { const updates: SessionNotification[] = []; const server = await startTestServer(); @@ -505,6 +770,79 @@ describe("createHttpStream", () => { }); }); +interface DurableSessionState { + readonly cwd: string; + readonly history: SessionNotification[]; +} + +class DurableSessionAgent implements Agent { + constructor( + private readonly connection: AgentSideConnection, + private readonly sessions: Map, + ) {} + + initialize(_params: InitializeRequest): Promise { + return Promise.resolve({ + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { + loadSession: true, + }, + }); + } + + newSession(params: NewSessionRequest): Promise { + const sessionId = globalThis.crypto.randomUUID(); + this.sessions.set(sessionId, { + cwd: params.cwd, + history: [], + }); + return Promise.resolve({ sessionId }); + } + + async loadSession(params: LoadSessionRequest): Promise { + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Unknown durable session: ${params.sessionId}`); + } + + for (const update of session.history) { + await this.connection.sessionUpdate(update); + } + + return {}; + } + + async prompt(params: PromptRequest): Promise { + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Unknown durable session: ${params.sessionId}`); + } + + const update: SessionNotification = { + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: `durable-history:${session.cwd}`, + }, + }, + }; + session.history.push(update); + await this.connection.sessionUpdate(update); + + return { stopReason: "end_turn" }; + } + + cancel(): Promise { + return Promise.resolve(); + } + + authenticate(): Promise { + return Promise.resolve(); + } +} + interface RecordedRequest { readonly url: string; readonly method: string; @@ -533,6 +871,25 @@ interface TestClientState { interface ControlledFetchOptions { readonly initializeCookies?: readonly string[]; readonly getCookies?: readonly string[]; + readonly getStatus?: number; +} + +function recordInitializeConnectionIds( + connectionIds: string[], +): typeof globalThis.fetch { + return async (input, init) => { + const response = await globalThis.fetch(input, init); + const headers = new Headers(init?.headers); + + if (init?.method === "POST" && !headers.has(HEADER_CONNECTION_ID)) { + const connectionId = response.headers.get(HEADER_CONNECTION_ID); + if (connectionId) { + connectionIds.push(connectionId); + } + } + + return response; + }; } function createControlledFetch( @@ -568,6 +925,14 @@ function createControlledFetch( } if (method === "GET") { + const status = options.getStatus ?? 200; + if (status !== 200) { + return new Response("SSE failed", { + status, + headers: setCookieResponseHeaders(options.getCookies), + }); + } + const stream = new TransformStream(); const writer = stream.writable.getWriter(); const signal = init?.signal; @@ -637,6 +1002,20 @@ async function readMessage( return result.value; } +async function waitForUpdates( + updates: readonly SessionNotification[], + count: number, +): Promise { + const deadline = Date.now() + 1_000; + while (updates.length < count) { + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for ${count} session updates`); + } + + await new Promise((resolve) => setTimeout(resolve, 1)); + } +} + function requestAt( requests: readonly RecordedRequest[], index: number, @@ -679,6 +1058,16 @@ function jsonResponse( }); } +function headersWithSetCookie(values: readonly string[]): Headers { + const headers = new Headers(); + + Object.defineProperty(headers, "getSetCookie", { + value: () => values, + }); + + return headers; +} + function setCookieResponseHeaders( cookies: readonly string[] | undefined, ): Record { diff --git a/src/http-stream.ts b/src/http-stream.ts index 3df6b7a6..1056e762 100644 --- a/src/http-stream.ts +++ b/src/http-stream.ts @@ -9,8 +9,10 @@ import { sessionIdFromMessageParams, sessionIdFromResponseResult, } from "./protocol.js"; +import { MemoryAcpCookieStore } from "./cookie-store.js"; import { parseSseStream } from "./sse.js"; +import type { AcpCookieStore } from "./cookie-store.js"; import type { AnyMessage } from "./jsonrpc.js"; import type { Stream } from "./stream.js"; @@ -21,13 +23,30 @@ export interface HttpStreamOptions { readonly headers?: Record; /** Cookie handling policy for transport requests. Defaults to `include`. */ readonly cookies?: "include" | "omit"; + /** + * Caller-owned affinity cookie store to reuse across reconnects. + * + * If omitted, the SDK creates an ephemeral per-stream store and clears it + * when the stream closes/errors. + */ + readonly cookieStore?: AcpCookieStore; } +export { MemoryAcpCookieStore } from "./cookie-store.js"; +export type { AcpCookieStore } from "./cookie-store.js"; + /** * Creates an ACP Stream over Streamable HTTP. * * Uses POST for client messages and SSE GET streams for server messages. - * Cookies are included by default for the lifetime of one stream. + * Cookies are included by default. Pass a caller-owned `AcpCookieStore` to + * retain affinity cookies across fresh streams during reconnect. + * + * ACP v1 reconnect creates a new transport connection; callers should save the + * ACP `sessionId`, create a new stream with the same auth headers/cookie store, + * call `initialize`, verify `agentCapabilities.loadSession`, then call + * `session/load`. Agents must authorize `session/load`, and ACP v1 does not + * replay in-flight transport messages emitted while disconnected. */ export function createHttpStream( serverUrl: string, @@ -42,7 +61,8 @@ class HttpStreamTransport { private readonly fetchImpl: typeof globalThis.fetch; private readonly headers: Record; private readonly cookiePolicy: RequestCredentials; - private readonly cookieJar = new ConnectionCookieJar(); + private readonly cookieStore: AcpCookieStore; + private readonly ownsCookieStore: boolean; private readonly abortController = new AbortController(); private readonly knownSessions = new Set(); private readonly pendingResponseSessions = new Map(); @@ -61,6 +81,8 @@ class HttpStreamTransport { this.fetchImpl = resolveFetch(options.fetch); this.headers = options.headers ?? {}; this.cookiePolicy = options.cookies ?? "include"; + this.cookieStore = options.cookieStore ?? new MemoryAcpCookieStore(); + this.ownsCookieStore = options.cookieStore === undefined; this.stream = { readable: new ReadableStream({ @@ -261,7 +283,7 @@ class HttpStreamTransport { }); if (this.cookiePolicy === "include") { - this.cookieJar.store(response.headers); + this.cookieStore.store(response.headers); } return response; @@ -276,7 +298,7 @@ class HttpStreamTransport { }); if (this.cookiePolicy === "include") { - this.cookieJar.apply(requestHeaders); + this.cookieStore.apply(requestHeaders); } return requestHeaders; @@ -300,17 +322,23 @@ class HttpStreamTransport { if (!response.ok) { this.abortController.abort(); - this.cookieJar.clear(); + this.clearOwnedCookieStore(); this.closeReadable(); throw await httpError("ACP DELETE failed", response); } } this.abortController.abort(); - this.cookieJar.clear(); + this.clearOwnedCookieStore(); this.closeReadable(); } + private clearOwnedCookieStore(): void { + if (this.ownsCookieStore) { + this.cookieStore.clear(); + } + } + private enqueue(message: AnyMessage): void { try { this.readableController?.enqueue(message); @@ -326,7 +354,7 @@ class HttpStreamTransport { this.isClosed = true; this.abortController.abort(); - this.cookieJar.clear(); + this.clearOwnedCookieStore(); try { this.readableController?.error(error); @@ -344,48 +372,6 @@ class HttpStreamTransport { } } -class ConnectionCookieJar { - private readonly cookies = new Map(); - - store(headers: Headers): void { - for (const value of setCookieHeaders(headers)) { - const cookie = parseSetCookie(value); - if (!cookie) { - continue; - } - - this.cookies.set(cookie.name, cookie.value); - } - } - - apply(headers: Headers): void { - const merged = mergeCookieHeaders( - this.cookieHeader(), - headers.get("Cookie"), - ); - if (merged) { - headers.set("Cookie", merged); - } - } - - clear(): void { - this.cookies.clear(); - } - - private cookieHeader(): string | undefined { - return this.cookies.size === 0 - ? undefined - : Array.from(this.cookies) - .map(([name, value]) => `${name}=${value}`) - .join("; "); - } -} - -interface CookiePair { - readonly name: string; - readonly value: string; -} - function resolveFetch( fetchImpl: typeof globalThis.fetch | undefined, ): typeof globalThis.fetch { @@ -402,104 +388,6 @@ function resolveFetch( ); } -function setCookieHeaders(headers: Headers): string[] { - const getSetCookie = headers.getSetCookie; - if (typeof getSetCookie === "function") { - return getSetCookie.call(headers); - } - - const setCookie = headers.get("Set-Cookie"); - return setCookie ? splitSetCookieHeader(setCookie) : []; -} - -function splitSetCookieHeader(header: string): string[] { - const result: string[] = []; - let start = 0; - let isInExpires = false; - - for (let index = 0; index < header.length; index += 1) { - const char = header[index]; - - if (char === "," && !isInExpires) { - result.push(header.slice(start, index).trim()); - start = index + 1; - continue; - } - - if (header.slice(index, index + 8).toLowerCase() === "expires=") { - isInExpires = true; - index += 7; - continue; - } - - if (char === ";" && isInExpires) { - isInExpires = false; - } - } - - result.push(header.slice(start).trim()); - return result.filter((value) => value.length > 0); -} - -function parseSetCookie(header: string): CookiePair | undefined { - const pair = header.split(";", 1)[0]; - const separator = pair.indexOf("="); - - if (separator <= 0) { - return undefined; - } - - return { - name: pair.slice(0, separator).trim(), - value: pair.slice(separator + 1).trim(), - }; -} - -function mergeCookieHeaders( - jarCookieHeader: string | undefined, - callerCookieHeader: string | null, -): string | undefined { - const cookies = new Map(); - - for (const cookie of parseCookieHeader(jarCookieHeader)) { - cookies.set(cookie.name, cookie.value); - } - - for (const cookie of parseCookieHeader(callerCookieHeader ?? undefined)) { - cookies.set(cookie.name, cookie.value); - } - - return cookies.size === 0 - ? undefined - : Array.from(cookies) - .map(([name, value]) => `${name}=${value}`) - .join("; "); -} - -function parseCookieHeader(header: string | undefined): CookiePair[] { - if (!header) { - return []; - } - - return header - .split(";") - .map(parseCookiePair) - .filter((cookie): cookie is CookiePair => cookie !== undefined); -} - -function parseCookiePair(value: string): CookiePair | undefined { - const separator = value.indexOf("="); - - if (separator <= 0) { - return undefined; - } - - return { - name: value.slice(0, separator).trim(), - value: value.slice(separator + 1).trim(), - }; -} - async function httpError(prefix: string, response: Response): Promise { const text = await response.text().catch(() => ""); diff --git a/src/protocol.test.ts b/src/protocol.test.ts index d27199b9..9ac6fbc3 100644 --- a/src/protocol.test.ts +++ b/src/protocol.test.ts @@ -40,9 +40,6 @@ describe("protocol transport helpers", () => { expect(methodRequiresSessionHeader(AGENT_METHODS.session_set_mode)).toBe( true, ); - expect(methodRequiresSessionHeader(AGENT_METHODS.session_set_model)).toBe( - true, - ); }); it("does not require a session header for connection-level or unsupported methods", () => { diff --git a/src/protocol.ts b/src/protocol.ts index 61c99e7d..8aa9c60a 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -15,7 +15,6 @@ const SESSION_SCOPED_METHODS = new Set([ AGENT_METHODS.session_resume, AGENT_METHODS.session_set_config_option, AGENT_METHODS.session_set_mode, - AGENT_METHODS.session_set_model, ]); export function methodRequiresSessionHeader(method: string): boolean { diff --git a/src/server-session-sse.test.ts b/src/server-session-sse.test.ts index 1ae524be..87b5ea9c 100644 --- a/src/server-session-sse.test.ts +++ b/src/server-session-sse.test.ts @@ -313,6 +313,11 @@ describe("AcpServer session SSE", () => { const connectionId = await initialize(server.url); const sessionId = "existing-session"; const connectionSse = await openConnectionSse(server.url, connectionId); + const sessionSse = await openSessionSse( + server.url, + connectionId, + sessionId, + ); const accepted = await postJson( server.url, createLoadSessionRequest(3, sessionId), @@ -321,14 +326,9 @@ describe("AcpServer session SSE", () => { [HEADER_SESSION_ID]: sessionId, }, ); - const sessionSse = await openSessionSse( - server.url, - connectionId, - sessionId, - ); - expect(accepted.status).toBe(202); expect(sessionSse.status).toBe(200); + expect(accepted.status).toBe(202); expect(await readSseMessages(sessionSse, 1)).toMatchObject([ { jsonrpc: "2.0", diff --git a/src/server.test.ts b/src/server.test.ts index e2081dd6..4e476f43 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -363,7 +363,7 @@ describe("AcpServer", () => { } }); - it("rejects session-scoped GETs for unknown sessions", async () => { + it("opens session-scoped GETs before session/load creates session state", async () => { const server = await startTestServer(); try { @@ -374,7 +374,8 @@ describe("AcpServer", () => { globalThis.crypto.randomUUID(), ); - expect(response.status).toBe(404); + expect(response.status).toBe(200); + await response.body?.cancel(); } finally { await server.close(); } diff --git a/src/server.ts b/src/server.ts index b7aa0e26..d88c1251 100644 --- a/src/server.ts +++ b/src/server.ts @@ -204,12 +204,7 @@ export class AcpServer { const sessionId = req.headers.get(HEADER_SESSION_ID); if (sessionId) { - const sessionStream = connection.sessionStreams.get(sessionId); - if (!sessionStream) { - return textResponse("Unknown Acp-Session-Id", 404); - } - - return sseResponse(sessionStream.subscribe()); + return sseResponse(connection.ensureSession(sessionId).subscribe()); } return sseResponse(connection.connectionStream.subscribe()); diff --git a/src/ws-stream.test.ts b/src/ws-stream.test.ts index cf71e265..92c1fdfb 100644 --- a/src/ws-stream.test.ts +++ b/src/ws-stream.test.ts @@ -3,14 +3,23 @@ import { WebSocket } from "ws"; import { ClientSideConnection, PROTOCOL_VERSION } from "./acp.js"; import { HEADER_CONNECTION_ID } from "./protocol.js"; -import { createWebSocketStream } from "./ws-stream.js"; +import { MemoryAcpCookieStore, createWebSocketStream } from "./ws-stream.js"; import { TestAgent } from "./test-support/test-agent.js"; import { startTestServer } from "./test-support/test-http-server.js"; import type { IncomingMessage } from "node:http"; import type { + Agent, AgentSideConnection, Client, + InitializeRequest, + InitializeResponse, + LoadSessionRequest, + LoadSessionResponse, + NewSessionRequest, + NewSessionResponse, + PromptRequest, + PromptResponse, RequestPermissionRequest, RequestPermissionResponse, SessionNotification, @@ -190,6 +199,107 @@ describe("createWebSocketStream", () => { } }); + it("passes managed cookies and custom headers to custom WebSocket constructors", async () => { + const cookieStore = new MemoryAcpCookieStore(); + cookieStore.store(headersWithSetCookie(["transport=alpha", "route=bravo"])); + const instances: FakeWebSocket[] = []; + const stream = createWebSocketStream("ws://agent.example/acp", { + WebSocket: createFakeWebSocketConstructor(instances), + cookieStore, + headers: { + Authorization: "Bearer token", + Cookie: "route=caller; caller=custom", + }, + }); + + try { + const socket = fakeSocketAt(instances, 0); + expect(socket.options).toEqual({ + headers: { + Authorization: "Bearer token", + Cookie: "transport=alpha; route=caller; caller=custom", + }, + }); + socket.open(); + } finally { + await closeStream(stream); + } + }); + + it("stores upgrade cookies for reuse by later WebSocket streams", async () => { + const cookieStore = new MemoryAcpCookieStore(); + const instances: FakeWebSocket[] = []; + const WebSocket = createFakeWebSocketConstructor(instances); + const firstStream = createWebSocketStream("ws://agent.example/acp", { + WebSocket, + cookieStore, + }); + + const firstSocket = fakeSocketAt(instances, 0); + firstSocket.upgrade({ + headers: { + "set-cookie": ["transport=alpha; Path=/"], + }, + }); + firstSocket.open(); + await closeStream(firstStream); + + const secondStream = createWebSocketStream("ws://agent.example/acp", { + WebSocket, + cookieStore, + }); + + try { + const secondSocket = fakeSocketAt(instances, 1); + expect(secondSocket.options).toEqual({ + headers: { + Cookie: "transport=alpha", + }, + }); + secondSocket.open(); + } finally { + await closeStream(secondStream); + } + }); + + it("omits managed cookies and upgrade cookie storage when cookies are disabled", async () => { + const cookieStore = new MemoryAcpCookieStore(); + const instances: FakeWebSocket[] = []; + const WebSocket = createFakeWebSocketConstructor(instances); + const firstStream = createWebSocketStream("ws://agent.example/acp", { + WebSocket, + cookieStore, + cookies: "omit", + }); + + const firstSocket = fakeSocketAt(instances, 0); + expect(firstSocket.options).toEqual({ + headers: undefined, + }); + firstSocket.upgrade({ + headers: { + "set-cookie": ["transport=alpha; Path=/"], + }, + }); + firstSocket.open(); + await closeStream(firstStream); + + const secondStream = createWebSocketStream("ws://agent.example/acp", { + WebSocket, + cookieStore, + }); + + try { + const secondSocket = fakeSocketAt(instances, 1); + expect(secondSocket.options).toEqual({ + headers: undefined, + }); + secondSocket.open(); + } finally { + await closeStream(secondStream); + } + }); + it("ignores binary, malformed JSON, and non-JSON-RPC messages", async () => { const instances: FakeWebSocket[] = []; const stream = createWebSocketStream("ws://agent.example/acp", { @@ -348,6 +458,82 @@ describe("createWebSocketStream", () => { } }); + it("loads durable sessions after a WebSocket reconnect", async () => { + const durableSessions = new Map(); + const connectionIds: string[] = []; + const WebSocket = createRecordingWebSocketConstructor(connectionIds); + const cookieStore = new MemoryAcpCookieStore(); + const server = await startTestServer( + (conn: AgentSideConnection) => + new DurableSessionAgent(conn, durableSessions), + ); + + try { + const firstUpdates: SessionNotification[] = []; + const firstStream = createWebSocketStream(server.wsUrl, { + WebSocket, + cookieStore, + }); + const firstConn = new ClientSideConnection( + () => createTestClient({ updates: firstUpdates }), + firstStream, + ); + const initialized = await firstConn.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + }); + expect(initialized.agentCapabilities?.loadSession).toBe(true); + const session = await firstConn.newSession({ + cwd: "/tmp", + mcpServers: [], + }); + await expect( + firstConn.prompt({ + sessionId: session.sessionId, + prompt: [{ type: "text", text: "Remember this" }], + }), + ).resolves.toEqual({ stopReason: "end_turn" }); + await waitForUpdates(firstUpdates, 1); + await closeStream(firstStream); + + expect(durableSessions.has(session.sessionId)).toBe(true); + + const secondUpdates: SessionNotification[] = []; + const secondStream = createWebSocketStream(server.wsUrl, { + WebSocket, + cookieStore, + }); + const secondConn = new ClientSideConnection( + () => createTestClient({ updates: secondUpdates }), + secondStream, + ); + const reinitialized = await secondConn.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + }); + expect(reinitialized.agentCapabilities?.loadSession).toBe(true); + + // Production agents must authorize session/load against the authenticated + // principal. This SDK transport test omits auth because there is no + // generic auth layer in the SDK. + await expect( + secondConn.loadSession({ + sessionId: session.sessionId, + cwd: "/tmp", + mcpServers: [], + }), + ).resolves.toEqual({}); + await waitForUpdates(secondUpdates, firstUpdates.length); + await closeStream(secondStream); + + expect(connectionIds).toHaveLength(2); + expect(connectionIds[0]).not.toBe(connectionIds[1]); + expect(secondUpdates).toEqual(firstUpdates); + } finally { + await server.close(); + } + }); + it("keeps multiple sessions isolated through the SDK client abstraction", async () => { const updates: SessionNotification[] = []; const server = await startTestServer(); @@ -405,6 +591,79 @@ describe("createWebSocketStream", () => { }); }); +interface DurableSessionState { + readonly cwd: string; + readonly history: SessionNotification[]; +} + +class DurableSessionAgent implements Agent { + constructor( + private readonly connection: AgentSideConnection, + private readonly sessions: Map, + ) {} + + initialize(_params: InitializeRequest): Promise { + return Promise.resolve({ + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { + loadSession: true, + }, + }); + } + + newSession(params: NewSessionRequest): Promise { + const sessionId = globalThis.crypto.randomUUID(); + this.sessions.set(sessionId, { + cwd: params.cwd, + history: [], + }); + return Promise.resolve({ sessionId }); + } + + async loadSession(params: LoadSessionRequest): Promise { + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Unknown durable session: ${params.sessionId}`); + } + + for (const update of session.history) { + await this.connection.sessionUpdate(update); + } + + return {}; + } + + async prompt(params: PromptRequest): Promise { + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Unknown durable session: ${params.sessionId}`); + } + + const update: SessionNotification = { + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: `durable-history:${session.cwd}`, + }, + }, + }; + session.history.push(update); + await this.connection.sessionUpdate(update); + + return { stopReason: "end_turn" }; + } + + cancel(): Promise { + return Promise.resolve(); + } + + authenticate(): Promise { + return Promise.resolve(); + } +} + interface TestClientState { readonly updates: SessionNotification[]; readonly permissionRequests?: RequestPermissionRequest[]; @@ -443,6 +702,41 @@ async function readMessage( return result.value; } +async function waitForUpdates( + updates: readonly SessionNotification[], + count: number, +): Promise { + const deadline = Date.now() + 1_000; + while (updates.length < count) { + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for ${count} session updates`); + } + + await new Promise((resolve) => setTimeout(resolve, 1)); + } +} + +function createRecordingWebSocketConstructor( + connectionIds: string[], +): WebSocketConstructor { + return class RecordingWebSocket extends WebSocket { + constructor( + url: string, + protocols?: string | string[], + options?: { headers?: Record }, + ) { + super(url, protocols, options); + this.once("upgrade", (message: IncomingMessage) => { + const connectionId = + message.headers[HEADER_CONNECTION_ID.toLowerCase()]; + if (typeof connectionId === "string") { + connectionIds.push(connectionId); + } + }); + } + } as unknown as WebSocketConstructor; +} + function createFakeWebSocketConstructor( instances: FakeWebSocket[], ): WebSocketConstructor { @@ -471,6 +765,16 @@ function fakeSocketAt( return socket; } +function headersWithSetCookie(values: readonly string[]): Headers { + const headers = new Headers(); + + Object.defineProperty(headers, "getSetCookie", { + value: () => values, + }); + + return headers; +} + class FakeWebSocket { readonly sent: string[] = []; readonly listeners = new Map void>>(); @@ -523,6 +827,10 @@ class FakeWebSocket { this.emit("message", { data, isBinary }); } + upgrade(message: unknown): void { + this.emit("upgrade", message); + } + private emit(type: string, event: unknown): void { for (const listener of this.listeners.get(type) ?? []) { listener(event); diff --git a/src/ws-stream.ts b/src/ws-stream.ts index 0ebea76f..91fa8975 100644 --- a/src/ws-stream.ts +++ b/src/ws-stream.ts @@ -1,5 +1,7 @@ +import { MemoryAcpCookieStore } from "./cookie-store.js"; import { isJsonRpcMessage } from "./jsonrpc.js"; import { onWebSocket, webSocketMessageToString } from "./ws-utils.js"; +import type { AcpCookieStore } from "./cookie-store.js"; import type { WebSocketLike } from "./ws-utils.js"; import type { AnyMessage } from "./jsonrpc.js"; import type { Stream } from "./stream.js"; @@ -14,8 +16,20 @@ export interface WebSocketStreamOptions { readonly headers?: Record; /** WebSocket constructor to use. Defaults to `globalThis.WebSocket`. */ readonly WebSocket?: WebSocketConstructor; + /** Cookie handling policy for transport requests. Defaults to `include`. */ + readonly cookies?: "include" | "omit"; + /** + * Caller-owned affinity cookie store to reuse across reconnects. + * + * Browser WebSocket uses the platform cookie jar; Node/custom constructors + * that support headers receive cookies from this store. + */ + readonly cookieStore?: AcpCookieStore; } +export { MemoryAcpCookieStore } from "./cookie-store.js"; +export type { AcpCookieStore } from "./cookie-store.js"; + /** Constructor shape used by `createWebSocketStream`. */ export interface WebSocketConstructor { new ( @@ -34,6 +48,15 @@ const SOCKET_OPEN = 1; * * Sends and receives ACP JSON-RPC messages as WebSocket text frames. In Node, * pass a WebSocket constructor such as `ws.WebSocket` via `options.WebSocket`. + * Browser WebSocket uses the platform cookie jar; Node/custom constructors + * receive merged `Cookie` headers when supported and can store upgrade + * `Set-Cookie` headers in a caller-owned `AcpCookieStore`. + * + * ACP v1 reconnect creates a new transport connection; callers should save the + * ACP `sessionId`, create a new stream with the same auth headers/cookie store, + * call `initialize`, verify `agentCapabilities.loadSession`, then call + * `session/load`. Agents must authorize `session/load`, and ACP v1 does not + * replay in-flight transport messages emitted while disconnected. */ export function createWebSocketStream( serverUrl: string, @@ -46,6 +69,8 @@ class WebSocketStreamTransport { readonly stream: Stream; private readonly socket: WebSocketLike; + private readonly cookieStore: AcpCookieStore; + private readonly ownsCookieStore: boolean; private readableController: | ReadableStreamDefaultController | undefined; @@ -57,8 +82,15 @@ class WebSocketStreamTransport { constructor(serverUrl: string, options: WebSocketStreamOptions) { const WebSocketCtor = resolveWebSocket(options.WebSocket); + const cookiePolicy = options.cookies ?? "include"; + this.cookieStore = options.cookieStore ?? new MemoryAcpCookieStore(); + this.ownsCookieStore = options.cookieStore === undefined; this.socket = new WebSocketCtor(serverUrl, options.protocols, { - headers: options.headers, + headers: createConstructorHeaders( + options.headers, + cookiePolicy, + this.cookieStore, + ), }); this.openPromise = new Promise((resolve, reject) => { @@ -93,6 +125,17 @@ class WebSocketStreamTransport { }), ); + if (cookiePolicy === "include") { + this.detachListeners.push( + onWebSocket(this.socket, "upgrade", (message) => { + const headers = upgradeHeaders(message); + if (headers) { + this.cookieStore.store(headers); + } + }), + ); + } + this.stream = { readable: new ReadableStream({ start: (controller) => { @@ -180,12 +223,19 @@ class WebSocketStreamTransport { } } + private clearOwnedCookieStore(): void { + if (this.ownsCookieStore) { + this.cookieStore.clear(); + } + } + private closeReadable(): void { if (this.isClosed) { return; } this.isClosed = true; + this.clearOwnedCookieStore(); for (const detach of this.detachListeners.splice(0)) { detach(); @@ -209,6 +259,7 @@ class WebSocketStreamTransport { } this.isClosed = true; + this.clearOwnedCookieStore(); for (const detach of this.detachListeners.splice(0)) { detach(); @@ -223,6 +274,78 @@ class WebSocketStreamTransport { } } +function createConstructorHeaders( + headers: Record | undefined, + cookiePolicy: "include" | "omit", + cookieStore: AcpCookieStore, +): Record | undefined { + const result: Record = headers ? { ...headers } : {}; + + if (cookiePolicy === "include") { + const requestHeaders = new Headers(headers); + cookieStore.apply(requestHeaders); + const cookieHeader = requestHeaders.get("Cookie"); + + if (cookieHeader) { + result[findHeaderName(result, "Cookie") ?? "Cookie"] = cookieHeader; + } + } + + return Object.keys(result).length > 0 ? result : undefined; +} + +function findHeaderName( + headers: Record, + name: string, +): string | undefined { + return Object.keys(headers).find( + (candidate) => candidate.toLowerCase() === name.toLowerCase(), + ); +} + +function upgradeHeaders(message: unknown): Headers | undefined { + if (message instanceof Headers) { + return message; + } + + if (!isRecord(message) || !("headers" in message)) { + return undefined; + } + + return headersFromRecord(message.headers); +} + +function headersFromRecord(value: unknown): Headers | undefined { + if (value instanceof Headers) { + return value; + } + + if (!isRecord(value)) { + return undefined; + } + + const headers = new Headers(); + + for (const [name, headerValue] of Object.entries(value)) { + if (Array.isArray(headerValue)) { + for (const item of headerValue) { + headers.append(name, String(item)); + } + continue; + } + + if (headerValue !== undefined) { + headers.set(name, String(headerValue)); + } + } + + return headers; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + function resolveWebSocket( WebSocketCtor: WebSocketConstructor | undefined, ): WebSocketConstructor {