/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ package com.github.copilot.sdk; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable; import com.github.copilot.sdk.events.AbstractSessionEvent; import com.github.copilot.sdk.events.AbortEvent; import com.github.copilot.sdk.events.AssistantMessageDeltaEvent; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.events.SessionIdleEvent; import com.github.copilot.sdk.events.SessionStartEvent; import com.github.copilot.sdk.events.ToolExecutionStartEvent; import com.github.copilot.sdk.events.UserMessageEvent; import com.github.copilot.sdk.json.MessageOptions; import com.github.copilot.sdk.json.SessionConfig; import com.github.copilot.sdk.json.SystemMessageConfig; /** * Tests for CopilotSession. * *

* These tests use the shared CapiProxy infrastructure for deterministic API * response replay. Snapshots are stored in test/snapshots/session/. *

*/ public class CopilotSessionTest { private static E2ETestContext ctx; @BeforeAll static void setup() throws Exception { ctx = E2ETestContext.create(); } @AfterAll static void teardown() throws Exception { if (ctx != null) { ctx.close(); } } @Test void testCreateAndDestroySession() throws Exception { ctx.configureForTest("session", "should_receive_session_events"); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession(new SessionConfig().setModel("fake-test-model")).get(); assertNotNull(session.getSessionId()); assertTrue(session.getSessionId().matches("^[a-f0-9-]+$")); List messages = session.getMessages().get(); assertFalse(messages.isEmpty()); assertTrue(messages.get(0) instanceof SessionStartEvent); session.close(); // Session should no longer be accessible try { session.getMessages().get(); fail("Expected exception for closed session"); } catch (Exception e) { assertTrue(e.getMessage().toLowerCase().contains("not found") || e.getCause().getMessage().toLowerCase().contains("not found")); } } } @Test void testStatefulConversation() throws Exception { ctx.configureForTest("session", "should_have_stateful_conversation"); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession().get(); AssistantMessageEvent response1 = session.sendAndWait(new MessageOptions().setPrompt("What is 1+1?"), 60000) .get(90, TimeUnit.SECONDS); assertNotNull(response1); assertTrue(response1.getData().getContent().contains("2"), "Response should contain 2: " + response1.getData().getContent()); AssistantMessageEvent response2 = session .sendAndWait(new MessageOptions().setPrompt("Now if you double that, what do you get?"), 60000) .get(90, TimeUnit.SECONDS); assertNotNull(response2); assertTrue(response2.getData().getContent().contains("4"), "Response should contain 4: " + response2.getData().getContent()); session.close(); } } @Test void testReceiveSessionEvents() throws Exception { ctx.configureForTest("session", "should_receive_session_events"); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession().get(); List receivedEvents = new ArrayList<>(); CompletableFuture idleReceived = new CompletableFuture<>(); session.on(evt -> { receivedEvents.add(evt); if (evt instanceof SessionIdleEvent) { idleReceived.complete(null); } }); session.send(new MessageOptions().setPrompt("What is 100+200?")).get(); idleReceived.get(60, TimeUnit.SECONDS); assertFalse(receivedEvents.isEmpty()); assertTrue(receivedEvents.stream().anyMatch(e -> e instanceof UserMessageEvent)); assertTrue(receivedEvents.stream().anyMatch(e -> e instanceof AssistantMessageEvent)); assertTrue(receivedEvents.stream().anyMatch(e -> e instanceof SessionIdleEvent)); // Find the assistant message AssistantMessageEvent assistantMsg = receivedEvents.stream().filter(e -> e instanceof AssistantMessageEvent) .map(e -> (AssistantMessageEvent) e).findFirst().orElse(null); assertNotNull(assistantMsg); assertTrue(assistantMsg.getData().getContent().contains("300"), "Response should contain 300: " + assistantMsg.getData().getContent()); session.close(); } } @Test void testSendReturnsImmediately() throws Exception { ctx.configureForTest("session", "send_returns_immediately_while_events_stream_in_background"); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession().get(); List events = new ArrayList<>(); AtomicReference lastMessage = new AtomicReference<>(); CompletableFuture done = new CompletableFuture<>(); session.on(evt -> { events.add(evt.getType()); if (evt instanceof AssistantMessageEvent msg) { lastMessage.set(msg); } else if (evt instanceof SessionIdleEvent) { done.complete(null); } }); // Use a slow command so we can verify send() returns before completion session.send(new MessageOptions().setPrompt("Run 'sleep 2 && echo done'")).get(); // At this point, we might not have received session.idle yet // The event handling happens asynchronously // Wait for completion done.get(60, TimeUnit.SECONDS); assertTrue(events.contains("session.idle")); assertTrue(events.contains("assistant.message")); assertNotNull(lastMessage.get()); assertTrue(lastMessage.get().getData().getContent().contains("done"), "Response should contain done: " + lastMessage.get().getData().getContent()); session.close(); } } @Test void testSendAndWaitBlocksUntilIdle() throws Exception { ctx.configureForTest("session", "sendandwait_blocks_until_session_idle_and_returns_final_assistant_message"); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession().get(); List events = new ArrayList<>(); session.on(evt -> events.add(evt.getType())); AssistantMessageEvent response = session.sendAndWait(new MessageOptions().setPrompt("What is 2+2?")).get(60, TimeUnit.SECONDS); assertNotNull(response); assertEquals("assistant.message", response.getType()); assertTrue(response.getData().getContent().contains("4"), "Response should contain 4: " + response.getData().getContent()); assertTrue(events.contains("session.idle")); assertTrue(events.contains("assistant.message")); session.close(); } } @Test void testResumeSessionWithSameClient() throws Exception { ctx.configureForTest("session", "should_resume_a_session_using_the_same_client"); try (CopilotClient client = ctx.createClient()) { // Create initial session CopilotSession session1 = client.createSession().get(); String sessionId = session1.getSessionId(); AssistantMessageEvent answer = session1.sendAndWait(new MessageOptions().setPrompt("What is 1+1?")).get(60, TimeUnit.SECONDS); assertNotNull(answer); assertTrue(answer.getData().getContent().contains("2"), "Response should contain 2: " + answer.getData().getContent()); // Resume using the same client CopilotSession session2 = client.resumeSession(sessionId).get(); assertEquals(sessionId, session2.getSessionId()); // Verify resumed session has the previous messages List messages = session2.getMessages().get(60, TimeUnit.SECONDS); boolean hasAssistantMessage = messages.stream().filter(m -> m instanceof AssistantMessageEvent) .map(m -> (AssistantMessageEvent) m).anyMatch(m -> m.getData().getContent().contains("2")); assertTrue(hasAssistantMessage, "Should find previous assistant message containing 2"); session2.close(); } } @Test void testResumeSessionWithNewClient() throws Exception { ctx.configureForTest("session", "should_resume_a_session_using_a_new_client"); // Use a single try-with-resources for the first client to keep it alive // throughout the test, matching the behavior of other SDK implementations try (CopilotClient client1 = ctx.createClient()) { // Create initial session CopilotSession session1 = client1.createSession().get(); String sessionId = session1.getSessionId(); AssistantMessageEvent answer = session1.sendAndWait(new MessageOptions().setPrompt("What is 1+1?")).get(60, TimeUnit.SECONDS); assertNotNull(answer); assertTrue(answer.getData().getContent().contains("2"), "Response should contain 2: " + answer.getData().getContent()); // Resume using a new client (keeping client1 alive) try (CopilotClient client2 = ctx.createClient()) { CopilotSession session2 = client2.resumeSession(sessionId).get(); assertEquals(sessionId, session2.getSessionId()); // When resuming with a new client, validate messages contain expected types List messages = session2.getMessages().get(60, TimeUnit.SECONDS); assertTrue(messages.stream().anyMatch(m -> m instanceof UserMessageEvent), "Should contain user.message event"); assertTrue(messages.stream().anyMatch(m -> "session.resume".equals(m.getType())), "Should contain session.resume event"); session2.close(); } } } @Test void testSessionWithAppendedSystemMessage() throws Exception { ctx.configureForTest("session", "should_create_a_session_with_appended_systemmessage_config"); try (CopilotClient client = ctx.createClient()) { String systemMessageSuffix = "End each response with the phrase 'Have a nice day!'"; SessionConfig config = new SessionConfig().setSystemMessage( new SystemMessageConfig().setContent(systemMessageSuffix).setMode(SystemMessageMode.APPEND)); CopilotSession session = client.createSession(config).get(); assertNotNull(session.getSessionId()); AssistantMessageEvent response = session .sendAndWait(new MessageOptions().setPrompt("What is your full name?")).get(60, TimeUnit.SECONDS); assertNotNull(response); assertTrue(response.getData().getContent().contains("GitHub"), "Response should contain GitHub: " + response.getData().getContent()); assertTrue(response.getData().getContent().contains("Have a nice day!"), "Response should end with 'Have a nice day!': " + response.getData().getContent()); session.close(); } } @Test void testSessionWithReplacedSystemMessage() throws Exception { ctx.configureForTest("session", "should_create_a_session_with_replaced_systemmessage_config"); try (CopilotClient client = ctx.createClient()) { String testSystemMessage = "You are an assistant called Testy McTestface. Reply succinctly."; SessionConfig config = new SessionConfig().setSystemMessage( new SystemMessageConfig().setContent(testSystemMessage).setMode(SystemMessageMode.REPLACE)); CopilotSession session = client.createSession(config).get(); assertNotNull(session.getSessionId()); AssistantMessageEvent response = session .sendAndWait(new MessageOptions().setPrompt("What is your full name?")).get(60, TimeUnit.SECONDS); assertNotNull(response); assertTrue(response.getData().getContent().contains("Testy McTestface"), "Response should contain 'Testy McTestface': " + response.getData().getContent()); session.close(); } } @Test void testSessionWithStreamingEnabled() throws Exception { ctx.configureForTest("session", "should_receive_streaming_delta_events_when_streaming_is_enabled"); try (CopilotClient client = ctx.createClient()) { SessionConfig config = new SessionConfig().setStreaming(true); CopilotSession session = client.createSession(config).get(); List receivedEvents = new ArrayList<>(); CompletableFuture idleReceived = new CompletableFuture<>(); session.on(evt -> { receivedEvents.add(evt); if (evt instanceof SessionIdleEvent) { idleReceived.complete(null); } }); session.send(new MessageOptions().setPrompt("What is 2+2?")).get(); idleReceived.get(60, TimeUnit.SECONDS); // Should have received delta events when streaming is enabled boolean hasDeltaEvents = receivedEvents.stream().anyMatch(e -> e instanceof AssistantMessageDeltaEvent); assertTrue(hasDeltaEvents, "Should receive streaming delta events when streaming is enabled"); session.close(); } } @Test void testAbortSession() throws Exception { ctx.configureForTest("session", "should_abort_a_session"); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession().get(); assertNotNull(session.getSessionId()); // Set up wait for tool execution to start BEFORE sending CompletableFuture toolStartFuture = new CompletableFuture<>(); CompletableFuture sessionIdleFuture = new CompletableFuture<>(); session.on(evt -> { if (evt instanceof ToolExecutionStartEvent toolStart && !toolStartFuture.isDone()) { toolStartFuture.complete(toolStart); } else if (evt instanceof SessionIdleEvent idle && !sessionIdleFuture.isDone()) { sessionIdleFuture.complete(idle); } }); // Send a message that will trigger a long-running shell command session.send(new MessageOptions() .setPrompt("run the shell command 'sleep 100' (note this works on both bash and PowerShell)")) .get(); // Wait for the tool to start executing toolStartFuture.get(60, TimeUnit.SECONDS); // Abort the session while the tool is running session.abort(); // Wait for session to become idle after abort sessionIdleFuture.get(30, TimeUnit.SECONDS); // The session should still be alive and usable after abort List messages = session.getMessages().get(60, TimeUnit.SECONDS); assertFalse(messages.isEmpty()); // Verify an abort event exists in messages assertTrue(messages.stream().anyMatch(m -> m instanceof AbortEvent), "Expected an abort event in messages"); // We should be able to send another message AssistantMessageEvent answer = session.sendAndWait(new MessageOptions().setPrompt("What is 2+2?")).get(60, TimeUnit.SECONDS); assertNotNull(answer); assertTrue(answer.getData().getContent().contains("4"), "Response should contain 4: " + answer.getData().getContent()); session.close(); } } @Test void testSessionWithAvailableTools() throws Exception { ctx.configureForTest("session", "should_create_a_session_with_availabletools"); try (CopilotClient client = ctx.createClient()) { SessionConfig config = new SessionConfig().setAvailableTools(List.of("view", "edit")); CopilotSession session = client.createSession(config).get(); assertNotNull(session.getSessionId()); AssistantMessageEvent response = session.sendAndWait(new MessageOptions().setPrompt("What is 1+1?")).get(60, TimeUnit.SECONDS); assertNotNull(response); session.close(); } } @Test void testSessionWithExcludedTools() throws Exception { ctx.configureForTest("session", "should_create_a_session_with_excludedtools"); try (CopilotClient client = ctx.createClient()) { SessionConfig config = new SessionConfig().setExcludedTools(List.of("view")); CopilotSession session = client.createSession(config).get(); assertNotNull(session.getSessionId()); AssistantMessageEvent response = session.sendAndWait(new MessageOptions().setPrompt("What is 1+1?")).get(60, TimeUnit.SECONDS); assertNotNull(response); assertTrue(response.getData().getContent().contains("2"), "Response should contain 2: " + response.getData().getContent()); session.close(); } } @Test void testThrowErrorWhenResumingNonExistentSession() throws Exception { ctx.configureForTest("session", "should_receive_session_events"); try (CopilotClient client = ctx.createClient()) { try { client.resumeSession("non-existent-session-id").get(30, TimeUnit.SECONDS); fail("Expected exception when resuming non-existent session"); } catch (Exception e) { // Should throw an error assertTrue(e.getMessage() != null || e.getCause() != null, "Exception should have a message or cause"); } } } @Test void testCreateSessionWithCustomConfigDir() throws Exception { ctx.configureForTest("session", "should_create_session_with_custom_config_dir"); try (CopilotClient client = ctx.createClient()) { String customConfigDir = ctx.getWorkDir().resolve("custom-config").toString(); SessionConfig config = new SessionConfig().setConfigDir(customConfigDir); CopilotSession session = client.createSession(config).get(); assertNotNull(session.getSessionId()); assertTrue(session.getSessionId().matches("^[a-f0-9-]+$")); // Session should work normally with custom config dir AssistantMessageEvent response = session.sendAndWait(new MessageOptions().setPrompt("What is 1+1?")).get(60, TimeUnit.SECONDS); assertNotNull(response); assertTrue(response.getData().getContent().contains("2"), "Response should contain 2: " + response.getData().getContent()); session.close(); } } // Skip in CI - this test validates client-side timeout behavior, not LLM // responses. // The test intentionally times out before receiving a response, so there's no // snapshot to replay. @Test @DisabledIfEnvironmentVariable(named = "CI", matches = ".*") void testSendAndWaitThrowsOnTimeout() throws Exception { ctx.configureForTest("session", "sendandwait_throws_on_timeout"); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession().get(); // Use a very short timeout that will definitely expire try { // Note: We use a command that takes time so timeout triggers before completion session.sendAndWait(new MessageOptions().setPrompt("Run 'sleep 2 && echo done'"), 100).get(5, TimeUnit.SECONDS); fail("Expected timeout exception"); } catch (Exception e) { // Should throw a timeout-related error assertTrue(e.getMessage().toLowerCase().contains("timeout") || (e.getCause() != null && e.getCause().getMessage().toLowerCase().contains("timeout")), "Should throw timeout exception: " + e.getMessage()); } session.close(); } } }