From 80b1392e279d98193552fc14b9a97ad9927595c9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Mar 2026 01:59:55 +0000 Subject: [PATCH 01/69] [maven-release-plugin] prepare for next development iteration --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index d8417fdeb..abf99ae57 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ com.github copilot-sdk-java - 0.2.1-java.0 + 0.2.2-java.0-SNAPSHOT jar GitHub Copilot SDK :: Java @@ -33,7 +33,7 @@ scm:git:https://github.com/github/copilot-sdk-java.git scm:git:https://github.com/github/copilot-sdk-java.git https://github.com/github/copilot-sdk-java - v0.2.1-java.0 + HEAD From 3c405b75a4a9f17e26f516e80042c51023d4397d Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Fri, 27 Mar 2026 14:31:12 -0400 Subject: [PATCH 02/69] On branch edburns/dd-2758695-virtual-threads Add **Shared `ScheduledExecutorService`** for timeouts ## CopilotSession.java - Added `ScheduledExecutorService` import. - New field `timeoutScheduler`: shared single-thread scheduler, daemon thread named `sendAndWait-timeout`. - Initialized in 3-arg constructor. - `sendAndWait()`: replaced per-call `Executors.newSingleThreadScheduledExecutor()` with `timeoutScheduler.schedule()`. Cleanup calls `timeoutTask.cancel(false)` instead of `scheduler.shutdown()`. - `close()`: added `timeoutScheduler.shutdownNow()` before the blocking `session.destroy` RPC call so stale timeouts cannot fire after close. ## TimeoutEdgeCaseTest.java (new) - `testTimeoutDoesNotFireAfterSessionClose`: proves close() cancels pending timeouts (future not completed by stale TimeoutException). - `testSendAndWaitReusesTimeoutThread`: proves two sendAndWait calls share one scheduler thread instead of spawning two. - Uses reflection to construct a hanging `JsonRpcClient` (blocking InputStream, sink OutputStream). Signed-off-by: Ed Burns --- .../github/copilot/sdk/CopilotSession.java | 21 +-- .../copilot/sdk/TimeoutEdgeCaseTest.java | 148 ++++++++++++++++++ 2 files changed, 160 insertions(+), 9 deletions(-) create mode 100644 src/test/java/com/github/copilot/sdk/TimeoutEdgeCaseTest.java diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 8c68e1e3e..8a1cefd38 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -14,6 +14,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicReference; @@ -121,6 +122,7 @@ public final class CopilotSession implements AutoCloseable { private volatile EventErrorHandler eventErrorHandler; private volatile EventErrorPolicy eventErrorPolicy = EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS; private volatile Map>> transformCallbacks; + private final ScheduledExecutorService timeoutScheduler; /** Tracks whether this session instance has been terminated via close(). */ private volatile boolean isTerminated = false; @@ -157,6 +159,11 @@ public final class CopilotSession implements AutoCloseable { this.sessionId = sessionId; this.rpc = rpc; this.workspacePath = workspacePath; + this.timeoutScheduler = Executors.newSingleThreadScheduledExecutor(r -> { + var t = new Thread(r, "sendAndWait-timeout"); + t.setDaemon(true); + return t; + }); } /** @@ -407,17 +414,11 @@ public CompletableFuture sendAndWait(MessageOptions optio return null; }); - // Set up timeout with daemon thread so it doesn't prevent JVM exit - var scheduler = Executors.newSingleThreadScheduledExecutor(r -> { - var t = new Thread(r, "sendAndWait-timeout"); - t.setDaemon(true); - return t; - }); - scheduler.schedule(() -> { + // Schedule timeout on the shared session-level scheduler + var timeoutTask = timeoutScheduler.schedule(() -> { if (!future.isDone()) { future.completeExceptionally(new TimeoutException("sendAndWait timed out after " + timeoutMs + "ms")); } - scheduler.shutdown(); }, timeoutMs, TimeUnit.MILLISECONDS); var result = new CompletableFuture(); @@ -429,7 +430,7 @@ public CompletableFuture sendAndWait(MessageOptions optio } catch (IOException e) { LOG.log(Level.SEVERE, "Error closing subscription", e); } - scheduler.shutdown(); + timeoutTask.cancel(false); if (!result.isDone()) { if (ex != null) { result.completeExceptionally(ex); @@ -1303,6 +1304,8 @@ public void close() { isTerminated = true; } + timeoutScheduler.shutdownNow(); + try { rpc.invoke("session.destroy", Map.of("sessionId", sessionId), Void.class).get(5, TimeUnit.SECONDS); } catch (Exception e) { diff --git a/src/test/java/com/github/copilot/sdk/TimeoutEdgeCaseTest.java b/src/test/java/com/github/copilot/sdk/TimeoutEdgeCaseTest.java new file mode 100644 index 000000000..0f37a0539 --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/TimeoutEdgeCaseTest.java @@ -0,0 +1,148 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.Socket; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.Test; + +import com.github.copilot.sdk.events.AssistantMessageEvent; +import com.github.copilot.sdk.json.MessageOptions; + +/** + * Tests for timeout edge cases in {@link CopilotSession#sendAndWait}. + *

+ * These tests prove two defects in the current per-call + * {@code ScheduledExecutorService} approach: + *

    + *
  1. A timeout fires after {@code close()}, leaking a {@code TimeoutException} + * onto the returned future.
  2. + *
  3. Each {@code sendAndWait} call spawns a new OS thread (~1 MB stack), + * instead of reusing a shared scheduler thread.
  4. + *
+ */ +public class TimeoutEdgeCaseTest { + + /** + * Creates a {@link JsonRpcClient} whose {@code invoke()} returns futures that + * never complete. The reader thread blocks forever on the input stream, and + * writes go to a no-op output stream. + */ + private JsonRpcClient createHangingRpcClient() throws Exception { + InputStream blockingInput = new InputStream() { + @Override + public int read() throws IOException { + try { + Thread.sleep(Long.MAX_VALUE); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return -1; + } + return -1; + } + }; + ByteArrayOutputStream sinkOutput = new ByteArrayOutputStream(); + + var ctor = JsonRpcClient.class.getDeclaredConstructor( + InputStream.class, java.io.OutputStream.class, Socket.class, Process.class); + ctor.setAccessible(true); + return (JsonRpcClient) ctor.newInstance(blockingInput, sinkOutput, null, null); + } + + /** + * After {@code close()}, the future returned by {@code sendAndWait} must NOT be + * completed by a stale timeout. + *

+ * Current buggy behavior: the per-call scheduler is not cancelled by + * {@code close()}, so its 2-second timeout fires during the 5-second + * {@code session.destroy} RPC wait, completing the future with + * {@code TimeoutException}. + *

+ * Expected behavior after fix: {@code close()} cancels pending timeouts before + * the blocking RPC call, so the future remains incomplete. + */ + @Test + void testTimeoutDoesNotFireAfterSessionClose() throws Exception { + JsonRpcClient rpc = createHangingRpcClient(); + try { + CopilotSession session = new CopilotSession("test-timeout-id", rpc); + + CompletableFuture result = session.sendAndWait( + new MessageOptions().setPrompt("hello"), 2000); + + assertFalse(result.isDone(), "Future should be pending before timeout fires"); + + // close() blocks up to 5s on session.destroy RPC. The 2s timeout + // fires during that window with the current per-call scheduler. + session.close(); + + assertFalse(result.isDone(), + "Future should not be completed by a timeout after session is closed. " + + "The per-call ScheduledExecutorService leaked a TimeoutException."); + } finally { + rpc.close(); + } + } + + /** + * A shared scheduler should reuse a single thread across multiple + * {@code sendAndWait} calls, rather than spawning a new OS thread per call. + *

+ * Current buggy behavior: two calls create two {@code sendAndWait-timeout} + * threads. + *

+ * Expected behavior after fix: two calls still use only one scheduler thread. + */ + @Test + void testSendAndWaitReusesTimeoutThread() throws Exception { + JsonRpcClient rpc = createHangingRpcClient(); + try { + CopilotSession session = new CopilotSession("test-thread-count-id", rpc); + + long baselineCount = countTimeoutThreads(); + + CompletableFuture result1 = session.sendAndWait( + new MessageOptions().setPrompt("hello1"), 30000); + + Thread.sleep(100); + long afterFirst = countTimeoutThreads(); + assertTrue(afterFirst >= baselineCount + 1, + "Expected at least one new sendAndWait-timeout thread after first call. " + + "Baseline: " + baselineCount + ", after: " + afterFirst); + + CompletableFuture result2 = session.sendAndWait( + new MessageOptions().setPrompt("hello2"), 30000); + + Thread.sleep(100); + long afterSecond = countTimeoutThreads(); + assertTrue(afterSecond == afterFirst, + "Shared scheduler should reuse the same thread — no new threads after second call. " + + "After first: " + afterFirst + ", after second: " + afterSecond); + + result1.cancel(true); + result2.cancel(true); + session.close(); + } finally { + rpc.close(); + } + } + + /** + * Counts the number of live threads whose name contains "sendAndWait-timeout". + */ + private long countTimeoutThreads() { + return Thread.getAllStackTraces().keySet().stream() + .filter(t -> t.getName().contains("sendAndWait-timeout")) + .filter(Thread::isAlive) + .count(); + } +} From a36d145b777971a22e6cfb25ba605de177f6351d Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Fri, 27 Mar 2026 14:35:06 -0400 Subject: [PATCH 03/69] On branch edburns/dd-2758695-virtual-threads Add **Shared `ScheduledExecutorService`** for timeouts ## CopilotSession.java - Added `ScheduledExecutorService` import. - New field `timeoutScheduler`: shared single-thread scheduler, daemon thread named `sendAndWait-timeout`. - Initialized in 3-arg constructor. - `sendAndWait()`: replaced per-call `Executors.newSingleThreadScheduledExecutor()` with `timeoutScheduler.schedule()`. Cleanup calls `timeoutTask.cancel(false)` instead of `scheduler.shutdown()`. - `close()`: added `timeoutScheduler.shutdownNow()` before the blocking `session.destroy` RPC call so stale timeouts cannot fire after close. ## TimeoutEdgeCaseTest.java (new) - `testTimeoutDoesNotFireAfterSessionClose`: proves close() cancels pending timeouts (future not completed by stale TimeoutException). - `testSendAndWaitReusesTimeoutThread`: proves two sendAndWait calls share one scheduler thread instead of spawning two. - Uses reflection to construct a hanging `JsonRpcClient` (blocking InputStream, sink OutputStream). Signed-off-by: Ed Burns --- .../copilot/sdk/TimeoutEdgeCaseTest.java | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/test/java/com/github/copilot/sdk/TimeoutEdgeCaseTest.java b/src/test/java/com/github/copilot/sdk/TimeoutEdgeCaseTest.java index 0f37a0539..5fa6f7e09 100644 --- a/src/test/java/com/github/copilot/sdk/TimeoutEdgeCaseTest.java +++ b/src/test/java/com/github/copilot/sdk/TimeoutEdgeCaseTest.java @@ -52,8 +52,8 @@ public int read() throws IOException { }; ByteArrayOutputStream sinkOutput = new ByteArrayOutputStream(); - var ctor = JsonRpcClient.class.getDeclaredConstructor( - InputStream.class, java.io.OutputStream.class, Socket.class, Process.class); + var ctor = JsonRpcClient.class.getDeclaredConstructor(InputStream.class, java.io.OutputStream.class, + Socket.class, Process.class); ctor.setAccessible(true); return (JsonRpcClient) ctor.newInstance(blockingInput, sinkOutput, null, null); } @@ -76,8 +76,8 @@ void testTimeoutDoesNotFireAfterSessionClose() throws Exception { try { CopilotSession session = new CopilotSession("test-timeout-id", rpc); - CompletableFuture result = session.sendAndWait( - new MessageOptions().setPrompt("hello"), 2000); + CompletableFuture result = session + .sendAndWait(new MessageOptions().setPrompt("hello"), 2000); assertFalse(result.isDone(), "Future should be pending before timeout fires"); @@ -85,9 +85,8 @@ void testTimeoutDoesNotFireAfterSessionClose() throws Exception { // fires during that window with the current per-call scheduler. session.close(); - assertFalse(result.isDone(), - "Future should not be completed by a timeout after session is closed. " - + "The per-call ScheduledExecutorService leaked a TimeoutException."); + assertFalse(result.isDone(), "Future should not be completed by a timeout after session is closed. " + + "The per-call ScheduledExecutorService leaked a TimeoutException."); } finally { rpc.close(); } @@ -110,17 +109,17 @@ void testSendAndWaitReusesTimeoutThread() throws Exception { long baselineCount = countTimeoutThreads(); - CompletableFuture result1 = session.sendAndWait( - new MessageOptions().setPrompt("hello1"), 30000); + CompletableFuture result1 = session + .sendAndWait(new MessageOptions().setPrompt("hello1"), 30000); Thread.sleep(100); long afterFirst = countTimeoutThreads(); assertTrue(afterFirst >= baselineCount + 1, - "Expected at least one new sendAndWait-timeout thread after first call. " - + "Baseline: " + baselineCount + ", after: " + afterFirst); + "Expected at least one new sendAndWait-timeout thread after first call. " + "Baseline: " + + baselineCount + ", after: " + afterFirst); - CompletableFuture result2 = session.sendAndWait( - new MessageOptions().setPrompt("hello2"), 30000); + CompletableFuture result2 = session + .sendAndWait(new MessageOptions().setPrompt("hello2"), 30000); Thread.sleep(100); long afterSecond = countTimeoutThreads(); @@ -140,9 +139,7 @@ void testSendAndWaitReusesTimeoutThread() throws Exception { * Counts the number of live threads whose name contains "sendAndWait-timeout". */ private long countTimeoutThreads() { - return Thread.getAllStackTraces().keySet().stream() - .filter(t -> t.getName().contains("sendAndWait-timeout")) - .filter(Thread::isAlive) - .count(); + return Thread.getAllStackTraces().keySet().stream().filter(t -> t.getName().contains("sendAndWait-timeout")) + .filter(Thread::isAlive).count(); } } From 9e102bcba86fb156060a90c7ba0c0a87136953c7 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Fri, 27 Mar 2026 16:52:14 -0400 Subject: [PATCH 04/69] Fix memory leak from cancelled timeout tasks in CopilotSession Replace Executors.newSingleThreadScheduledExecutor with an explicit ScheduledThreadPoolExecutor so we can enable removeOnCancelPolicy(true). Without this, each call to sendAndWait() that completes normally cancels its timeout task, but the cancelled task remains in the scheduler's work queue, leaking memory over the lifetime of the session. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ed Burns --- src/main/java/com/github/copilot/sdk/CopilotSession.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 8a1cefd38..b4a4b2c7c 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -15,6 +15,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicReference; @@ -159,11 +160,13 @@ public final class CopilotSession implements AutoCloseable { this.sessionId = sessionId; this.rpc = rpc; this.workspacePath = workspacePath; - this.timeoutScheduler = Executors.newSingleThreadScheduledExecutor(r -> { + var executor = new ScheduledThreadPoolExecutor(1, r -> { var t = new Thread(r, "sendAndWait-timeout"); t.setDaemon(true); return t; }); + executor.setRemoveOnCancelPolicy(true); + this.timeoutScheduler = executor; } /** From a1668c75fbf51c49dc055fae99a874d150ff6062 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Fri, 27 Mar 2026 17:16:42 -0400 Subject: [PATCH 05/69] Fix scheduler memory leak and close() race condition in CopilotSession pom.xml Add mockito-core 5.17.0 as a test dependency. src/main/java/com/github/copilot/sdk/CopilotSession.java Replace Executors.newSingleThreadScheduledExecutor with explicit ScheduledThreadPoolExecutor and enable removeOnCancelPolicy(true) so cancelled timeout tasks are purged from the work queue immediately. Wrap timeoutScheduler.schedule() in a try-catch for RejectedExecutionException. On rejection (close() race), the event subscription is cleaned up and the returned future completes exceptionally instead of throwing uncaught. src/test/java/com/github/copilot/sdk/SchedulerShutdownRaceTest.java (new) TDD test that reproduces the scheduler shutdown race. Uses Mockito to stub JsonRpcClient.invoke(), then shuts down the scheduler without setting isTerminated, and asserts sendAndWait() returns a failed future rather than throwing RejectedExecutionException. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ed Burns --- pom.xml | 6 ++ .../github/copilot/sdk/CopilotSession.java | 26 ++++++-- .../sdk/SchedulerShutdownRaceTest.java | 65 +++++++++++++++++++ 3 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 src/test/java/com/github/copilot/sdk/SchedulerShutdownRaceTest.java diff --git a/pom.xml b/pom.xml index abf99ae57..eee587375 100644 --- a/pom.xml +++ b/pom.xml @@ -86,6 +86,12 @@ 5.14.1 test + + org.mockito + mockito-core + 5.17.0 + test + diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index b4a4b2c7c..7760a499d 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -14,7 +14,9 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -417,14 +419,26 @@ public CompletableFuture sendAndWait(MessageOptions optio return null; }); + var result = new CompletableFuture(); + // Schedule timeout on the shared session-level scheduler - var timeoutTask = timeoutScheduler.schedule(() -> { - if (!future.isDone()) { - future.completeExceptionally(new TimeoutException("sendAndWait timed out after " + timeoutMs + "ms")); + ScheduledFuture timeoutTask; + try { + timeoutTask = timeoutScheduler.schedule(() -> { + if (!future.isDone()) { + future.completeExceptionally( + new TimeoutException("sendAndWait timed out after " + timeoutMs + "ms")); + } + }, timeoutMs, TimeUnit.MILLISECONDS); + } catch (RejectedExecutionException e) { + try { + subscription.close(); + } catch (IOException closeEx) { + e.addSuppressed(closeEx); } - }, timeoutMs, TimeUnit.MILLISECONDS); - - var result = new CompletableFuture(); + result.completeExceptionally(e); + return result; + } // When inner future completes, run cleanup and propagate to result future.whenComplete((r, ex) -> { diff --git a/src/test/java/com/github/copilot/sdk/SchedulerShutdownRaceTest.java b/src/test/java/com/github/copilot/sdk/SchedulerShutdownRaceTest.java new file mode 100644 index 000000000..d40108592 --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/SchedulerShutdownRaceTest.java @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import com.github.copilot.sdk.json.MessageOptions; + +/** + * Reproduces the race between {@code sendAndWait()} and {@code close()}. + *

+ * If {@code close()} shuts down the timeout scheduler after + * {@code ensureNotTerminated()} passes but before + * {@code timeoutScheduler.schedule()} executes, the schedule call throws + * {@link RejectedExecutionException}. Without a fix the exception propagates + * uncaught, leaking the event subscription and leaving the returned future + * incomplete. + */ +public class SchedulerShutdownRaceTest { + + @SuppressWarnings("unchecked") + @Test + void sendAndWaitShouldReturnFailedFutureWhenSchedulerIsShutDown() throws Exception { + // Build a session via reflection (package-private constructor) + var ctor = CopilotSession.class.getDeclaredConstructor( + String.class, JsonRpcClient.class, String.class); + ctor.setAccessible(true); + + // Mock JsonRpcClient so send() returns a pending future instead of NPE + var mockRpc = mock(JsonRpcClient.class); + when(mockRpc.invoke(any(), any(), any())) + .thenReturn(new CompletableFuture<>()); + + var session = ctor.newInstance("race-test", mockRpc, null); + + // Shut down the scheduler without setting isTerminated, + // simulating the race window between ensureNotTerminated() and schedule() + var schedulerField = CopilotSession.class.getDeclaredField("timeoutScheduler"); + schedulerField.setAccessible(true); + var scheduler = (ScheduledExecutorService) schedulerField.get(session); + scheduler.shutdownNow(); + + // With the fix: sendAndWait returns a future that completes exceptionally. + // Without the fix: sendAndWait throws RejectedExecutionException directly. + CompletableFuture result = session.sendAndWait( + new MessageOptions().setPrompt("test"), 5000); + + assertNotNull(result, "sendAndWait should return a future, not throw"); + var ex = assertThrows(ExecutionException.class, + () -> result.get(1, TimeUnit.SECONDS)); + assertInstanceOf(RejectedExecutionException.class, ex.getCause()); + } +} From a01f0b51144e6aeb0b511e2b1ee9b1d3be030184 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Fri, 27 Mar 2026 17:34:15 -0400 Subject: [PATCH 06/69] Honor documented contract: timeoutMs <= 0 means no timeout in sendAndWait src/main/java/com/github/copilot/sdk/CopilotSession.java Skip scheduling the timeout task when timeoutMs <= 0, matching the Javadoc contract that 0 or negative means "no timeout". Previously, timeoutMs=0 would schedule an immediate timeout, contradicting the docs. The timeout cancel in the whenComplete cleanup is now guarded for the null case. src/test/java/com/github/copilot/sdk/ZeroTimeoutContractTest.java (new) TDD test that asserts the documented contract: calling sendAndWait with timeoutMs=0 should not cause the future to complete with a TimeoutException. Uses Mockito to stub JsonRpcClient.invoke(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../github/copilot/sdk/CopilotSession.java | 38 +++++++------ .../copilot/sdk/ZeroTimeoutContractTest.java | 53 +++++++++++++++++++ 2 files changed, 75 insertions(+), 16 deletions(-) create mode 100644 src/test/java/com/github/copilot/sdk/ZeroTimeoutContractTest.java diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 7760a499d..b90877bc2 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -421,33 +421,39 @@ public CompletableFuture sendAndWait(MessageOptions optio var result = new CompletableFuture(); - // Schedule timeout on the shared session-level scheduler - ScheduledFuture timeoutTask; - try { - timeoutTask = timeoutScheduler.schedule(() -> { - if (!future.isDone()) { - future.completeExceptionally( - new TimeoutException("sendAndWait timed out after " + timeoutMs + "ms")); - } - }, timeoutMs, TimeUnit.MILLISECONDS); - } catch (RejectedExecutionException e) { + // Schedule timeout on the shared session-level scheduler. + // Per Javadoc, timeoutMs <= 0 means "no timeout". + ScheduledFuture timeoutTask = null; + if (timeoutMs > 0) { try { - subscription.close(); - } catch (IOException closeEx) { - e.addSuppressed(closeEx); + timeoutTask = timeoutScheduler.schedule(() -> { + if (!future.isDone()) { + future.completeExceptionally( + new TimeoutException("sendAndWait timed out after " + timeoutMs + "ms")); + } + }, timeoutMs, TimeUnit.MILLISECONDS); + } catch (RejectedExecutionException e) { + try { + subscription.close(); + } catch (IOException closeEx) { + e.addSuppressed(closeEx); + } + result.completeExceptionally(e); + return result; } - result.completeExceptionally(e); - return result; } // When inner future completes, run cleanup and propagate to result + final ScheduledFuture taskToCancel = timeoutTask; future.whenComplete((r, ex) -> { try { subscription.close(); } catch (IOException e) { LOG.log(Level.SEVERE, "Error closing subscription", e); } - timeoutTask.cancel(false); + if (taskToCancel != null) { + taskToCancel.cancel(false); + } if (!result.isDone()) { if (ex != null) { result.completeExceptionally(ex); diff --git a/src/test/java/com/github/copilot/sdk/ZeroTimeoutContractTest.java b/src/test/java/com/github/copilot/sdk/ZeroTimeoutContractTest.java new file mode 100644 index 000000000..10e6f7edf --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/ZeroTimeoutContractTest.java @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.junit.jupiter.api.Test; + +import com.github.copilot.sdk.events.AssistantMessageEvent; +import com.github.copilot.sdk.events.SessionIdleEvent; +import com.github.copilot.sdk.json.MessageOptions; + +/** + * Verifies the documented contract that {@code timeoutMs <= 0} means "no + * timeout" in {@link CopilotSession#sendAndWait(MessageOptions, long)}. + */ +public class ZeroTimeoutContractTest { + + @SuppressWarnings("unchecked") + @Test + void sendAndWaitWithZeroTimeoutShouldNotTimeOut() throws Exception { + // Build a session via reflection (package-private constructor) + var ctor = CopilotSession.class.getDeclaredConstructor( + String.class, JsonRpcClient.class, String.class); + ctor.setAccessible(true); + + var mockRpc = mock(JsonRpcClient.class); + when(mockRpc.invoke(any(), any(), any())) + .thenReturn(new CompletableFuture<>()); + + var session = ctor.newInstance("zero-timeout-test", mockRpc, null); + + // Per the Javadoc: timeoutMs of 0 means "no timeout". + // The future should NOT complete with TimeoutException. + CompletableFuture result = + session.sendAndWait(new MessageOptions().setPrompt("test"), 0); + + // Give the scheduler a chance to fire if it was (incorrectly) scheduled + Thread.sleep(200); + + // The future should still be pending — not timed out + assertFalse(result.isDone(), + "Future should not be done; timeoutMs=0 means no timeout per Javadoc"); + } +} From 9dc5933042a9dd40483c0c3476b0fc8cf18dd3f2 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Fri, 27 Mar 2026 17:47:23 -0400 Subject: [PATCH 07/69] Prevent CopilotSession leak on assertion failure in TimeoutEdgeCaseTest src/test/java/com/github/copilot/sdk/TimeoutEdgeCaseTest.java Wrap CopilotSession in try-with-resources in both tests so the session and its scheduler thread are always cleaned up, even if an assertion fails before the explicit close() call. In test 1, the explicit session.close() is kept because it is the action under test; the try-with-resources provides a safety net via idempotent double-close. In test 2, the explicit session.close() is removed since it was purely cleanup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../copilot/sdk/TimeoutEdgeCaseTest.java | 57 ++++++++++--------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/src/test/java/com/github/copilot/sdk/TimeoutEdgeCaseTest.java b/src/test/java/com/github/copilot/sdk/TimeoutEdgeCaseTest.java index 5fa6f7e09..f97d1f254 100644 --- a/src/test/java/com/github/copilot/sdk/TimeoutEdgeCaseTest.java +++ b/src/test/java/com/github/copilot/sdk/TimeoutEdgeCaseTest.java @@ -74,19 +74,20 @@ public int read() throws IOException { void testTimeoutDoesNotFireAfterSessionClose() throws Exception { JsonRpcClient rpc = createHangingRpcClient(); try { - CopilotSession session = new CopilotSession("test-timeout-id", rpc); + try (CopilotSession session = new CopilotSession("test-timeout-id", rpc)) { - CompletableFuture result = session - .sendAndWait(new MessageOptions().setPrompt("hello"), 2000); + CompletableFuture result = session + .sendAndWait(new MessageOptions().setPrompt("hello"), 2000); - assertFalse(result.isDone(), "Future should be pending before timeout fires"); + assertFalse(result.isDone(), "Future should be pending before timeout fires"); - // close() blocks up to 5s on session.destroy RPC. The 2s timeout - // fires during that window with the current per-call scheduler. - session.close(); + // close() blocks up to 5s on session.destroy RPC. The 2s timeout + // fires during that window with the current per-call scheduler. + session.close(); - assertFalse(result.isDone(), "Future should not be completed by a timeout after session is closed. " - + "The per-call ScheduledExecutorService leaked a TimeoutException."); + assertFalse(result.isDone(), "Future should not be completed by a timeout after session is closed. " + + "The per-call ScheduledExecutorService leaked a TimeoutException."); + } } finally { rpc.close(); } @@ -105,31 +106,31 @@ void testTimeoutDoesNotFireAfterSessionClose() throws Exception { void testSendAndWaitReusesTimeoutThread() throws Exception { JsonRpcClient rpc = createHangingRpcClient(); try { - CopilotSession session = new CopilotSession("test-thread-count-id", rpc); + try (CopilotSession session = new CopilotSession("test-thread-count-id", rpc)) { - long baselineCount = countTimeoutThreads(); + long baselineCount = countTimeoutThreads(); - CompletableFuture result1 = session - .sendAndWait(new MessageOptions().setPrompt("hello1"), 30000); + CompletableFuture result1 = session + .sendAndWait(new MessageOptions().setPrompt("hello1"), 30000); - Thread.sleep(100); - long afterFirst = countTimeoutThreads(); - assertTrue(afterFirst >= baselineCount + 1, - "Expected at least one new sendAndWait-timeout thread after first call. " + "Baseline: " - + baselineCount + ", after: " + afterFirst); + Thread.sleep(100); + long afterFirst = countTimeoutThreads(); + assertTrue(afterFirst >= baselineCount + 1, + "Expected at least one new sendAndWait-timeout thread after first call. " + "Baseline: " + + baselineCount + ", after: " + afterFirst); - CompletableFuture result2 = session - .sendAndWait(new MessageOptions().setPrompt("hello2"), 30000); + CompletableFuture result2 = session + .sendAndWait(new MessageOptions().setPrompt("hello2"), 30000); - Thread.sleep(100); - long afterSecond = countTimeoutThreads(); - assertTrue(afterSecond == afterFirst, - "Shared scheduler should reuse the same thread — no new threads after second call. " - + "After first: " + afterFirst + ", after second: " + afterSecond); + Thread.sleep(100); + long afterSecond = countTimeoutThreads(); + assertTrue(afterSecond == afterFirst, + "Shared scheduler should reuse the same thread — no new threads after second call. " + + "After first: " + afterFirst + ", after second: " + afterSecond); - result1.cancel(true); - result2.cancel(true); - session.close(); + result1.cancel(true); + result2.cancel(true); + } } finally { rpc.close(); } From 075df313376abd8b77cf03176a6506828614cda9 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Fri, 27 Mar 2026 17:52:26 -0400 Subject: [PATCH 08/69] spotless --- .../com/github/copilot/sdk/CopilotSession.java | 1 - .../copilot/sdk/SchedulerShutdownRaceTest.java | 12 ++++-------- .../copilot/sdk/ZeroTimeoutContractTest.java | 16 +++++----------- 3 files changed, 9 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index b90877bc2..6ee5d8c4e 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -13,7 +13,6 @@ import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; diff --git a/src/test/java/com/github/copilot/sdk/SchedulerShutdownRaceTest.java b/src/test/java/com/github/copilot/sdk/SchedulerShutdownRaceTest.java index d40108592..3d874d1e9 100644 --- a/src/test/java/com/github/copilot/sdk/SchedulerShutdownRaceTest.java +++ b/src/test/java/com/github/copilot/sdk/SchedulerShutdownRaceTest.java @@ -34,14 +34,12 @@ public class SchedulerShutdownRaceTest { @Test void sendAndWaitShouldReturnFailedFutureWhenSchedulerIsShutDown() throws Exception { // Build a session via reflection (package-private constructor) - var ctor = CopilotSession.class.getDeclaredConstructor( - String.class, JsonRpcClient.class, String.class); + var ctor = CopilotSession.class.getDeclaredConstructor(String.class, JsonRpcClient.class, String.class); ctor.setAccessible(true); // Mock JsonRpcClient so send() returns a pending future instead of NPE var mockRpc = mock(JsonRpcClient.class); - when(mockRpc.invoke(any(), any(), any())) - .thenReturn(new CompletableFuture<>()); + when(mockRpc.invoke(any(), any(), any())).thenReturn(new CompletableFuture<>()); var session = ctor.newInstance("race-test", mockRpc, null); @@ -54,12 +52,10 @@ void sendAndWaitShouldReturnFailedFutureWhenSchedulerIsShutDown() throws Excepti // With the fix: sendAndWait returns a future that completes exceptionally. // Without the fix: sendAndWait throws RejectedExecutionException directly. - CompletableFuture result = session.sendAndWait( - new MessageOptions().setPrompt("test"), 5000); + CompletableFuture result = session.sendAndWait(new MessageOptions().setPrompt("test"), 5000); assertNotNull(result, "sendAndWait should return a future, not throw"); - var ex = assertThrows(ExecutionException.class, - () -> result.get(1, TimeUnit.SECONDS)); + var ex = assertThrows(ExecutionException.class, () -> result.get(1, TimeUnit.SECONDS)); assertInstanceOf(RejectedExecutionException.class, ex.getCause()); } } diff --git a/src/test/java/com/github/copilot/sdk/ZeroTimeoutContractTest.java b/src/test/java/com/github/copilot/sdk/ZeroTimeoutContractTest.java index 10e6f7edf..249dc780b 100644 --- a/src/test/java/com/github/copilot/sdk/ZeroTimeoutContractTest.java +++ b/src/test/java/com/github/copilot/sdk/ZeroTimeoutContractTest.java @@ -9,13 +9,10 @@ import static org.mockito.Mockito.*; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import org.junit.jupiter.api.Test; import com.github.copilot.sdk.events.AssistantMessageEvent; -import com.github.copilot.sdk.events.SessionIdleEvent; import com.github.copilot.sdk.json.MessageOptions; /** @@ -28,26 +25,23 @@ public class ZeroTimeoutContractTest { @Test void sendAndWaitWithZeroTimeoutShouldNotTimeOut() throws Exception { // Build a session via reflection (package-private constructor) - var ctor = CopilotSession.class.getDeclaredConstructor( - String.class, JsonRpcClient.class, String.class); + var ctor = CopilotSession.class.getDeclaredConstructor(String.class, JsonRpcClient.class, String.class); ctor.setAccessible(true); var mockRpc = mock(JsonRpcClient.class); - when(mockRpc.invoke(any(), any(), any())) - .thenReturn(new CompletableFuture<>()); + when(mockRpc.invoke(any(), any(), any())).thenReturn(new CompletableFuture<>()); var session = ctor.newInstance("zero-timeout-test", mockRpc, null); // Per the Javadoc: timeoutMs of 0 means "no timeout". // The future should NOT complete with TimeoutException. - CompletableFuture result = - session.sendAndWait(new MessageOptions().setPrompt("test"), 0); + CompletableFuture result = session.sendAndWait(new MessageOptions().setPrompt("test"), + 0); // Give the scheduler a chance to fire if it was (incorrectly) scheduled Thread.sleep(200); // The future should still be pending — not timed out - assertFalse(result.isDone(), - "Future should not be done; timeoutMs=0 means no timeout per Javadoc"); + assertFalse(result.isDone(), "Future should not be done; timeoutMs=0 means no timeout per Javadoc"); } } From 446fe7422aa20345a7506dfddf9e204727aac467 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Mon, 30 Mar 2026 16:33:10 -0400 Subject: [PATCH 09/69] Update src/test/java/com/github/copilot/sdk/ZeroTimeoutContractTest.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../copilot/sdk/ZeroTimeoutContractTest.java | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/test/java/com/github/copilot/sdk/ZeroTimeoutContractTest.java b/src/test/java/com/github/copilot/sdk/ZeroTimeoutContractTest.java index 249dc780b..3c79ac263 100644 --- a/src/test/java/com/github/copilot/sdk/ZeroTimeoutContractTest.java +++ b/src/test/java/com/github/copilot/sdk/ZeroTimeoutContractTest.java @@ -29,19 +29,29 @@ void sendAndWaitWithZeroTimeoutShouldNotTimeOut() throws Exception { ctor.setAccessible(true); var mockRpc = mock(JsonRpcClient.class); - when(mockRpc.invoke(any(), any(), any())).thenReturn(new CompletableFuture<>()); - - var session = ctor.newInstance("zero-timeout-test", mockRpc, null); - - // Per the Javadoc: timeoutMs of 0 means "no timeout". - // The future should NOT complete with TimeoutException. - CompletableFuture result = session.sendAndWait(new MessageOptions().setPrompt("test"), - 0); - - // Give the scheduler a chance to fire if it was (incorrectly) scheduled - Thread.sleep(200); - - // The future should still be pending — not timed out - assertFalse(result.isDone(), "Future should not be done; timeoutMs=0 means no timeout per Javadoc"); + when(mockRpc.invoke(any(), any(), any())).thenAnswer(invocation -> { + Object method = invocation.getArgument(0); + if ("session.destroy".equals(method)) { + // Make session.close() non-blocking by completing destroy immediately + return CompletableFuture.completedFuture(null); + } + // For other calls (e.g., message send), return an incomplete future so the + // sendAndWait result does not complete due to a mock response. + return new CompletableFuture<>(); + }); + + try (var session = ctor.newInstance("zero-timeout-test", mockRpc, null)) { + + // Per the Javadoc: timeoutMs of 0 means "no timeout". + // The future should NOT complete with TimeoutException. + CompletableFuture result = session + .sendAndWait(new MessageOptions().setPrompt("test"), 0); + + // Give the scheduler a chance to fire if it was (incorrectly) scheduled + Thread.sleep(200); + + // The future should still be pending — not timed out + assertFalse(result.isDone(), "Future should not be done; timeoutMs=0 means no timeout per Javadoc"); + } } } From a22c1ffc3b172a18083ee5eb6b2f3f5f1fac6aef Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Mon, 30 Mar 2026 16:41:07 -0400 Subject: [PATCH 10/69] increase timeout in compaction test --- src/test/java/com/github/copilot/sdk/CompactionTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/github/copilot/sdk/CompactionTest.java b/src/test/java/com/github/copilot/sdk/CompactionTest.java index 49640ac00..ae8f8b1ea 100644 --- a/src/test/java/com/github/copilot/sdk/CompactionTest.java +++ b/src/test/java/com/github/copilot/sdk/CompactionTest.java @@ -56,7 +56,7 @@ static void teardown() throws Exception { * compaction/should_trigger_compaction_with_low_threshold_and_emit_events */ @Test - @Timeout(value = 120, unit = TimeUnit.SECONDS) + @Timeout(value = 300, unit = TimeUnit.SECONDS) void testShouldTriggerCompactionWithLowThresholdAndEmitEvents() throws Exception { ctx.configureForTest("compaction", "should_trigger_compaction_with_low_threshold_and_emit_events"); @@ -96,8 +96,8 @@ void testShouldTriggerCompactionWithLowThresholdAndEmitEvents() throws Exception // Wait for compaction to complete - it may arrive slightly after sendAndWait // returns due to async event delivery from the CLI - assertTrue(compactionCompleteLatch.await(10, TimeUnit.SECONDS), - "Should have received a compaction complete event within 10 seconds"); + assertTrue(compactionCompleteLatch.await(30, TimeUnit.SECONDS), + "Should have received a compaction complete event within 30 seconds"); long compactionStartCount = events.stream().filter(e -> e instanceof SessionCompactionStartEvent).count(); long compactionCompleteCount = events.stream().filter(e -> e instanceof SessionCompactionCompleteEvent) .count(); From 4c6c2ecb5c5053ae8b93c6d2928be2f963dbdce5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:41:23 +0000 Subject: [PATCH 11/69] Update Javadoc in TimeoutEdgeCaseTest and SchedulerShutdownRaceTest to use contract/regression language Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/82d9999d-8d2f-4ccc-b0a9-0dfe932f8f78 Co-authored-by: edburns <75821+edburns@users.noreply.github.com> --- .../sdk/SchedulerShutdownRaceTest.java | 13 ++++---- .../copilot/sdk/TimeoutEdgeCaseTest.java | 32 ++++++++----------- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/src/test/java/com/github/copilot/sdk/SchedulerShutdownRaceTest.java b/src/test/java/com/github/copilot/sdk/SchedulerShutdownRaceTest.java index 3d874d1e9..e60e4aa34 100644 --- a/src/test/java/com/github/copilot/sdk/SchedulerShutdownRaceTest.java +++ b/src/test/java/com/github/copilot/sdk/SchedulerShutdownRaceTest.java @@ -19,14 +19,16 @@ import com.github.copilot.sdk.json.MessageOptions; /** - * Reproduces the race between {@code sendAndWait()} and {@code close()}. + * Regression coverage for the race between {@code sendAndWait()} and + * {@code close()}. *

* If {@code close()} shuts down the timeout scheduler after * {@code ensureNotTerminated()} passes but before * {@code timeoutScheduler.schedule()} executes, the schedule call throws - * {@link RejectedExecutionException}. Without a fix the exception propagates - * uncaught, leaking the event subscription and leaving the returned future - * incomplete. + * {@link RejectedExecutionException}. This test asserts that + * {@code sendAndWait()} handles this race by returning a future that completes + * exceptionally (rather than propagating the exception to the caller or leaving + * the returned future incomplete). */ public class SchedulerShutdownRaceTest { @@ -50,8 +52,7 @@ void sendAndWaitShouldReturnFailedFutureWhenSchedulerIsShutDown() throws Excepti var scheduler = (ScheduledExecutorService) schedulerField.get(session); scheduler.shutdownNow(); - // With the fix: sendAndWait returns a future that completes exceptionally. - // Without the fix: sendAndWait throws RejectedExecutionException directly. + // sendAndWait must return a failed future rather than throwing directly. CompletableFuture result = session.sendAndWait(new MessageOptions().setPrompt("test"), 5000); assertNotNull(result, "sendAndWait should return a future, not throw"); diff --git a/src/test/java/com/github/copilot/sdk/TimeoutEdgeCaseTest.java b/src/test/java/com/github/copilot/sdk/TimeoutEdgeCaseTest.java index f97d1f254..c5ed3af81 100644 --- a/src/test/java/com/github/copilot/sdk/TimeoutEdgeCaseTest.java +++ b/src/test/java/com/github/copilot/sdk/TimeoutEdgeCaseTest.java @@ -19,15 +19,16 @@ import com.github.copilot.sdk.json.MessageOptions; /** - * Tests for timeout edge cases in {@link CopilotSession#sendAndWait}. + * Regression tests for timeout edge cases in + * {@link CopilotSession#sendAndWait}. *

- * These tests prove two defects in the current per-call + * These tests assert two behavioral contracts of the shared * {@code ScheduledExecutorService} approach: *

    - *
  1. A timeout fires after {@code close()}, leaking a {@code TimeoutException} - * onto the returned future.
  2. - *
  3. Each {@code sendAndWait} call spawns a new OS thread (~1 MB stack), - * instead of reusing a shared scheduler thread.
  4. + *
  5. A pending timeout must NOT fire after {@code close()} and must NOT + * complete the returned future with a {@code TimeoutException}.
  6. + *
  7. Multiple {@code sendAndWait} calls must reuse a single shared scheduler + * thread rather than spawning a new OS thread per call.
  8. *
*/ public class TimeoutEdgeCaseTest { @@ -62,13 +63,10 @@ public int read() throws IOException { * After {@code close()}, the future returned by {@code sendAndWait} must NOT be * completed by a stale timeout. *

- * Current buggy behavior: the per-call scheduler is not cancelled by - * {@code close()}, so its 2-second timeout fires during the 5-second - * {@code session.destroy} RPC wait, completing the future with - * {@code TimeoutException}. - *

- * Expected behavior after fix: {@code close()} cancels pending timeouts before - * the blocking RPC call, so the future remains incomplete. + * Contract: {@code close()} shuts down the timeout scheduler before the + * blocking {@code session.destroy} RPC call, so any pending timeout task is + * cancelled and the future remains incomplete (not exceptionally completed with + * {@code TimeoutException}). */ @Test void testTimeoutDoesNotFireAfterSessionClose() throws Exception { @@ -94,13 +92,11 @@ void testTimeoutDoesNotFireAfterSessionClose() throws Exception { } /** - * A shared scheduler should reuse a single thread across multiple + * A shared scheduler must reuse a single thread across multiple * {@code sendAndWait} calls, rather than spawning a new OS thread per call. *

- * Current buggy behavior: two calls create two {@code sendAndWait-timeout} - * threads. - *

- * Expected behavior after fix: two calls still use only one scheduler thread. + * Contract: after two consecutive {@code sendAndWait} calls the number of live + * {@code sendAndWait-timeout} threads must not increase after the second call. */ @Test void testSendAndWaitReusesTimeoutThread() throws Exception { From c67dbb3ab08a968a5f65ca1184746c807d145d0d Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Mon, 30 Mar 2026 16:48:48 -0400 Subject: [PATCH 12/69] Updates to gitignore for JTDLS related artifacts --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 8d7f42429..ddb2508ba 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ smoke-test *job-logs.txt temporary-prompts/ changebundle.txt* +.classpath +.project +.settings From 11681a9904a280da5d7987b10610723e7fe5605c Mon Sep 17 00:00:00 2001 From: brunoborges <129743+brunoborges@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:56:28 +0000 Subject: [PATCH 13/69] Update JaCoCo coverage badge --- .github/badges/jacoco.svg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index ddc73f3a8..19da90d2e 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -12,7 +12,7 @@ coverage coverage - 84.8% - 84.8% + 85% + 85% From 2ba6e1dc1083fd5fade553fcf515a0d17aeba349 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Tue, 31 Mar 2026 13:30:23 -0400 Subject: [PATCH 14/69] On branch edburns/spotless-agentic-workflow-42 modified: .github/copilot-instructions.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added a new "Pre-commit Hooks and Formatting (Coding Agent)" section that: - Explains the hook is automatically enabled via copilot-setup-steps.yml - Gives explicit 3-step recovery instructions: mvn spotless:apply → git add -u → retry commit - Recommends running mvn spotless:apply proactively before committing Now when the Copilot coding agent starts a session, the setup steps will enable the pre-commit hook, and if a commit is rejected by Spotless formatting checks, the agent has clear instructions to fix and retry. modified: .github/workflows/copilot-setup-steps.yml - Added a new step "Enable pre-commit hooks" that runs git config core.hooksPath .githooks before the verification step - Updated the verification step to also print the configured hooks path for confirmation Signed-off-by: Ed Burns --- .github/copilot-instructions.md | 12 ++++++++++++ .github/workflows/copilot-setup-steps.yml | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d7dafb081..e3a8eb275 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -244,6 +244,18 @@ This SDK is designed to be **lightweight with minimal dependencies**: 5. Check for security vulnerabilities 6. Get team approval for non-trivial additions +## Pre-commit Hooks and Formatting (Coding Agent) + +The repository has a pre-commit hook (`.githooks/pre-commit`) that is **automatically enabled** in the Copilot coding agent environment via `copilot-setup-steps.yml`. The hook runs `mvn spotless:check` on any commit that includes changes under `src/`. + +**If a commit fails due to the pre-commit hook:** + +1. Run `mvn spotless:apply` to auto-fix formatting issues. +2. Re-stage the changed files with `git add -u`. +3. Retry the commit. + +**Best practice:** Always run `mvn spotless:apply` before committing Java source changes to avoid hook failures in the first place. If you forget and the hook rejects the commit, follow the three steps above and continue. + ## Commit and PR Guidelines ### Commit Messages diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 6a0cdec5b..8d8aa75c9 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -41,6 +41,10 @@ jobs: distribution: 'temurin' cache: 'maven' + # Enable pre-commit hooks so Spotless formatting is enforced on every commit + - name: Enable pre-commit hooks + run: git config core.hooksPath .githooks + # Verify installations - name: Verify tool installations run: | @@ -50,4 +54,6 @@ jobs: java -version gh --version gh aw version + echo "--- Git hooks path ---" + git config core.hooksPath echo "✅ All tools installed successfully" From dc71acbd18e0da9816c5f548c802213456e90b63 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Tue, 31 Mar 2026 13:34:32 -0400 Subject: [PATCH 15/69] Update .github/workflows/copilot-setup-steps.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/copilot-setup-steps.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 8d8aa75c9..145629457 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -41,7 +41,7 @@ jobs: distribution: 'temurin' cache: 'maven' - # Enable pre-commit hooks so Spotless formatting is enforced on every commit + # Enable repository pre-commit hooks (including Spotless checks for relevant source changes) - name: Enable pre-commit hooks run: git config core.hooksPath .githooks From 59b7fa116f8516fbc5a580a632c60778c2c1c7b9 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Fri, 27 Mar 2026 17:20:02 -0400 Subject: [PATCH 16/69] Add optional Executor to CopilotClientOptions; wire all internal *Async calls through it; shared timeout scheduler. src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java - Added `Executor` field, `getExecutor()`, fluent `setExecutor(Executor)` with pending-null guard, and clone support. src/main/java/com/github/copilot/sdk/CopilotClient.java - Extracted `startCoreBody()` from `startCore()` lambda; `supplyAsync` uses provided executor when non-null. - `stop()` routes session-close `runAsync` through provided executor when non-null. - Passes executor to `RpcHandlerDispatcher` constructor. - Sets executor on new sessions via `session.setExecutor()` in `createSession` and `resumeSession`. src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java - Added `Executor` field and 3-arg constructor. - All 5 `CompletableFuture.runAsync()` calls now go through private `runAsync(Runnable)` helper that uses executor when non-null. src/main/java/com/github/copilot/sdk/CopilotSession.java - Added `Executor` field and package-private `setExecutor()`. - Replaced per-call `ScheduledExecutorService` with shared `timeoutScheduler` (daemon thread, shut down in `close()`). - `executeToolAndRespondAsync` and `executePermissionAndRespondAsync` use executor when non-null. src/test/java/com/github/copilot/sdk/ExecutorWiringTest.java (new) - 6 E2E tests using `TrackingExecutor` decorator to verify all `*Async` paths route through the provided executor: client start, tool call, permission, user input, hooks, and client stop. src/test/java/com/github/copilot/sdk/RpcHandlerDispatcherTest.java - Updated constructor call to pass `null` for new executor parameter. --- .../com/github/copilot/sdk/CopilotClient.java | 83 ++-- .../github/copilot/sdk/CopilotSession.java | 29 +- .../copilot/sdk/RpcHandlerDispatcher.java | 26 +- .../sdk/json/CopilotClientOptions.java | 34 ++ .../copilot/sdk/ExecutorWiringTest.java | 368 ++++++++++++++++++ .../copilot/sdk/RpcHandlerDispatcherTest.java | 2 +- 6 files changed, 498 insertions(+), 44 deletions(-) create mode 100644 src/test/java/com/github/copilot/sdk/ExecutorWiringTest.java diff --git a/src/main/java/com/github/copilot/sdk/CopilotClient.java b/src/main/java/com/github/copilot/sdk/CopilotClient.java index 707469428..00dea3876 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -13,6 +13,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; @@ -150,42 +151,48 @@ public CompletableFuture start() { private CompletableFuture startCore() { LOG.fine("Starting Copilot client"); - return CompletableFuture.supplyAsync(() -> { - try { - JsonRpcClient rpc; - Process process = null; - - if (optionsHost != null && optionsPort != null) { - // External server (TCP) - rpc = serverManager.connectToServer(null, optionsHost, optionsPort); - } else { - // Child process (stdio or TCP) - CliServerManager.ProcessInfo processInfo = serverManager.startCliServer(); - process = processInfo.process(); - rpc = serverManager.connectToServer(process, processInfo.port() != null ? "localhost" : null, - processInfo.port()); - } + Executor exec = options.getExecutor(); + return exec != null + ? CompletableFuture.supplyAsync(this::startCoreBody, exec) + : CompletableFuture.supplyAsync(this::startCoreBody); + } - Connection connection = new Connection(rpc, process); + private Connection startCoreBody() { + try { + JsonRpcClient rpc; + Process process = null; + + if (optionsHost != null && optionsPort != null) { + // External server (TCP) + rpc = serverManager.connectToServer(null, optionsHost, optionsPort); + } else { + // Child process (stdio or TCP) + CliServerManager.ProcessInfo processInfo = serverManager.startCliServer(); + process = processInfo.process(); + rpc = serverManager.connectToServer(process, processInfo.port() != null ? "localhost" : null, + processInfo.port()); + } - // Register handlers for server-to-client calls - RpcHandlerDispatcher dispatcher = new RpcHandlerDispatcher(sessions, lifecycleManager::dispatch); - dispatcher.registerHandlers(rpc); + Connection connection = new Connection(rpc, process); - // Verify protocol version - verifyProtocolVersion(connection); + // Register handlers for server-to-client calls + RpcHandlerDispatcher dispatcher = new RpcHandlerDispatcher(sessions, lifecycleManager::dispatch, + options.getExecutor()); + dispatcher.registerHandlers(rpc); - LOG.info("Copilot client connected"); - return connection; - } catch (Exception e) { - String stderr = serverManager.getStderrOutput(); - if (!stderr.isEmpty()) { - throw new CompletionException( - new IOException("CLI process exited unexpectedly. stderr: " + stderr, e)); - } - throw new CompletionException(e); + // Verify protocol version + verifyProtocolVersion(connection); + + LOG.info("Copilot client connected"); + return connection; + } catch (Exception e) { + String stderr = serverManager.getStderrOutput(); + if (!stderr.isEmpty()) { + throw new CompletionException( + new IOException("CLI process exited unexpectedly. stderr: " + stderr, e)); } - }); + throw new CompletionException(e); + } } private static final int MIN_PROTOCOL_VERSION = 2; @@ -228,15 +235,19 @@ private void verifyProtocolVersion(Connection connection) throws Exception { */ public CompletableFuture stop() { var closeFutures = new ArrayList>(); + Executor exec = options.getExecutor(); for (CopilotSession session : new ArrayList<>(sessions.values())) { - closeFutures.add(CompletableFuture.runAsync(() -> { + Runnable closeTask = () -> { try { session.close(); } catch (Exception e) { LOG.log(Level.WARNING, "Error closing session " + session.getSessionId(), e); } - })); + }; + closeFutures.add(exec != null + ? CompletableFuture.runAsync(closeTask, exec) + : CompletableFuture.runAsync(closeTask)); } sessions.clear(); @@ -329,6 +340,9 @@ public CompletableFuture createSession(SessionConfig config) { : java.util.UUID.randomUUID().toString(); var session = new CopilotSession(sessionId, connection.rpc); + if (options.getExecutor() != null) { + session.setExecutor(options.getExecutor()); + } SessionRequestBuilder.configureSession(session, config); sessions.put(sessionId, session); @@ -399,6 +413,9 @@ public CompletableFuture resumeSession(String sessionId, ResumeS return ensureConnected().thenCompose(connection -> { // Register the session before the RPC call to avoid missing early events. var session = new CopilotSession(sessionId, connection.rpc); + if (options.getExecutor() != null) { + session.setExecutor(options.getExecutor()); + } SessionRequestBuilder.configureSession(session, config); sessions.put(sessionId, session); diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 6ee5d8c4e..485c39312 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -13,6 +13,8 @@ import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -125,6 +127,7 @@ public final class CopilotSession implements AutoCloseable { private volatile EventErrorPolicy eventErrorPolicy = EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS; private volatile Map>> transformCallbacks; private final ScheduledExecutorService timeoutScheduler; + private volatile Executor executor; /** Tracks whether this session instance has been terminated via close(). */ private volatile boolean isTerminated = false; @@ -170,6 +173,14 @@ public final class CopilotSession implements AutoCloseable { this.timeoutScheduler = executor; } + /** + * Sets the executor for internal async operations. Package-private; called by + * CopilotClient after construction. + */ + void setExecutor(Executor executor) { + this.executor = executor; + } + /** * Gets the unique identifier for this session. * @@ -673,7 +684,7 @@ private void handleBroadcastEventAsync(AbstractSessionEvent event) { */ private void executeToolAndRespondAsync(String requestId, String toolName, String toolCallId, Object arguments, ToolDefinition tool) { - CompletableFuture.runAsync(() -> { + Runnable task = () -> { try { JsonNode argumentsNode = arguments instanceof JsonNode jn ? jn @@ -718,7 +729,12 @@ private void executeToolAndRespondAsync(String requestId, String toolName, Strin LOG.log(Level.WARNING, "Error sending tool error for requestId=" + requestId, sendEx); } } - }); + }; + if (executor != null) { + CompletableFuture.runAsync(task, executor); + } else { + CompletableFuture.runAsync(task); + } } /** @@ -727,7 +743,7 @@ private void executeToolAndRespondAsync(String requestId, String toolName, Strin */ private void executePermissionAndRespondAsync(String requestId, PermissionRequest permissionRequest, PermissionHandler handler) { - CompletableFuture.runAsync(() -> { + Runnable task = () -> { try { var invocation = new PermissionInvocation(); invocation.setSessionId(sessionId); @@ -766,7 +782,12 @@ private void executePermissionAndRespondAsync(String requestId, PermissionReques LOG.log(Level.WARNING, "Error sending permission denied for requestId=" + requestId, sendEx); } } - }); + }; + if (executor != null) { + CompletableFuture.runAsync(task, executor); + } else { + CompletableFuture.runAsync(task); + } } /** diff --git a/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java b/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java index 101f68528..9f55938af 100644 --- a/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java +++ b/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java @@ -9,6 +9,7 @@ import java.util.Collections; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; import java.util.logging.Level; import java.util.logging.Logger; @@ -45,6 +46,7 @@ final class RpcHandlerDispatcher { private final Map sessions; private final LifecycleEventDispatcher lifecycleDispatcher; + private final Executor executor; /** * Creates a dispatcher with session registry and lifecycle dispatcher. @@ -53,10 +55,14 @@ final class RpcHandlerDispatcher { * the session registry to look up sessions by ID * @param lifecycleDispatcher * callback for dispatching lifecycle events + * @param executor + * the executor for async dispatch, or {@code null} for default */ - RpcHandlerDispatcher(Map sessions, LifecycleEventDispatcher lifecycleDispatcher) { + RpcHandlerDispatcher(Map sessions, LifecycleEventDispatcher lifecycleDispatcher, + Executor executor) { this.sessions = sessions; this.lifecycleDispatcher = lifecycleDispatcher; + this.executor = executor; } /** @@ -118,7 +124,7 @@ private void handleLifecycleEvent(JsonNode params) { } private void handleToolCall(JsonRpcClient rpc, String requestId, JsonNode params) { - CompletableFuture.runAsync(() -> { + runAsync(() -> { try { String sessionId = params.get("sessionId").asText(); String toolCallId = params.get("toolCallId").asText(); @@ -178,7 +184,7 @@ private void handleToolCall(JsonRpcClient rpc, String requestId, JsonNode params } private void handlePermissionRequest(JsonRpcClient rpc, String requestId, JsonNode params) { - CompletableFuture.runAsync(() -> { + runAsync(() -> { try { String sessionId = params.get("sessionId").asText(); JsonNode permissionRequest = params.get("permissionRequest"); @@ -222,7 +228,7 @@ private void handlePermissionRequest(JsonRpcClient rpc, String requestId, JsonNo private void handleUserInputRequest(JsonRpcClient rpc, String requestId, JsonNode params) { LOG.fine("Received userInput.request: " + params); - CompletableFuture.runAsync(() -> { + runAsync(() -> { try { String sessionId = params.get("sessionId").asText(); String question = params.get("question").asText(); @@ -278,7 +284,7 @@ private void handleUserInputRequest(JsonRpcClient rpc, String requestId, JsonNod } private void handleHooksInvoke(JsonRpcClient rpc, String requestId, JsonNode params) { - CompletableFuture.runAsync(() -> { + runAsync(() -> { try { String sessionId = params.get("sessionId").asText(); String hookType = params.get("hookType").asText(); @@ -321,7 +327,7 @@ interface LifecycleEventDispatcher { } private void handleSystemMessageTransform(JsonRpcClient rpc, String requestId, JsonNode params) { - CompletableFuture.runAsync(() -> { + runAsync(() -> { try { final long requestIdLong; try { @@ -359,4 +365,12 @@ private void handleSystemMessageTransform(JsonRpcClient rpc, String requestId, J } }); } + + private void runAsync(Runnable task) { + if (executor != null) { + CompletableFuture.runAsync(task, executor); + } else { + CompletableFuture.runAsync(task); + } + } } diff --git a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java index 4cdee912c..33d84a9c5 100644 --- a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java +++ b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; import java.util.function.Supplier; import com.fasterxml.jackson.annotation.JsonInclude; @@ -49,6 +50,7 @@ public class CopilotClientOptions { private Boolean useLoggedInUser; private Supplier>> onListModels; private TelemetryConfig telemetry; + private Executor executor; /** * Gets the path to the Copilot CLI executable. @@ -412,6 +414,37 @@ public CopilotClientOptions setTelemetry(TelemetryConfig telemetry) { return this; } + /** + * Gets the executor used for internal asynchronous operations. + * + * @return the executor, or {@code null} to use the default + * {@code ForkJoinPool.commonPool()} + */ + public Executor getExecutor() { + return executor; + } + + /** + * Sets the executor used for internal asynchronous operations. + *

+ * When provided, the SDK uses this executor for all internal + * {@code CompletableFuture} combinators instead of the default + * {@code ForkJoinPool.commonPool()}. This allows callers to isolate SDK + * work onto a dedicated thread pool or integrate with container-managed + * threading. + * + * @param executor + * the executor to use, or {@code null} for the default + * @return this options instance for fluent chaining + */ + public CopilotClientOptions setExecutor(Executor executor) { + if (null == executor) { + throw new IllegalArgumentException("PENDING(copilot): not implemented"); + } + this.executor = executor; + return this; + } + /** * Creates a shallow clone of this {@code CopilotClientOptions} instance. *

@@ -439,6 +472,7 @@ public CopilotClientOptions clone() { copy.useLoggedInUser = this.useLoggedInUser; copy.onListModels = this.onListModels; copy.telemetry = this.telemetry; + copy.executor = this.executor; return copy; } } diff --git a/src/test/java/com/github/copilot/sdk/ExecutorWiringTest.java b/src/test/java/com/github/copilot/sdk/ExecutorWiringTest.java new file mode 100644 index 000000000..736564139 --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/ExecutorWiringTest.java @@ -0,0 +1,368 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.github.copilot.sdk.events.AssistantMessageEvent; +import com.github.copilot.sdk.json.CopilotClientOptions; +import com.github.copilot.sdk.json.MessageOptions; +import com.github.copilot.sdk.json.PermissionHandler; +import com.github.copilot.sdk.json.PermissionRequest; +import com.github.copilot.sdk.json.PermissionRequestResult; +import com.github.copilot.sdk.json.PreToolUseHookOutput; +import com.github.copilot.sdk.json.SessionConfig; +import com.github.copilot.sdk.json.SessionHooks; +import com.github.copilot.sdk.json.ToolDefinition; +import com.github.copilot.sdk.json.UserInputResponse; + +/** + * TDD red-phase tests verifying that when an {@link Executor} is provided via + * {@link CopilotClientOptions#setExecutor(Executor)}, all internal + * {@code CompletableFuture.*Async} calls are routed through that executor + * instead of {@code ForkJoinPool.commonPool()}. + * + *

+ * Uses a {@link TrackingExecutor} decorator that delegates to a real executor + * while counting task submissions. After SDK operations complete, the tests + * assert the decorator was invoked. + *

+ */ +public class ExecutorWiringTest { + + private static E2ETestContext ctx; + + @BeforeAll + static void setup() throws Exception { + ctx = E2ETestContext.create(); + } + + @AfterAll + static void teardown() throws Exception { + if (ctx != null) { + ctx.close(); + } + } + + /** + * A decorator executor that delegates to a real executor while counting + * task submissions. + */ + static class TrackingExecutor implements Executor { + + private final Executor delegate; + private final AtomicInteger taskCount = new AtomicInteger(0); + + TrackingExecutor(Executor delegate) { + this.delegate = delegate; + } + + @Override + public void execute(Runnable command) { + taskCount.incrementAndGet(); + delegate.execute(command); + } + + int getTaskCount() { + return taskCount.get(); + } + } + + private CopilotClientOptions createOptionsWithExecutor(TrackingExecutor executor) { + CopilotClientOptions options = new CopilotClientOptions().setCliPath(ctx.getCliPath()) + .setCwd(ctx.getWorkDir().toString()).setEnvironment(ctx.getEnvironment()).setExecutor(executor); + + String ci = System.getenv("GITHUB_ACTIONS"); + if (ci != null && !ci.isEmpty()) { + options.setGitHubToken("fake-token-for-e2e-tests"); + } + return options; + } + + /** + * Verifies that client start-up routes through the provided executor. + * + *

+ * {@code CopilotClient.startCore()} uses + * {@code CompletableFuture.supplyAsync(...)} to initialize the connection. + * This test asserts that the start-up task goes through the caller-supplied + * executor, not {@code ForkJoinPool.commonPool()}. + *

+ * + * @see Snapshot: tools/invokes_custom_tool + */ + @Test + void testClientStartUsesProvidedExecutor() throws Exception { + ctx.configureForTest("tools", "invokes_custom_tool"); + + TrackingExecutor trackingExecutor = new TrackingExecutor(ForkJoinPool.commonPool()); + int beforeStart = trackingExecutor.getTaskCount(); + + try (CopilotClient client = new CopilotClient(createOptionsWithExecutor(trackingExecutor))) { + client.start().get(30, TimeUnit.SECONDS); + + assertTrue(trackingExecutor.getTaskCount() > beforeStart, + "Expected the tracking executor to have been invoked during client start, " + + "but task count did not increase. CopilotClient.startCore() is not " + + "routing supplyAsync through the provided executor."); + } + } + + /** + * Verifies that tool call dispatch routes through the provided executor. + * + *

+ * When a custom tool is invoked by the LLM, the + * {@code RpcHandlerDispatcher} calls + * {@code CompletableFuture.runAsync(...)} to dispatch the tool handler. + * This test asserts that dispatch goes through the caller-supplied executor. + *

+ * + * @see Snapshot: tools/invokes_custom_tool + */ + @Test + void testToolCallDispatchUsesProvidedExecutor() throws Exception { + ctx.configureForTest("tools", "invokes_custom_tool"); + + TrackingExecutor trackingExecutor = new TrackingExecutor(ForkJoinPool.commonPool()); + + var parameters = new HashMap(); + var properties = new HashMap(); + var inputProp = new HashMap(); + inputProp.put("type", "string"); + inputProp.put("description", "String to encrypt"); + properties.put("input", inputProp); + parameters.put("type", "object"); + parameters.put("properties", properties); + parameters.put("required", List.of("input")); + + ToolDefinition encryptTool = ToolDefinition.create("encrypt_string", "Encrypts a string", parameters, + (invocation) -> { + Map args = invocation.getArguments(); + String input = (String) args.get("input"); + return CompletableFuture.completedFuture(input.toUpperCase()); + }); + + // Reset count after client construction to isolate tool-call dispatch + try (CopilotClient client = new CopilotClient(createOptionsWithExecutor(trackingExecutor))) { + CopilotSession session = client.createSession(new SessionConfig().setTools(List.of(encryptTool)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + + int beforeToolCall = trackingExecutor.getTaskCount(); + + AssistantMessageEvent response = session + .sendAndWait(new MessageOptions().setPrompt("Use encrypt_string to encrypt this string: Hello")) + .get(60, TimeUnit.SECONDS); + + assertNotNull(response); + + assertTrue(trackingExecutor.getTaskCount() > beforeToolCall, + "Expected the tracking executor to have been invoked for tool call dispatch, " + + "but task count did not increase after sendAndWait. " + + "RpcHandlerDispatcher is not routing runAsync through the provided executor."); + + session.close(); + } + } + + /** + * Verifies that permission request dispatch routes through the provided + * executor. + * + *

+ * When the LLM requests a permission, the {@code RpcHandlerDispatcher} + * calls {@code CompletableFuture.runAsync(...)} to dispatch the permission + * handler. This test asserts that dispatch goes through the caller-supplied + * executor. + *

+ * + * @see Snapshot: permissions/permission_handler_for_write_operations + */ + @Test + void testPermissionDispatchUsesProvidedExecutor() throws Exception { + ctx.configureForTest("permissions", "permission_handler_for_write_operations"); + + TrackingExecutor trackingExecutor = new TrackingExecutor(ForkJoinPool.commonPool()); + + var config = new SessionConfig().setOnPermissionRequest((request, invocation) -> CompletableFuture + .completedFuture(new PermissionRequestResult().setKind("approved"))); + + try (CopilotClient client = new CopilotClient(createOptionsWithExecutor(trackingExecutor))) { + CopilotSession session = client.createSession(config).get(); + + Path testFile = ctx.getWorkDir().resolve("test.txt"); + Files.writeString(testFile, "original content"); + + int beforeSend = trackingExecutor.getTaskCount(); + + session.sendAndWait( + new MessageOptions().setPrompt("Edit test.txt and replace 'original' with 'modified'")) + .get(60, TimeUnit.SECONDS); + + assertTrue(trackingExecutor.getTaskCount() > beforeSend, + "Expected the tracking executor to have been invoked for permission dispatch, " + + "but task count did not increase after sendAndWait. " + + "RpcHandlerDispatcher is not routing permission runAsync through the provided executor."); + + session.close(); + } + } + + /** + * Verifies that user input request dispatch routes through the provided + * executor. + * + *

+ * When the LLM asks for user input, the {@code RpcHandlerDispatcher} calls + * {@code CompletableFuture.runAsync(...)} to dispatch the user input + * handler. This test asserts that dispatch goes through the caller-supplied + * executor. + *

+ * + * @see Snapshot: + * ask_user/should_invoke_user_input_handler_when_model_uses_ask_user_tool + */ + @Test + void testUserInputDispatchUsesProvidedExecutor() throws Exception { + ctx.configureForTest("ask_user", "should_invoke_user_input_handler_when_model_uses_ask_user_tool"); + + TrackingExecutor trackingExecutor = new TrackingExecutor(ForkJoinPool.commonPool()); + + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setOnUserInputRequest((request, invocation) -> { + String answer = (request.getChoices() != null && !request.getChoices().isEmpty()) + ? request.getChoices().get(0) + : "freeform answer"; + boolean wasFreeform = request.getChoices() == null || request.getChoices().isEmpty(); + return CompletableFuture + .completedFuture(new UserInputResponse().setAnswer(answer).setWasFreeform(wasFreeform)); + }); + + try (CopilotClient client = new CopilotClient(createOptionsWithExecutor(trackingExecutor))) { + CopilotSession session = client.createSession(config).get(); + + int beforeSend = trackingExecutor.getTaskCount(); + + session.sendAndWait(new MessageOptions().setPrompt( + "Ask me to choose between 'Option A' and 'Option B' using the ask_user tool. Wait for my response before continuing.")) + .get(60, TimeUnit.SECONDS); + + assertTrue(trackingExecutor.getTaskCount() > beforeSend, + "Expected the tracking executor to have been invoked for user input dispatch, " + + "but task count did not increase after sendAndWait. " + + "RpcHandlerDispatcher is not routing userInput runAsync through the provided executor."); + + session.close(); + } + } + + /** + * Verifies that hooks dispatch routes through the provided executor. + * + *

+ * When the LLM triggers a hook, the {@code RpcHandlerDispatcher} calls + * {@code CompletableFuture.runAsync(...)} to dispatch the hooks handler. + * This test asserts that dispatch goes through the caller-supplied executor. + *

+ * + * @see Snapshot: hooks/invoke_pre_tool_use_hook_when_model_runs_a_tool + */ + @Test + void testHooksDispatchUsesProvidedExecutor() throws Exception { + ctx.configureForTest("hooks", "invoke_pre_tool_use_hook_when_model_runs_a_tool"); + + TrackingExecutor trackingExecutor = new TrackingExecutor(ForkJoinPool.commonPool()); + + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setHooks(new SessionHooks().setOnPreToolUse( + (input, invocation) -> CompletableFuture.completedFuture(PreToolUseHookOutput.allow()))); + + try (CopilotClient client = new CopilotClient(createOptionsWithExecutor(trackingExecutor))) { + CopilotSession session = client.createSession(config).get(); + + Path testFile = ctx.getWorkDir().resolve("hello.txt"); + Files.writeString(testFile, "Hello from the test!"); + + int beforeSend = trackingExecutor.getTaskCount(); + + session.sendAndWait( + new MessageOptions().setPrompt("Read the contents of hello.txt and tell me what it says")) + .get(60, TimeUnit.SECONDS); + + assertTrue(trackingExecutor.getTaskCount() > beforeSend, + "Expected the tracking executor to have been invoked for hooks dispatch, " + + "but task count did not increase after sendAndWait. " + + "RpcHandlerDispatcher is not routing hooks runAsync through the provided executor."); + + session.close(); + } + } + + /** + * Verifies that {@code CopilotClient.stop()} routes session closure through + * the provided executor. + * + *

+ * {@code CopilotClient.stop()} uses + * {@code CompletableFuture.runAsync(...)} to close each active session. + * This test asserts that those closures go through the caller-supplied + * executor. + *

+ * + * @see Snapshot: tools/invokes_custom_tool + */ + @Test + void testClientStopUsesProvidedExecutor() throws Exception { + ctx.configureForTest("tools", "invokes_custom_tool"); + + TrackingExecutor trackingExecutor = new TrackingExecutor(ForkJoinPool.commonPool()); + + var parameters = new HashMap(); + var properties = new HashMap(); + var inputProp = new HashMap(); + inputProp.put("type", "string"); + inputProp.put("description", "String to encrypt"); + properties.put("input", inputProp); + parameters.put("type", "object"); + parameters.put("properties", properties); + parameters.put("required", List.of("input")); + + ToolDefinition encryptTool = ToolDefinition.create("encrypt_string", "Encrypts a string", parameters, + (invocation) -> { + Map args = invocation.getArguments(); + String input = (String) args.get("input"); + return CompletableFuture.completedFuture(input.toUpperCase()); + }); + + CopilotClient client = new CopilotClient(createOptionsWithExecutor(trackingExecutor)); + client.createSession(new SessionConfig().setTools(List.of(encryptTool)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + + int beforeStop = trackingExecutor.getTaskCount(); + + // stop() should use the provided executor for async session closure + client.stop().get(30, TimeUnit.SECONDS); + + assertTrue(trackingExecutor.getTaskCount() > beforeStop, + "Expected the tracking executor to have been invoked during client stop, " + + "but task count did not increase. CopilotClient.stop() is not " + + "routing session closure runAsync through the provided executor."); + } +} diff --git a/src/test/java/com/github/copilot/sdk/RpcHandlerDispatcherTest.java b/src/test/java/com/github/copilot/sdk/RpcHandlerDispatcherTest.java index 61ad4dadd..79f5d7c7e 100644 --- a/src/test/java/com/github/copilot/sdk/RpcHandlerDispatcherTest.java +++ b/src/test/java/com/github/copilot/sdk/RpcHandlerDispatcherTest.java @@ -66,7 +66,7 @@ void setup() throws Exception { sessions = new ConcurrentHashMap<>(); lifecycleEvents = new CopyOnWriteArrayList<>(); - dispatcher = new RpcHandlerDispatcher(sessions, lifecycleEvents::add); + dispatcher = new RpcHandlerDispatcher(sessions, lifecycleEvents::add, null); dispatcher.registerHandlers(rpc); // Extract the registered handlers via reflection so we can invoke them directly From 31803bcb7dfcc34c0b75a8411abc5208ebaa1f7d Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Mon, 30 Mar 2026 16:59:43 -0400 Subject: [PATCH 17/69] On branch edburns/dd-2758695-virtual-threads-accept-executor modified: src/main/java/com/github/copilot/sdk/CopilotClient.java - Spotless. modified: src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java - Remove stub from TDD red phase. modified: src/site/markdown/cookbook/multiple-sessions.md - Document new feature. modified: src/test/java/com/github/copilot/sdk/ExecutorWiringTest.java - Update test documentation. Signed-off-by: Ed Burns --- .../com/github/copilot/sdk/CopilotClient.java | 8 +-- .../sdk/json/CopilotClientOptions.java | 8 +-- .../markdown/cookbook/multiple-sessions.md | 63 +++++++++++++++++++ .../copilot/sdk/ExecutorWiringTest.java | 44 ++++++------- 4 files changed, 87 insertions(+), 36 deletions(-) diff --git a/src/main/java/com/github/copilot/sdk/CopilotClient.java b/src/main/java/com/github/copilot/sdk/CopilotClient.java index 00dea3876..285b5fbdc 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -188,8 +188,7 @@ private Connection startCoreBody() { } catch (Exception e) { String stderr = serverManager.getStderrOutput(); if (!stderr.isEmpty()) { - throw new CompletionException( - new IOException("CLI process exited unexpectedly. stderr: " + stderr, e)); + throw new CompletionException(new IOException("CLI process exited unexpectedly. stderr: " + stderr, e)); } throw new CompletionException(e); } @@ -245,9 +244,8 @@ public CompletableFuture stop() { LOG.log(Level.WARNING, "Error closing session " + session.getSessionId(), e); } }; - closeFutures.add(exec != null - ? CompletableFuture.runAsync(closeTask, exec) - : CompletableFuture.runAsync(closeTask)); + closeFutures.add( + exec != null ? CompletableFuture.runAsync(closeTask, exec) : CompletableFuture.runAsync(closeTask)); } sessions.clear(); diff --git a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java index 33d84a9c5..af8667864 100644 --- a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java +++ b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java @@ -429,18 +429,14 @@ public Executor getExecutor() { *

* When provided, the SDK uses this executor for all internal * {@code CompletableFuture} combinators instead of the default - * {@code ForkJoinPool.commonPool()}. This allows callers to isolate SDK - * work onto a dedicated thread pool or integrate with container-managed - * threading. + * {@code ForkJoinPool.commonPool()}. This allows callers to isolate SDK work + * onto a dedicated thread pool or integrate with container-managed threading. * * @param executor * the executor to use, or {@code null} for the default * @return this options instance for fluent chaining */ public CopilotClientOptions setExecutor(Executor executor) { - if (null == executor) { - throw new IllegalArgumentException("PENDING(copilot): not implemented"); - } this.executor = executor; return this; } diff --git a/src/site/markdown/cookbook/multiple-sessions.md b/src/site/markdown/cookbook/multiple-sessions.md index 94a83acae..0121bfc4c 100644 --- a/src/site/markdown/cookbook/multiple-sessions.md +++ b/src/site/markdown/cookbook/multiple-sessions.md @@ -164,6 +164,69 @@ public class ParallelSessions { } ``` +## Providing a custom Executor for parallel sessions + +By default, `CompletableFuture` operations run on `ForkJoinPool.commonPool()`, +which has limited parallelism (typically `Runtime.availableProcessors() - 1` +threads). When multiple sessions block waiting for CLI responses, those threads +are unavailable for other work—a condition known as *pool starvation*. + +Use `CopilotClientOptions.setExecutor(Executor)` to supply a dedicated thread +pool so that SDK work does not compete with the rest of your application for +common-pool threads: + +```java +//DEPS com.github:copilot-sdk-java:${project.version} +import com.github.copilot.sdk.CopilotClient; +import com.github.copilot.sdk.json.CopilotClientOptions; +import com.github.copilot.sdk.json.SessionConfig; +import com.github.copilot.sdk.json.MessageOptions; +import com.github.copilot.sdk.json.PermissionHandler; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class ParallelSessionsWithExecutor { + public static void main(String[] args) throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(4); + try { + var options = new CopilotClientOptions().setExecutor(pool); + try (CopilotClient client = new CopilotClient(options)) { + client.start().get(); + + var s1 = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setModel("gpt-5")).get(); + var s2 = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setModel("gpt-5")).get(); + var s3 = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setModel("claude-sonnet-4.5")).get(); + + CompletableFuture.allOf( + s1.sendAndWait(new MessageOptions().setPrompt("Question 1")), + s2.sendAndWait(new MessageOptions().setPrompt("Question 2")), + s3.sendAndWait(new MessageOptions().setPrompt("Question 3")) + ).get(); + + s1.close(); + s2.close(); + s3.close(); + } + } finally { + pool.shutdown(); + } + } +} +``` + +Passing `null` (or omitting `setExecutor` entirely) keeps the default +`ForkJoinPool.commonPool()` behaviour. The executor is used for all internal +`CompletableFuture.runAsync` / `supplyAsync` calls—including client start/stop, +tool-call dispatch, permission dispatch, user-input dispatch, and hooks. + ## Use cases - **Multi-user applications**: One session per user diff --git a/src/test/java/com/github/copilot/sdk/ExecutorWiringTest.java b/src/test/java/com/github/copilot/sdk/ExecutorWiringTest.java index 736564139..db5b1aaf4 100644 --- a/src/test/java/com/github/copilot/sdk/ExecutorWiringTest.java +++ b/src/test/java/com/github/copilot/sdk/ExecutorWiringTest.java @@ -25,7 +25,6 @@ import com.github.copilot.sdk.json.CopilotClientOptions; import com.github.copilot.sdk.json.MessageOptions; import com.github.copilot.sdk.json.PermissionHandler; -import com.github.copilot.sdk.json.PermissionRequest; import com.github.copilot.sdk.json.PermissionRequestResult; import com.github.copilot.sdk.json.PreToolUseHookOutput; import com.github.copilot.sdk.json.SessionConfig; @@ -62,8 +61,8 @@ static void teardown() throws Exception { } /** - * A decorator executor that delegates to a real executor while counting - * task submissions. + * A decorator executor that delegates to a real executor while counting task + * submissions. */ static class TrackingExecutor implements Executor { @@ -101,8 +100,8 @@ private CopilotClientOptions createOptionsWithExecutor(TrackingExecutor executor * *

* {@code CopilotClient.startCore()} uses - * {@code CompletableFuture.supplyAsync(...)} to initialize the connection. - * This test asserts that the start-up task goes through the caller-supplied + * {@code CompletableFuture.supplyAsync(...)} to initialize the connection. This + * test asserts that the start-up task goes through the caller-supplied * executor, not {@code ForkJoinPool.commonPool()}. *

* @@ -129,9 +128,8 @@ void testClientStartUsesProvidedExecutor() throws Exception { * Verifies that tool call dispatch routes through the provided executor. * *

- * When a custom tool is invoked by the LLM, the - * {@code RpcHandlerDispatcher} calls - * {@code CompletableFuture.runAsync(...)} to dispatch the tool handler. + * When a custom tool is invoked by the LLM, the {@code RpcHandlerDispatcher} + * calls {@code CompletableFuture.runAsync(...)} to dispatch the tool handler. * This test asserts that dispatch goes through the caller-supplied executor. *

* @@ -187,10 +185,9 @@ void testToolCallDispatchUsesProvidedExecutor() throws Exception { * executor. * *

- * When the LLM requests a permission, the {@code RpcHandlerDispatcher} - * calls {@code CompletableFuture.runAsync(...)} to dispatch the permission - * handler. This test asserts that dispatch goes through the caller-supplied - * executor. + * When the LLM requests a permission, the {@code RpcHandlerDispatcher} calls + * {@code CompletableFuture.runAsync(...)} to dispatch the permission handler. + * This test asserts that dispatch goes through the caller-supplied executor. *

* * @see Snapshot: permissions/permission_handler_for_write_operations @@ -212,8 +209,7 @@ void testPermissionDispatchUsesProvidedExecutor() throws Exception { int beforeSend = trackingExecutor.getTaskCount(); - session.sendAndWait( - new MessageOptions().setPrompt("Edit test.txt and replace 'original' with 'modified'")) + session.sendAndWait(new MessageOptions().setPrompt("Edit test.txt and replace 'original' with 'modified'")) .get(60, TimeUnit.SECONDS); assertTrue(trackingExecutor.getTaskCount() > beforeSend, @@ -231,9 +227,8 @@ void testPermissionDispatchUsesProvidedExecutor() throws Exception { * *

* When the LLM asks for user input, the {@code RpcHandlerDispatcher} calls - * {@code CompletableFuture.runAsync(...)} to dispatch the user input - * handler. This test asserts that dispatch goes through the caller-supplied - * executor. + * {@code CompletableFuture.runAsync(...)} to dispatch the user input handler. + * This test asserts that dispatch goes through the caller-supplied executor. *

* * @see Snapshot: @@ -278,8 +273,8 @@ void testUserInputDispatchUsesProvidedExecutor() throws Exception { * *

* When the LLM triggers a hook, the {@code RpcHandlerDispatcher} calls - * {@code CompletableFuture.runAsync(...)} to dispatch the hooks handler. - * This test asserts that dispatch goes through the caller-supplied executor. + * {@code CompletableFuture.runAsync(...)} to dispatch the hooks handler. This + * test asserts that dispatch goes through the caller-supplied executor. *

* * @see Snapshot: hooks/invoke_pre_tool_use_hook_when_model_runs_a_tool @@ -316,14 +311,13 @@ void testHooksDispatchUsesProvidedExecutor() throws Exception { } /** - * Verifies that {@code CopilotClient.stop()} routes session closure through - * the provided executor. + * Verifies that {@code CopilotClient.stop()} routes session closure through the + * provided executor. * *

- * {@code CopilotClient.stop()} uses - * {@code CompletableFuture.runAsync(...)} to close each active session. - * This test asserts that those closures go through the caller-supplied - * executor. + * {@code CopilotClient.stop()} uses {@code CompletableFuture.runAsync(...)} to + * close each active session. This test asserts that those closures go through + * the caller-supplied executor. *

* * @see Snapshot: tools/invokes_custom_tool From e62485739e973ce899cbf57396a43ad4cfc47be0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:19:56 +0000 Subject: [PATCH 18/69] Remove "TDD red-phase" from ExecutorWiringTest Javadoc Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/93199b25-7c90-4c45-9540-527396b8990c Co-authored-by: edburns <75821+edburns@users.noreply.github.com> --- src/test/java/com/github/copilot/sdk/ExecutorWiringTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/github/copilot/sdk/ExecutorWiringTest.java b/src/test/java/com/github/copilot/sdk/ExecutorWiringTest.java index db5b1aaf4..15904504a 100644 --- a/src/test/java/com/github/copilot/sdk/ExecutorWiringTest.java +++ b/src/test/java/com/github/copilot/sdk/ExecutorWiringTest.java @@ -33,7 +33,7 @@ import com.github.copilot.sdk.json.UserInputResponse; /** - * TDD red-phase tests verifying that when an {@link Executor} is provided via + * Tests verifying that when an {@link Executor} is provided via * {@link CopilotClientOptions#setExecutor(Executor)}, all internal * {@code CompletableFuture.*Async} calls are routed through that executor * instead of {@code ForkJoinPool.commonPool()}. From e975d6cf420547ecbc89ce1ae6a7cdf518e6e330 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:53:47 +0000 Subject: [PATCH 19/69] Handle RejectedExecutionException in all async submission sites Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/63b9b09f-f1f4-44d3-8e34-ad01e355cc6a Co-authored-by: edburns <75821+edburns@users.noreply.github.com> --- .../com/github/copilot/sdk/CopilotClient.java | 24 +++++++++++++---- .../github/copilot/sdk/CopilotSession.java | 27 +++++++++++++------ .../copilot/sdk/RpcHandlerDispatcher.java | 14 +++++++--- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/github/copilot/sdk/CopilotClient.java b/src/main/java/com/github/copilot/sdk/CopilotClient.java index 285b5fbdc..e2790f6a3 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -14,6 +14,7 @@ import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; @@ -152,9 +153,13 @@ private CompletableFuture startCore() { LOG.fine("Starting Copilot client"); Executor exec = options.getExecutor(); - return exec != null - ? CompletableFuture.supplyAsync(this::startCoreBody, exec) - : CompletableFuture.supplyAsync(this::startCoreBody); + try { + return exec != null + ? CompletableFuture.supplyAsync(this::startCoreBody, exec) + : CompletableFuture.supplyAsync(this::startCoreBody); + } catch (RejectedExecutionException e) { + return CompletableFuture.failedFuture(e); + } } private Connection startCoreBody() { @@ -244,8 +249,17 @@ public CompletableFuture stop() { LOG.log(Level.WARNING, "Error closing session " + session.getSessionId(), e); } }; - closeFutures.add( - exec != null ? CompletableFuture.runAsync(closeTask, exec) : CompletableFuture.runAsync(closeTask)); + CompletableFuture future; + try { + future = exec != null + ? CompletableFuture.runAsync(closeTask, exec) + : CompletableFuture.runAsync(closeTask); + } catch (RejectedExecutionException e) { + LOG.log(Level.WARNING, "Executor rejected session close task; closing inline", e); + closeTask.run(); + future = CompletableFuture.completedFuture(null); + } + closeFutures.add(future); } sessions.clear(); diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 485c39312..23b54ed2d 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -730,10 +730,15 @@ private void executeToolAndRespondAsync(String requestId, String toolName, Strin } } }; - if (executor != null) { - CompletableFuture.runAsync(task, executor); - } else { - CompletableFuture.runAsync(task); + try { + if (executor != null) { + CompletableFuture.runAsync(task, executor); + } else { + CompletableFuture.runAsync(task); + } + } catch (RejectedExecutionException e) { + LOG.log(Level.WARNING, "Executor rejected tool task for requestId=" + requestId + "; running inline", e); + task.run(); } } @@ -783,10 +788,16 @@ private void executePermissionAndRespondAsync(String requestId, PermissionReques } } }; - if (executor != null) { - CompletableFuture.runAsync(task, executor); - } else { - CompletableFuture.runAsync(task); + try { + if (executor != null) { + CompletableFuture.runAsync(task, executor); + } else { + CompletableFuture.runAsync(task); + } + } catch (RejectedExecutionException e) { + LOG.log(Level.WARNING, "Executor rejected permission task for requestId=" + requestId + "; running inline", + e); + task.run(); } } diff --git a/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java b/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java index 9f55938af..f1d488105 100644 --- a/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java +++ b/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java @@ -10,6 +10,7 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; import java.util.logging.Level; import java.util.logging.Logger; @@ -367,10 +368,15 @@ private void handleSystemMessageTransform(JsonRpcClient rpc, String requestId, J } private void runAsync(Runnable task) { - if (executor != null) { - CompletableFuture.runAsync(task, executor); - } else { - CompletableFuture.runAsync(task); + try { + if (executor != null) { + CompletableFuture.runAsync(task, executor); + } else { + CompletableFuture.runAsync(task); + } + } catch (RejectedExecutionException e) { + LOG.log(Level.WARNING, "Executor rejected handler task; running inline", e); + task.run(); } } } From 63b6d31bd8d6bc516fcfe5f44144728fe1053dd2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:56:13 +0000 Subject: [PATCH 20/69] Align RejectedExecutionException log message formatting in CopilotSession Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/63b9b09f-f1f4-44d3-8e34-ad01e355cc6a Co-authored-by: edburns <75821+edburns@users.noreply.github.com> --- src/main/java/com/github/copilot/sdk/CopilotSession.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 23b54ed2d..523da1114 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -795,8 +795,7 @@ private void executePermissionAndRespondAsync(String requestId, PermissionReques CompletableFuture.runAsync(task); } } catch (RejectedExecutionException e) { - LOG.log(Level.WARNING, "Executor rejected permission task for requestId=" + requestId + "; running inline", - e); + LOG.log(Level.WARNING, "Executor rejected perm task for requestId=" + requestId + "; running inline", e); task.run(); } } From 7728b4f65bc396eddcd55a776ef27730c0a1d9b4 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Mon, 30 Mar 2026 19:17:00 -0400 Subject: [PATCH 21/69] On branch edburns/dd-2758695-virtual-threads-accept-executor modified: README.md - Use the "uncomment these three lines to get Virtual Threads" approach modified: src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java - Cleanup. Sorting. Signed-off-by: Ed Burns --- README.md | 9 +- .../sdk/json/CopilotClientOptions.java | 342 +++++++++--------- 2 files changed, 179 insertions(+), 172 deletions(-) diff --git a/README.md b/README.md index 0084eb417..84f0d6d81 100644 --- a/README.md +++ b/README.md @@ -69,16 +69,23 @@ implementation 'com.github:copilot-sdk-java:0.2.1-java.0' import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.events.SessionUsageInfoEvent; +import com.github.copilot.sdk.json.CopilotClientOptions; import com.github.copilot.sdk.json.MessageOptions; import com.github.copilot.sdk.json.PermissionHandler; import com.github.copilot.sdk.json.SessionConfig; +import java.util.concurrent.Executors; + public class CopilotSDK { public static void main(String[] args) throws Exception { var lastMessage = new String[]{null}; // Create and start client - try (var client = new CopilotClient()) { + try (var client = new CopilotClient()) { // JDK 25+: comment out this line + // JDK 25+: uncomment the following 3 lines for virtual thread support + // var options = new CopilotClientOptions() + // .setExecutor(Executors.newVirtualThreadPerTaskExecutor()); + // try (var client = new CopilotClient(options)) { client.start().get(); // Create a session diff --git a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java index af8667864..3ba9fb0a0 100644 --- a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java +++ b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java @@ -35,133 +35,115 @@ @JsonInclude(JsonInclude.Include.NON_NULL) public class CopilotClientOptions { - private String cliPath; - private String[] cliArgs; - private String cwd; - private int port; - private boolean useStdio = true; - private String cliUrl; - private String logLevel = "info"; - private boolean autoStart = true; @Deprecated private boolean autoRestart; + private boolean autoStart = true; + private String[] cliArgs; + private String cliPath; + private String cliUrl; + private String cwd; private Map environment; + private Executor executor; private String gitHubToken; - private Boolean useLoggedInUser; + private String logLevel = "info"; private Supplier>> onListModels; + private int port; private TelemetryConfig telemetry; - private Executor executor; - - /** - * Gets the path to the Copilot CLI executable. - * - * @return the CLI path, or {@code null} to use "copilot" from PATH - */ - public String getCliPath() { - return cliPath; - } - - /** - * Sets the path to the Copilot CLI executable. - * - * @param cliPath - * the path to the CLI executable, or {@code null} to use "copilot" - * from PATH - * @return this options instance for method chaining - */ - public CopilotClientOptions setCliPath(String cliPath) { - this.cliPath = cliPath; - return this; - } + private Boolean useLoggedInUser; + private boolean useStdio = true; /** - * Gets the extra CLI arguments. + * Returns whether the client should automatically restart the server on crash. * - * @return the extra arguments to pass to the CLI + * @return the auto-restart flag value (no longer has any effect) + * @deprecated This option has no effect and will be removed in a future + * release. */ - public String[] getCliArgs() { - return cliArgs; + @Deprecated + public boolean isAutoRestart() { + return autoRestart; } /** - * Sets extra arguments to pass to the CLI process. - *

- * These arguments are prepended before SDK-managed flags. + * Sets whether the client should automatically restart the CLI server if it + * crashes unexpectedly. * - * @param cliArgs - * the extra arguments to pass + * @param autoRestart + * ignored — this option no longer has any effect * @return this options instance for method chaining + * @deprecated This option has no effect and will be removed in a future + * release. */ - public CopilotClientOptions setCliArgs(String[] cliArgs) { - this.cliArgs = cliArgs; + @Deprecated + public CopilotClientOptions setAutoRestart(boolean autoRestart) { + this.autoRestart = autoRestart; return this; } /** - * Gets the working directory for the CLI process. + * Returns whether the client should automatically start the server. * - * @return the working directory path + * @return {@code true} to auto-start (default), {@code false} for manual start */ - public String getCwd() { - return cwd; + public boolean isAutoStart() { + return autoStart; } /** - * Sets the working directory for the CLI process. + * Sets whether the client should automatically start the CLI server when the + * first request is made. * - * @param cwd - * the working directory path + * @param autoStart + * {@code true} to auto-start, {@code false} for manual start * @return this options instance for method chaining */ - public CopilotClientOptions setCwd(String cwd) { - this.cwd = cwd; + public CopilotClientOptions setAutoStart(boolean autoStart) { + this.autoStart = autoStart; return this; } /** - * Gets the TCP port for the CLI server. + * Gets the extra CLI arguments. * - * @return the port number, or 0 for a random port + * @return the extra arguments to pass to the CLI */ - public int getPort() { - return port; + public String[] getCliArgs() { + return cliArgs; } /** - * Sets the TCP port for the CLI server to listen on. + * Sets extra arguments to pass to the CLI process. *

- * This is only used when {@link #isUseStdio()} is {@code false}. + * These arguments are prepended before SDK-managed flags. * - * @param port - * the port number, or 0 for a random port + * @param cliArgs + * the extra arguments to pass * @return this options instance for method chaining */ - public CopilotClientOptions setPort(int port) { - this.port = port; + public CopilotClientOptions setCliArgs(String[] cliArgs) { + this.cliArgs = cliArgs; return this; } /** - * Returns whether to use stdio transport instead of TCP. + * Gets the path to the Copilot CLI executable. * - * @return {@code true} to use stdio (default), {@code false} to use TCP + * @return the CLI path, or {@code null} to use "copilot" from PATH */ - public boolean isUseStdio() { - return useStdio; + public String getCliPath() { + return cliPath; } /** - * Sets whether to use stdio transport instead of TCP. - *

- * Stdio transport is more efficient and is the default. TCP transport can be - * useful for debugging or connecting to remote servers. + * Sets the path to the Copilot CLI executable. * - * @param useStdio - * {@code true} to use stdio, {@code false} to use TCP + * @param cliPath + * the path to the CLI executable, or {@code null} to use "copilot" + * from PATH * @return this options instance for method chaining */ - public CopilotClientOptions setUseStdio(boolean useStdio) { - this.useStdio = useStdio; + public CopilotClientOptions setCliPath(String cliPath) { + this.cliPath = cliPath; return this; } @@ -193,98 +175,73 @@ public CopilotClientOptions setCliUrl(String cliUrl) { } /** - * Gets the log level for the CLI process. - * - * @return the log level (default: "info") - */ - public String getLogLevel() { - return logLevel; - } - - /** - * Sets the log level for the CLI process. - *

- * Valid levels include: "error", "warn", "info", "debug", "trace". - * - * @param logLevel - * the log level - * @return this options instance for method chaining - */ - public CopilotClientOptions setLogLevel(String logLevel) { - this.logLevel = logLevel; - return this; - } - - /** - * Returns whether the client should automatically start the server. + * Gets the working directory for the CLI process. * - * @return {@code true} to auto-start (default), {@code false} for manual start + * @return the working directory path */ - public boolean isAutoStart() { - return autoStart; + public String getCwd() { + return cwd; } /** - * Sets whether the client should automatically start the CLI server when the - * first request is made. + * Sets the working directory for the CLI process. * - * @param autoStart - * {@code true} to auto-start, {@code false} for manual start + * @param cwd + * the working directory path * @return this options instance for method chaining */ - public CopilotClientOptions setAutoStart(boolean autoStart) { - this.autoStart = autoStart; + public CopilotClientOptions setCwd(String cwd) { + this.cwd = cwd; return this; } /** - * Returns whether the client should automatically restart the server on crash. + * Gets the environment variables for the CLI process. * - * @return the auto-restart flag value (no longer has any effect) - * @deprecated This option has no effect and will be removed in a future - * release. + * @return the environment variables map */ - @Deprecated - public boolean isAutoRestart() { - return autoRestart; + public Map getEnvironment() { + return environment; } /** - * Sets whether the client should automatically restart the CLI server if it - * crashes unexpectedly. + * Sets environment variables to pass to the CLI process. + *

+ * When set, these environment variables replace the inherited environment. * - * @param autoRestart - * ignored — this option no longer has any effect + * @param environment + * the environment variables map * @return this options instance for method chaining - * @deprecated This option has no effect and will be removed in a future - * release. */ - @Deprecated - public CopilotClientOptions setAutoRestart(boolean autoRestart) { - this.autoRestart = autoRestart; + public CopilotClientOptions setEnvironment(Map environment) { + this.environment = environment; return this; } /** - * Gets the environment variables for the CLI process. + * Gets the executor used for internal asynchronous operations. * - * @return the environment variables map + * @return the executor, or {@code null} to use the default + * {@code ForkJoinPool.commonPool()} */ - public Map getEnvironment() { - return environment; + public Executor getExecutor() { + return executor; } /** - * Sets environment variables to pass to the CLI process. + * Sets the executor used for internal asynchronous operations. *

- * When set, these environment variables replace the inherited environment. + * When provided, the SDK uses this executor for all internal + * {@code CompletableFuture} combinators instead of the default + * {@code ForkJoinPool.commonPool()}. This allows callers to isolate SDK work + * onto a dedicated thread pool or integrate with container-managed threading. * - * @param environment - * the environment variables map - * @return this options instance for method chaining + * @param executor + * the executor to use, or {@code null} for the default + * @return this options instance for fluent chaining */ - public CopilotClientOptions setEnvironment(Map environment) { - this.environment = environment; + public CopilotClientOptions setExecutor(Executor executor) { + this.executor = executor; return this; } @@ -338,28 +295,25 @@ public CopilotClientOptions setGithubToken(String githubToken) { } /** - * Returns whether to use the logged-in user for authentication. + * Gets the log level for the CLI process. * - * @return {@code true} to use logged-in user auth, {@code false} to use only - * explicit tokens, or {@code null} to use default behavior + * @return the log level (default: "info") */ - public Boolean getUseLoggedInUser() { - return useLoggedInUser; + public String getLogLevel() { + return logLevel; } /** - * Sets whether to use the logged-in user for authentication. + * Sets the log level for the CLI process. *

- * When true, the CLI server will attempt to use stored OAuth tokens or gh CLI - * auth. When false, only explicit tokens (gitHubToken or environment variables) - * are used. Default: true (but defaults to false when gitHubToken is provided). + * Valid levels include: "error", "warn", "info", "debug", "trace". * - * @param useLoggedInUser - * {@code true} to use logged-in user auth, {@code false} otherwise + * @param logLevel + * the log level * @return this options instance for method chaining */ - public CopilotClientOptions setUseLoggedInUser(Boolean useLoggedInUser) { - this.useLoggedInUser = useLoggedInUser; + public CopilotClientOptions setLogLevel(String logLevel) { + this.logLevel = logLevel; return this; } @@ -388,6 +342,29 @@ public CopilotClientOptions setOnListModels(Supplier + * This is only used when {@link #isUseStdio()} is {@code false}. + * + * @param port + * the port number, or 0 for a random port + * @return this options instance for method chaining + */ + public CopilotClientOptions setPort(int port) { + this.port = port; + return this; + } + /** * Gets the OpenTelemetry configuration for the CLI server. * @@ -415,29 +392,52 @@ public CopilotClientOptions setTelemetry(TelemetryConfig telemetry) { } /** - * Gets the executor used for internal asynchronous operations. + * Returns whether to use the logged-in user for authentication. * - * @return the executor, or {@code null} to use the default - * {@code ForkJoinPool.commonPool()} + * @return {@code true} to use logged-in user auth, {@code false} to use only + * explicit tokens, or {@code null} to use default behavior */ - public Executor getExecutor() { - return executor; + public Boolean getUseLoggedInUser() { + return useLoggedInUser; } /** - * Sets the executor used for internal asynchronous operations. + * Sets whether to use the logged-in user for authentication. *

- * When provided, the SDK uses this executor for all internal - * {@code CompletableFuture} combinators instead of the default - * {@code ForkJoinPool.commonPool()}. This allows callers to isolate SDK work - * onto a dedicated thread pool or integrate with container-managed threading. + * When true, the CLI server will attempt to use stored OAuth tokens or gh CLI + * auth. When false, only explicit tokens (gitHubToken or environment variables) + * are used. Default: true (but defaults to false when gitHubToken is provided). * - * @param executor - * the executor to use, or {@code null} for the default - * @return this options instance for fluent chaining + * @param useLoggedInUser + * {@code true} to use logged-in user auth, {@code false} otherwise + * @return this options instance for method chaining */ - public CopilotClientOptions setExecutor(Executor executor) { - this.executor = executor; + public CopilotClientOptions setUseLoggedInUser(Boolean useLoggedInUser) { + this.useLoggedInUser = useLoggedInUser; + return this; + } + + /** + * Returns whether to use stdio transport instead of TCP. + * + * @return {@code true} to use stdio (default), {@code false} to use TCP + */ + public boolean isUseStdio() { + return useStdio; + } + + /** + * Sets whether to use stdio transport instead of TCP. + *

+ * Stdio transport is more efficient and is the default. TCP transport can be + * useful for debugging or connecting to remote servers. + * + * @param useStdio + * {@code true} to use stdio, {@code false} to use TCP + * @return this options instance for method chaining + */ + public CopilotClientOptions setUseStdio(boolean useStdio) { + this.useStdio = useStdio; return this; } @@ -454,21 +454,21 @@ public CopilotClientOptions setExecutor(Executor executor) { @Override public CopilotClientOptions clone() { CopilotClientOptions copy = new CopilotClientOptions(); - copy.cliPath = this.cliPath; + copy.autoRestart = this.autoRestart; + copy.autoStart = this.autoStart; copy.cliArgs = this.cliArgs != null ? this.cliArgs.clone() : null; - copy.cwd = this.cwd; - copy.port = this.port; - copy.useStdio = this.useStdio; + copy.cliPath = this.cliPath; copy.cliUrl = this.cliUrl; - copy.logLevel = this.logLevel; - copy.autoStart = this.autoStart; - copy.autoRestart = this.autoRestart; + copy.cwd = this.cwd; copy.environment = this.environment != null ? new java.util.HashMap<>(this.environment) : null; + copy.executor = this.executor; copy.gitHubToken = this.gitHubToken; - copy.useLoggedInUser = this.useLoggedInUser; + copy.logLevel = this.logLevel; copy.onListModels = this.onListModels; + copy.port = this.port; copy.telemetry = this.telemetry; - copy.executor = this.executor; + copy.useLoggedInUser = this.useLoggedInUser; + copy.useStdio = this.useStdio; return copy; } } From 1721ffc78104f7988ef8e85adb282f09e2c72f04 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:42:11 +0000 Subject: [PATCH 22/69] Add null checks to all reference-type setters in CopilotClientOptions Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/019cc9a8-a29a-49a5-a7ac-aa573931dfb8 Co-authored-by: edburns <75821+edburns@users.noreply.github.com> --- .../sdk/json/CopilotClientOptions.java | 32 +++++++++---------- .../copilot/sdk/CliServerManagerTest.java | 4 +-- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java index 3ba9fb0a0..ecba8ce82 100644 --- a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java +++ b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; import java.util.function.Supplier; @@ -121,7 +122,7 @@ public String[] getCliArgs() { * @return this options instance for method chaining */ public CopilotClientOptions setCliArgs(String[] cliArgs) { - this.cliArgs = cliArgs; + this.cliArgs = Objects.requireNonNull(cliArgs, "cliArgs must not be null"); return this; } @@ -138,12 +139,11 @@ public String getCliPath() { * Sets the path to the Copilot CLI executable. * * @param cliPath - * the path to the CLI executable, or {@code null} to use "copilot" - * from PATH + * the path to the CLI executable * @return this options instance for method chaining */ public CopilotClientOptions setCliPath(String cliPath) { - this.cliPath = cliPath; + this.cliPath = Objects.requireNonNull(cliPath, "cliPath must not be null"); return this; } @@ -170,7 +170,7 @@ public String getCliUrl() { * @return this options instance for method chaining */ public CopilotClientOptions setCliUrl(String cliUrl) { - this.cliUrl = cliUrl; + this.cliUrl = Objects.requireNonNull(cliUrl, "cliUrl must not be null"); return this; } @@ -191,7 +191,7 @@ public String getCwd() { * @return this options instance for method chaining */ public CopilotClientOptions setCwd(String cwd) { - this.cwd = cwd; + this.cwd = Objects.requireNonNull(cwd, "cwd must not be null"); return this; } @@ -214,7 +214,7 @@ public Map getEnvironment() { * @return this options instance for method chaining */ public CopilotClientOptions setEnvironment(Map environment) { - this.environment = environment; + this.environment = Objects.requireNonNull(environment, "environment must not be null"); return this; } @@ -237,11 +237,11 @@ public Executor getExecutor() { * onto a dedicated thread pool or integrate with container-managed threading. * * @param executor - * the executor to use, or {@code null} for the default + * the executor to use * @return this options instance for fluent chaining */ public CopilotClientOptions setExecutor(Executor executor) { - this.executor = executor; + this.executor = Objects.requireNonNull(executor, "executor must not be null"); return this; } @@ -265,7 +265,7 @@ public String getGitHubToken() { * @return this options instance for method chaining */ public CopilotClientOptions setGitHubToken(String gitHubToken) { - this.gitHubToken = gitHubToken; + this.gitHubToken = Objects.requireNonNull(gitHubToken, "gitHubToken must not be null"); return this; } @@ -290,7 +290,7 @@ public String getGithubToken() { */ @Deprecated public CopilotClientOptions setGithubToken(String githubToken) { - this.gitHubToken = githubToken; + this.gitHubToken = Objects.requireNonNull(githubToken, "githubToken must not be null"); return this; } @@ -313,7 +313,7 @@ public String getLogLevel() { * @return this options instance for method chaining */ public CopilotClientOptions setLogLevel(String logLevel) { - this.logLevel = logLevel; + this.logLevel = Objects.requireNonNull(logLevel, "logLevel must not be null"); return this; } @@ -338,7 +338,7 @@ public Supplier>> getOnListModels() { * @return this options instance for method chaining */ public CopilotClientOptions setOnListModels(Supplier>> onListModels) { - this.onListModels = onListModels; + this.onListModels = Objects.requireNonNull(onListModels, "onListModels must not be null"); return this; } @@ -378,8 +378,8 @@ public TelemetryConfig getTelemetry() { /** * Sets the OpenTelemetry configuration for the CLI server. *

- * When set to a non-{@code null} value, the CLI server is started with - * OpenTelemetry instrumentation enabled using the provided settings. + * When set, the CLI server is started with OpenTelemetry instrumentation + * enabled using the provided settings. * * @param telemetry * the telemetry configuration @@ -387,7 +387,7 @@ public TelemetryConfig getTelemetry() { * @since 1.2.0 */ public CopilotClientOptions setTelemetry(TelemetryConfig telemetry) { - this.telemetry = telemetry; + this.telemetry = Objects.requireNonNull(telemetry, "telemetry must not be null"); return this; } diff --git a/src/test/java/com/github/copilot/sdk/CliServerManagerTest.java b/src/test/java/com/github/copilot/sdk/CliServerManagerTest.java index 86d6be875..f17201583 100644 --- a/src/test/java/com/github/copilot/sdk/CliServerManagerTest.java +++ b/src/test/java/com/github/copilot/sdk/CliServerManagerTest.java @@ -199,8 +199,8 @@ void startCliServerWithGitHubTokenAndNoExplicitUseLoggedInUser() throws Exceptio @Test void startCliServerWithNullCliPath() throws Exception { - // Test the null cliPath branch (defaults to "copilot") - var options = new CopilotClientOptions().setCliPath(null).setUseStdio(true); + // Test the default cliPath branch (defaults to "copilot" when not set) + var options = new CopilotClientOptions().setUseStdio(true); var manager = new CliServerManager(options); // "copilot" likely doesn't exist in the test env — that's fine From 11aa5d65e300f50e510d6f484e72754d00d873e0 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Tue, 31 Mar 2026 14:10:44 -0400 Subject: [PATCH 23/69] On branch edburns/dd-2758695-virtual-threads-accept-executor modified: src/main/java/com/github/copilot/sdk/CopilotSession.java modified: src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java - Spotless apply. - Sync javadoc to behavior. --- .../github/copilot/sdk/CopilotSession.java | 1 - .../sdk/json/CopilotClientOptions.java | 73 ++++++++++++++----- 2 files changed, 56 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 523da1114..844737fc2 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -14,7 +14,6 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; -import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; diff --git a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java index ecba8ce82..9470cd05f 100644 --- a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java +++ b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java @@ -4,6 +4,8 @@ package com.github.copilot.sdk.json; +import java.util.Arrays; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -105,24 +107,35 @@ public CopilotClientOptions setAutoStart(boolean autoStart) { /** * Gets the extra CLI arguments. + *

+ * Returns a shallow copy of the internal array, or {@code null} if no arguments + * have been set. * - * @return the extra arguments to pass to the CLI + * @return a copy of the extra arguments, or {@code null} */ public String[] getCliArgs() { - return cliArgs; + return cliArgs != null ? Arrays.copyOf(cliArgs, cliArgs.length) : null; } /** * Sets extra arguments to pass to the CLI process. *

- * These arguments are prepended before SDK-managed flags. + * These arguments are prepended before SDK-managed flags. A shallow copy of the + * provided array is stored. If {@code null} or empty, the existing arguments + * are cleared. * * @param cliArgs - * the extra arguments to pass + * the extra arguments to pass, or {@code null}/empty to clear * @return this options instance for method chaining */ public CopilotClientOptions setCliArgs(String[] cliArgs) { - this.cliArgs = Objects.requireNonNull(cliArgs, "cliArgs must not be null"); + if (cliArgs == null || cliArgs.length == 0) { + if (this.cliArgs != null) { + this.cliArgs = new String[0]; + } + } else { + this.cliArgs = Arrays.copyOf(cliArgs, cliArgs.length); + } return this; } @@ -166,8 +179,11 @@ public String getCliUrl() { * {@link #setUseStdio(boolean)} and {@link #setCliPath(String)}. * * @param cliUrl - * the CLI server URL to connect to + * the CLI server URL to connect to (must not be {@code null} or + * empty) * @return this options instance for method chaining + * @throws IllegalArgumentException + * if {@code cliUrl} is {@code null} or empty */ public CopilotClientOptions setCliUrl(String cliUrl) { this.cliUrl = Objects.requireNonNull(cliUrl, "cliUrl must not be null"); @@ -187,8 +203,10 @@ public String getCwd() { * Sets the working directory for the CLI process. * * @param cwd - * the working directory path + * the working directory path (must not be {@code null} or empty) * @return this options instance for method chaining + * @throws IllegalArgumentException + * if {@code cwd} is {@code null} or empty */ public CopilotClientOptions setCwd(String cwd) { this.cwd = Objects.requireNonNull(cwd, "cwd must not be null"); @@ -197,24 +215,35 @@ public CopilotClientOptions setCwd(String cwd) { /** * Gets the environment variables for the CLI process. + *

+ * Returns a shallow copy of the internal map, or {@code null} if no environment + * has been set. * - * @return the environment variables map + * @return a copy of the environment variables map, or {@code null} */ public Map getEnvironment() { - return environment; + return environment != null ? new HashMap<>(environment) : null; } /** * Sets environment variables to pass to the CLI process. *

- * When set, these environment variables replace the inherited environment. + * When set, these environment variables replace the inherited environment. A + * shallow copy of the provided map is stored. If {@code null} or empty, the + * existing environment is cleared. * * @param environment - * the environment variables map + * the environment variables map, or {@code null}/empty to clear * @return this options instance for method chaining */ public CopilotClientOptions setEnvironment(Map environment) { - this.environment = Objects.requireNonNull(environment, "environment must not be null"); + if (environment == null || environment.isEmpty()) { + if (this.environment != null) { + this.environment.clear(); + } + } else { + this.environment = new HashMap<>(environment); + } return this; } @@ -261,8 +290,10 @@ public String getGitHubToken() { * variable. This takes priority over other authentication methods. * * @param gitHubToken - * the GitHub token + * the GitHub token (must not be {@code null} or empty) * @return this options instance for method chaining + * @throws IllegalArgumentException + * if {@code gitHubToken} is {@code null} or empty */ public CopilotClientOptions setGitHubToken(String gitHubToken) { this.gitHubToken = Objects.requireNonNull(gitHubToken, "gitHubToken must not be null"); @@ -309,8 +340,10 @@ public String getLogLevel() { * Valid levels include: "error", "warn", "info", "debug", "trace". * * @param logLevel - * the log level + * the log level (must not be {@code null} or empty) * @return this options instance for method chaining + * @throws IllegalArgumentException + * if {@code logLevel} is {@code null} or empty */ public CopilotClientOptions setLogLevel(String logLevel) { this.logLevel = Objects.requireNonNull(logLevel, "logLevel must not be null"); @@ -334,8 +367,11 @@ public Supplier>> getOnListModels() { * available from your custom provider. * * @param onListModels - * the handler that returns the list of available models + * the handler that returns the list of available models (must not be + * {@code null}) * @return this options instance for method chaining + * @throws IllegalArgumentException + * if {@code onListModels} is {@code null} */ public CopilotClientOptions setOnListModels(Supplier>> onListModels) { this.onListModels = Objects.requireNonNull(onListModels, "onListModels must not be null"); @@ -407,13 +443,16 @@ public Boolean getUseLoggedInUser() { * When true, the CLI server will attempt to use stored OAuth tokens or gh CLI * auth. When false, only explicit tokens (gitHubToken or environment variables) * are used. Default: true (but defaults to false when gitHubToken is provided). + *

+ * Passing {@code null} is equivalent to passing {@link Boolean#FALSE}. * * @param useLoggedInUser - * {@code true} to use logged-in user auth, {@code false} otherwise + * {@code true} to use logged-in user auth, {@code false} or + * {@code null} otherwise * @return this options instance for method chaining */ public CopilotClientOptions setUseLoggedInUser(Boolean useLoggedInUser) { - this.useLoggedInUser = useLoggedInUser; + this.useLoggedInUser = useLoggedInUser != null ? useLoggedInUser : Boolean.FALSE; return this; } From 4fdef27df41fba977ffff7bfb58ed1e296b07299 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Tue, 31 Mar 2026 14:31:33 -0400 Subject: [PATCH 24/69] On branch edburns/dd-2758695-virtual-threads-accept-executor modified: src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java - Correctly implement the semantic of "null argument to setExecutor means use the default executor." modified: src/test/java/com/github/copilot/sdk/ConfigCloneTest.java - Adjust test based on defensive copy changes. --- .../sdk/json/CopilotClientOptions.java | 7 +++++-- .../github/copilot/sdk/ConfigCloneTest.java | 19 ++++++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java index 9470cd05f..2e9a80456 100644 --- a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java +++ b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java @@ -264,13 +264,16 @@ public Executor getExecutor() { * {@code CompletableFuture} combinators instead of the default * {@code ForkJoinPool.commonPool()}. This allows callers to isolate SDK work * onto a dedicated thread pool or integrate with container-managed threading. + *

+ * Passing {@code null} reverts to the default {@code ForkJoinPool.commonPool()} + * behavior. * * @param executor - * the executor to use + * the executor to use, or {@code null} for the default * @return this options instance for fluent chaining */ public CopilotClientOptions setExecutor(Executor executor) { - this.executor = Objects.requireNonNull(executor, "executor must not be null"); + this.executor = executor; return this; } diff --git a/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java b/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java index f3eceb4c2..bf4881d5c 100644 --- a/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java +++ b/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java @@ -49,10 +49,16 @@ void copilotClientOptionsArrayIndependence() { original.setCliArgs(args); CopilotClientOptions cloned = original.clone(); - cloned.getCliArgs()[0] = "--changed"; + // Mutate the source array after set — should not affect original or clone + args[0] = "--changed"; + + assertEquals("--flag1", original.getCliArgs()[0]); + assertEquals("--flag1", cloned.getCliArgs()[0]); + + // getCliArgs() returns a copy, so mutating it should not affect internals + original.getCliArgs()[0] = "--mutated"; assertEquals("--flag1", original.getCliArgs()[0]); - assertEquals("--changed", cloned.getCliArgs()[0]); } @Test @@ -64,12 +70,15 @@ void copilotClientOptionsEnvironmentIndependence() { CopilotClientOptions cloned = original.clone(); - // Mutate the original environment map to test independence + // Mutate the source map after set — should not affect original or clone env.put("KEY2", "value2"); - // The cloned config should be unaffected by mutations to the original map + assertEquals(1, original.getEnvironment().size()); assertEquals(1, cloned.getEnvironment().size()); - assertEquals(2, original.getEnvironment().size()); + + // getEnvironment() returns a copy, so mutating it should not affect internals + original.getEnvironment().put("KEY3", "value3"); + assertEquals(1, original.getEnvironment().size()); } @Test From 49d56f3ca3205c4154040688d8eccd148269e5e3 Mon Sep 17 00:00:00 2001 From: edburns <75821+edburns@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:55:26 +0000 Subject: [PATCH 25/69] Update JaCoCo coverage badge --- .github/badges/jacoco.svg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 19da90d2e..482adb13b 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -12,7 +12,7 @@ coverage coverage - 85% - 85% + 84.7% + 84.7% From f8e59c8addcf72cce26dd6e0ae74198b707b5c8d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:02:43 +0000 Subject: [PATCH 26/69] Initial plan From afc34c439dd3e393840fe3c213ccc4d7b12fed39 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Tue, 31 Mar 2026 16:09:48 -0400 Subject: [PATCH 27/69] Revert "Update .github/workflows/copilot-setup-steps.yml" This reverts commit dc71acbd18e0da9816c5f548c802213456e90b63. --- .github/workflows/copilot-setup-steps.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 145629457..8d8aa75c9 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -41,7 +41,7 @@ jobs: distribution: 'temurin' cache: 'maven' - # Enable repository pre-commit hooks (including Spotless checks for relevant source changes) + # Enable pre-commit hooks so Spotless formatting is enforced on every commit - name: Enable pre-commit hooks run: git config core.hooksPath .githooks From 05d06d97f398a092897725a7651af10f96a047da Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Tue, 31 Mar 2026 16:09:52 -0400 Subject: [PATCH 28/69] Revert "On branch edburns/spotless-agentic-workflow-42" This reverts commit 2ba6e1dc1083fd5fade553fcf515a0d17aeba349. --- .github/copilot-instructions.md | 12 ------------ .github/workflows/copilot-setup-steps.yml | 6 ------ 2 files changed, 18 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e3a8eb275..d7dafb081 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -244,18 +244,6 @@ This SDK is designed to be **lightweight with minimal dependencies**: 5. Check for security vulnerabilities 6. Get team approval for non-trivial additions -## Pre-commit Hooks and Formatting (Coding Agent) - -The repository has a pre-commit hook (`.githooks/pre-commit`) that is **automatically enabled** in the Copilot coding agent environment via `copilot-setup-steps.yml`. The hook runs `mvn spotless:check` on any commit that includes changes under `src/`. - -**If a commit fails due to the pre-commit hook:** - -1. Run `mvn spotless:apply` to auto-fix formatting issues. -2. Re-stage the changed files with `git add -u`. -3. Retry the commit. - -**Best practice:** Always run `mvn spotless:apply` before committing Java source changes to avoid hook failures in the first place. If you forget and the hook rejects the commit, follow the three steps above and continue. - ## Commit and PR Guidelines ### Commit Messages diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 8d8aa75c9..6a0cdec5b 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -41,10 +41,6 @@ jobs: distribution: 'temurin' cache: 'maven' - # Enable pre-commit hooks so Spotless formatting is enforced on every commit - - name: Enable pre-commit hooks - run: git config core.hooksPath .githooks - # Verify installations - name: Verify tool installations run: | @@ -54,6 +50,4 @@ jobs: java -version gh --version gh aw version - echo "--- Git hooks path ---" - git config core.hooksPath echo "✅ All tools installed successfully" From 9b14893d92b70061e77aa5b59b07f93d22f9dcdb Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Tue, 31 Mar 2026 16:16:28 -0400 Subject: [PATCH 29/69] Add JDK 25 smoke test job with virtual thread support - Rename existing smoke-test job to smoke-test-jdk17 - Add smoke-test-java25 job using Microsoft JDK 25 - JDK 25 job instructs agent to follow // JDK 25+: marker comments in README Quick Start to enable virtual threads Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/run-smoke-test.yml | 56 +++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-smoke-test.yml b/.github/workflows/run-smoke-test.yml index d5181e038..8d0feac0c 100644 --- a/.github/workflows/run-smoke-test.yml +++ b/.github/workflows/run-smoke-test.yml @@ -11,8 +11,8 @@ permissions: contents: read jobs: - smoke-test: - name: Build SDK and run smoke test + smoke-test-jdk17: + name: Build SDK and run smoke test (JDK 17) runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' steps: @@ -59,3 +59,55 @@ jobs: cd smoke-test java -jar ./target/copilot-sdk-smoketest-1.0-SNAPSHOT.jar echo "Smoke test passed (exit code 0)" + + smoke-test-java25: + name: Build SDK and run smoke test (JDK 25) + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up JDK 25 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + java-version: "25" + distribution: "microsoft" + cache: "maven" + + - uses: ./.github/actions/setup-copilot + + - name: Build SDK and install to local repo + run: mvn -DskipTests -Pskip-test-harness clean install + + - name: Create and run smoke test via Copilot CLI + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + run: | + cat > /tmp/smoke-test-prompt.txt << 'PROMPT_EOF' + You are running inside the copilot-sdk-java repository. + The SDK has already been built and installed into the local Maven repository. + JDK 25 and Maven are already installed and on PATH. + + Execute the prompt at `src/test/prompts/PROMPT-smoke-test.md` with the following critical overrides: + + **Critical override — disable SNAPSHOT updates (but allow downloads):** The goal of this workflow is to validate the SDK SNAPSHOT that was just built and installed locally, not any newer SNAPSHOT that might exist in a remote repository. To ensure Maven does not download a newer timestamped SNAPSHOT of the SDK while still allowing it to download any missing plugins or dependencies, you must run the smoke-test Maven build without `-U` and with `--no-snapshot-updates`, so that it uses the locally installed SDK artifact. Use `mvn --no-snapshot-updates clean package` instead of `mvn -U clean package` or `mvn -o clean package`. + + **Critical override — do NOT run the jar:** Stop after the `mvn --no-snapshot-updates clean package` build succeeds. Do NOT execute Step 4 (java -jar) or Step 5 (verify exit code) from the prompt. The workflow will run the jar in a separate deterministic step to guarantee the exit code propagates correctly. + + **Critical override — enable Virtual Threads for JDK 25:** After creating the Java source file from the README "Quick Start" section but BEFORE building, you must modify the source file to enable virtual thread support. The Quick Start code contains inline comments that start with `// JDK 25+:` — these are instructions. Find every such comment and follow what it says (comment out lines it says to comment out, uncomment lines it says to uncomment). Add any imports required by the newly uncommented code (e.g. `java.util.concurrent.Executors`). + Also set `maven.compiler.source` and `maven.compiler.target` to `25` in the `pom.xml`. + + Follow steps 1-3 only: create the `smoke-test/` directory, create `pom.xml` and the Java source file exactly as specified, apply the JDK 25 virtual thread modifications described above, and build with `mvn --no-snapshot-updates clean package` (no SNAPSHOT updates and without `-U`). + + If any step fails, exit with a non-zero exit code. Do not silently fix errors. + PROMPT_EOF + + copilot --yolo --prompt "$(cat /tmp/smoke-test-prompt.txt)" + + - name: Run smoke test jar + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + run: | + cd smoke-test + java -jar ./target/copilot-sdk-smoketest-1.0-SNAPSHOT.jar + echo "Smoke test passed (exit code 0)" From 2108ece372154f02c47a5c40af15b2d18ead2648 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Thu, 2 Apr 2026 12:06:09 -0400 Subject: [PATCH 30/69] On branch main Prepare to release 0.2.1-java.1 modified: pom.xml --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index eee587375..c83d7ff9b 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ com.github copilot-sdk-java - 0.2.2-java.0-SNAPSHOT + 0.2.1-java.1-SNAPSHOT jar GitHub Copilot SDK :: Java From 8c2071597bf769b635790388fcea6e977d6b2302 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Thu, 2 Apr 2026 12:15:27 -0400 Subject: [PATCH 31/69] On branch main Document JDK 25 recommended level. 17 is still minimum. modified: README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 84f0d6d81..c081349cf 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build A ### Requirements -- Java 17 or later -- GitHub Copilot CLI 0.0.411-1 or later installed and in PATH (or provide custom `cliPath`) +- Java 17 or later. **JDK 25 recommended**. Selecting JDK 25 enables the use of virtual threads, as shown in the [Quick Start](#quick-start). +- GitHub Copilot 1.0.15-0 or later installed and in `PATH` (or provide custom `cliPath`) ### Maven From 3ca5671f0cc4b336195ca97321478136d39f408a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 2 Apr 2026 16:21:22 +0000 Subject: [PATCH 32/69] docs: update version references to 0.2.1-java.1 --- CHANGELOG.md | 14 +++++++++++--- README.md | 4 ++-- src/site/markdown/cookbook/error-handling.md | 14 +++++++------- src/site/markdown/cookbook/managing-local-files.md | 4 ++-- src/site/markdown/cookbook/multiple-sessions.md | 4 ++-- src/site/markdown/cookbook/persisting-sessions.md | 6 +++--- src/site/markdown/cookbook/pr-visualization.md | 2 +- 7 files changed, 28 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c54038b9c..e306db097 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). > **Upstream sync:** [`github/copilot-sdk@4088739`](https://github.com/github/copilot-sdk/commit/40887393a9e687dacc141a645799441b0313ff15) +## [0.2.1-java.1] - 2026-04-02 + +> **Upstream sync:** [`github/copilot-sdk@4088739`](https://github.com/github/copilot-sdk/commit/40887393a9e687dacc141a645799441b0313ff15) ## [0.2.1-java.0] - 2026-03-26 > **Upstream sync:** [`github/copilot-sdk@4088739`](https://github.com/github/copilot-sdk/commit/40887393a9e687dacc141a645799441b0313ff15) @@ -462,12 +465,17 @@ New types: `GetForegroundSessionResponse`, `SetForegroundSessionResponse` - Pre-commit hook for Spotless code formatting - Comprehensive API documentation -[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.0...HEAD +[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.1...HEAD +[0.2.1-java.1]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.0...v0.2.1-java.1 +[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.1...HEAD +[0.2.1-java.1]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.0...v0.2.1-java.1 [0.2.1-java.0]: https://github.com/github/copilot-sdk-java/compare/v0.1.32-java.0...v0.2.1-java.0 -[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.0...HEAD +[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.1...HEAD +[0.2.1-java.1]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.0...v0.2.1-java.1 [0.2.1-java.0]: https://github.com/github/copilot-sdk-java/compare/v0.1.32-java.0...v0.2.1-java.0 [0.1.32-java.0]: https://github.com/github/copilot-sdk-java/compare/v1.0.11...v0.1.32-java.0 -[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.0...HEAD +[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.1...HEAD +[0.2.1-java.1]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.0...v0.2.1-java.1 [0.2.1-java.0]: https://github.com/github/copilot-sdk-java/compare/v0.1.32-java.0...v0.2.1-java.0 [0.1.32-java.0]: https://github.com/github/copilot-sdk-java/compare/v1.0.11...v0.1.32-java.0 [1.0.11]: https://github.com/github/copilot-sdk-java/compare/v1.0.10...v1.0.11 diff --git a/README.md b/README.md index c081349cf..3010b6839 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build A com.github copilot-sdk-java - 0.2.1-java.0 + 0.2.1-java.1 ``` @@ -60,7 +60,7 @@ Snapshot builds of the next development version are published to Maven Central S ### Gradle ```groovy -implementation 'com.github:copilot-sdk-java:0.2.1-java.0' +implementation 'com.github:copilot-sdk-java:0.2.1-java.1' ``` ## Quick Start diff --git a/src/site/markdown/cookbook/error-handling.md b/src/site/markdown/cookbook/error-handling.md index d085ecd91..5ee5ef2ca 100644 --- a/src/site/markdown/cookbook/error-handling.md +++ b/src/site/markdown/cookbook/error-handling.md @@ -30,7 +30,7 @@ jbang BasicErrorHandling.java **Code:** ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.0 +//DEPS com.github:copilot-sdk-java:0.2.1-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.json.MessageOptions; @@ -64,7 +64,7 @@ public class BasicErrorHandling { ## Handling specific error types ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.0 +//DEPS com.github:copilot-sdk-java:0.2.1-java.1 import com.github.copilot.sdk.CopilotClient; import java.util.concurrent.ExecutionException; @@ -99,7 +99,7 @@ public class SpecificErrorHandling { ## Timeout handling ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.0 +//DEPS com.github:copilot-sdk-java:0.2.1-java.1 import com.github.copilot.sdk.CopilotSession; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.json.MessageOptions; @@ -130,7 +130,7 @@ public class TimeoutHandling { ## Aborting a request ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.0 +//DEPS com.github:copilot-sdk-java:0.2.1-java.1 import com.github.copilot.sdk.CopilotSession; import com.github.copilot.sdk.json.MessageOptions; import java.util.concurrent.Executors; @@ -162,7 +162,7 @@ public class AbortRequest { ## Graceful shutdown ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.0 +//DEPS com.github:copilot-sdk-java:0.2.1-java.1 import com.github.copilot.sdk.CopilotClient; public class GracefulShutdown { @@ -192,7 +192,7 @@ public class GracefulShutdown { ## Try-with-resources pattern ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.0 +//DEPS com.github:copilot-sdk-java:0.2.1-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.json.MessageOptions; @@ -224,7 +224,7 @@ public class TryWithResources { ## Handling tool errors ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.0 +//DEPS com.github:copilot-sdk-java:0.2.1-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.json.MessageOptions; diff --git a/src/site/markdown/cookbook/managing-local-files.md b/src/site/markdown/cookbook/managing-local-files.md index 4c0622928..aa9ba23bc 100644 --- a/src/site/markdown/cookbook/managing-local-files.md +++ b/src/site/markdown/cookbook/managing-local-files.md @@ -34,7 +34,7 @@ jbang ManagingLocalFiles.java **Code:** ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.0 +//DEPS com.github:copilot-sdk-java:0.2.1-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.events.SessionIdleEvent; @@ -161,7 +161,7 @@ session.send(new MessageOptions().setPrompt(prompt)); ## Interactive file organization ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.0 +//DEPS com.github:copilot-sdk-java:0.2.1-java.1 import java.io.BufferedReader; import java.io.InputStreamReader; diff --git a/src/site/markdown/cookbook/multiple-sessions.md b/src/site/markdown/cookbook/multiple-sessions.md index 0121bfc4c..fe5c2f0d9 100644 --- a/src/site/markdown/cookbook/multiple-sessions.md +++ b/src/site/markdown/cookbook/multiple-sessions.md @@ -30,7 +30,7 @@ jbang MultipleSessions.java **Code:** ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.0 +//DEPS com.github:copilot-sdk-java:0.2.1-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.json.MessageOptions; @@ -123,7 +123,7 @@ try { ## Managing session lifecycle with CompletableFuture ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.0 +//DEPS com.github:copilot-sdk-java:0.2.1-java.1 import java.util.concurrent.CompletableFuture; import java.util.List; diff --git a/src/site/markdown/cookbook/persisting-sessions.md b/src/site/markdown/cookbook/persisting-sessions.md index 213959ce6..e3fd11b13 100644 --- a/src/site/markdown/cookbook/persisting-sessions.md +++ b/src/site/markdown/cookbook/persisting-sessions.md @@ -30,7 +30,7 @@ jbang PersistingSessions.java **Code:** ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.0 +//DEPS com.github:copilot-sdk-java:0.2.1-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.json.MessageOptions; @@ -127,7 +127,7 @@ public class DeleteSession { ## Getting session history ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.0 +//DEPS com.github:copilot-sdk-java:0.2.1-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.events.UserMessageEvent; @@ -162,7 +162,7 @@ public class SessionHistory { ## Complete example with session management ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.0 +//DEPS com.github:copilot-sdk-java:0.2.1-java.1 import java.util.Scanner; public class SessionManager { diff --git a/src/site/markdown/cookbook/pr-visualization.md b/src/site/markdown/cookbook/pr-visualization.md index 77b6631b8..dbd240a40 100644 --- a/src/site/markdown/cookbook/pr-visualization.md +++ b/src/site/markdown/cookbook/pr-visualization.md @@ -34,7 +34,7 @@ jbang PRVisualization.java github/copilot-sdk ## Full example: PRVisualization.java ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.0 +//DEPS com.github:copilot-sdk-java:0.2.1-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.events.ToolExecutionStartEvent; From 64dbfc0f9d23e2d3498057315980c0dca9faed2b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 2 Apr 2026 16:21:43 +0000 Subject: [PATCH 33/69] [maven-release-plugin] prepare release v0.2.1-java.1 --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index c83d7ff9b..43e1b436d 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ com.github copilot-sdk-java - 0.2.1-java.1-SNAPSHOT + 0.2.1-java.1 jar GitHub Copilot SDK :: Java @@ -33,7 +33,7 @@ scm:git:https://github.com/github/copilot-sdk-java.git scm:git:https://github.com/github/copilot-sdk-java.git https://github.com/github/copilot-sdk-java - HEAD + v0.2.1-java.1 From 828d30010eb72f48a3340e524d09356104f91cc8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 2 Apr 2026 16:21:45 +0000 Subject: [PATCH 34/69] [maven-release-plugin] prepare for next development iteration --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 43e1b436d..45404d99a 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ com.github copilot-sdk-java - 0.2.1-java.1 + 0.2.2-java.1-SNAPSHOT jar GitHub Copilot SDK :: Java @@ -33,7 +33,7 @@ scm:git:https://github.com/github/copilot-sdk-java.git scm:git:https://github.com/github/copilot-sdk-java.git https://github.com/github/copilot-sdk-java - v0.2.1-java.1 + HEAD From 3862fdd97a20a6e763efa7873ce426ae45bc4bf4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:29:22 +0000 Subject: [PATCH 35/69] Initial plan From be757e1421924626969ca7dc4df9bd9796aae923 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:36:18 +0000 Subject: [PATCH 36/69] Fix weekly upstream sync agentic workflow prompt Rewrite the prompt from documentation-style to clear imperative instructions. The agent previously misinterpreted the documentation as instructions to perform the merge itself (invoking the agentic-merge-upstream skill), then failed because it couldn't push code from the sandbox. The new prompt: - Gives explicit step-by-step instructions - Provides bash commands for upstream change detection - Directs the agent to use safe-output tools (create_issue, close_issue, assign_to_agent, noop) - Explicitly prohibits invoking skills, editing files, or pushing code Only the markdown content changed; frontmatter is unchanged so no lock file recompilation is needed. Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/945e9454-9363-45bc-b281-0fe3d6c87019 Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- .github/workflows/weekly-upstream-sync.md | 134 ++++++++-------------- 1 file changed, 50 insertions(+), 84 deletions(-) diff --git a/.github/workflows/weekly-upstream-sync.md b/.github/workflows/weekly-upstream-sync.md index 641f29301..0aaff8d1e 100644 --- a/.github/workflows/weekly-upstream-sync.md +++ b/.github/workflows/weekly-upstream-sync.md @@ -41,111 +41,77 @@ safe-outputs: noop: report-as-issue: false --- -# Weekly Upstream Sync Agentic Workflow -This document describes the `weekly-upstream-sync.yml` GitHub Actions workflow, which automates the detection of new changes in the official [Copilot SDK](https://github.com/github/copilot-sdk) and delegates the merge work to the Copilot coding agent. +# Weekly Upstream Sync -## Overview +You are an automation agent that detects new upstream changes and creates GitHub issues. You do **NOT** perform any code merges, edits, or pushes. Do **NOT** invoke any skills (especially `agentic-merge-upstream`). Your only job is to check for changes and use safe-output tools to create or close issues. -The workflow runs on a **weekly schedule** (every Monday at 10:00 UTC) and can also be triggered manually. It does **not** perform the actual merge — instead, it detects upstream changes and creates a GitHub issue assigned to `copilot`, instructing the agent to follow the [agentic-merge-upstream](../prompts/agentic-merge-upstream.prompt.md) prompt to port the changes. +## Instructions -The agent must also create the Pull Request with the label `upstream-sync`. This allows the workflow to track the merge progress and avoid creating duplicate issues if the agent is still working on a previous sync. +Follow these steps exactly: -## Trigger +### Step 1: Read `.lastmerge` -| Trigger | Schedule | -|---|---| -| `schedule` | Every Monday at 10:00 UTC (`0 10 * * 1`) | -| `workflow_dispatch` | Manual trigger from the Actions tab | +Read the file `.lastmerge` in the repository root. It contains the SHA of the last upstream commit that was merged into this Java SDK. -## Workflow Steps +### Step 2: Check for upstream changes -### 1. Checkout repository +Clone the upstream repository and compare commits: -Checks out the repo to read the `.lastmerge` file, which contains the SHA of the last upstream commit that was merged into the Java SDK. - -### 2. Check for upstream changes - -- Reads the last merged commit hash from `.lastmerge` -- Clones the upstream `github/copilot-sdk` repository -- Compares `.lastmerge` against upstream `HEAD` -- If they match: sets `has_changes=false` -- If they differ: counts new commits, generates a summary (up to 20 most recent), and sets outputs (`commit_count`, `upstream_head`, `last_merge`, `summary`) - -### 3. Close previous upstream-sync issues (when changes found) - -**Condition:** `has_changes == true` +```bash +LAST_MERGE=$(cat .lastmerge) +git clone --quiet https://github.com/github/copilot-sdk.git /tmp/gh-aw/agent/upstream +cd /tmp/gh-aw/agent/upstream +UPSTREAM_HEAD=$(git rev-parse HEAD) +``` -Before creating a new issue, closes any existing open issues with the `upstream-sync` label. This prevents stale issues from accumulating when previous sync attempts were incomplete or superseded. Each closed issue receives a comment explaining it was superseded. +If `LAST_MERGE` equals `UPSTREAM_HEAD`, there are **no new changes**. Go to Step 3a. -### 4. Close stale upstream-sync issues (when no changes found) +If they differ, count the new commits and generate a summary: -**Condition:** `has_changes == false` +```bash +COMMIT_COUNT=$(git rev-list --count "$LAST_MERGE".."$UPSTREAM_HEAD") +SUMMARY=$(git log --oneline "$LAST_MERGE".."$UPSTREAM_HEAD" | head -20) +``` -If the upstream is already up to date, closes any lingering open `upstream-sync` issues with a comment noting that no changes were detected. This handles the case where a previous issue was created but the changes were merged manually (updating `.lastmerge`) before the agent completed. +Go to Step 3b. -### 5. Create issue and assign to Copilot +### Step 3a: No changes detected -**Condition:** `has_changes == true` +1. Search for any open issues with the `upstream-sync` label using the GitHub MCP tools. +2. If there are open `upstream-sync` issues, close each one using the `close_issue` safe-output tool with a comment: "No new upstream changes detected. The Java SDK is up to date. Closing." +3. Call the `noop` safe-output tool with message: "No new upstream changes since last merge ()." +4. **Stop here.** Do not proceed further. -Creates a new GitHub issue with: +### Step 3b: Changes detected -- **Title:** `Upstream sync: N new commits (YYYY-MM-DD)` -- **Label:** `upstream-sync` -- **Assignee:** `copilot` -- **Body:** Contains commit count, commit range links, a summary of recent commits, and a link to the merge prompt +1. Search for any open issues with the `upstream-sync` label using the GitHub MCP tools. +2. Close each existing open `upstream-sync` issue using the `close_issue` safe-output tool with a comment: "Superseded by a newer upstream sync check." +3. Create a new issue using the `create_issue` safe-output tool with: + - **Title:** `Upstream sync: new commits ()` + - **Body:** Include the following information: + ``` + ## Automated Upstream Sync -The Copilot coding agent picks up the issue, creates a branch and PR, then follows the merge prompt to port the changes. + There are **** new commits in the [official Copilot SDK](https://github.com/github/copilot-sdk) since the last merge. -### 6. Summary + - **Last merged commit:** [``](https://github.com/github/copilot-sdk/commit/) + - **Upstream HEAD:** [``](https://github.com/github/copilot-sdk/commit/) -Writes a GitHub Actions step summary with: + ### Recent upstream commits -- Whether changes were detected -- Commit count and range -- Recent upstream commits -- Link to the created issue (if any) + ``` +

+ ``` -## Flow Diagram + ### Instructions -``` -┌─────────────────────┐ -│ Schedule / Manual │ -└──────────┬──────────┘ - │ - ▼ -┌─────────────────────┐ -│ Read .lastmerge │ -│ Clone upstream SDK │ -│ Compare commits │ -└──────────┬──────────┘ - │ - ┌─────┴─────┐ - │ │ - changes? no changes - │ │ - ▼ ▼ -┌──────────┐ ┌──────────────────┐ -│ Close old│ │ Close stale │ -│ issues │ │ issues │ -└────┬─────┘ └──────────────────┘ - │ - ▼ -┌──────────────────────────┐ -│ Create issue assigned to │ -│ copilot │ -└──────────────────────────┘ - │ - ▼ -┌──────────────────────────┐ -│ Agent follows prompt to │ -│ port changes → PR │ -└──────────────────────────┘ -``` + Follow the [agentic-merge-upstream](.github/prompts/agentic-merge-upstream.prompt.md) prompt to port these changes to the Java SDK. + ``` +4. After creating the issue, use the `assign_to_agent` safe-output tool to assign Copilot to the newly created issue. -## Related Files +## Important constraints -| File | Purpose | -|---|---| -| `.lastmerge` | Stores the SHA of the last merged upstream commit | -| [agentic-merge-upstream.prompt.md](../prompts/agentic-merge-upstream.prompt.md) | Detailed instructions the Copilot agent follows to port changes | -| `.github/scripts/upstream-sync/` | Helper scripts used by the merge prompt | +- **Do NOT edit any files**, create branches, or push code. +- **Do NOT invoke any skills** such as `agentic-merge-upstream` or `commit-as-pull-request`. +- **Do NOT attempt to merge or port upstream changes.** That is done by a separate agent that picks up the issue you create. +- You **MUST** call at least one safe-output tool (`create_issue`, `close_issue`, `noop`, etc.) before finishing. From 844f3cf399af5f45279ed2cd6b8110e84bfe7a7b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:24:14 +0000 Subject: [PATCH 37/69] Initial plan From cc84fab663d4809e3592ff21193bdd9cf8964cf0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:46:28 +0000 Subject: [PATCH 38/69] Port Commands, Elicitation, and Capabilities features from upstream Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/16d575c4-83f4-4d20-99d9-b48635b3791d Co-authored-by: edburns <75821+edburns@users.noreply.github.com> --- .../com/github/copilot/sdk/CopilotClient.java | 31 ++ .../github/copilot/sdk/CopilotSession.java | 373 ++++++++++++++++++ .../copilot/sdk/SessionRequestBuilder.java | 33 ++ .../sdk/events/AbstractSessionEvent.java | 4 +- .../sdk/events/CapabilitiesChangedEvent.java | 44 +++ .../sdk/events/CommandExecuteEvent.java | 43 ++ .../sdk/events/ElicitationRequestedEvent.java | 54 +++ .../sdk/events/PermissionRequestedEvent.java | 3 +- .../sdk/events/SessionEventParser.java | 3 + .../copilot/sdk/json/CommandContext.java | 74 ++++ .../copilot/sdk/json/CommandDefinition.java | 98 +++++ .../copilot/sdk/json/CommandHandler.java | 41 ++ .../sdk/json/CommandWireDefinition.java | 58 +++ .../sdk/json/CreateSessionRequest.java | 26 ++ .../sdk/json/CreateSessionResponse.java | 5 +- .../copilot/sdk/json/ElicitationContext.java | 112 ++++++ .../copilot/sdk/json/ElicitationHandler.java | 44 +++ .../copilot/sdk/json/ElicitationParams.java | 58 +++ .../copilot/sdk/json/ElicitationResult.java | 68 ++++ .../sdk/json/ElicitationResultAction.java | 33 ++ .../copilot/sdk/json/ElicitationSchema.java | 92 +++++ .../sdk/json/GetSessionMetadataResponse.java | 15 + .../github/copilot/sdk/json/InputOptions.java | 108 +++++ .../copilot/sdk/json/ResumeSessionConfig.java | 54 +++ .../sdk/json/ResumeSessionRequest.java | 26 ++ .../sdk/json/ResumeSessionResponse.java | 5 +- .../copilot/sdk/json/SessionCapabilities.java | 39 ++ .../copilot/sdk/json/SessionConfig.java | 54 +++ .../github/copilot/sdk/json/SessionUiApi.java | 85 ++++ .../sdk/json/SessionUiCapabilities.java | 37 ++ 30 files changed, 1715 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/github/copilot/sdk/events/CapabilitiesChangedEvent.java create mode 100644 src/main/java/com/github/copilot/sdk/events/CommandExecuteEvent.java create mode 100644 src/main/java/com/github/copilot/sdk/events/ElicitationRequestedEvent.java create mode 100644 src/main/java/com/github/copilot/sdk/json/CommandContext.java create mode 100644 src/main/java/com/github/copilot/sdk/json/CommandDefinition.java create mode 100644 src/main/java/com/github/copilot/sdk/json/CommandHandler.java create mode 100644 src/main/java/com/github/copilot/sdk/json/CommandWireDefinition.java create mode 100644 src/main/java/com/github/copilot/sdk/json/ElicitationContext.java create mode 100644 src/main/java/com/github/copilot/sdk/json/ElicitationHandler.java create mode 100644 src/main/java/com/github/copilot/sdk/json/ElicitationParams.java create mode 100644 src/main/java/com/github/copilot/sdk/json/ElicitationResult.java create mode 100644 src/main/java/com/github/copilot/sdk/json/ElicitationResultAction.java create mode 100644 src/main/java/com/github/copilot/sdk/json/ElicitationSchema.java create mode 100644 src/main/java/com/github/copilot/sdk/json/GetSessionMetadataResponse.java create mode 100644 src/main/java/com/github/copilot/sdk/json/InputOptions.java create mode 100644 src/main/java/com/github/copilot/sdk/json/SessionCapabilities.java create mode 100644 src/main/java/com/github/copilot/sdk/json/SessionUiApi.java create mode 100644 src/main/java/com/github/copilot/sdk/json/SessionUiCapabilities.java diff --git a/src/main/java/com/github/copilot/sdk/CopilotClient.java b/src/main/java/com/github/copilot/sdk/CopilotClient.java index e2790f6a3..f00e2fd11 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -24,6 +24,7 @@ import com.github.copilot.sdk.json.DeleteSessionResponse; import com.github.copilot.sdk.json.GetAuthStatusResponse; import com.github.copilot.sdk.json.GetLastSessionIdResponse; +import com.github.copilot.sdk.json.GetSessionMetadataResponse; import com.github.copilot.sdk.json.GetModelsResponse; import com.github.copilot.sdk.json.GetStatusResponse; import com.github.copilot.sdk.json.ListSessionsResponse; @@ -374,6 +375,7 @@ public CompletableFuture createSession(SessionConfig config) { return connection.rpc.invoke("session.create", request, CreateSessionResponse.class).thenApply(response -> { session.setWorkspacePath(response.workspacePath()); + session.setCapabilities(response.capabilities()); // If the server returned a different sessionId (e.g. a v2 CLI that ignores // the client-supplied ID), re-key the sessions map. String returnedId = response.sessionId(); @@ -444,6 +446,7 @@ public CompletableFuture resumeSession(String sessionId, ResumeS return connection.rpc.invoke("session.resume", request, ResumeSessionResponse.class).thenApply(response -> { session.setWorkspacePath(response.workspacePath()); + session.setCapabilities(response.capabilities()); // If the server returned a different sessionId than what was requested, re-key. String returnedId = response.sessionId(); if (returnedId != null && !returnedId.equals(sessionId)) { @@ -657,6 +660,34 @@ public CompletableFuture> listSessions(SessionListFilter f }); } + /** + * Gets metadata for a specific session by ID. + *

+ * This provides an efficient O(1) lookup of a single session's metadata instead + * of listing all sessions. + * + *

Example Usage

+ * + *
{@code
+     * var metadata = client.getSessionMetadata("session-123").get();
+     * if (metadata != null) {
+     * 	System.out.println("Session started at: " + metadata.getStartTime());
+     * }
+     * }
+ * + * @param sessionId + * the ID of the session to look up + * @return a future that resolves with the {@link SessionMetadata}, or + * {@code null} if the session was not found + * @see SessionMetadata + * @since 1.0.0 + */ + public CompletableFuture getSessionMetadata(String sessionId) { + return ensureConnected().thenCompose(connection -> connection.rpc + .invoke("session.getMetadata", Map.of("sessionId", sessionId), GetSessionMetadataResponse.class) + .thenApply(GetSessionMetadataResponse::session)); + } + /** * Gets the ID of the session currently displayed in the TUI. *

diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 844737fc2..768f0adaa 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -31,14 +31,27 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.github.copilot.sdk.events.AbstractSessionEvent; import com.github.copilot.sdk.events.AssistantMessageEvent; +import com.github.copilot.sdk.events.CapabilitiesChangedEvent; +import com.github.copilot.sdk.events.CommandExecuteEvent; +import com.github.copilot.sdk.events.ElicitationRequestedEvent; import com.github.copilot.sdk.events.ExternalToolRequestedEvent; import com.github.copilot.sdk.events.PermissionRequestedEvent; import com.github.copilot.sdk.events.SessionErrorEvent; import com.github.copilot.sdk.events.SessionEventParser; import com.github.copilot.sdk.events.SessionIdleEvent; import com.github.copilot.sdk.json.AgentInfo; +import com.github.copilot.sdk.json.CommandContext; +import com.github.copilot.sdk.json.CommandDefinition; +import com.github.copilot.sdk.json.CommandHandler; +import com.github.copilot.sdk.json.ElicitationContext; +import com.github.copilot.sdk.json.ElicitationHandler; +import com.github.copilot.sdk.json.ElicitationParams; +import com.github.copilot.sdk.json.ElicitationResult; +import com.github.copilot.sdk.json.ElicitationResultAction; +import com.github.copilot.sdk.json.ElicitationSchema; import com.github.copilot.sdk.json.GetMessagesResponse; import com.github.copilot.sdk.json.HookInvocation; +import com.github.copilot.sdk.json.InputOptions; import com.github.copilot.sdk.json.MessageOptions; import com.github.copilot.sdk.json.PermissionHandler; import com.github.copilot.sdk.json.PermissionInvocation; @@ -49,9 +62,12 @@ import com.github.copilot.sdk.json.PreToolUseHookInput; import com.github.copilot.sdk.json.SendMessageRequest; import com.github.copilot.sdk.json.SendMessageResponse; +import com.github.copilot.sdk.json.SessionCapabilities; import com.github.copilot.sdk.json.SessionEndHookInput; import com.github.copilot.sdk.json.SessionHooks; import com.github.copilot.sdk.json.SessionStartHookInput; +import com.github.copilot.sdk.json.SessionUiApi; +import com.github.copilot.sdk.json.SessionUiCapabilities; import com.github.copilot.sdk.json.ToolDefinition; import com.github.copilot.sdk.json.ToolResultObject; import com.github.copilot.sdk.json.UserInputHandler; @@ -116,11 +132,15 @@ public final class CopilotSession implements AutoCloseable { */ private volatile String sessionId; private volatile String workspacePath; + private volatile SessionCapabilities capabilities = new SessionCapabilities(); + private final SessionUiApi ui; private final JsonRpcClient rpc; private final Set> eventHandlers = ConcurrentHashMap.newKeySet(); private final Map toolHandlers = new ConcurrentHashMap<>(); + private final Map commandHandlers = new ConcurrentHashMap<>(); private final AtomicReference permissionHandler = new AtomicReference<>(); private final AtomicReference userInputHandler = new AtomicReference<>(); + private final AtomicReference elicitationHandler = new AtomicReference<>(); private final AtomicReference hooksHandler = new AtomicReference<>(); private volatile EventErrorHandler eventErrorHandler; private volatile EventErrorPolicy eventErrorPolicy = EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS; @@ -163,6 +183,7 @@ public final class CopilotSession implements AutoCloseable { this.sessionId = sessionId; this.rpc = rpc; this.workspacePath = workspacePath; + this.ui = new SessionUiApiImpl(); var executor = new ScheduledThreadPoolExecutor(1, r -> { var t = new Thread(r, "sendAndWait-timeout"); t.setDaemon(true); @@ -225,6 +246,30 @@ void setWorkspacePath(String workspacePath) { this.workspacePath = workspacePath; } + /** + * Gets the capabilities reported by the host for this session. + *

+ * Capabilities are populated from the session create/resume response and + * updated in real time via {@code capabilities.changed} events. + * + * @return the session capabilities (never {@code null}) + */ + public SessionCapabilities getCapabilities() { + return capabilities; + } + + /** + * Gets the UI API for eliciting information from the user during this session. + *

+ * All methods on this API throw {@link IllegalStateException} if the host does + * not report elicitation support via {@link #getCapabilities()}. + * + * @return the UI API + */ + public SessionUiApi getUi() { + return ui; + } + /** * Sets a custom error handler for exceptions thrown by event handlers. *

@@ -669,11 +714,49 @@ private void handleBroadcastEventAsync(AbstractSessionEvent event) { if (data == null || data.requestId() == null || data.permissionRequest() == null) { return; } + if (Boolean.TRUE.equals(data.resolvedByHook())) { + return; // Already resolved by a permissionRequest hook; no client action needed. + } PermissionHandler handler = permissionHandler.get(); if (handler == null) { return; // This client doesn't handle permissions; another client will } executePermissionAndRespondAsync(data.requestId(), data.permissionRequest(), handler); + } else if (event instanceof CommandExecuteEvent cmdEvent) { + var data = cmdEvent.getData(); + if (data == null || data.requestId() == null) { + return; + } + executeCommandAndRespondAsync(data.requestId(), data.commandName(), data.command(), data.args()); + } else if (event instanceof ElicitationRequestedEvent elicitEvent) { + var data = elicitEvent.getData(); + if (data == null || data.requestId() == null) { + return; + } + ElicitationHandler handler = elicitationHandler.get(); + if (handler != null) { + ElicitationSchema schema = null; + if (data.requestedSchema() != null) { + schema = new ElicitationSchema().setType(data.requestedSchema().type()) + .setProperties(data.requestedSchema().properties()) + .setRequired(data.requestedSchema().required()); + } + var context = new ElicitationContext().setSessionId(sessionId).setMessage(data.message()) + .setRequestedSchema(schema).setMode(data.mode()).setElicitationSource(data.elicitationSource()) + .setUrl(data.url()); + handleElicitationRequestAsync(context, data.requestId()); + } + } else if (event instanceof CapabilitiesChangedEvent capEvent) { + var data = capEvent.getData(); + if (data != null) { + var newCapabilities = new SessionCapabilities(); + if (data.ui() != null) { + newCapabilities.setUi(new SessionUiCapabilities().setElicitation(data.ui().elicitation())); + } else { + newCapabilities.setUi(capabilities.getUi()); + } + capabilities = newCapabilities; + } } } @@ -816,6 +899,250 @@ void registerTools(List tools) { } } + /** + * Executes a command handler and sends the result back via + * {@code session.commands.handlePendingCommand}. + */ + private void executeCommandAndRespondAsync(String requestId, String commandName, String command, String args) { + CommandHandler handler = commandHandlers.get(commandName); + Runnable task = () -> { + if (handler == null) { + try { + rpc.invoke("session.commands.handlePendingCommand", Map.of("sessionId", sessionId, "requestId", + requestId, "error", "Unknown command: " + commandName), Object.class); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error sending command error for requestId=" + requestId, e); + } + return; + } + try { + var ctx = new CommandContext().setSessionId(sessionId).setCommand(command).setCommandName(commandName) + .setArgs(args); + handler.handle(ctx).thenRun(() -> { + try { + rpc.invoke("session.commands.handlePendingCommand", + Map.of("sessionId", sessionId, "requestId", requestId), Object.class); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error sending command result for requestId=" + requestId, e); + } + }).exceptionally(ex -> { + try { + String msg = ex.getMessage() != null ? ex.getMessage() : ex.toString(); + rpc.invoke("session.commands.handlePendingCommand", + Map.of("sessionId", sessionId, "requestId", requestId, "error", msg), Object.class); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error sending command error for requestId=" + requestId, e); + } + return null; + }); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error executing command for requestId=" + requestId, e); + try { + String msg = e.getMessage() != null ? e.getMessage() : e.toString(); + rpc.invoke("session.commands.handlePendingCommand", + Map.of("sessionId", sessionId, "requestId", requestId, "error", msg), Object.class); + } catch (Exception sendEx) { + LOG.log(Level.WARNING, "Error sending command error for requestId=" + requestId, sendEx); + } + } + }; + try { + if (executor != null) { + CompletableFuture.runAsync(task, executor); + } else { + CompletableFuture.runAsync(task); + } + } catch (RejectedExecutionException e) { + LOG.log(Level.WARNING, "Executor rejected command task for requestId=" + requestId + "; running inline", e); + task.run(); + } + } + + /** + * Dispatches an elicitation request to the registered handler and responds via + * {@code session.ui.handlePendingElicitation}. Auto-cancels on handler errors. + */ + private void handleElicitationRequestAsync(ElicitationContext context, String requestId) { + ElicitationHandler handler = elicitationHandler.get(); + if (handler == null) { + return; + } + Runnable task = () -> { + try { + handler.handle(context).thenAccept(result -> { + try { + String actionStr = result.getAction() != null + ? result.getAction().getValue() + : ElicitationResultAction.CANCEL.getValue(); + Map resultMap = result.getContent() != null + ? Map.of("action", actionStr, "content", result.getContent()) + : Map.of("action", actionStr); + rpc.invoke("session.ui.handlePendingElicitation", + Map.of("sessionId", sessionId, "requestId", requestId, "result", resultMap), + Object.class); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error sending elicitation result for requestId=" + requestId, e); + } + }).exceptionally(ex -> { + try { + rpc.invoke("session.ui.handlePendingElicitation", Map.of("sessionId", sessionId, "requestId", + requestId, "result", Map.of("action", ElicitationResultAction.CANCEL.getValue())), + Object.class); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error sending elicitation cancel for requestId=" + requestId, e); + } + return null; + }); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error executing elicitation handler for requestId=" + requestId, e); + try { + rpc.invoke( + "session.ui.handlePendingElicitation", Map.of("sessionId", sessionId, "requestId", + requestId, "result", Map.of("action", ElicitationResultAction.CANCEL.getValue())), + Object.class); + } catch (Exception sendEx) { + LOG.log(Level.WARNING, "Error sending elicitation cancel for requestId=" + requestId, sendEx); + } + } + }; + try { + if (executor != null) { + CompletableFuture.runAsync(task, executor); + } else { + CompletableFuture.runAsync(task); + } + } catch (RejectedExecutionException e) { + LOG.log(Level.WARNING, "Executor rejected elicitation task for requestId=" + requestId + "; running inline", + e); + task.run(); + } + } + + /** + * Throws if the host does not support elicitation. + */ + private void assertElicitation() { + SessionCapabilities caps = capabilities; + if (caps == null || caps.getUi() == null || !Boolean.TRUE.equals(caps.getUi().getElicitation())) { + throw new IllegalStateException("Elicitation is not supported by the host. " + + "Check session.getCapabilities().getUi()?.getElicitation() before calling UI methods."); + } + } + + /** + * Implements {@link SessionUiApi} backed by the session's RPC connection. + */ + private final class SessionUiApiImpl implements SessionUiApi { + + @Override + public CompletableFuture elicitation(ElicitationParams params) { + assertElicitation(); + var schema = new java.util.HashMap(); + schema.put("type", params.getRequestedSchema().getType()); + schema.put("properties", params.getRequestedSchema().getProperties()); + if (params.getRequestedSchema().getRequired() != null) { + schema.put("required", params.getRequestedSchema().getRequired()); + } + return rpc.invoke("session.ui.elicitation", + Map.of("sessionId", sessionId, "message", params.getMessage(), "requestedSchema", schema), + ElicitationRpcResponse.class).thenApply(resp -> { + var result = new ElicitationResult(); + if (resp.action() != null) { + for (ElicitationResultAction a : ElicitationResultAction.values()) { + if (a.getValue().equalsIgnoreCase(resp.action())) { + result.setAction(a); + break; + } + } + } + if (result.getAction() == null) { + result.setAction(ElicitationResultAction.CANCEL); + } + result.setContent(resp.content()); + return result; + }); + } + + @Override + public CompletableFuture confirm(String message) { + assertElicitation(); + var field = Map.of("type", "boolean", "default", (Object) true); + var schema = Map.of("type", (Object) "object", "properties", (Object) Map.of("confirmed", (Object) field), + "required", (Object) new String[]{"confirmed"}); + return rpc.invoke("session.ui.elicitation", + Map.of("sessionId", sessionId, "message", message, "requestedSchema", schema), + ElicitationRpcResponse.class).thenApply(resp -> { + if ("accept".equalsIgnoreCase(resp.action()) && resp.content() != null) { + Object val = resp.content().get("confirmed"); + if (val instanceof Boolean b) { + return b; + } + if (val instanceof com.fasterxml.jackson.databind.node.BooleanNode bn) { + return bn.booleanValue(); + } + if (val instanceof String s) { + return Boolean.parseBoolean(s); + } + } + return false; + }); + } + + @Override + public CompletableFuture select(String message, String[] options) { + assertElicitation(); + var field = Map.of("type", (Object) "string", "enum", (Object) options); + var schema = Map.of("type", (Object) "object", "properties", (Object) Map.of("selection", (Object) field), + "required", (Object) new String[]{"selection"}); + return rpc.invoke("session.ui.elicitation", + Map.of("sessionId", sessionId, "message", message, "requestedSchema", schema), + ElicitationRpcResponse.class).thenApply(resp -> { + if ("accept".equalsIgnoreCase(resp.action()) && resp.content() != null) { + Object val = resp.content().get("selection"); + return val != null ? val.toString() : null; + } + return null; + }); + } + + @Override + public CompletableFuture input(String message, InputOptions options) { + assertElicitation(); + var field = new java.util.LinkedHashMap(); + field.put("type", "string"); + if (options != null) { + if (options.getTitle() != null) + field.put("title", options.getTitle()); + if (options.getDescription() != null) + field.put("description", options.getDescription()); + if (options.getMinLength() != null) + field.put("minLength", options.getMinLength()); + if (options.getMaxLength() != null) + field.put("maxLength", options.getMaxLength()); + if (options.getFormat() != null) + field.put("format", options.getFormat()); + if (options.getDefaultValue() != null) + field.put("default", options.getDefaultValue()); + } + var schema = Map.of("type", (Object) "object", "properties", (Object) Map.of("value", (Object) field), + "required", (Object) new String[]{"value"}); + return rpc.invoke("session.ui.elicitation", + Map.of("sessionId", sessionId, "message", message, "requestedSchema", schema), + ElicitationRpcResponse.class).thenApply(resp -> { + if ("accept".equalsIgnoreCase(resp.action()) && resp.content() != null) { + Object val = resp.content().get("value"); + return val != null ? val.toString() : null; + } + return null; + }); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record ElicitationRpcResponse(@JsonProperty("action") String action, + @JsonProperty("content") Map content) { + } + /** * Retrieves a registered tool by name. * @@ -888,6 +1215,50 @@ void registerUserInputHandler(UserInputHandler handler) { userInputHandler.set(handler); } + /** + * Registers command handlers for this session. + *

+ * Called internally when creating or resuming a session with commands. + * + * @param commands + * the command definitions to register + */ + void registerCommands(java.util.List commands) { + commandHandlers.clear(); + if (commands != null) { + for (CommandDefinition cmd : commands) { + if (cmd.getName() != null && cmd.getHandler() != null) { + commandHandlers.put(cmd.getName(), cmd.getHandler()); + } + } + } + } + + /** + * Registers an elicitation handler for this session. + *

+ * Called internally when creating or resuming a session with an elicitation + * handler. + * + * @param handler + * the handler to invoke when an elicitation request is received + */ + void registerElicitationHandler(ElicitationHandler handler) { + elicitationHandler.set(handler); + } + + /** + * Sets the capabilities reported by the host for this session. + *

+ * Called internally after session create/resume response. + * + * @param sessionCapabilities + * the capabilities to set, or {@code null} for empty capabilities + */ + void setCapabilities(SessionCapabilities sessionCapabilities) { + this.capabilities = sessionCapabilities != null ? sessionCapabilities : new SessionCapabilities(); + } + /** * Handles a user input request from the Copilot CLI. *

@@ -1366,8 +1737,10 @@ public void close() { eventHandlers.clear(); toolHandlers.clear(); + commandHandlers.clear(); permissionHandler.set(null); userInputHandler.set(null); + elicitationHandler.set(null); hooksHandler.set(null); } diff --git a/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java b/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java index 6f1cd573c..d74bbfaf3 100644 --- a/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java +++ b/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java @@ -10,6 +10,7 @@ import java.util.function.Function; import com.github.copilot.sdk.json.CreateSessionRequest; +import com.github.copilot.sdk.json.CommandWireDefinition; import com.github.copilot.sdk.json.ResumeSessionConfig; import com.github.copilot.sdk.json.ResumeSessionRequest; import com.github.copilot.sdk.json.SectionOverride; @@ -122,6 +123,16 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess request.setDisabledSkills(config.getDisabledSkills()); request.setConfigDir(config.getConfigDir()); + if (config.getCommands() != null && !config.getCommands().isEmpty()) { + var wireCommands = config.getCommands().stream() + .map(c -> new CommandWireDefinition(c.getName(), c.getDescription())) + .collect(java.util.stream.Collectors.toList()); + request.setCommands(wireCommands); + } + if (config.getOnElicitationRequest() != null) { + request.setRequestElicitation(true); + } + return request; } @@ -183,6 +194,16 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo request.setDisabledSkills(config.getDisabledSkills()); request.setInfiniteSessions(config.getInfiniteSessions()); + if (config.getCommands() != null && !config.getCommands().isEmpty()) { + var wireCommands = config.getCommands().stream() + .map(c -> new CommandWireDefinition(c.getName(), c.getDescription())) + .collect(java.util.stream.Collectors.toList()); + request.setCommands(wireCommands); + } + if (config.getOnElicitationRequest() != null) { + request.setRequestElicitation(true); + } + return request; } @@ -211,6 +232,12 @@ static void configureSession(CopilotSession session, SessionConfig config) { if (config.getHooks() != null) { session.registerHooks(config.getHooks()); } + if (config.getCommands() != null) { + session.registerCommands(config.getCommands()); + } + if (config.getOnElicitationRequest() != null) { + session.registerElicitationHandler(config.getOnElicitationRequest()); + } if (config.getOnEvent() != null) { session.on(config.getOnEvent()); } @@ -241,6 +268,12 @@ static void configureSession(CopilotSession session, ResumeSessionConfig config) if (config.getHooks() != null) { session.registerHooks(config.getHooks()); } + if (config.getCommands() != null) { + session.registerCommands(config.getCommands()); + } + if (config.getOnElicitationRequest() != null) { + session.registerElicitationHandler(config.getOnElicitationRequest()); + } if (config.getOnEvent() != null) { session.on(config.getOnEvent()); } diff --git a/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java b/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java index 4626bb4f8..51f6d8712 100644 --- a/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java @@ -65,8 +65,8 @@ public abstract sealed class AbstractSessionEvent permits ToolExecutionCompleteEvent, // Broadcast request/completion events (protocol v3) ExternalToolRequestedEvent, ExternalToolCompletedEvent, PermissionRequestedEvent, PermissionCompletedEvent, - CommandQueuedEvent, CommandCompletedEvent, ExitPlanModeRequestedEvent, ExitPlanModeCompletedEvent, - SystemNotificationEvent, + CommandQueuedEvent, CommandCompletedEvent, CommandExecuteEvent, ElicitationRequestedEvent, + CapabilitiesChangedEvent, ExitPlanModeRequestedEvent, ExitPlanModeCompletedEvent, SystemNotificationEvent, // User events UserMessageEvent, PendingMessagesModifiedEvent, // Skill events diff --git a/src/main/java/com/github/copilot/sdk/events/CapabilitiesChangedEvent.java b/src/main/java/com/github/copilot/sdk/events/CapabilitiesChangedEvent.java new file mode 100644 index 000000000..0db68189d --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/CapabilitiesChangedEvent.java @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: capabilities.changed + *

+ * Broadcast when the host's session capabilities change. The SDK updates + * {@link com.github.copilot.sdk.CopilotSession#getCapabilities()} accordingly. + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class CapabilitiesChangedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private CapabilitiesChangedData data; + + @Override + public String getType() { + return "capabilities.changed"; + } + + public CapabilitiesChangedData getData() { + return data; + } + + public void setData(CapabilitiesChangedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record CapabilitiesChangedData(@JsonProperty("ui") CapabilitiesChangedUi ui) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record CapabilitiesChangedUi(@JsonProperty("elicitation") Boolean elicitation) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/CommandExecuteEvent.java b/src/main/java/com/github/copilot/sdk/events/CommandExecuteEvent.java new file mode 100644 index 000000000..c08c4a88d --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/CommandExecuteEvent.java @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: command.execute + *

+ * Broadcast when the user executes a slash command registered by this client. + * Clients that have a matching command handler should respond via + * {@code session.commands.handlePendingCommand}. + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class CommandExecuteEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private CommandExecuteData data; + + @Override + public String getType() { + return "command.execute"; + } + + public CommandExecuteData getData() { + return data; + } + + public void setData(CommandExecuteData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record CommandExecuteData(@JsonProperty("requestId") String requestId, + @JsonProperty("command") String command, @JsonProperty("commandName") String commandName, + @JsonProperty("args") String args) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/ElicitationRequestedEvent.java b/src/main/java/com/github/copilot/sdk/events/ElicitationRequestedEvent.java new file mode 100644 index 000000000..e459dfb77 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/ElicitationRequestedEvent.java @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: elicitation.requested + *

+ * Broadcast when the server or an MCP tool requests structured input from the + * user. Clients that have an elicitation handler should respond via + * {@code session.ui.handlePendingElicitation}. + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ElicitationRequestedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private ElicitationRequestedData data; + + @Override + public String getType() { + return "elicitation.requested"; + } + + public ElicitationRequestedData getData() { + return data; + } + + public void setData(ElicitationRequestedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ElicitationRequestedData(@JsonProperty("requestId") String requestId, + @JsonProperty("toolCallId") String toolCallId, @JsonProperty("elicitationSource") String elicitationSource, + @JsonProperty("message") String message, @JsonProperty("mode") String mode, + @JsonProperty("requestedSchema") ElicitationRequestedSchema requestedSchema, + @JsonProperty("url") String url) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ElicitationRequestedSchema(@JsonProperty("type") String type, + @JsonProperty("properties") Map properties, + @JsonProperty("required") List required) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/PermissionRequestedEvent.java b/src/main/java/com/github/copilot/sdk/events/PermissionRequestedEvent.java index d8f9ec147..7ebce5ac7 100644 --- a/src/main/java/com/github/copilot/sdk/events/PermissionRequestedEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/PermissionRequestedEvent.java @@ -38,6 +38,7 @@ public void setData(PermissionRequestedData data) { @JsonIgnoreProperties(ignoreUnknown = true) public record PermissionRequestedData(@JsonProperty("requestId") String requestId, - @JsonProperty("permissionRequest") PermissionRequest permissionRequest) { + @JsonProperty("permissionRequest") PermissionRequest permissionRequest, + @JsonProperty("resolvedByHook") Boolean resolvedByHook) { } } diff --git a/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java b/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java index 308317e6b..dda971769 100644 --- a/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java +++ b/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java @@ -99,6 +99,9 @@ public class SessionEventParser { TYPE_MAP.put("permission.completed", PermissionCompletedEvent.class); TYPE_MAP.put("command.queued", CommandQueuedEvent.class); TYPE_MAP.put("command.completed", CommandCompletedEvent.class); + TYPE_MAP.put("command.execute", CommandExecuteEvent.class); + TYPE_MAP.put("elicitation.requested", ElicitationRequestedEvent.class); + TYPE_MAP.put("capabilities.changed", CapabilitiesChangedEvent.class); TYPE_MAP.put("exit_plan_mode.requested", ExitPlanModeRequestedEvent.class); TYPE_MAP.put("exit_plan_mode.completed", ExitPlanModeCompletedEvent.class); TYPE_MAP.put("system.notification", SystemNotificationEvent.class); diff --git a/src/main/java/com/github/copilot/sdk/json/CommandContext.java b/src/main/java/com/github/copilot/sdk/json/CommandContext.java new file mode 100644 index 000000000..4657699bb --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/CommandContext.java @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +/** + * Context passed to a {@link CommandHandler} when a slash command is executed. + * + * @since 1.0.0 + */ +public class CommandContext { + + private String sessionId; + private String command; + private String commandName; + private String args; + + /** Gets the session ID where the command was invoked. @return the session ID */ + public String getSessionId() { + return sessionId; + } + + /** Sets the session ID. @param sessionId the session ID @return this */ + public CommandContext setSessionId(String sessionId) { + this.sessionId = sessionId; + return this; + } + + /** + * Gets the full command text (e.g., {@code /deploy production}). + * + * @return the full command text + */ + public String getCommand() { + return command; + } + + /** Sets the full command text. @param command the command text @return this */ + public CommandContext setCommand(String command) { + this.command = command; + return this; + } + + /** + * Gets the command name without the leading {@code /}. + * + * @return the command name + */ + public String getCommandName() { + return commandName; + } + + /** Sets the command name. @param commandName the command name @return this */ + public CommandContext setCommandName(String commandName) { + this.commandName = commandName; + return this; + } + + /** + * Gets the raw argument string after the command name. + * + * @return the argument string + */ + public String getArgs() { + return args; + } + + /** Sets the argument string. @param args the argument string @return this */ + public CommandContext setArgs(String args) { + this.args = args; + return this; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/CommandDefinition.java b/src/main/java/com/github/copilot/sdk/json/CommandDefinition.java new file mode 100644 index 000000000..33a6cbada --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/CommandDefinition.java @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +/** + * Defines a slash command that users can invoke from the CLI TUI. + *

+ * Register commands via {@link SessionConfig#setCommands(java.util.List)} or + * {@link ResumeSessionConfig#setCommands(java.util.List)}. Each command appears + * as {@code /name} in the CLI TUI. + * + *

Example Usage

+ * + *
{@code
+ * var config = new SessionConfig().setCommands(List.of(
+ * 		new CommandDefinition().setName("deploy").setDescription("Deploy the application").setHandler(context -> {
+ * 			System.out.println("Deploying: " + context.getArgs());
+ * 			return CompletableFuture.completedFuture(null);
+ * 		})));
+ * }
+ * + * @see CommandHandler + * @see CommandContext + * @since 1.0.0 + */ +public class CommandDefinition { + + private String name; + private String description; + private CommandHandler handler; + + /** + * Gets the command name (without leading {@code /}). + * + * @return the command name + */ + public String getName() { + return name; + } + + /** + * Sets the command name (without leading {@code /}). + *

+ * For example, {@code "deploy"} registers the {@code /deploy} command. + * + * @param name + * the command name + * @return this instance for method chaining + */ + public CommandDefinition setName(String name) { + this.name = name; + return this; + } + + /** + * Gets the human-readable description shown in the command completion UI. + * + * @return the description, or {@code null} if not set + */ + public String getDescription() { + return description; + } + + /** + * Sets the human-readable description shown in the command completion UI. + * + * @param description + * the description + * @return this instance for method chaining + */ + public CommandDefinition setDescription(String description) { + this.description = description; + return this; + } + + /** + * Gets the handler invoked when the command is executed. + * + * @return the command handler + */ + public CommandHandler getHandler() { + return handler; + } + + /** + * Sets the handler invoked when the command is executed. + * + * @param handler + * the command handler + * @return this instance for method chaining + */ + public CommandDefinition setHandler(CommandHandler handler) { + this.handler = handler; + return this; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/CommandHandler.java b/src/main/java/com/github/copilot/sdk/json/CommandHandler.java new file mode 100644 index 000000000..d63955638 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/CommandHandler.java @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +import java.util.concurrent.CompletableFuture; + +/** + * Functional interface for handling slash-command executions. + *

+ * Implement this interface to define the behavior of a registered slash + * command. The handler is invoked when the user executes the command in the CLI + * TUI. + * + *

Example Usage

+ * + *
{@code
+ * CommandHandler deployHandler = context -> {
+ * 	System.out.println("Deploying with args: " + context.getArgs());
+ * 	// perform deployment...
+ * 	return CompletableFuture.completedFuture(null);
+ * };
+ * }
+ * + * @see CommandDefinition + * @since 1.0.0 + */ +@FunctionalInterface +public interface CommandHandler { + + /** + * Handles a slash-command execution. + * + * @param context + * the command context containing session ID, command text, and + * arguments + * @return a future that completes when the command handling is done + */ + CompletableFuture handle(CommandContext context); +} diff --git a/src/main/java/com/github/copilot/sdk/json/CommandWireDefinition.java b/src/main/java/com/github/copilot/sdk/json/CommandWireDefinition.java new file mode 100644 index 000000000..2ee65c58e --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/CommandWireDefinition.java @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Wire-format representation of a command definition for RPC serialization. + *

+ * This is a low-level class used internally. Use {@link CommandDefinition} to + * define commands for a session. + * + * @since 1.0.0 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class CommandWireDefinition { + + @JsonProperty("name") + private String name; + + @JsonProperty("description") + private String description; + + /** Creates an empty definition. */ + public CommandWireDefinition() { + } + + /** Creates a definition with name and description. */ + public CommandWireDefinition(String name, String description) { + this.name = name; + this.description = description; + } + + /** Gets the command name. @return the name */ + public String getName() { + return name; + } + + /** Sets the command name. @param name the name @return this */ + public CommandWireDefinition setName(String name) { + this.name = name; + return this; + } + + /** Gets the description. @return the description */ + public String getDescription() { + return description; + } + + /** Sets the description. @param description the description @return this */ + public CommandWireDefinition setDescription(String description) { + this.description = description; + return this; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java b/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java index c0243f14b..d030631de 100644 --- a/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java +++ b/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java @@ -91,6 +91,12 @@ public final class CreateSessionRequest { @JsonProperty("configDir") private String configDir; + @JsonProperty("commands") + private List commands; + + @JsonProperty("requestElicitation") + private Boolean requestElicitation; + /** Gets the model name. @return the model */ public String getModel() { return model; @@ -312,4 +318,24 @@ public String getConfigDir() { public void setConfigDir(String configDir) { this.configDir = configDir; } + + /** Gets the commands wire definitions. @return the commands */ + public List getCommands() { + return commands == null ? null : Collections.unmodifiableList(commands); + } + + /** Sets the commands wire definitions. @param commands the commands */ + public void setCommands(List commands) { + this.commands = commands; + } + + /** Gets the requestElicitation flag. @return the flag */ + public Boolean getRequestElicitation() { + return requestElicitation; + } + + /** Sets the requestElicitation flag. @param requestElicitation the flag */ + public void setRequestElicitation(Boolean requestElicitation) { + this.requestElicitation = requestElicitation; + } } diff --git a/src/main/java/com/github/copilot/sdk/json/CreateSessionResponse.java b/src/main/java/com/github/copilot/sdk/json/CreateSessionResponse.java index 5b1a177f0..b47af050b 100644 --- a/src/main/java/com/github/copilot/sdk/json/CreateSessionResponse.java +++ b/src/main/java/com/github/copilot/sdk/json/CreateSessionResponse.java @@ -11,9 +11,12 @@ * @param workspacePath * the workspace path, or {@code null} if infinite sessions are * disabled + * @param capabilities + * the capabilities reported by the host, or {@code null} * @since 1.0.0 */ @JsonInclude(JsonInclude.Include.NON_NULL) public record CreateSessionResponse(@JsonProperty("sessionId") String sessionId, - @JsonProperty("workspacePath") String workspacePath) { + @JsonProperty("workspacePath") String workspacePath, + @JsonProperty("capabilities") SessionCapabilities capabilities) { } diff --git a/src/main/java/com/github/copilot/sdk/json/ElicitationContext.java b/src/main/java/com/github/copilot/sdk/json/ElicitationContext.java new file mode 100644 index 000000000..87687b194 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/ElicitationContext.java @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +/** + * Context for an elicitation request received from the server or MCP tools. + * + * @since 1.0.0 + */ +public class ElicitationContext { + + private String sessionId; + private String message; + private ElicitationSchema requestedSchema; + private String mode; + private String elicitationSource; + private String url; + + /** + * Gets the session ID that triggered the elicitation request. @return the + * session ID + */ + public String getSessionId() { + return sessionId; + } + + /** Sets the session ID. @param sessionId the session ID @return this */ + public ElicitationContext setSessionId(String sessionId) { + this.sessionId = sessionId; + return this; + } + + /** + * Gets the message describing what information is needed from the user. + * + * @return the message + */ + public String getMessage() { + return message; + } + + /** Sets the message. @param message the message @return this */ + public ElicitationContext setMessage(String message) { + this.message = message; + return this; + } + + /** + * Gets the JSON Schema describing the form fields to present (form mode only). + * + * @return the schema, or {@code null} + */ + public ElicitationSchema getRequestedSchema() { + return requestedSchema; + } + + /** Sets the schema. @param requestedSchema the schema @return this */ + public ElicitationContext setRequestedSchema(ElicitationSchema requestedSchema) { + this.requestedSchema = requestedSchema; + return this; + } + + /** + * Gets the elicitation mode: {@code "form"} for structured input, {@code "url"} + * for browser redirect. + * + * @return the mode, or {@code null} (defaults to {@code "form"}) + */ + public String getMode() { + return mode; + } + + /** Sets the mode. @param mode the mode @return this */ + public ElicitationContext setMode(String mode) { + this.mode = mode; + return this; + } + + /** + * Gets the source that initiated the request (e.g., MCP server name). + * + * @return the elicitation source, or {@code null} + */ + public String getElicitationSource() { + return elicitationSource; + } + + /** + * Sets the elicitation source. @param elicitationSource the source @return this + */ + public ElicitationContext setElicitationSource(String elicitationSource) { + this.elicitationSource = elicitationSource; + return this; + } + + /** + * Gets the URL to open in the user's browser (url mode only). + * + * @return the URL, or {@code null} + */ + public String getUrl() { + return url; + } + + /** Sets the URL. @param url the URL @return this */ + public ElicitationContext setUrl(String url) { + this.url = url; + return this; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/ElicitationHandler.java b/src/main/java/com/github/copilot/sdk/json/ElicitationHandler.java new file mode 100644 index 000000000..d0a0d0616 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/ElicitationHandler.java @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +import java.util.concurrent.CompletableFuture; + +/** + * Functional interface for handling elicitation requests from the server. + *

+ * Register an elicitation handler via + * {@link SessionConfig#setOnElicitationRequest(ElicitationHandler)} or + * {@link ResumeSessionConfig#setOnElicitationRequest(ElicitationHandler)}. When + * provided, the server routes elicitation requests to this handler and reports + * elicitation as a supported capability. + * + *

Example Usage

+ * + *
{@code
+ * ElicitationHandler handler = context -> {
+ * 	// Show the form to the user and collect responses
+ * 	Map formValues = showForm(context.getMessage(), context.getRequestedSchema());
+ * 	return CompletableFuture.completedFuture(
+ * 			new ElicitationResult().setAction(ElicitationResultAction.ACCEPT).setContent(formValues));
+ * };
+ * }
+ * + * @see ElicitationContext + * @see ElicitationResult + * @since 1.0.0 + */ +@FunctionalInterface +public interface ElicitationHandler { + + /** + * Handles an elicitation request from the server. + * + * @param context + * the elicitation context containing the message, schema, and mode + * @return a future that resolves with the elicitation result + */ + CompletableFuture handle(ElicitationContext context); +} diff --git a/src/main/java/com/github/copilot/sdk/json/ElicitationParams.java b/src/main/java/com/github/copilot/sdk/json/ElicitationParams.java new file mode 100644 index 000000000..8bd81022e --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/ElicitationParams.java @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +/** + * Parameters for an elicitation request sent from the SDK to the host. + * + * @since 1.0.0 + */ +public class ElicitationParams { + + private String message; + private ElicitationSchema requestedSchema; + + /** + * Gets the message describing what information is needed from the user. + * + * @return the message + */ + public String getMessage() { + return message; + } + + /** + * Sets the message describing what information is needed from the user. + * + * @param message + * the message + * @return this instance for method chaining + */ + public ElicitationParams setMessage(String message) { + this.message = message; + return this; + } + + /** + * Gets the JSON Schema describing the form fields to present. + * + * @return the requested schema + */ + public ElicitationSchema getRequestedSchema() { + return requestedSchema; + } + + /** + * Sets the JSON Schema describing the form fields to present. + * + * @param requestedSchema + * the schema + * @return this instance for method chaining + */ + public ElicitationParams setRequestedSchema(ElicitationSchema requestedSchema) { + this.requestedSchema = requestedSchema; + return this; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/ElicitationResult.java b/src/main/java/com/github/copilot/sdk/json/ElicitationResult.java new file mode 100644 index 000000000..3ba30b83d --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/ElicitationResult.java @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +import java.util.Map; + +/** + * Result returned from an elicitation dialog. + * + * @since 1.0.0 + */ +public class ElicitationResult { + + private ElicitationResultAction action; + private Map content; + + /** + * Gets the user action taken on the elicitation dialog. + *

+ * {@link ElicitationResultAction#ACCEPT} means the user submitted the form, + * {@link ElicitationResultAction#DECLINE} means the user rejected the request, + * and {@link ElicitationResultAction#CANCEL} means the user dismissed the + * dialog. + * + * @return the user action + */ + public ElicitationResultAction getAction() { + return action; + } + + /** + * Sets the user action taken on the elicitation dialog. + * + * @param action + * the user action + * @return this instance for method chaining + */ + public ElicitationResult setAction(ElicitationResultAction action) { + this.action = action; + return this; + } + + /** + * Gets the form values submitted by the user. + *

+ * Only present when {@link #getAction()} is + * {@link ElicitationResultAction#ACCEPT}. + * + * @return the submitted form values, or {@code null} if the user did not accept + */ + public Map getContent() { + return content; + } + + /** + * Sets the form values submitted by the user. + * + * @param content + * the submitted form values + * @return this instance for method chaining + */ + public ElicitationResult setContent(Map content) { + this.content = content; + return this; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/ElicitationResultAction.java b/src/main/java/com/github/copilot/sdk/json/ElicitationResultAction.java new file mode 100644 index 000000000..fd280cdeb --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/ElicitationResultAction.java @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +/** + * Action value for an {@link ElicitationResult}. + * + * @since 1.0.0 + */ +public enum ElicitationResultAction { + + /** The user submitted the form (accepted). */ + ACCEPT("accept"), + + /** The user explicitly rejected the request. */ + DECLINE("decline"), + + /** The user dismissed the dialog without responding. */ + CANCEL("cancel"); + + private final String value; + + ElicitationResultAction(String value) { + this.value = value; + } + + /** Returns the wire-format string value. @return the string value */ + public String getValue() { + return value; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/ElicitationSchema.java b/src/main/java/com/github/copilot/sdk/json/ElicitationSchema.java new file mode 100644 index 000000000..c3d548775 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/ElicitationSchema.java @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * JSON Schema describing the form fields to present for an elicitation dialog. + * + * @since 1.0.0 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ElicitationSchema { + + @JsonProperty("type") + private String type = "object"; + + @JsonProperty("properties") + private Map properties; + + @JsonProperty("required") + private List required; + + /** + * Gets the schema type indicator (always {@code "object"}). + * + * @return the type + */ + public String getType() { + return type; + } + + /** + * Sets the schema type indicator. + * + * @param type + * the type (typically {@code "object"}) + * @return this instance for method chaining + */ + public ElicitationSchema setType(String type) { + this.type = type; + return this; + } + + /** + * Gets the form field definitions, keyed by field name. + * + * @return the properties map + */ + public Map getProperties() { + return properties; + } + + /** + * Sets the form field definitions, keyed by field name. + * + * @param properties + * the properties map + * @return this instance for method chaining + */ + public ElicitationSchema setProperties(Map properties) { + this.properties = properties; + return this; + } + + /** + * Gets the list of required field names. + * + * @return the required field names, or {@code null} + */ + public List getRequired() { + return required; + } + + /** + * Sets the list of required field names. + * + * @param required + * the required field names + * @return this instance for method chaining + */ + public ElicitationSchema setRequired(List required) { + this.required = required; + return this; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/GetSessionMetadataResponse.java b/src/main/java/com/github/copilot/sdk/json/GetSessionMetadataResponse.java new file mode 100644 index 000000000..87c686f58 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/GetSessionMetadataResponse.java @@ -0,0 +1,15 @@ +package com.github.copilot.sdk.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Internal response object from getting session metadata by ID. + * + * @param session + * the session metadata, or {@code null} if not found + * @since 1.0.0 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record GetSessionMetadataResponse(@JsonProperty("session") SessionMetadata session) { +} diff --git a/src/main/java/com/github/copilot/sdk/json/InputOptions.java b/src/main/java/com/github/copilot/sdk/json/InputOptions.java new file mode 100644 index 000000000..9b0b6c8dd --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/InputOptions.java @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +/** + * Options for the {@link SessionUiApi#input(String, InputOptions)} convenience + * method. + * + * @since 1.0.0 + */ +public class InputOptions { + + private String title; + private String description; + private Integer minLength; + private Integer maxLength; + private String format; + private String defaultValue; + + /** Gets the title label for the input field. @return the title */ + public String getTitle() { + return title; + } + + /** + * Sets the title label for the input field. @param title the title @return this + */ + public InputOptions setTitle(String title) { + this.title = title; + return this; + } + + /** Gets the descriptive text shown below the field. @return the description */ + public String getDescription() { + return description; + } + + /** + * Sets the descriptive text shown below the field. @param description the + * description @return this + */ + public InputOptions setDescription(String description) { + this.description = description; + return this; + } + + /** Gets the minimum character length. @return the min length */ + public Integer getMinLength() { + return minLength; + } + + /** + * Sets the minimum character length. @param minLength the min length @return + * this + */ + public InputOptions setMinLength(Integer minLength) { + this.minLength = minLength; + return this; + } + + /** Gets the maximum character length. @return the max length */ + public Integer getMaxLength() { + return maxLength; + } + + /** + * Sets the maximum character length. @param maxLength the max length @return + * this + */ + public InputOptions setMaxLength(Integer maxLength) { + this.maxLength = maxLength; + return this; + } + + /** + * Gets the semantic format hint (e.g., {@code "email"}, {@code "uri"}, + * {@code "date"}, {@code "date-time"}). + * + * @return the format hint + */ + public String getFormat() { + return format; + } + + /** Sets the semantic format hint. @param format the format @return this */ + public InputOptions setFormat(String format) { + this.format = format; + return this; + } + + /** + * Gets the default value pre-populated in the field. @return the default value + */ + public String getDefaultValue() { + return defaultValue; + } + + /** + * Sets the default value pre-populated in the field. @param defaultValue the + * default value @return this + */ + public InputOptions setDefaultValue(String defaultValue) { + this.defaultValue = defaultValue; + return this; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java b/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java index eab3c789c..139f5238b 100644 --- a/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java +++ b/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java @@ -58,6 +58,8 @@ public class ResumeSessionConfig { private List disabledSkills; private InfiniteSessionConfig infiniteSessions; private Consumer onEvent; + private List commands; + private ElicitationHandler onElicitationRequest; /** * Gets the AI model to use. @@ -555,6 +557,56 @@ public ResumeSessionConfig setOnEvent(Consumer onEvent) { return this; } + /** + * Gets the slash commands registered for this session. + * + * @return the list of command definitions, or {@code null} + */ + public List getCommands() { + return commands == null ? null : Collections.unmodifiableList(commands); + } + + /** + * Sets slash commands registered for this session. + *

+ * When the CLI has a TUI, each command appears as {@code /name} for the user to + * invoke. The handler is called when the user executes the command. + * + * @param commands + * the list of command definitions + * @return this config for method chaining + * @see CommandDefinition + */ + public ResumeSessionConfig setCommands(List commands) { + this.commands = commands; + return this; + } + + /** + * Gets the elicitation request handler. + * + * @return the elicitation handler, or {@code null} + */ + public ElicitationHandler getOnElicitationRequest() { + return onElicitationRequest; + } + + /** + * Sets a handler for elicitation requests from the server or MCP tools. + *

+ * When provided, the server will route elicitation requests to this handler and + * report elicitation as a supported capability. + * + * @param onElicitationRequest + * the elicitation handler + * @return this config for method chaining + * @see ElicitationHandler + */ + public ResumeSessionConfig setOnElicitationRequest(ElicitationHandler onElicitationRequest) { + this.onElicitationRequest = onElicitationRequest; + return this; + } + /** * Creates a shallow clone of this {@code ResumeSessionConfig} instance. *

@@ -591,6 +643,8 @@ public ResumeSessionConfig clone() { copy.disabledSkills = this.disabledSkills != null ? new ArrayList<>(this.disabledSkills) : null; copy.infiniteSessions = this.infiniteSessions; copy.onEvent = this.onEvent; + copy.commands = this.commands != null ? new ArrayList<>(this.commands) : null; + copy.onElicitationRequest = this.onElicitationRequest; return copy; } } diff --git a/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java b/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java index 31d88399a..7be9a6281 100644 --- a/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java +++ b/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java @@ -95,6 +95,12 @@ public final class ResumeSessionRequest { @JsonProperty("infiniteSessions") private InfiniteSessionConfig infiniteSessions; + @JsonProperty("commands") + private List commands; + + @JsonProperty("requestElicitation") + private Boolean requestElicitation; + /** Gets the session ID. @return the session ID */ public String getSessionId() { return sessionId; @@ -332,4 +338,24 @@ public InfiniteSessionConfig getInfiniteSessions() { public void setInfiniteSessions(InfiniteSessionConfig infiniteSessions) { this.infiniteSessions = infiniteSessions; } + + /** Gets the commands wire definitions. @return the commands */ + public List getCommands() { + return commands == null ? null : Collections.unmodifiableList(commands); + } + + /** Sets the commands wire definitions. @param commands the commands */ + public void setCommands(List commands) { + this.commands = commands; + } + + /** Gets the requestElicitation flag. @return the flag */ + public Boolean getRequestElicitation() { + return requestElicitation; + } + + /** Sets the requestElicitation flag. @param requestElicitation the flag */ + public void setRequestElicitation(Boolean requestElicitation) { + this.requestElicitation = requestElicitation; + } } diff --git a/src/main/java/com/github/copilot/sdk/json/ResumeSessionResponse.java b/src/main/java/com/github/copilot/sdk/json/ResumeSessionResponse.java index 654c1486c..8349c5d30 100644 --- a/src/main/java/com/github/copilot/sdk/json/ResumeSessionResponse.java +++ b/src/main/java/com/github/copilot/sdk/json/ResumeSessionResponse.java @@ -11,9 +11,12 @@ * @param workspacePath * the workspace path, or {@code null} if infinite sessions are * disabled + * @param capabilities + * the capabilities reported by the host, or {@code null} * @since 1.0.0 */ @JsonInclude(JsonInclude.Include.NON_NULL) public record ResumeSessionResponse(@JsonProperty("sessionId") String sessionId, - @JsonProperty("workspacePath") String workspacePath) { + @JsonProperty("workspacePath") String workspacePath, + @JsonProperty("capabilities") SessionCapabilities capabilities) { } diff --git a/src/main/java/com/github/copilot/sdk/json/SessionCapabilities.java b/src/main/java/com/github/copilot/sdk/json/SessionCapabilities.java new file mode 100644 index 000000000..4eb4fc025 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/SessionCapabilities.java @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +/** + * Represents the capabilities reported by the host for a session. + *

+ * Capabilities are populated from the session create/resume response and + * updated in real time via {@code capabilities.changed} events. + * + * @since 1.0.0 + */ +public class SessionCapabilities { + + private SessionUiCapabilities ui; + + /** + * Gets the UI-related capabilities. + * + * @return the UI capabilities, or {@code null} if not reported + */ + public SessionUiCapabilities getUi() { + return ui; + } + + /** + * Sets the UI-related capabilities. + * + * @param ui + * the UI capabilities + * @return this instance for method chaining + */ + public SessionCapabilities setUi(SessionUiCapabilities ui) { + this.ui = ui; + return this; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/SessionConfig.java b/src/main/java/com/github/copilot/sdk/json/SessionConfig.java index 76c15660d..5dcd39788 100644 --- a/src/main/java/com/github/copilot/sdk/json/SessionConfig.java +++ b/src/main/java/com/github/copilot/sdk/json/SessionConfig.java @@ -58,6 +58,8 @@ public class SessionConfig { private List disabledSkills; private String configDir; private Consumer onEvent; + private List commands; + private ElicitationHandler onElicitationRequest; /** * Gets the custom session ID. @@ -595,6 +597,56 @@ public SessionConfig setOnEvent(Consumer onEvent) { return this; } + /** + * Gets the slash commands registered for this session. + * + * @return the list of command definitions, or {@code null} + */ + public List getCommands() { + return commands == null ? null : Collections.unmodifiableList(commands); + } + + /** + * Sets slash commands registered for this session. + *

+ * When the CLI has a TUI, each command appears as {@code /name} for the user to + * invoke. The handler is called when the user executes the command. + * + * @param commands + * the list of command definitions + * @return this config instance for method chaining + * @see CommandDefinition + */ + public SessionConfig setCommands(List commands) { + this.commands = commands; + return this; + } + + /** + * Gets the elicitation request handler. + * + * @return the elicitation handler, or {@code null} + */ + public ElicitationHandler getOnElicitationRequest() { + return onElicitationRequest; + } + + /** + * Sets a handler for elicitation requests from the server or MCP tools. + *

+ * When provided, the server will route elicitation requests to this handler and + * report elicitation as a supported capability. + * + * @param onElicitationRequest + * the elicitation handler + * @return this config instance for method chaining + * @see ElicitationHandler + */ + public SessionConfig setOnElicitationRequest(ElicitationHandler onElicitationRequest) { + this.onElicitationRequest = onElicitationRequest; + return this; + } + /** * Creates a shallow clone of this {@code SessionConfig} instance. *

@@ -631,6 +683,8 @@ public SessionConfig clone() { copy.disabledSkills = this.disabledSkills != null ? new ArrayList<>(this.disabledSkills) : null; copy.configDir = this.configDir; copy.onEvent = this.onEvent; + copy.commands = this.commands != null ? new ArrayList<>(this.commands) : null; + copy.onElicitationRequest = this.onElicitationRequest; return copy; } } diff --git a/src/main/java/com/github/copilot/sdk/json/SessionUiApi.java b/src/main/java/com/github/copilot/sdk/json/SessionUiApi.java new file mode 100644 index 000000000..bfc9fc161 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/SessionUiApi.java @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +import java.util.concurrent.CompletableFuture; + +/** + * Provides UI methods for eliciting information from the user during a session. + *

+ * All methods on this interface throw {@link IllegalStateException} if the host + * does not report elicitation support via + * {@link com.github.copilot.sdk.CopilotSession#getCapabilities()}. Check + * {@code session.getCapabilities().getUi()?.getElicitation() == true} before + * calling. + * + *

Example Usage

+ * + *
{@code
+ * if (Boolean.TRUE
+ * 		.equals(session.getCapabilities().getUi() != null && session.getCapabilities().getUi().getElicitation())) {
+ * 	boolean confirmed = session.getUi().confirm("Are you sure?").get();
+ * }
+ * }
+ * + * @see com.github.copilot.sdk.CopilotSession#getUi() + * @since 1.0.0 + */ +public interface SessionUiApi { + + /** + * Shows a generic elicitation dialog with a custom schema. + * + * @param params + * the elicitation parameters including message and schema + * @return a future that resolves with the {@link ElicitationResult} + * @throws IllegalStateException + * if the host does not support elicitation + */ + CompletableFuture elicitation(ElicitationParams params); + + /** + * Shows a confirmation dialog and returns the user's boolean answer. + *

+ * Returns {@code false} if the user declines or cancels. + * + * @param message + * the message to display + * @return a future that resolves to {@code true} if the user confirmed + * @throws IllegalStateException + * if the host does not support elicitation + */ + CompletableFuture confirm(String message); + + /** + * Shows a selection dialog with the given options. + *

+ * Returns the selected value, or {@code null} if the user declines/cancels. + * + * @param message + * the message to display + * @param options + * the options to present + * @return a future that resolves to the selected string, or {@code null} + * @throws IllegalStateException + * if the host does not support elicitation + */ + CompletableFuture select(String message, String[] options); + + /** + * Shows a text input dialog. + *

+ * Returns the entered text, or {@code null} if the user declines/cancels. + * + * @param message + * the message to display + * @param options + * optional input field options, or {@code null} + * @return a future that resolves to the entered string, or {@code null} + * @throws IllegalStateException + * if the host does not support elicitation + */ + CompletableFuture input(String message, InputOptions options); +} diff --git a/src/main/java/com/github/copilot/sdk/json/SessionUiCapabilities.java b/src/main/java/com/github/copilot/sdk/json/SessionUiCapabilities.java new file mode 100644 index 000000000..9b8e0b587 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/SessionUiCapabilities.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +/** + * UI-specific capability flags for a session. + * + * @since 1.0.0 + */ +public class SessionUiCapabilities { + + private Boolean elicitation; + + /** + * Returns whether the host supports interactive elicitation dialogs. + * + * @return {@code true} if elicitation is supported, {@code false} or + * {@code null} otherwise + */ + public Boolean getElicitation() { + return elicitation; + } + + /** + * Sets whether the host supports interactive elicitation dialogs. + * + * @param elicitation + * {@code true} if elicitation is supported + * @return this instance for method chaining + */ + public SessionUiCapabilities setElicitation(Boolean elicitation) { + this.elicitation = elicitation; + return this; + } +} From 4001135367c73413f74b4f6e4cd8c3490b4b4cda Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:58:53 +0000 Subject: [PATCH 39/69] Add tests, documentation, and update .lastmerge to f7fd757 Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/16d575c4-83f4-4d20-99d9-b48635b3791d Co-authored-by: edburns <75821+edburns@users.noreply.github.com> --- .lastmerge | 2 +- README.md | 2 +- src/site/markdown/advanced.md | 137 +++++++++++++ src/site/markdown/index.md | 2 +- .../com/github/copilot/sdk/CommandsTest.java | 138 +++++++++++++ .../copilot/sdk/CopilotSessionTest.java | 28 +++ .../github/copilot/sdk/ElicitationTest.java | 191 ++++++++++++++++++ 7 files changed, 497 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com/github/copilot/sdk/CommandsTest.java create mode 100644 src/test/java/com/github/copilot/sdk/ElicitationTest.java diff --git a/.lastmerge b/.lastmerge index a0cf76b72..0d0067b5b 100644 --- a/.lastmerge +++ b/.lastmerge @@ -1 +1 @@ -40887393a9e687dacc141a645799441b0313ff15 +f7fd7577109d64e261456b16c49baa56258eae4e diff --git a/README.md b/README.md index 3010b6839..f71424950 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build A ### Requirements - Java 17 or later. **JDK 25 recommended**. Selecting JDK 25 enables the use of virtual threads, as shown in the [Quick Start](#quick-start). -- GitHub Copilot 1.0.15-0 or later installed and in `PATH` (or provide custom `cliPath`) +- GitHub Copilot 1.0.17 or later installed and in `PATH` (or provide custom `cliPath`) ### Maven diff --git a/src/site/markdown/advanced.md b/src/site/markdown/advanced.md index bc9302840..598acc2f7 100644 --- a/src/site/markdown/advanced.md +++ b/src/site/markdown/advanced.md @@ -1093,6 +1093,143 @@ See [TelemetryConfig](apidocs/com/github/copilot/sdk/json/TelemetryConfig.html) --- +## Slash Commands + +Register custom slash commands that users can invoke from the CLI TUI with `/commandname`. + +### Registering Commands + +```java +var config = new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setCommands(List.of( + new CommandDefinition() + .setName("deploy") + .setDescription("Deploy the current branch") + .setHandler(context -> { + System.out.println("Deploying with args: " + context.getArgs()); + // perform deployment ... + return CompletableFuture.completedFuture(null); + }), + new CommandDefinition() + .setName("rollback") + .setDescription("Roll back the last deployment") + .setHandler(context -> { + // perform rollback ... + return CompletableFuture.completedFuture(null); + }) + )); + +try (CopilotClient client = new CopilotClient()) { + client.start().get(); + var session = client.createSession(config).get(); + // Users can now type /deploy or /rollback in the TUI +} +``` + +Each `CommandDefinition` requires a `name` (without the leading `/`), an optional `description` shown in the TUI's command completion UI, and a `CommandHandler` that is invoked when the user executes the command. + +The `CommandContext` passed to the handler provides: +- `getSessionId()` — the ID of the session where the command was invoked +- `getCommand()` — the full command text (e.g., `/deploy production`) +- `getCommandName()` — command name without the leading `/` (e.g., `deploy`) +- `getArgs()` — the argument string after the command name (e.g., `production`) + +--- + +## Elicitation (UI Dialogs) + +Elicitation allows your application to present structured UI dialogs to the user. There are two directions: + +1. **Incoming** — The server or an MCP tool requests input from the user via your `onElicitationRequest` handler. +2. **Outgoing** — Your session-side code proactively requests input via `session.getUi()`. + +### Incoming Elicitation Handler + +Register a handler to receive elicitation requests from the server: + +```java +var config = new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setOnElicitationRequest(context -> { + System.out.println("Elicitation request: " + context.getMessage()); + // Show the form to the user ... + var content = Map.of("confirmed", true); + return CompletableFuture.completedFuture( + new ElicitationResult() + .setAction(ElicitationResultAction.ACCEPT) + .setContent(content) + ); + }); +``` + +When `onElicitationRequest` is set, the SDK reports elicitation as a supported capability and the server will route elicitation requests to your handler. + +### Session Capabilities + +After `createSession` or `resumeSession`, check `session.getCapabilities()` to see what the host supports: + +```java +var session = client.createSession(config).get(); + +var caps = session.getCapabilities(); +if (caps.getUi() != null && Boolean.TRUE.equals(caps.getUi().getElicitation())) { + System.out.println("Elicitation is supported"); +} +``` + +Capabilities are updated in real time when a `capabilities.changed` event is received. + +### Outgoing Elicitation via `session.getUi()` + +If the host reports elicitation support, you can call the convenience methods on `session.getUi()`: + +```java +var ui = session.getUi(); + +// Boolean confirmation +boolean confirmed = ui.confirm("Are you sure you want to proceed?").get(); + +// Selection from options +String choice = ui.select("Choose an environment", new String[]{"dev", "staging", "prod"}).get(); + +// Text input +String value = ui.input("Enter your name", null).get(); + +// Custom schema +var result = ui.elicitation(new ElicitationParams() + .setMessage("Enter deployment details") + .setRequestedSchema(new ElicitationSchema() + .setProperties(Map.of( + "branch", Map.of("type", "string"), + "environment", Map.of("type", "string", "enum", List.of("dev", "staging", "prod")) + )) + .setRequired(List.of("branch", "environment")) + )).get(); +``` + +All `getUi()` methods throw `IllegalStateException` if the host does not support elicitation. Always check capabilities first. + +--- + +## Getting Session Metadata by ID + +Retrieve metadata for a specific session without listing all sessions: + +```java +SessionMetadata metadata = client.getSessionMetadata("session-123").get(); +if (metadata != null) { + System.out.println("Session: " + metadata.getSessionId()); + System.out.println("Started: " + metadata.getStartTime()); +} else { + System.out.println("Session not found"); +} +``` + +This is more efficient than `listSessions()` when you already know the session ID, as it performs a direct O(1) lookup instead of scanning all sessions. + +--- + ## Next Steps - 📖 **[Documentation](documentation.html)** - Core concepts, events, streaming, models, tool filtering, reasoning effort diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index 2f93c4ce9..b599484d9 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -9,7 +9,7 @@ Welcome to the documentation for the **GitHub Copilot SDK for Java** — a Java ### Requirements - Java 17 or later -- GitHub Copilot CLI 0.0.411-1 or later installed and in PATH (or provide custom `cliPath`) +- GitHub Copilot CLI 1.0.17 or later installed and in PATH (or provide custom `cliPath`) ### Installation diff --git a/src/test/java/com/github/copilot/sdk/CommandsTest.java b/src/test/java/com/github/copilot/sdk/CommandsTest.java new file mode 100644 index 000000000..dad26afbb --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/CommandsTest.java @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.Test; + +import com.github.copilot.sdk.json.CommandContext; +import com.github.copilot.sdk.json.CommandDefinition; +import com.github.copilot.sdk.json.CommandHandler; +import com.github.copilot.sdk.json.CommandWireDefinition; +import com.github.copilot.sdk.json.PermissionHandler; +import com.github.copilot.sdk.json.ResumeSessionConfig; +import com.github.copilot.sdk.json.SessionConfig; + +/** + * Unit tests for the Commands feature (CommandDefinition, CommandContext, + * SessionConfig.commands, ResumeSessionConfig.commands, and the wire + * representation). + * + *

+ * Ported from {@code CommandsTests.cs} in the upstream dotnet SDK. + *

+ */ +class CommandsTest { + + @Test + void commandDefinitionHasRequiredProperties() { + CommandHandler handler = context -> CompletableFuture.completedFuture(null); + var cmd = new CommandDefinition().setName("deploy").setDescription("Deploy the app").setHandler(handler); + + assertEquals("deploy", cmd.getName()); + assertEquals("Deploy the app", cmd.getDescription()); + assertNotNull(cmd.getHandler()); + } + + @Test + void commandContextHasAllProperties() { + var ctx = new CommandContext().setSessionId("session-1").setCommand("/deploy production") + .setCommandName("deploy").setArgs("production"); + + assertEquals("session-1", ctx.getSessionId()); + assertEquals("/deploy production", ctx.getCommand()); + assertEquals("deploy", ctx.getCommandName()); + assertEquals("production", ctx.getArgs()); + } + + @Test + void sessionConfigCommandsAreCloned() { + CommandHandler handler = ctx -> CompletableFuture.completedFuture(null); + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setCommands(List.of(new CommandDefinition().setName("deploy").setHandler(handler))); + + var clone = config.clone(); + + assertNotNull(clone.getCommands()); + assertEquals(1, clone.getCommands().size()); + assertEquals("deploy", clone.getCommands().get(0).getName()); + + // Collections should be independent — clone list is a copy + assertNotSame(config.getCommands(), clone.getCommands()); + } + + @Test + void resumeConfigCommandsAreCloned() { + CommandHandler handler = ctx -> CompletableFuture.completedFuture(null); + var config = new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setCommands(List.of(new CommandDefinition().setName("deploy").setHandler(handler))); + + var clone = config.clone(); + + assertNotNull(clone.getCommands()); + assertEquals(1, clone.getCommands().size()); + assertEquals("deploy", clone.getCommands().get(0).getName()); + } + + @Test + void buildCreateRequestIncludesCommandWireDefinitions() { + CommandHandler handler = ctx -> CompletableFuture.completedFuture(null); + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setCommands( + List.of(new CommandDefinition().setName("deploy").setDescription("Deploy").setHandler(handler), + new CommandDefinition().setName("rollback").setHandler(handler))); + + var request = SessionRequestBuilder.buildCreateRequest(config); + + assertNotNull(request.getCommands()); + assertEquals(2, request.getCommands().size()); + assertEquals("deploy", request.getCommands().get(0).getName()); + assertEquals("Deploy", request.getCommands().get(0).getDescription()); + assertEquals("rollback", request.getCommands().get(1).getName()); + assertNull(request.getCommands().get(1).getDescription()); + } + + @Test + void buildResumeRequestIncludesCommandWireDefinitions() { + CommandHandler handler = ctx -> CompletableFuture.completedFuture(null); + var config = new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setCommands( + List.of(new CommandDefinition().setName("deploy").setDescription("Deploy").setHandler(handler))); + + var request = SessionRequestBuilder.buildResumeRequest("session-1", config); + + assertNotNull(request.getCommands()); + assertEquals(1, request.getCommands().size()); + assertEquals("deploy", request.getCommands().get(0).getName()); + assertEquals("Deploy", request.getCommands().get(0).getDescription()); + } + + @Test + void buildCreateRequestWithNoCommandsHasNullCommandsList() { + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL); + + var request = SessionRequestBuilder.buildCreateRequest(config); + + assertNull(request.getCommands()); + } + + @Test + void commandWireDefinitionHasNameAndDescription() { + var wire = new CommandWireDefinition("deploy", "Deploy the app"); + + assertEquals("deploy", wire.getName()); + assertEquals("Deploy the app", wire.getDescription()); + } + + @Test + void commandWireDefinitionNullDescriptionAllowed() { + var wire = new CommandWireDefinition("rollback", null); + + assertEquals("rollback", wire.getName()); + assertNull(wire.getDescription()); + } +} diff --git a/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java b/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java index 787312cef..39406d260 100644 --- a/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java +++ b/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java @@ -7,6 +7,7 @@ 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.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -821,4 +822,31 @@ void testSessionListFilterFluentAPI() throws Exception { session.close(); } } + + /** + * Verifies that getSessionMetadata returns metadata for a known session ID. + * + * @see Snapshot: session/should_get_session_metadata_by_id + */ + @Test + void testShouldGetSessionMetadataById() throws Exception { + ctx.configureForTest("session", "should_get_session_metadata_by_id"); + + try (CopilotClient client = ctx.createClient()) { + var session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + + session.sendAndWait(new MessageOptions().setPrompt("Say hello")).get(60, TimeUnit.SECONDS); + + var metadata = client.getSessionMetadata(session.getSessionId()).get(30, TimeUnit.SECONDS); + assertNotNull(metadata, "Metadata should not be null for known session"); + assertEquals(session.getSessionId(), metadata.getSessionId(), "Metadata session ID should match"); + + // A non-existent session should return null + var notFound = client.getSessionMetadata("non-existent-session-id").get(30, TimeUnit.SECONDS); + assertNull(notFound, "Non-existent session should return null"); + + session.close(); + } + } } diff --git a/src/test/java/com/github/copilot/sdk/ElicitationTest.java b/src/test/java/com/github/copilot/sdk/ElicitationTest.java new file mode 100644 index 000000000..330153dd2 --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/ElicitationTest.java @@ -0,0 +1,191 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.Test; + +import com.github.copilot.sdk.json.ElicitationContext; +import com.github.copilot.sdk.json.ElicitationHandler; +import com.github.copilot.sdk.json.ElicitationParams; +import com.github.copilot.sdk.json.ElicitationResult; +import com.github.copilot.sdk.json.ElicitationResultAction; +import com.github.copilot.sdk.json.ElicitationSchema; +import com.github.copilot.sdk.json.InputOptions; +import com.github.copilot.sdk.json.PermissionHandler; +import com.github.copilot.sdk.json.ResumeSessionConfig; +import com.github.copilot.sdk.json.SessionCapabilities; +import com.github.copilot.sdk.json.SessionConfig; +import com.github.copilot.sdk.json.SessionUiCapabilities; + +/** + * Unit tests for the Elicitation feature and Session Capabilities. + * + *

+ * Ported from {@code ElicitationTests.cs} in the upstream dotnet SDK. + *

+ */ +class ElicitationTest { + + @Test + void sessionCapabilitiesTypesAreProperlyStructured() { + var capabilities = new SessionCapabilities().setUi(new SessionUiCapabilities().setElicitation(true)); + + assertNotNull(capabilities.getUi()); + assertTrue(capabilities.getUi().getElicitation()); + + // Test with null UI + var emptyCapabilities = new SessionCapabilities(); + assertNull(emptyCapabilities.getUi()); + assertNull(emptyCapabilities.getUi()); + } + + @Test + void defaultCapabilitiesAreEmpty() { + var capabilities = new SessionCapabilities(); + + assertNull(capabilities.getUi()); + } + + @Test + void elicitationResultActionValues() { + assertEquals("accept", ElicitationResultAction.ACCEPT.getValue()); + assertEquals("decline", ElicitationResultAction.DECLINE.getValue()); + assertEquals("cancel", ElicitationResultAction.CANCEL.getValue()); + } + + @Test + void elicitationResultHasActionAndContent() { + var content = Map.of("name", (Object) "Alice"); + var result = new ElicitationResult().setAction(ElicitationResultAction.ACCEPT).setContent(content); + + assertEquals(ElicitationResultAction.ACCEPT, result.getAction()); + assertEquals(content, result.getContent()); + } + + @Test + void elicitationSchemaHasTypeAndProperties() { + var properties = Map.of("name", (Object) Map.of("type", "string")); + var schema = new ElicitationSchema().setType("object").setProperties(properties).setRequired(List.of("name")); + + assertEquals("object", schema.getType()); + assertEquals(properties, schema.getProperties()); + assertEquals(List.of("name"), schema.getRequired()); + } + + @Test + void elicitationSchemaDefaultTypeIsObject() { + var schema = new ElicitationSchema(); + + assertEquals("object", schema.getType()); + } + + @Test + void elicitationContextHasAllProperties() { + var properties = Map.of("field", (Object) Map.of("type", "string")); + var schema = new ElicitationSchema().setProperties(properties); + + var ctx = new ElicitationContext().setSessionId("session-1").setMessage("Please enter your name") + .setRequestedSchema(schema).setMode("form").setElicitationSource("mcp-server").setUrl(null); + + assertEquals("session-1", ctx.getSessionId()); + assertEquals("Please enter your name", ctx.getMessage()); + assertEquals(schema, ctx.getRequestedSchema()); + assertEquals("form", ctx.getMode()); + assertEquals("mcp-server", ctx.getElicitationSource()); + assertNull(ctx.getUrl()); + } + + @Test + void elicitationParamsHasMessageAndSchema() { + var schema = new ElicitationSchema().setProperties(Map.of("field", (Object) Map.of("type", "string"))); + var params = new ElicitationParams().setMessage("Enter name").setRequestedSchema(schema); + + assertEquals("Enter name", params.getMessage()); + assertEquals(schema, params.getRequestedSchema()); + } + + @Test + void inputOptionsHasAllFields() { + var opts = new InputOptions().setTitle("My Title").setDescription("My Desc").setMinLength(1).setMaxLength(100) + .setFormat("email").setDefaultValue("default@example.com"); + + assertEquals("My Title", opts.getTitle()); + assertEquals("My Desc", opts.getDescription()); + assertEquals(1, opts.getMinLength()); + assertEquals(100, opts.getMaxLength()); + assertEquals("email", opts.getFormat()); + assertEquals("default@example.com", opts.getDefaultValue()); + } + + @Test + void sessionConfigOnElicitationRequestIsCloned() { + ElicitationHandler handler = ctx -> CompletableFuture + .completedFuture(new ElicitationResult().setAction(ElicitationResultAction.ACCEPT)); + + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setOnElicitationRequest(handler); + + var clone = config.clone(); + + // Handler reference is shared (not deep-cloned), but the field is copied + assertNotNull(clone.getOnElicitationRequest()); + assertSame(handler, clone.getOnElicitationRequest()); + } + + @Test + void resumeConfigOnElicitationRequestIsCloned() { + ElicitationHandler handler = ctx -> CompletableFuture + .completedFuture(new ElicitationResult().setAction(ElicitationResultAction.CANCEL)); + + var config = new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setOnElicitationRequest(handler); + + var clone = config.clone(); + + assertNotNull(clone.getOnElicitationRequest()); + assertSame(handler, clone.getOnElicitationRequest()); + } + + @Test + void buildCreateRequestSetsRequestElicitationWhenHandlerPresent() { + ElicitationHandler handler = ctx -> CompletableFuture + .completedFuture(new ElicitationResult().setAction(ElicitationResultAction.ACCEPT)); + + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setOnElicitationRequest(handler); + + var request = SessionRequestBuilder.buildCreateRequest(config); + + assertTrue(Boolean.TRUE.equals(request.getRequestElicitation())); + } + + @Test + void buildCreateRequestDoesNotSetRequestElicitationWhenNoHandler() { + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL); + + var request = SessionRequestBuilder.buildCreateRequest(config); + + assertNull(request.getRequestElicitation()); + } + + @Test + void buildResumeRequestSetsRequestElicitationWhenHandlerPresent() { + ElicitationHandler handler = ctx -> CompletableFuture + .completedFuture(new ElicitationResult().setAction(ElicitationResultAction.ACCEPT)); + + var config = new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setOnElicitationRequest(handler); + + var request = SessionRequestBuilder.buildResumeRequest("session-1", config); + + assertTrue(Boolean.TRUE.equals(request.getRequestElicitation())); + } +} From 2da6b5a8334f1fc9c696858401856e277d3e1948 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:01:31 +0000 Subject: [PATCH 40/69] Fix code review issues: duplicate assertion, copyright header, Javadoc example Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/16d575c4-83f4-4d20-99d9-b48635b3791d Co-authored-by: edburns <75821+edburns@users.noreply.github.com> --- .../github/copilot/sdk/json/GetSessionMetadataResponse.java | 4 ++++ src/main/java/com/github/copilot/sdk/json/SessionUiApi.java | 4 ++-- src/test/java/com/github/copilot/sdk/ElicitationTest.java | 1 - 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/github/copilot/sdk/json/GetSessionMetadataResponse.java b/src/main/java/com/github/copilot/sdk/json/GetSessionMetadataResponse.java index 87c686f58..eeceb4177 100644 --- a/src/main/java/com/github/copilot/sdk/json/GetSessionMetadataResponse.java +++ b/src/main/java/com/github/copilot/sdk/json/GetSessionMetadataResponse.java @@ -1,3 +1,7 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + package com.github.copilot.sdk.json; import com.fasterxml.jackson.annotation.JsonInclude; diff --git a/src/main/java/com/github/copilot/sdk/json/SessionUiApi.java b/src/main/java/com/github/copilot/sdk/json/SessionUiApi.java index bfc9fc161..6dc583457 100644 --- a/src/main/java/com/github/copilot/sdk/json/SessionUiApi.java +++ b/src/main/java/com/github/copilot/sdk/json/SessionUiApi.java @@ -18,8 +18,8 @@ *

Example Usage

* *
{@code
- * if (Boolean.TRUE
- * 		.equals(session.getCapabilities().getUi() != null && session.getCapabilities().getUi().getElicitation())) {
+ * var caps = session.getCapabilities();
+ * if (caps.getUi() != null && Boolean.TRUE.equals(caps.getUi().getElicitation())) {
  * 	boolean confirmed = session.getUi().confirm("Are you sure?").get();
  * }
  * }
diff --git a/src/test/java/com/github/copilot/sdk/ElicitationTest.java b/src/test/java/com/github/copilot/sdk/ElicitationTest.java index 330153dd2..d6e2ac0b5 100644 --- a/src/test/java/com/github/copilot/sdk/ElicitationTest.java +++ b/src/test/java/com/github/copilot/sdk/ElicitationTest.java @@ -44,7 +44,6 @@ void sessionCapabilitiesTypesAreProperlyStructured() { // Test with null UI var emptyCapabilities = new SessionCapabilities(); assertNull(emptyCapabilities.getUi()); - assertNull(emptyCapabilities.getUi()); } @Test From 7060eed9c996e58f9c3896a72e65fa108dfc509b Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Mon, 6 Apr 2026 13:12:05 -0400 Subject: [PATCH 41/69] Update src/main/java/com/github/copilot/sdk/CopilotSession.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/main/java/com/github/copilot/sdk/CopilotSession.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 768f0adaa..23b1b5368 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -724,7 +724,7 @@ private void handleBroadcastEventAsync(AbstractSessionEvent event) { executePermissionAndRespondAsync(data.requestId(), data.permissionRequest(), handler); } else if (event instanceof CommandExecuteEvent cmdEvent) { var data = cmdEvent.getData(); - if (data == null || data.requestId() == null) { + if (data == null || data.requestId() == null || data.commandName() == null) { return; } executeCommandAndRespondAsync(data.requestId(), data.commandName(), data.command(), data.args()); From 0bd084c6a6c8c06a6becf63d8e2efc4d51b5ef68 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Tue, 31 Mar 2026 16:33:52 -0400 Subject: [PATCH 42/69] Reapply "On branch edburns/spotless-agentic-workflow-42" This reverts commit 05d06d97f398a092897725a7651af10f96a047da. --- .github/copilot-instructions.md | 12 ++++++++++++ .github/workflows/copilot-setup-steps.yml | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d7dafb081..e3a8eb275 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -244,6 +244,18 @@ This SDK is designed to be **lightweight with minimal dependencies**: 5. Check for security vulnerabilities 6. Get team approval for non-trivial additions +## Pre-commit Hooks and Formatting (Coding Agent) + +The repository has a pre-commit hook (`.githooks/pre-commit`) that is **automatically enabled** in the Copilot coding agent environment via `copilot-setup-steps.yml`. The hook runs `mvn spotless:check` on any commit that includes changes under `src/`. + +**If a commit fails due to the pre-commit hook:** + +1. Run `mvn spotless:apply` to auto-fix formatting issues. +2. Re-stage the changed files with `git add -u`. +3. Retry the commit. + +**Best practice:** Always run `mvn spotless:apply` before committing Java source changes to avoid hook failures in the first place. If you forget and the hook rejects the commit, follow the three steps above and continue. + ## Commit and PR Guidelines ### Commit Messages diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 6a0cdec5b..8d8aa75c9 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -41,6 +41,10 @@ jobs: distribution: 'temurin' cache: 'maven' + # Enable pre-commit hooks so Spotless formatting is enforced on every commit + - name: Enable pre-commit hooks + run: git config core.hooksPath .githooks + # Verify installations - name: Verify tool installations run: | @@ -50,4 +54,6 @@ jobs: java -version gh --version gh aw version + echo "--- Git hooks path ---" + git config core.hooksPath echo "✅ All tools installed successfully" From 99d456780806e148292e25b51d0f8449545c00bd Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Tue, 31 Mar 2026 16:33:56 -0400 Subject: [PATCH 43/69] Reapply "Update .github/workflows/copilot-setup-steps.yml" This reverts commit afc34c439dd3e393840fe3c213ccc4d7b12fed39. --- .github/workflows/copilot-setup-steps.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 8d8aa75c9..145629457 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -41,7 +41,7 @@ jobs: distribution: 'temurin' cache: 'maven' - # Enable pre-commit hooks so Spotless formatting is enforced on every commit + # Enable repository pre-commit hooks (including Spotless checks for relevant source changes) - name: Enable pre-commit hooks run: git config core.hooksPath .githooks From e665e2100ab90262ba9e3a4d2fc04ca1f45c339e Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Mon, 6 Apr 2026 13:27:33 -0400 Subject: [PATCH 44/69] On branch edburns/re-enable-pre-commit-hooks modified: .github/copilot-instructions.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply @Copilot review recommendation. > This new section says the pre-commit hook is “automatically enabled” via `copilot-setup-steps.yml`, but earlier in the same document (and in CONTRIBUTING/README) the hook is described as something developers must manually enable with `git config core.hooksPath .githooks`. Please clarify the scope (e.g., auto-enabled only in the Copilot coding agent environment; local development still requires manual enablement) to avoid conflicting guidance, and consider linking explicitly to `.github/workflows/copilot-setup-steps.yml` for precision. Signed-off-by: Ed Burns --- .github/copilot-instructions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e3a8eb275..284e2b800 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -104,7 +104,7 @@ When porting from .NET: - 4-space indentation (enforced by Spotless with Eclipse formatter) - Fluent setter pattern for configuration classes (e.g., `new SessionConfig().setModel("gpt-5").setTools(tools)`) - Public APIs require Javadoc (enforced by Checkstyle, except `json` and `events` packages) -- Pre-commit hook runs `mvn spotless:check` - enable with: `git config core.hooksPath .githooks` +- Pre-commit hook runs `mvn spotless:check` - Must be manually enabled with: `git config core.hooksPath .githooks`, except in the Copilot coding agent environment. This hook is explicitly enabled in the Copilot coding agent environment. See [copilot-setup-steps.yml](workflows/copilot-setup-steps.yml). ### Handler Pattern From 8cb5827b9423bdf91fe0c1cf8ac0abb1fec53a11 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Mon, 6 Apr 2026 13:40:27 -0400 Subject: [PATCH 45/69] Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f71424950..677389c6e 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build A ### Requirements - Java 17 or later. **JDK 25 recommended**. Selecting JDK 25 enables the use of virtual threads, as shown in the [Quick Start](#quick-start). -- GitHub Copilot 1.0.17 or later installed and in `PATH` (or provide custom `cliPath`) +- GitHub Copilot CLI 1.0.17 or later installed and in `PATH` (or provide custom `cliPath`) ### Maven From e365e05e692ff57f6371c43837e8db7ec299e6a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:42:42 +0000 Subject: [PATCH 46/69] Fix invalid Java ?. operator in SessionUiApi Javadoc prose Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/b5d79053-ae26-4c98-9047-b2457c08b0b8 Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- src/main/java/com/github/copilot/sdk/json/SessionUiApi.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/github/copilot/sdk/json/SessionUiApi.java b/src/main/java/com/github/copilot/sdk/json/SessionUiApi.java index 6dc583457..f0a43f261 100644 --- a/src/main/java/com/github/copilot/sdk/json/SessionUiApi.java +++ b/src/main/java/com/github/copilot/sdk/json/SessionUiApi.java @@ -12,8 +12,9 @@ * All methods on this interface throw {@link IllegalStateException} if the host * does not report elicitation support via * {@link com.github.copilot.sdk.CopilotSession#getCapabilities()}. Check - * {@code session.getCapabilities().getUi()?.getElicitation() == true} before - * calling. + * {@code session.getCapabilities().getUi() != null && + * Boolean.TRUE.equals(session.getCapabilities().getUi().getElicitation())} + * before calling. * *

Example Usage

* From 8835bd0fe108bff43844c0b678a0143d735bcb17 Mon Sep 17 00:00:00 2001 From: brunoborges <129743+brunoborges@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:29:56 +0000 Subject: [PATCH 47/69] Update JaCoCo coverage badge --- .github/badges/jacoco.svg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 482adb13b..a310734d0 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -6,13 +6,13 @@ - + coverage coverage - 84.7% - 84.7% + 78.3% + 78.3% From 538f138ad33680771cdb132b1c5ea9d856d49fa4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:18:16 +0000 Subject: [PATCH 48/69] Initial plan From 2bd1bf82152887efe1d6e5c2edf2568faeb0b3d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:50:36 +0000 Subject: [PATCH 49/69] Add tests to increase JaCoCo coverage from 78.7% to 85.2% Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/ee2608f6-2d6c-477e-9f79-a2968dec2436 Co-authored-by: edburns <75821+edburns@users.noreply.github.com> --- .../com/github/copilot/sdk/AgentInfoTest.java | 64 ++++++ .../copilot/sdk/CliServerManagerTest.java | 27 +++ .../com/github/copilot/sdk/CommandsTest.java | 18 ++ .../github/copilot/sdk/ConfigCloneTest.java | 96 +++++++++ .../copilot/sdk/DataObjectCoverageTest.java | 172 ++++++++++++++++ .../com/github/copilot/sdk/ModelInfoTest.java | 68 +++++++ .../copilot/sdk/RpcHandlerDispatcherTest.java | 48 +++++ .../copilot/sdk/SessionEventParserTest.java | 184 ++++++++++++++++++ .../sdk/SessionRequestBuilderTest.java | 95 +++++++++ .../copilot/sdk/TelemetryConfigTest.java | 77 ++++++++ 10 files changed, 849 insertions(+) create mode 100644 src/test/java/com/github/copilot/sdk/AgentInfoTest.java create mode 100644 src/test/java/com/github/copilot/sdk/DataObjectCoverageTest.java create mode 100644 src/test/java/com/github/copilot/sdk/ModelInfoTest.java create mode 100644 src/test/java/com/github/copilot/sdk/TelemetryConfigTest.java diff --git a/src/test/java/com/github/copilot/sdk/AgentInfoTest.java b/src/test/java/com/github/copilot/sdk/AgentInfoTest.java new file mode 100644 index 000000000..0893773e7 --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/AgentInfoTest.java @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import com.github.copilot.sdk.json.AgentInfo; + +/** + * Unit tests for {@link AgentInfo} getters, setters, and fluent chaining. + */ +class AgentInfoTest { + + @Test + void defaultValuesAreNull() { + var agent = new AgentInfo(); + assertNull(agent.getName()); + assertNull(agent.getDisplayName()); + assertNull(agent.getDescription()); + } + + @Test + void nameGetterSetter() { + var agent = new AgentInfo(); + agent.setName("coder"); + assertEquals("coder", agent.getName()); + } + + @Test + void displayNameGetterSetter() { + var agent = new AgentInfo(); + agent.setDisplayName("Code Assistant"); + assertEquals("Code Assistant", agent.getDisplayName()); + } + + @Test + void descriptionGetterSetter() { + var agent = new AgentInfo(); + agent.setDescription("Helps with coding tasks"); + assertEquals("Helps with coding tasks", agent.getDescription()); + } + + @Test + void fluentChainingReturnsThis() { + var agent = new AgentInfo().setName("coder").setDisplayName("Code Assistant") + .setDescription("Helps with coding tasks"); + + assertEquals("coder", agent.getName()); + assertEquals("Code Assistant", agent.getDisplayName()); + assertEquals("Helps with coding tasks", agent.getDescription()); + } + + @Test + void fluentChainingReturnsSameInstance() { + var agent = new AgentInfo(); + assertSame(agent, agent.setName("test")); + assertSame(agent, agent.setDisplayName("Test")); + assertSame(agent, agent.setDescription("A test agent")); + } +} diff --git a/src/test/java/com/github/copilot/sdk/CliServerManagerTest.java b/src/test/java/com/github/copilot/sdk/CliServerManagerTest.java index f17201583..32257b0a5 100644 --- a/src/test/java/com/github/copilot/sdk/CliServerManagerTest.java +++ b/src/test/java/com/github/copilot/sdk/CliServerManagerTest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.api.Test; import com.github.copilot.sdk.json.CopilotClientOptions; +import com.github.copilot.sdk.json.TelemetryConfig; /** * Unit tests for {@link CliServerManager} covering parseCliUrl, @@ -212,4 +213,30 @@ void startCliServerWithNullCliPath() throws Exception { assertNotNull(e); } } + + @Test + void startCliServerWithTelemetryAllOptions() throws Exception { + // The telemetry env vars are applied before ProcessBuilder.start() + // so even with a nonexistent CLI path, the telemetry code path is exercised + var telemetry = new TelemetryConfig().setOtlpEndpoint("http://localhost:4318").setFilePath("/tmp/telemetry.log") + .setExporterType("otlp-http").setSourceName("test-app").setCaptureContent(true); + var options = new CopilotClientOptions().setCliPath("/nonexistent/copilot").setTelemetry(telemetry) + .setUseStdio(true); + var manager = new CliServerManager(options); + + var ex = assertThrows(IOException.class, () -> manager.startCliServer()); + assertNotNull(ex); + } + + @Test + void startCliServerWithTelemetryCaptureContentFalse() throws Exception { + // Test the false branch of getCaptureContent() + var telemetry = new TelemetryConfig().setCaptureContent(false); + var options = new CopilotClientOptions().setCliPath("/nonexistent/copilot").setTelemetry(telemetry) + .setUseStdio(true); + var manager = new CliServerManager(options); + + var ex = assertThrows(IOException.class, () -> manager.startCliServer()); + assertNotNull(ex); + } } diff --git a/src/test/java/com/github/copilot/sdk/CommandsTest.java b/src/test/java/com/github/copilot/sdk/CommandsTest.java index dad26afbb..baf26b39b 100644 --- a/src/test/java/com/github/copilot/sdk/CommandsTest.java +++ b/src/test/java/com/github/copilot/sdk/CommandsTest.java @@ -135,4 +135,22 @@ void commandWireDefinitionNullDescriptionAllowed() { assertEquals("rollback", wire.getName()); assertNull(wire.getDescription()); } + + @Test + void commandWireDefinitionFluentSetters() { + var wire = new CommandWireDefinition(); + wire.setName("status"); + wire.setDescription("Show deployment status"); + + assertEquals("status", wire.getName()); + assertEquals("Show deployment status", wire.getDescription()); + } + + @Test + void commandWireDefinitionFluentSettersChaining() { + var wire = new CommandWireDefinition().setName("logs").setDescription("View application logs"); + + assertEquals("logs", wire.getName()); + assertEquals("View application logs", wire.getDescription()); + } } diff --git a/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java b/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java index bf4881d5c..f3787705f 100644 --- a/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java +++ b/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java @@ -17,10 +17,13 @@ import com.github.copilot.sdk.events.AbstractSessionEvent; import com.github.copilot.sdk.json.CopilotClientOptions; +import com.github.copilot.sdk.json.InfiniteSessionConfig; import com.github.copilot.sdk.json.MessageOptions; import com.github.copilot.sdk.json.ModelInfo; import com.github.copilot.sdk.json.ResumeSessionConfig; import com.github.copilot.sdk.json.SessionConfig; +import com.github.copilot.sdk.json.SystemMessageConfig; +import com.github.copilot.sdk.json.TelemetryConfig; class ConfigCloneTest { @@ -193,4 +196,97 @@ void clonePreservesNullFields() { MessageOptions msgClone = msg.clone(); assertNull(msgClone.getMode()); } + + @Test + @SuppressWarnings("deprecation") + void copilotClientOptionsDeprecatedAutoRestart() { + CopilotClientOptions opts = new CopilotClientOptions(); + assertFalse(opts.isAutoRestart()); + opts.setAutoRestart(true); + assertTrue(opts.isAutoRestart()); + } + + @Test + void copilotClientOptionsSetCliArgsNullClearsExisting() { + CopilotClientOptions opts = new CopilotClientOptions(); + opts.setCliArgs(new String[]{"--flag1"}); + assertNotNull(opts.getCliArgs()); + + // Setting null should clear the existing array + opts.setCliArgs(null); + assertNotNull(opts.getCliArgs()); + assertEquals(0, opts.getCliArgs().length); + } + + @Test + void copilotClientOptionsSetEnvironmentNullClearsExisting() { + CopilotClientOptions opts = new CopilotClientOptions(); + opts.setEnvironment(Map.of("KEY", "VALUE")); + assertNotNull(opts.getEnvironment()); + + // Setting null should clear the existing map (clears in-place → returns empty + // map) + opts.setEnvironment(null); + var env = opts.getEnvironment(); + assertTrue(env == null || env.isEmpty()); + } + + @Test + @SuppressWarnings("deprecation") + void copilotClientOptionsDeprecatedGithubToken() { + CopilotClientOptions opts = new CopilotClientOptions(); + opts.setGithubToken("ghp_deprecated_token"); + assertEquals("ghp_deprecated_token", opts.getGithubToken()); + assertEquals("ghp_deprecated_token", opts.getGitHubToken()); + } + + @Test + void copilotClientOptionsSetTelemetry() { + var telemetry = new TelemetryConfig().setOtlpEndpoint("http://localhost:4318"); + var opts = new CopilotClientOptions(); + opts.setTelemetry(telemetry); + assertSame(telemetry, opts.getTelemetry()); + } + + @Test + void copilotClientOptionsSetUseLoggedInUserNull() { + var opts = new CopilotClientOptions(); + opts.setUseLoggedInUser(null); + // null → Boolean.FALSE + assertEquals(Boolean.FALSE, opts.getUseLoggedInUser()); + } + + @Test + void resumeSessionConfigAllSetters() { + var config = new ResumeSessionConfig(); + + var sysMsg = new SystemMessageConfig(); + config.setSystemMessage(sysMsg); + assertSame(sysMsg, config.getSystemMessage()); + + config.setAvailableTools(List.of("bash", "read_file")); + assertEquals(List.of("bash", "read_file"), config.getAvailableTools()); + + config.setExcludedTools(List.of("write_file")); + assertEquals(List.of("write_file"), config.getExcludedTools()); + + config.setReasoningEffort("high"); + assertEquals("high", config.getReasoningEffort()); + + config.setWorkingDirectory("/project/src"); + assertEquals("/project/src", config.getWorkingDirectory()); + + config.setConfigDir("/home/user/.config/copilot"); + assertEquals("/home/user/.config/copilot", config.getConfigDir()); + + config.setSkillDirectories(List.of("/skills/custom")); + assertEquals(List.of("/skills/custom"), config.getSkillDirectories()); + + config.setDisabledSkills(List.of("some-skill")); + assertEquals(List.of("some-skill"), config.getDisabledSkills()); + + var infiniteConfig = new InfiniteSessionConfig().setEnabled(true); + config.setInfiniteSessions(infiniteConfig); + assertSame(infiniteConfig, config.getInfiniteSessions()); + } } diff --git a/src/test/java/com/github/copilot/sdk/DataObjectCoverageTest.java b/src/test/java/com/github/copilot/sdk/DataObjectCoverageTest.java new file mode 100644 index 000000000..203f5faed --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/DataObjectCoverageTest.java @@ -0,0 +1,172 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.copilot.sdk.json.GetForegroundSessionResponse; +import com.github.copilot.sdk.json.PermissionRequest; +import com.github.copilot.sdk.json.PermissionRequestResult; +import com.github.copilot.sdk.json.PostToolUseHookInput; +import com.github.copilot.sdk.json.PostToolUseHookOutput; +import com.github.copilot.sdk.json.PreToolUseHookInput; +import com.github.copilot.sdk.json.PreToolUseHookOutput; +import com.github.copilot.sdk.json.SectionOverride; +import com.github.copilot.sdk.json.SetForegroundSessionResponse; +import com.github.copilot.sdk.json.ToolBinaryResult; +import com.github.copilot.sdk.json.ToolResultObject; + +/** + * Unit tests for various data transfer objects and record types that were + * missing coverage, including hook output factory methods, record constructors, + * and getters for hook inputs. + */ +class DataObjectCoverageTest { + + // ===== PreToolUseHookOutput factory methods ===== + + @Test + void preToolUseHookOutputDenyWithReason() { + var output = PreToolUseHookOutput.deny("Security policy violation"); + assertEquals("deny", output.permissionDecision()); + assertEquals("Security policy violation", output.permissionDecisionReason()); + assertNull(output.modifiedArgs()); + } + + @Test + void preToolUseHookOutputAsk() { + var output = PreToolUseHookOutput.ask(); + assertEquals("ask", output.permissionDecision()); + assertNull(output.permissionDecisionReason()); + } + + @Test + void preToolUseHookOutputWithModifiedArgs() { + ObjectNode args = JsonNodeFactory.instance.objectNode(); + args.put("path", "/safe/path"); + + var output = PreToolUseHookOutput.withModifiedArgs("allow", args); + assertEquals("allow", output.permissionDecision()); + assertEquals(args, output.modifiedArgs()); + } + + // ===== PostToolUseHookOutput record ===== + + @Test + void postToolUseHookOutputRecord() { + var output = new PostToolUseHookOutput(null, "Extra context", false); + assertNull(output.modifiedResult()); + assertEquals("Extra context", output.additionalContext()); + assertFalse(output.suppressOutput()); + } + + // ===== ToolBinaryResult record ===== + + @Test + void toolBinaryResultRecord() { + var result = new ToolBinaryResult("base64data==", "image/png", "image", "A chart"); + assertEquals("base64data==", result.data()); + assertEquals("image/png", result.mimeType()); + assertEquals("image", result.type()); + assertEquals("A chart", result.description()); + } + + // ===== GetForegroundSessionResponse record ===== + + @Test + void getForegroundSessionResponseRecord() { + var response = new GetForegroundSessionResponse("session-123", "/home/user/project"); + assertEquals("session-123", response.sessionId()); + assertEquals("/home/user/project", response.workspacePath()); + } + + // ===== SetForegroundSessionResponse record ===== + + @Test + void setForegroundSessionResponseRecord() { + var successResponse = new SetForegroundSessionResponse(true, null); + assertTrue(successResponse.success()); + assertNull(successResponse.error()); + + var errorResponse = new SetForegroundSessionResponse(false, "Session not found"); + assertFalse(errorResponse.success()); + assertEquals("Session not found", errorResponse.error()); + } + + // ===== ToolResultObject factory methods ===== + + @Test + void toolResultObjectErrorWithTextAndError() { + var result = ToolResultObject.error("partial output", "File not found"); + assertEquals("error", result.resultType()); + assertEquals("partial output", result.textResultForLlm()); + assertEquals("File not found", result.error()); + } + + @Test + void toolResultObjectFailure() { + var result = ToolResultObject.failure("Tool unavailable", "Unknown tool"); + assertEquals("failure", result.resultType()); + assertEquals("Tool unavailable", result.textResultForLlm()); + assertEquals("Unknown tool", result.error()); + } + + // ===== PermissionRequest additional setters ===== + + @Test + void permissionRequestSetExtensionData() { + var req = new PermissionRequest(); + req.setExtensionData(java.util.Map.of("key", "value")); + assertEquals("value", req.getExtensionData().get("key")); + } + + // ===== SectionOverride setContent ===== + + @Test + void sectionOverrideSetContent() { + var override = new SectionOverride(); + override.setContent("Custom content"); + assertEquals("Custom content", override.getContent()); + } + + // ===== PreToolUseHookInput getters ===== + + @Test + void preToolUseHookInputGetters() { + var input = new PreToolUseHookInput(); + // Default values + assertEquals(0L, input.getTimestamp()); + assertNull(input.getCwd()); + assertNull(input.getToolArgs()); + } + + // ===== PostToolUseHookInput getters ===== + + @Test + void postToolUseHookInputGetters() { + var input = new PostToolUseHookInput(); + // Default values + assertEquals(0L, input.getTimestamp()); + assertNull(input.getCwd()); + assertNull(input.getToolArgs()); + } + + // ===== PermissionRequestResult setRules ===== + + @Test + void permissionRequestResultSetRules() { + var result = new PermissionRequestResult().setKind("allow"); + var rules = new java.util.ArrayList(); + rules.add("bash:read"); + rules.add("bash:write"); + result.setRules(rules); + assertEquals(2, result.getRules().size()); + assertEquals("bash:read", result.getRules().get(0)); + } +} diff --git a/src/test/java/com/github/copilot/sdk/ModelInfoTest.java b/src/test/java/com/github/copilot/sdk/ModelInfoTest.java new file mode 100644 index 000000000..f36d0c4bd --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/ModelInfoTest.java @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.github.copilot.sdk.json.ModelInfo; +import com.github.copilot.sdk.json.ModelSupports; +import com.github.copilot.sdk.json.SessionMetadata; + +/** + * Unit tests for {@link ModelInfo}, {@link ModelSupports}, and + * {@link SessionMetadata} getters and setters. + */ +class ModelInfoTest { + + @Test + void modelSupportsReasoningEffortGetterSetter() { + var supports = new ModelSupports(); + assertFalse(supports.isReasoningEffort()); + + supports.setReasoningEffort(true); + assertTrue(supports.isReasoningEffort()); + } + + @Test + void modelSupportsFluentChaining() { + var supports = new ModelSupports().setVision(true).setReasoningEffort(true); + assertTrue(supports.isVision()); + assertTrue(supports.isReasoningEffort()); + } + + @Test + void modelInfoSupportedReasoningEffortsGetterSetter() { + var model = new ModelInfo(); + assertNull(model.getSupportedReasoningEfforts()); + + model.setSupportedReasoningEfforts(List.of("low", "medium", "high")); + assertEquals(List.of("low", "medium", "high"), model.getSupportedReasoningEfforts()); + } + + @Test + void modelInfoDefaultReasoningEffortGetterSetter() { + var model = new ModelInfo(); + assertNull(model.getDefaultReasoningEffort()); + + model.setDefaultReasoningEffort("medium"); + assertEquals("medium", model.getDefaultReasoningEffort()); + } + + @Test + void sessionMetadataGettersAndSetters() { + var meta = new SessionMetadata(); + assertNull(meta.getStartTime()); + assertNull(meta.getModifiedTime()); + assertNull(meta.getSummary()); + assertFalse(meta.isRemote()); + + meta.setRemote(true); + assertTrue(meta.isRemote()); + } +} diff --git a/src/test/java/com/github/copilot/sdk/RpcHandlerDispatcherTest.java b/src/test/java/com/github/copilot/sdk/RpcHandlerDispatcherTest.java index 79f5d7c7e..315a38b90 100644 --- a/src/test/java/com/github/copilot/sdk/RpcHandlerDispatcherTest.java +++ b/src/test/java/com/github/copilot/sdk/RpcHandlerDispatcherTest.java @@ -542,4 +542,52 @@ void hooksInvokeWithNoHooksRegistered() throws Exception { JsonNode output = response.get("result").get("output"); assertTrue(output == null || output.isNull(), "Output should be null when no hooks registered"); } + + // ===== systemMessage.transform tests ===== + + @Test + void systemMessageTransformWithUnknownSession() throws Exception { + ObjectNode params = MAPPER.createObjectNode(); + params.put("sessionId", "nonexistent"); + params.putObject("sections"); + + invokeHandler("systemMessage.transform", "40", params); + + JsonNode response = readResponse(); + assertNotNull(response.get("error")); + assertEquals(-32602, response.get("error").get("code").asInt()); + } + + @Test + void systemMessageTransformWithNullSessionId() throws Exception { + ObjectNode params = MAPPER.createObjectNode(); + // sessionId omitted → null → session lookup returns null → error + params.putObject("sections"); + + invokeHandler("systemMessage.transform", "41", params); + + JsonNode response = readResponse(); + assertNotNull(response.get("error")); + assertEquals(-32602, response.get("error").get("code").asInt()); + } + + @Test + void systemMessageTransformWithKnownSessionNoCallbacks() throws Exception { + // Session without transform callbacks returns the sections unchanged + createSession("s1"); + + ObjectNode params = MAPPER.createObjectNode(); + params.put("sessionId", "s1"); + ObjectNode sections = params.putObject("sections"); + ObjectNode sectionData = sections.putObject("identity"); + sectionData.put("content", "Original content"); + + invokeHandler("systemMessage.transform", "42", params); + + JsonNode response = readResponse(); + assertNotNull(response.get("result")); + JsonNode resultSections = response.get("result").get("sections"); + assertNotNull(resultSections); + assertEquals("Original content", resultSections.get("identity").get("content").asText()); + } } diff --git a/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java b/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java index 5898d5301..4a63bb243 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java @@ -2385,4 +2385,188 @@ void testParseSystemNotificationEvent() throws Exception { assertNotNull(event.getData()); assertTrue(event.getData().content().contains("Agent completed")); } + + @Test + void testParseCapabilitiesChangedEvent() throws Exception { + String json = """ + { + "type": "capabilities.changed", + "data": { + "ui": { + "elicitation": true + } + } + } + """; + + AbstractSessionEvent event = parseJson(json); + assertNotNull(event); + assertInstanceOf(CapabilitiesChangedEvent.class, event); + assertEquals("capabilities.changed", event.getType()); + + var castedEvent = (CapabilitiesChangedEvent) event; + assertNotNull(castedEvent.getData()); + assertNotNull(castedEvent.getData().ui()); + assertTrue(castedEvent.getData().ui().elicitation()); + + // Verify setData round-trip + var newData = new CapabilitiesChangedEvent.CapabilitiesChangedData( + new CapabilitiesChangedEvent.CapabilitiesChangedUi(false)); + castedEvent.setData(newData); + assertFalse(castedEvent.getData().ui().elicitation()); + } + + @Test + void testParseCommandExecuteEvent() throws Exception { + String json = """ + { + "type": "command.execute", + "data": { + "requestId": "req-001", + "command": "/deploy production", + "commandName": "deploy", + "args": "production" + } + } + """; + + AbstractSessionEvent event = parseJson(json); + assertNotNull(event); + assertInstanceOf(CommandExecuteEvent.class, event); + assertEquals("command.execute", event.getType()); + + var castedEvent = (CommandExecuteEvent) event; + assertNotNull(castedEvent.getData()); + assertEquals("req-001", castedEvent.getData().requestId()); + assertEquals("/deploy production", castedEvent.getData().command()); + assertEquals("deploy", castedEvent.getData().commandName()); + assertEquals("production", castedEvent.getData().args()); + + // Verify setData round-trip + castedEvent.setData(new CommandExecuteEvent.CommandExecuteData("req-002", "/rollback", "rollback", null)); + assertEquals("req-002", castedEvent.getData().requestId()); + } + + @Test + void testParseElicitationRequestedEvent() throws Exception { + String json = """ + { + "type": "elicitation.requested", + "data": { + "requestId": "elix-001", + "toolCallId": "tc-123", + "elicitationSource": "mcp_tool", + "message": "Please provide your name", + "mode": "form", + "requestedSchema": { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name"] + }, + "url": null + } + } + """; + + AbstractSessionEvent event = parseJson(json); + assertNotNull(event); + assertInstanceOf(ElicitationRequestedEvent.class, event); + assertEquals("elicitation.requested", event.getType()); + + var castedEvent = (ElicitationRequestedEvent) event; + assertNotNull(castedEvent.getData()); + assertEquals("elix-001", castedEvent.getData().requestId()); + assertEquals("tc-123", castedEvent.getData().toolCallId()); + assertEquals("mcp_tool", castedEvent.getData().elicitationSource()); + assertEquals("Please provide your name", castedEvent.getData().message()); + assertEquals("form", castedEvent.getData().mode()); + assertNotNull(castedEvent.getData().requestedSchema()); + assertEquals("object", castedEvent.getData().requestedSchema().type()); + assertNotNull(castedEvent.getData().requestedSchema().properties()); + assertNotNull(castedEvent.getData().requestedSchema().required()); + assertTrue(castedEvent.getData().requestedSchema().required().contains("name")); + + // Verify setData round-trip + castedEvent.setData(new ElicitationRequestedEvent.ElicitationRequestedData("elix-002", null, null, "Enter URL", + "url", null, "https://example.com")); + assertEquals("elix-002", castedEvent.getData().requestId()); + assertEquals("url", castedEvent.getData().mode()); + } + + @Test + void testParseSessionContextChangedEvent() throws Exception { + String json = """ + { + "type": "session.context_changed", + "data": { + "cwd": "/home/user/project", + "gitRoot": "/home/user/project", + "repository": "my-repo", + "branch": "main" + } + } + """; + + AbstractSessionEvent event = parseJson(json); + assertNotNull(event); + assertInstanceOf(SessionContextChangedEvent.class, event); + assertEquals("session.context_changed", event.getType()); + + var castedEvent = (SessionContextChangedEvent) event; + assertNotNull(castedEvent.getData()); + assertEquals("/home/user/project", castedEvent.getData().getCwd()); + + // Verify setData round-trip + castedEvent.setData(null); + assertNull(castedEvent.getData()); + } + + @Test + void testParseSessionTaskCompleteEvent() throws Exception { + String json = """ + { + "type": "session.task_complete", + "data": { + "summary": "Task completed successfully" + } + } + """; + + AbstractSessionEvent event = parseJson(json); + assertNotNull(event); + assertInstanceOf(SessionTaskCompleteEvent.class, event); + assertEquals("session.task_complete", event.getType()); + + var castedEvent = (SessionTaskCompleteEvent) event; + assertNotNull(castedEvent.getData()); + assertEquals("Task completed successfully", castedEvent.getData().summary()); + + // Verify setData round-trip + castedEvent.setData(new SessionTaskCompleteEvent.SessionTaskCompleteData("New summary")); + assertEquals("New summary", castedEvent.getData().summary()); + } + + @Test + void testParseSubagentDeselectedEvent() throws Exception { + String json = """ + { + "type": "subagent.deselected", + "data": {} + } + """; + + AbstractSessionEvent event = parseJson(json); + assertNotNull(event); + assertInstanceOf(SubagentDeselectedEvent.class, event); + assertEquals("subagent.deselected", event.getType()); + + var castedEvent = (SubagentDeselectedEvent) event; + assertNotNull(castedEvent.getData()); + + // Verify setData round-trip + castedEvent.setData(new SubagentDeselectedEvent.SubagentDeselectedData()); + assertNotNull(castedEvent.getData()); + } } diff --git a/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java b/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java index 1a64b7534..75457583e 100644 --- a/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java @@ -13,6 +13,9 @@ import org.junit.jupiter.api.Test; import com.github.copilot.sdk.json.CreateSessionRequest; +import com.github.copilot.sdk.json.ElicitationHandler; +import com.github.copilot.sdk.json.ElicitationResult; +import com.github.copilot.sdk.json.ElicitationResultAction; import com.github.copilot.sdk.json.ResumeSessionConfig; import com.github.copilot.sdk.json.ResumeSessionRequest; import com.github.copilot.sdk.json.SessionConfig; @@ -305,4 +308,96 @@ void extractTransformCallbacks_customizeModeWithTransform_extractsCallbacks() { assertEquals(com.github.copilot.sdk.json.SectionOverrideAction.TRANSFORM, wireSection.getAction()); assertNull(wireSection.getTransform()); } + + @Test + @SuppressWarnings("deprecation") + void buildCreateRequestWithSessionId_usesProvidedSessionId() { + var config = new SessionConfig(); + config.setSessionId("my-session-id"); + + // The deprecated single-arg overload uses the sessionId from config when set + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config); + + assertEquals("my-session-id", request.getSessionId()); + } + + @Test + void configureSessionWithNullConfig_returnsEarly() { + // configureSession with null config should return without error + CopilotSession session = new CopilotSession("session-1", null); + // Covers the null config early-return branch (L219-220) + assertDoesNotThrow(() -> SessionRequestBuilder.configureSession(session, (SessionConfig) null)); + } + + @Test + void configureSessionWithCommands_registersCommands() { + CopilotSession session = new CopilotSession("session-1", null); + + var cmd = new com.github.copilot.sdk.json.CommandDefinition().setName("deploy") + .setHandler(ctx -> CompletableFuture.completedFuture(null)); + var config = new SessionConfig().setCommands(List.of(cmd)); + + // Covers config.getCommands() != null branch (L235-236) + SessionRequestBuilder.configureSession(session, config); + // If no exception thrown, the branch was covered + } + + @Test + void configureSessionWithElicitationHandler_registersHandler() { + CopilotSession session = new CopilotSession("session-1", null); + + ElicitationHandler handler = (context) -> CompletableFuture + .completedFuture(new ElicitationResult().setAction(ElicitationResultAction.CANCEL)); + var config = new SessionConfig().setOnElicitationRequest(handler); + + // Covers config.getOnElicitationRequest() != null branch (L238-239) + SessionRequestBuilder.configureSession(session, config); + } + + @Test + void configureSessionWithOnEvent_registersEventHandler() { + CopilotSession session = new CopilotSession("session-1", null); + + var config = new SessionConfig().setOnEvent(event -> { + }); + + // Covers config.getOnEvent() != null branch (L241-242) + SessionRequestBuilder.configureSession(session, config); + } + + @Test + void configureResumedSessionWithCommands_registersCommands() { + CopilotSession session = new CopilotSession("session-1", null); + + var cmd = new com.github.copilot.sdk.json.CommandDefinition().setName("rollback") + .setHandler(ctx -> CompletableFuture.completedFuture(null)); + var config = new ResumeSessionConfig().setCommands(List.of(cmd)); + + // Covers ResumeSessionConfig.getCommands() != null branch (L271-272) + SessionRequestBuilder.configureSession(session, config); + } + + @Test + void configureResumedSessionWithElicitationHandler_registersHandler() { + CopilotSession session = new CopilotSession("session-1", null); + + ElicitationHandler handler = (context) -> CompletableFuture + .completedFuture(new ElicitationResult().setAction(ElicitationResultAction.CANCEL)); + var config = new ResumeSessionConfig().setOnElicitationRequest(handler); + + // Covers ResumeSessionConfig.getOnElicitationRequest() != null branch + // (L274-275) + SessionRequestBuilder.configureSession(session, config); + } + + @Test + void configureResumedSessionWithOnEvent_registersEventHandler() { + CopilotSession session = new CopilotSession("session-1", null); + + var config = new ResumeSessionConfig().setOnEvent(event -> { + }); + + // Covers ResumeSessionConfig.getOnEvent() != null branch (L277-278) + SessionRequestBuilder.configureSession(session, config); + } } diff --git a/src/test/java/com/github/copilot/sdk/TelemetryConfigTest.java b/src/test/java/com/github/copilot/sdk/TelemetryConfigTest.java new file mode 100644 index 000000000..99b360d2d --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/TelemetryConfigTest.java @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import com.github.copilot.sdk.json.TelemetryConfig; + +/** + * Unit tests for {@link TelemetryConfig} getters, setters, and fluent chaining. + */ +class TelemetryConfigTest { + + @Test + void defaultValuesAreNull() { + var config = new TelemetryConfig(); + assertNull(config.getOtlpEndpoint()); + assertNull(config.getFilePath()); + assertNull(config.getExporterType()); + assertNull(config.getSourceName()); + assertNull(config.getCaptureContent()); + } + + @Test + void otlpEndpointGetterSetter() { + var config = new TelemetryConfig(); + config.setOtlpEndpoint("http://localhost:4318"); + assertEquals("http://localhost:4318", config.getOtlpEndpoint()); + } + + @Test + void filePathGetterSetter() { + var config = new TelemetryConfig(); + config.setFilePath("/tmp/telemetry.log"); + assertEquals("/tmp/telemetry.log", config.getFilePath()); + } + + @Test + void exporterTypeGetterSetter() { + var config = new TelemetryConfig(); + config.setExporterType("otlp-http"); + assertEquals("otlp-http", config.getExporterType()); + } + + @Test + void sourceNameGetterSetter() { + var config = new TelemetryConfig(); + config.setSourceName("my-app"); + assertEquals("my-app", config.getSourceName()); + } + + @Test + void captureContentGetterSetter() { + var config = new TelemetryConfig(); + config.setCaptureContent(true); + assertTrue(config.getCaptureContent()); + + config.setCaptureContent(false); + assertFalse(config.getCaptureContent()); + } + + @Test + void fluentChainingReturnsThis() { + var config = new TelemetryConfig().setOtlpEndpoint("http://localhost:4318").setFilePath("/tmp/spans.json") + .setExporterType("file").setSourceName("sdk-test").setCaptureContent(true); + + assertEquals("http://localhost:4318", config.getOtlpEndpoint()); + assertEquals("/tmp/spans.json", config.getFilePath()); + assertEquals("file", config.getExporterType()); + assertEquals("sdk-test", config.getSourceName()); + assertTrue(config.getCaptureContent()); + } +} From 2b24c6f639e1f51dd6228b6ef8ea4360f111ca41 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Mon, 6 Apr 2026 18:30:36 -0400 Subject: [PATCH 50/69] Update Mockito to 5.23.0 for Java 25 support Mockito 5.17.0 bundled ByteBuddy 1.15.11 which does not support Java 25 class files, causing SchedulerShutdownRaceTest and ZeroTimeoutContractTest to fail with 'Mockito cannot mock this class'. - Upgrade mockito-core from 5.17.0 to 5.23.0 (includes ByteBuddy 1.17.5+) - Add JDK 21+ profile that passes -XX:+EnableDynamicAgentLoading to Surefire, allowing Mockito/ByteBuddy to attach at runtime (JEP 451) - Use a default-empty surefire.jvm.args property so JDK 17-20 are unaffected by the flag Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pom.xml | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/pom.xml b/pom.xml index 45404d99a..43e36ec4d 100644 --- a/pom.xml +++ b/pom.xml @@ -51,6 +51,8 @@ ${copilot.sdk.clone.dir}/test false + + @@ -89,7 +91,7 @@ org.mockito mockito-core - 5.17.0 + 5.23.0 test @@ -245,8 +247,8 @@ maven-surefire-plugin 3.5.4 - - ${testExecutionAgentArgs} + + ${testExecutionAgentArgs} ${surefire.jvm.args} ${copilot.tests.dir} ${copilot.sdk.clone.dir} @@ -543,6 +545,18 @@ + + + jdk21+ + + [21,) + + + -XX:+EnableDynamicAgentLoading + + skip-test-harness From cce87cb1caf9701753bf64ee25be76cc3156d224 Mon Sep 17 00:00:00 2001 From: brunoborges <129743+brunoborges@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:53:38 +0000 Subject: [PATCH 51/69] Update JaCoCo coverage badge --- .github/badges/jacoco.svg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index a310734d0..f1a7c5eb3 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -6,13 +6,13 @@ - + coverage coverage - 78.3% - 78.3% + 84.4% + 84.4% From 00a3aa58630e142c23b7c85a80e0caa91a2dcc79 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:05:43 +0000 Subject: [PATCH 52/69] Initial plan From 7e440159b95bda22d9cee3239f96befe5cf848eb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:28:30 +0000 Subject: [PATCH 53/69] Update .lastmerge to c3fa6cbfb83d4a20b7912b1a17013d48f5a277a1 Co-authored-by: edburns <75821+edburns@users.noreply.github.com> --- .lastmerge | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.lastmerge b/.lastmerge index 0d0067b5b..83feb636c 100644 --- a/.lastmerge +++ b/.lastmerge @@ -1 +1 @@ -f7fd7577109d64e261456b16c49baa56258eae4e +c3fa6cbfb83d4a20b7912b1a17013d48f5a277a1 From 1508b9f323dec865eadef9fb53c7512bbdda1b80 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Tue, 7 Apr 2026 09:37:25 -0400 Subject: [PATCH 54/69] Update documentation for PR #52 upstream sync features Add coverage for slash commands, elicitation (UI dialogs), session capabilities, SessionUiApi, and getSessionMetadata across: - CHANGELOG.md: Unreleased section with Added/Fixed entries - documentation.md: event types, SessionConfig reference, resume options - advanced.md: table of contents entries for new sections - site.xml: navigation menu items Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 22 +++++++++++++++++++++- src/site/markdown/advanced.md | 7 +++++++ src/site/markdown/documentation.md | 17 +++++++++++++++++ src/site/site.xml | 3 +++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e306db097..f5050a3d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] -> **Upstream sync:** [`github/copilot-sdk@4088739`](https://github.com/github/copilot-sdk/commit/40887393a9e687dacc141a645799441b0313ff15) +> **Upstream sync:** [`github/copilot-sdk@f7fd757`](https://github.com/github/copilot-sdk/commit/f7fd7577109d64e261456b16c49baa56258eae4e) + +### Added + +- Slash commands — register `/command` handlers invoked from the CLI TUI via `SessionConfig.setCommands()` (upstream: [`f7fd757`](https://github.com/github/copilot-sdk/commit/f7fd757)) +- `CommandDefinition`, `CommandContext`, `CommandHandler`, `CommandWireDefinition` — types for defining and handling slash commands +- `CommandExecuteEvent` — event dispatched when a registered slash command is executed +- Elicitation (UI dialogs) — incoming handler via `SessionConfig.setOnElicitationRequest()` and outgoing convenience methods via `session.getUi()` (upstream: [`f7fd757`](https://github.com/github/copilot-sdk/commit/f7fd757)) +- `ElicitationContext`, `ElicitationHandler`, `ElicitationParams`, `ElicitationResult`, `ElicitationResultAction`, `ElicitationSchema`, `InputOptions` — types for elicitation +- `ElicitationRequestedEvent` — event dispatched when an elicitation request is received +- `SessionUiApi` — convenience API on `session.getUi()` for `confirm()`, `select()`, `input()`, and `elicitation()` calls +- `SessionCapabilities` and `SessionUiCapabilities` — session capability reporting populated from create/resume response +- `CapabilitiesChangedEvent` — event dispatched when session capabilities are updated +- `CopilotClient.getSessionMetadata(String)` — O(1) session lookup by ID +- `GetSessionMetadataResponse` — response type for `getSessionMetadata` + +### Fixed + +- Permission events already resolved by a pre-hook now short-circuit before invoking the client-side handler +- `SessionUiApi` Javadoc now uses valid Java null-check syntax instead of `?.` +- README updated to say "GitHub Copilot CLI 1.0.17" instead of "GitHub Copilot 1.0.17" ## [0.2.1-java.1] - 2026-04-02 diff --git a/src/site/markdown/advanced.md b/src/site/markdown/advanced.md index 598acc2f7..5ae5c8f94 100644 --- a/src/site/markdown/advanced.md +++ b/src/site/markdown/advanced.md @@ -47,6 +47,13 @@ This guide covers advanced scenarios for extending and customizing your Copilot - [Custom Event Error Handler](#Custom_Event_Error_Handler) - [Event Error Policy](#Event_Error_Policy) - [OpenTelemetry](#OpenTelemetry) +- [Slash Commands](#Slash_Commands) + - [Registering Commands](#Registering_Commands) +- [Elicitation (UI Dialogs)](#Elicitation_UI_Dialogs) + - [Incoming Elicitation Handler](#Incoming_Elicitation_Handler) + - [Session Capabilities](#Session_Capabilities) + - [Outgoing Elicitation via session.getUi()](#Outgoing_Elicitation_via_session.getUi) +- [Getting Session Metadata by ID](#Getting_Session_Metadata_by_ID) --- diff --git a/src/site/markdown/documentation.md b/src/site/markdown/documentation.md index 8a9f919ac..a96f66698 100644 --- a/src/site/markdown/documentation.md +++ b/src/site/markdown/documentation.md @@ -245,6 +245,19 @@ The SDK supports event types organized by category. All events extend `AbstractS |-------|-------------|-------------| | `CommandQueuedEvent` | `command.queued` | A command was queued for execution | | `CommandCompletedEvent` | `command.completed` | A queued command completed | +| `CommandExecuteEvent` | `command.execute` | A registered slash command was dispatched for execution | + +### Elicitation Events + +| Event | Type String | Description | +|-------|-------------|-------------| +| `ElicitationRequestedEvent` | `elicitation.requested` | An elicitation (UI dialog) request was received | + +### Capability Events + +| Event | Type String | Description | +|-------|-------------|-------------| +| `CapabilitiesChangedEvent` | `capabilities.changed` | Session capabilities were updated | ### Plan Mode Events @@ -633,6 +646,8 @@ When resuming a session, you can optionally reconfigure many settings. This is u | `skillDirectories` | Directories to load skills from | | `disabledSkills` | Skills to disable | | `infiniteSessions` | Configure infinite session behavior | +| `commands` | Slash command definitions for the resumed session | +| `onElicitationRequest` | Handler for incoming elicitation requests | | `disableResume` | When `true`, resumes without emitting a `session.resume` event | | `onEvent` | Event handler registered before session resumption | @@ -691,6 +706,8 @@ Complete list of all `SessionConfig` options for `createSession()`: | `skillDirectories` | List<String> | Directories to load skills from | [Skills](advanced.html#Skills_Configuration) | | `disabledSkills` | List<String> | Skills to disable by name | [Skills](advanced.html#Skills_Configuration) | | `configDir` | String | Custom configuration directory | [Config Dir](advanced.html#Custom_Configuration_Directory) | +| `commands` | List<CommandDefinition> | Slash command definitions | [Slash Commands](advanced.html#Slash_Commands) | +| `onElicitationRequest` | ElicitationHandler | Handler for incoming elicitation requests | [Elicitation](advanced.html#Elicitation_UI_Dialogs) | | `onEvent` | Consumer<AbstractSessionEvent> | Event handler registered before session creation | [Early Event Registration](advanced.html#Early_Event_Registration) | ### Cloning SessionConfig diff --git a/src/site/site.xml b/src/site/site.xml index f89ebe076..d012c0335 100644 --- a/src/site/site.xml +++ b/src/site/site.xml @@ -59,6 +59,9 @@ + + + From 04d03a798acc69044aa5e6377334fe34e0cc952a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 7 Apr 2026 14:13:12 +0000 Subject: [PATCH 55/69] docs: update version references to 0.2.2-java.1 --- CHANGELOG.md | 19 ++++++++++++++----- README.md | 4 ++-- src/site/markdown/cookbook/error-handling.md | 14 +++++++------- .../markdown/cookbook/managing-local-files.md | 4 ++-- .../markdown/cookbook/multiple-sessions.md | 4 ++-- .../markdown/cookbook/persisting-sessions.md | 6 +++--- .../markdown/cookbook/pr-visualization.md | 2 +- 7 files changed, 31 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5050a3d2..38f6e6589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] -> **Upstream sync:** [`github/copilot-sdk@f7fd757`](https://github.com/github/copilot-sdk/commit/f7fd7577109d64e261456b16c49baa56258eae4e) +> **Upstream sync:** [`github/copilot-sdk@c3fa6cb`](https://github.com/github/copilot-sdk/commit/c3fa6cbfb83d4a20b7912b1a17013d48f5a277a1) +## [0.2.2-java.1] - 2026-04-07 + +> **Upstream sync:** [`github/copilot-sdk@c3fa6cb`](https://github.com/github/copilot-sdk/commit/c3fa6cbfb83d4a20b7912b1a17013d48f5a277a1) ### Added - Slash commands — register `/command` handlers invoked from the CLI TUI via `SessionConfig.setCommands()` (upstream: [`f7fd757`](https://github.com/github/copilot-sdk/commit/f7fd757)) @@ -485,16 +488,22 @@ New types: `GetForegroundSessionResponse`, `SetForegroundSessionResponse` - Pre-commit hook for Spotless code formatting - Comprehensive API documentation -[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.1...HEAD +[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.2-java.1...HEAD +[0.2.2-java.1]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.1...v0.2.2-java.1 +[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.2-java.1...HEAD +[0.2.2-java.1]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.1...v0.2.2-java.1 [0.2.1-java.1]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.0...v0.2.1-java.1 -[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.1...HEAD +[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.2-java.1...HEAD +[0.2.2-java.1]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.1...v0.2.2-java.1 [0.2.1-java.1]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.0...v0.2.1-java.1 [0.2.1-java.0]: https://github.com/github/copilot-sdk-java/compare/v0.1.32-java.0...v0.2.1-java.0 -[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.1...HEAD +[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.2-java.1...HEAD +[0.2.2-java.1]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.1...v0.2.2-java.1 [0.2.1-java.1]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.0...v0.2.1-java.1 [0.2.1-java.0]: https://github.com/github/copilot-sdk-java/compare/v0.1.32-java.0...v0.2.1-java.0 [0.1.32-java.0]: https://github.com/github/copilot-sdk-java/compare/v1.0.11...v0.1.32-java.0 -[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.1...HEAD +[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.2-java.1...HEAD +[0.2.2-java.1]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.1...v0.2.2-java.1 [0.2.1-java.1]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.0...v0.2.1-java.1 [0.2.1-java.0]: https://github.com/github/copilot-sdk-java/compare/v0.1.32-java.0...v0.2.1-java.0 [0.1.32-java.0]: https://github.com/github/copilot-sdk-java/compare/v1.0.11...v0.1.32-java.0 diff --git a/README.md b/README.md index 677389c6e..539a33895 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build A com.github copilot-sdk-java - 0.2.1-java.1 + 0.2.2-java.1 ``` @@ -60,7 +60,7 @@ Snapshot builds of the next development version are published to Maven Central S ### Gradle ```groovy -implementation 'com.github:copilot-sdk-java:0.2.1-java.1' +implementation 'com.github:copilot-sdk-java:0.2.2-java.1' ``` ## Quick Start diff --git a/src/site/markdown/cookbook/error-handling.md b/src/site/markdown/cookbook/error-handling.md index 5ee5ef2ca..4240dc1ff 100644 --- a/src/site/markdown/cookbook/error-handling.md +++ b/src/site/markdown/cookbook/error-handling.md @@ -30,7 +30,7 @@ jbang BasicErrorHandling.java **Code:** ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.json.MessageOptions; @@ -64,7 +64,7 @@ public class BasicErrorHandling { ## Handling specific error types ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotClient; import java.util.concurrent.ExecutionException; @@ -99,7 +99,7 @@ public class SpecificErrorHandling { ## Timeout handling ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotSession; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.json.MessageOptions; @@ -130,7 +130,7 @@ public class TimeoutHandling { ## Aborting a request ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotSession; import com.github.copilot.sdk.json.MessageOptions; import java.util.concurrent.Executors; @@ -162,7 +162,7 @@ public class AbortRequest { ## Graceful shutdown ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotClient; public class GracefulShutdown { @@ -192,7 +192,7 @@ public class GracefulShutdown { ## Try-with-resources pattern ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.json.MessageOptions; @@ -224,7 +224,7 @@ public class TryWithResources { ## Handling tool errors ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.json.MessageOptions; diff --git a/src/site/markdown/cookbook/managing-local-files.md b/src/site/markdown/cookbook/managing-local-files.md index aa9ba23bc..9535772b2 100644 --- a/src/site/markdown/cookbook/managing-local-files.md +++ b/src/site/markdown/cookbook/managing-local-files.md @@ -34,7 +34,7 @@ jbang ManagingLocalFiles.java **Code:** ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.events.SessionIdleEvent; @@ -161,7 +161,7 @@ session.send(new MessageOptions().setPrompt(prompt)); ## Interactive file organization ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import java.io.BufferedReader; import java.io.InputStreamReader; diff --git a/src/site/markdown/cookbook/multiple-sessions.md b/src/site/markdown/cookbook/multiple-sessions.md index fe5c2f0d9..776b6db6d 100644 --- a/src/site/markdown/cookbook/multiple-sessions.md +++ b/src/site/markdown/cookbook/multiple-sessions.md @@ -30,7 +30,7 @@ jbang MultipleSessions.java **Code:** ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.json.MessageOptions; @@ -123,7 +123,7 @@ try { ## Managing session lifecycle with CompletableFuture ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import java.util.concurrent.CompletableFuture; import java.util.List; diff --git a/src/site/markdown/cookbook/persisting-sessions.md b/src/site/markdown/cookbook/persisting-sessions.md index e3fd11b13..e653b8a6a 100644 --- a/src/site/markdown/cookbook/persisting-sessions.md +++ b/src/site/markdown/cookbook/persisting-sessions.md @@ -30,7 +30,7 @@ jbang PersistingSessions.java **Code:** ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.json.MessageOptions; @@ -127,7 +127,7 @@ public class DeleteSession { ## Getting session history ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.events.UserMessageEvent; @@ -162,7 +162,7 @@ public class SessionHistory { ## Complete example with session management ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import java.util.Scanner; public class SessionManager { diff --git a/src/site/markdown/cookbook/pr-visualization.md b/src/site/markdown/cookbook/pr-visualization.md index dbd240a40..ad2939842 100644 --- a/src/site/markdown/cookbook/pr-visualization.md +++ b/src/site/markdown/cookbook/pr-visualization.md @@ -34,7 +34,7 @@ jbang PRVisualization.java github/copilot-sdk ## Full example: PRVisualization.java ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.events.ToolExecutionStartEvent; From a86edb2891b5784c271f787c29ea368b12734f61 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 7 Apr 2026 14:13:34 +0000 Subject: [PATCH 56/69] [maven-release-plugin] prepare release v0.2.2-java.1 --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 43e36ec4d..8e3e7e1df 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ com.github copilot-sdk-java - 0.2.2-java.1-SNAPSHOT + 0.2.2-java.1 jar GitHub Copilot SDK :: Java @@ -33,7 +33,7 @@ scm:git:https://github.com/github/copilot-sdk-java.git scm:git:https://github.com/github/copilot-sdk-java.git https://github.com/github/copilot-sdk-java - HEAD + v0.2.2-java.1 From 0704bc5b44d7802aa1fe31a62f98c7e0dd50aef7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 7 Apr 2026 14:13:38 +0000 Subject: [PATCH 57/69] [maven-release-plugin] prepare for next development iteration --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 8e3e7e1df..b61c36166 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ com.github copilot-sdk-java - 0.2.2-java.1 + 0.2.3-java.1-SNAPSHOT jar GitHub Copilot SDK :: Java @@ -33,7 +33,7 @@ scm:git:https://github.com/github/copilot-sdk-java.git scm:git:https://github.com/github/copilot-sdk-java.git https://github.com/github/copilot-sdk-java - v0.2.2-java.1 + HEAD From a1c3bdd42b5ffdbde680563a63f3b3afb65be391 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Tue, 7 Apr 2026 10:18:01 -0400 Subject: [PATCH 58/69] Update GitHub release title to 'GitHub Copilot SDK for Java' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/publish-maven.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-maven.yml b/.github/workflows/publish-maven.yml index a7bb58f01..f2960a756 100644 --- a/.github/workflows/publish-maven.yml +++ b/.github/workflows/publish-maven.yml @@ -206,7 +206,7 @@ jobs: # Build the gh release command GH_ARGS=("${CURRENT_TAG}") - GH_ARGS+=("--title" "Copilot Java SDK ${VERSION}") + GH_ARGS+=("--title" "GitHub Copilot SDK for Java ${VERSION}") GH_ARGS+=("--notes" "${RELEASE_NOTES}") GH_ARGS+=("--generate-notes") From 9079e047a279a9a34aba3bc6fcb3a5b2db6de92f Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Tue, 7 Apr 2026 10:25:54 -0400 Subject: [PATCH 59/69] Update disclaimer from pre-GA to public preview Replace 'Disclaimer' warnings with 'Public Preview' notices across README, site docs, release notes template, and HTML version selector. Drop 'Use at your own risk' language and tone down to reflect public preview status. Fix 'SDKS' typo to 'SDKs'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/templates/index.html | 4 ++-- .github/workflows/notes.template | 2 +- README.md | 2 +- instructions/copilot-sdk-java.instructions.md | 2 +- src/site/markdown/index.md | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/templates/index.html b/.github/templates/index.html index 9af01dded..85b96106f 100644 --- a/.github/templates/index.html +++ b/.github/templates/index.html @@ -65,8 +65,8 @@

Available Versions

-
- ⚠️ Disclaimer: This is the official Java SDK for GitHub Copilot. This repository treats the official .NET and nodejs SDKs for GitHub Copilot as reference implementations. These SDKS are all officially supported as GitHub open source projects. The Java implementation follows the backward compatibility guarantees offered by the reference implementations. As such this implementation may introduce breaking changes, according to the policy declared by the reference implementations. Use at your own risk. +
+ ℹ️ Public Preview: This is the official Java SDK for GitHub Copilot. This repository treats the official .NET and nodejs SDKs for GitHub Copilot as reference implementations. These SDKs are all officially supported as GitHub open source projects. The Java implementation follows the backward compatibility guarantees offered by the reference implementations. While in public preview, minor breaking changes may still occur between releases.
diff --git a/.github/workflows/notes.template b/.github/workflows/notes.template index 0fd7af642..0199452eb 100644 --- a/.github/workflows/notes.template +++ b/.github/workflows/notes.template @@ -1,6 +1,6 @@ # Installation -⚠️ **Disclaimer:** This is the official Java SDK for GitHub Copilot. This repository treats the official .NET and nodejs SDKs for GitHub Copilot as reference implementations. These SDKS are all officially supported as GitHub open source projects. The Java implementation follows the backward compatibility guarantees offered by the reference implementations. As such this implementation may introduce breaking changes, according to the policy declared by the reference implementations. Use at your own risk. +ℹ️ **Public Preview:** This is the official Java SDK for GitHub Copilot. This repository treats the official .NET and nodejs SDKs for GitHub Copilot as reference implementations. These SDKs are all officially supported as GitHub open source projects. The Java implementation follows the backward compatibility guarantees offered by the reference implementations. While in public preview, minor breaking changes may still occur between releases. ⚠️ **Artifact versioning plan:** Releases of this implementation track releases of the reference implementation. For each release of the reference implementation, there may follow a corresponding relase of this implementation with the same number as the reference implementation. Release identifiers of the reference implementation are in the form `vMaj.Min.Micro`. For example v0.1.32. The corresponding maven version for the release will be `Maj.Min.Micro-java.N`, where `Maj`, `Min` and `Micro` are the corresponding numbers for the reference impementation release, and `N` is a monotonically increasing sequence number starting with 0 for each release. See the corrseponding architectural decision record for more information in the `docs/adr` directory of the source code. diff --git a/README.md b/README.md index 539a33895..699fa3f9e 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ## Background -> ⚠️ **Disclaimer:** This SDK tracks the pre-GA [GitHub Copilot SDKs](https://github.com/github/copilot-sdk) for [.NET](https://github.com/github/copilot-sdk/tree/main/dotnet) and [nodejs](https://github.com/github/copilot-sdk/tree/main/nodejs). This SDK may change in breaking ways. Use at your own risk. +> ℹ️ **Public Preview:** This SDK tracks the [GitHub Copilot SDKs](https://github.com/github/copilot-sdk) for [.NET](https://github.com/github/copilot-sdk/tree/main/dotnet) and [nodejs](https://github.com/github/copilot-sdk/tree/main/nodejs). While in public preview, minor breaking changes may still occur between releases. Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build AI-powered applications and agentic workflows. diff --git a/instructions/copilot-sdk-java.instructions.md b/instructions/copilot-sdk-java.instructions.md index bf18a3c5a..7881322fd 100644 --- a/instructions/copilot-sdk-java.instructions.md +++ b/instructions/copilot-sdk-java.instructions.md @@ -6,7 +6,7 @@ name: 'GitHub Copilot SDK Java Instructions' ## Core Principles -- The SDK is in technical preview and may have breaking changes +- The SDK is in public preview and may have breaking changes - Requires Java 17 or later - Requires GitHub Copilot CLI installed and in PATH - Uses `CompletableFuture` for all async operations diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index b599484d9..b2a85c567 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -1,6 +1,6 @@ # GitHub Copilot SDK for Java -> ⚠️ **Disclaimer:** This is the official Java SDK for GitHub Copilot. This repository treats the official .NET and nodejs SDKs for GitHub Copilot as reference implementations. These SDKS are all officially supported as GitHub open source projects. The Java implementation follows the backward compatibility guarantees offered by the reference implementations. As such this implementation may introduce breaking changes, according to the policy declared by the reference implementations. Use at your own risk. +> ℹ️ **Public Preview:** This is the official Java SDK for GitHub Copilot. This repository treats the official .NET and nodejs SDKs for GitHub Copilot as reference implementations. These SDKs are all officially supported as GitHub open source projects. The Java implementation follows the backward compatibility guarantees offered by the reference implementations. While in public preview, minor breaking changes may still occur between releases. Welcome to the documentation for the **GitHub Copilot SDK for Java** — a Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build AI-powered applications and agentic workflows. From 89be8e74452f4ee6e7fcb66b96196c0efdcca54e Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Tue, 7 Apr 2026 10:32:11 -0400 Subject: [PATCH 60/69] Fix stale snapshot version in README and automate updates Update the snapshot version in README.md from 0.2.1-java.0-SNAPSHOT to 0.2.3-java.1-SNAPSHOT to match the current pom.xml. Add a sed command to the publish-maven workflow to update the snapshot version during releases, preventing it from going stale again. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/publish-maven.yml | 4 ++++ README.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-maven.yml b/.github/workflows/publish-maven.yml index f2960a756..e258a54aa 100644 --- a/.github/workflows/publish-maven.yml +++ b/.github/workflows/publish-maven.yml @@ -121,6 +121,10 @@ jobs: sed -i "s|[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\(-java\.[0-9][0-9]*\)\{0,1\}|${VERSION}|g" README.md sed -i "s|copilot-sdk-java:[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\(-java\.[0-9][0-9]*\)\{0,1\}|copilot-sdk-java:${VERSION}|g" README.md + # Update snapshot version in README.md + DEV_VERSION="${{ steps.versions.outputs.dev_version }}" + sed -i "s|[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\(-java\.[0-9][0-9]*\)\{0,1\}-SNAPSHOT|${DEV_VERSION}|g" README.md + # Update version in jbang-example.java sed -i "s|copilot-sdk-java:[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\(-java\.[0-9][0-9]*\)\{0,1\}|copilot-sdk-java:${VERSION}|g" jbang-example.java diff --git a/README.md b/README.md index 539a33895..af621d9bb 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Snapshot builds of the next development version are published to Maven Central S com.github copilot-sdk-java - 0.2.1-java.0-SNAPSHOT + 0.2.3-java.1-SNAPSHOT ``` From ef08ee6e07c022b4fdb83ff55f9294d6a0dc4cf4 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Tue, 7 Apr 2026 10:35:55 -0400 Subject: [PATCH 61/69] Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 699fa3f9e..0009483f2 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ## Background -> ℹ️ **Public Preview:** This SDK tracks the [GitHub Copilot SDKs](https://github.com/github/copilot-sdk) for [.NET](https://github.com/github/copilot-sdk/tree/main/dotnet) and [nodejs](https://github.com/github/copilot-sdk/tree/main/nodejs). While in public preview, minor breaking changes may still occur between releases. +> ℹ️ **Public Preview:** This SDK tracks the [GitHub Copilot SDKs](https://github.com/github/copilot-sdk) for [.NET](https://github.com/github/copilot-sdk/tree/main/dotnet) and [Node.js](https://github.com/github/copilot-sdk/tree/main/nodejs). While in public preview, minor breaking changes may still occur between releases. Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build AI-powered applications and agentic workflows. From 8069433e8bb07bb209793671196abd7a812e039b Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Tue, 7 Apr 2026 10:36:09 -0400 Subject: [PATCH 62/69] Update .github/templates/index.html Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/templates/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/templates/index.html b/.github/templates/index.html index 85b96106f..d273ad074 100644 --- a/.github/templates/index.html +++ b/.github/templates/index.html @@ -66,7 +66,7 @@

Available Versions

- ℹ️ Public Preview: This is the official Java SDK for GitHub Copilot. This repository treats the official .NET and nodejs SDKs for GitHub Copilot as reference implementations. These SDKs are all officially supported as GitHub open source projects. The Java implementation follows the backward compatibility guarantees offered by the reference implementations. While in public preview, minor breaking changes may still occur between releases. + ℹ️ Public Preview: This is the official Java SDK for GitHub Copilot. This repository treats the official .NET and Node.js SDKs for GitHub Copilot as reference implementations. These SDKs are all officially supported as GitHub open source projects. The Java implementation follows the backward compatibility guarantees offered by the reference implementations. While in public preview, minor breaking changes may still occur between releases.
From f0a82b906f55e2122c20411e457f55618a7f331a Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Tue, 7 Apr 2026 10:36:17 -0400 Subject: [PATCH 63/69] Update .github/workflows/notes.template Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/notes.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/notes.template b/.github/workflows/notes.template index 0199452eb..9c148cdf1 100644 --- a/.github/workflows/notes.template +++ b/.github/workflows/notes.template @@ -1,6 +1,6 @@ # Installation -ℹ️ **Public Preview:** This is the official Java SDK for GitHub Copilot. This repository treats the official .NET and nodejs SDKs for GitHub Copilot as reference implementations. These SDKs are all officially supported as GitHub open source projects. The Java implementation follows the backward compatibility guarantees offered by the reference implementations. While in public preview, minor breaking changes may still occur between releases. +ℹ️ **Public Preview:** This is the official Java SDK for GitHub Copilot. This repository treats the official .NET and Node.js SDKs for GitHub Copilot as reference implementations. These SDKs are all officially supported as GitHub open source projects. The Java implementation follows the backward compatibility guarantees offered by the reference implementations. While in public preview, minor breaking changes may still occur between releases. ⚠️ **Artifact versioning plan:** Releases of this implementation track releases of the reference implementation. For each release of the reference implementation, there may follow a corresponding relase of this implementation with the same number as the reference implementation. Release identifiers of the reference implementation are in the form `vMaj.Min.Micro`. For example v0.1.32. The corresponding maven version for the release will be `Maj.Min.Micro-java.N`, where `Maj`, `Min` and `Micro` are the corresponding numbers for the reference impementation release, and `N` is a monotonically increasing sequence number starting with 0 for each release. See the corrseponding architectural decision record for more information in the `docs/adr` directory of the source code. From 2dc74128c3d2179f02d7478a9adaf40ee1ee4cfd Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Tue, 7 Apr 2026 10:36:25 -0400 Subject: [PATCH 64/69] Update src/site/markdown/index.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/site/markdown/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index b2a85c567..60b96ce9d 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -1,6 +1,6 @@ # GitHub Copilot SDK for Java -> ℹ️ **Public Preview:** This is the official Java SDK for GitHub Copilot. This repository treats the official .NET and nodejs SDKs for GitHub Copilot as reference implementations. These SDKs are all officially supported as GitHub open source projects. The Java implementation follows the backward compatibility guarantees offered by the reference implementations. While in public preview, minor breaking changes may still occur between releases. +> ℹ️ **Public Preview:** This is the official Java SDK for GitHub Copilot. This repository treats the official .NET and Node.js SDKs for GitHub Copilot as reference implementations. These SDKs are all officially supported as GitHub open source projects. The Java implementation follows the backward compatibility guarantees offered by the reference implementations. While in public preview, minor breaking changes may still occur between releases. Welcome to the documentation for the **GitHub Copilot SDK for Java** — a Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build AI-powered applications and agentic workflows. From 86593d0e501621bb928151b940769b1449ee0f46 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Tue, 7 Apr 2026 10:49:40 -0400 Subject: [PATCH 65/69] Fix jbang-example.java to use actual version instead of Maven placeholder Replace ${project.version} with a real version number (0.2.2-java.1) in jbang-example.java since this file is not Maven-filtered. Also add a fallback sed pattern in the release workflow to handle the placeholder during the transition. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/publish-maven.yml | 1 + jbang-example.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-maven.yml b/.github/workflows/publish-maven.yml index f2960a756..154ab908a 100644 --- a/.github/workflows/publish-maven.yml +++ b/.github/workflows/publish-maven.yml @@ -123,6 +123,7 @@ jobs: # Update version in jbang-example.java sed -i "s|copilot-sdk-java:[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\(-java\.[0-9][0-9]*\)\{0,1\}|copilot-sdk-java:${VERSION}|g" jbang-example.java + sed -i "s|copilot-sdk-java:\${project\.version}|copilot-sdk-java:${VERSION}|g" jbang-example.java # Update version in cookbook files (hardcoded for direct GitHub browsing and JBang usage) find src/site/markdown/cookbook -name "*.md" -type f -exec \ diff --git a/jbang-example.java b/jbang-example.java index 3d02653c1..dd1f80762 100644 --- a/jbang-example.java +++ b/jbang-example.java @@ -1,5 +1,5 @@ ! -//DEPS com.github:copilot-sdk-java:${project.version} +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.events.SessionUsageInfoEvent; From 0d1b0dc0c1175821df9d9fad827ae8ce9d250e07 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Tue, 7 Apr 2026 11:04:06 -0400 Subject: [PATCH 66/69] Update .github/workflows/publish-maven.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/publish-maven.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-maven.yml b/.github/workflows/publish-maven.yml index 154ab908a..c0df3ed0d 100644 --- a/.github/workflows/publish-maven.yml +++ b/.github/workflows/publish-maven.yml @@ -123,7 +123,7 @@ jobs: # Update version in jbang-example.java sed -i "s|copilot-sdk-java:[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\(-java\.[0-9][0-9]*\)\{0,1\}|copilot-sdk-java:${VERSION}|g" jbang-example.java - sed -i "s|copilot-sdk-java:\${project\.version}|copilot-sdk-java:${VERSION}|g" jbang-example.java + sed -i 's|copilot-sdk-java:${project\.version}|copilot-sdk-java:'"${VERSION}"'|g' jbang-example.java # Update version in cookbook files (hardcoded for direct GitHub browsing and JBang usage) find src/site/markdown/cookbook -name "*.md" -type f -exec \ From 62ff965cca742ca9384560137958095d70a68d49 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:01:32 +0000 Subject: [PATCH 67/69] Initial plan From 20acdbfc0a7e43a8b5bdf06cd83cac5f2e50e236 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:08:23 +0000 Subject: [PATCH 68/69] Fix cross-platform process creation in tests: use cmd /c more on Windows Agent-Logs-Url: https://github.com/github/copilot-sdk-java/sessions/a5638a0d-f412-4810-b724-45bc5f10438d Co-authored-by: edburns <75821+edburns@users.noreply.github.com> --- .../com/github/copilot/sdk/CliServerManagerTest.java | 7 ++++++- .../com/github/copilot/sdk/JsonRpcClientTest.java | 11 ++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/github/copilot/sdk/CliServerManagerTest.java b/src/test/java/com/github/copilot/sdk/CliServerManagerTest.java index 32257b0a5..e59d03ae8 100644 --- a/src/test/java/com/github/copilot/sdk/CliServerManagerTest.java +++ b/src/test/java/com/github/copilot/sdk/CliServerManagerTest.java @@ -70,13 +70,18 @@ void connectToServerTcpMode() throws Exception { } } + private static Process startBlockingProcess() throws IOException { + boolean isWindows = System.getProperty("os.name").toLowerCase().contains("windows"); + return (isWindows ? new ProcessBuilder("cmd", "/c", "more") : new ProcessBuilder("cat")).start(); + } + @Test void connectToServerStdioMode() throws Exception { var options = new CopilotClientOptions(); var manager = new CliServerManager(options); // Create a dummy process for stdio mode - Process process = new ProcessBuilder("cat").start(); + Process process = startBlockingProcess(); try { JsonRpcClient client = manager.connectToServer(process, null, null); assertNotNull(client); diff --git a/src/test/java/com/github/copilot/sdk/JsonRpcClientTest.java b/src/test/java/com/github/copilot/sdk/JsonRpcClientTest.java index 8b5c1858c..4fb43f4b6 100644 --- a/src/test/java/com/github/copilot/sdk/JsonRpcClientTest.java +++ b/src/test/java/com/github/copilot/sdk/JsonRpcClientTest.java @@ -133,9 +133,14 @@ void testIsConnectedWithSocketClosed() throws Exception { pair.serverSocket.close(); } + private static Process startBlockingProcess() throws IOException { + boolean isWindows = System.getProperty("os.name").toLowerCase().contains("windows"); + return (isWindows ? new ProcessBuilder("cmd", "/c", "more") : new ProcessBuilder("cat")).start(); + } + @Test void testIsConnectedWithProcess() throws Exception { - Process proc = new ProcessBuilder("cat").start(); + Process proc = startBlockingProcess(); try (var client = JsonRpcClient.fromProcess(proc)) { assertTrue(client.isConnected()); } @@ -143,7 +148,7 @@ void testIsConnectedWithProcess() throws Exception { @Test void testIsConnectedWithProcessDead() throws Exception { - Process proc = new ProcessBuilder("cat").start(); + Process proc = startBlockingProcess(); var client = JsonRpcClient.fromProcess(proc); proc.destroy(); proc.waitFor(5, TimeUnit.SECONDS); @@ -155,7 +160,7 @@ void testIsConnectedWithProcessDead() throws Exception { @Test void testGetProcessReturnsProcess() throws Exception { - Process proc = new ProcessBuilder("cat").start(); + Process proc = startBlockingProcess(); try (var client = JsonRpcClient.fromProcess(proc)) { assertSame(proc, client.getProcess()); } From fc71b95636ac94b4816b6399233af799d657c234 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Sat, 11 Apr 2026 10:40:47 -0400 Subject: [PATCH 69/69] Fix Windows test compatibility for ProcessBuilder usage On Windows, Java's ProcessBuilder cannot directly run shell wrappers like `npx` (installed as npx.cmd) or Unix commands like `cat`. Tests that used these commands failed with "Cannot run program" errors. Additionally, Unix-style paths like "/nonexistent/copilot" are not absolute on Windows, causing assertThrows(IOException) tests to pass unexpectedly when CliServerManager wrapped them with "cmd /c". Changes: - CapiProxy: use "cmd /c npx" on Windows to launch the test harness - CliServerManagerTest: replace "cat" with cross-platform dummy process; use a platform-appropriate nonexistent absolute path so IOException is thrown on all platforms - JsonRpcClientTest: replace "cat" with cross-platform dummy process All changes use runtime os.name detection and preserve existing behavior on Linux and macOS. Full test suite passes on all platforms (556 tests, 0 failures, 0 errors). --- .../com/github/copilot/sdk/CapiProxy.java | 6 ++++- .../copilot/sdk/CliServerManagerTest.java | 26 ++++++++++++------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/test/java/com/github/copilot/sdk/CapiProxy.java b/src/test/java/com/github/copilot/sdk/CapiProxy.java index 1a7df2d6c..bcd064d94 100644 --- a/src/test/java/com/github/copilot/sdk/CapiProxy.java +++ b/src/test/java/com/github/copilot/sdk/CapiProxy.java @@ -89,7 +89,11 @@ public String start() throws IOException, InterruptedException { } // Start the harness server using npx tsx - var pb = new ProcessBuilder("npx", "tsx", "server.ts"); + // On Windows, npx is installed as npx.cmd which requires cmd /c to launch + boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win"); + var pb = isWindows + ? new ProcessBuilder("cmd", "/c", "npx", "tsx", "server.ts") + : new ProcessBuilder("npx", "tsx", "server.ts"); pb.directory(harnessDir.toFile()); pb.redirectErrorStream(false); diff --git a/src/test/java/com/github/copilot/sdk/CliServerManagerTest.java b/src/test/java/com/github/copilot/sdk/CliServerManagerTest.java index e59d03ae8..e556839cc 100644 --- a/src/test/java/com/github/copilot/sdk/CliServerManagerTest.java +++ b/src/test/java/com/github/copilot/sdk/CliServerManagerTest.java @@ -131,6 +131,14 @@ void processInfoWithNullPort() { // resolveCliCommand is private, so we test indirectly through startCliServer // with specific cliPath values. + // On Windows, "/nonexistent/copilot" is not an absolute path (no drive letter), + // so resolveCliCommand wraps it with "cmd /c" and ProcessBuilder.start() + // succeeds + // (launching cmd.exe). Use a Windows-absolute path to ensure IOException. + private static final String NONEXISTENT_CLI = System.getProperty("os.name").toLowerCase().contains("win") + ? "C:\\nonexistent\\copilot" + : "/nonexistent/copilot"; + @Test void startCliServerWithJsFile() throws Exception { // Using a .js file path causes resolveCliCommand to prepend "node" @@ -152,8 +160,8 @@ void startCliServerWithJsFile() throws Exception { @Test void startCliServerWithCliArgs() throws Exception { // Test that cliArgs are included in the command - var options = new CopilotClientOptions().setCliPath("/nonexistent/copilot") - .setCliArgs(new String[]{"--extra-flag"}).setUseStdio(true); + var options = new CopilotClientOptions().setCliPath(NONEXISTENT_CLI).setCliArgs(new String[]{"--extra-flag"}) + .setUseStdio(true); var manager = new CliServerManager(options); var ex = assertThrows(IOException.class, () -> manager.startCliServer()); @@ -163,7 +171,7 @@ void startCliServerWithCliArgs() throws Exception { @Test void startCliServerWithExplicitPort() throws Exception { // Test the explicit port branch (useStdio=false, port > 0) - var options = new CopilotClientOptions().setCliPath("/nonexistent/copilot").setUseStdio(false).setPort(9999); + var options = new CopilotClientOptions().setCliPath(NONEXISTENT_CLI).setUseStdio(false).setPort(9999); var manager = new CliServerManager(options); var ex = assertThrows(IOException.class, () -> manager.startCliServer()); @@ -173,7 +181,7 @@ void startCliServerWithExplicitPort() throws Exception { @Test void startCliServerWithGitHubToken() throws Exception { // Test the github token branch - var options = new CopilotClientOptions().setCliPath("/nonexistent/copilot").setGitHubToken("ghp_test123") + var options = new CopilotClientOptions().setCliPath(NONEXISTENT_CLI).setGitHubToken("ghp_test123") .setUseStdio(true); var manager = new CliServerManager(options); @@ -184,7 +192,7 @@ void startCliServerWithGitHubToken() throws Exception { @Test void startCliServerWithUseLoggedInUserExplicit() throws Exception { // Test the explicit useLoggedInUser=false branch (adds --no-auto-login) - var options = new CopilotClientOptions().setCliPath("/nonexistent/copilot").setUseLoggedInUser(false) + var options = new CopilotClientOptions().setCliPath(NONEXISTENT_CLI).setUseLoggedInUser(false) .setUseStdio(true); var manager = new CliServerManager(options); @@ -195,7 +203,7 @@ void startCliServerWithUseLoggedInUserExplicit() throws Exception { @Test void startCliServerWithGitHubTokenAndNoExplicitUseLoggedInUser() throws Exception { // When gitHubToken is set and useLoggedInUser is null, defaults to false - var options = new CopilotClientOptions().setCliPath("/nonexistent/copilot").setGitHubToken("ghp_test123") + var options = new CopilotClientOptions().setCliPath(NONEXISTENT_CLI).setGitHubToken("ghp_test123") .setUseStdio(true); var manager = new CliServerManager(options); @@ -225,8 +233,7 @@ void startCliServerWithTelemetryAllOptions() throws Exception { // so even with a nonexistent CLI path, the telemetry code path is exercised var telemetry = new TelemetryConfig().setOtlpEndpoint("http://localhost:4318").setFilePath("/tmp/telemetry.log") .setExporterType("otlp-http").setSourceName("test-app").setCaptureContent(true); - var options = new CopilotClientOptions().setCliPath("/nonexistent/copilot").setTelemetry(telemetry) - .setUseStdio(true); + var options = new CopilotClientOptions().setCliPath(NONEXISTENT_CLI).setTelemetry(telemetry).setUseStdio(true); var manager = new CliServerManager(options); var ex = assertThrows(IOException.class, () -> manager.startCliServer()); @@ -237,8 +244,7 @@ void startCliServerWithTelemetryAllOptions() throws Exception { void startCliServerWithTelemetryCaptureContentFalse() throws Exception { // Test the false branch of getCaptureContent() var telemetry = new TelemetryConfig().setCaptureContent(false); - var options = new CopilotClientOptions().setCliPath("/nonexistent/copilot").setTelemetry(telemetry) - .setUseStdio(true); + var options = new CopilotClientOptions().setCliPath(NONEXISTENT_CLI).setTelemetry(telemetry).setUseStdio(true); var manager = new CliServerManager(options); var ex = assertThrows(IOException.class, () -> manager.startCliServer());