From c415cb8b987a8e99c951630308116f16733724a1 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Thu, 28 May 2026 15:34:24 -0700 Subject: [PATCH 1/3] backport https://github.com/github/copilot-sdk/pull/1483 --- .github/actions/test-report/action.yml | 10 +- .github/workflows/build-test.yml | 82 +++++--- README.md | 80 +++---- jbang-example.java | 2 +- pom.xml | 129 +++++++++++- scripts/compare-standalone-to-monorepo.sh | 52 ++++- .../com/github/copilot/CopilotClient.java | 197 +++++++++++++----- .../com/github/copilot/CopilotSession.java | 39 +++- .../copilot/InternalExecutorProvider.java | 60 ++++++ .../github/copilot/SessionRequestBuilder.java | 18 +- .../copilot/rpc/CopilotClientOptions.java | 21 +- .../copilot/rpc/CreateSessionRequest.java | 88 +++++++- .../copilot/rpc/LargeToolOutputConfig.java | 91 ++++++++ .../github/copilot/rpc/MessageOptions.java | 26 +++ .../copilot/rpc/ResumeSessionConfig.java | 144 ++++++++++++- .../copilot/rpc/ResumeSessionRequest.java | 88 +++++++- .../copilot/rpc/SendMessageRequest.java | 18 ++ .../com/github/copilot/rpc/SessionConfig.java | 162 +++++++++++++- .../copilot/rpc/SessionUiCapabilities.java | 40 ++++ .../copilot/InternalExecutorProvider.java | 65 ++++++ .../com/github/copilot/ConfigCloneTest.java | 19 +- .../github/copilot/CopilotSessionTest.java | 2 +- .../copilot/CreateSessionReKeyEntryTest.java | 44 ++-- .../copilot/InternalExecutorProviderIT.java | 108 ++++++++++ .../InternalExecutorProviderProbe.java | 74 +++++++ .../copilot/InternalExecutorProviderTest.java | 55 +++++ .../copilot/SessionRequestBuilderTest.java | 75 +++++++ 27 files changed, 1587 insertions(+), 202 deletions(-) create mode 100644 src/main/java/com/github/copilot/InternalExecutorProvider.java create mode 100644 src/main/java/com/github/copilot/rpc/LargeToolOutputConfig.java create mode 100644 src/main/java25/com/github/copilot/InternalExecutorProvider.java create mode 100644 src/test/java/com/github/copilot/InternalExecutorProviderIT.java create mode 100644 src/test/java/com/github/copilot/InternalExecutorProviderProbe.java create mode 100644 src/test/java/com/github/copilot/InternalExecutorProviderTest.java diff --git a/.github/actions/test-report/action.yml b/.github/actions/test-report/action.yml index 52a093a68d..6ac1fcbc76 100644 --- a/.github/actions/test-report/action.yml +++ b/.github/actions/test-report/action.yml @@ -4,7 +4,7 @@ inputs: report-path: description: "Path to the test report XML files (glob pattern)" required: false - default: "target/surefire-reports*/TEST-*.xml" + default: "target/{surefire-reports*,failsafe-reports}/TEST-*.xml" jacoco-path: description: "Path to the JaCoCo XML report" required: false @@ -17,13 +17,17 @@ inputs: description: "Name for the check run" required: false default: "Java SDK Test Results" + title: + description: "Title for the test report summary" + required: false + default: "Copilot Java SDK :: Test Results" runs: using: "composite" steps: - name: Generate Test Summary shell: bash run: | - echo "## ๐Ÿงช Copilot Java SDK :: Test Results" >> $GITHUB_STEP_SUMMARY + echo "## ๐Ÿงช ${{ inputs.title }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY if ls ${{ inputs.report-path }} 1>/dev/null 2>&1; then @@ -148,7 +152,7 @@ runs: if [ -f "$JACOCO_CSV" ]; then extract_instruction_scope() { local scope=$1 - awk -F',' -v scope="$scope" -v generated_prefix="com.github.copilot.sdk.generated" ' + awk -F',' -v scope="$scope" -v generated_prefix="com.github.copilot.generated" ' NR > 1 { is_generated = index($2, generated_prefix) == 1 if ((scope == "generated" && is_generated) || diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 29fd1fd119..dbc7406993 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -30,7 +30,7 @@ jobs: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} java-sdk: - name: "Java SDK Tests" + name: "Java SDK Tests (JDK ${{ matrix.test-jdk }})" needs: smoke-test if: ${{ always() && needs.smoke-test.result != 'failure' }} permissions: @@ -39,6 +39,10 @@ jobs: pull-requests: write runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + test-jdk: ["25", "17"] defaults: run: shell: bash @@ -46,16 +50,29 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 - with: - node-version: 22 + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 with: - java-version: "17" + java-version: "25" distribution: "microsoft" cache: "maven" + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: 22 + + - name: Build SDK and set up test harness + run: mvn test-compile jar:jar + + - name: Verify Javadoc generation + if: matrix.test-jdk == '25' + run: mvn javadoc:javadoc -q + + - name: Verify CLI works + run: node target/copilot-sdk/nodejs/node_modules/@github/copilot/index.js --version + - name: Run spotless check + if: matrix.test-jdk == '25' run: | mvn spotless:check if [ $? -ne 0 ]; then @@ -64,30 +81,27 @@ jobs: fi echo "โœ… spotless:check passed" - - name: Build SDK and clone test harness - run: mvn test-compile - - - name: Verify Javadoc generation - run: mvn javadoc:javadoc -q - - - name: Install Copilot CLI from cloned SDK - id: setup-copilot - run: | - # Install dependencies in the cloned SDK's nodejs directory - # This ensures we use the same CLI version as the test harness expects - cd target/copilot-sdk/nodejs - npm ci --ignore-scripts - echo "path=$(pwd)/node_modules/@github/copilot/index.js" >> $GITHUB_OUTPUT + - name: Run Java SDK tests (JDK 25) + if: matrix.test-jdk == '25' + env: + CI: "true" + run: mvn verify -Dskip.test.harness=true - - name: Verify CLI works - run: node ${{ steps.setup-copilot.outputs.path }} --version + - name: Switch to JDK 17 + if: matrix.test-jdk == '17' + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + java-version: "17" + distribution: "microsoft" - - name: Run Java SDK tests + - name: Run Java SDK tests (JDK 17, no recompilation) + if: matrix.test-jdk == '17' env: CI: "true" - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - COPILOT_CLI_PATH: ${{ steps.setup-copilot.outputs.path }} - run: mvn verify + run: | + echo "Running tests against JDK 25-built classes using JDK 17 runtime..." + java -version + mvn jacoco:prepare-agent@wire-up-coverage-instrumentation antrun:run@print-test-jdk-banner surefire:test failsafe:integration-test failsafe:verify jacoco:report@build-coverage-report-from-tests -Denforcer.skip=true - name: Validate reference-impl-sync completeness if: >- @@ -123,7 +137,7 @@ jobs: fi - name: Upload test results for site generation - if: success() && github.ref == 'refs/heads/main' + if: success() && github.ref == 'refs/heads/main' && matrix.test-jdk == '25' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: test-results-for-site @@ -134,11 +148,11 @@ jobs: retention-days: 1 - name: Generate JaCoCo badge - if: success() && github.ref == 'refs/heads/main' + if: success() && github.ref == 'refs/heads/main' && matrix.test-jdk == '25' run: .github/scripts/generate-coverage-badge.sh - name: Create PR for JaCoCo badge update - if: success() && github.ref == 'refs/heads/main' + if: success() && github.ref == 'refs/heads/main' && matrix.test-jdk == '25' uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v7 with: commit-message: "Update JaCoCo coverage badge" @@ -151,3 +165,15 @@ jobs: - name: Generate Test Report Summary if: always() uses: ./.github/actions/test-report + with: + title: "Copilot Java SDK :: Test Results JDK ${{ matrix.test-jdk }}" + + - name: Upload test results on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: test-results-jdk-${{ matrix.test-jdk }} + path: | + target/surefire-reports/ + target/surefire-reports-isolated/ + retention-days: 7 diff --git a/README.md b/README.md index 22b2f29cfc..46c01635ad 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,14 @@ # GitHub Copilot SDK for Java -[![Build](https://github.com/github/copilot-sdk-java/actions/workflows/build-test.yml/badge.svg)](https://github.com/github/copilot-sdk-java/actions/workflows/build-test.yml) -[![Site](https://github.com/github/copilot-sdk-java/actions/workflows/deploy-site.yml/badge.svg)](https://github.com/github/copilot-sdk-java/actions/workflows/deploy-site.yml) -[![Handwritten Coverage](.github/badges/jacoco-handwritten.svg)](https://github.github.io/copilot-sdk-java/snapshot/jacoco/index.html) -[![Generated Coverage](.github/badges/jacoco-generated.svg)](https://github.github.io/copilot-sdk-java/snapshot/jacoco/index.html) -[![Documentation](https://img.shields.io/badge/docs-online-brightgreen)](https://github.github.io/copilot-sdk-java/) +[![Build](https://github.com/github/copilot-sdk/actions/workflows/java-sdk-tests.yml/badge.svg)](https://github.com/github/copilot-sdk/actions/workflows/java-sdk-tests.yml) [![Java 17+](https://img.shields.io/badge/Java-17%2B-blue?logo=openjdk&logoColor=white)](https://openjdk.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) #### Latest release -[![GitHub Release Date](https://img.shields.io/github/release-date/github/copilot-sdk-java)](https://github.com/github/copilot-sdk-java/releases) -[![GitHub Release](https://img.shields.io/github/v/release/github/copilot-sdk-java)](https://github.com/github/copilot-sdk-java/releases) + +[![GitHub Release Date](https://img.shields.io/github/release-date/github/copilot-sdk)](https://github.com/github/copilot-sdk/releases) +[![GitHub Release](https://img.shields.io/github/v/release/github/copilot-sdk)](https://github.com/github/copilot-sdk/releases) [![Maven Central](https://img.shields.io/maven-central/v/com.github/copilot-sdk-java)](https://central.sonatype.com/artifact/com.github/copilot-sdk-java) -[![Documentation](https://img.shields.io/badge/docs-latest-brightgreen)](https://github.github.io/copilot-sdk-java/latest/) [![Javadoc](https://javadoc.io/badge2/com.github/copilot-sdk-java/javadoc.svg?q=1)](https://javadoc.io/doc/com.github/copilot-sdk-java/latest/index.html) ## Background @@ -23,10 +19,10 @@ Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build A ## Installation -### Requirements +### Runtime requirements -- Java 17 or later. **JDK 25 recommended**. Selecting JDK 25 enables the use of virtual threads, as shown in the [Quick Start](#quick-start). -- GitHub Copilot CLI 1.0.17 or later installed and in `PATH` (or provide custom `cliPath`) +- Java 17 or later. **JDK 25 recommended**. The distributed jar is a multi-release jar (MR-JAR) and is compiled on JDK 25 with `maven.compiler.release` set to 17. This means, when run on JDK 25 and later, the SDK automatically uses virtual threads for its default internal executor. +- GitHub Copilot CLI 1.0.55-5. or later installed and in `PATH` (or provide custom `cliPath`) ### Maven @@ -34,10 +30,16 @@ Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build A com.github copilot-sdk-java - 1.0.0-beta-8-java.0 + 1.0.0-beta-10-java.0 ``` +### Gradle + +```groovy +implementation 'com.github:copilot-sdk-java:1.0.0-beta-10-java.0' + + #### Snapshot Builds Snapshot builds of the next development version are published to Maven Central Snapshots. To use them, add the repository and update the dependency version in your `pom.xml`: @@ -54,14 +56,14 @@ Snapshot builds of the next development version are published to Maven Central S com.github copilot-sdk-java - 1.0.0-beta-8-java.1-SNAPSHOT + 1.0.0-beta-10-java.0-SNAPSHOT ``` ### Gradle ```groovy -implementation 'com.github:copilot-sdk-java:1.0.0-beta-8-java.0' +implementation 'com.github:copilot-sdk-java:1.0.0-beta-10-java.0-SNAPSHOT' ``` ## Quick Start @@ -70,23 +72,16 @@ implementation 'com.github:copilot-sdk-java:1.0.0-beta-8-java.0' import com.github.copilot.CopilotClient; import com.github.copilot.generated.AssistantMessageEvent; import com.github.copilot.generated.SessionUsageInfoEvent; -import com.github.copilot.rpc.CopilotClientOptions; import com.github.copilot.rpc.MessageOptions; import com.github.copilot.rpc.PermissionHandler; import com.github.copilot.rpc.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()) { // 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)) { + try (var client = new CopilotClient()) { client.start().get(); // Create a session @@ -130,31 +125,20 @@ See the full source of [`jbang-example.java`](jbang-example.java) for a complete Or run it directly from the repository: ```bash -jbang https://github.com/github/copilot-sdk-java/blob/latest/jbang-example.java +jbang https://github.com/github/copilot-sdk/blob/main/java/jbang-example.java ``` -## Documentation - -๐Ÿ“š **[Full Documentation](https://github.github.io/copilot-sdk-java/)** โ€” Complete API reference, advanced usage examples, and guides. - -### Quick Links - -- [Getting Started](https://github.github.io/copilot-sdk-java/latest/documentation.html) -- [Javadoc API Reference](https://github.github.io/copilot-sdk-java/latest/apidocs/) -- [MCP Servers Integration](https://github.github.io/copilot-sdk-java/latest/mcp.html) -- [Cookbook](src/site/markdown/cookbook/) โ€” Practical recipes for common use cases - ## Projects Using This SDK -| Project | Description | -|---------|-------------| +| Project | Description | +| ----------------------------------------------------------------------------- | ------------------------------------------ | | [JMeter Copilot Plugin](https://github.com/brunoborges/jmeter-copilot-plugin) | JMeter plugin for AI-assisted load testing | > Want to add your project? Open a PR! ## CI/CD Workflows -This project uses several GitHub Actions workflows for building, testing, releasing, and syncing with the reference implementation SDK. +This project uses several GitHub Actions workflows for building, testing, releasing, and syncing with the reference implementation SDK. See [WORKFLOWS.md](docs/WORKFLOWS.md) for a full overview and details on each workflow. @@ -169,21 +153,28 @@ This SDK tracks the official [Copilot SDK](https://github.com/github/copilot-sdk **Automated sync** โ€” A [scheduled GitHub Actions workflow](.github/workflows/reference-impl-sync.yml) runs on the schedule specified in that file. It checks for new reference implementation commits since the last merge (tracked in [`.lastmerge`](.lastmerge)), and if changes are found, creates an issue labeled `reference-impl-sync` and assigns it to the GitHub Copilot coding agent. Any previously open `reference-impl-sync` issues are automatically closed. The sync also updates the `@github/copilot` version in both `pom.xml` and `scripts/codegen/package.json` to keep schemas and test CLI in lockstep. **Reusable prompt** โ€” The merge workflow is defined in [`agentic-merge-reference-impl.prompt.md`](.github/prompts/agentic-merge-reference-impl.prompt.md). It can be triggered manually from: + - **VS Code Copilot Chat** โ€” type `/agentic-merge-reference-impl` - **GitHub Copilot CLI** โ€” use `copilot` CLI with the same skill reference ### Development Setup +Requires JDK 25 or later for development. + ```bash # Clone the repository -git clone https://github.com/github/copilot-sdk-java.git -cd copilot-sdk-java +git clone https://github.com/github/copilot-sdk.git +cd copilot-sdk/java # Enable git hooks for code formatting git config core.hooksPath .githooks -# Build and test +# Build and test with JDK 25 mvn clean verify + +# Set your paths for JDK 17 +# Run the JDK 25 built jar with JDK 17 JVM for tests. Do not re-compile the jar. +mvn jacoco:prepare-agent@wire-up-coverage-instrumentation antrun:run@print-test-jdk-banner surefire:test failsafe:integration-test failsafe:verify jacoco:report@build-coverage-report-from-tests -Denforcer.skip=true ``` The tests require the official [copilot-sdk](https://github.com/github/copilot-sdk) test harness, which is automatically cloned during build. @@ -204,12 +195,3 @@ See [SECURITY.md](SECURITY.md) for reporting security vulnerabilities. MIT โ€” see [LICENSE](LICENSE) for details. -## Acknowledgement - -- Initially developed with Copilot and [Bruno Borges](https://www.linkedin.com/in/brunocborges/). - -## Star History - -[![Star History Chart](https://api.star-history.com/svg?repos=github/copilot-sdk-java&type=Date)](https://www.star-history.com/#github/copilot-sdk-java&Date) - -โญ Drop a star if you find this useful! diff --git a/jbang-example.java b/jbang-example.java index 7482eee3df..a3616e2636 100644 --- a/jbang-example.java +++ b/jbang-example.java @@ -1,5 +1,5 @@ ///usr/bin/env jbang "$0" "$@" ; exit $? -//DEPS com.github:copilot-sdk-java:1.0.0-beta-8-java.0 +//DEPS com.github:copilot-sdk-java:1.0.0-beta-java.4 import com.github.copilot.CopilotClient; import com.github.copilot.generated.AssistantMessageEvent; import com.github.copilot.generated.SessionUsageInfoEvent; diff --git a/pom.xml b/pom.xml index bd6425a1b7..fe267e2361 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ com.github copilot-sdk-java - 1.0.0-beta-8-java.1-SNAPSHOT + 1.0.0-beta-10-java.0-SNAPSHOT jar GitHub Copilot SDK :: Java @@ -245,6 +245,18 @@ + + print-test-jdk-banner + process-test-classes + + run + + + + + + + @@ -307,6 +319,33 @@ + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.5.5 + + + + integration-test + verify + + + + + + ${project.build.directory} + ${project.build.finalName} + ${project.build.testOutputDirectory} + + + org.apache.maven.plugins maven-surefire-plugin @@ -321,7 +360,7 @@ CompactionTest) where snapshot matching can be sensitive to non-deterministic compaction behaviour. Revisit this once this issue is successfully resolved. - https://github.com/github/copilot-sdk/issues/1227 + https://github.com/github/copilot-sdk/issues/1227 --> 2 @@ -447,6 +486,10 @@ ${project.build.directory}/jacoco-test-results/sdk-tests.exec ${project.reporting.outputDirectory}/jacoco-coverage + + + META-INF/versions/**/*.class + @@ -693,6 +736,88 @@ -XX:+EnableDynamicAgentLoading + + java25-multi-release + + [25,) + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + compile-java25 + compile + + compile + + + 25 + false + + ${project.basedir}/src/main/java25 + + true + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + true + + + + + + + org.apache.maven.plugins + maven-antrun-plugin + + + verify-java25-overlay + package + + run + + + + + + + + + +JDK 25 multi-release overlay class is missing from the packaged JAR. +Expected entry: META-INF/versions/25/com/github/copilot/InternalExecutorProvider.class +JAR: ${project.build.directory}/${project.build.finalName}.jar + +This usually means the 'java25-multi-release' Maven profile did not activate +(e.g. the build is running on a JDK older than 25) or maven-compiler-plugin +did not produce the multi-release output. Re-build on JDK 25+ and verify the +'compile-java25' execution ran during the 'compile' phase. + + + + + + + + + skip-test-harness diff --git a/scripts/compare-standalone-to-monorepo.sh b/scripts/compare-standalone-to-monorepo.sh index ad329fdb1f..34ac23ec65 100755 --- a/scripts/compare-standalone-to-monorepo.sh +++ b/scripts/compare-standalone-to-monorepo.sh @@ -176,6 +176,35 @@ else LASTMERGE_STATUS="missing-both" fi +# โ”€โ”€ Compare README.md (standalone README.md โ†” monorepo java/README.md) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +README_STATUS="" +STANDALONE_README="${STANDALONE}/README.md" +MONO_README="${MONO_JAVA}/README.md" + +if [ -f "$STANDALONE_README" ] && [ -f "$MONO_README" ]; then + if diff -q "$STANDALONE_README" "$MONO_README" >/dev/null 2>&1; then + README_STATUS="identical" + SAME_COUNT=$((SAME_COUNT + 1)) + else + README_STATUS="differ" + DIFFER_COUNT=$((DIFFER_COUNT + 1)) + DIFFER_LIST="${DIFFER_LIST}README.md +" + fi +elif [ -f "$STANDALONE_README" ] && [ ! -f "$MONO_README" ]; then + README_STATUS="only-standalone" + MISSING_FROM_MONO_COUNT=$((MISSING_FROM_MONO_COUNT + 1)) + MISSING_FROM_MONO_LIST="${MISSING_FROM_MONO_LIST}README.md +" +elif [ ! -f "$STANDALONE_README" ] && [ -f "$MONO_README" ]; then + README_STATUS="only-monorepo" + MISSING_FROM_STANDALONE_COUNT=$((MISSING_FROM_STANDALONE_COUNT + 1)) + MISSING_FROM_STANDALONE_LIST="${MISSING_FROM_STANDALONE_LIST}README.md +" +else + README_STATUS="missing-both" +fi + STANDALONE_TOTAL=$(wc -l < "$TMPFILE_STANDALONE" | tr -d ' ') MONO_TOTAL=$(wc -l < "$TMPFILE_MONO" | tr -d ' ') @@ -202,6 +231,19 @@ else echo ".lastmerge: not found in either location" fi +# README.md status +if [ "$README_STATUS" = "identical" ]; then + echo "README.md: identical" +elif [ "$README_STATUS" = "differ" ]; then + echo "README.md: DIFFERS" +elif [ "$README_STATUS" = "only-standalone" ]; then + echo "README.md: only in standalone" +elif [ "$README_STATUS" = "only-monorepo" ]; then + echo "README.md: only in monorepo" +else + echo "README.md: not found in either location" +fi + # scripts/codegen/java.ts status if [ "$JAVATS_STATUS" = "identical" ]; then echo "scripts/codegen/java.ts: identical" @@ -253,7 +295,7 @@ if [ "$SHOW_DIFF" = true ] && [ "$DIFFER_COUNT" -gt 0 ]; then echo "Unified diffs for differing files:" echo "================================================================================" printf '%s' "$DIFFER_LIST" | while IFS= read -r f; do - if [ -n "$f" ] && [ "$f" != ".lastmerge" ]; then + if [ -n "$f" ] && [ "$f" != ".lastmerge" ] && [ "$f" != "README.md" ]; then echo "" echo "--- standalone/$f" echo "+++ monorepo/java/$f" @@ -262,6 +304,14 @@ if [ "$SHOW_DIFF" = true ] && [ "$DIFFER_COUNT" -gt 0 ]; then done fi +# โ”€โ”€ Optional README.md diff (paths differ between repos) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +if [ "$SHOW_DIFF" = true ] && [ "$README_STATUS" = "differ" ]; then + echo "" + echo "--- standalone/README.md" + echo "+++ monorepo/java/README.md" + diff -u "${STANDALONE}/README.md" "${MONO_JAVA}/README.md" || true +fi + # โ”€โ”€ Optional .lastmerge commit log comparison โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if [ "$SHOW_DIFF" = true ] && [ "$LASTMERGE_STATUS" = "differ" ]; then STANDALONE_HASH=$(cat "$STANDALONE_LASTMERGE" | tr -d '[:space:]') diff --git a/src/main/java/com/github/copilot/CopilotClient.java b/src/main/java/com/github/copilot/CopilotClient.java index 662b66c7b4..36a3c78d1b 100644 --- a/src/main/java/com/github/copilot/CopilotClient.java +++ b/src/main/java/com/github/copilot/CopilotClient.java @@ -14,6 +14,7 @@ import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; import java.util.logging.Level; @@ -80,7 +81,24 @@ public final class CopilotClient implements AutoCloseable { */ public static final int AUTOCLOSEABLE_TIMEOUT_SECONDS = 10; private static final int FORCE_KILL_TIMEOUT_SECONDS = 10; + + /** + * One-shot dispatcher used to run the owned-executor shutdown off any caller + * thread that might itself belong to that executor (e.g. the + * {@link #forceStop()} continuation, which is chained off async work scheduled + * on the internal executor). Spawning a fresh daemon thread guarantees + * {@link java.util.concurrent.ExecutorService#awaitTermination(long, TimeUnit)} + * is never called from inside the very executor it is waiting on. + */ + private static final Executor SHUTDOWN_DISPATCHER = runnable -> { + Thread t = new Thread(runnable, "copilot-client-shutdown"); + t.setDaemon(true); + t.start(); + }; + private final CopilotClientOptions options; + private final Executor executor; + private final boolean executorCanBeShutdown; private final CliServerManager serverManager; private final LifecycleEventManager lifecycleManager = new LifecycleEventManager(); private final Map sessions = new ConcurrentHashMap<>(); @@ -168,6 +186,10 @@ public CopilotClient(CopilotClientOptions options) { this.optionsPort = null; } + InternalExecutorProvider executorProvider = new InternalExecutorProvider(this.options.getExecutor()); + this.executor = executorProvider.get(); + this.executorCanBeShutdown = executorProvider.canBeShutdown(); + this.serverManager = new CliServerManager(this.options); this.serverManager.setConnectionToken(this.effectiveConnectionToken); } @@ -191,11 +213,8 @@ public CompletableFuture start() { private CompletableFuture startCore() { LOG.fine("Starting Copilot client"); - Executor exec = options.getExecutor(); try { - return exec != null - ? CompletableFuture.supplyAsync(this::startCoreBody, exec) - : CompletableFuture.supplyAsync(this::startCoreBody); + return CompletableFuture.supplyAsync(this::startCoreBody, executor); } catch (RejectedExecutionException e) { return CompletableFuture.failedFuture(e); } @@ -224,8 +243,7 @@ private Connection startCoreBody() { Connection connection = new Connection(rpc, process, new ServerRpc(rpc::invoke)); // Register handlers for server-to-client calls - RpcHandlerDispatcher dispatcher = new RpcHandlerDispatcher(sessions, lifecycleManager::dispatch, - options.getExecutor()); + RpcHandlerDispatcher dispatcher = new RpcHandlerDispatcher(sessions, lifecycleManager::dispatch, executor); dispatcher.registerHandlers(rpc); // Verify protocol version @@ -323,7 +341,6 @@ private static boolean isUnsupportedConnectMethod(JsonRpcException ex) { */ public CompletableFuture stop() { var closeFutures = new ArrayList>(); - Executor exec = options.getExecutor(); for (CopilotSession session : new ArrayList<>(sessions.values())) { Runnable closeTask = () -> { @@ -335,9 +352,7 @@ public CompletableFuture stop() { }; CompletableFuture future; try { - future = exec != null - ? CompletableFuture.runAsync(closeTask, exec) - : CompletableFuture.runAsync(closeTask); + future = CompletableFuture.runAsync(closeTask, executor); } catch (RejectedExecutionException e) { LOG.log(Level.WARNING, "Executor rejected session close task; closing inline", e); closeTask.run(); @@ -359,7 +374,12 @@ public CompletableFuture stop() { public CompletableFuture forceStop() { disposed = true; sessions.clear(); - return cleanupConnection(); + // Dispatch the blocking shutdownOwnedExecutor() on a dedicated thread: + // cleanupConnection() is chained off async work running on the owned + // executor, so a plain whenComplete(...) here could land the awaitTermination + // call on one of the very threads it is waiting to drain, forcing the full + // AUTOCLOSEABLE_TIMEOUT_SECONDS timeout followed by shutdownNow(). + return cleanupConnection().whenCompleteAsync((ignored, error) -> shutdownOwnedExecutor(), SHUTDOWN_DISPATCHER); } private CompletableFuture cleanupConnection() { @@ -443,33 +463,57 @@ public CompletableFuture createSession(SessionConfig config) { } return ensureConnected().thenCompose(connection -> { long totalNanos = System.nanoTime(); - // Pre-generate session ID so the session can be registered before the RPC call, - // ensuring no events emitted by the CLI during creation are lost. - String sessionId = config.getSessionId() != null - ? config.getSessionId() - : java.util.UUID.randomUUID().toString(); + // For cloud sessions, let the CLI/server assign the session id + // and register the session lazily once the response arrives. For + // non-cloud sessions we generate the id client-side (when the + // caller didn't supply one) so the session can be registered + // BEFORE the RPC โ€” the CLI may issue session-scoped requests + // (e.g. sessionFs.writeFile for workspace metadata) during + // session.create processing, before it has sent the response. + String callerSessionId = config.getSessionId(); + boolean useServerGeneratedId = config.getCloud() != null + && (callerSessionId == null || callerSessionId.isEmpty()); + String localSessionId = useServerGeneratedId + ? null + : (callerSessionId != null && !callerSessionId.isEmpty() + ? callerSessionId + : java.util.UUID.randomUUID().toString()); + + // Extract transform callbacks from the system message config. Callbacks + // are registered with the session; a wire-safe copy of the system + // message (with transform sections replaced by action="transform") is + // used in the RPC request. + var extracted = SessionRequestBuilder.extractTransformCallbacks(config.getSystemMessage()); - long setupNanos = System.nanoTime(); - var session = new CopilotSession(sessionId, connection.rpc); - if (options.getExecutor() != null) { - session.setExecutor(options.getExecutor()); - } - SessionRequestBuilder.configureSession(session, config); - sessions.put(sessionId, session); - LoggingHelpers.logTiming(LOG, Level.FINE, - "CopilotClient.createSession local setup complete. Elapsed={Elapsed}, SessionId=" + sessionId, - setupNanos); + // Creates the session, wires up handlers, and registers it in the + // sessions map. + java.util.function.Function initializeSession = sid -> { + long setupNanos = System.nanoTime(); + var s = new CopilotSession(sid, connection.rpc); + s.setExecutor(executor); + SessionRequestBuilder.configureSession(s, config); + if (extracted.transformCallbacks() != null) { + s.registerTransformCallbacks(extracted.transformCallbacks()); + } + sessions.put(sid, s); + LoggingHelpers.logTiming(LOG, Level.FINE, + "CopilotClient.createSession local setup complete. Elapsed={Elapsed}, SessionId=" + sid, + setupNanos); + return s; + }; - // Extract transform callbacks from the system message config. - // Callbacks are registered with the session; a wire-safe copy of the - // system message (with transform sections replaced by action="transform") - // is used in the RPC request. - var extracted = SessionRequestBuilder.extractTransformCallbacks(config.getSystemMessage()); - if (extracted.transformCallbacks() != null) { - session.registerTransformCallbacks(extracted.transformCallbacks()); + String[] registeredIdHolder = new String[1]; + CopilotSession[] preRegisteredSessionHolder = new CopilotSession[1]; + + // Pre-register non-cloud sessions BEFORE issuing the RPC so any + // session-scoped requests the CLI emits during session.create + // processing can be routed to the correct handlers. + if (localSessionId != null) { + preRegisteredSessionHolder[0] = initializeSession.apply(localSessionId); + registeredIdHolder[0] = localSessionId; } - var request = SessionRequestBuilder.buildCreateRequest(config, sessionId); + var request = SessionRequestBuilder.buildCreateRequest(config, localSessionId); if (extracted.wireSystemMessage() != config.getSystemMessage()) { request.setSystemMessage(extracted.wireSystemMessage()); } @@ -477,31 +521,41 @@ public CompletableFuture createSession(SessionConfig config) { // Empty mode: validate availableTools and set toolFilterPrecedence if (options.getMode() == CopilotClientMode.EMPTY) { if (config.getAvailableTools() == null) { + if (registeredIdHolder[0] != null) { + sessions.remove(registeredIdHolder[0]); + } throw new IllegalArgumentException( "CopilotClient is in Mode = EMPTY but the session config did not specify " + "availableTools. Empty mode requires every session to explicitly opt into " + "the tools it wants โ€” e.g. setAvailableTools(new ToolSet().addBuiltIn(BuiltInTools.ISOLATED))."); } request.setToolFilterPrecedence("excluded"); + if (request.getMcpOAuthTokenStorage() == null) { + request.setMcpOAuthTokenStorage("in-memory"); + } } long rpcNanos = System.nanoTime(); return connection.rpc.invoke("session.create", request, CreateSessionResponse.class) .thenCompose(response -> { + String returnedId = response.sessionId(); LoggingHelpers.logTiming(LOG, Level.FINE, "CopilotClient.createSession session creation request completed. Elapsed={Elapsed}, SessionId=" - + sessionId, + + (returnedId != null ? returnedId : localSessionId), rpcNanos); + if (returnedId == null || returnedId.isEmpty()) { + throw new RuntimeException("session.create response did not include a sessionId"); + } + if (localSessionId != null && !localSessionId.equals(returnedId)) { + throw new RuntimeException("session.create returned sessionId " + returnedId + + " but the caller requested " + localSessionId); + } + CopilotSession session = preRegisteredSessionHolder[0] != null + ? preRegisteredSessionHolder[0] + : initializeSession.apply(returnedId); + registeredIdHolder[0] = returnedId; session.setWorkspacePath(response.workspacePath()); session.setCapabilities(response.capabilities()); - // If the server returned a different sessionId (e.g. a v2 CLI that ignores - // the client-supplied ID), re-key the sessions map. - String returnedId = response.sessionId(); - if (returnedId != null && !returnedId.equals(sessionId)) { - sessions.remove(sessionId); - session.setActiveSessionId(returnedId); - sessions.put(returnedId, session); - } return updateSessionOptionsForMode(session, config.getSkipCustomInstructions().orElse(null), config.getCustomAgentsLocalOnly().orElse(null), @@ -509,19 +563,17 @@ public CompletableFuture createSession(SessionConfig config) { config.getManageScheduleEnabled().orElse(null)).thenApply(v -> { LoggingHelpers.logTiming(LOG, Level.FINE, "CopilotClient.createSession complete. Elapsed={Elapsed}, SessionId=" - + sessionId, + + session.getSessionId(), totalNanos); return session; }); }).exceptionally(ex -> { - sessions.remove(sessionId); - // Also remove the re-keyed entry if the server returned a different ID - String activeId = session.getSessionId(); - if (!sessionId.equals(activeId)) { - sessions.remove(activeId); + if (registeredIdHolder[0] != null) { + sessions.remove(registeredIdHolder[0]); } LoggingHelpers.logTiming(LOG, Level.WARNING, ex, - "CopilotClient.createSession failed. Elapsed={Elapsed}, SessionId=" + sessionId, + "CopilotClient.createSession failed. Elapsed={Elapsed}, SessionId=" + + (registeredIdHolder[0] != null ? registeredIdHolder[0] : ""), totalNanos); throw ex instanceof RuntimeException re ? re : new RuntimeException(ex); }); @@ -565,9 +617,7 @@ public CompletableFuture resumeSession(String sessionId, ResumeS // Register the session before the RPC call to avoid missing early events. long setupNanos = System.nanoTime(); var session = new CopilotSession(sessionId, connection.rpc); - if (options.getExecutor() != null) { - session.setExecutor(options.getExecutor()); - } + session.setExecutor(executor); SessionRequestBuilder.configureSession(session, config); sessions.put(sessionId, session); LoggingHelpers.logTiming(LOG, Level.FINE, @@ -595,6 +645,9 @@ public CompletableFuture resumeSession(String sessionId, ResumeS + "the tools it wants โ€” e.g. setAvailableTools(new ToolSet().addBuiltIn(BuiltInTools.ISOLATED))."); } request.setToolFilterPrecedence("excluded"); + if (request.getMcpOAuthTokenStorage() == null) { + request.setMcpOAuthTokenStorage("in-memory"); + } } long rpcNanos = System.nanoTime(); @@ -1111,6 +1164,44 @@ public void close() { stop().get(AUTOCLOSEABLE_TIMEOUT_SECONDS, TimeUnit.SECONDS); } catch (Exception e) { LOG.log(Level.FINE, "Error during close", e); + } finally { + shutdownOwnedExecutor(); + } + } + + private void shutdownOwnedExecutor() { + if (!executorCanBeShutdown) { + return; + } + + ExecutorService serviceToShutdown = executor instanceof ExecutorService es ? es : null; + if (serviceToShutdown == null) { + LOG.log(Level.FINE, "Executor is not an ExecutorService; skipping shutdown"); + return; + } + + // Short-circuit when the owned executor is already shut down. close() and + // forceStop() can each call this method (e.g. forceStop() invoked before a + // subsequent close() in user code), and re-entering shutdown() + + // awaitTermination() + // is redundant. Logging at FINE aids diagnostics without spamming normal + // output. + if (serviceToShutdown.isShutdown()) { + LOG.log(Level.FINE, "Owned executor was already shut down; skipping redundant shutdown call."); + return; + } + + serviceToShutdown.shutdown(); + try { + if (!serviceToShutdown.awaitTermination(AUTOCLOSEABLE_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + LOG.log(Level.FINE, "Owned executor did not terminate within {0} seconds; forcing shutdown.", + AUTOCLOSEABLE_TIMEOUT_SECONDS); + serviceToShutdown.shutdownNow(); + } + } catch (InterruptedException e) { + serviceToShutdown.shutdownNow(); + Thread.currentThread().interrupt(); + LOG.log(Level.FINE, "Interrupted while waiting for owned executor to terminate", e); } } diff --git a/src/main/java/com/github/copilot/CopilotSession.java b/src/main/java/com/github/copilot/CopilotSession.java index 2651edb0b7..64c02d8b8f 100644 --- a/src/main/java/com/github/copilot/CopilotSession.java +++ b/src/main/java/com/github/copilot/CopilotSession.java @@ -478,6 +478,7 @@ public CompletableFuture send(MessageOptions options) { request.setMode(options.getMode()); request.setAgentMode(options.getAgentMode()); request.setRequestHeaders(options.getRequestHeaders()); + request.setDisplayPrompt(options.getDisplayPrompt()); return rpc.invoke("session.send", request, SendMessageResponse.class).thenApply(SendMessageResponse::messageId); } @@ -1728,6 +1729,35 @@ public CompletableFuture setModel(String model, String reasoningEffort) { */ public CompletableFuture setModel(String model, String reasoningEffort, com.github.copilot.rpc.ModelCapabilitiesOverride modelCapabilities) { + return setModel(model, reasoningEffort, null, modelCapabilities); + } + + /** + * Changes the model for this session with optional reasoning effort, reasoning + * summary mode, and capability overrides. + *

+ * The new model takes effect for the next message. Conversation history is + * preserved. + * + * @param model + * the model ID to switch to (e.g., {@code "gpt-4.1"}) + * @param reasoningEffort + * reasoning effort level; {@code null} to use default + * @param reasoningSummary + * reasoning summary mode ({@code "none"}, {@code "concise"}, or + * {@code "detailed"}); {@code null} to use default. Use + * {@code "none"} to suppress summary output regardless of whether + * reasoning is enabled. + * @param modelCapabilities + * per-property overrides for model capabilities; {@code null} to use + * runtime defaults + * @return a future that completes when the model switch is acknowledged + * @throws IllegalStateException + * if this session has been terminated + * @since 1.3.0 + */ + public CompletableFuture setModel(String model, String reasoningEffort, String reasoningSummary, + com.github.copilot.rpc.ModelCapabilitiesOverride modelCapabilities) { ensureNotTerminated(); ModelCapabilitiesOverride generatedCapabilities = null; if (modelCapabilities != null) { @@ -1744,10 +1774,11 @@ public CompletableFuture setModel(String model, String reasoningEffort, } generatedCapabilities = new ModelCapabilitiesOverride(supports, limits); } - return getRpc().model - .switchTo( - new SessionModelSwitchToParams(sessionId, model, reasoningEffort, null, generatedCapabilities)) - .thenApply(r -> null); + var generatedReasoningSummary = reasoningSummary == null + ? null + : com.github.copilot.generated.rpc.ReasoningSummary.fromValue(reasoningSummary); + return getRpc().model.switchTo(new SessionModelSwitchToParams(sessionId, model, reasoningEffort, + generatedReasoningSummary, generatedCapabilities)).thenApply(r -> null); } /** diff --git a/src/main/java/com/github/copilot/InternalExecutorProvider.java b/src/main/java/com/github/copilot/InternalExecutorProvider.java new file mode 100644 index 0000000000..284965513a --- /dev/null +++ b/src/main/java/com/github/copilot/InternalExecutorProvider.java @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import java.util.concurrent.Executor; +import java.util.concurrent.ForkJoinPool; + +/** + * Resolves the {@link Executor} used by {@link CopilotClient} for internal + * asynchronous work. + * + *

+ * This is the baseline (JDK 17+) implementation. When no + * user-provided executor is supplied, it falls back to + * {@link ForkJoinPool#commonPool()}, which is shared with the rest of the JVM + * and therefore never owned by the SDK. + * + *

+ * Multi-release JAR contract. This class has a sibling variant + * at {@code src/main/java25/com/github/copilot/InternalExecutorProvider.java} + * that is compiled with {@code --release 25} into {@code META-INF/versions/25/} + * and selected automatically by the JVM on JDK 25+. Any change to the + * package-private surface of this class + * ({@link #InternalExecutorProvider(Executor) constructor}, {@link #get()}, + * {@link #canBeShutdown()}) must be mirrored in both source + * trees. The two implementations must remain behaviourally + * interchangeable from the caller's perspective; only the default-executor + * strategy and ownership semantics differ. + * + * @implNote Maintainers: when editing this file, also edit + * {@code src/main/java25/com/github/copilot/InternalExecutorProvider.java}. + * The packaged JAR is verified at build time (see the + * {@code java25-multi-release} profile in {@code pom.xml}) to ensure + * the JDK 25 overlay is present. + */ +final class InternalExecutorProvider { + + private final Executor executor; + + InternalExecutorProvider(Executor userProvided) { + if (userProvided != null) { + this.executor = userProvided; + } else { + this.executor = ForkJoinPool.commonPool(); + } + } + + Executor get() { + return executor; + } + + boolean canBeShutdown() { + // Since we are using ForkJoinPool.commonPool() or user provided only, + // we should not attempt to shut it down + return false; + } + +} diff --git a/src/main/java/com/github/copilot/SessionRequestBuilder.java b/src/main/java/com/github/copilot/SessionRequestBuilder.java index d9ad69282d..52f4e21797 100644 --- a/src/main/java/com/github/copilot/SessionRequestBuilder.java +++ b/src/main/java/com/github/copilot/SessionRequestBuilder.java @@ -106,6 +106,7 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess request.setModel(config.getModel()); request.setClientName(config.getClientName()); request.setReasoningEffort(config.getReasoningEffort()); + request.setReasoningSummary(config.getReasoningSummary()); request.setTools(config.getTools()); request.setSystemMessage(config.getSystemMessage()); request.setAvailableTools(config.getAvailableTools()); @@ -124,14 +125,17 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess } config.getIncludeSubAgentStreamingEvents().ifPresent(request::setIncludeSubAgentStreamingEvents); request.setMcpServers(config.getMcpServers()); + request.setMcpOAuthTokenStorage(config.getMcpOAuthTokenStorage()); request.setCustomAgents(config.getCustomAgents()); request.setDefaultAgent(config.getDefaultAgent()); request.setAgent(config.getAgent()); request.setInfiniteSessions(config.getInfiniteSessions()); request.setSkillDirectories(config.getSkillDirectories()); request.setInstructionDirectories(config.getInstructionDirectories()); + request.setPluginDirectories(config.getPluginDirectories()); + request.setLargeOutput(config.getLargeOutput()); request.setDisabledSkills(config.getDisabledSkills()); - request.setConfigDir(config.getConfigDir()); + request.setConfigDirectory(config.getConfigDirectory()); config.getEnableConfigDiscovery().ifPresent(request::setEnableConfigDiscovery); request.setModelCapabilities(config.getModelCapabilities()); @@ -144,6 +148,9 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess if (config.getOnElicitationRequest() != null) { request.setRequestElicitation(true); } + if (config.isEnableMcpApps()) { + request.setRequestMcpApps(true); + } if (config.getOnExitPlanMode() != null) { request.setRequestExitPlanMode(true); } @@ -197,6 +204,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo request.setModel(config.getModel()); request.setClientName(config.getClientName()); request.setReasoningEffort(config.getReasoningEffort()); + request.setReasoningSummary(config.getReasoningSummary()); request.setTools(config.getTools()); request.setSystemMessage(config.getSystemMessage()); request.setAvailableTools(config.getAvailableTools()); @@ -210,7 +218,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo request.setHooks(true); } request.setWorkingDirectory(config.getWorkingDirectory()); - request.setConfigDir(config.getConfigDir()); + request.setConfigDirectory(config.getConfigDirectory()); config.getEnableConfigDiscovery().ifPresent(request::setEnableConfigDiscovery); if (config.isDisableResume()) { request.setDisableResume(true); @@ -220,11 +228,14 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo } config.getIncludeSubAgentStreamingEvents().ifPresent(request::setIncludeSubAgentStreamingEvents); request.setMcpServers(config.getMcpServers()); + request.setMcpOAuthTokenStorage(config.getMcpOAuthTokenStorage()); request.setCustomAgents(config.getCustomAgents()); request.setDefaultAgent(config.getDefaultAgent()); request.setAgent(config.getAgent()); request.setSkillDirectories(config.getSkillDirectories()); request.setInstructionDirectories(config.getInstructionDirectories()); + request.setPluginDirectories(config.getPluginDirectories()); + request.setLargeOutput(config.getLargeOutput()); request.setDisabledSkills(config.getDisabledSkills()); request.setInfiniteSessions(config.getInfiniteSessions()); request.setModelCapabilities(config.getModelCapabilities()); @@ -238,6 +249,9 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo if (config.getOnElicitationRequest() != null) { request.setRequestElicitation(true); } + if (config.isEnableMcpApps()) { + request.setRequestMcpApps(true); + } if (config.getOnExitPlanMode() != null) { request.setRequestExitPlanMode(true); } diff --git a/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java b/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java index 69464aa724..9414670597 100644 --- a/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java +++ b/src/main/java/com/github/copilot/rpc/CopilotClientOptions.java @@ -288,9 +288,11 @@ public CopilotClientOptions setEnvironment(Map environment) { /** * Gets the executor used for internal asynchronous operations. + *

+ * Returns {@code null} if no executor has been explicitly set, indicating that + * the SDK should use its default executor strategy. * - * @return the executor, or {@code null} to use the default - * {@code ForkJoinPool.commonPool()} + * @return the executor, or {@code null} if using SDK defaults */ public Executor getExecutor() { return executor; @@ -300,15 +302,18 @@ public Executor getExecutor() { * 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. + * {@code CompletableFuture} combinators. This allows callers to isolate SDK + * work onto a dedicated thread pool or integrate with container-managed + * threading. *

- * Passing {@code null} reverts to the default {@code ForkJoinPool.commonPool()} - * behavior. + * The SDK will not shut down a user-provided executor. If you pass a custom + * {@code ExecutorService}, you remain responsible for shutting it down. + *

+ * If not set (or set to {@code null}), the SDK uses its default executor: + * virtual threads on JDK 25+, {@code ForkJoinPool.commonPool()} on older JDKs. * * @param executor - * the executor to use, or {@code null} for the default + * the executor to use, or {@code null} for SDK defaults * @return this options instance for fluent chaining */ public CopilotClientOptions setExecutor(Executor executor) { diff --git a/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java b/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java index 1354e8c336..b8dddbec77 100644 --- a/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -36,6 +36,9 @@ public final class CreateSessionRequest { @JsonProperty("reasoningEffort") private String reasoningEffort; + @JsonProperty("reasoningSummary") + private String reasoningSummary; + @JsonProperty("tools") private List tools; @@ -78,6 +81,9 @@ public final class CreateSessionRequest { @JsonProperty("mcpServers") private Map mcpServers; + @JsonProperty("mcpOAuthTokenStorage") + private String mcpOAuthTokenStorage; + @JsonProperty("envValueMode") private String envValueMode; @@ -99,11 +105,17 @@ public final class CreateSessionRequest { @JsonProperty("instructionDirectories") private List instructionDirectories; + @JsonProperty("pluginDirectories") + private List pluginDirectories; + + @JsonProperty("largeOutput") + private LargeToolOutputConfig largeOutput; + @JsonProperty("disabledSkills") private List disabledSkills; @JsonProperty("configDir") - private String configDir; + private String configDirectory; @JsonProperty("enableConfigDiscovery") private Boolean enableConfigDiscovery; @@ -114,6 +126,9 @@ public final class CreateSessionRequest { @JsonProperty("requestElicitation") private Boolean requestElicitation; + @JsonProperty("requestMcpApps") + private Boolean requestMcpApps; + @JsonProperty("requestExitPlanMode") private Boolean requestExitPlanMode; @@ -174,6 +189,19 @@ public void setReasoningEffort(String reasoningEffort) { this.reasoningEffort = reasoningEffort; } + /** Gets the reasoning summary mode. @return the reasoning summary mode */ + public String getReasoningSummary() { + return reasoningSummary; + } + + /** + * Sets the reasoning summary mode. @param reasoningSummary the reasoning + * summary mode + */ + public void setReasoningSummary(String reasoningSummary) { + this.reasoningSummary = reasoningSummary; + } + /** Gets the tools. @return the tool definitions */ public List getTools() { return tools == null ? null : Collections.unmodifiableList(tools); @@ -344,6 +372,19 @@ public void setMcpServers(Map mcpServers) { this.mcpServers = mcpServers; } + /** Gets MCP OAuth token storage mode. @return the storage mode */ + public String getMcpOAuthTokenStorage() { + return mcpOAuthTokenStorage; + } + + /** + * Sets MCP OAuth token storage mode. @param mcpOAuthTokenStorage the storage + * mode + */ + public void setMcpOAuthTokenStorage(String mcpOAuthTokenStorage) { + this.mcpOAuthTokenStorage = mcpOAuthTokenStorage; + } + /** Gets MCP environment variable value mode. @return the mode */ public String getEnvValueMode() { return envValueMode; @@ -418,6 +459,26 @@ public void setInstructionDirectories(List instructionDirectories) { this.instructionDirectories = instructionDirectories; } + /** Gets plugin directories. @return the plugin directories */ + public List getPluginDirectories() { + return pluginDirectories == null ? null : Collections.unmodifiableList(pluginDirectories); + } + + /** Sets plugin directories. @param pluginDirectories the directories */ + public void setPluginDirectories(List pluginDirectories) { + this.pluginDirectories = pluginDirectories; + } + + /** Gets large output config. @return the large output config */ + public LargeToolOutputConfig getLargeOutput() { + return largeOutput; + } + + /** Sets large output config. @param largeOutput the large output config */ + public void setLargeOutput(LargeToolOutputConfig largeOutput) { + this.largeOutput = largeOutput; + } + /** Gets disabled skills. @return the disabled skill names */ public List getDisabledSkills() { return disabledSkills == null ? null : Collections.unmodifiableList(disabledSkills); @@ -429,13 +490,13 @@ public void setDisabledSkills(List disabledSkills) { } /** Gets config directory. @return the config directory path */ - public String getConfigDir() { - return configDir; + public String getConfigDirectory() { + return configDirectory; } - /** Sets config directory. @param configDir the config directory path */ - public void setConfigDir(String configDir) { - this.configDir = configDir; + /** Sets config directory. @param configDirectory the config directory path */ + public void setConfigDirectory(String configDirectory) { + this.configDirectory = configDirectory; } /** Gets enable config discovery flag. @return the flag */ @@ -503,6 +564,21 @@ public void clearRequestElicitation() { this.requestElicitation = null; } + /** Gets the requestMcpApps flag. @return the flag */ + public Boolean getRequestMcpApps() { + return requestMcpApps; + } + + /** Sets the requestMcpApps flag. @param requestMcpApps the flag */ + public void setRequestMcpApps(boolean requestMcpApps) { + this.requestMcpApps = requestMcpApps; + } + + /** Clears the requestMcpApps setting, reverting to the default behavior. */ + public void clearRequestMcpApps() { + this.requestMcpApps = null; + } + /** Gets the requestExitPlanMode flag. @return the flag */ public Boolean getRequestExitPlanMode() { return requestExitPlanMode; diff --git a/src/main/java/com/github/copilot/rpc/LargeToolOutputConfig.java b/src/main/java/com/github/copilot/rpc/LargeToolOutputConfig.java new file mode 100644 index 0000000000..1694761e2b --- /dev/null +++ b/src/main/java/com/github/copilot/rpc/LargeToolOutputConfig.java @@ -0,0 +1,91 @@ +package com.github.copilot.rpc; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Configuration for large tool output handling. + *

+ * When a tool produces output exceeding {@link #getMaxSizeBytes()}, the SDK + * writes the full output to a file in {@link #getOutputDirectory()} and returns + * a truncated preview to the model. + * + * @since 1.3.0 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class LargeToolOutputConfig { + + @JsonProperty("enabled") + private Boolean enabled; + + @JsonProperty("maxSizeBytes") + private Long maxSizeBytes; + + @JsonProperty("outputDir") + private String outputDirectory; + + /** + * Gets whether large tool output handling is enabled. + * + * @return {@code true} if enabled, {@code false} if disabled, {@code null} for + * default + */ + public Boolean getEnabled() { + return enabled; + } + + /** + * Sets whether large tool output handling is enabled. Defaults to {@code true} + * when unset. + * + * @param enabled + * {@code true} to enable, {@code false} to disable + * @return this config for method chaining + */ + public LargeToolOutputConfig setEnabled(Boolean enabled) { + this.enabled = enabled; + return this; + } + + /** + * Gets the maximum tool output size in bytes before it is redirected to a file. + * + * @return the maximum size in bytes, or {@code null} for default + */ + public Long getMaxSizeBytes() { + return maxSizeBytes; + } + + /** + * Sets the maximum tool output size in bytes before it is redirected to a file. + * + * @param maxSizeBytes + * the maximum size in bytes + * @return this config for method chaining + */ + public LargeToolOutputConfig setMaxSizeBytes(Long maxSizeBytes) { + this.maxSizeBytes = maxSizeBytes; + return this; + } + + /** + * Gets the directory where large tool output files are written. + * + * @return the output directory path, or {@code null} for default + */ + public String getOutputDirectory() { + return outputDirectory; + } + + /** + * Sets the directory where large tool output files are written. + * + * @param outputDirectory + * the output directory path + * @return this config for method chaining + */ + public LargeToolOutputConfig setOutputDirectory(String outputDirectory) { + this.outputDirectory = outputDirectory; + return this; + } +} diff --git a/src/main/java/com/github/copilot/rpc/MessageOptions.java b/src/main/java/com/github/copilot/rpc/MessageOptions.java index a6cd02b0a7..c781011ff8 100644 --- a/src/main/java/com/github/copilot/rpc/MessageOptions.java +++ b/src/main/java/com/github/copilot/rpc/MessageOptions.java @@ -47,6 +47,7 @@ public class MessageOptions { private String mode; private AgentMode agentMode; private Map requestHeaders; + private String displayPrompt; /** * Gets the message prompt. @@ -176,6 +177,30 @@ public MessageOptions setRequestHeaders(Map requestHeaders) { return this; } + /** + * Gets the display prompt shown in the timeline instead of the prompt. + * + * @return the display prompt, or {@code null} if not set + */ + public String getDisplayPrompt() { + return displayPrompt; + } + + /** + * Sets the display prompt shown in the timeline instead of the prompt. + *

+ * If provided, this text is displayed in the conversation timeline UI instead + * of the actual prompt text. + * + * @param displayPrompt + * the display prompt text + * @return this options instance for method chaining + */ + public MessageOptions setDisplayPrompt(String displayPrompt) { + this.displayPrompt = displayPrompt; + return this; + } + /** * Creates a shallow clone of this {@code MessageOptions} instance. *

@@ -194,6 +219,7 @@ public MessageOptions clone() { copy.mode = this.mode; copy.agentMode = this.agentMode; copy.requestHeaders = this.requestHeaders != null ? new HashMap<>(this.requestHeaders) : null; + copy.displayPrompt = this.displayPrompt; return copy; } diff --git a/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java b/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java index fa28258b37..3cdf191826 100644 --- a/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -51,22 +51,26 @@ public class ResumeSessionConfig { private Boolean coauthorEnabled; private Boolean manageScheduleEnabled; private String reasoningEffort; + private String reasoningSummary; private ModelCapabilitiesOverride modelCapabilities; private PermissionHandler onPermissionRequest; private UserInputHandler onUserInputRequest; private SessionHooks hooks; private String workingDirectory; - private String configDir; + private String configDirectory; private Boolean enableConfigDiscovery; private boolean disableResume; private boolean streaming; private Boolean includeSubAgentStreamingEvents; private Map mcpServers; + private String mcpOAuthTokenStorage; private List customAgents; private DefaultAgentConfig defaultAgent; private String agent; private List skillDirectories; private List instructionDirectories; + private List pluginDirectories; + private LargeToolOutputConfig largeOutput; private List disabledSkills; private InfiniteSessionConfig infiniteSessions; private Consumer onEvent; @@ -74,6 +78,7 @@ public class ResumeSessionConfig { private ElicitationHandler onElicitationRequest; private ExitPlanModeHandler onExitPlanMode; private AutoModeSwitchHandler onAutoModeSwitch; + private boolean enableMcpApps; private String gitHubToken; private String remoteSession; @@ -468,6 +473,29 @@ public ResumeSessionConfig setReasoningEffort(String reasoningEffort) { return this; } + /** + * Gets the reasoning summary mode. + * + * @return the reasoning summary mode ("none", "concise", or "detailed") + */ + public String getReasoningSummary() { + return reasoningSummary; + } + + /** + * Sets the reasoning summary mode for models that support configurable + * reasoning summaries. Use {@code "none"} to suppress summary output regardless + * of whether reasoning is enabled. + * + * @param reasoningSummary + * the reasoning summary mode + * @return this config for method chaining + */ + public ResumeSessionConfig setReasoningSummary(String reasoningSummary) { + this.reasoningSummary = reasoningSummary; + return this; + } + /** * Gets the permission request handler. * @@ -560,8 +588,8 @@ public ResumeSessionConfig setWorkingDirectory(String workingDirectory) { * * @return the configuration directory path */ - public String getConfigDir() { - return configDir; + public String getConfigDirectory() { + return configDirectory; } /** @@ -569,12 +597,12 @@ public String getConfigDir() { *

* Override the default configuration directory location. * - * @param configDir + * @param configDirectory * the configuration directory path * @return this config for method chaining */ - public ResumeSessionConfig setConfigDir(String configDir) { - this.configDir = configDir; + public ResumeSessionConfig setConfigDirectory(String configDirectory) { + this.configDirectory = configDirectory; return this; } @@ -740,6 +768,37 @@ public ResumeSessionConfig setMcpServers(Map mcpServers return this; } + /** + * Gets the MCP OAuth token storage mode. + * + * @return the storage mode, or {@code null} if not set + */ + public String getMcpOAuthTokenStorage() { + return mcpOAuthTokenStorage; + } + + /** + * Sets the MCP OAuth token storage mode. + *

+ * Controls how MCP OAuth tokens are stored for this session: + *

    + *
  • {@code "persistent"} โ€” tokens are stored in the OS keychain (shared + * across sessions)
  • + *
  • {@code "in-memory"} โ€” tokens are stored in memory and discarded when the + * session ends
  • + *
+ * If not set, the SDK defaults to {@code "in-memory"} for safe multitenant + * behavior. + * + * @param mcpOAuthTokenStorage + * the storage mode + * @return this config for method chaining + */ + public ResumeSessionConfig setMcpOAuthTokenStorage(String mcpOAuthTokenStorage) { + this.mcpOAuthTokenStorage = mcpOAuthTokenStorage; + return this; + } + /** * Gets the custom agent configurations. * @@ -852,6 +911,48 @@ public ResumeSessionConfig setInstructionDirectories(List instructionDir return this; } + /** + * Gets the plugin directories to load Open Plugin definitions from. + * + * @return the list of plugin directory paths + */ + public List getPluginDirectories() { + return pluginDirectories == null ? null : Collections.unmodifiableList(pluginDirectories); + } + + /** + * Sets the plugin directories to load Open Plugin definitions from. + * + * @param pluginDirectories + * the list of plugin directory paths + * @return this config for method chaining + */ + public ResumeSessionConfig setPluginDirectories(List pluginDirectories) { + this.pluginDirectories = pluginDirectories; + return this; + } + + /** + * Gets the configuration for large tool output handling. + * + * @return the large output config, or {@code null} for default + */ + public LargeToolOutputConfig getLargeOutput() { + return largeOutput; + } + + /** + * Sets the configuration for large tool output handling. + * + * @param largeOutput + * the large output config + * @return this config for method chaining + */ + public ResumeSessionConfig setLargeOutput(LargeToolOutputConfig largeOutput) { + this.largeOutput = largeOutput; + return this; + } + /** * Gets the disabled skills. * @@ -972,6 +1073,31 @@ public ResumeSessionConfig setOnElicitationRequest(ElicitationHandler onElicitat return this; } + /** + * Returns whether MCP Apps (SEP-1865) UI passthrough is enabled on resume. + * + * @return {@code true} if the consumer has opted into MCP Apps, otherwise + * {@code false} + * @see #setEnableMcpApps(boolean) + */ + public boolean isEnableMcpApps() { + return enableMcpApps; + } + + /** + * Enables MCP Apps (SEP-1865) UI passthrough on the resumed session. See + * {@link SessionConfig#setEnableMcpApps(boolean)} for full semantics (runtime + * gate, capability inspection, renderer requirement). + * + * @param enableMcpApps + * {@code true} to opt into MCP Apps support on resume + * @return this config for method chaining + */ + public ResumeSessionConfig setEnableMcpApps(boolean enableMcpApps) { + this.enableMcpApps = enableMcpApps; + return this; + } + /** * Gets the exit-plan-mode request handler. * @@ -1104,12 +1230,13 @@ public ResumeSessionConfig clone() { copy.provider = this.provider; copy.enableSessionTelemetry = this.enableSessionTelemetry; copy.reasoningEffort = this.reasoningEffort; + copy.reasoningSummary = this.reasoningSummary; copy.modelCapabilities = this.modelCapabilities; copy.onPermissionRequest = this.onPermissionRequest; copy.onUserInputRequest = this.onUserInputRequest; copy.hooks = this.hooks; copy.workingDirectory = this.workingDirectory; - copy.configDir = this.configDir; + copy.configDirectory = this.configDirectory; copy.enableConfigDiscovery = this.enableConfigDiscovery; copy.disableResume = this.disableResume; copy.streaming = this.streaming; @@ -1122,6 +1249,8 @@ public ResumeSessionConfig clone() { copy.instructionDirectories = this.instructionDirectories != null ? new ArrayList<>(this.instructionDirectories) : null; + copy.pluginDirectories = this.pluginDirectories != null ? new ArrayList<>(this.pluginDirectories) : null; + copy.largeOutput = this.largeOutput; copy.disabledSkills = this.disabledSkills != null ? new ArrayList<>(this.disabledSkills) : null; copy.infiniteSessions = this.infiniteSessions; copy.onEvent = this.onEvent; @@ -1129,6 +1258,7 @@ public ResumeSessionConfig clone() { copy.onElicitationRequest = this.onElicitationRequest; copy.onExitPlanMode = this.onExitPlanMode; copy.onAutoModeSwitch = this.onAutoModeSwitch; + copy.enableMcpApps = this.enableMcpApps; copy.gitHubToken = this.gitHubToken; copy.remoteSession = this.remoteSession; return copy; diff --git a/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java b/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java index 4321a24aa3..5305639edc 100644 --- a/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -38,6 +38,9 @@ public final class ResumeSessionRequest { @JsonProperty("reasoningEffort") private String reasoningEffort; + @JsonProperty("reasoningSummary") + private String reasoningSummary; + @JsonProperty("tools") private List tools; @@ -72,7 +75,7 @@ public final class ResumeSessionRequest { private String workingDirectory; @JsonProperty("configDir") - private String configDir; + private String configDirectory; @JsonProperty("enableConfigDiscovery") private Boolean enableConfigDiscovery; @@ -89,6 +92,9 @@ public final class ResumeSessionRequest { @JsonProperty("mcpServers") private Map mcpServers; + @JsonProperty("mcpOAuthTokenStorage") + private String mcpOAuthTokenStorage; + @JsonProperty("envValueMode") private String envValueMode; @@ -107,6 +113,12 @@ public final class ResumeSessionRequest { @JsonProperty("instructionDirectories") private List instructionDirectories; + @JsonProperty("pluginDirectories") + private List pluginDirectories; + + @JsonProperty("largeOutput") + private LargeToolOutputConfig largeOutput; + @JsonProperty("disabledSkills") private List disabledSkills; @@ -119,6 +131,9 @@ public final class ResumeSessionRequest { @JsonProperty("requestElicitation") private Boolean requestElicitation; + @JsonProperty("requestMcpApps") + private Boolean requestMcpApps; + @JsonProperty("requestExitPlanMode") private Boolean requestExitPlanMode; @@ -176,6 +191,19 @@ public void setReasoningEffort(String reasoningEffort) { this.reasoningEffort = reasoningEffort; } + /** Gets the reasoning summary mode. @return the reasoning summary mode */ + public String getReasoningSummary() { + return reasoningSummary; + } + + /** + * Sets the reasoning summary mode. @param reasoningSummary the reasoning + * summary mode + */ + public void setReasoningSummary(String reasoningSummary) { + this.reasoningSummary = reasoningSummary; + } + /** Gets the tools. @return the tool definitions */ public List getTools() { return tools == null ? null : Collections.unmodifiableList(tools); @@ -323,13 +351,13 @@ public void setWorkingDirectory(String workingDirectory) { } /** Gets config directory. @return the config directory */ - public String getConfigDir() { - return configDir; + public String getConfigDirectory() { + return configDirectory; } - /** Sets config directory. @param configDir the config directory */ - public void setConfigDir(String configDir) { - this.configDir = configDir; + /** Sets config directory. @param configDirectory the config directory */ + public void setConfigDirectory(String configDirectory) { + this.configDirectory = configDirectory; } /** Gets enable config discovery flag. @return the flag */ @@ -414,6 +442,19 @@ public void setMcpServers(Map mcpServers) { this.mcpServers = mcpServers; } + /** Gets MCP OAuth token storage mode. @return the storage mode */ + public String getMcpOAuthTokenStorage() { + return mcpOAuthTokenStorage; + } + + /** + * Sets MCP OAuth token storage mode. @param mcpOAuthTokenStorage the storage + * mode + */ + public void setMcpOAuthTokenStorage(String mcpOAuthTokenStorage) { + this.mcpOAuthTokenStorage = mcpOAuthTokenStorage; + } + /** Gets MCP environment variable value mode. @return the mode */ public String getEnvValueMode() { return envValueMode; @@ -478,6 +519,26 @@ public void setInstructionDirectories(List instructionDirectories) { this.instructionDirectories = instructionDirectories; } + /** Gets plugin directories. @return the plugin directories */ + public List getPluginDirectories() { + return pluginDirectories == null ? null : Collections.unmodifiableList(pluginDirectories); + } + + /** Sets plugin directories. @param pluginDirectories the directories */ + public void setPluginDirectories(List pluginDirectories) { + this.pluginDirectories = pluginDirectories; + } + + /** Gets large output config. @return the large output config */ + public LargeToolOutputConfig getLargeOutput() { + return largeOutput; + } + + /** Sets large output config. @param largeOutput the large output config */ + public void setLargeOutput(LargeToolOutputConfig largeOutput) { + this.largeOutput = largeOutput; + } + /** Gets disabled skills. @return the disabled skill names */ public List getDisabledSkills() { return disabledSkills == null ? null : Collections.unmodifiableList(disabledSkills); @@ -528,6 +589,21 @@ public void clearRequestElicitation() { this.requestElicitation = null; } + /** Gets the requestMcpApps flag. @return the flag */ + public Boolean getRequestMcpApps() { + return requestMcpApps; + } + + /** Sets the requestMcpApps flag. @param requestMcpApps the flag */ + public void setRequestMcpApps(boolean requestMcpApps) { + this.requestMcpApps = requestMcpApps; + } + + /** Clears the requestMcpApps setting, reverting to the default behavior. */ + public void clearRequestMcpApps() { + this.requestMcpApps = null; + } + /** Gets the requestExitPlanMode flag. @return the flag */ public Boolean getRequestExitPlanMode() { return requestExitPlanMode; diff --git a/src/main/java/com/github/copilot/rpc/SendMessageRequest.java b/src/main/java/com/github/copilot/rpc/SendMessageRequest.java index b73d2caa22..c87dda7623 100644 --- a/src/main/java/com/github/copilot/rpc/SendMessageRequest.java +++ b/src/main/java/com/github/copilot/rpc/SendMessageRequest.java @@ -43,6 +43,9 @@ public final class SendMessageRequest { @JsonProperty("requestHeaders") private Map requestHeaders; + @JsonProperty("displayPrompt") + private String displayPrompt; + /** Gets the session ID. @return the session ID */ public String getSessionId() { return sessionId; @@ -102,4 +105,19 @@ public Map getRequestHeaders() { public void setRequestHeaders(Map requestHeaders) { this.requestHeaders = requestHeaders; } + + /** Gets the display prompt. @return the display prompt */ + public String getDisplayPrompt() { + return displayPrompt; + } + + /** + * Sets the display prompt shown in the timeline instead of the prompt. + * + * @param displayPrompt + * the display prompt + */ + public void setDisplayPrompt(String displayPrompt) { + this.displayPrompt = displayPrompt; + } } diff --git a/src/main/java/com/github/copilot/rpc/SessionConfig.java b/src/main/java/com/github/copilot/rpc/SessionConfig.java index 2a42df6108..c845306879 100644 --- a/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -42,6 +42,7 @@ public class SessionConfig { private String clientName; private String model; private String reasoningEffort; + private String reasoningSummary; private List tools; private SystemMessageConfig systemMessage; private List availableTools; @@ -59,14 +60,17 @@ public class SessionConfig { private boolean streaming; private Boolean includeSubAgentStreamingEvents; private Map mcpServers; + private String mcpOAuthTokenStorage; private List customAgents; private DefaultAgentConfig defaultAgent; private String agent; private InfiniteSessionConfig infiniteSessions; private List skillDirectories; private List instructionDirectories; + private List pluginDirectories; + private LargeToolOutputConfig largeOutput; private List disabledSkills; - private String configDir; + private String configDirectory; private Boolean enableConfigDiscovery; private ModelCapabilitiesOverride modelCapabilities; private Consumer onEvent; @@ -74,6 +78,7 @@ public class SessionConfig { private ElicitationHandler onElicitationRequest; private ExitPlanModeHandler onExitPlanMode; private AutoModeSwitchHandler onAutoModeSwitch; + private boolean enableMcpApps; private String gitHubToken; private String remoteSession; private CloudSessionOptions cloud; @@ -171,6 +176,29 @@ public SessionConfig setReasoningEffort(String reasoningEffort) { return this; } + /** + * Gets the reasoning summary mode. + * + * @return the reasoning summary mode ("none", "concise", or "detailed") + */ + public String getReasoningSummary() { + return reasoningSummary; + } + + /** + * Sets the reasoning summary mode for models that support configurable + * reasoning summaries. Use {@code "none"} to suppress summary output regardless + * of whether reasoning is enabled. + * + * @param reasoningSummary + * the reasoning summary mode + * @return this config instance for method chaining + */ + public SessionConfig setReasoningSummary(String reasoningSummary) { + this.reasoningSummary = reasoningSummary; + return this; + } + /** * Gets the custom tools for this session. * @@ -650,6 +678,37 @@ public SessionConfig setMcpServers(Map mcpServers) { return this; } + /** + * Gets the MCP OAuth token storage mode. + * + * @return the storage mode, or {@code null} if not set + */ + public String getMcpOAuthTokenStorage() { + return mcpOAuthTokenStorage; + } + + /** + * Sets the MCP OAuth token storage mode. + *

+ * Controls how MCP OAuth tokens are stored for this session: + *

    + *
  • {@code "persistent"} โ€” tokens are stored in the OS keychain (shared + * across sessions)
  • + *
  • {@code "in-memory"} โ€” tokens are stored in memory and discarded when the + * session ends
  • + *
+ * If not set, the SDK defaults to {@code "in-memory"} for safe multitenant + * behavior. + * + * @param mcpOAuthTokenStorage + * the storage mode + * @return this config instance for method chaining + */ + public SessionConfig setMcpOAuthTokenStorage(String mcpOAuthTokenStorage) { + this.mcpOAuthTokenStorage = mcpOAuthTokenStorage; + return this; + } + /** * Gets the custom agent configurations. * @@ -796,6 +855,48 @@ public SessionConfig setInstructionDirectories(List instructionDirectori return this; } + /** + * Gets the plugin directories to load Open Plugin definitions from. + * + * @return the list of plugin directory paths + */ + public List getPluginDirectories() { + return pluginDirectories == null ? null : Collections.unmodifiableList(pluginDirectories); + } + + /** + * Sets the plugin directories to load Open Plugin definitions from. + * + * @param pluginDirectories + * the list of plugin directory paths + * @return this config instance for method chaining + */ + public SessionConfig setPluginDirectories(List pluginDirectories) { + this.pluginDirectories = pluginDirectories; + return this; + } + + /** + * Gets the configuration for large tool output handling. + * + * @return the large output config, or {@code null} for default + */ + public LargeToolOutputConfig getLargeOutput() { + return largeOutput; + } + + /** + * Sets the configuration for large tool output handling. + * + * @param largeOutput + * the large output config + * @return this config instance for method chaining + */ + public SessionConfig setLargeOutput(LargeToolOutputConfig largeOutput) { + this.largeOutput = largeOutput; + return this; + } + /** * Gets the disabled skill names. * @@ -825,8 +926,8 @@ public SessionConfig setDisabledSkills(List disabledSkills) { * * @return the config directory path */ - public String getConfigDir() { - return configDir; + public String getConfigDirectory() { + return configDirectory; } /** @@ -835,12 +936,12 @@ public String getConfigDir() { * This allows using a specific directory for session configuration instead of * the default location. * - * @param configDir + * @param configDirectory * the configuration directory path * @return this config instance for method chaining */ - public SessionConfig setConfigDir(String configDir) { - this.configDir = configDir; + public SessionConfig setConfigDirectory(String configDirectory) { + this.configDirectory = configDirectory; return this; } @@ -1033,6 +1134,49 @@ public SessionConfig setOnElicitationRequest(ElicitationHandler onElicitationReq return this; } + /** + * Returns whether MCP Apps (SEP-1865) UI passthrough is enabled on this + * session. + * + * @return {@code true} if the consumer has opted into MCP Apps, otherwise + * {@code false} + * @see #setEnableMcpApps(boolean) + */ + public boolean isEnableMcpApps() { + return enableMcpApps; + } + + /** + * Enables MCP Apps (SEP-1865) UI passthrough on this session. + *

+ * When {@code true} and the runtime has MCP Apps enabled (via the + * {@code MCP_APPS} feature flag or {@code COPILOT_MCP_APPS=true} environment + * override), the runtime adds the {@code mcp-apps} capability to the session, + * which causes it to advertise the + * {@code extensions.io.modelcontextprotocol/ui} extension to MCP servers (so + * they expose {@code _meta.ui.resourceUri} on tools) and to expose the + * {@code session.rpc.mcp.apps.{listTools,callTool,readResource, + * setHostContext,getHostContext,diagnose}} JSON-RPC methods. + *

+ * If the runtime gate is off, the opt-in is silently dropped server-side (the + * runtime logs a warning); the session is created normally but the MCP Apps + * surface is unavailable. Inspect {@link SessionUiCapabilities#getMcpApps()} on + * {@link com.github.copilot.CopilotSession#getCapabilities()} to detect this. + *

+ * SDK consumers MUST set this to {@code true} only when they have an iframe + * renderer that can display {@code ui://} MCP App bundles. Setting it without a + * renderer will cause MCP servers to register UI-enabled tool variants the + * consumer cannot display. + * + * @param enableMcpApps + * {@code true} to opt into MCP Apps support + * @return this config instance for method chaining + */ + public SessionConfig setEnableMcpApps(boolean enableMcpApps) { + this.enableMcpApps = enableMcpApps; + return this; + } + /** * Gets the exit-plan-mode request handler. * @@ -1200,6 +1344,7 @@ public SessionConfig clone() { copy.clientName = this.clientName; copy.model = this.model; copy.reasoningEffort = this.reasoningEffort; + copy.reasoningSummary = this.reasoningSummary; copy.tools = this.tools != null ? new ArrayList<>(this.tools) : null; copy.systemMessage = this.systemMessage; copy.availableTools = this.availableTools != null ? new ArrayList<>(this.availableTools) : null; @@ -1225,8 +1370,10 @@ public SessionConfig clone() { copy.instructionDirectories = this.instructionDirectories != null ? new ArrayList<>(this.instructionDirectories) : null; + copy.pluginDirectories = this.pluginDirectories != null ? new ArrayList<>(this.pluginDirectories) : null; + copy.largeOutput = this.largeOutput; copy.disabledSkills = this.disabledSkills != null ? new ArrayList<>(this.disabledSkills) : null; - copy.configDir = this.configDir; + copy.configDirectory = this.configDirectory; copy.enableConfigDiscovery = this.enableConfigDiscovery; copy.modelCapabilities = this.modelCapabilities; copy.onEvent = this.onEvent; @@ -1234,6 +1381,7 @@ public SessionConfig clone() { copy.onElicitationRequest = this.onElicitationRequest; copy.onExitPlanMode = this.onExitPlanMode; copy.onAutoModeSwitch = this.onAutoModeSwitch; + copy.enableMcpApps = this.enableMcpApps; copy.gitHubToken = this.gitHubToken; copy.remoteSession = this.remoteSession; copy.cloud = this.cloud; diff --git a/src/main/java/com/github/copilot/rpc/SessionUiCapabilities.java b/src/main/java/com/github/copilot/rpc/SessionUiCapabilities.java index 015220d0c1..1d3397c8f4 100644 --- a/src/main/java/com/github/copilot/rpc/SessionUiCapabilities.java +++ b/src/main/java/com/github/copilot/rpc/SessionUiCapabilities.java @@ -21,6 +21,9 @@ public class SessionUiCapabilities { @JsonProperty("elicitation") private Boolean elicitation; + @JsonProperty("mcpApps") + private Boolean mcpApps; + /** * Returns whether the host supports interactive elicitation dialogs. * @@ -53,4 +56,41 @@ public SessionUiCapabilities clearElicitation() { return this; } + /** + * Returns whether the runtime has accepted the session's MCP Apps (SEP-1865) + * opt-in. Present and {@code true} when the consumer set + * {@code enableMcpApps=true} on create/resume and the runtime's + * {@code MCP_APPS} feature flag (or {@code COPILOT_MCP_APPS=true} env override) + * is on. Otherwise empty or {@code false}, indicating the runtime silently + * dropped the opt-in. + * + * @return an {@link Optional} containing the boolean value, or empty if not set + */ + @JsonIgnore + public Optional getMcpApps() { + return Optional.ofNullable(mcpApps); + } + + /** + * Sets whether the runtime has accepted the MCP Apps opt-in. + * + * @param mcpApps + * {@code true} if MCP Apps is enabled for this session + * @return this instance for method chaining + */ + public SessionUiCapabilities setMcpApps(boolean mcpApps) { + this.mcpApps = mcpApps; + return this; + } + + /** + * Clears the mcpApps setting. + * + * @return this instance for method chaining + */ + public SessionUiCapabilities clearMcpApps() { + this.mcpApps = null; + return this; + } + } diff --git a/src/main/java25/com/github/copilot/InternalExecutorProvider.java b/src/main/java25/com/github/copilot/InternalExecutorProvider.java new file mode 100644 index 0000000000..10878bb0c4 --- /dev/null +++ b/src/main/java25/com/github/copilot/InternalExecutorProvider.java @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.ForkJoinPool; + +/** + * Resolves the {@link Executor} used by {@link CopilotClient} for internal + * asynchronous work. + * + *

This is the JDK 25+ multi-release variant. It is + * compiled with {@code --release 25} into + * {@code META-INF/versions/25/com/github/copilot/InternalExecutorProvider.class} + * inside the packaged JAR and is automatically loaded in preference to the + * baseline class when the JVM runtime feature version is 25 or greater. + * When no user-provided executor is supplied, it creates an SDK-owned + * {@link Executors#newVirtualThreadPerTaskExecutor() virtual-thread executor} + * that is shut down by {@link CopilotClient#close()}. + * + *

Multi-release JAR contract. This class is the + * JDK 25 sibling of the baseline implementation at + * {@code src/main/java/com/github/copilot/InternalExecutorProvider.java}. + * The package-private surface of both classes + * ({@link #InternalExecutorProvider(Executor) constructor}, + * {@link #get()}, {@link #canBeShutdown()}) must be kept in + * lock-step; only the default-executor strategy and ownership + * semantics differ. + * + * @implNote + * Maintainers: when editing this file, also edit + * {@code src/main/java/com/github/copilot/InternalExecutorProvider.java}. + * The packaged JAR is verified at build time (see the + * {@code java25-multi-release} profile in {@code pom.xml}) to ensure this + * overlay class is present. + */ +final class InternalExecutorProvider { + + private final Executor executor; + private final boolean owned; + + InternalExecutorProvider(Executor userProvided) { + if (userProvided != null) { + this.executor = userProvided; + this.owned = false; + } else { + this.executor = Executors.newVirtualThreadPerTaskExecutor(); + this.owned = true; + } + } + + Executor get() { + return executor; + } + + boolean canBeShutdown() { + // We can only shut down the executor if we created it (i.e., if it's owned) + // such as when using Executors.newVirtualThreadPerTaskExecutor(), + // which creates an executor that we are responsible for shutting down. + return owned; + } +} diff --git a/src/test/java/com/github/copilot/ConfigCloneTest.java b/src/test/java/com/github/copilot/ConfigCloneTest.java index f26f67ed91..e40a3048b0 100644 --- a/src/test/java/com/github/copilot/ConfigCloneTest.java +++ b/src/test/java/com/github/copilot/ConfigCloneTest.java @@ -21,6 +21,7 @@ import com.github.copilot.rpc.DefaultAgentConfig; import com.github.copilot.rpc.ExitPlanModeResult; import com.github.copilot.rpc.InfiniteSessionConfig; +import com.github.copilot.rpc.LargeToolOutputConfig; import com.github.copilot.rpc.MessageOptions; import com.github.copilot.rpc.ModelInfo; import com.github.copilot.rpc.ResumeSessionConfig; @@ -114,6 +115,10 @@ void sessionConfigCloneBasic() { original.setSessionId("my-session"); original.setClientName("my-app"); original.setModel("gpt-4o"); + original.setReasoningSummary("detailed"); + original.setPluginDirectories(List.of("/plugins/a", "/plugins/b")); + original.setLargeOutput( + new LargeToolOutputConfig().setEnabled(true).setMaxSizeBytes(1024L).setOutputDirectory("/tmp/out")); original.setStreaming(true); SessionConfig cloned = original.clone(); @@ -121,6 +126,9 @@ void sessionConfigCloneBasic() { assertEquals(original.getSessionId(), cloned.getSessionId()); assertEquals(original.getClientName(), cloned.getClientName()); assertEquals(original.getModel(), cloned.getModel()); + assertEquals(original.getReasoningSummary(), cloned.getReasoningSummary()); + assertEquals(original.getPluginDirectories(), cloned.getPluginDirectories()); + assertEquals(original.getLargeOutput(), cloned.getLargeOutput()); assertEquals(original.isStreaming(), cloned.isStreaming()); } @@ -162,11 +170,18 @@ void sessionConfigAgentAndOnEventCloned() { void resumeSessionConfigCloneBasic() { ResumeSessionConfig original = new ResumeSessionConfig(); original.setModel("o1"); + original.setReasoningSummary("none"); + original.setPluginDirectories(List.of("/plugins/r")); + original.setLargeOutput( + new LargeToolOutputConfig().setEnabled(false).setMaxSizeBytes(2048L).setOutputDirectory("/tmp/resume")); original.setStreaming(false); ResumeSessionConfig cloned = original.clone(); assertEquals(original.getModel(), cloned.getModel()); + assertEquals(original.getReasoningSummary(), cloned.getReasoningSummary()); + assertEquals(original.getPluginDirectories(), cloned.getPluginDirectories()); + assertEquals(original.getLargeOutput(), cloned.getLargeOutput()); assertEquals(original.isStreaming(), cloned.isStreaming()); } @@ -328,8 +343,8 @@ void resumeSessionConfigAllSetters() { config.setWorkingDirectory("/project/src"); assertEquals("/project/src", config.getWorkingDirectory()); - config.setConfigDir("/home/user/.config/copilot"); - assertEquals("/home/user/.config/copilot", config.getConfigDir()); + config.setConfigDirectory("/home/user/.config/copilot"); + assertEquals("/home/user/.config/copilot", config.getConfigDirectory()); config.setSkillDirectories(List.of("/skills/custom")); assertEquals(List.of("/skills/custom"), config.getSkillDirectories()); diff --git a/src/test/java/com/github/copilot/CopilotSessionTest.java b/src/test/java/com/github/copilot/CopilotSessionTest.java index 9c74d49464..44a7373ec7 100644 --- a/src/test/java/com/github/copilot/CopilotSessionTest.java +++ b/src/test/java/com/github/copilot/CopilotSessionTest.java @@ -559,7 +559,7 @@ void testShouldCreateSessionWithCustomConfigDir() throws Exception { String customConfigDir = ctx.getWorkDir().resolve("custom-config").toString(); SessionConfig config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) - .setConfigDir(customConfigDir); + .setConfigDirectory(customConfigDir); CopilotSession session = client.createSession(config).get(); assertNotNull(session.getSessionId()); diff --git a/src/test/java/com/github/copilot/CreateSessionReKeyEntryTest.java b/src/test/java/com/github/copilot/CreateSessionReKeyEntryTest.java index 94f4edbdf1..156c968489 100644 --- a/src/test/java/com/github/copilot/CreateSessionReKeyEntryTest.java +++ b/src/test/java/com/github/copilot/CreateSessionReKeyEntryTest.java @@ -25,8 +25,10 @@ import com.github.copilot.rpc.SessionConfig; /** - * Tests for the session-map re-key cleanup paths in CopilotClient when the - * server returns a different session ID than the client-supplied one. + * Tests that CopilotClient rejects session.create responses whose sessionId + * differs from the client-supplied one. Re-keying the sessions map at runtime + * is intentionally not supported โ€” the server must honor the client-supplied + * sessionId (or generate one when none is supplied). */ class CreateSessionReKeyEntryTest { @@ -177,7 +179,7 @@ private static void injectConnection(CopilotClient client, JsonRpcClient rpc) th } @Test - void createSessionReKeyEntry_successfulReKey_removesOldKeyAndAddsNewKey() throws Exception { + void createSession_serverReturnsDifferentSessionId_throwsAndRemovesPreRegisteredEntry() throws Exception { String clientSessionId = "client-supplied-id"; String serverSessionId = "server-returned-id"; @@ -188,26 +190,26 @@ void createSessionReKeyEntry_successfulReKey_removesOldKeyAndAddsNewKey() throws var config = new SessionConfig().setSessionId(clientSessionId) .setOnPermissionRequest(PermissionHandler.APPROVE_ALL); - CopilotSession session = client.createSession(config).get(); + ExecutionException ex = assertThrows(ExecutionException.class, () -> client.createSession(config).get()); + assertNotNull(ex.getCause()); + assertTrue(ex.getCause().getMessage().contains(serverSessionId), + "Error message should mention the server-returned sessionId"); + assertTrue(ex.getCause().getMessage().contains(clientSessionId), + "Error message should mention the client-requested sessionId"); Map sessions = getSessionsMap(client); - - // The old client-supplied key should be removed assertNull(sessions.get(clientSessionId), - "Old client-supplied sessionId should be removed from sessions map after re-key"); - // The new server-returned key should be present - assertSame(session, sessions.get(serverSessionId), - "Server-returned sessionId should be the key in sessions map"); - // The session object should report the server-returned ID - assertEquals(serverSessionId, session.getSessionId(), - "Session should report the server-returned sessionId"); + "Pre-registered client-supplied sessionId should be removed after rejection"); + assertNull(sessions.get(serverSessionId), + "Server-returned sessionId should never be registered after rejection"); + assertTrue(sessions.isEmpty(), "Sessions map should be empty after rejected create"); client.close(); } } @Test - void createSessionReKeyEntry_failureAfterReKey_removesBothKeys() throws Exception { + void createSession_serverReturnsDifferentSessionIdWithSkipCustomInstructions_throwsAndCleansUp() throws Exception { String clientSessionId = "client-supplied-id"; String serverSessionId = "server-returned-id"; @@ -215,29 +217,27 @@ void createSessionReKeyEntry_failureAfterReKey_removesBothKeys() throws Exceptio var client = new CopilotClient(new CopilotClientOptions().setAutoStart(false)); injectConnection(client, server.rpcClient); - // Set skipCustomInstructions so that session.options.update is actually invoked + // Even when skipCustomInstructions would trigger session.options.update, + // the sessionId-mismatch error fires first and short-circuits the flow. var config = new SessionConfig().setSessionId(clientSessionId) .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setSkipCustomInstructions(true); - // The session.options.update will fail, triggering the exceptionally handler ExecutionException ex = assertThrows(ExecutionException.class, () -> client.createSession(config).get()); assertNotNull(ex.getCause()); Map sessions = getSessionsMap(client); - - // Both the original and re-keyed entries should be cleaned up assertNull(sessions.get(clientSessionId), - "Original client-supplied sessionId should be removed on failure"); + "Pre-registered client-supplied sessionId should be removed on failure"); assertNull(sessions.get(serverSessionId), - "Re-keyed server-returned sessionId should be removed on failure"); - assertTrue(sessions.isEmpty(), "Sessions map should be empty after failed create with re-key"); + "Server-returned sessionId should never be registered on failure"); + assertTrue(sessions.isEmpty(), "Sessions map should be empty after failed create"); client.close(); } } @Test - void createSessionReKeyEntry_noReKey_sameIdKept() throws Exception { + void createSession_serverReturnsSameSessionId_sessionKeptUnderClientId() throws Exception { String sessionId = "same-id-for-both"; try (var server = new ReKeyServer(sessionId, false)) { diff --git a/src/test/java/com/github/copilot/InternalExecutorProviderIT.java b/src/test/java/com/github/copilot/InternalExecutorProviderIT.java new file mode 100644 index 0000000000..1cc6b482e8 --- /dev/null +++ b/src/test/java/com/github/copilot/InternalExecutorProviderIT.java @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +/** + * Failsafe integration test that asserts the multi-release behaviour of + * {@link InternalExecutorProvider} against the actually packaged JAR. + *

+ * Runs after {@code package}, when {@code target/${finalName}.jar} exists with + * its real {@code Multi-Release: true} manifest and (on JDK 25+ builds) the + * {@code META-INF/versions/25/} override produced by {@code maven-jar-plugin}. + *

+ * The test spawns a child JVM with the packaged JAR plus {@code test-classes} + * on the classpath, runs {@link InternalExecutorProviderProbe}, and asserts + * that the executor selected for the current runtime matches expectations. + */ +class InternalExecutorProviderIT { + + @Test + void packagedJarSelectsExecutorPerRuntimeVersion() throws Exception { + Path packagedJar = locatePackagedJar(); + Path testClasses = locateTestClassesDir(); + String javaBin = locateJavaBinary(); + + String classpath = packagedJar.toString() + File.pathSeparator + testClasses.toString(); + Process process = new ProcessBuilder(javaBin, "-cp", classpath, + "com.github.copilot.InternalExecutorProviderProbe").redirectErrorStream(true).start(); + + String output; + try { + output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(process.waitFor(30, TimeUnit.SECONDS), "Probe JVM did not exit within 30s. Output:\n" + output); + } finally { + if (process.isAlive()) { + process.destroyForcibly(); + } + } + + assertEquals(0, process.exitValue(), "Probe exited non-zero. Output:\n" + output); + + Map kv = parseKeyValues(output); + String featureRaw = kv.get("feature"); + assertNotNull(featureRaw, "Probe did not report 'feature'. Output:\n" + output); + int feature = Integer.parseInt(featureRaw); + + boolean expectOwnedVirtual = feature >= 25; + assertEquals(String.valueOf(expectOwnedVirtual), kv.get("canBeShutdown"), + "canBeShutdown mismatch for JDK feature=" + feature + ". Output:\n" + output); + assertEquals(String.valueOf(expectOwnedVirtual), kv.get("virtual"), + "virtual mismatch for JDK feature=" + feature + ". Output:\n" + output); + } + + private static Path locatePackagedJar() { + String buildDir = System.getProperty("project.build.directory"); + String finalName = System.getProperty("project.build.finalName"); + assertNotNull(buildDir, "System property 'project.build.directory' must be set by failsafe"); + assertNotNull(finalName, "System property 'project.build.finalName' must be set by failsafe"); + Path jar = Path.of(buildDir, finalName + ".jar"); + assertTrue(Files.isRegularFile(jar), "Packaged JAR must exist: " + jar); + return jar; + } + + private static Path locateTestClassesDir() { + String testOutput = System.getProperty("project.build.testOutputDirectory"); + assertNotNull(testOutput, "System property 'project.build.testOutputDirectory' must be set by failsafe"); + Path dir = Path.of(testOutput); + assertTrue(Files.isDirectory(dir), "test-classes dir must exist: " + dir); + return dir; + } + + private static String locateJavaBinary() { + Path javaHome = Path.of(System.getProperty("java.home")); + Path candidate = javaHome.resolve("bin").resolve(isWindows() ? "java.exe" : "java"); + assertTrue(Files.isExecutable(candidate), "java binary must be executable: " + candidate); + return candidate.toString(); + } + + private static boolean isWindows() { + return System.getProperty("os.name", "").toLowerCase().contains("win"); + } + + private static Map parseKeyValues(String output) { + Map map = new HashMap<>(); + for (String line : output.split("\\R")) { + int eq = line.indexOf('='); + if (eq > 0) { + map.put(line.substring(0, eq).trim(), line.substring(eq + 1).trim()); + } + } + return map; + } +} diff --git a/src/test/java/com/github/copilot/InternalExecutorProviderProbe.java b/src/test/java/com/github/copilot/InternalExecutorProviderProbe.java new file mode 100644 index 0000000000..85d12f14f1 --- /dev/null +++ b/src/test/java/com/github/copilot/InternalExecutorProviderProbe.java @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import java.lang.reflect.Method; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Diagnostic main launched as a separate JVM by + * {@code InternalExecutorProviderIT} to inspect the multi-release behaviour of + * {@link InternalExecutorProvider} against the actually packaged JAR. + *

+ * Lives in the same package as {@link InternalExecutorProvider} so it can use + * its package-private API directly, without reflection. + *

+ * Output format (key=value, one per line): + * + *

+ *   feature=<JDK feature version>
+ *   canBeShutdown=<true|false>
+ *   virtual=<true|false>
+ * 
+ */ +final class InternalExecutorProviderProbe { + + private InternalExecutorProviderProbe() { + } + + public static void main(String[] args) throws Exception { + InternalExecutorProvider provider = new InternalExecutorProvider(null); + Executor executor = provider.get(); + boolean canBeShutdown = provider.canBeShutdown(); + + AtomicBoolean virtual = new AtomicBoolean(); + CountDownLatch latch = new CountDownLatch(1); + executor.execute(() -> { + try { + virtual.set(isCurrentThreadVirtual()); + } finally { + latch.countDown(); + } + }); + + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + System.out.println("error=task-timeout"); + System.exit(2); + } + } finally { + if (executor instanceof ExecutorService es) { + es.shutdownNow(); + } + } + + System.out.println("feature=" + Runtime.version().feature()); + System.out.println("canBeShutdown=" + canBeShutdown); + System.out.println("virtual=" + virtual.get()); + } + + private static boolean isCurrentThreadVirtual() { + try { + Method isVirtual = Thread.class.getMethod("isVirtual"); + return (Boolean) isVirtual.invoke(Thread.currentThread()); + } catch (ReflectiveOperationException e) { + return false; + } + } +} diff --git a/src/test/java/com/github/copilot/InternalExecutorProviderTest.java b/src/test/java/com/github/copilot/InternalExecutorProviderTest.java new file mode 100644 index 0000000000..f1d854cb53 --- /dev/null +++ b/src/test/java/com/github/copilot/InternalExecutorProviderTest.java @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import java.lang.reflect.Modifier; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ForkJoinPool; + +import org.junit.jupiter.api.Test; + +import com.github.copilot.rpc.CopilotClientOptions; + +class InternalExecutorProviderTest { + + @Test + void baseProviderReturnsCommonPool() { + Executor executor = new InternalExecutorProvider(null).get(); + + assertSame(ForkJoinPool.commonPool(), executor); + } + + @Test + void userProvidedExecutorIsNotOwned() { + Executor executor = ForkJoinPool.commonPool(); + + assertFalse(new InternalExecutorProvider(executor).canBeShutdown()); + } + + @Test + void providerIsPackagePrivate() { + assertFalse(Modifier.isPublic(InternalExecutorProvider.class.getModifiers())); + } + + @Test + void clientDoesNotShutDownUserProvidedExecutor() { + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + try (var client = new CopilotClient(new CopilotClientOptions().setAutoStart(false).setExecutor(executor))) { + assertNotNull(client); + } + + assertFalse(executor.isShutdown()); + } finally { + executor.shutdownNow(); + } + } +} diff --git a/src/test/java/com/github/copilot/SessionRequestBuilderTest.java b/src/test/java/com/github/copilot/SessionRequestBuilderTest.java index 49a4c9c308..9fca584d1b 100644 --- a/src/test/java/com/github/copilot/SessionRequestBuilderTest.java +++ b/src/test/java/com/github/copilot/SessionRequestBuilderTest.java @@ -21,6 +21,7 @@ import com.github.copilot.rpc.ElicitationResult; import com.github.copilot.rpc.ElicitationResultAction; import com.github.copilot.rpc.ExitPlanModeResult; +import com.github.copilot.rpc.LargeToolOutputConfig; import com.github.copilot.rpc.ResumeSessionConfig; import com.github.copilot.rpc.ResumeSessionRequest; import com.github.copilot.rpc.SessionConfig; @@ -90,6 +91,23 @@ void testBuildCreateRequestSetsClientName() { assertEquals("my-app", request.getClientName()); } + @Test + void testBuildCreateRequestSetsReasoningSummary() { + var config = new SessionConfig().setReasoningSummary("concise"); + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config); + assertEquals("concise", request.getReasoningSummary()); + } + + @Test + void testBuildCreateRequestSetsPluginDirectoriesAndLargeOutput() { + var largeOutput = new LargeToolOutputConfig().setEnabled(true).setMaxSizeBytes(1024L) + .setOutputDirectory("/tmp/out"); + var config = new SessionConfig().setPluginDirectories(List.of("/plugins/a")).setLargeOutput(largeOutput); + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config); + assertEquals(List.of("/plugins/a"), request.getPluginDirectories()); + assertEquals(largeOutput, request.getLargeOutput()); + } + @Test void testBuildCreateRequestForwardsEnableSessionTelemetryWhenFalse() { var config = new SessionConfig().setEnableSessionTelemetry(false); @@ -104,6 +122,26 @@ void testBuildCreateRequestOmitsEnableSessionTelemetryWhenNotSet() { assertNull(request.getEnableSessionTelemetry()); } + @Test + void testBuildCreateRequestPassesThroughNullMcpOAuthTokenStorage() { + var config = new SessionConfig(); + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config); + assertNull(request.getMcpOAuthTokenStorage()); + } + + @Test + void testBuildCreateRequestForwardsExplicitMcpOAuthTokenStorage() { + var config = new SessionConfig().setMcpOAuthTokenStorage("persistent"); + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config); + assertEquals("persistent", request.getMcpOAuthTokenStorage()); + } + + @Test + void testBuildCreateRequestNullConfigHasNullMcpOAuthTokenStorage() { + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(null); + assertNull(request.getMcpOAuthTokenStorage()); + } + // ========================================================================= // buildResumeRequest // ========================================================================= @@ -212,6 +250,43 @@ void testBuildResumeRequestSetsClientName() { assertEquals("my-app", request.getClientName()); } + @Test + void testBuildResumeRequestPassesThroughNullMcpOAuthTokenStorage() { + var config = new ResumeSessionConfig(); + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-11", config); + assertNull(request.getMcpOAuthTokenStorage()); + } + + @Test + void testBuildResumeRequestForwardsExplicitMcpOAuthTokenStorage() { + var config = new ResumeSessionConfig().setMcpOAuthTokenStorage("persistent"); + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-12", config); + assertEquals("persistent", request.getMcpOAuthTokenStorage()); + } + + @Test + void testBuildResumeRequestNullConfigHasNullMcpOAuthTokenStorage() { + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-13", null); + assertNull(request.getMcpOAuthTokenStorage()); + } + + @Test + void testBuildResumeRequestSetsReasoningSummary() { + var config = new ResumeSessionConfig().setReasoningSummary("none"); + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-14", config); + assertEquals("none", request.getReasoningSummary()); + } + + @Test + void testBuildResumeRequestSetsPluginDirectoriesAndLargeOutput() { + var largeOutput = new LargeToolOutputConfig().setEnabled(false).setMaxSizeBytes(2048L) + .setOutputDirectory("/tmp/resume"); + var config = new ResumeSessionConfig().setPluginDirectories(List.of("/plugins/r")).setLargeOutput(largeOutput); + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-15", config); + assertEquals(List.of("/plugins/r"), request.getPluginDirectories()); + assertEquals(largeOutput, request.getLargeOutput()); + } + // ========================================================================= // configureSession (ResumeSessionConfig overload) // ========================================================================= From da3f8c7e77a0307e366207fc033dd2298ec7915b Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Thu, 28 May 2026 15:57:44 -0700 Subject: [PATCH 2/3] Apply https://github.com/github/copilot-sdk-java/pull/237#discussion_r3321064387 --- .../java/com/github/copilot/rpc/LargeToolOutputConfig.java | 5 +++++ .../java/com/github/copilot/rpc/ResumeSessionConfig.java | 6 ++++-- src/main/java/com/github/copilot/rpc/SessionConfig.java | 6 ++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/github/copilot/rpc/LargeToolOutputConfig.java b/src/main/java/com/github/copilot/rpc/LargeToolOutputConfig.java index 1694761e2b..cd1e6b5252 100644 --- a/src/main/java/com/github/copilot/rpc/LargeToolOutputConfig.java +++ b/src/main/java/com/github/copilot/rpc/LargeToolOutputConfig.java @@ -1,3 +1,8 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + package com.github.copilot.rpc; import com.fasterxml.jackson.annotation.JsonInclude; diff --git a/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java b/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java index 3cdf191826..d254d73eb4 100644 --- a/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -787,8 +787,10 @@ public String getMcpOAuthTokenStorage() { *
  • {@code "in-memory"} โ€” tokens are stored in memory and discarded when the * session ends
  • * - * If not set, the SDK defaults to {@code "in-memory"} for safe multitenant - * behavior. + * If not set and the client is in + * {@link com.github.copilot.CopilotClientMode#EMPTY EMPTY} mode, the SDK + * defaults to {@code "in-memory"} for safe multitenant behavior. In other modes + * this field is left unset. * * @param mcpOAuthTokenStorage * the storage mode diff --git a/src/main/java/com/github/copilot/rpc/SessionConfig.java b/src/main/java/com/github/copilot/rpc/SessionConfig.java index c845306879..ead3801b87 100644 --- a/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -697,8 +697,10 @@ public String getMcpOAuthTokenStorage() { *
  • {@code "in-memory"} โ€” tokens are stored in memory and discarded when the * session ends
  • * - * If not set, the SDK defaults to {@code "in-memory"} for safe multitenant - * behavior. + * If not set and the client is in + * {@link com.github.copilot.CopilotClientMode#EMPTY EMPTY} mode, the SDK + * defaults to {@code "in-memory"} for safe multitenant behavior. In other modes + * this field is left unset. * * @param mcpOAuthTokenStorage * the storage mode From 1f46ecf2cfe1196310866ae58a19455c8b05a6fa Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Thu, 28 May 2026 16:02:04 -0700 Subject: [PATCH 3/3] Apply https://github.com/github/copilot-sdk-java/pull/237#discussion_r3321064405 --- .github/workflows/build-test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index dbc7406993..455e5a8201 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -176,4 +176,5 @@ jobs: path: | target/surefire-reports/ target/surefire-reports-isolated/ + target/failsafe-reports/ retention-days: 7