/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ package com.github.copilot.sdk; import static org.junit.jupiter.api.Assertions.*; import java.io.Closeable; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; import java.util.logging.Logger; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import com.github.copilot.sdk.events.AbstractSessionEvent; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.events.SessionIdleEvent; import com.github.copilot.sdk.events.SessionStartEvent; /** * Unit tests for session event handling API. *

* These are pure unit tests that don't require the Copilot CLI. They test the * event dispatch mechanism directly. */ public class SessionEventHandlingTest { private CopilotSession session; @BeforeEach void setup() throws Exception { // Create a minimal session for testing event handling // We use reflection to create a session without a real RPC connection session = createTestSession(); } private CopilotSession createTestSession() throws Exception { // Use the package-private constructor via reflection for testing var constructor = CopilotSession.class.getDeclaredConstructor(String.class, JsonRpcClient.class, String.class); constructor.setAccessible(true); return constructor.newInstance("test-session-id", null, null); } @Test void testGenericEventHandler() { var receivedEvents = new ArrayList(); session.on(event -> receivedEvents.add(event)); // Dispatch some events dispatchEvent(createSessionStartEvent()); dispatchEvent(createAssistantMessageEvent("Hello")); dispatchEvent(createSessionIdleEvent()); assertEquals(3, receivedEvents.size()); assertInstanceOf(SessionStartEvent.class, receivedEvents.get(0)); assertInstanceOf(AssistantMessageEvent.class, receivedEvents.get(1)); assertInstanceOf(SessionIdleEvent.class, receivedEvents.get(2)); } @Test void testTypedEventHandler() { var receivedMessages = new ArrayList(); session.on(AssistantMessageEvent.class, msg -> receivedMessages.add(msg)); // Dispatch various events - only AssistantMessageEvent should be captured dispatchEvent(createSessionStartEvent()); dispatchEvent(createAssistantMessageEvent("First message")); dispatchEvent(createSessionIdleEvent()); dispatchEvent(createAssistantMessageEvent("Second message")); // Should only have the two assistant messages assertEquals(2, receivedMessages.size()); assertEquals("First message", receivedMessages.get(0).getData().getContent()); assertEquals("Second message", receivedMessages.get(1).getData().getContent()); } @Test void testMultipleTypedHandlers() { var messages = new ArrayList(); var idles = new ArrayList(); var starts = new ArrayList(); session.on(AssistantMessageEvent.class, messages::add); session.on(SessionIdleEvent.class, idles::add); session.on(SessionStartEvent.class, starts::add); dispatchEvent(createSessionStartEvent()); dispatchEvent(createAssistantMessageEvent("Hello")); dispatchEvent(createSessionIdleEvent()); dispatchEvent(createAssistantMessageEvent("World")); assertEquals(1, starts.size()); assertEquals(2, messages.size()); assertEquals(1, idles.size()); } @Test void testUnsubscribe() { var count = new AtomicInteger(0); Closeable subscription = session.on(AssistantMessageEvent.class, msg -> count.incrementAndGet()); dispatchEvent(createAssistantMessageEvent("First")); assertEquals(1, count.get()); // Unsubscribe try { subscription.close(); } catch (Exception e) { fail("Unsubscribe should not throw: " + e.getMessage()); } // Should no longer receive events dispatchEvent(createAssistantMessageEvent("Second")); assertEquals(1, count.get()); // Still 1, not 2 } @Test void testUnsubscribeGenericHandler() { var count = new AtomicInteger(0); Closeable subscription = session.on(event -> count.incrementAndGet()); dispatchEvent(createSessionStartEvent()); assertEquals(1, count.get()); try { subscription.close(); } catch (Exception e) { fail("Unsubscribe should not throw: " + e.getMessage()); } dispatchEvent(createSessionIdleEvent()); assertEquals(1, count.get()); // Still 1 } @Test void testMixedHandlers() { var allEvents = new ArrayList(); var messageEvents = new ArrayList(); // Generic handler captures everything session.on(event -> allEvents.add(event.getType())); // Typed handler captures only messages session.on(AssistantMessageEvent.class, msg -> messageEvents.add(msg.getData().getContent())); dispatchEvent(createSessionStartEvent()); dispatchEvent(createAssistantMessageEvent("Hello")); dispatchEvent(createSessionIdleEvent()); assertEquals(3, allEvents.size()); assertEquals(1, messageEvents.size()); assertEquals("Hello", messageEvents.get(0)); } @Test void testHandlerReceivesCorrectEventData() { var capturedContent = new AtomicReference(); var capturedSessionId = new AtomicReference(); session.on(AssistantMessageEvent.class, msg -> { capturedContent.set(msg.getData().getContent()); }); session.on(SessionStartEvent.class, start -> { capturedSessionId.set(start.getData().getSessionId()); }); SessionStartEvent startEvent = createSessionStartEvent(); startEvent.getData().setSessionId("my-session-123"); dispatchEvent(startEvent); AssistantMessageEvent msgEvent = createAssistantMessageEvent("Test content"); dispatchEvent(msgEvent); assertEquals("my-session-123", capturedSessionId.get()); assertEquals("Test content", capturedContent.get()); } @Test void testHandlerExceptionDoesNotBreakOtherHandlers() { var handler2Events = new ArrayList(); // Suppress logging for this test to avoid confusing stack traces in build // output Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); Level originalLevel = sessionLogger.getLevel(); sessionLogger.setLevel(Level.OFF); try { // First handler throws an exception session.on(AssistantMessageEvent.class, msg -> { throw new RuntimeException("Handler 1 error"); }); // Second handler should still receive events session.on(AssistantMessageEvent.class, msg -> { handler2Events.add(msg.getData().getContent()); }); // This should not throw - exceptions are caught assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); // Second handler should have received the event assertEquals(1, handler2Events.size()); assertEquals("Test", handler2Events.get(0)); } finally { sessionLogger.setLevel(originalLevel); } } @Test void testNoHandlersDoesNotThrow() { // Dispatching events with no handlers should not throw assertDoesNotThrow(() -> { dispatchEvent(createSessionStartEvent()); dispatchEvent(createAssistantMessageEvent("Test")); dispatchEvent(createSessionIdleEvent()); }); } @Test void testDuplicateTypedHandlersBothReceiveEvent() { var count1 = new AtomicInteger(); var count2 = new AtomicInteger(); session.on(AssistantMessageEvent.class, msg -> count1.incrementAndGet()); session.on(AssistantMessageEvent.class, msg -> count2.incrementAndGet()); dispatchEvent(createAssistantMessageEvent("hello")); assertEquals(1, count1.get(), "First typed handler should be called"); assertEquals(1, count2.get(), "Second typed handler should be called"); } @Test void testDuplicateGenericHandlersBothFire() { var events1 = new ArrayList(); var events2 = new ArrayList(); session.on(event -> events1.add(event.getType())); session.on(event -> events2.add(event.getType())); dispatchEvent(createAssistantMessageEvent("test")); assertEquals(1, events1.size(), "First generic handler should receive event"); assertEquals(1, events2.size(), "Second generic handler should receive event"); } @Test void testUnsubscribeOneKeepsOther() { var count1 = new AtomicInteger(); var count2 = new AtomicInteger(); var sub1 = session.on(AssistantMessageEvent.class, msg -> count1.incrementAndGet()); session.on(AssistantMessageEvent.class, msg -> count2.incrementAndGet()); dispatchEvent(createAssistantMessageEvent("before")); assertEquals(1, count1.get()); assertEquals(1, count2.get()); // Unsubscribe first handler try { sub1.close(); } catch (Exception e) { fail("Unsubscribe should not throw: " + e.getMessage()); } dispatchEvent(createAssistantMessageEvent("after")); assertEquals(1, count1.get(), "Unsubscribed handler should not be called again"); assertEquals(2, count2.get(), "Remaining handler should still be called"); } @Test void testAllHandlersInvoked() { var called = new ArrayList(); session.on(AssistantMessageEvent.class, msg -> called.add("first")); session.on(AssistantMessageEvent.class, msg -> called.add("second")); session.on(AssistantMessageEvent.class, msg -> called.add("third")); dispatchEvent(createAssistantMessageEvent("test")); assertEquals(3, called.size(), "All three handlers should be invoked"); assertTrue(called.containsAll(List.of("first", "second", "third")), "All handler labels should be present"); } @Test void testHandlersRunOnDispatchThread() throws Exception { var handlerThreadName = new AtomicReference(); var latch = new CountDownLatch(1); session.on(AssistantMessageEvent.class, msg -> { handlerThreadName.set(Thread.currentThread().getName()); latch.countDown(); }); // Dispatch from a named thread to simulate the jsonrpc-reader var t = new Thread(() -> dispatchEvent(createAssistantMessageEvent("async")), "jsonrpc-reader-mock"); t.start(); assertTrue(latch.await(5, TimeUnit.SECONDS), "Handler should be invoked within timeout"); t.join(5000); assertEquals("jsonrpc-reader-mock", handlerThreadName.get(), "Handler should run on the dispatch thread, not a different one"); } @Test void testHandlersRunOffMainThread() throws Exception { var mainThreadName = Thread.currentThread().getName(); var handlerThreadName = new AtomicReference(); var latch = new CountDownLatch(1); session.on(AssistantMessageEvent.class, msg -> { handlerThreadName.set(Thread.currentThread().getName()); latch.countDown(); }); // Dispatch from a background thread (simulates jsonrpc-reader) new Thread(() -> dispatchEvent(createAssistantMessageEvent("bg")), "background-dispatcher").start(); assertTrue(latch.await(5, TimeUnit.SECONDS), "Handler should be invoked within timeout"); assertNotEquals(mainThreadName, handlerThreadName.get(), "Handler should NOT run on the main/test thread"); assertEquals("background-dispatcher", handlerThreadName.get(), "Handler should run on the background dispatch thread"); } @Test void testConcurrentDispatchFromMultipleThreads() throws Exception { var totalEvents = 100; var receivedCount = new AtomicInteger(); var threadNames = ConcurrentHashMap.newKeySet(); var latch = new CountDownLatch(totalEvents); session.on(AssistantMessageEvent.class, msg -> { receivedCount.incrementAndGet(); threadNames.add(Thread.currentThread().getName()); latch.countDown(); }); // Fire events from 10 concurrent threads, 10 events each var threads = new ArrayList(); for (int i = 0; i < 10; i++) { var threadIdx = i; var t = new Thread(() -> { for (int j = 0; j < 10; j++) { dispatchEvent(createAssistantMessageEvent("msg-" + threadIdx + "-" + j)); } }, "dispatcher-" + i); threads.add(t); } for (var t : threads) { t.start(); } assertTrue(latch.await(10, TimeUnit.SECONDS), "All events should be delivered within timeout"); for (var t : threads) { t.join(5000); } assertEquals(totalEvents, receivedCount.get(), "All " + totalEvents + " events should be delivered"); assertTrue(threadNames.size() > 1, "Events should have been dispatched from multiple threads"); } // Helper methods to dispatch events using reflection // ==================================================================== // EventErrorHandler tests // ==================================================================== @Test void testDefaultNoErrorHandlerSilentlyContinues() { // Without a custom error handler, exceptions should be silently consumed var received = new ArrayList(); session.on(AssistantMessageEvent.class, msg -> { throw new RuntimeException("boom"); }); session.on(AssistantMessageEvent.class, msg -> { received.add(msg.getData().getContent()); }); assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); // Second handler should still execute (default CONTINUE policy) assertEquals(1, received.size()); } @Test void testCustomEventErrorHandlerReceivesEventAndException() { var capturedEvents = new ArrayList(); var capturedExceptions = new ArrayList(); session.setEventErrorHandler((event, exception) -> { capturedEvents.add(event); capturedExceptions.add(exception); }); var thrownException = new RuntimeException("test error"); session.on(AssistantMessageEvent.class, msg -> { throw thrownException; }); var event = createAssistantMessageEvent("Hello"); dispatchEvent(event); assertEquals(1, capturedEvents.size()); assertSame(event, capturedEvents.get(0)); assertEquals(1, capturedExceptions.size()); assertSame(thrownException, capturedExceptions.get(0)); } @Test void testCustomErrorHandlerCalledForAllErrors() { var errorCount = new AtomicInteger(0); session.setEventErrorHandler((event, exception) -> { errorCount.incrementAndGet(); }); session.on(AssistantMessageEvent.class, msg -> { throw new RuntimeException("error 1"); }); session.on(AssistantMessageEvent.class, msg -> { throw new RuntimeException("error 2"); }); dispatchEvent(createAssistantMessageEvent("Test")); // Both handler errors should be reported to the custom error handler assertEquals(2, errorCount.get()); } @Test void testErrorHandlerItselfThrowingStopsDispatch() { var handler1Called = new AtomicInteger(0); var handler2Called = new AtomicInteger(0); Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); Level originalLevel = sessionLogger.getLevel(); sessionLogger.setLevel(Level.OFF); try { session.setEventErrorHandler((event, exception) -> { throw new RuntimeException("error handler also broke"); }); // Two handlers that throw session.on(AssistantMessageEvent.class, msg -> { handler1Called.incrementAndGet(); throw new RuntimeException("handler error"); }); session.on(AssistantMessageEvent.class, msg -> { handler2Called.incrementAndGet(); throw new RuntimeException("handler error"); }); assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); // Error handler threw — dispatch stops regardless of policy int totalCalls = handler1Called.get() + handler2Called.get(); assertEquals(1, totalCalls, "Only one handler should have been called (dispatch stopped when error handler threw)"); } finally { sessionLogger.setLevel(originalLevel); } } @Test void testSetEventErrorHandlerToNullRestoresDefaultBehavior() { var errorCount = new AtomicInteger(0); // Set custom handler session.setEventErrorHandler((event, exception) -> { errorCount.incrementAndGet(); }); session.on(AssistantMessageEvent.class, msg -> { throw new RuntimeException("error"); }); dispatchEvent(createAssistantMessageEvent("Test1")); assertEquals(1, errorCount.get()); // Reset to null (restore default silent behavior) session.setEventErrorHandler(null); dispatchEvent(createAssistantMessageEvent("Test2")); // Custom handler should NOT have been called again assertEquals(1, errorCount.get()); } @Test void testErrorHandlerReceivesCorrectEventType() { var capturedEvents = new ArrayList(); session.setEventErrorHandler((event, exception) -> { capturedEvents.add(event); }); session.on(event -> { throw new RuntimeException("always fails"); }); var msgEvent = createAssistantMessageEvent("msg"); var idleEvent = createSessionIdleEvent(); dispatchEvent(msgEvent); dispatchEvent(idleEvent); assertEquals(2, capturedEvents.size()); assertInstanceOf(AssistantMessageEvent.class, capturedEvents.get(0)); assertInstanceOf(SessionIdleEvent.class, capturedEvents.get(1)); } // ==================================================================== // EventErrorPolicy tests // ==================================================================== @Test void testDefaultPolicyContinuesOnError() { var handler1Called = new AtomicInteger(0); var handler2Called = new AtomicInteger(0); var handler3Called = new AtomicInteger(0); session.setEventErrorHandler((event, exception) -> { // just consume }); session.on(AssistantMessageEvent.class, msg -> { handler1Called.incrementAndGet(); throw new RuntimeException("error"); }); session.on(AssistantMessageEvent.class, msg -> { handler2Called.incrementAndGet(); }); session.on(AssistantMessageEvent.class, msg -> { handler3Called.incrementAndGet(); }); dispatchEvent(createAssistantMessageEvent("Test")); // All handlers should have been called (SUPPRESS is default) assertEquals(1, handler1Called.get()); assertEquals(1, handler2Called.get()); assertEquals(1, handler3Called.get()); } @Test void testPropagatePolicyStopsOnFirstError() { var handler1Called = new AtomicInteger(0); var handler2Called = new AtomicInteger(0); var errorHandlerCalls = new AtomicInteger(0); session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE); session.setEventErrorHandler((event, exception) -> { errorHandlerCalls.incrementAndGet(); }); // Two handlers that throw session.on(AssistantMessageEvent.class, msg -> { handler1Called.incrementAndGet(); throw new RuntimeException("error 1"); }); session.on(AssistantMessageEvent.class, msg -> { handler2Called.incrementAndGet(); throw new RuntimeException("error 2"); }); dispatchEvent(createAssistantMessageEvent("Test")); // Only one handler should have been called (PROPAGATE policy) assertEquals(1, errorHandlerCalls.get()); int totalCalls = handler1Called.get() + handler2Called.get(); assertEquals(1, totalCalls, "Only one handler should execute with PROPAGATE policy"); } @Test void testPropagatePolicyErrorHandlerAlwaysInvoked() { var errorHandlerCalls = new AtomicInteger(0); session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE); session.setEventErrorHandler((event, exception) -> { errorHandlerCalls.incrementAndGet(); }); session.on(AssistantMessageEvent.class, msg -> { throw new RuntimeException("error"); }); dispatchEvent(createAssistantMessageEvent("Test")); // Error handler should be called even with PROPAGATE policy assertEquals(1, errorHandlerCalls.get()); } @Test void testSuppressPolicyWithMultipleErrors() { var errorHandlerCalls = new AtomicInteger(0); var successfulHandlerCalls = new AtomicInteger(0); session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS); session.setEventErrorHandler((event, exception) -> { errorHandlerCalls.incrementAndGet(); }); session.on(AssistantMessageEvent.class, msg -> { throw new RuntimeException("error 1"); }); session.on(AssistantMessageEvent.class, msg -> { throw new RuntimeException("error 2"); }); session.on(AssistantMessageEvent.class, msg -> { successfulHandlerCalls.incrementAndGet(); }); session.on(AssistantMessageEvent.class, msg -> { throw new RuntimeException("error 3"); }); dispatchEvent(createAssistantMessageEvent("Test")); // All errors should be reported, successful handler should run assertEquals(3, errorHandlerCalls.get()); assertEquals(1, successfulHandlerCalls.get()); } @Test void testSwitchPolicyDynamically() { var handler1Called = new AtomicInteger(0); var handler2Called = new AtomicInteger(0); session.setEventErrorHandler((event, exception) -> { // just consume }); // Two handlers that throw session.on(AssistantMessageEvent.class, msg -> { handler1Called.incrementAndGet(); throw new RuntimeException("error"); }); session.on(AssistantMessageEvent.class, msg -> { handler2Called.incrementAndGet(); throw new RuntimeException("error"); }); // With SUPPRESS, both should fire session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS); dispatchEvent(createAssistantMessageEvent("Test1")); assertEquals(1, handler1Called.get()); assertEquals(1, handler2Called.get()); handler1Called.set(0); handler2Called.set(0); // Switch to PROPAGATE — only one should fire session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE); dispatchEvent(createAssistantMessageEvent("Test2")); int totalCalls = handler1Called.get() + handler2Called.get(); assertEquals(1, totalCalls, "Only one handler should execute after switching to PROPAGATE"); } @Test void testPropagatePolicyNoErrorHandlerSilentlyStops() { var handler1Called = new AtomicInteger(0); var handler2Called = new AtomicInteger(0); // No error handler set, PROPAGATE policy session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE); session.on(AssistantMessageEvent.class, msg -> { handler1Called.incrementAndGet(); throw new RuntimeException("error"); }); session.on(AssistantMessageEvent.class, msg -> { handler2Called.incrementAndGet(); throw new RuntimeException("error"); }); assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); // PROPAGATE policy should stop after first error, even without error handler int totalCalls = handler1Called.get() + handler2Called.get(); assertEquals(1, totalCalls, "Only one handler should execute with PROPAGATE policy and no error handler"); } @Test void testErrorHandlerThrowingStopsRegardlessOfPolicy() { var handler1Called = new AtomicInteger(0); var handler2Called = new AtomicInteger(0); Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); Level originalLevel = sessionLogger.getLevel(); sessionLogger.setLevel(Level.OFF); try { // SUPPRESS policy, but error handler throws session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS); session.setEventErrorHandler((event, exception) -> { throw new RuntimeException("error handler broke"); }); session.on(AssistantMessageEvent.class, msg -> { handler1Called.incrementAndGet(); throw new RuntimeException("error"); }); session.on(AssistantMessageEvent.class, msg -> { handler2Called.incrementAndGet(); throw new RuntimeException("error"); }); assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); // Error handler threw — should stop regardless of SUPPRESS policy int totalCalls = handler1Called.get() + handler2Called.get(); assertEquals(1, totalCalls, "Only one handler should execute when error handler throws, even with SUPPRESS policy"); } finally { sessionLogger.setLevel(originalLevel); } } // ==================================================================== // Helper methods // ==================================================================== private void dispatchEvent(AbstractSessionEvent event) { try { Method dispatchMethod = CopilotSession.class.getDeclaredMethod("dispatchEvent", AbstractSessionEvent.class); dispatchMethod.setAccessible(true); dispatchMethod.invoke(session, event); } catch (Exception e) { throw new RuntimeException("Failed to dispatch event", e); } } // Factory methods for creating test events private SessionStartEvent createSessionStartEvent() { var event = new SessionStartEvent(); var data = new SessionStartEvent.SessionStartData(); data.setSessionId("test-session"); event.setData(data); return event; } private AssistantMessageEvent createAssistantMessageEvent(String content) { var event = new AssistantMessageEvent(); var data = new AssistantMessageEvent.AssistantMessageData(); data.setContent(content); event.setData(data); return event; } private SessionIdleEvent createSessionIdleEvent() { return new SessionIdleEvent(); } }