Skip to content

Commit b994d60

Browse files
author
Mark Pollack
committed
Add acp-test module with test utilities
- InMemoryTransportPair: bidirectional in-memory transport - MockAcpAgent: mock agent for testing client code - MockAcpClient: mock client for testing agent code These utilities were previously in acp-core's test sources.
1 parent 7fc4861 commit b994d60

File tree

8 files changed

+1387
-0
lines changed

8 files changed

+1387
-0
lines changed

acp-test/pom.xml

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
5+
<modelVersion>4.0.0</modelVersion>
6+
7+
<parent>
8+
<groupId>com.agentclientprotocol</groupId>
9+
<artifactId>acp-java-sdk</artifactId>
10+
<version>0.9.0-SNAPSHOT</version>
11+
</parent>
12+
13+
<artifactId>acp-test</artifactId>
14+
<packaging>jar</packaging>
15+
16+
<name>ACP Test Utilities</name>
17+
<description>Test utilities for ACP SDK - InMemoryTransportPair, MockAcpAgent, MockAcpClient</description>
18+
19+
<dependencies>
20+
<!-- ACP Core (required for transport interfaces and schema types) -->
21+
<dependency>
22+
<groupId>com.agentclientprotocol</groupId>
23+
<artifactId>acp-core</artifactId>
24+
</dependency>
25+
26+
<!-- Reactor for async operations -->
27+
<dependency>
28+
<groupId>io.projectreactor</groupId>
29+
<artifactId>reactor-core</artifactId>
30+
</dependency>
31+
32+
<!-- Test dependencies for module's own tests -->
33+
<dependency>
34+
<groupId>org.junit.jupiter</groupId>
35+
<artifactId>junit-jupiter</artifactId>
36+
<scope>test</scope>
37+
</dependency>
38+
<dependency>
39+
<groupId>org.assertj</groupId>
40+
<artifactId>assertj-core</artifactId>
41+
<scope>test</scope>
42+
</dependency>
43+
<dependency>
44+
<groupId>ch.qos.logback</groupId>
45+
<artifactId>logback-classic</artifactId>
46+
<scope>test</scope>
47+
</dependency>
48+
</dependencies>
49+
50+
<build>
51+
<plugins>
52+
<plugin>
53+
<groupId>org.apache.maven.plugins</groupId>
54+
<artifactId>maven-compiler-plugin</artifactId>
55+
</plugin>
56+
<plugin>
57+
<groupId>org.apache.maven.plugins</groupId>
58+
<artifactId>maven-surefire-plugin</artifactId>
59+
</plugin>
60+
<plugin>
61+
<groupId>org.apache.maven.plugins</groupId>
62+
<artifactId>maven-source-plugin</artifactId>
63+
</plugin>
64+
<plugin>
65+
<groupId>org.apache.maven.plugins</groupId>
66+
<artifactId>maven-javadoc-plugin</artifactId>
67+
</plugin>
68+
</plugins>
69+
</build>
70+
</project>
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
/*
2+
* Copyright 2025-2025 the original author or authors.
3+
*/
4+
5+
package com.agentclientprotocol.sdk.test;
6+
7+
import java.util.List;
8+
import java.util.function.Consumer;
9+
import java.util.function.Function;
10+
11+
import com.agentclientprotocol.sdk.spec.AcpAgentTransport;
12+
import com.agentclientprotocol.sdk.spec.AcpClientTransport;
13+
import com.agentclientprotocol.sdk.spec.AcpSchema;
14+
import io.modelcontextprotocol.json.McpJsonMapper;
15+
import io.modelcontextprotocol.json.TypeRef;
16+
import reactor.core.publisher.Mono;
17+
import reactor.core.publisher.Sinks;
18+
19+
/**
20+
* Creates a bidirectional in-memory transport pair for testing client ↔ agent communication
21+
* without real processes or network connections.
22+
*
23+
* <p>
24+
* This class provides connected client and agent transports that communicate through
25+
* in-memory sinks, enabling:
26+
* </p>
27+
* <ul>
28+
* <li>Unit testing of protocol logic without I/O</li>
29+
* <li>Fast, deterministic tests</li>
30+
* <li>Testing both client and agent sides in isolation or together</li>
31+
* </ul>
32+
*
33+
* <p>
34+
* Example usage:
35+
* </p>
36+
* <pre>{@code
37+
* InMemoryTransportPair pair = InMemoryTransportPair.create();
38+
*
39+
* // Use client transport in client code
40+
* AcpClientTransport clientTransport = pair.clientTransport();
41+
*
42+
* // Use agent transport in agent code
43+
* AcpAgentTransport agentTransport = pair.agentTransport();
44+
*
45+
* // Messages sent by client arrive at agent, and vice versa
46+
* }</pre>
47+
*
48+
* @author Mark Pollack
49+
*/
50+
public class InMemoryTransportPair {
51+
52+
private final InMemoryClientTransport clientTransport;
53+
54+
private final InMemoryAgentTransport agentTransport;
55+
56+
private InMemoryTransportPair() {
57+
// Bidirectional sinks: client→agent and agent→client
58+
Sinks.Many<AcpSchema.JSONRPCMessage> clientToAgent = Sinks.many().unicast().onBackpressureBuffer();
59+
Sinks.Many<AcpSchema.JSONRPCMessage> agentToClient = Sinks.many().unicast().onBackpressureBuffer();
60+
61+
this.clientTransport = new InMemoryClientTransport(clientToAgent, agentToClient);
62+
this.agentTransport = new InMemoryAgentTransport(agentToClient, clientToAgent);
63+
}
64+
65+
/**
66+
* Creates a new transport pair with connected client and agent transports.
67+
* @return a new InMemoryTransportPair
68+
*/
69+
public static InMemoryTransportPair create() {
70+
return new InMemoryTransportPair();
71+
}
72+
73+
/**
74+
* Gets the client-side transport.
75+
* @return the client transport
76+
*/
77+
public AcpClientTransport clientTransport() {
78+
return clientTransport;
79+
}
80+
81+
/**
82+
* Gets the agent-side transport.
83+
* @return the agent transport
84+
*/
85+
public AcpAgentTransport agentTransport() {
86+
return agentTransport;
87+
}
88+
89+
/**
90+
* Closes both transports gracefully.
91+
* @return a Mono that completes when both transports are closed
92+
*/
93+
public Mono<Void> closeGracefully() {
94+
return Mono.when(clientTransport.closeGracefully(), agentTransport.closeGracefully());
95+
}
96+
97+
/**
98+
* In-memory client transport implementation.
99+
*/
100+
private static class InMemoryClientTransport implements AcpClientTransport {
101+
102+
private final Sinks.Many<AcpSchema.JSONRPCMessage> outbound;
103+
104+
private final Sinks.Many<AcpSchema.JSONRPCMessage> inbound;
105+
106+
private volatile boolean connected = false;
107+
108+
private Consumer<Throwable> exceptionHandler = t -> {
109+
};
110+
111+
InMemoryClientTransport(Sinks.Many<AcpSchema.JSONRPCMessage> outbound,
112+
Sinks.Many<AcpSchema.JSONRPCMessage> inbound) {
113+
this.outbound = outbound;
114+
this.inbound = inbound;
115+
}
116+
117+
@Override
118+
public List<Integer> protocolVersions() {
119+
return List.of(AcpSchema.LATEST_PROTOCOL_VERSION);
120+
}
121+
122+
@Override
123+
public Mono<Void> connect(Function<Mono<AcpSchema.JSONRPCMessage>, Mono<AcpSchema.JSONRPCMessage>> handler) {
124+
if (connected) {
125+
return Mono.error(new IllegalStateException("Already connected"));
126+
}
127+
connected = true;
128+
return inbound.asFlux()
129+
.flatMap(message -> Mono.just(message).transform(handler))
130+
.doOnError(exceptionHandler::accept)
131+
.doFinally(signal -> connected = false)
132+
.then();
133+
}
134+
135+
@Override
136+
public Mono<Void> sendMessage(AcpSchema.JSONRPCMessage message) {
137+
Sinks.EmitResult result = outbound.tryEmitNext(message);
138+
if (result.isFailure()) {
139+
return Mono.error(new RuntimeException("Failed to send message: " + result));
140+
}
141+
return Mono.empty();
142+
}
143+
144+
@Override
145+
public Mono<Void> closeGracefully() {
146+
return Mono.defer(() -> {
147+
connected = false;
148+
outbound.tryEmitComplete();
149+
return Mono.empty();
150+
});
151+
}
152+
153+
@Override
154+
public void setExceptionHandler(Consumer<Throwable> handler) {
155+
this.exceptionHandler = handler;
156+
}
157+
158+
@Override
159+
public <T> T unmarshalFrom(Object data, TypeRef<T> typeRef) {
160+
return McpJsonMapper.getDefault().convertValue(data, typeRef);
161+
}
162+
163+
}
164+
165+
/**
166+
* In-memory agent transport implementation.
167+
*/
168+
private static class InMemoryAgentTransport implements AcpAgentTransport {
169+
170+
private final Sinks.Many<AcpSchema.JSONRPCMessage> outbound;
171+
172+
private final Sinks.Many<AcpSchema.JSONRPCMessage> inbound;
173+
174+
private final Sinks.One<Void> terminationSink = Sinks.one();
175+
176+
private volatile boolean started = false;
177+
178+
private Consumer<Throwable> exceptionHandler = t -> {
179+
};
180+
181+
InMemoryAgentTransport(Sinks.Many<AcpSchema.JSONRPCMessage> outbound,
182+
Sinks.Many<AcpSchema.JSONRPCMessage> inbound) {
183+
this.outbound = outbound;
184+
this.inbound = inbound;
185+
}
186+
187+
@Override
188+
public List<Integer> protocolVersions() {
189+
return List.of(AcpSchema.LATEST_PROTOCOL_VERSION);
190+
}
191+
192+
@Override
193+
public Mono<Void> start(Function<Mono<AcpSchema.JSONRPCMessage>, Mono<AcpSchema.JSONRPCMessage>> handler) {
194+
if (started) {
195+
return Mono.error(new IllegalStateException("Already started"));
196+
}
197+
started = true;
198+
return inbound.asFlux()
199+
.flatMap(message -> Mono.just(message)
200+
.transform(handler)
201+
.flatMap(response -> {
202+
// Send response back through outbound sink
203+
Sinks.EmitResult result = outbound.tryEmitNext(response);
204+
if (result.isFailure()) {
205+
return Mono.error(new RuntimeException("Failed to send response: " + result));
206+
}
207+
return Mono.empty();
208+
}))
209+
.doOnError(exceptionHandler::accept)
210+
.doFinally(signal -> started = false)
211+
.then();
212+
}
213+
214+
@Override
215+
public Mono<Void> sendMessage(AcpSchema.JSONRPCMessage message) {
216+
Sinks.EmitResult result = outbound.tryEmitNext(message);
217+
if (result.isFailure()) {
218+
return Mono.error(new RuntimeException("Failed to send message: " + result));
219+
}
220+
return Mono.empty();
221+
}
222+
223+
@Override
224+
public Mono<Void> closeGracefully() {
225+
return Mono.defer(() -> {
226+
started = false;
227+
outbound.tryEmitComplete();
228+
terminationSink.tryEmitValue(null);
229+
return Mono.empty();
230+
});
231+
}
232+
233+
@Override
234+
public Mono<Void> awaitTermination() {
235+
return terminationSink.asMono();
236+
}
237+
238+
@Override
239+
public void setExceptionHandler(Consumer<Throwable> handler) {
240+
this.exceptionHandler = handler;
241+
}
242+
243+
@Override
244+
public <T> T unmarshalFrom(Object data, TypeRef<T> typeRef) {
245+
return McpJsonMapper.getDefault().convertValue(data, typeRef);
246+
}
247+
248+
}
249+
250+
}

0 commit comments

Comments
 (0)