diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index ddc73f3a8..482adb13b 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -12,7 +12,7 @@ coverage coverage - 84.8% - 84.8% + 84.7% + 84.7% 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)" 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 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 0084eb417..3010b6839 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 @@ -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 @@ -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/pom.xml b/pom.xml index d8417fdeb..43e1b436d 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ com.github copilot-sdk-java - 0.2.1-java.0 + 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 - v0.2.1-java.0 + v0.2.1-java.1 @@ -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/CopilotClient.java b/src/main/java/com/github/copilot/sdk/CopilotClient.java index 707469428..e2790f6a3 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -13,6 +13,8 @@ import java.util.concurrent.CompletableFuture; 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; @@ -150,42 +152,51 @@ 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(); + try { + return exec != null + ? CompletableFuture.supplyAsync(this::startCoreBody, exec) + : CompletableFuture.supplyAsync(this::startCoreBody); + } catch (RejectedExecutionException e) { + return CompletableFuture.failedFuture(e); + } + } - 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 +239,27 @@ 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); } - })); + }; + 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(); @@ -329,6 +352,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 +425,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 8c68e1e3e..844737fc2 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -13,7 +13,11 @@ import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; +import java.util.concurrent.Executor; +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; import java.util.concurrent.atomic.AtomicReference; @@ -121,6 +125,8 @@ 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; + private volatile Executor executor; /** Tracks whether this session instance has been terminated via close(). */ private volatile boolean isTerminated = false; @@ -157,6 +163,21 @@ public final class CopilotSession implements AutoCloseable { this.sessionId = sessionId; this.rpc = rpc; this.workspacePath = workspacePath; + var executor = new ScheduledThreadPoolExecutor(1, r -> { + var t = new Thread(r, "sendAndWait-timeout"); + t.setDaemon(true); + return t; + }); + executor.setRemoveOnCancelPolicy(true); + this.timeoutScheduler = executor; + } + + /** + * Sets the executor for internal async operations. Package-private; called by + * CopilotClient after construction. + */ + void setExecutor(Executor executor) { + this.executor = executor; } /** @@ -407,29 +428,41 @@ 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(() -> { - if (!future.isDone()) { - future.completeExceptionally(new TimeoutException("sendAndWait timed out after " + timeoutMs + "ms")); - } - scheduler.shutdown(); - }, timeoutMs, TimeUnit.MILLISECONDS); - var result = new CompletableFuture(); + // Schedule timeout on the shared session-level scheduler. + // Per Javadoc, timeoutMs <= 0 means "no timeout". + ScheduledFuture timeoutTask = null; + if (timeoutMs > 0) { + 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); + } + 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); } - scheduler.shutdown(); + if (taskToCancel != null) { + taskToCancel.cancel(false); + } if (!result.isDone()) { if (ex != null) { result.completeExceptionally(ex); @@ -650,7 +683,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 @@ -695,7 +728,17 @@ private void executeToolAndRespondAsync(String requestId, String toolName, Strin LOG.log(Level.WARNING, "Error sending tool 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 tool task for requestId=" + requestId + "; running inline", e); + task.run(); + } } /** @@ -704,7 +747,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); @@ -743,7 +786,17 @@ private void executePermissionAndRespondAsync(String requestId, PermissionReques LOG.log(Level.WARNING, "Error sending permission denied 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 perm task for requestId=" + requestId + "; running inline", e); + task.run(); + } } /** @@ -1303,6 +1356,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/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java b/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java index 101f68528..f1d488105 100644 --- a/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java +++ b/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java @@ -9,6 +9,8 @@ import java.util.Collections; 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; @@ -45,6 +47,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 +56,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 +125,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 +185,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 +229,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 +285,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 +328,7 @@ interface LifecycleEventDispatcher { } private void handleSystemMessageTransform(JsonRpcClient rpc, String requestId, JsonNode params) { - CompletableFuture.runAsync(() -> { + runAsync(() -> { try { final long requestIdLong; try { @@ -359,4 +366,17 @@ private void handleSystemMessageTransform(JsonRpcClient rpc, String requestId, J } }); } + + private void runAsync(Runnable 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(); + } + } } 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..2e9a80456 100644 --- a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java +++ b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java @@ -4,9 +4,13 @@ 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; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; import java.util.function.Supplier; import com.fasterxml.jackson.annotation.JsonInclude; @@ -34,132 +38,125 @@ @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 Boolean useLoggedInUser; + private boolean useStdio = true; /** - * 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; - } - - /** - * 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. + *

+ * Returns a shallow copy of the internal array, or {@code null} if no arguments + * have been set. * - * @return the port number, or 0 for a random port + * @return a copy of the extra arguments, or {@code null} */ - public int getPort() { - return port; + public String[] getCliArgs() { + return cliArgs != null ? Arrays.copyOf(cliArgs, cliArgs.length) : null; } /** - * 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. A shallow copy of the + * provided array is stored. If {@code null} or empty, the existing arguments + * are cleared. * - * @param port - * the port number, or 0 for a random port + * @param cliArgs + * the extra arguments to pass, or {@code null}/empty to clear * @return this options instance for method chaining */ - public CopilotClientOptions setPort(int port) { - this.port = port; + public CopilotClientOptions setCliArgs(String[] cliArgs) { + 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; } /** - * 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 * @return this options instance for method chaining */ - public CopilotClientOptions setUseStdio(boolean useStdio) { - this.useStdio = useStdio; + public CopilotClientOptions setCliPath(String cliPath) { + this.cliPath = Objects.requireNonNull(cliPath, "cliPath must not be null"); return this; } @@ -182,107 +179,101 @@ 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 = cliUrl; - return this; - } - - /** - * 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; + this.cliUrl = Objects.requireNonNull(cliUrl, "cliUrl must not be null"); 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 (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 setAutoStart(boolean autoStart) { - this.autoStart = autoStart; + public CopilotClientOptions setCwd(String cwd) { + this.cwd = Objects.requireNonNull(cwd, "cwd must not be null"); return this; } /** - * Returns whether the client should automatically restart the server on crash. + * 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 auto-restart flag value (no longer has any effect) - * @deprecated This option has no effect and will be removed in a future - * release. + * @return a copy of the environment variables map, or {@code null} */ - @Deprecated - public boolean isAutoRestart() { - return autoRestart; + public Map getEnvironment() { + return environment != null ? new HashMap<>(environment) : null; } /** - * 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. A + * shallow copy of the provided map is stored. If {@code null} or empty, the + * existing environment is cleared. * - * @param autoRestart - * ignored — this option no longer has any effect + * @param environment + * the environment variables map, or {@code null}/empty to clear * @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) { + if (environment == null || environment.isEmpty()) { + if (this.environment != null) { + this.environment.clear(); + } + } else { + this.environment = new HashMap<>(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 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 set, these environment variables replace the inherited environment. + * Passing {@code null} reverts to the default {@code ForkJoinPool.commonPool()} + * behavior. * - * @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; } @@ -302,11 +293,13 @@ 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 = gitHubToken; + this.gitHubToken = Objects.requireNonNull(gitHubToken, "gitHubToken must not be null"); return this; } @@ -331,33 +324,32 @@ public String getGithubToken() { */ @Deprecated public CopilotClientOptions setGithubToken(String githubToken) { - this.gitHubToken = githubToken; + this.gitHubToken = Objects.requireNonNull(githubToken, "githubToken must not be null"); return this; } /** - * 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 (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 setUseLoggedInUser(Boolean useLoggedInUser) { - this.useLoggedInUser = useLoggedInUser; + public CopilotClientOptions setLogLevel(String logLevel) { + this.logLevel = Objects.requireNonNull(logLevel, "logLevel must not be null"); return this; } @@ -378,11 +370,37 @@ 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 = onListModels; + this.onListModels = Objects.requireNonNull(onListModels, "onListModels must not be null"); + return this; + } + + /** + * Gets the TCP port for the CLI server. + * + * @return the port number, or 0 for a random port + */ + public int getPort() { + return port; + } + + /** + * Sets the TCP port for the CLI server to listen on. + *

+ * 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; } @@ -399,8 +417,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 @@ -408,7 +426,60 @@ 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; + } + + /** + * Returns whether to use the logged-in user for authentication. + * + * @return {@code true} to use logged-in user auth, {@code false} to use only + * explicit tokens, or {@code null} to use default behavior + */ + public Boolean getUseLoggedInUser() { + return useLoggedInUser; + } + + /** + * Sets whether to use the logged-in user for authentication. + *

+ * 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} or + * {@code null} otherwise + * @return this options instance for method chaining + */ + public CopilotClientOptions setUseLoggedInUser(Boolean useLoggedInUser) { + this.useLoggedInUser = useLoggedInUser != null ? useLoggedInUser : Boolean.FALSE; + 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; } @@ -425,20 +496,21 @@ public CopilotClientOptions setTelemetry(TelemetryConfig telemetry) { @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.useLoggedInUser = this.useLoggedInUser; + copy.useStdio = this.useStdio; return copy; } } 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 94a83acae..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; @@ -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/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; 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 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(); 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 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..15904504a --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/ExecutorWiringTest.java @@ -0,0 +1,362 @@ +/*--------------------------------------------------------------------------------------------- + * 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.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; + +/** + * 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 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..e60e4aa34 --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/SchedulerShutdownRaceTest.java @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * 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; + +/** + * 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}. 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 { + + @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(); + + // 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"); + 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/TimeoutEdgeCaseTest.java b/src/test/java/com/github/copilot/sdk/TimeoutEdgeCaseTest.java new file mode 100644 index 000000000..c5ed3af81 --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/TimeoutEdgeCaseTest.java @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * 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; + +/** + * Regression tests for timeout edge cases in + * {@link CopilotSession#sendAndWait}. + *

+ * These tests assert two behavioral contracts of the shared + * {@code ScheduledExecutorService} approach: + *

    + *
  1. A pending timeout must NOT fire after {@code close()} and must NOT + * complete the returned future with a {@code TimeoutException}.
  2. + *
  3. Multiple {@code sendAndWait} calls must reuse a single shared scheduler + * thread rather than spawning a new OS thread per call.
  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. + *

+ * 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 { + JsonRpcClient rpc = createHangingRpcClient(); + try { + 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 must reuse a single thread across multiple + * {@code sendAndWait} calls, rather than spawning a new OS thread per call. + *

+ * 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 { + JsonRpcClient rpc = createHangingRpcClient(); + try { + 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); + } + } 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(); + } +} 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..3c79ac263 --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/ZeroTimeoutContractTest.java @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * 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 org.junit.jupiter.api.Test; + +import com.github.copilot.sdk.events.AssistantMessageEvent; +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())).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"); + } + } +}