55package com .agentclientprotocol .sdk .spec ;
66
77import java .time .Duration ;
8- import java .util .HashMap ;
98import java .util .List ;
109import java .util .Map ;
1110import java .util .concurrent .CountDownLatch ;
11+ import java .util .concurrent .CopyOnWriteArrayList ;
1212import java .util .concurrent .TimeUnit ;
13+ import java .util .concurrent .atomic .AtomicInteger ;
1314import java .util .concurrent .atomic .AtomicReference ;
1415
1516import com .agentclientprotocol .sdk .test .InMemoryTransportPair ;
@@ -159,16 +160,17 @@ void handlesNotification() throws Exception {
159160 }
160161
161162 @ Test
162- void singleTurnEnforcementRejectsConcurrentPrompts () throws Exception {
163+ void singleTurnEnforcementRejectsConcurrentPromptsForSameSession () throws Exception {
163164 var transportPair = InMemoryTransportPair .create ();
164165 try {
165- // Create a handler that uses a Mono.delay to simulate async processing
166- AtomicReference < CountDownLatch > promptCanProceedRef = new AtomicReference <>( new CountDownLatch ( 1 ) );
166+ CountDownLatch handlerStarted = new CountDownLatch ( 1 );
167+ AtomicInteger handlerInvocations = new AtomicInteger ( );
167168
168169 Map <String , AcpAgentSession .RequestHandler <?>> requestHandlers = Map .of (AcpSchema .METHOD_SESSION_PROMPT ,
169170 params -> Mono .defer (() -> {
170- // First call gets blocked, second call should be rejected before getting here
171- return Mono .delay (Duration .ofMillis (100 ))
171+ handlerInvocations .incrementAndGet ();
172+ handlerStarted .countDown ();
173+ return Mono .delay (Duration .ofMillis (250 ))
172174 .map (ignored -> new AcpSchema .PromptResponse (AcpSchema .StopReason .END_TURN ));
173175 }));
174176
@@ -177,52 +179,85 @@ void singleTurnEnforcementRejectsConcurrentPrompts() throws Exception {
177179
178180 Thread .sleep (100 );
179181
180- // Manually set active prompt to simulate an in-progress prompt
181- // We use reflection to access the activePrompt field for testing
182- java .lang .reflect .Field activePromptField = AcpAgentSession .class .getDeclaredField ("activePrompt" );
183- activePromptField .setAccessible (true );
184- @ SuppressWarnings ("unchecked" )
185- AtomicReference <Object > activePromptRef = (AtomicReference <Object >) activePromptField .get (session );
186-
187- // Create an ActivePrompt instance using reflection
188- Class <?> activePromptClass = Class .forName (
189- "com.agentclientprotocol.sdk.spec.AcpAgentSession$ActivePrompt" );
190- java .lang .reflect .Constructor <?> constructor = activePromptClass .getDeclaredConstructor (String .class ,
191- Object .class );
192- constructor .setAccessible (true );
193- Object activePrompt = constructor .newInstance ("session-1" , "existing-request-id" );
194- activePromptRef .set (activePrompt );
195-
196- // Verify active prompt is set
197- assertThat (session .hasActivePrompt ()).isTrue ();
182+ CountDownLatch responseLatch = new CountDownLatch (2 );
183+ List <AcpSchema .JSONRPCResponse > responses = new CopyOnWriteArrayList <>();
198184
199- // Set up client to receive response
200- CountDownLatch responseLatch = new CountDownLatch (1 );
201- AtomicReference <AcpSchema .JSONRPCResponse > response = new AtomicReference <>();
185+ transportPair .clientTransport ().connect (mono -> mono .doOnNext (msg -> {
186+ if (msg instanceof AcpSchema .JSONRPCResponse response ) {
187+ responses .add (response );
188+ }
189+ responseLatch .countDown ();
190+ }).then (Mono .empty ())).subscribe ();
191+
192+ Thread .sleep (50 );
193+
194+ transportPair .clientTransport ().sendMessage (promptRequest ("1" , "session-1" , "first" )).block (TIMEOUT );
195+ assertThat (handlerStarted .await (5 , TimeUnit .SECONDS )).isTrue ();
196+ assertThat (session .hasActivePrompt ("session-1" )).isTrue ();
197+
198+ transportPair .clientTransport ().sendMessage (promptRequest ("2" , "session-1" , "second" )).block (TIMEOUT );
199+
200+ assertThat (responseLatch .await (5 , TimeUnit .SECONDS )).isTrue ();
201+
202+ AcpSchema .JSONRPCResponse rejectedResponse = responseById (responses , "2" );
203+ assertThat (rejectedResponse .error ()).isNotNull ();
204+ assertThat (rejectedResponse .error ().code ()).isEqualTo (-32000 );
205+ assertThat (rejectedResponse .error ().message ()).contains ("already an active prompt" );
206+ assertThat (handlerInvocations .get ()).isEqualTo (1 );
207+ assertThat (session .hasActivePrompt ()).isFalse ();
208+ }
209+ finally {
210+ transportPair .closeGracefully ().block (TIMEOUT );
211+ }
212+ }
213+
214+ @ Test
215+ void singleTurnEnforcementAllowsConcurrentPromptsForDifferentSessions () throws Exception {
216+ var transportPair = InMemoryTransportPair .create ();
217+ try {
218+ CountDownLatch handlersStarted = new CountDownLatch (2 );
219+ AtomicInteger handlerInvocations = new AtomicInteger ();
220+
221+ Map <String , AcpAgentSession .RequestHandler <?>> requestHandlers = Map .of (AcpSchema .METHOD_SESSION_PROMPT ,
222+ params -> Mono .defer (() -> {
223+ handlerInvocations .incrementAndGet ();
224+ handlersStarted .countDown ();
225+ return Mono .delay (Duration .ofMillis (250 ))
226+ .map (ignored -> new AcpSchema .PromptResponse (AcpSchema .StopReason .END_TURN ));
227+ }));
228+
229+ AcpAgentSession session = new AcpAgentSession (TIMEOUT , transportPair .agentTransport (), requestHandlers ,
230+ Map .of ());
231+
232+ Thread .sleep (100 );
233+
234+ CountDownLatch responseLatch = new CountDownLatch (2 );
235+ List <AcpSchema .JSONRPCResponse > responses = new CopyOnWriteArrayList <>();
202236
203237 transportPair .clientTransport ().connect (mono -> mono .doOnNext (msg -> {
204- response .set ((AcpSchema .JSONRPCResponse ) msg );
238+ if (msg instanceof AcpSchema .JSONRPCResponse response ) {
239+ responses .add (response );
240+ }
205241 responseLatch .countDown ();
206242 }).then (Mono .empty ())).subscribe ();
207243
208244 Thread .sleep (50 );
209245
210- // Send prompt request while another is "active"
211- Map < String , Object > params = new HashMap <>( );
212- params . put ( "sessionId" , "session-1" );
213- params . put ( "prompt" , List . of ( new AcpSchema . TextContent ( "Hello" )) );
214- AcpSchema . JSONRPCRequest request = new AcpSchema . JSONRPCRequest ( AcpSchema . JSONRPC_VERSION , "1" ,
215- AcpSchema . METHOD_SESSION_PROMPT , params );
216- transportPair . clientTransport (). sendMessage ( request ). block ( TIMEOUT );
246+ transportPair . clientTransport (). sendMessage ( promptRequest ( "1" , "session-1" , "first" )). block ( TIMEOUT );
247+ transportPair . clientTransport (). sendMessage ( promptRequest ( "2" , "session-2" , "second" )). block ( TIMEOUT );
248+
249+ assertThat ( handlersStarted . await ( 5 , TimeUnit . SECONDS )). isTrue ( );
250+ assertThat ( session . hasActivePrompt ( "session-1" )). isTrue ();
251+ assertThat ( session . hasActivePrompt ( "session-2" )). isTrue ( );
252+ assertThat ( session . getActivePromptSessionIds ()). containsExactlyInAnyOrder ( "session-1" , "session-2" );
217253
218- // Wait for response
219254 assertThat (responseLatch .await (5 , TimeUnit .SECONDS )).isTrue ();
220255
221- // Should be rejected with error
222- assertThat (response . get ()).isNotNull ();
223- assertThat (response .get (). error ()). isNotNull ( );
224- assertThat (response . get (). error (). code ()). isEqualTo (- 32000 );
225- assertThat (response . get (). error (). message ()). contains ( "already an active prompt" );
256+ assertThat ( responseById ( responses , "1" ). error ()). isNull ();
257+ assertThat (responseById ( responses , "2" ). error ()).isNull ();
258+ assertThat (handlerInvocations .get ()). isEqualTo ( 2 );
259+ assertThat (session . hasActivePrompt ()). isFalse ( );
260+ assertThat (session . getActivePromptSessionIds ()). isEmpty ( );
226261 }
227262 finally {
228263 transportPair .closeGracefully ().block (TIMEOUT );
@@ -233,42 +268,43 @@ void singleTurnEnforcementRejectsConcurrentPrompts() throws Exception {
233268 void hasActivePromptReturnsCorrectState () throws Exception {
234269 var transportPair = InMemoryTransportPair .create ();
235270 try {
271+ CountDownLatch handlerStarted = new CountDownLatch (1 );
272+
236273 Map <String , AcpAgentSession .RequestHandler <?>> requestHandlers = Map .of (AcpSchema .METHOD_SESSION_PROMPT ,
237- params -> Mono .just (new AcpSchema .PromptResponse (AcpSchema .StopReason .END_TURN )));
274+ params -> Mono .defer (() -> {
275+ handlerStarted .countDown ();
276+ return Mono .delay (Duration .ofMillis (250 ))
277+ .map (ignored -> new AcpSchema .PromptResponse (AcpSchema .StopReason .END_TURN ));
278+ }));
238279
239280 AcpAgentSession session = new AcpAgentSession (TIMEOUT , transportPair .agentTransport (), requestHandlers ,
240281 Map .of ());
241282
242283 Thread .sleep (100 );
243284
244- // Initially no active prompt
245285 assertThat (session .hasActivePrompt ()).isFalse ();
286+ assertThat (session .hasActivePrompt ("session-1" )).isFalse ();
246287 assertThat (session .getActivePromptSessionId ()).isNull ();
288+ assertThat (session .getActivePromptSessionIds ()).isEmpty ();
289+
290+ CountDownLatch responseLatch = new CountDownLatch (1 );
291+ transportPair .clientTransport ().connect (mono -> mono .doOnNext (msg -> responseLatch .countDown ())
292+ .then (Mono .empty ())).subscribe ();
293+
294+ Thread .sleep (50 );
295+ transportPair .clientTransport ().sendMessage (promptRequest ("1" , "session-1" , "hello" )).block (TIMEOUT );
247296
248- // Manually set active prompt using reflection to test the getter methods
249- java .lang .reflect .Field activePromptField = AcpAgentSession .class .getDeclaredField ("activePrompt" );
250- activePromptField .setAccessible (true );
251- @ SuppressWarnings ("unchecked" )
252- AtomicReference <Object > activePromptRef = (AtomicReference <Object >) activePromptField .get (session );
253-
254- // Create an ActivePrompt instance using reflection
255- Class <?> activePromptClass = Class .forName (
256- "com.agentclientprotocol.sdk.spec.AcpAgentSession$ActivePrompt" );
257- java .lang .reflect .Constructor <?> constructor = activePromptClass .getDeclaredConstructor (String .class ,
258- Object .class );
259- constructor .setAccessible (true );
260- Object activePrompt = constructor .newInstance ("session-1" , "request-1" );
261- activePromptRef .set (activePrompt );
262-
263- // Now there should be an active prompt
297+ assertThat (handlerStarted .await (5 , TimeUnit .SECONDS )).isTrue ();
264298 assertThat (session .hasActivePrompt ()).isTrue ();
299+ assertThat (session .hasActivePrompt ("session-1" )).isTrue ();
300+ assertThat (session .getActivePromptSessionIds ()).containsExactly ("session-1" );
265301 assertThat (session .getActivePromptSessionId ()).isEqualTo ("session-1" );
266302
267- // Clear active prompt
268- activePromptRef .set (null );
303+ assertThat (responseLatch .await (5 , TimeUnit .SECONDS )).isTrue ();
269304
270- // Active prompt should be cleared
271305 assertThat (session .hasActivePrompt ()).isFalse ();
306+ assertThat (session .hasActivePrompt ("session-1" )).isFalse ();
307+ assertThat (session .getActivePromptSessionIds ()).isEmpty ();
272308 assertThat (session .getActivePromptSessionId ()).isNull ();
273309 }
274310 finally {
@@ -327,4 +363,13 @@ void handlerErrorReturnsJsonRpcError() throws Exception {
327363 }
328364 }
329365
366+ private static AcpSchema .JSONRPCRequest promptRequest (String id , String sessionId , String text ) {
367+ return new AcpSchema .JSONRPCRequest (AcpSchema .JSONRPC_VERSION , id , AcpSchema .METHOD_SESSION_PROMPT ,
368+ new AcpSchema .PromptRequest (sessionId , List .of (new AcpSchema .TextContent (text ))));
369+ }
370+
371+ private static AcpSchema .JSONRPCResponse responseById (List <AcpSchema .JSONRPCResponse > responses , Object id ) {
372+ return responses .stream ().filter (response -> id .equals (response .id ())).findFirst ().orElseThrow ();
373+ }
374+
330375}
0 commit comments