Skip to content

Commit abe618f

Browse files
author
Mark Pollack
committed
Null-mark the acp-test package
MockAcpAgent and MockAcpClient no longer construct themselves with a null delegate and write the final field by reflection afterwards: each constructor builds its delegate, whose handlers record into the mock's already-initialized fields. MockAcpClient's session id is @nullable until newSession, and its blocking calls go through one helper.
1 parent c5a2377 commit abe618f

3 files changed

Lines changed: 97 additions & 95 deletions

File tree

‎acp-test/src/main/java/com/agentclientprotocol/sdk/test/MockAcpAgent.java‎

Lines changed: 26 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,31 @@ public class MockAcpAgent {
6464

6565
private final AtomicReference<CountDownLatch> promptLatch = new AtomicReference<>(new CountDownLatch(0));
6666

67-
private MockAcpAgent(AcpAsyncAgent delegate) {
68-
this.delegate = delegate;
67+
private MockAcpAgent(Builder builder) {
68+
AcpSchema.InitializeResponse initializeResponse = builder.initializeResponse;
69+
AcpSchema.NewSessionResponse newSessionResponse = builder.newSessionResponse;
70+
Function<AcpSchema.PromptRequest, AcpSchema.PromptResponse> promptResponseProvider = builder.promptResponseProvider;
71+
// The handlers record into this mock's fields, which are initialized before this body runs
72+
this.delegate = AcpAgent.async(builder.transport)
73+
.requestTimeout(builder.requestTimeout)
74+
.initializeHandler(request -> {
75+
receivedInitRequests.add(request);
76+
return Mono.just(initializeResponse);
77+
})
78+
.newSessionHandler(request -> {
79+
receivedNewSessionRequests.add(request);
80+
return Mono.just(newSessionResponse);
81+
})
82+
.promptHandler((request, updater) -> {
83+
receivedPrompts.add(request);
84+
promptLatch.get().countDown();
85+
return Mono.just(promptResponseProvider.apply(request));
86+
})
87+
.cancelHandler(notification -> {
88+
receivedCancellations.add(notification);
89+
return Mono.empty();
90+
})
91+
.build();
6992
}
7093

7194
/**
@@ -242,40 +265,7 @@ public Builder requestTimeout(Duration timeout) {
242265
* @return The configured mock agent
243266
*/
244267
public MockAcpAgent build() {
245-
MockAcpAgent mockAgent = new MockAcpAgent(null);
246-
247-
AcpAsyncAgent delegate = AcpAgent.async(transport)
248-
.requestTimeout(requestTimeout)
249-
.initializeHandler(request -> {
250-
mockAgent.receivedInitRequests.add(request);
251-
return Mono.just(initializeResponse);
252-
})
253-
.newSessionHandler(request -> {
254-
mockAgent.receivedNewSessionRequests.add(request);
255-
return Mono.just(newSessionResponse);
256-
})
257-
.promptHandler((request, updater) -> {
258-
mockAgent.receivedPrompts.add(request);
259-
mockAgent.promptLatch.get().countDown();
260-
return Mono.just(promptResponseProvider.apply(request));
261-
})
262-
.cancelHandler(notification -> {
263-
mockAgent.receivedCancellations.add(notification);
264-
return Mono.empty();
265-
})
266-
.build();
267-
268-
// Replace the delegate using reflection (a bit ugly but avoids circular reference issues)
269-
try {
270-
var field = MockAcpAgent.class.getDeclaredField("delegate");
271-
field.setAccessible(true);
272-
field.set(mockAgent, delegate);
273-
}
274-
catch (Exception e) {
275-
throw new RuntimeException("Failed to set delegate", e);
276-
}
277-
278-
return mockAgent;
268+
return new MockAcpAgent(this);
279269
}
280270

281271
}

‎acp-test/src/main/java/com/agentclientprotocol/sdk/test/MockAcpClient.java‎

Lines changed: 55 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import com.agentclientprotocol.sdk.client.AcpClient;
1616
import com.agentclientprotocol.sdk.spec.AcpClientTransport;
1717
import com.agentclientprotocol.sdk.spec.AcpSchema;
18+
import org.jspecify.annotations.Nullable;
1819
import reactor.core.publisher.Mono;
1920

2021
/**
@@ -69,12 +70,36 @@ public class MockAcpClient {
6970

7071
private volatile CountDownLatch updateLatch = new CountDownLatch(0);
7172

72-
private volatile String currentSessionId;
73-
74-
private MockAcpClient(AcpAsyncClient delegate, AcpClientTransport transport, Duration timeout) {
75-
this.delegate = delegate;
76-
this.transport = transport;
77-
this.timeout = timeout;
73+
private volatile @Nullable String currentSessionId;
74+
75+
private MockAcpClient(Builder builder) {
76+
this.transport = builder.transport;
77+
this.timeout = builder.requestTimeout;
78+
Function<AcpSchema.RequestPermissionRequest, AcpSchema.RequestPermissionResponse> permissionHandler = builder.permissionHandler;
79+
Function<AcpSchema.ReadTextFileRequest, AcpSchema.ReadTextFileResponse> readFileHandler = builder.readFileHandler;
80+
Function<AcpSchema.WriteTextFileRequest, AcpSchema.WriteTextFileResponse> writeFileHandler = builder.writeFileHandler;
81+
// The handlers record into this mock's fields, which are initialized before this body runs
82+
this.delegate = AcpClient.async(builder.transport)
83+
.requestTimeout(builder.requestTimeout)
84+
.sessionUpdateConsumer(notification -> {
85+
receivedUpdates.add(notification);
86+
updateLatch.countDown();
87+
return Mono.empty();
88+
})
89+
// Using typed handlers (no manual unmarshalling needed)
90+
.requestPermissionHandler((AcpSchema.RequestPermissionRequest request) -> {
91+
receivedPermissionRequests.add(request);
92+
return Mono.just(permissionHandler.apply(request));
93+
})
94+
.readTextFileHandler((AcpSchema.ReadTextFileRequest request) -> {
95+
receivedFileReadRequests.add(request);
96+
return Mono.just(readFileHandler.apply(request));
97+
})
98+
.writeTextFileHandler((AcpSchema.WriteTextFileRequest request) -> {
99+
receivedFileWriteRequests.add(request);
100+
return Mono.just(writeFileHandler.apply(request));
101+
})
102+
.build();
78103
}
79104

80105
/**
@@ -104,7 +129,7 @@ public AcpSchema.InitializeResponse initialize() {
104129
// Mock client advertises all capabilities by default
105130
AcpSchema.FileSystemCapability fs = new AcpSchema.FileSystemCapability(true, true);
106131
AcpSchema.ClientCapabilities caps = new AcpSchema.ClientCapabilities(fs, true);
107-
return delegate.initialize(new AcpSchema.InitializeRequest(1, caps)).block(timeout);
132+
return await(delegate.initialize(new AcpSchema.InitializeRequest(1, caps)));
108133
}
109134

110135
/**
@@ -113,11 +138,9 @@ public AcpSchema.InitializeResponse initialize() {
113138
* @return The new session response
114139
*/
115140
public AcpSchema.NewSessionResponse newSession(String cwd) {
116-
AcpSchema.NewSessionResponse response = delegate.newSession(new AcpSchema.NewSessionRequest(cwd, List.of()))
117-
.block(timeout);
118-
if (response != null) {
119-
this.currentSessionId = response.sessionId();
120-
}
141+
AcpSchema.NewSessionResponse response = await(
142+
delegate.newSession(new AcpSchema.NewSessionRequest(cwd, List.of())));
143+
this.currentSessionId = response.sessionId();
121144
return response;
122145
}
123146

@@ -127,12 +150,7 @@ public AcpSchema.NewSessionResponse newSession(String cwd) {
127150
* @return The prompt response
128151
*/
129152
public AcpSchema.PromptResponse prompt(String text) {
130-
if (currentSessionId == null) {
131-
throw new IllegalStateException("No session created. Call newSession() first.");
132-
}
133-
return delegate
134-
.prompt(new AcpSchema.PromptRequest(currentSessionId, List.of(new AcpSchema.TextContent(text))))
135-
.block(timeout);
153+
return prompt(requireSessionId(), text);
136154
}
137155

138156
/**
@@ -142,18 +160,31 @@ public AcpSchema.PromptResponse prompt(String text) {
142160
* @return The prompt response
143161
*/
144162
public AcpSchema.PromptResponse prompt(String sessionId, String text) {
145-
return delegate.prompt(new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent(text))))
146-
.block(timeout);
163+
return await(delegate.prompt(new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent(text)))));
147164
}
148165

149166
/**
150167
* Cancels operations for the current session.
151168
*/
152169
public void cancel() {
153-
if (currentSessionId == null) {
170+
delegate.cancel(new AcpSchema.CancelNotification(requireSessionId())).block(timeout);
171+
}
172+
173+
private String requireSessionId() {
174+
String sessionId = this.currentSessionId;
175+
if (sessionId == null) {
154176
throw new IllegalStateException("No session created. Call newSession() first.");
155177
}
156-
delegate.cancel(new AcpSchema.CancelNotification(currentSessionId)).block(timeout);
178+
return sessionId;
179+
}
180+
181+
/** Blocks for a response; a request's Mono emits its response or fails, never completes empty. */
182+
private <T> T await(Mono<T> response) {
183+
T value = response.block(timeout);
184+
if (value == null) {
185+
throw new IllegalStateException("ACP request completed without a response");
186+
}
187+
return value;
157188
}
158189

159190
/**
@@ -210,7 +241,7 @@ public List<AcpSchema.WriteTextFileRequest> getReceivedFileWriteRequests() {
210241
* Gets the current session ID.
211242
* @return The session ID, or null if no session created
212243
*/
213-
public String getCurrentSessionId() {
244+
public @Nullable String getCurrentSessionId() {
214245
return currentSessionId;
215246
}
216247

@@ -325,42 +356,7 @@ public Builder requestTimeout(Duration timeout) {
325356
* @return The configured mock client
326357
*/
327358
public MockAcpClient build() {
328-
// We need to create the mock first to capture updates
329-
MockAcpClient mockClient = new MockAcpClient(null, transport, requestTimeout);
330-
331-
AcpAsyncClient delegate = AcpClient.async(transport)
332-
.requestTimeout(requestTimeout)
333-
.sessionUpdateConsumer(notification -> {
334-
mockClient.receivedUpdates.add(notification);
335-
mockClient.updateLatch.countDown();
336-
return Mono.empty();
337-
})
338-
// Using typed handlers (no manual unmarshalling needed)
339-
.requestPermissionHandler((AcpSchema.RequestPermissionRequest request) -> {
340-
mockClient.receivedPermissionRequests.add(request);
341-
return Mono.just(permissionHandler.apply(request));
342-
})
343-
.readTextFileHandler((AcpSchema.ReadTextFileRequest request) -> {
344-
mockClient.receivedFileReadRequests.add(request);
345-
return Mono.just(readFileHandler.apply(request));
346-
})
347-
.writeTextFileHandler((AcpSchema.WriteTextFileRequest request) -> {
348-
mockClient.receivedFileWriteRequests.add(request);
349-
return Mono.just(writeFileHandler.apply(request));
350-
})
351-
.build();
352-
353-
// Replace the delegate using reflection
354-
try {
355-
var field = MockAcpClient.class.getDeclaredField("delegate");
356-
field.setAccessible(true);
357-
field.set(mockClient, delegate);
358-
}
359-
catch (Exception e) {
360-
throw new RuntimeException("Failed to set delegate", e);
361-
}
362-
363-
return mockClient;
359+
return new MockAcpClient(this);
364360
}
365361

366362
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/*
2+
* Copyright 2025-2026 the original author or authors.
3+
*/
4+
5+
/**
6+
* Test support for ACP applications: an in-memory transport pair and mock agent and client.
7+
*
8+
* <p>
9+
* The package is null-marked: every type is non-null unless annotated
10+
* {@link org.jspecify.annotations.Nullable @Nullable}.
11+
* </p>
12+
*/
13+
@NullMarked
14+
package com.agentclientprotocol.sdk.test;
15+
16+
import org.jspecify.annotations.NullMarked;

0 commit comments

Comments
 (0)