import { describe, it, expect, beforeEach } from "vitest"; import { Agent, ClientSideConnection, Client, AgentSideConnection, InitializeRequest, InitializeResponse, NewSessionRequest, NewSessionResponse, LoadSessionRequest, LoadSessionResponse, AuthenticateRequest, AuthenticateResponse, PromptRequest, PromptResponse, WriteTextFileRequest, WriteTextFileResponse, ReadTextFileRequest, ReadTextFileResponse, RequestPermissionRequest, RequestPermissionResponse, CancelNotification, SessionNotification, PROTOCOL_VERSION, ndJsonStream, } from "./acp.js"; describe("Connection", () => { let clientToAgent: TransformStream; let agentToClient: TransformStream; beforeEach(() => { clientToAgent = new TransformStream(); agentToClient = new TransformStream(); }); it("handles errors in bidirectional communication", async () => { // Create client that throws errors class TestClient implements Client { async writeTextFile( _: WriteTextFileRequest, ): Promise { throw new Error("Write failed"); } async readTextFile( _: ReadTextFileRequest, ): Promise { throw new Error("Read failed"); } async requestPermission( _: RequestPermissionRequest, ): Promise { throw new Error("Permission denied"); } async sessionUpdate(_: SessionNotification): Promise { // no-op } } // Create agent that throws errors class TestAgent implements Agent { async initialize(_: InitializeRequest): Promise { throw new Error("Failed to initialize"); } async newSession(_: NewSessionRequest): Promise { throw new Error("Failed to create session"); } async loadSession(_: LoadSessionRequest): Promise { throw new Error("Failed to load session"); } async authenticate(_: AuthenticateRequest): Promise { throw new Error("Authentication failed"); } async prompt(_: PromptRequest): Promise { throw new Error("Prompt failed"); } async cancel(_: CancelNotification): Promise { // no-op } } // Set up connections const agentConnection = new ClientSideConnection( () => new TestClient(), ndJsonStream(clientToAgent.writable, agentToClient.readable), ); const clientConnection = new AgentSideConnection( () => new TestAgent(), ndJsonStream(agentToClient.writable, clientToAgent.readable), ); // Test error handling in client->agent direction await expect( clientConnection.writeTextFile({ path: "/test.txt", content: "test", sessionId: "test-session", }), ).rejects.toThrow(); // Test error handling in agent->client direction await expect( agentConnection.newSession({ cwd: "/test", mcpServers: [], }), ).rejects.toThrow(); }); it("handles concurrent requests", async () => { let requestCount = 0; // Create client class TestClient implements Client { async writeTextFile( _: WriteTextFileRequest, ): Promise { requestCount++; const currentCount = requestCount; await new Promise((resolve) => setTimeout(resolve, 40)); console.log(`Write request ${currentCount} completed`); return {}; } async readTextFile( params: ReadTextFileRequest, ): Promise { return { content: `Content of ${params.path}` }; } async requestPermission( _: RequestPermissionRequest, ): Promise { return { outcome: { outcome: "selected", optionId: "allow", }, }; } async sessionUpdate(_: SessionNotification): Promise { // no-op } } // Create agent class TestAgent implements Agent { async initialize(_: InitializeRequest): Promise { return { protocolVersion: 1, agentCapabilities: { loadSession: false }, authMethods: [], }; } async newSession(_: NewSessionRequest): Promise { return { sessionId: "test-session", }; } async loadSession(_: LoadSessionRequest): Promise { return {}; } async authenticate(_: AuthenticateRequest): Promise { // no-op } async prompt(_: PromptRequest): Promise { return { stopReason: "end_turn" }; } async cancel(_: CancelNotification): Promise { // no-op } } // Set up connections const agentConnection = new ClientSideConnection( () => new TestClient(), ndJsonStream(clientToAgent.writable, agentToClient.readable), ); const clientConnection = new AgentSideConnection( () => new TestAgent(), ndJsonStream(agentToClient.writable, clientToAgent.readable), ); // Send multiple concurrent requests const promises = [ clientConnection.writeTextFile({ path: "/file1.txt", content: "content1", sessionId: "session1", }), clientConnection.writeTextFile({ path: "/file2.txt", content: "content2", sessionId: "session1", }), clientConnection.writeTextFile({ path: "/file3.txt", content: "content3", sessionId: "session1", }), ]; const results = await Promise.all(promises); // Verify all requests completed successfully expect(results).toHaveLength(3); expect(results[0]).toEqual({}); expect(results[1]).toEqual({}); expect(results[2]).toEqual({}); expect(requestCount).toBe(3); }); it("handles message ordering correctly", async () => { const messageLog: string[] = []; // Create client class TestClient implements Client { async writeTextFile( params: WriteTextFileRequest, ): Promise { messageLog.push(`writeTextFile called: ${params.path}`); return {}; } async readTextFile( params: ReadTextFileRequest, ): Promise { messageLog.push(`readTextFile called: ${params.path}`); return { content: "test content" }; } async requestPermission( params: RequestPermissionRequest, ): Promise { messageLog.push(`requestPermission called: ${params.toolCall.title}`); return { outcome: { outcome: "selected", optionId: "allow", }, }; } async sessionUpdate(params: SessionNotification): Promise { messageLog.push("sessionUpdate called"); } } // Create agent class TestAgent implements Agent { async initialize(_: InitializeRequest): Promise { return { protocolVersion: 1, agentCapabilities: { loadSession: false }, authMethods: [], }; } async newSession( request: NewSessionRequest, ): Promise { messageLog.push(`newSession called: ${request.cwd}`); return { sessionId: "test-session", }; } async loadSession( params: LoadSessionRequest, ): Promise { messageLog.push(`loadSession called: ${params.sessionId}`); return {}; } async authenticate(params: AuthenticateRequest): Promise { messageLog.push(`authenticate called: ${params.methodId}`); } async prompt(params: PromptRequest): Promise { messageLog.push(`prompt called: ${params.sessionId}`); return { stopReason: "end_turn" }; } async cancel(params: CancelNotification): Promise { messageLog.push(`cancelled called: ${params.sessionId}`); } } // Set up connections const agentConnection = new ClientSideConnection( () => new TestClient(), ndJsonStream(clientToAgent.writable, agentToClient.readable), ); const clientConnection = new AgentSideConnection( () => new TestAgent(), ndJsonStream(agentToClient.writable, clientToAgent.readable), ); // Send requests in specific order await agentConnection.newSession({ cwd: "/test", mcpServers: [], }); await clientConnection.writeTextFile({ path: "/test.txt", content: "test", sessionId: "test-session", }); await clientConnection.readTextFile({ path: "/test.txt", sessionId: "test-session", }); await clientConnection.requestPermission({ sessionId: "test-session", toolCall: { title: "Execute command", kind: "execute", status: "pending", toolCallId: "tool-123", content: [ { type: "content", content: { type: "text", text: "ls -la", }, }, ], }, options: [ { kind: "allow_once", name: "Allow", optionId: "allow", }, { kind: "reject_once", name: "Reject", optionId: "reject", }, ], }); // Verify order expect(messageLog).toEqual([ "newSession called: /test", "writeTextFile called: /test.txt", "readTextFile called: /test.txt", "requestPermission called: Execute command", ]); }); it("handles notifications correctly", async () => { const notificationLog: string[] = []; // Create client class TestClient implements Client { async writeTextFile( _: WriteTextFileRequest, ): Promise { return {}; } async readTextFile( _: ReadTextFileRequest, ): Promise { return { content: "test" }; } async requestPermission( _: RequestPermissionRequest, ): Promise { return { outcome: { outcome: "selected", optionId: "allow", }, }; } async sessionUpdate(notification: SessionNotification): Promise { if ( notification.update && "sessionUpdate" in notification.update && notification.update.sessionUpdate === "agent_message_chunk" ) { notificationLog.push( `agent message: ${(notification.update.content as any).text}`, ); } } } // Create agent class TestAgent implements Agent { async initialize(_: InitializeRequest): Promise { return { protocolVersion: 1, agentCapabilities: { loadSession: false }, authMethods: [], }; } async newSession(_: NewSessionRequest): Promise { return { sessionId: "test-session", }; } async loadSession(_: LoadSessionRequest): Promise { return {}; } async authenticate(_: AuthenticateRequest): Promise { // no-op } async prompt(_: PromptRequest): Promise { return { stopReason: "end_turn" }; } async cancel(params: CancelNotification): Promise { notificationLog.push(`cancelled: ${params.sessionId}`); } } // Create shared instances const testClient = () => new TestClient(); const testAgent = () => new TestAgent(); // Set up connections const agentConnection = new ClientSideConnection( testClient, ndJsonStream(clientToAgent.writable, agentToClient.readable), ); const clientConnection = new AgentSideConnection( testAgent, ndJsonStream(agentToClient.writable, clientToAgent.readable), ); // Send notifications await clientConnection.sessionUpdate({ sessionId: "test-session", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Hello from agent", }, }, }); await agentConnection.cancel({ sessionId: "test-session", }); // Wait a bit for async handlers await new Promise((resolve) => setTimeout(resolve, 50)); // Verify notifications were received expect(notificationLog).toContain("agent message: Hello from agent"); expect(notificationLog).toContain("cancelled: test-session"); }); it("handles initialize method", async () => { // Create client class TestClient implements Client { async writeTextFile( _: WriteTextFileRequest, ): Promise { return {}; } async readTextFile( _: ReadTextFileRequest, ): Promise { return { content: "test" }; } async requestPermission( _: RequestPermissionRequest, ): Promise { return { outcome: { outcome: "selected", optionId: "allow", }, }; } async sessionUpdate(_: SessionNotification): Promise { // no-op } } // Create agent class TestAgent implements Agent { async initialize(params: InitializeRequest): Promise { return { protocolVersion: params.protocolVersion, agentCapabilities: { loadSession: true }, authMethods: [ { id: "oauth", name: "OAuth", description: "Authenticate with OAuth", }, ], }; } async newSession(_: NewSessionRequest): Promise { return { sessionId: "test-session" }; } async loadSession(_: LoadSessionRequest): Promise { return {}; } async authenticate(_: AuthenticateRequest): Promise { // no-op } async prompt(_: PromptRequest): Promise { return { stopReason: "end_turn" }; } async cancel(_: CancelNotification): Promise { // no-op } } // Set up connections const agentConnection = new ClientSideConnection( () => new TestClient(), ndJsonStream(clientToAgent.writable, agentToClient.readable), ); const clientConnection = new AgentSideConnection( () => new TestAgent(), ndJsonStream(agentToClient.writable, clientToAgent.readable), ); // Test initialize request const response = await agentConnection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false, }, }, }); expect(response.protocolVersion).toBe(PROTOCOL_VERSION); expect(response.agentCapabilities?.loadSession).toBe(true); expect(response.authMethods).toHaveLength(1); expect(response.authMethods?.[0].id).toBe("oauth"); }); it("handles extension methods and notifications", async () => { const extensionLog: string[] = []; // Create client with extension method support class TestClient implements Client { async writeTextFile( _: WriteTextFileRequest, ): Promise { return {}; } async readTextFile( _: ReadTextFileRequest, ): Promise { return { content: "test" }; } async requestPermission( _: RequestPermissionRequest, ): Promise { return { outcome: { outcome: "selected", optionId: "allow", }, }; } async sessionUpdate(_: SessionNotification): Promise { // no-op } async extMethod( method: string, params: Record, ): Promise> { if (method === "example.com/ping") { return { response: "pong", params }; } throw new Error(`Unknown method: ${method}`); } async extNotification( method: string, params: Record, ): Promise { extensionLog.push(`client extNotification: ${method}`); } } // Create agent with extension method support class TestAgent implements Agent { async initialize(_: InitializeRequest): Promise { return { protocolVersion: PROTOCOL_VERSION, agentCapabilities: { loadSession: false }, }; } async newSession(_: NewSessionRequest): Promise { return { sessionId: "test-session" }; } async authenticate(_: AuthenticateRequest): Promise { // no-op } async prompt(_: PromptRequest): Promise { return { stopReason: "end_turn" }; } async cancel(_: CancelNotification): Promise { // no-op } async extMethod( method: string, params: Record, ): Promise> { if (method === "example.com/echo") { return { echo: params }; } throw new Error(`Unknown method: ${method}`); } async extNotification( method: string, params: Record, ): Promise { extensionLog.push(`agent extNotification: ${method}`); } } // Set up connections const agentConnection = new ClientSideConnection( () => new TestClient(), ndJsonStream(clientToAgent.writable, agentToClient.readable), ); const clientConnection = new AgentSideConnection( () => new TestAgent(), ndJsonStream(agentToClient.writable, clientToAgent.readable), ); // Test agent calling client extension method const clientResponse = await clientConnection.extMethod( "example.com/ping", { data: "test", }, ); expect(clientResponse).toEqual({ response: "pong", params: { data: "test" }, }); // Test client calling agent extension method const agentResponse = await agentConnection.extMethod("example.com/echo", { message: "hello", }); expect(agentResponse).toEqual({ echo: { message: "hello" } }); // Test extension notifications await clientConnection.extNotification("example.com/client/notify", { info: "client notification", }); await agentConnection.extNotification("example.com/agent/notify", { info: "agent notification", }); // Wait a bit for async handlers await new Promise((resolve) => setTimeout(resolve, 50)); // Verify notifications were logged expect(extensionLog).toContain( "client extNotification: example.com/client/notify", ); expect(extensionLog).toContain( "agent extNotification: example.com/agent/notify", ); }); it("handles optional extension methods correctly", async () => { // Create client WITHOUT extension methods class TestClientWithoutExtensions implements Client { async writeTextFile( _: WriteTextFileRequest, ): Promise { return {}; } async readTextFile( _: ReadTextFileRequest, ): Promise { return { content: "test" }; } async requestPermission( _: RequestPermissionRequest, ): Promise { return { outcome: { outcome: "selected", optionId: "allow", }, }; } async sessionUpdate(_: SessionNotification): Promise { // no-op } // Note: No extMethod or extNotification implemented } // Create agent WITHOUT extension methods class TestAgentWithoutExtensions implements Agent { async initialize(_: InitializeRequest): Promise { return { protocolVersion: PROTOCOL_VERSION, agentCapabilities: { loadSession: false }, }; } async newSession(_: NewSessionRequest): Promise { return { sessionId: "test-session" }; } async authenticate(_: AuthenticateRequest): Promise { // no-op } async prompt(_: PromptRequest): Promise { return { stopReason: "end_turn" }; } async cancel(_: CancelNotification): Promise { // no-op } // Note: No extMethod or extNotification implemented } // Set up connections const agentConnection = new ClientSideConnection( () => new TestClientWithoutExtensions(), ndJsonStream(clientToAgent.writable, agentToClient.readable), ); const clientConnection = new AgentSideConnection( () => new TestAgentWithoutExtensions(), ndJsonStream(agentToClient.writable, clientToAgent.readable), ); // Test that calling extension methods on connections without them throws method not found try { await clientConnection.extMethod("example.com/ping", { data: "test" }); expect.fail("Should have thrown method not found error"); } catch (error: any) { expect(error.code).toBe(-32601); // Method not found expect(error.data.method).toBe("_example.com/ping"); // Should show full method name with underscore } try { await agentConnection.extMethod("example.com/echo", { message: "hello" }); expect.fail("Should have thrown method not found error"); } catch (error: any) { expect(error.code).toBe(-32601); // Method not found expect(error.data.method).toBe("_example.com/echo"); // Should show full method name with underscore } // Notifications should be ignored when not implemented (no error thrown) await clientConnection.extNotification("example.com/notify", { info: "test", }); await agentConnection.extNotification("example.com/notify", { info: "test", }); }); it("handles methods returning response objects with _meta or void", async () => { // Create client that returns both response objects and void class TestClient implements Client { async writeTextFile( params: WriteTextFileRequest, ): Promise { // Return response object with _meta return { _meta: { timestamp: new Date().toISOString(), version: "1.0.0", }, }; } async readTextFile( params: ReadTextFileRequest, ): Promise { return { content: "test content", _meta: { encoding: "utf-8", }, }; } async requestPermission( params: RequestPermissionRequest, ): Promise { return { outcome: { outcome: "selected", optionId: "allow", }, _meta: { userId: "test-user", }, }; } async sessionUpdate(params: SessionNotification): Promise { // Returns void } } // Create agent that returns both response objects and void class TestAgent implements Agent { async initialize(params: InitializeRequest): Promise { return { protocolVersion: params.protocolVersion, agentCapabilities: { loadSession: true }, _meta: { agentVersion: "2.0.0", }, }; } async newSession(params: NewSessionRequest): Promise { return { sessionId: "test-session", _meta: { sessionType: "ephemeral", }, }; } async loadSession( params: LoadSessionRequest, ): Promise { // Test returning minimal response return {}; } async authenticate( params: AuthenticateRequest, ): Promise { if (params.methodId === "none") { // Test returning void return; } // Test returning response with _meta return { _meta: { authenticated: true, method: params.methodId, }, }; } async prompt(params: PromptRequest): Promise { return { stopReason: "end_turn" }; } async cancel(params: CancelNotification): Promise { // Returns void } } // Set up connections const agentConnection = new ClientSideConnection( () => new TestClient(), ndJsonStream(clientToAgent.writable, agentToClient.readable), ); const clientConnection = new AgentSideConnection( () => new TestAgent(), ndJsonStream(agentToClient.writable, clientToAgent.readable), ); // Test writeTextFile returns response with _meta const writeResponse = await clientConnection.writeTextFile({ path: "/test.txt", content: "test", sessionId: "test-session", }); expect(writeResponse).toEqual({ _meta: { timestamp: expect.any(String), version: "1.0.0", }, }); // Test readTextFile returns response with content and _meta const readResponse = await clientConnection.readTextFile({ path: "/test.txt", sessionId: "test-session", }); expect(readResponse.content).toBe("test content"); expect(readResponse._meta).toEqual({ encoding: "utf-8", }); // Test initialize with _meta const initResponse = await agentConnection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {}, }); expect(initResponse._meta).toEqual({ agentVersion: "2.0.0", }); // Test authenticate returning void const authResponseVoid = await agentConnection.authenticate({ methodId: "none", }); expect(authResponseVoid).toEqual({}); // Test authenticate returning response with _meta const authResponse = await agentConnection.authenticate({ methodId: "oauth", }); expect(authResponse).toEqual({ _meta: { authenticated: true, method: "oauth", }, }); // Test newSession with _meta const sessionResponse = await agentConnection.newSession({ cwd: "/test", mcpServers: [], }); expect(sessionResponse._meta).toEqual({ sessionType: "ephemeral", }); // Test loadSession returning minimal response const loadResponse = await agentConnection.loadSession({ sessionId: "test-session", mcpServers: [], cwd: "/test", }); expect(loadResponse).toEqual({}); }); });