Skip to content

Commit 060be4d

Browse files
committed
test: add OpenAI backend to streamable HTTP demo
1 parent e2ba51c commit 060be4d

3 files changed

Lines changed: 219 additions & 10 deletions

File tree

test-fixtures/streamable-http-agent-server/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,19 @@ node dist/client.js --endpoint http://127.0.0.1:8080/acp --scenario happy-path
2727
The demo supports `initialize`, `session/new`, `session/load`, `session/prompt`,
2828
and `session/cancel`. Prompts containing the word `permission` also exercise the
2929
agent-to-client `session/request_permission` round trip.
30+
31+
By default, the server uses a deterministic echo backend. To exercise the same
32+
ACP transport with a real OpenAI-backed agent through Spring AI:
33+
34+
```bash
35+
export OPENAI_API_KEY=...
36+
# Optional; defaults to OPENAI_MODEL or gpt-4o-mini.
37+
export OPENAI_MODEL=gpt-4o-mini
38+
39+
java -jar test-fixtures/streamable-http-agent-server/target/acp-streamable-http-agent-server.jar \
40+
--port 8080 \
41+
--backend spring-ai-openai
42+
```
43+
44+
The Spring AI backend is intentionally scoped to this runnable fixture. It is not
45+
part of the core SDK transport implementation.

test-fixtures/streamable-http-agent-server/pom.xml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,30 @@
2020
<properties>
2121
<!-- This is a runnable fixture, not a published SDK artifact. -->
2222
<maven.deploy.skip>true</maven.deploy.skip>
23+
<spring-ai.version>1.1.6</spring-ai.version>
2324
</properties>
2425

26+
<dependencyManagement>
27+
<dependencies>
28+
<dependency>
29+
<groupId>org.springframework.ai</groupId>
30+
<artifactId>spring-ai-bom</artifactId>
31+
<version>${spring-ai.version}</version>
32+
<type>pom</type>
33+
<scope>import</scope>
34+
</dependency>
35+
</dependencies>
36+
</dependencyManagement>
37+
2538
<dependencies>
2639
<dependency>
2740
<groupId>com.agentclientprotocol</groupId>
2841
<artifactId>acp-streamable-http-jetty</artifactId>
2942
</dependency>
43+
<dependency>
44+
<groupId>org.springframework.ai</groupId>
45+
<artifactId>spring-ai-openai</artifactId>
46+
</dependency>
3047
<dependency>
3148
<groupId>ch.qos.logback</groupId>
3249
<artifactId>logback-classic</artifactId>

test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java

Lines changed: 186 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,30 @@
55
package com.agentclientprotocol.sdk.fixtures;
66

77
import java.time.Duration;
8+
import java.util.List;
89
import java.util.Locale;
910
import java.util.Map;
1011
import java.util.concurrent.ConcurrentHashMap;
12+
import java.util.concurrent.ExecutorService;
13+
import java.util.concurrent.Executors;
1114
import java.util.concurrent.atomic.AtomicInteger;
1215

1316
import com.agentclientprotocol.sdk.agent.AcpAgent;
1417
import com.agentclientprotocol.sdk.agent.AcpAgentFactory;
1518
import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport;
1619
import com.agentclientprotocol.sdk.json.AcpJsonMapper;
1720
import com.agentclientprotocol.sdk.spec.AcpSchema;
21+
import org.springframework.ai.chat.messages.SystemMessage;
22+
import org.springframework.ai.chat.messages.UserMessage;
23+
import org.springframework.ai.chat.model.ChatResponse;
24+
import org.springframework.ai.chat.model.Generation;
25+
import org.springframework.ai.chat.prompt.Prompt;
26+
import org.springframework.ai.openai.OpenAiChatModel;
27+
import org.springframework.ai.openai.OpenAiChatOptions;
28+
import org.springframework.ai.openai.api.OpenAiApi;
1829
import reactor.core.publisher.Mono;
30+
import reactor.core.scheduler.Scheduler;
31+
import reactor.core.scheduler.Schedulers;
1932

2033
/**
2134
* Small runnable ACP agent server for manually exercising the Streamable HTTP transport.
@@ -28,6 +41,13 @@ public final class StreamableHttpAgentDemoServer {
2841

2942
private static final Duration STOP_TIMEOUT = Duration.ofSeconds(5);
3043

44+
private static final String OPENAI_SYSTEM_PROMPT = """
45+
You are a small ACP demo agent running inside the Java SDK Streamable HTTP fixture.
46+
Answer concisely. If the user asks about implementation details, say that this
47+
fixture is exercising the ACP Streamable HTTP transport, not providing a full
48+
production agent runtime.
49+
""";
50+
3151
private StreamableHttpAgentDemoServer() {
3252
}
3353

@@ -50,6 +70,15 @@ public static void main(String[] args) {
5070

5171
Map<String, String> sessionCwds = new ConcurrentHashMap<>();
5272
AtomicInteger sessionCounter = new AtomicInteger();
73+
PromptBackend promptBackend;
74+
try {
75+
promptBackend = options.backend().create();
76+
}
77+
catch (IllegalArgumentException e) {
78+
System.err.println(e.getMessage());
79+
System.exit(2);
80+
return;
81+
}
5382

5483
AcpAgentFactory agentFactory = AcpAgentFactory.async(transport -> AcpAgent.async(transport)
5584
.requestTimeout(Duration.ofMinutes(2))
@@ -75,7 +104,7 @@ public static void main(String[] args) {
75104
"Permission " + (allowed ? "granted" : "denied") + ". Prompt: " + normalized))
76105
.onErrorResume(error -> context.sendMessage(
77106
"Permission request failed in demo server: " + error.getMessage()))
78-
: context.sendMessage("Demo agent received: " + normalized + " [cwd=" + cwd + "]");
107+
: promptBackend.generate(normalized, request.sessionId(), cwd).flatMap(context::sendMessage);
79108
return response.thenReturn(AcpSchema.PromptResponse.endTurn());
80109
})
81110
.cancelHandler(notification -> {
@@ -88,8 +117,14 @@ public static void main(String[] args) {
88117
AcpJsonMapper.createDefault(), agentFactory)
89118
.routingMode(options.routingMode());
90119

91-
Runtime.getRuntime().addShutdownHook(new Thread(() -> server.closeGracefully().block(STOP_TIMEOUT),
92-
"acp-demo-shutdown"));
120+
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
121+
try {
122+
server.closeGracefully().block(STOP_TIMEOUT);
123+
}
124+
finally {
125+
promptBackend.close();
126+
}
127+
}, "acp-demo-shutdown"));
93128

94129
server.start().block(START_TIMEOUT);
95130
System.out.println("ACP Streamable HTTP demo agent listening at http://127.0.0.1:" + server.getPort()
@@ -103,29 +138,39 @@ private static void printUsage() {
103138
Usage: java -jar acp-streamable-http-agent-server.jar [options]
104139
105140
Options:
106-
--port <port> Port to listen on. Defaults to 8080.
107-
--path <path> ACP endpoint path. Defaults to /acp.
108-
--strict Use strict transport routing.
109-
--compatible Use compatible transport routing. This is the default.
110-
-h, --help Show this help.
141+
--port <port> Port to listen on. Defaults to 8080.
142+
--path <path> ACP endpoint path. Defaults to /acp.
143+
--backend <backend> Agent backend: echo or spring-ai-openai. Defaults to echo.
144+
--openai-model <model> OpenAI model for spring-ai-openai. Defaults to OPENAI_MODEL or gpt-4o-mini.
145+
--strict Use strict transport routing.
146+
--compatible Use compatible transport routing. This is the default.
147+
-h, --help Show this help.
148+
149+
Environment:
150+
OPENAI_API_KEY Required when --backend spring-ai-openai is used.
151+
OPENAI_MODEL Optional default model for --backend spring-ai-openai.
111152
""");
112153
}
113154

114155
private record Options(int port, String path, StreamableHttpAcpAgentTransport.RoutingMode routingMode,
115-
boolean help) {
156+
Backend backend, boolean help) {
116157

117158
static Options parse(String[] args) {
118159
int port = 8080;
119160
String path = StreamableHttpAcpAgentTransport.DEFAULT_ACP_PATH;
120161
StreamableHttpAcpAgentTransport.RoutingMode routingMode =
121162
StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE;
163+
String backendName = "echo";
164+
String openAiModel = null;
122165
boolean help = false;
123166

124167
for (int i = 0; i < args.length; i++) {
125168
String arg = args[i];
126169
switch (arg) {
127170
case "--port" -> port = parsePort(requireValue(args, ++i, "--port"));
128171
case "--path" -> path = requireValue(args, ++i, "--path");
172+
case "--backend" -> backendName = requireValue(args, ++i, "--backend");
173+
case "--openai-model" -> openAiModel = requireValue(args, ++i, "--openai-model");
129174
case "--strict" -> routingMode = StreamableHttpAcpAgentTransport.RoutingMode.STRICT;
130175
case "--compatible" -> routingMode = StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE;
131176
case "-h", "--help" -> help = true;
@@ -136,7 +181,8 @@ static Options parse(String[] args) {
136181
if (!path.startsWith("/")) {
137182
throw new IllegalArgumentException("--path must start with /");
138183
}
139-
return new Options(port, path, routingMode, help);
184+
Backend backend = Backend.parse(backendName, openAiModel);
185+
return new Options(port, path, routingMode, backend, help);
140186
}
141187

142188
private static String requireValue(String[] args, int index, String option) {
@@ -161,4 +207,134 @@ private static int parsePort(String value) {
161207

162208
}
163209

210+
private sealed interface Backend permits EchoBackend, SpringAiOpenAiBackend {
211+
212+
PromptBackend create();
213+
214+
static Backend echo() {
215+
return new EchoBackend();
216+
}
217+
218+
static Backend springAiOpenAi(String model) {
219+
return new SpringAiOpenAiBackend(model);
220+
}
221+
222+
static Backend parse(String value, String openAiModel) {
223+
return switch (value) {
224+
case "echo" -> echo();
225+
case "spring-ai-openai" -> springAiOpenAi(openAiModel);
226+
default -> throw new IllegalArgumentException("Unknown backend: " + value);
227+
};
228+
}
229+
230+
}
231+
232+
@FunctionalInterface
233+
private interface PromptBackend {
234+
235+
Mono<String> generate(String prompt, String sessionId, String cwd);
236+
237+
default void close() {
238+
}
239+
240+
}
241+
242+
private record EchoBackend() implements Backend {
243+
244+
@Override
245+
public PromptBackend create() {
246+
return new EchoPromptBackend();
247+
}
248+
249+
}
250+
251+
private static final class EchoPromptBackend implements PromptBackend {
252+
253+
@Override
254+
public Mono<String> generate(String prompt, String sessionId, String cwd) {
255+
return Mono.just("Demo agent received: " + prompt + " [cwd=" + cwd + "]");
256+
}
257+
258+
}
259+
260+
private record SpringAiOpenAiBackend(String model) implements Backend {
261+
262+
@Override
263+
public PromptBackend create() {
264+
String apiKey = System.getenv("OPENAI_API_KEY");
265+
if (apiKey == null || apiKey.isBlank()) {
266+
throw new IllegalArgumentException(
267+
"OPENAI_API_KEY is required when --backend spring-ai-openai is used");
268+
}
269+
270+
OpenAiApi openAiApi = OpenAiApi.builder().apiKey(apiKey).build();
271+
OpenAiChatOptions chatOptions = OpenAiChatOptions.builder()
272+
.model(resolveOpenAiModel(this.model))
273+
.temperature(0.2)
274+
.maxTokens(800)
275+
.build();
276+
OpenAiChatModel chatModel = OpenAiChatModel.builder()
277+
.openAiApi(openAiApi)
278+
.defaultOptions(chatOptions)
279+
.build();
280+
281+
return new SpringAiOpenAiPromptBackend(chatModel);
282+
}
283+
284+
}
285+
286+
private static final class SpringAiOpenAiPromptBackend implements PromptBackend {
287+
288+
private final OpenAiChatModel chatModel;
289+
290+
private final ExecutorService executorService;
291+
292+
private final Scheduler scheduler;
293+
294+
private SpringAiOpenAiPromptBackend(OpenAiChatModel chatModel) {
295+
this.chatModel = chatModel;
296+
AtomicInteger threadCounter = new AtomicInteger();
297+
this.executorService = Executors.newCachedThreadPool(task -> {
298+
Thread thread = new Thread(task, "acp-demo-openai-" + threadCounter.incrementAndGet());
299+
thread.setDaemon(true);
300+
return thread;
301+
});
302+
this.scheduler = Schedulers.fromExecutorService(this.executorService, "acp-demo-openai");
303+
}
304+
305+
@Override
306+
public Mono<String> generate(String prompt, String sessionId, String cwd) {
307+
return Mono.fromCallable(() -> generatePrompt(prompt, sessionId, cwd)).subscribeOn(this.scheduler);
308+
}
309+
310+
@Override
311+
public void close() {
312+
this.scheduler.dispose();
313+
this.executorService.shutdownNow();
314+
}
315+
316+
private String generatePrompt(String prompt, String sessionId, String cwd) {
317+
ChatResponse response = chatModel.call(new Prompt(List.of(new SystemMessage(OPENAI_SYSTEM_PROMPT),
318+
new UserMessage("Session: " + sessionId + "\nCWD: " + cwd + "\n\nUser prompt:\n" + prompt))));
319+
Generation generation = response.getResult();
320+
if (generation == null || generation.getOutput() == null || generation.getOutput().getText() == null
321+
|| generation.getOutput().getText().isBlank()) {
322+
return "(OpenAI returned an empty response)";
323+
}
324+
return generation.getOutput().getText();
325+
}
326+
327+
}
328+
329+
private static String resolveOpenAiModel(String model) {
330+
if (model != null && !model.isBlank()) {
331+
return model;
332+
}
333+
String envModel = System.getenv("OPENAI_MODEL");
334+
if (envModel != null && !envModel.isBlank()) {
335+
return envModel;
336+
}
337+
return "gpt-4o-mini";
338+
}
339+
164340
}

0 commit comments

Comments
 (0)