- * 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
+ * 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
+ * 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
- * 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
+ * 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.
+ *
+ * {@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()}.
+ *
+ * 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.
+ *
+ * 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 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.
+ *
+ * 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 CopilotClient.stop()} uses {@code CompletableFuture.runAsync(...)} to
+ * close each active session. This test asserts that those closures go through
+ * the caller-supplied executor.
+ *
+ * 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:
+ *
+ * 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
+ * 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
+ *
+ */
+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.
+ *