From 08c25064e402b914165c3ac5bd36fb879fa55568 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 5 Feb 2026 22:14:43 +0000 Subject: [PATCH 001/109] [maven-release-plugin] prepare for next development iteration --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 85826a9e3..27514de59 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ io.github.copilot-community-sdk copilot-sdk - 1.0.7 + 1.0.8-SNAPSHOT jar GitHub Copilot Community SDK :: Java @@ -33,7 +33,7 @@ scm:git:https://github.com/copilot-community-sdk/copilot-sdk-java.git scm:git:https://github.com/copilot-community-sdk/copilot-sdk-java.git https://github.com/copilot-community-sdk/copilot-sdk-java - v1.0.7 + HEAD From a78ceedfee333538439ffc471b195a7883c39ab7 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 14:25:32 -0800 Subject: [PATCH 002/109] docs: update GitHub Copilot CLI version requirement to 0.0.404 --- src/site/markdown/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index fcbf78a0b..8fe2d2efe 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -9,7 +9,7 @@ Welcome to the documentation for the **Copilot SDK for Java** β€” a Java SDK for ### Requirements - Java 17 or later -- GitHub Copilot CLI 0.0.401 or later installed and in PATH (or provide custom `cliPath`) +- GitHub Copilot CLI 0.0.404 or later installed and in PATH (or provide custom `cliPath`) ### Installation From 8d2b80812b49602ca4f7469a1682b95bdecb45e2 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 14:25:39 -0800 Subject: [PATCH 003/109] docs: synchronize Copilot CLI version requirement in instructions --- .github/copilot-instructions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c3765c4d3..8bae9ff7e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -169,3 +169,4 @@ Test method names are converted to lowercase snake_case for snapshot filenames t - Site docs in `src/site/markdown/` (filtered for `${project.version}` substitution) - Update `src/site/site.xml` when adding new documentation pages - Javadoc required for public APIs except `json` and `events` packages (self-documenting DTOs) +- **Copilot CLI Version**: When updating the required Copilot CLI version in `README.md`, also update it in `src/site/markdown/index.md` to keep them in sync From 146ac097f79bfc7a125cb766736cce8cb48635a3 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 14:33:13 -0800 Subject: [PATCH 004/109] docs: add detailed changelog for version 1.0.7 --- .github/release.yml | 2 + CHANGELOG.md | 109 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 CHANGELOG.md diff --git a/.github/release.yml b/.github/release.yml index 0146a4a5a..1203ddbec 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -2,6 +2,8 @@ # https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes changelog: + header: | + πŸ“‹ **Full Changelog**: See [CHANGELOG.md](https://github.com/copilot-community-sdk/copilot-sdk-java/blob/main/CHANGELOG.md) for detailed release notes. exclude: labels: - ignore-for-release diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..f9a3a488c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,109 @@ +# Changelog + +All notable changes to the Copilot SDK for Java will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.7] - 2026-02-05 + +### Added + +#### Session Lifecycle Hooks +Extended the hooks system with three new hook types for session lifecycle control: +- **`onSessionStart`** - Called when a session starts (new or resumed) +- **`onSessionEnd`** - Called when a session ends +- **`onUserPromptSubmitted`** - Called when the user submits a prompt + +New types: +- `SessionStartHandler`, `SessionStartHookInput`, `SessionStartHookOutput` +- `SessionEndHandler`, `SessionEndHookInput`, `SessionEndHookOutput` +- `UserPromptSubmittedHandler`, `UserPromptSubmittedHookInput`, `UserPromptSubmittedHookOutput` + +#### Session Lifecycle Events (Client-Level) +Added client-level lifecycle event subscriptions: +- `client.onLifecycle(handler)` - Subscribe to all session lifecycle events +- `client.onLifecycle(eventType, handler)` - Subscribe to specific event types +- `SessionLifecycleEventTypes.CREATED`, `DELETED`, `UPDATED`, `FOREGROUND`, `BACKGROUND` + +New types: `SessionLifecycleEvent`, `SessionLifecycleEventMetadata`, `SessionLifecycleHandler` + +#### Foreground Session Control (TUI+Server Mode) +For servers running with `--ui-server`: +- `client.getForegroundSessionId()` - Get the session displayed in TUI +- `client.setForegroundSessionId(sessionId)` - Switch TUI display to a session + +New types: `GetForegroundSessionResponse`, `SetForegroundSessionResponse` + +#### New Event Types +- **`SessionShutdownEvent`** - Emitted when session is shutting down, includes reason and exit code +- **`SkillInvokedEvent`** - Emitted when a skill is invoked, includes skill name and context + +#### Extended Event Data +- `AssistantMessageEvent.Data` - Added `id`, `isLastReply`, `thinkingContent` fields +- `AssistantUsageEvent.Data` - Added `outputReasoningTokens` field +- `SessionCompactionCompleteEvent.Data` - Added `success`, `messagesRemoved`, `tokensRemoved` fields +- `SessionErrorEvent.Data` - Extended with additional error context + +#### Documentation +- New **[hooks.md](src/site/markdown/hooks.md)** - Comprehensive guide covering all 5 session hooks with examples for security gates, logging, result enrichment, and lifecycle management +- Expanded **[documentation.md](src/site/markdown/documentation.md)** with all 33 event types, `getMessages()`, `abort()`, and custom timeout examples +- Enhanced **[advanced.md](src/site/markdown/advanced.md)** with session hooks, lifecycle events, and foreground session control +- Added **[.github/copilot-instructions.md](.github/copilot-instructions.md)** for AI assistants + +#### Testing +- `SessionEventParserTest` - 850+ lines of unit tests for JSON event deserialization +- `SessionEventsE2ETest` - End-to-end tests for session event lifecycle +- `ErrorHandlingTest` - Tests for error handling scenarios +- Enhanced `E2ETestContext` with snapshot validation and expected prompt logging +- Added logging configuration (`logging.properties`) + +#### Build & CI +- JaCoCo 0.8.14 for test coverage reporting +- Coverage reports generated at `target/site/jacoco-coverage/` +- New test report action at `.github/actions/test-report/` +- JaCoCo coverage summary in workflow summary +- Coverage report artifact upload + +### Changed + +- **Copilot CLI**: Minimum version updated from 0.0.400 to **0.0.404** +- Refactored `ProcessInfo` and `Connection` to use records +- Extended `SessionHooks` to support 5 hook types (was 2) +- Renamed test methods to match snapshot naming conventions with Javadoc + +### Fixed + +- Improved timeout exception handling with detailed logging +- Test infrastructure improvements for proxy resilience + +## [1.0.6] - 2026-01-28 + +### Added + +- Initial public release of the Copilot SDK for Java +- Core classes: `CopilotClient`, `CopilotSession`, `JsonRpcClient` +- Session configuration with `SessionConfig`, `ResumeSessionConfig` +- Custom tools with `ToolDefinition` and `ToolHandler` +- Event system with 30+ event types extending `AbstractSessionEvent` +- Pre-tool and post-tool hooks with `SessionHooks` +- Permission handling with `PermissionHandler` +- User input handling with `UserInputHandler` +- BYOK (Bring Your Own Key) support with `ProviderConfig` +- MCP server integration +- Infinite sessions with `InfiniteSessionConfig` +- Skills configuration with `skillDirectories` and `disabledSkills` +- System message customization with `SystemMessageConfig` +- File attachments support +- Streaming responses with delta events + +### Documentation + +- Getting started guide +- Basic usage documentation +- Advanced usage guide +- MCP servers guide +- Generated Javadoc + +[1.0.7]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.6...v1.0.7 +[1.0.6]: https://github.com/copilot-community-sdk/copilot-sdk-java/releases/tag/v1.0.6 From 280a8b5a4388ecd0d14fffaff03055eba294ba58 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 14:42:24 -0800 Subject: [PATCH 005/109] docs: update changelog for version 1.0.6 with new features, changes, and fixes. Add previous versions too. --- CHANGELOG.md | 167 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 150 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9a3a488c..7b462889b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,33 +77,166 @@ New types: `GetForegroundSessionResponse`, `SetForegroundSessionResponse` - Improved timeout exception handling with detailed logging - Test infrastructure improvements for proxy resilience -## [1.0.6] - 2026-01-28 +## [1.0.6] - 2026-02-02 ### Added -- Initial public release of the Copilot SDK for Java +- Auth options for BYOK configuration (`authType`, `apiKey`, `organizationId`, `endpoint`) +- Reasoning effort configuration (`reasoningEffort` in session config) +- User input handler for freeform user prompts (`UserInputHandler`, `UserInputRequest`, `UserInputResponse`) +- Pre-tool use and post-tool use hooks (`PreToolUseHandler`, `PostToolUseHandler`) +- VSCode launch and debug configurations +- Logging configuration for test debugging + +### Changed + +- Enhanced permission request handling with graceful error recovery +- Updated test harness integration to clone from upstream SDK +- Improved logging for session events and user input requests + +### Fixed + +- Non-null answer enforcement in user input responses for CLI compatibility +- Permission handler error handling improvements + +## [1.0.5] - 2026-01-29 + +### Added + +- Skills configuration: `skillDirectories` and `disabledSkills` in `SessionConfig` +- Skill events handling (`SkillInvokedEvent`) +- Javadoc verification step in build workflow +- Deploy-site job for automatic documentation deployment after releases + +### Changed + +- Merged upstream SDK changes (commit 87ff5510) +- Added agentic-merge-upstream Claude skill for tracking upstream changes + +### Fixed + +- Resume session handling to keep first client alive +- Build workflow updated to use `test-compile` instead of `compile` +- NPM dependency installation in CI workflow +- Enhanced error handling in permission request processing +- Checkstyle and Maven Resources Plugin version updates +- Test harness CLI installation to match upstream version + +## [1.0.4] - 2026-01-27 + +### Added + +- Advanced usage documentation with comprehensive examples +- Getting started guide with Maven and JBang instructions +- Package-info.java files for `com.github.copilot.sdk`, `events`, and `json` packages +- `@since` annotations on all public classes +- Versioned documentation with version selector on GitHub Pages +- Maven resources plugin for site markdown filtering + +### Changed + +- Refactored tool argument handling for improved type safety +- Optimized event listener registration in examples +- Enhanced site navigation with documentation links +- Merged upstream SDK changes from commit f902b76 + +### Fixed + +- BufferedReader replaced with BufferedInputStream for accurate JSON-RPC byte reading +- Timeout thread now uses daemon thread to prevent JVM exit blocking +- XML root element corrected from `` to `` in site.xml +- Badge titles in README for consistency + +## [1.0.3] - 2026-01-26 + +### Added + +- MCP Servers documentation and integration examples +- Infinite sessions documentation section +- Versioned documentation template with version selector +- Guidelines for porting upstream SDK changes to Java +- Configuration for automatically generated release notes + +### Changed + +- Renamed and retitled GitHub Actions workflows for clarity +- Improved gh-pages initialization and remote setup + +### Fixed + +- Documentation navigation to include MCP Servers section +- GitHub Pages deployment workflow to use correct branch +- Enhanced version handling in documentation build steps +- Rollback mechanism added for release failures + +## [1.0.2] - 2026-01-25 + +### Added + +- Infinite sessions support with `InfiniteSessionConfig` and workspace persistence +- GitHub Actions workflow for GitHub Pages deployment +- Daily schedule trigger for SDK E2E tests +- Checkstyle configuration and Maven integration + +### Changed + +- Updated GitHub Actions to latest action versions +- Enhanced Maven site deployment with documentation versioning +- Simplified GitHub release title naming convention + +### Fixed + +- Documentation links in site.xml and README for consistency +- Maven build step to include `clean` for fresh builds +- Image handling in README and site generation + +## [1.0.1] - 2026-01-22 + +### Added + +- Metadata APIs implementation +- Tool execution progress event (`ToolExecutionProgressEvent`) +- SDK protocol version 2 support +- Image in README for visual representation +- Detailed sections in README with usage examples +- Badges for build status, Maven Central, Java version, and license + +### Changed + +- Enhanced version handling in Maven release workflow +- Updated SCM connection URLs to use HTTPS + +### Fixed + +- GitHub release command version formatting and title +- Documentation commit messages to include version information +- JBang dependency declaration with correct group ID + +## [1.0.0] - 2026-01-21 + +### Added + +- Initial release of the Copilot SDK for Java - Core classes: `CopilotClient`, `CopilotSession`, `JsonRpcClient` -- Session configuration with `SessionConfig`, `ResumeSessionConfig` +- Session configuration with `SessionConfig` - Custom tools with `ToolDefinition` and `ToolHandler` - Event system with 30+ event types extending `AbstractSessionEvent` -- Pre-tool and post-tool hooks with `SessionHooks` - Permission handling with `PermissionHandler` -- User input handling with `UserInputHandler` - BYOK (Bring Your Own Key) support with `ProviderConfig` -- MCP server integration -- Infinite sessions with `InfiniteSessionConfig` -- Skills configuration with `skillDirectories` and `disabledSkills` +- MCP server integration via `McpServerConfig` - System message customization with `SystemMessageConfig` - File attachments support - Streaming responses with delta events - -### Documentation - -- Getting started guide -- Basic usage documentation -- Advanced usage guide -- MCP servers guide -- Generated Javadoc +- JBang example for quick testing +- GitHub Actions workflows for testing and Maven Central publishing +- Pre-commit hook for Spotless code formatting +- Comprehensive API documentation [1.0.7]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.6...v1.0.7 -[1.0.6]: https://github.com/copilot-community-sdk/copilot-sdk-java/releases/tag/v1.0.6 +[1.0.6]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.5...v1.0.6 +[1.0.5]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.4...v1.0.5 +[1.0.4]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.3...v1.0.4 +[1.0.3]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.2...v1.0.3 +[1.0.2]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.1...v1.0.2 +[1.0.1]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/1.0.0...v1.0.1 +[1.0.0]: https://github.com/copilot-community-sdk/copilot-sdk-java/releases/tag/1.0.0 From c016247156da705613e308b58a1e050f181fe7a3 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 16:21:09 -0800 Subject: [PATCH 006/109] ci: update snapshot documentation build to include JaCoCo coverage --- .github/workflows/deploy-site.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index ff22c4ba3..d734fee4b 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -114,7 +114,8 @@ jobs: - name: Build snapshot documentation (main branch) if: steps.tags.outputs.is_release == 'false' && inputs.rebuild_all_versions != true run: | - ./mvnw clean site -DskipTests -Dcheckstyle.skip=true + # Run tests with JaCoCo coverage for snapshot builds + ./mvnw clean verify site -Dcheckstyle.skip=true rm -rf "site/snapshot" mkdir -p "site/snapshot" @@ -171,9 +172,9 @@ jobs: done <<< "${{ steps.tags.outputs.all_tags }}" - # Return to main and build snapshot + # Return to main and build snapshot with JaCoCo coverage git checkout main - ./mvnw clean site -DskipTests -Dcheckstyle.skip=true + ./mvnw clean verify site -Dcheckstyle.skip=true rm -rf "site/snapshot" mkdir -p "site/snapshot" From d0b21b56c620a751a4533cffc6273e200e16445a Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 16:29:07 -0800 Subject: [PATCH 007/109] docs: add Javadoc, GitHub, and documentation badges to README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 405b44e1c..2193897ce 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,12 @@ [![Build](https://github.com/copilot-community-sdk/copilot-sdk-java/actions/workflows/build-test.yml/badge.svg)](https://github.com/copilot-community-sdk/copilot-sdk-java/actions/workflows/build-test.yml) [![Site](https://github.com/copilot-community-sdk/copilot-sdk-java/actions/workflows/deploy-site.yml/badge.svg)](https://github.com/copilot-community-sdk/copilot-sdk-java/actions/workflows/deploy-site.yml) [![Maven Central](https://img.shields.io/maven-central/v/io.github.copilot-community-sdk/copilot-sdk)](https://central.sonatype.com/artifact/io.github.copilot-community-sdk/copilot-sdk) +[![Javadoc](https://javadoc.io/badge2/io.github.copilot-community-sdk/copilot-sdk/javadoc.svg)](https://javadoc.io/doc/io.github.copilot-community-sdk/copilot-sdk) +[![Documentation](https://img.shields.io/badge/docs-online-brightgreen)](https://copilot-community-sdk.github.io/copilot-sdk-java/) [![Java 17+](https://img.shields.io/badge/Java-17%2B-blue)](https://openjdk.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![GitHub Release](https://img.shields.io/github/v/release/copilot-community-sdk/copilot-sdk-java)](https://github.com/copilot-community-sdk/copilot-sdk-java/releases) +[![GitHub Stars](https://img.shields.io/github/stars/copilot-community-sdk/copilot-sdk-java)](https://github.com/copilot-community-sdk/copilot-sdk-java/stargazers) > ⚠️ **Disclaimer:** This is an **unofficial, community-driven SDK** and is **not supported or endorsed by GitHub**. This SDK may change in breaking ways. Use at your own risk. From c1856bbf6b36ae09fa50a4a82335ceb2f7a86145 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 16:43:58 -0800 Subject: [PATCH 008/109] build: add Maven plugins for Surefire, SpotBugs, tag tracking, and dependency analysis --- pom.xml | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/pom.xml b/pom.xml index 27514de59..35125d203 100644 --- a/pom.xml +++ b/pom.xml @@ -426,6 +426,56 @@ + + + org.apache.maven.plugins + maven-surefire-report-plugin + 3.5.4 + + + + com.github.spotbugs + spotbugs-maven-plugin + 4.9.8.2 + + + + org.codehaus.mojo + taglist-maven-plugin + 3.2.1 + + + + + Todo Work + + + TODO + ignoreCase + + + FIXME + ignoreCase + + + + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.9.0 + + + + analyze-report + + + + From fd60d92b8cc49d0411ff445fd2d53ba6a53f86c6 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 16:57:50 -0800 Subject: [PATCH 009/109] ci: update workflows to improve JaCoCo coverage reporting and deployment conditions --- .github/workflows/build-test.yml | 24 ++++++------------ .github/workflows/deploy-site.yml | 42 +++++++++++++++++++++++-------- 2 files changed, 39 insertions(+), 27 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index f440b2b26..2d9c5d1fa 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -71,22 +71,14 @@ jobs: COPILOT_CLI_PATH: ${{ steps.setup-copilot.outputs.path }} run: mvn verify - - name: Generate Test Report Summary - if: always() - uses: ./.github/actions/test-report - - - name: Upload Test Reports - uses: actions/upload-artifact@v6 - if: always() + - name: Upload JaCoCo exec for site generation + if: success() && github.ref == 'refs/heads/main' + uses: actions/upload-artifact@v4 with: - name: test-reports - path: target/surefire-reports/ - retention-days: 14 + name: jacoco-exec + path: target/jacoco-test-results/sdk-tests.exec + retention-days: 1 - - name: Upload Coverage Report - uses: actions/upload-artifact@v6 + - name: Generate Test Report Summary if: always() - with: - name: coverage-report - path: target/site/jacoco-coverage/ - retention-days: 14 + uses: ./.github/actions/test-report diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index d734fee4b..f425fb53e 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -2,13 +2,11 @@ name: Deploy Documentation on: - # Runs on pushes targeting the default branch (publishes to /snapshot/) - push: - branches: ["main"] - paths: - - 'pom.xml' - - 'src/**' - - '.github/**' + # Runs after Build & Test succeeds on main (publishes to /snapshot/) + workflow_run: + workflows: ["Build & Test"] + types: [completed] + branches: [main] # Runs on release publish (publishes to /latest/ and /vX.Y.Z/) release: @@ -43,6 +41,8 @@ concurrency: jobs: build-and-deploy: + # Skip if triggered by workflow_run that failed + if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest environment: name: github-pages @@ -111,11 +111,31 @@ jobs: echo "is_release=false" >> $GITHUB_OUTPUT fi + - name: Download JaCoCo exec from Build & Test + if: steps.tags.outputs.is_release == 'false' && inputs.rebuild_all_versions != true && github.event_name == 'workflow_run' + uses: actions/download-artifact@v4 + with: + name: jacoco-exec + path: /tmp/jacoco-exec + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: true + - name: Build snapshot documentation (main branch) if: steps.tags.outputs.is_release == 'false' && inputs.rebuild_all_versions != true run: | - # Run tests with JaCoCo coverage for snapshot builds - ./mvnw clean verify site -Dcheckstyle.skip=true + # Compile sources (needed for javadoc and other reports) + ./mvnw clean compile -DskipTests -Dcheckstyle.skip=true + + # Restore JaCoCo exec if available from Build & Test + if [ -f "/tmp/jacoco-exec/sdk-tests.exec" ]; then + mkdir -p target/jacoco-test-results + cp /tmp/jacoco-exec/sdk-tests.exec target/jacoco-test-results/ + echo "JaCoCo exec restored β€” coverage report will be included" + fi + + # Generate site (JaCoCo report plugin picks up the exec file) + ./mvnw site -DskipTests -Dcheckstyle.skip=true rm -rf "site/snapshot" mkdir -p "site/snapshot" @@ -172,9 +192,9 @@ jobs: done <<< "${{ steps.tags.outputs.all_tags }}" - # Return to main and build snapshot with JaCoCo coverage + # Return to main and build snapshot git checkout main - ./mvnw clean verify site -Dcheckstyle.skip=true + ./mvnw clean site -DskipTests -Dcheckstyle.skip=true rm -rf "site/snapshot" mkdir -p "site/snapshot" From bab1ce4cc948aa04db2941ff11daf1df709b6cbb Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 17:04:26 -0800 Subject: [PATCH 010/109] ci: update workflows to upload and download test results for site generation --- .github/workflows/build-test.yml | 8 +++++--- .github/workflows/deploy-site.yml | 17 ++++++++--------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 2d9c5d1fa..087e76028 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -71,12 +71,14 @@ jobs: COPILOT_CLI_PATH: ${{ steps.setup-copilot.outputs.path }} run: mvn verify - - name: Upload JaCoCo exec for site generation + - name: Upload test results for site generation if: success() && github.ref == 'refs/heads/main' uses: actions/upload-artifact@v4 with: - name: jacoco-exec - path: target/jacoco-test-results/sdk-tests.exec + name: test-results-for-site + path: | + target/jacoco-test-results/sdk-tests.exec + target/surefire-reports/ retention-days: 1 - name: Generate Test Report Summary diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index f425fb53e..8e3ed5d0a 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -111,12 +111,12 @@ jobs: echo "is_release=false" >> $GITHUB_OUTPUT fi - - name: Download JaCoCo exec from Build & Test + - name: Download test results from Build & Test if: steps.tags.outputs.is_release == 'false' && inputs.rebuild_all_versions != true && github.event_name == 'workflow_run' uses: actions/download-artifact@v4 with: - name: jacoco-exec - path: /tmp/jacoco-exec + name: test-results-for-site + path: /tmp/test-results run-id: ${{ github.event.workflow_run.id }} github-token: ${{ secrets.GITHUB_TOKEN }} continue-on-error: true @@ -127,14 +127,13 @@ jobs: # Compile sources (needed for javadoc and other reports) ./mvnw clean compile -DskipTests -Dcheckstyle.skip=true - # Restore JaCoCo exec if available from Build & Test - if [ -f "/tmp/jacoco-exec/sdk-tests.exec" ]; then - mkdir -p target/jacoco-test-results - cp /tmp/jacoco-exec/sdk-tests.exec target/jacoco-test-results/ - echo "JaCoCo exec restored β€” coverage report will be included" + # Restore test results from Build & Test (for JaCoCo + Surefire reports) + if [ -d "/tmp/test-results/target" ]; then + cp -r /tmp/test-results/target/* target/ + echo "Test results restored β€” JaCoCo and Surefire reports will be included" fi - # Generate site (JaCoCo report plugin picks up the exec file) + # Generate site (report plugins pick up the restored data) ./mvnw site -DskipTests -Dcheckstyle.skip=true rm -rf "site/snapshot" From ddb3088f73312ce3dbb1d0a06322d8b864900e63 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 17:18:17 -0800 Subject: [PATCH 011/109] ci: update GITHUB_TOKEN permissions to allow artifact downloads from Build & Test workflow --- .github/workflows/deploy-site.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 8e3ed5d0a..66111e54e 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -30,6 +30,7 @@ on: # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: + actions: read # Required to download artifacts from Build & Test workflow contents: write pages: write id-token: write From 4c6c14a632e6422ffb854a1b26ccd4ac3b3ef553 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 17:21:57 -0800 Subject: [PATCH 012/109] ci: remove COPILOT_GITHUB_TOKEN from system-wide environment variables in build-test workflow --- .github/workflows/build-test.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 087e76028..f4ad4280a 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -1,8 +1,5 @@ name: "Build & Test" -env: - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - on: schedule: # Run once a day at 00:00 UTC From 020c790105ecb646df2ad4b8bd311464f1ce4f74 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 17:29:19 -0800 Subject: [PATCH 013/109] ci: update test report header to include 'Copilot' for clarity --- .github/actions/test-report/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/test-report/action.yml b/.github/actions/test-report/action.yml index 55387da04..43d45f287 100644 --- a/.github/actions/test-report/action.yml +++ b/.github/actions/test-report/action.yml @@ -19,7 +19,7 @@ runs: - name: Generate Test Summary shell: bash run: | - echo "## πŸ§ͺ Java SDK Test Results" >> $GITHUB_STEP_SUMMARY + echo "## πŸ§ͺ Copilot Java SDK :: Test Results" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY REPORT_DIR=$(dirname "${{ inputs.report-path }}") From 94a4193618dc8b15ee6673433380f9341f828012 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 17:30:56 -0800 Subject: [PATCH 014/109] Merged --- .github/workflows/deploy-site.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 66111e54e..3bf29870b 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -129,9 +129,16 @@ jobs: ./mvnw clean compile -DskipTests -Dcheckstyle.skip=true # Restore test results from Build & Test (for JaCoCo + Surefire reports) - if [ -d "/tmp/test-results/target" ]; then - cp -r /tmp/test-results/target/* target/ - echo "Test results restored β€” JaCoCo and Surefire reports will be included" + # upload-artifact strips the common ancestor (target/), so files are at root + if [ -d "/tmp/test-results/surefire-reports" ]; then + mkdir -p target/surefire-reports + cp -r /tmp/test-results/surefire-reports/* target/surefire-reports/ + echo "Surefire reports restored" + fi + if [ -f "/tmp/test-results/jacoco-test-results/sdk-tests.exec" ]; then + mkdir -p target/jacoco-test-results + cp /tmp/test-results/jacoco-test-results/sdk-tests.exec target/jacoco-test-results/ + echo "JaCoCo exec restored" fi # Generate site (report plugins pick up the restored data) From 69e9e39b9229a58d9de20bb355f512184792135a Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 17:53:49 -0800 Subject: [PATCH 015/109] docs: update README to reorder badges and improve clarity --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2193897ce..b980ef8b3 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,17 @@ [![Build](https://github.com/copilot-community-sdk/copilot-sdk-java/actions/workflows/build-test.yml/badge.svg)](https://github.com/copilot-community-sdk/copilot-sdk-java/actions/workflows/build-test.yml) [![Site](https://github.com/copilot-community-sdk/copilot-sdk-java/actions/workflows/deploy-site.yml/badge.svg)](https://github.com/copilot-community-sdk/copilot-sdk-java/actions/workflows/deploy-site.yml) -[![Maven Central](https://img.shields.io/maven-central/v/io.github.copilot-community-sdk/copilot-sdk)](https://central.sonatype.com/artifact/io.github.copilot-community-sdk/copilot-sdk) -[![Javadoc](https://javadoc.io/badge2/io.github.copilot-community-sdk/copilot-sdk/javadoc.svg)](https://javadoc.io/doc/io.github.copilot-community-sdk/copilot-sdk) [![Documentation](https://img.shields.io/badge/docs-online-brightgreen)](https://copilot-community-sdk.github.io/copilot-sdk-java/) [![Java 17+](https://img.shields.io/badge/Java-17%2B-blue)](https://openjdk.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +#### Latest release [![GitHub Release](https://img.shields.io/github/v/release/copilot-community-sdk/copilot-sdk-java)](https://github.com/copilot-community-sdk/copilot-sdk-java/releases) -[![GitHub Stars](https://img.shields.io/github/stars/copilot-community-sdk/copilot-sdk-java)](https://github.com/copilot-community-sdk/copilot-sdk-java/stargazers) +[![Maven Central](https://img.shields.io/maven-central/v/io.github.copilot-community-sdk/copilot-sdk)](https://central.sonatype.com/artifact/io.github.copilot-community-sdk/copilot-sdk) +[![Documentation](https://img.shields.io/badge/docs-online-brightgreen)](https://copilot-community-sdk.github.io/copilot-sdk-java/1.0.7/) +[![Javadoc](https://javadoc.io/badge2/io.github.copilot-community-sdk/copilot-sdk/javadoc.svg)](https://javadoc.io/doc/io.github.copilot-community-sdk/copilot-sdk/latest/index.html) + +## Overview > ⚠️ **Disclaimer:** This is an **unofficial, community-driven SDK** and is **not supported or endorsed by GitHub**. This SDK may change in breaking ways. Use at your own risk. From 2a2366a9f4201d4ef0a8f00df096f0eaa0f7d6b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 02:01:02 +0000 Subject: [PATCH 016/109] Fix SpotBugs DM_DEFAULT_ENCODING: Add UTF-8 charset to InputStreamReader Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- src/main/java/com/github/copilot/sdk/CopilotClient.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/github/copilot/sdk/CopilotClient.java b/src/main/java/com/github/copilot/sdk/CopilotClient.java index b8929d541..21e981c4e 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -10,6 +10,7 @@ import java.io.InputStreamReader; import java.net.Socket; import java.net.URI; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -1014,7 +1015,8 @@ private ProcessInfo startCliServer() throws IOException, InterruptedException { // Forward stderr to logger in background Thread stderrThread = new Thread(() -> { - try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { LOG.fine("[CLI] " + line); @@ -1029,7 +1031,8 @@ private ProcessInfo startCliServer() throws IOException, InterruptedException { Integer detectedPort = null; if (!options.isUseStdio()) { // Wait for port announcement - BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)); Pattern portPattern = Pattern.compile("listening on port (\\d+)", Pattern.CASE_INSENSITIVE); long deadline = System.currentTimeMillis() + 30000; From 72215c7a9d54ca3069d757458cb4ce1bbc1a4538 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 02:04:50 +0000 Subject: [PATCH 017/109] Fix SpotBugs REC_CATCH_EXCEPTION: narrow exception catch in JsonRpcClient.handleMessage() Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- src/main/java/com/github/copilot/sdk/JsonRpcClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/github/copilot/sdk/JsonRpcClient.java b/src/main/java/com/github/copilot/sdk/JsonRpcClient.java index ff4e10e1b..21512b1bc 100644 --- a/src/main/java/com/github/copilot/sdk/JsonRpcClient.java +++ b/src/main/java/com/github/copilot/sdk/JsonRpcClient.java @@ -306,7 +306,7 @@ else if (node.has("method")) { } } } - } catch (Exception e) { + } catch (JsonProcessingException e) { LOG.log(Level.SEVERE, "Error parsing JSON-RPC message", e); } } From f9369164ce25c08e01ae6f746f0d7e1efa6b71c1 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 18:02:29 -0800 Subject: [PATCH 018/109] ci: add Copilot setup steps workflow for Java project --- .github/workflows/copilot-setup-steps.yml | 34 +++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/copilot-setup-steps.yml diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 000000000..9a2109eee --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,34 @@ +name: "Copilot Setup Steps" + +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + copilot-setup-steps: + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: 'temurin' + cache: 'maven' + + - name: Verify Java version + run: java -version + + - name: Download dependencies + run: mvn dependency:go-offline -B From 387e4a4a88348ef45685e5148ec69383efcd73db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 02:10:34 +0000 Subject: [PATCH 019/109] Enhance Copilot instructions with security, boundaries, and workflow guidelines Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- .github/copilot-instructions.md | 79 +++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8bae9ff7e..e3530baa8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,6 +2,15 @@ A Java SDK for programmatic control of GitHub Copilot CLI. This is a community-driven port of the official .NET SDK, targeting Java 17+. +## About These Instructions + +These instructions guide GitHub Copilot when assisting with this repository. They cover: + +- **Tech Stack**: Java 17+, Maven, Jackson for JSON, JUnit for testing +- **Purpose**: Provide a Java SDK for programmatic control of GitHub Copilot CLI +- **Architecture**: JSON-RPC client communicating with Copilot CLI over stdio +- **Key Goals**: Maintain parity with upstream .NET SDK while following Java idioms + ## Build & Test Commands ```bash @@ -170,3 +179,73 @@ Test method names are converted to lowercase snake_case for snapshot filenames t - Update `src/site/site.xml` when adding new documentation pages - Javadoc required for public APIs except `json` and `events` packages (self-documenting DTOs) - **Copilot CLI Version**: When updating the required Copilot CLI version in `README.md`, also update it in `src/site/markdown/index.md` to keep them in sync + +## Boundaries and Restrictions + +### What NOT to Modify + +- **DO NOT** edit `.github/agents/` directory - these contain instructions for other agents +- **DO NOT** modify `target/` directory - this contains build artifacts +- **DO NOT** edit `pom.xml` dependencies without careful consideration - this SDK has minimal dependencies by design +- **DO NOT** change the Jackson version without testing against all serialization patterns +- **DO NOT** modify test snapshots in `target/copilot-sdk/test/snapshots/` - these come from upstream +- **DO NOT** alter the Eclipse formatter configuration in `pom.xml` without team consensus +- **DO NOT** remove or skip Checkstyle or Spotless checks + +### Security Guidelines + +- **NEVER** commit secrets, API keys, tokens, or credentials to the repository +- **NEVER** commit `.env` files or any files containing sensitive configuration +- **NEVER** log sensitive data in code (API keys, tokens, user data) +- Always use `try-with-resources` for streams and readers to prevent resource leaks +- Always use `StandardCharsets.UTF_8` when creating InputStreamReader/OutputStreamWriter +- Review any new dependencies for known security vulnerabilities before adding them +- When handling user input in tools or handlers, consider injection risks + +## Dependency Management + +This SDK is designed to be **lightweight with minimal dependencies**: + +- Core dependencies: Jackson (JSON), JUnit (tests only) +- Before adding new dependencies: + 1. Consider if the functionality can be implemented without a new dependency + 2. Check if Jackson already provides the needed functionality + 3. Ensure the dependency is actively maintained and widely used + 4. Verify compatibility with Java 17+ + 5. Check for security vulnerabilities + 6. Get team approval for non-trivial additions + +## Commit and PR Guidelines + +### Commit Messages + +- Use clear, descriptive commit messages +- Start with a verb in present tense (e.g., "Add", "Fix", "Update", "Refactor") +- Keep the first line under 72 characters +- Add details in the body if needed +- Examples: + - `Add support for streaming responses` + - `Fix resource leak in JsonRpcClient` + - `Update documentation for tool handlers` + - `Refactor event handling to use sealed classes` + +### Pull Requests + +- Keep PRs focused and minimal - one feature/fix per PR +- Ensure all tests pass before requesting review +- Run `mvn spotless:apply` before committing +- Include tests for new functionality +- Update documentation if adding/changing public APIs +- Reference related issues using `#issue-number` +- For upstream merges, follow the `agentic-merge-upstream` skill workflow + +## Development Workflow + +1. **Setup**: Enable git hooks with `git config core.hooksPath .githooks` +2. **Branch**: Create feature branches from `main` +3. **Code**: Write code following the conventions above +4. **Format**: Run `mvn spotless:apply` to format code +5. **Test**: Run `mvn clean verify` to ensure all tests pass +6. **Commit**: Make focused commits with clear messages +7. **Push**: Push your branch and create a PR +8. **Review**: Address review feedback and iterate From f6e629202f71c07b9f7bb7d059347c8b4767e4b6 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 18:28:58 -0800 Subject: [PATCH 020/109] Fix SpotBugs OS_OPEN_STREAM: wrap BufferedReader in try-with-resources Wrap the BufferedReader used for CLI port detection in a try-with-resources block to guarantee stream closure on all exit paths (normal completion, exceptions, timeout). Fixes #17 --- .../com/github/copilot/sdk/CopilotClient.java | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/github/copilot/sdk/CopilotClient.java b/src/main/java/com/github/copilot/sdk/CopilotClient.java index 21e981c4e..595d10404 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -1031,27 +1031,28 @@ private ProcessInfo startCliServer() throws IOException, InterruptedException { Integer detectedPort = null; if (!options.isUseStdio()) { // Wait for port announcement - BufferedReader reader = new BufferedReader( - new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)); - Pattern portPattern = Pattern.compile("listening on port (\\d+)", Pattern.CASE_INSENSITIVE); - long deadline = System.currentTimeMillis() + 30000; - - while (System.currentTimeMillis() < deadline) { - String line = reader.readLine(); - if (line == null) { - throw new IOException("CLI process exited unexpectedly"); - } + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + Pattern portPattern = Pattern.compile("listening on port (\\d+)", Pattern.CASE_INSENSITIVE); + long deadline = System.currentTimeMillis() + 30000; + + while (System.currentTimeMillis() < deadline) { + String line = reader.readLine(); + if (line == null) { + throw new IOException("CLI process exited unexpectedly"); + } - Matcher matcher = portPattern.matcher(line); - if (matcher.find()) { - detectedPort = Integer.parseInt(matcher.group(1)); - break; + Matcher matcher = portPattern.matcher(line); + if (matcher.find()) { + detectedPort = Integer.parseInt(matcher.group(1)); + break; + } } - } - if (detectedPort == null) { - process.destroyForcibly(); - throw new IOException("Timeout waiting for CLI to announce port"); + if (detectedPort == null) { + process.destroyForcibly(); + throw new IOException("Timeout waiting for CLI to announce port"); + } } } From 222f4e1ffd5e067b2d8b81f4c75bd225ec8714bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 02:38:51 +0000 Subject: [PATCH 021/109] Make CopilotClient final to prevent Finalizer attacks Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- src/main/java/com/github/copilot/sdk/CopilotClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/github/copilot/sdk/CopilotClient.java b/src/main/java/com/github/copilot/sdk/CopilotClient.java index 595d10404..d41a0c398 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -79,7 +79,7 @@ * * @since 1.0.0 */ -public class CopilotClient implements AutoCloseable { +public final class CopilotClient implements AutoCloseable { private static final Logger LOG = Logger.getLogger(CopilotClient.class.getName()); private static final ObjectMapper MAPPER = JsonRpcClient.getObjectMapper(); From 2be4be713b6a8fdb13992fcae8bd47a6fcfa94fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 02:50:53 +0000 Subject: [PATCH 022/109] Add SpotBugs annotations to suppress EI_EXPOSE_REP2 warnings Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- pom.xml | 8 ++++++++ .../java/com/github/copilot/sdk/events/package-info.java | 1 + .../java/com/github/copilot/sdk/json/package-info.java | 1 + 3 files changed, 10 insertions(+) diff --git a/pom.xml b/pom.xml index 35125d203..0b39e10e9 100644 --- a/pom.xml +++ b/pom.xml @@ -62,6 +62,14 @@ 2.20.1 + + + com.github.spotbugs + spotbugs-annotations + 4.9.8 + provided + + org.junit.jupiter diff --git a/src/main/java/com/github/copilot/sdk/events/package-info.java b/src/main/java/com/github/copilot/sdk/events/package-info.java index 4572d9896..1bcc1ddd0 100644 --- a/src/main/java/com/github/copilot/sdk/events/package-info.java +++ b/src/main/java/com/github/copilot/sdk/events/package-info.java @@ -2,6 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ +@edu.umd.cs.findbugs.annotations.SuppressFBWarnings(value = "EI_EXPOSE_REP2", justification = "DTOs for JSON deserialization - low risk") /** * Event types emitted during Copilot session processing. * diff --git a/src/main/java/com/github/copilot/sdk/json/package-info.java b/src/main/java/com/github/copilot/sdk/json/package-info.java index 3a9538013..c0fbe53a9 100644 --- a/src/main/java/com/github/copilot/sdk/json/package-info.java +++ b/src/main/java/com/github/copilot/sdk/json/package-info.java @@ -2,6 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ +@edu.umd.cs.findbugs.annotations.SuppressFBWarnings(value = "EI_EXPOSE_REP2", justification = "DTOs for JSON deserialization - low risk") /** * Configuration classes and data transfer objects for the Copilot SDK. * From 0735b52a9b392cdcd8579541092a2d3552041ba6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 02:49:01 +0000 Subject: [PATCH 023/109] Add defensive copies to collection getters to fix SpotBugs EI_EXPOSE_REP findings Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- .../copilot/sdk/events/AssistantMessageEvent.java | 3 ++- .../copilot/sdk/events/AssistantUsageEvent.java | 3 ++- .../copilot/sdk/json/CreateSessionRequest.java | 15 ++++++++------- .../copilot/sdk/json/ResumeSessionConfig.java | 11 ++++++----- .../copilot/sdk/json/ResumeSessionRequest.java | 11 ++++++----- .../copilot/sdk/json/SendMessageRequest.java | 3 ++- .../github/copilot/sdk/json/SessionConfig.java | 15 ++++++++------- .../copilot/sdk/json/SessionEndHookOutput.java | 3 ++- .../copilot/sdk/json/SessionStartHookOutput.java | 3 ++- .../github/copilot/sdk/json/ToolResultObject.java | 5 +++-- .../github/copilot/sdk/json/UserInputRequest.java | 3 ++- 11 files changed, 43 insertions(+), 32 deletions(-) diff --git a/src/main/java/com/github/copilot/sdk/events/AssistantMessageEvent.java b/src/main/java/com/github/copilot/sdk/events/AssistantMessageEvent.java index 3a030b98c..b0be4a75e 100644 --- a/src/main/java/com/github/copilot/sdk/events/AssistantMessageEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/AssistantMessageEvent.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Collections; import java.util.List; /** @@ -136,7 +137,7 @@ public void setContent(String content) { * @return the tool requests, or {@code null} if none */ public List getToolRequests() { - return toolRequests; + return toolRequests == null ? null : Collections.unmodifiableList(toolRequests); } /** diff --git a/src/main/java/com/github/copilot/sdk/events/AssistantUsageEvent.java b/src/main/java/com/github/copilot/sdk/events/AssistantUsageEvent.java index 104e448bd..b84664c23 100644 --- a/src/main/java/com/github/copilot/sdk/events/AssistantUsageEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/AssistantUsageEvent.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Collections; import java.util.Map; /** @@ -161,7 +162,7 @@ public void setParentToolCallId(String parentToolCallId) { } public Map getQuotaSnapshots() { - return quotaSnapshots; + return quotaSnapshots == null ? null : Collections.unmodifiableMap(quotaSnapshots); } public void setQuotaSnapshots(Map quotaSnapshots) { diff --git a/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java b/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java index 597d1cbf8..328161030 100644 --- a/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java +++ b/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java @@ -4,6 +4,7 @@ package com.github.copilot.sdk.json; +import java.util.Collections; import java.util.List; import java.util.Map; @@ -115,7 +116,7 @@ public void setReasoningEffort(String reasoningEffort) { /** Gets the tools. @return the tool definitions */ public List getTools() { - return tools; + return tools == null ? null : Collections.unmodifiableList(tools); } /** Sets the tools. @param tools the tool definitions */ @@ -135,7 +136,7 @@ public void setSystemMessage(SystemMessageConfig systemMessage) { /** Gets available tools. @return the tool names */ public List getAvailableTools() { - return availableTools; + return availableTools == null ? null : Collections.unmodifiableList(availableTools); } /** Sets available tools. @param availableTools the tool names */ @@ -145,7 +146,7 @@ public void setAvailableTools(List availableTools) { /** Gets excluded tools. @return the tool names */ public List getExcludedTools() { - return excludedTools; + return excludedTools == null ? null : Collections.unmodifiableList(excludedTools); } /** Sets excluded tools. @param excludedTools the tool names */ @@ -215,7 +216,7 @@ public void setStreaming(Boolean streaming) { /** Gets MCP servers. @return the servers map */ public Map getMcpServers() { - return mcpServers; + return mcpServers == null ? null : Collections.unmodifiableMap(mcpServers); } /** Sets MCP servers. @param mcpServers the servers map */ @@ -225,7 +226,7 @@ public void setMcpServers(Map mcpServers) { /** Gets custom agents. @return the agents */ public List getCustomAgents() { - return customAgents; + return customAgents == null ? null : Collections.unmodifiableList(customAgents); } /** Sets custom agents. @param customAgents the agents */ @@ -245,7 +246,7 @@ public void setInfiniteSessions(InfiniteSessionConfig infiniteSessions) { /** Gets skill directories. @return the skill directories */ public List getSkillDirectories() { - return skillDirectories; + return skillDirectories == null ? null : Collections.unmodifiableList(skillDirectories); } /** Sets skill directories. @param skillDirectories the directories */ @@ -255,7 +256,7 @@ public void setSkillDirectories(List skillDirectories) { /** Gets disabled skills. @return the disabled skill names */ public List getDisabledSkills() { - return disabledSkills; + return disabledSkills == null ? null : Collections.unmodifiableList(disabledSkills); } /** Sets disabled skills. @param disabledSkills the skill names to disable */ diff --git a/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java b/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java index 4e229d5a2..768c4fa3b 100644 --- a/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java +++ b/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java @@ -4,6 +4,7 @@ package com.github.copilot.sdk.json; +import java.util.Collections; import java.util.List; import java.util.Map; @@ -51,7 +52,7 @@ public class ResumeSessionConfig { * @return the list of tool definitions */ public List getTools() { - return tools; + return tools == null ? null : Collections.unmodifiableList(tools); } /** @@ -249,7 +250,7 @@ public ResumeSessionConfig setStreaming(boolean streaming) { * @return the MCP servers map */ public Map getMcpServers() { - return mcpServers; + return mcpServers == null ? null : Collections.unmodifiableMap(mcpServers); } /** @@ -270,7 +271,7 @@ public ResumeSessionConfig setMcpServers(Map mcpServers) { * @return the list of custom agent configurations */ public List getCustomAgents() { - return customAgents; + return customAgents == null ? null : Collections.unmodifiableList(customAgents); } /** @@ -292,7 +293,7 @@ public ResumeSessionConfig setCustomAgents(List customAgents) * @return the list of skill directory paths */ public List getSkillDirectories() { - return skillDirectories; + return skillDirectories == null ? null : Collections.unmodifiableList(skillDirectories); } /** @@ -313,7 +314,7 @@ public ResumeSessionConfig setSkillDirectories(List skillDirectories) { * @return the list of disabled skill names */ public List getDisabledSkills() { - return disabledSkills; + return disabledSkills == null ? null : Collections.unmodifiableList(disabledSkills); } /** diff --git a/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java b/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java index 9d38e702e..7c1999f90 100644 --- a/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java +++ b/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java @@ -4,6 +4,7 @@ package com.github.copilot.sdk.json; +import java.util.Collections; import java.util.List; import java.util.Map; @@ -91,7 +92,7 @@ public void setReasoningEffort(String reasoningEffort) { /** Gets the tools. @return the tool definitions */ public List getTools() { - return tools; + return tools == null ? null : Collections.unmodifiableList(tools); } /** Sets the tools. @param tools the tool definitions */ @@ -171,7 +172,7 @@ public void setStreaming(Boolean streaming) { /** Gets MCP servers. @return the servers map */ public Map getMcpServers() { - return mcpServers; + return mcpServers == null ? null : Collections.unmodifiableMap(mcpServers); } /** Sets MCP servers. @param mcpServers the servers map */ @@ -181,7 +182,7 @@ public void setMcpServers(Map mcpServers) { /** Gets custom agents. @return the agents */ public List getCustomAgents() { - return customAgents; + return customAgents == null ? null : Collections.unmodifiableList(customAgents); } /** Sets custom agents. @param customAgents the agents */ @@ -191,7 +192,7 @@ public void setCustomAgents(List customAgents) { /** Gets skill directories. @return the directories */ public List getSkillDirectories() { - return skillDirectories; + return skillDirectories == null ? null : Collections.unmodifiableList(skillDirectories); } /** Sets skill directories. @param skillDirectories the directories */ @@ -201,7 +202,7 @@ public void setSkillDirectories(List skillDirectories) { /** Gets disabled skills. @return the disabled skill names */ public List getDisabledSkills() { - return disabledSkills; + return disabledSkills == null ? null : Collections.unmodifiableList(disabledSkills); } /** Sets disabled skills. @param disabledSkills the skill names to disable */ diff --git a/src/main/java/com/github/copilot/sdk/json/SendMessageRequest.java b/src/main/java/com/github/copilot/sdk/json/SendMessageRequest.java index 3c89b68ce..477af98dc 100644 --- a/src/main/java/com/github/copilot/sdk/json/SendMessageRequest.java +++ b/src/main/java/com/github/copilot/sdk/json/SendMessageRequest.java @@ -4,6 +4,7 @@ package com.github.copilot.sdk.json; +import java.util.Collections; import java.util.List; import com.fasterxml.jackson.annotation.JsonInclude; @@ -57,7 +58,7 @@ public void setPrompt(String prompt) { /** Gets the attachments. @return the list of attachments */ public List getAttachments() { - return attachments; + return attachments == null ? null : Collections.unmodifiableList(attachments); } /** Sets the attachments. @param attachments the list of attachments */ diff --git a/src/main/java/com/github/copilot/sdk/json/SessionConfig.java b/src/main/java/com/github/copilot/sdk/json/SessionConfig.java index b491f68e8..9d7e7367f 100644 --- a/src/main/java/com/github/copilot/sdk/json/SessionConfig.java +++ b/src/main/java/com/github/copilot/sdk/json/SessionConfig.java @@ -4,6 +4,7 @@ package com.github.copilot.sdk.json; +import java.util.Collections; import java.util.List; import java.util.Map; @@ -127,7 +128,7 @@ public SessionConfig setReasoningEffort(String reasoningEffort) { * @return the list of tool definitions */ public List getTools() { - return tools; + return tools == null ? null : Collections.unmodifiableList(tools); } /** @@ -179,7 +180,7 @@ public SessionConfig setSystemMessage(SystemMessageConfig systemMessage) { * @return the list of available tool names */ public List getAvailableTools() { - return availableTools; + return availableTools == null ? null : Collections.unmodifiableList(availableTools); } /** @@ -202,7 +203,7 @@ public SessionConfig setAvailableTools(List availableTools) { * @return the list of excluded tool names */ public List getExcludedTools() { - return excludedTools; + return excludedTools == null ? null : Collections.unmodifiableList(excludedTools); } /** @@ -369,7 +370,7 @@ public SessionConfig setStreaming(boolean streaming) { * @return the MCP servers map */ public Map getMcpServers() { - return mcpServers; + return mcpServers == null ? null : Collections.unmodifiableMap(mcpServers); } /** @@ -393,7 +394,7 @@ public SessionConfig setMcpServers(Map mcpServers) { * @return the list of custom agent configurations */ public List getCustomAgents() { - return customAgents; + return customAgents == null ? null : Collections.unmodifiableList(customAgents); } /** @@ -445,7 +446,7 @@ public SessionConfig setInfiniteSessions(InfiniteSessionConfig infiniteSessions) * @return the list of skill directory paths */ public List getSkillDirectories() { - return skillDirectories; + return skillDirectories == null ? null : Collections.unmodifiableList(skillDirectories); } /** @@ -470,7 +471,7 @@ public SessionConfig setSkillDirectories(List skillDirectories) { * @return the list of disabled skill names */ public List getDisabledSkills() { - return disabledSkills; + return disabledSkills == null ? null : Collections.unmodifiableList(disabledSkills); } /** diff --git a/src/main/java/com/github/copilot/sdk/json/SessionEndHookOutput.java b/src/main/java/com/github/copilot/sdk/json/SessionEndHookOutput.java index a8b008bcb..99d14e5d2 100644 --- a/src/main/java/com/github/copilot/sdk/json/SessionEndHookOutput.java +++ b/src/main/java/com/github/copilot/sdk/json/SessionEndHookOutput.java @@ -4,6 +4,7 @@ package com.github.copilot.sdk.json; +import java.util.Collections; import java.util.List; import com.fasterxml.jackson.annotation.JsonInclude; @@ -55,7 +56,7 @@ public SessionEndHookOutput setSuppressOutput(Boolean suppressOutput) { * @return the cleanup actions, or {@code null} */ public List getCleanupActions() { - return cleanupActions; + return cleanupActions == null ? null : Collections.unmodifiableList(cleanupActions); } /** diff --git a/src/main/java/com/github/copilot/sdk/json/SessionStartHookOutput.java b/src/main/java/com/github/copilot/sdk/json/SessionStartHookOutput.java index a589ed072..729660fdd 100644 --- a/src/main/java/com/github/copilot/sdk/json/SessionStartHookOutput.java +++ b/src/main/java/com/github/copilot/sdk/json/SessionStartHookOutput.java @@ -4,6 +4,7 @@ package com.github.copilot.sdk.json; +import java.util.Collections; import java.util.Map; import com.fasterxml.jackson.annotation.JsonInclude; @@ -52,7 +53,7 @@ public SessionStartHookOutput setAdditionalContext(String additionalContext) { * @return the modified configuration map, or {@code null} */ public Map getModifiedConfig() { - return modifiedConfig; + return modifiedConfig == null ? null : Collections.unmodifiableMap(modifiedConfig); } /** diff --git a/src/main/java/com/github/copilot/sdk/json/ToolResultObject.java b/src/main/java/com/github/copilot/sdk/json/ToolResultObject.java index becf86f15..72b5291c0 100644 --- a/src/main/java/com/github/copilot/sdk/json/ToolResultObject.java +++ b/src/main/java/com/github/copilot/sdk/json/ToolResultObject.java @@ -4,6 +4,7 @@ package com.github.copilot.sdk.json; +import java.util.Collections; import java.util.List; import java.util.Map; @@ -80,7 +81,7 @@ public ToolResultObject setTextResultForLlm(String textResultForLlm) { * @return the list of binary results */ public List getBinaryResultsForLlm() { - return binaryResultsForLlm; + return binaryResultsForLlm == null ? null : Collections.unmodifiableList(binaryResultsForLlm); } /** @@ -164,7 +165,7 @@ public ToolResultObject setSessionLog(String sessionLog) { * @return the telemetry map */ public Map getToolTelemetry() { - return toolTelemetry; + return toolTelemetry == null ? null : Collections.unmodifiableMap(toolTelemetry); } public ToolResultObject setToolTelemetry(Map toolTelemetry) { diff --git a/src/main/java/com/github/copilot/sdk/json/UserInputRequest.java b/src/main/java/com/github/copilot/sdk/json/UserInputRequest.java index ff47dab3a..9b466f866 100644 --- a/src/main/java/com/github/copilot/sdk/json/UserInputRequest.java +++ b/src/main/java/com/github/copilot/sdk/json/UserInputRequest.java @@ -4,6 +4,7 @@ package com.github.copilot.sdk.json; +import java.util.Collections; import java.util.List; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @@ -56,7 +57,7 @@ public UserInputRequest setQuestion(String question) { * @return the list of choices, or {@code null} for freeform input */ public List getChoices() { - return choices; + return choices == null ? null : Collections.unmodifiableList(choices); } /** From 922ec0e52333c4ff3557b31194667da59748a55e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 02:51:26 +0000 Subject: [PATCH 024/109] Fix remaining collection getter defensive copies in events and json packages Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- .../com/github/copilot/sdk/events/SubagentSelectedEvent.java | 2 +- .../copilot/sdk/events/ToolExecutionCompleteEvent.java | 3 ++- .../java/com/github/copilot/sdk/events/UserMessageEvent.java | 3 ++- .../java/com/github/copilot/sdk/json/CustomAgentConfig.java | 5 +++-- .../java/com/github/copilot/sdk/json/MessageOptions.java | 3 ++- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/github/copilot/sdk/events/SubagentSelectedEvent.java b/src/main/java/com/github/copilot/sdk/events/SubagentSelectedEvent.java index c0ea1483a..c5b9a58d8 100644 --- a/src/main/java/com/github/copilot/sdk/events/SubagentSelectedEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/SubagentSelectedEvent.java @@ -60,7 +60,7 @@ public void setAgentDisplayName(String agentDisplayName) { } public String[] getTools() { - return tools; + return tools == null ? null : tools.clone(); } public void setTools(String[] tools) { diff --git a/src/main/java/com/github/copilot/sdk/events/ToolExecutionCompleteEvent.java b/src/main/java/com/github/copilot/sdk/events/ToolExecutionCompleteEvent.java index 19e26b0a3..9eb8301ef 100644 --- a/src/main/java/com/github/copilot/sdk/events/ToolExecutionCompleteEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/ToolExecutionCompleteEvent.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Collections; import java.util.Map; /** @@ -98,7 +99,7 @@ public void setError(Error error) { } public Map getToolTelemetry() { - return toolTelemetry; + return toolTelemetry == null ? null : Collections.unmodifiableMap(toolTelemetry); } public void setToolTelemetry(Map toolTelemetry) { diff --git a/src/main/java/com/github/copilot/sdk/events/UserMessageEvent.java b/src/main/java/com/github/copilot/sdk/events/UserMessageEvent.java index b0ab70737..6251604ee 100644 --- a/src/main/java/com/github/copilot/sdk/events/UserMessageEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/UserMessageEvent.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Collections; import java.util.List; /** @@ -65,7 +66,7 @@ public void setTransformedContent(String transformedContent) { } public List getAttachments() { - return attachments; + return attachments == null ? null : Collections.unmodifiableList(attachments); } public void setAttachments(List attachments) { diff --git a/src/main/java/com/github/copilot/sdk/json/CustomAgentConfig.java b/src/main/java/com/github/copilot/sdk/json/CustomAgentConfig.java index aee91378b..99731ddfb 100644 --- a/src/main/java/com/github/copilot/sdk/json/CustomAgentConfig.java +++ b/src/main/java/com/github/copilot/sdk/json/CustomAgentConfig.java @@ -4,6 +4,7 @@ package com.github.copilot.sdk.json; +import java.util.Collections; import java.util.List; import java.util.Map; @@ -128,7 +129,7 @@ public CustomAgentConfig setDescription(String description) { * @return the list of tool identifiers */ public List getTools() { - return tools; + return tools == null ? null : Collections.unmodifiableList(tools); } /** @@ -175,7 +176,7 @@ public CustomAgentConfig setPrompt(String prompt) { * @return the MCP servers map */ public Map getMcpServers() { - return mcpServers; + return mcpServers == null ? null : Collections.unmodifiableMap(mcpServers); } /** diff --git a/src/main/java/com/github/copilot/sdk/json/MessageOptions.java b/src/main/java/com/github/copilot/sdk/json/MessageOptions.java index 60adfc645..5155ed7c8 100644 --- a/src/main/java/com/github/copilot/sdk/json/MessageOptions.java +++ b/src/main/java/com/github/copilot/sdk/json/MessageOptions.java @@ -4,6 +4,7 @@ package com.github.copilot.sdk.json; +import java.util.Collections; import java.util.List; import com.fasterxml.jackson.annotation.JsonInclude; @@ -60,7 +61,7 @@ public MessageOptions setPrompt(String prompt) { * @return the list of attachments */ public List getAttachments() { - return attachments; + return attachments == null ? null : Collections.unmodifiableList(attachments); } /** From e90b3f9d794016f63fcb7bb6e0a470b7104d7bae Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 20:57:12 -0800 Subject: [PATCH 025/109] Add SpotBugs exclusion filter for events and json packages --- pom.xml | 11 +++++++++++ spotbugs-exclude.xml | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 spotbugs-exclude.xml diff --git a/pom.xml b/pom.xml index 0b39e10e9..5ea38832a 100644 --- a/pom.xml +++ b/pom.xml @@ -92,6 +92,14 @@ none + + com.github.spotbugs + spotbugs-maven-plugin + 4.9.8.2 + + spotbugs-exclude.xml + + @@ -445,6 +453,9 @@ com.github.spotbugs spotbugs-maven-plugin 4.9.8.2 + + spotbugs-exclude.xml + diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml new file mode 100644 index 000000000..7f3b068cb --- /dev/null +++ b/spotbugs-exclude.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + From dcce429f8e010b80b7daa4fd39aeb106a7167a8b Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 21:19:20 -0800 Subject: [PATCH 026/109] Refactor: use var for local variable type inference Apply Java var (local variable type inference) across the codebase where the type is obvious from the right-hand side expression: - Constructor calls: Type x = new Type() -> var x = new Type() - Diamond operator fill: List x = new ArrayList<>() -> var x = new ArrayList() - Cast expressions: Type x = (Type) expr -> var x = (Type) expr Modified 3 main source files and 14 test files (17 files total). --- .../com/github/copilot/sdk/CopilotClient.java | 44 +++++++++---------- .../github/copilot/sdk/CopilotSession.java | 19 ++++---- .../com/github/copilot/sdk/JsonRpcClient.java | 18 ++++---- .../com/github/copilot/sdk/AskUserTest.java | 13 +++--- .../com/github/copilot/sdk/CapiProxy.java | 2 +- .../github/copilot/sdk/CompactionTest.java | 13 +++--- .../github/copilot/sdk/CopilotClientTest.java | 30 ++++++------- .../copilot/sdk/CopilotSessionTest.java | 16 +++---- .../github/copilot/sdk/ErrorHandlingTest.java | 10 ++--- .../com/github/copilot/sdk/HooksTest.java | 19 ++++---- .../github/copilot/sdk/McpAndAgentsTest.java | 12 ++--- .../github/copilot/sdk/MetadataApiTest.java | 2 +- .../github/copilot/sdk/PermissionsTest.java | 26 +++++------ .../copilot/sdk/SessionEventHandlingTest.java | 33 +++++++------- .../copilot/sdk/SessionEventParserTest.java | 22 +++++----- .../copilot/sdk/SessionEventsE2ETest.java | 15 +++---- .../com/github/copilot/sdk/ToolsTest.java | 16 +++---- 17 files changed, 148 insertions(+), 162 deletions(-) diff --git a/src/main/java/com/github/copilot/sdk/CopilotClient.java b/src/main/java/com/github/copilot/sdk/CopilotClient.java index d41a0c398..020fc4d53 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -281,14 +281,13 @@ private void handleToolCall(JsonRpcClient rpc, String requestId, JsonNode params ToolDefinition tool = session.getTool(toolName); if (tool == null || tool.getHandler() == null) { - ToolResultObject result = new ToolResultObject() - .setTextResultForLlm("Tool '" + toolName + "' is not supported.").setResultType("failure") - .setError("tool '" + toolName + "' not supported"); + var result = new ToolResultObject().setTextResultForLlm("Tool '" + toolName + "' is not supported.") + .setResultType("failure").setError("tool '" + toolName + "' not supported"); rpc.sendResponse(Long.parseLong(requestId), Map.of("result", result)); return; } - ToolInvocation invocation = new ToolInvocation().setSessionId(sessionId).setToolCallId(toolCallId) + var invocation = new ToolInvocation().setSessionId(sessionId).setToolCallId(toolCallId) .setToolName(toolName).setArguments(arguments); tool.getHandler().invoke(invocation).thenAccept(result -> { @@ -306,7 +305,7 @@ private void handleToolCall(JsonRpcClient rpc, String requestId, JsonNode params } }).exceptionally(ex -> { try { - ToolResultObject result = new ToolResultObject() + var result = new ToolResultObject() .setTextResultForLlm( "Invoking this tool produced an error. Detailed information is not available.") .setResultType("failure").setError(ex.getMessage()); @@ -335,7 +334,7 @@ private void handlePermissionRequest(JsonRpcClient rpc, String requestId, JsonNo CopilotSession session = sessions.get(sessionId); if (session == null) { - PermissionRequestResult result = new PermissionRequestResult() + var result = new PermissionRequestResult() .setKind("denied-no-approval-rule-and-could-not-request-from-user"); rpc.sendResponse(Long.parseLong(requestId), Map.of("result", result)); return; @@ -349,7 +348,7 @@ private void handlePermissionRequest(JsonRpcClient rpc, String requestId, JsonNo } }).exceptionally(ex -> { try { - PermissionRequestResult result = new PermissionRequestResult() + var result = new PermissionRequestResult() .setKind("denied-no-approval-rule-and-could-not-request-from-user"); rpc.sendResponse(Long.parseLong(requestId), Map.of("result", result)); } catch (IOException e) { @@ -381,10 +380,9 @@ private void handleUserInputRequest(JsonRpcClient rpc, String requestId, JsonNod return; } - com.github.copilot.sdk.json.UserInputRequest request = new com.github.copilot.sdk.json.UserInputRequest() - .setQuestion(question); + var request = new com.github.copilot.sdk.json.UserInputRequest().setQuestion(question); if (choicesNode != null && choicesNode.isArray()) { - List choices = new ArrayList<>(); + var choices = new ArrayList(); for (JsonNode choice : choicesNode) { choices.add(choice.asText()); } @@ -461,7 +459,7 @@ private void handleHooksInvoke(JsonRpcClient rpc, String requestId, JsonNode par private void verifyProtocolVersion(Connection connection) throws Exception { int expectedVersion = SdkProtocolVersion.get(); - Map params = new HashMap<>(); + var params = new HashMap(); params.put("message", null); PingResponse pingResponse = connection.rpc.invoke("ping", params, PingResponse.class).get(30, TimeUnit.SECONDS); @@ -484,7 +482,7 @@ private void verifyProtocolVersion(Connection connection) throws Exception { * @return A future that completes when the client is stopped */ public CompletableFuture stop() { - List> closeFutures = new ArrayList<>(); + var closeFutures = new ArrayList>(); for (CopilotSession session : new ArrayList<>(sessions.values())) { closeFutures.add(CompletableFuture.runAsync(() -> { @@ -555,7 +553,7 @@ private CompletableFuture cleanupConnection() { */ public CompletableFuture createSession(SessionConfig config) { return ensureConnected().thenCompose(connection -> { - CreateSessionRequest request = new CreateSessionRequest(); + var request = new CreateSessionRequest(); if (config != null) { request.setModel(config.getModel()); request.setSessionId(config.getSessionId()); @@ -585,8 +583,7 @@ public CompletableFuture createSession(SessionConfig config) { } return connection.rpc.invoke("session.create", request, CreateSessionResponse.class).thenApply(response -> { - CopilotSession session = new CopilotSession(response.getSessionId(), connection.rpc, - response.getWorkspacePath()); + var session = new CopilotSession(response.getSessionId(), connection.rpc, response.getWorkspacePath()); if (config != null && config.getTools() != null) { session.registerTools(config.getTools()); } @@ -632,7 +629,7 @@ public CompletableFuture createSession() { */ public CompletableFuture resumeSession(String sessionId, ResumeSessionConfig config) { return ensureConnected().thenCompose(connection -> { - ResumeSessionRequest request = new ResumeSessionRequest(); + var request = new ResumeSessionRequest(); request.setSessionId(sessionId); if (config != null) { request.setReasoningEffort(config.getReasoningEffort()); @@ -655,8 +652,7 @@ public CompletableFuture resumeSession(String sessionId, ResumeS } return connection.rpc.invoke("session.resume", request, ResumeSessionResponse.class).thenApply(response -> { - CopilotSession session = new CopilotSession(response.getSessionId(), connection.rpc, - response.getWorkspacePath()); + var session = new CopilotSession(response.getSessionId(), connection.rpc, response.getWorkspacePath()); if (config != null && config.getTools() != null) { session.registerTools(config.getTools()); } @@ -960,7 +956,7 @@ private CompletableFuture ensureConnected() { private ProcessInfo startCliServer() throws IOException, InterruptedException { String cliPath = options.getCliPath() != null ? options.getCliPath() : "copilot"; - List args = new ArrayList<>(); + var args = new ArrayList(); if (options.getCliArgs() != null) { args.addAll(Arrays.asList(options.getCliArgs())); @@ -993,7 +989,7 @@ private ProcessInfo startCliServer() throws IOException, InterruptedException { List command = resolveCliCommand(cliPath, args); - ProcessBuilder pb = new ProcessBuilder(command); + var pb = new ProcessBuilder(command); pb.redirectErrorStream(false); if (options.getCwd() != null) { @@ -1014,7 +1010,7 @@ private ProcessInfo startCliServer() throws IOException, InterruptedException { Process process = pb.start(); // Forward stderr to logger in background - Thread stderrThread = new Thread(() -> { + var stderrThread = new Thread(() -> { try (BufferedReader reader = new BufferedReader( new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) { String line; @@ -1063,7 +1059,7 @@ private List resolveCliCommand(String cliPath, List args) { boolean isJsFile = cliPath.toLowerCase().endsWith(".js"); if (isJsFile) { - List result = new ArrayList<>(); + var result = new ArrayList(); result.add("node"); result.add(cliPath); result.addAll(args); @@ -1073,7 +1069,7 @@ private List resolveCliCommand(String cliPath, List args) { // On Windows, use cmd /c to resolve the executable String os = System.getProperty("os.name").toLowerCase(); if (os.contains("win") && !new File(cliPath).isAbsolute()) { - List result = new ArrayList<>(); + var result = new ArrayList(); result.add("cmd"); result.add("/c"); result.add(cliPath); @@ -1081,7 +1077,7 @@ private List resolveCliCommand(String cliPath, List args) { return result; } - List result = new ArrayList<>(); + var result = new ArrayList(); result.add(cliPath); result.addAll(args); return result; diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 79a3f76a4..8e36bf127 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -13,7 +13,6 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicReference; @@ -196,7 +195,7 @@ public CompletableFuture sendAndWait(String prompt) { * @see #send(String) */ public CompletableFuture send(MessageOptions options) { - SendMessageRequest request = new SendMessageRequest(); + var request = new SendMessageRequest(); request.setSessionId(sessionId); request.setPrompt(options.getPrompt()); request.setAttachments(options.getAttachments()); @@ -225,8 +224,8 @@ public CompletableFuture send(MessageOptions options) { * @see #send(MessageOptions) */ public CompletableFuture sendAndWait(MessageOptions options, long timeoutMs) { - CompletableFuture future = new CompletableFuture<>(); - AtomicReference lastAssistantMessage = new AtomicReference<>(); + var future = new CompletableFuture(); + var lastAssistantMessage = new AtomicReference(); Consumer handler = evt -> { if (evt instanceof AssistantMessageEvent msg) { @@ -252,8 +251,8 @@ public CompletableFuture sendAndWait(MessageOptions optio }); // Set up timeout with daemon thread so it doesn't prevent JVM exit - ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { - Thread t = new Thread(r, "sendAndWait-timeout"); + var scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + var t = new Thread(r, "sendAndWait-timeout"); t.setDaemon(true); return t; }); @@ -444,7 +443,7 @@ CompletableFuture handlePermissionRequest(JsonNode perm try { PermissionRequest request = MAPPER.treeToValue(permissionRequestData, PermissionRequest.class); - PermissionInvocation invocation = new PermissionInvocation(); + var invocation = new PermissionInvocation(); invocation.setSessionId(sessionId); return handler.handle(request, invocation).exceptionally(ex -> { LOG.log(Level.SEVERE, "Permission handler threw an exception", ex); @@ -489,7 +488,7 @@ CompletableFuture handleUserInputRequest(UserInputRequest req } try { - UserInputInvocation invocation = new UserInputInvocation().setSessionId(sessionId); + var invocation = new UserInputInvocation().setSessionId(sessionId); return handler.handle(request, invocation).exceptionally(ex -> { LOG.log(Level.SEVERE, "User input handler threw an exception", ex); throw new RuntimeException("User input handler error", ex); @@ -529,7 +528,7 @@ CompletableFuture handleHooksInvoke(String hookType, JsonNode input) { return CompletableFuture.completedFuture(null); } - HookInvocation invocation = new HookInvocation().setSessionId(sessionId); + var invocation = new HookInvocation().setSessionId(sessionId); try { switch (hookType) { @@ -592,7 +591,7 @@ CompletableFuture handleHooksInvoke(String hookType, JsonNode input) { public CompletableFuture> getMessages() { return rpc.invoke("session.getMessages", Map.of("sessionId", sessionId), GetMessagesResponse.class) .thenApply(response -> { - List events = new ArrayList<>(); + var events = new ArrayList(); if (response.getEvents() != null) { for (JsonNode eventNode : response.getEvents()) { try { diff --git a/src/main/java/com/github/copilot/sdk/JsonRpcClient.java b/src/main/java/com/github/copilot/sdk/JsonRpcClient.java index 21512b1bc..66ab1726d 100644 --- a/src/main/java/com/github/copilot/sdk/JsonRpcClient.java +++ b/src/main/java/com/github/copilot/sdk/JsonRpcClient.java @@ -66,7 +66,7 @@ private JsonRpcClient(InputStream inputStream, OutputStream outputStream, Socket } static ObjectMapper createObjectMapper() { - ObjectMapper mapper = new ObjectMapper(); + var mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); @@ -105,10 +105,10 @@ public void registerMethodHandler(String method, BiConsumer ha */ public CompletableFuture invoke(String method, Object params, Class responseType) { long id = requestIdCounter.incrementAndGet(); - CompletableFuture future = new CompletableFuture<>(); + var future = new CompletableFuture(); pendingRequests.put(id, future); - JsonRpcRequest request = new JsonRpcRequest(); + var request = new JsonRpcRequest(); request.setJsonrpc("2.0"); request.setId(id); request.setMethod(method); @@ -137,7 +137,7 @@ public CompletableFuture invoke(String method, Object params, Class re * Sends a JSON-RPC notification (no response expected). */ public void notify(String method, Object params) throws IOException { - JsonRpcRequest notification = new JsonRpcRequest(); + var notification = new JsonRpcRequest(); notification.setJsonrpc("2.0"); notification.setMethod(method); notification.setParams(params); @@ -148,7 +148,7 @@ public void notify(String method, Object params) throws IOException { * Sends a JSON-RPC response to a server request. */ public void sendResponse(Object id, Object result) throws IOException { - JsonRpcResponse response = new JsonRpcResponse(); + var response = new JsonRpcResponse(); response.setJsonrpc("2.0"); response.setId(id); response.setResult(result); @@ -159,10 +159,10 @@ public void sendResponse(Object id, Object result) throws IOException { * Sends a JSON-RPC error response to a server request. */ public void sendErrorResponse(Object id, int code, String message) throws IOException { - JsonRpcResponse response = new JsonRpcResponse(); + var response = new JsonRpcResponse(); response.setJsonrpc("2.0"); response.setId(id); - JsonRpcError error = new JsonRpcError(); + var error = new JsonRpcError(); error.setCode(code); error.setMessage(message); response.setError(error); @@ -186,12 +186,12 @@ private void startReader() { try { // We need to read bytes because Content-Length specifies bytes, not characters. // Using BufferedReader would cause issues with multi-byte UTF-8 characters. - BufferedInputStream bis = new BufferedInputStream(inputStream); + var bis = new BufferedInputStream(inputStream); while (running) { // Read headers line by line int contentLength = -1; - StringBuilder headerLine = new StringBuilder(); + var headerLine = new StringBuilder(); boolean lastWasCR = false; boolean inHeaders = true; diff --git a/src/test/java/com/github/copilot/sdk/AskUserTest.java b/src/test/java/com/github/copilot/sdk/AskUserTest.java index a0497278b..5d45e3b2a 100644 --- a/src/test/java/com/github/copilot/sdk/AskUserTest.java +++ b/src/test/java/com/github/copilot/sdk/AskUserTest.java @@ -7,7 +7,6 @@ import static org.junit.jupiter.api.Assertions.*; import java.util.ArrayList; -import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; @@ -54,10 +53,10 @@ static void teardown() throws Exception { void testShouldInvokeUserInputHandlerWhenModelUsesAskUserTool() throws Exception { ctx.configureForTest("ask_user", "should_invoke_user_input_handler_when_model_uses_ask_user_tool"); - List userInputRequests = new ArrayList<>(); + var userInputRequests = new ArrayList(); final String[] sessionIdHolder = new String[1]; - SessionConfig config = new SessionConfig().setOnUserInputRequest((request, invocation) -> { + var config = new SessionConfig().setOnUserInputRequest((request, invocation) -> { userInputRequests.add(request); assertEquals(sessionIdHolder[0], invocation.getSessionId()); @@ -97,9 +96,9 @@ void testShouldInvokeUserInputHandlerWhenModelUsesAskUserTool() throws Exception void testShouldReceiveChoicesInUserInputRequest() throws Exception { ctx.configureForTest("ask_user", "should_receive_choices_in_user_input_request"); - List userInputRequests = new ArrayList<>(); + var userInputRequests = new ArrayList(); - SessionConfig config = new SessionConfig().setOnUserInputRequest((request, invocation) -> { + var config = new SessionConfig().setOnUserInputRequest((request, invocation) -> { userInputRequests.add(request); // Pick the first choice @@ -135,10 +134,10 @@ void testShouldReceiveChoicesInUserInputRequest() throws Exception { void testShouldHandleFreeformUserInputResponse() throws Exception { ctx.configureForTest("ask_user", "should_handle_freeform_user_input_response"); - final List userInputRequests = new ArrayList<>(); + final var userInputRequests = new ArrayList(); String freeformAnswer = "This is my custom freeform answer that was not in the choices"; - SessionConfig config = new SessionConfig().setOnUserInputRequest((request, invocation) -> { + var config = new SessionConfig().setOnUserInputRequest((request, invocation) -> { userInputRequests.add(request); // Return a freeform answer (not from choices) diff --git a/src/test/java/com/github/copilot/sdk/CapiProxy.java b/src/test/java/com/github/copilot/sdk/CapiProxy.java index ecfdaceee..1a7df2d6c 100644 --- a/src/test/java/com/github/copilot/sdk/CapiProxy.java +++ b/src/test/java/com/github/copilot/sdk/CapiProxy.java @@ -89,7 +89,7 @@ public String start() throws IOException, InterruptedException { } // Start the harness server using npx tsx - ProcessBuilder pb = new ProcessBuilder("npx", "tsx", "server.ts"); + var pb = new ProcessBuilder("npx", "tsx", "server.ts"); pb.directory(harnessDir.toFile()); pb.redirectErrorStream(false); diff --git a/src/test/java/com/github/copilot/sdk/CompactionTest.java b/src/test/java/com/github/copilot/sdk/CompactionTest.java index efd4ea813..fc95d53f3 100644 --- a/src/test/java/com/github/copilot/sdk/CompactionTest.java +++ b/src/test/java/com/github/copilot/sdk/CompactionTest.java @@ -7,7 +7,6 @@ import static org.junit.jupiter.api.Assertions.*; import java.util.ArrayList; -import java.util.List; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.AfterAll; @@ -61,15 +60,15 @@ void testShouldTriggerCompactionWithLowThresholdAndEmitEvents() throws Exception // Create session with very low compaction thresholds to trigger compaction // quickly - InfiniteSessionConfig infiniteConfig = new InfiniteSessionConfig().setEnabled(true) + var infiniteConfig = new InfiniteSessionConfig().setEnabled(true) // Trigger background compaction at 0.5% context usage (~1000 tokens) .setBackgroundCompactionThreshold(0.005) // Block at 1% to ensure compaction runs .setBufferExhaustionThreshold(0.01); - SessionConfig config = new SessionConfig().setInfiniteSessions(infiniteConfig); + var config = new SessionConfig().setInfiniteSessions(infiniteConfig); - List events = new ArrayList<>(); + var events = new ArrayList(); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession(config).get(); @@ -131,11 +130,11 @@ void testShouldTriggerCompactionWithLowThresholdAndEmitEvents() throws Exception void testShouldNotEmitCompactionEventsWhenInfiniteSessionsDisabled() throws Exception { ctx.configureForTest("compaction", "should_not_emit_compaction_events_when_infinite_sessions_disabled"); - InfiniteSessionConfig infiniteConfig = new InfiniteSessionConfig().setEnabled(false); + var infiniteConfig = new InfiniteSessionConfig().setEnabled(false); - SessionConfig config = new SessionConfig().setInfiniteSessions(infiniteConfig); + var config = new SessionConfig().setInfiniteSessions(infiniteConfig); - List compactionEvents = new ArrayList<>(); + var compactionEvents = new ArrayList(); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession(config).get(); diff --git a/src/test/java/com/github/copilot/sdk/CopilotClientTest.java b/src/test/java/com/github/copilot/sdk/CopilotClientTest.java index fa17be004..d3ece944f 100644 --- a/src/test/java/com/github/copilot/sdk/CopilotClientTest.java +++ b/src/test/java/com/github/copilot/sdk/CopilotClientTest.java @@ -63,7 +63,7 @@ private static String findCopilotInPath() { try { // Use 'where' on Windows, 'which' on Unix-like systems String command = System.getProperty("os.name").toLowerCase().contains("win") ? "where" : "which"; - ProcessBuilder pb = new ProcessBuilder(command, "copilot"); + var pb = new ProcessBuilder(command, "copilot"); pb.redirectErrorStream(true); Process process = pb.start(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { @@ -81,31 +81,30 @@ private static String findCopilotInPath() { @Test void testClientConstruction() { - CopilotClient client = new CopilotClient(); + var client = new CopilotClient(); assertEquals(ConnectionState.DISCONNECTED, client.getState()); client.close(); } @Test void testClientConstructionWithOptions() { - CopilotClientOptions options = new CopilotClientOptions().setCliPath("/path/to/cli").setLogLevel("debug") - .setAutoStart(false); + var options = new CopilotClientOptions().setCliPath("/path/to/cli").setLogLevel("debug").setAutoStart(false); - CopilotClient client = new CopilotClient(options); + var client = new CopilotClient(options); assertEquals(ConnectionState.DISCONNECTED, client.getState()); client.close(); } @Test void testCliUrlMutualExclusion() { - CopilotClientOptions options = new CopilotClientOptions().setCliUrl("localhost:3000").setUseStdio(true); + var options = new CopilotClientOptions().setCliUrl("localhost:3000").setUseStdio(true); assertThrows(IllegalArgumentException.class, () -> new CopilotClient(options)); } @Test void testCliUrlMutualExclusionWithCliPath() { - CopilotClientOptions options = new CopilotClientOptions().setCliUrl("localhost:3000").setCliPath("/path/to/cli") + var options = new CopilotClientOptions().setCliUrl("localhost:3000").setCliPath("/path/to/cli") .setUseStdio(false); assertThrows(IllegalArgumentException.class, () -> new CopilotClient(options)); @@ -166,45 +165,44 @@ void testForceStopWithoutCleanup() throws Exception { @Test void testGithubTokenOptionAccepted() { - CopilotClientOptions options = new CopilotClientOptions().setCliPath("/path/to/cli") - .setGithubToken("gho_test_token"); + var options = new CopilotClientOptions().setCliPath("/path/to/cli").setGithubToken("gho_test_token"); assertEquals("gho_test_token", options.getGithubToken()); } @Test void testUseLoggedInUserDefaultsToNull() { - CopilotClientOptions options = new CopilotClientOptions().setCliPath("/path/to/cli"); + var options = new CopilotClientOptions().setCliPath("/path/to/cli"); assertNull(options.getUseLoggedInUser()); } @Test void testExplicitUseLoggedInUserFalse() { - CopilotClientOptions options = new CopilotClientOptions().setCliPath("/path/to/cli").setUseLoggedInUser(false); + var options = new CopilotClientOptions().setCliPath("/path/to/cli").setUseLoggedInUser(false); assertEquals(false, options.getUseLoggedInUser()); } @Test void testExplicitUseLoggedInUserTrueWithGithubToken() { - CopilotClientOptions options = new CopilotClientOptions().setCliPath("/path/to/cli") - .setGithubToken("gho_test_token").setUseLoggedInUser(true); + var options = new CopilotClientOptions().setCliPath("/path/to/cli").setGithubToken("gho_test_token") + .setUseLoggedInUser(true); assertEquals(true, options.getUseLoggedInUser()); } @Test void testGithubTokenWithCliUrlThrows() { - CopilotClientOptions options = new CopilotClientOptions().setCliUrl("localhost:8080") - .setGithubToken("gho_test_token").setUseStdio(false); + var options = new CopilotClientOptions().setCliUrl("localhost:8080").setGithubToken("gho_test_token") + .setUseStdio(false); assertThrows(IllegalArgumentException.class, () -> new CopilotClient(options)); } @Test void testUseLoggedInUserWithCliUrlThrows() { - CopilotClientOptions options = new CopilotClientOptions().setCliUrl("localhost:8080").setUseLoggedInUser(false) + var options = new CopilotClientOptions().setCliUrl("localhost:8080").setUseLoggedInUser(false) .setUseStdio(false); assertThrows(IllegalArgumentException.class, () -> new CopilotClient(options)); diff --git a/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java b/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java index c50673ae1..398e6f2f8 100644 --- a/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java +++ b/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java @@ -178,9 +178,9 @@ void testSendReturnsImmediatelyWhileEventsStreamInBackground() throws Exception try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession().get(); - List events = new ArrayList<>(); - AtomicReference lastMessage = new AtomicReference<>(); - CompletableFuture done = new CompletableFuture<>(); + var events = new ArrayList(); + var lastMessage = new AtomicReference(); + var done = new CompletableFuture(); session.on(evt -> { events.add(evt.getType()); @@ -224,7 +224,7 @@ void testSendAndWaitBlocksUntilSessionIdleAndReturnsFinalAssistantMessage() thro try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession().get(); - List events = new ArrayList<>(); + var events = new ArrayList(); session.on(evt -> events.add(evt.getType())); AssistantMessageEvent response = session.sendAndWait(new MessageOptions().setPrompt("What is 2+2?")).get(60, @@ -391,8 +391,8 @@ void testShouldReceiveStreamingDeltaEventsWhenStreamingIsEnabled() throws Except CopilotSession session = client.createSession(config).get(); - List receivedEvents = new ArrayList<>(); - CompletableFuture idleReceived = new CompletableFuture<>(); + var receivedEvents = new ArrayList(); + var idleReceived = new CompletableFuture(); session.on(evt -> { receivedEvents.add(evt); @@ -427,8 +427,8 @@ void testShouldAbortSession() throws Exception { assertNotNull(session.getSessionId()); // Set up wait for tool execution to start BEFORE sending - CompletableFuture toolStartFuture = new CompletableFuture<>(); - CompletableFuture sessionIdleFuture = new CompletableFuture<>(); + var toolStartFuture = new CompletableFuture(); + var sessionIdleFuture = new CompletableFuture(); session.on(evt -> { if (evt instanceof ToolExecutionStartEvent toolStart && !toolStartFuture.isDone()) { diff --git a/src/test/java/com/github/copilot/sdk/ErrorHandlingTest.java b/src/test/java/com/github/copilot/sdk/ErrorHandlingTest.java index 5d7322d53..8b9df4039 100644 --- a/src/test/java/com/github/copilot/sdk/ErrorHandlingTest.java +++ b/src/test/java/com/github/copilot/sdk/ErrorHandlingTest.java @@ -59,7 +59,7 @@ void testHandlesToolCallingErrors_toolErrorDoesNotCrashSession() throws Exceptio LOG.info("Running test: testHandlesToolCallingErrors_toolErrorDoesNotCrashSession"); ctx.configureForTest("tools", "handles_tool_calling_errors"); - List allEvents = new ArrayList<>(); + var allEvents = new ArrayList(); ToolDefinition errorTool = ToolDefinition.create("get_user_location", "Gets the user's location", Map.of("type", "object", "properties", Map.of()), (invocation) -> { @@ -130,9 +130,9 @@ void testShouldHandlePermissionHandlerErrorsGracefully_deniesPermission() throws LOG.info("Running test: testShouldHandlePermissionHandlerErrorsGracefully_deniesPermission"); ctx.configureForTest("permissions", "should_handle_permission_handler_errors_gracefully"); - List errorEvents = new ArrayList<>(); + var errorEvents = new ArrayList(); - SessionConfig config = new SessionConfig().setOnPermissionRequest((request, invocation) -> { + var config = new SessionConfig().setOnPermissionRequest((request, invocation) -> { throw new RuntimeException("Permission handler crashed"); }); @@ -169,9 +169,9 @@ void testPermissionHandlerErrors_sessionErrorEventContainsDetails() throws Excep LOG.info("Running test: testPermissionHandlerErrors_sessionErrorEventContainsDetails"); ctx.configureForTest("permissions", "permission_handler_errors"); - List errorEvents = new ArrayList<>(); + var errorEvents = new ArrayList(); - SessionConfig config = new SessionConfig().setOnPermissionRequest((request, invocation) -> { + var config = new SessionConfig().setOnPermissionRequest((request, invocation) -> { throw new RuntimeException("Test error message"); }); diff --git a/src/test/java/com/github/copilot/sdk/HooksTest.java b/src/test/java/com/github/copilot/sdk/HooksTest.java index 8d8075e20..af440632d 100644 --- a/src/test/java/com/github/copilot/sdk/HooksTest.java +++ b/src/test/java/com/github/copilot/sdk/HooksTest.java @@ -9,7 +9,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; -import java.util.List; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; @@ -65,10 +64,10 @@ static void teardown() throws Exception { void testInvokePreToolUseHookWhenModelRunsATool() throws Exception { ctx.configureForTest("hooks", "invoke_pre_tool_use_hook_when_model_runs_a_tool"); - List preToolUseInputs = new ArrayList<>(); + var preToolUseInputs = new ArrayList(); final String[] sessionIdHolder = new String[1]; - SessionConfig config = new SessionConfig().setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> { + var config = new SessionConfig().setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> { preToolUseInputs.add(input); assertEquals(sessionIdHolder[0], invocation.getSessionId()); return CompletableFuture.completedFuture(new PreToolUseHookOutput().setPermissionDecision("allow")); @@ -104,10 +103,10 @@ void testInvokePreToolUseHookWhenModelRunsATool() throws Exception { void testInvokePostToolUseHookAfterModelRunsATool() throws Exception { ctx.configureForTest("hooks", "invoke_post_tool_use_hook_after_model_runs_a_tool"); - List postToolUseInputs = new ArrayList<>(); + var postToolUseInputs = new ArrayList(); final String[] sessionIdHolder = new String[1]; - SessionConfig config = new SessionConfig().setHooks(new SessionHooks().setOnPostToolUse((input, invocation) -> { + var config = new SessionConfig().setHooks(new SessionHooks().setOnPostToolUse((input, invocation) -> { postToolUseInputs.add(input); assertEquals(sessionIdHolder[0], invocation.getSessionId()); return CompletableFuture.completedFuture(null); @@ -145,10 +144,10 @@ void testInvokePostToolUseHookAfterModelRunsATool() throws Exception { void testInvokeBothHooksForSingleToolCall() throws Exception { ctx.configureForTest("hooks", "invoke_both_hooks_for_single_tool_call"); - List preToolUseInputs = new ArrayList<>(); - List postToolUseInputs = new ArrayList<>(); + var preToolUseInputs = new ArrayList(); + var postToolUseInputs = new ArrayList(); - SessionConfig config = new SessionConfig().setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> { + var config = new SessionConfig().setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> { preToolUseInputs.add(input); return CompletableFuture.completedFuture(new PreToolUseHookOutput().setPermissionDecision("allow")); }).setOnPostToolUse((input, invocation) -> { @@ -191,9 +190,9 @@ void testInvokeBothHooksForSingleToolCall() throws Exception { void testDenyToolExecutionWhenPreToolUseReturnsDeny() throws Exception { ctx.configureForTest("hooks", "deny_tool_execution_when_pre_tool_use_returns_deny"); - List preToolUseInputs = new ArrayList<>(); + var preToolUseInputs = new ArrayList(); - SessionConfig config = new SessionConfig().setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> { + var config = new SessionConfig().setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> { preToolUseInputs.add(input); // Deny all tool calls return CompletableFuture.completedFuture(new PreToolUseHookOutput().setPermissionDecision("deny")); diff --git a/src/test/java/com/github/copilot/sdk/McpAndAgentsTest.java b/src/test/java/com/github/copilot/sdk/McpAndAgentsTest.java index 9269dbc97..275cc48d6 100644 --- a/src/test/java/com/github/copilot/sdk/McpAndAgentsTest.java +++ b/src/test/java/com/github/copilot/sdk/McpAndAgentsTest.java @@ -47,7 +47,7 @@ static void teardown() throws Exception { // Helper method to create an MCP local server configuration private Map createLocalMcpServer(String command, List args) { - Map server = new HashMap<>(); + var server = new HashMap(); server.put("type", "local"); server.put("command", command); server.put("args", args); @@ -67,7 +67,7 @@ private Map createLocalMcpServer(String command, List ar void testShouldAcceptMcpServerConfigurationOnSessionCreate() throws Exception { ctx.configureForTest("mcp_and_agents", "should_accept_mcp_server_configuration_on_session_create"); - Map mcpServers = new HashMap<>(); + var mcpServers = new HashMap(); mcpServers.put("test-server", createLocalMcpServer("echo", List.of("hello"))); try (CopilotClient client = ctx.createClient()) { @@ -104,7 +104,7 @@ void testShouldAcceptMcpServerConfigurationOnSessionResume() throws Exception { session1.sendAndWait(new MessageOptions().setPrompt("What is 1+1?")).get(60, TimeUnit.SECONDS); // Resume with MCP servers - Map mcpServers = new HashMap<>(); + var mcpServers = new HashMap(); mcpServers.put("test-server", createLocalMcpServer("echo", List.of("hello"))); CopilotSession session2 = client @@ -135,7 +135,7 @@ void testShouldHandleMultipleMcpServers() throws Exception { // count ctx.configureForTest("mcp_and_agents", "should_accept_mcp_server_configuration_on_session_create"); - Map mcpServers = new HashMap<>(); + var mcpServers = new HashMap(); mcpServers.put("server1", createLocalMcpServer("echo", List.of("server1"))); mcpServers.put("server2", createLocalMcpServer("echo", List.of("server2"))); @@ -252,7 +252,7 @@ void testShouldAcceptCustomAgentWithMcpServers() throws Exception { // Use combined snapshot since this uses both MCP servers and custom agents ctx.configureForTest("mcp_and_agents", "should_accept_both_mcp_servers_and_custom_agents"); - Map agentMcpServers = new HashMap<>(); + var agentMcpServers = new HashMap(); agentMcpServers.put("agent-server", createLocalMcpServer("echo", List.of("agent-mcp"))); List customAgents = List.of(new CustomAgentConfig().setName("mcp-agent") @@ -304,7 +304,7 @@ void testShouldAcceptMultipleCustomAgents() throws Exception { void testShouldAcceptBothMcpServersAndCustomAgents() throws Exception { ctx.configureForTest("mcp_and_agents", "should_accept_both_mcp_servers_and_custom_agents"); - Map mcpServers = new HashMap<>(); + var mcpServers = new HashMap(); mcpServers.put("shared-server", createLocalMcpServer("echo", List.of("shared"))); List customAgents = List.of(new CustomAgentConfig().setName("combined-agent") diff --git a/src/test/java/com/github/copilot/sdk/MetadataApiTest.java b/src/test/java/com/github/copilot/sdk/MetadataApiTest.java index 2d83b4ec5..a10b54a3c 100644 --- a/src/test/java/com/github/copilot/sdk/MetadataApiTest.java +++ b/src/test/java/com/github/copilot/sdk/MetadataApiTest.java @@ -62,7 +62,7 @@ private static String getCliPath() { private static String findCopilotInPath() { try { String command = System.getProperty("os.name").toLowerCase().contains("win") ? "where" : "which"; - ProcessBuilder pb = new ProcessBuilder(command, "copilot"); + var pb = new ProcessBuilder(command, "copilot"); pb.redirectErrorStream(true); Process process = pb.start(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { diff --git a/src/test/java/com/github/copilot/sdk/PermissionsTest.java b/src/test/java/com/github/copilot/sdk/PermissionsTest.java index d71025627..ba68de0e2 100644 --- a/src/test/java/com/github/copilot/sdk/PermissionsTest.java +++ b/src/test/java/com/github/copilot/sdk/PermissionsTest.java @@ -9,7 +9,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; -import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; @@ -58,11 +57,11 @@ static void teardown() throws Exception { void testPermissionHandlerForWriteOperations(TestInfo testInfo) throws Exception { ctx.configureForTest("permissions", "permission_handler_for_write_operations"); - List permissionRequests = new ArrayList<>(); + var permissionRequests = new ArrayList(); final String[] sessionIdHolder = new String[1]; - SessionConfig config = new SessionConfig().setOnPermissionRequest((request, invocation) -> { + var config = new SessionConfig().setOnPermissionRequest((request, invocation) -> { permissionRequests.add(request); assertEquals(sessionIdHolder[0], invocation.getSessionId()); // Approve the permission @@ -100,7 +99,7 @@ void testPermissionHandlerForWriteOperations(TestInfo testInfo) throws Exception void testDenyPermission(TestInfo testInfo) throws Exception { ctx.configureForTest("permissions", "deny_permission"); - SessionConfig config = new SessionConfig().setOnPermissionRequest((request, invocation) -> { + var config = new SessionConfig().setOnPermissionRequest((request, invocation) -> { // Deny all permissions return CompletableFuture .completedFuture(new PermissionRequestResult().setKind("denied-interactively-by-user")); @@ -158,9 +157,9 @@ void testWithoutPermissionHandler(TestInfo testInfo) throws Exception { void testAsyncPermissionHandler(TestInfo testInfo) throws Exception { ctx.configureForTest("permissions", "async_permission_handler"); - List permissionRequests = new ArrayList<>(); + var permissionRequests = new ArrayList(); - SessionConfig config = new SessionConfig().setOnPermissionRequest((request, invocation) -> { + var config = new SessionConfig().setOnPermissionRequest((request, invocation) -> { permissionRequests.add(request); // Simulate async permission check with delay @@ -196,7 +195,7 @@ void testAsyncPermissionHandler(TestInfo testInfo) throws Exception { void testResumeSessionWithPermissionHandler(TestInfo testInfo) throws Exception { ctx.configureForTest("permissions", "resume_session_with_permission_handler"); - List permissionRequests = new ArrayList<>(); + var permissionRequests = new ArrayList(); try (CopilotClient client = ctx.createClient()) { // Create session without permission handler @@ -205,11 +204,10 @@ void testResumeSessionWithPermissionHandler(TestInfo testInfo) throws Exception session1.sendAndWait(new MessageOptions().setPrompt("What is 1+1?")).get(60, TimeUnit.SECONDS); // Resume with permission handler - ResumeSessionConfig resumeConfig = new ResumeSessionConfig() - .setOnPermissionRequest((request, invocation) -> { - permissionRequests.add(request); - return CompletableFuture.completedFuture(new PermissionRequestResult().setKind("approved")); - }); + var resumeConfig = new ResumeSessionConfig().setOnPermissionRequest((request, invocation) -> { + permissionRequests.add(request); + return CompletableFuture.completedFuture(new PermissionRequestResult().setKind("approved")); + }); CopilotSession session2 = client.resumeSession(sessionId, resumeConfig).get(); @@ -235,7 +233,7 @@ void testToolCallIdInPermissionRequests(TestInfo testInfo) throws Exception { final boolean[] receivedToolCallId = {false}; - SessionConfig config = new SessionConfig().setOnPermissionRequest((request, invocation) -> { + var config = new SessionConfig().setOnPermissionRequest((request, invocation) -> { if (request.getToolCallId() != null) { receivedToolCallId[0] = true; assertFalse(request.getToolCallId().isEmpty(), "Tool call ID should not be empty"); @@ -267,7 +265,7 @@ void testToolCallIdInPermissionRequests(TestInfo testInfo) throws Exception { void testShouldHandlePermissionHandlerErrorsGracefully(TestInfo testInfo) throws Exception { ctx.configureForTest("permissions", "should_handle_permission_handler_errors_gracefully"); - SessionConfig config = new SessionConfig().setOnPermissionRequest((request, invocation) -> { + var config = new SessionConfig().setOnPermissionRequest((request, invocation) -> { // Throw an error in the handler throw new RuntimeException("Handler error"); }); diff --git a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java index bff0987c8..961f800de 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java @@ -9,7 +9,6 @@ import java.io.Closeable; import java.lang.reflect.Method; import java.util.ArrayList; -import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; @@ -49,7 +48,7 @@ private CopilotSession createTestSession() throws Exception { @Test void testGenericEventHandler() { - List receivedEvents = new ArrayList<>(); + var receivedEvents = new ArrayList(); session.on(event -> receivedEvents.add(event)); @@ -66,7 +65,7 @@ void testGenericEventHandler() { @Test void testTypedEventHandler() { - List receivedMessages = new ArrayList<>(); + var receivedMessages = new ArrayList(); session.on(AssistantMessageEvent.class, msg -> receivedMessages.add(msg)); @@ -84,9 +83,9 @@ void testTypedEventHandler() { @Test void testMultipleTypedHandlers() { - List messages = new ArrayList<>(); - List idles = new ArrayList<>(); - List starts = new ArrayList<>(); + var messages = new ArrayList(); + var idles = new ArrayList(); + var starts = new ArrayList(); session.on(AssistantMessageEvent.class, messages::add); session.on(SessionIdleEvent.class, idles::add); @@ -104,7 +103,7 @@ void testMultipleTypedHandlers() { @Test void testUnsubscribe() { - AtomicInteger count = new AtomicInteger(0); + var count = new AtomicInteger(0); Closeable subscription = session.on(AssistantMessageEvent.class, msg -> count.incrementAndGet()); @@ -125,7 +124,7 @@ void testUnsubscribe() { @Test void testUnsubscribeGenericHandler() { - AtomicInteger count = new AtomicInteger(0); + var count = new AtomicInteger(0); Closeable subscription = session.on(event -> count.incrementAndGet()); @@ -144,8 +143,8 @@ void testUnsubscribeGenericHandler() { @Test void testMixedHandlers() { - List allEvents = new ArrayList<>(); - List messageEvents = new ArrayList<>(); + var allEvents = new ArrayList(); + var messageEvents = new ArrayList(); // Generic handler captures everything session.on(event -> allEvents.add(event.getType())); @@ -164,8 +163,8 @@ void testMixedHandlers() { @Test void testHandlerReceivesCorrectEventData() { - AtomicReference capturedContent = new AtomicReference<>(); - AtomicReference capturedSessionId = new AtomicReference<>(); + var capturedContent = new AtomicReference(); + var capturedSessionId = new AtomicReference(); session.on(AssistantMessageEvent.class, msg -> { capturedContent.set(msg.getData().getContent()); @@ -188,7 +187,7 @@ void testHandlerReceivesCorrectEventData() { @Test void testHandlerExceptionDoesNotBreakOtherHandlers() { - List handler2Events = new ArrayList<>(); + var handler2Events = new ArrayList(); // Suppress logging for this test to avoid confusing stack traces in build // output @@ -241,16 +240,16 @@ private void dispatchEvent(AbstractSessionEvent event) { // Factory methods for creating test events private SessionStartEvent createSessionStartEvent() { - SessionStartEvent event = new SessionStartEvent(); - SessionStartEvent.SessionStartData data = new SessionStartEvent.SessionStartData(); + var event = new SessionStartEvent(); + var data = new SessionStartEvent.SessionStartData(); data.setSessionId("test-session"); event.setData(data); return event; } private AssistantMessageEvent createAssistantMessageEvent(String content) { - AssistantMessageEvent event = new AssistantMessageEvent(); - AssistantMessageEvent.AssistantMessageData data = new AssistantMessageEvent.AssistantMessageData(); + var event = new AssistantMessageEvent(); + var data = new AssistantMessageEvent.AssistantMessageData(); data.setContent(content); event.setData(data); return event; diff --git a/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java b/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java index cdd5a95ff..e891c03d8 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java @@ -43,7 +43,7 @@ void testParseSessionStartEvent() { assertInstanceOf(SessionStartEvent.class, event); assertEquals("session.start", event.getType()); - SessionStartEvent startEvent = (SessionStartEvent) event; + var startEvent = (SessionStartEvent) event; assertEquals("sess-123", startEvent.getData().getSessionId()); } @@ -82,7 +82,7 @@ void testParseSessionErrorEvent() { assertInstanceOf(SessionErrorEvent.class, event); assertEquals("session.error", event.getType()); - SessionErrorEvent errorEvent = (SessionErrorEvent) event; + var errorEvent = (SessionErrorEvent) event; assertEquals("RateLimitError", errorEvent.getData().getErrorType()); assertEquals("Rate limit exceeded", errorEvent.getData().getMessage()); assertNotNull(errorEvent.getData().getStack()); @@ -120,7 +120,7 @@ void testParseSessionInfoEvent() { assertInstanceOf(SessionInfoEvent.class, event); assertEquals("session.info", event.getType()); - SessionInfoEvent infoEvent = (SessionInfoEvent) event; + var infoEvent = (SessionInfoEvent) event; assertEquals("status", infoEvent.getData().getInfoType()); assertEquals("Processing request", infoEvent.getData().getMessage()); } @@ -300,7 +300,7 @@ void testParseAssistantTurnStartEvent() { assertInstanceOf(AssistantTurnStartEvent.class, event); assertEquals("assistant.turn_start", event.getType()); - AssistantTurnStartEvent turnEvent = (AssistantTurnStartEvent) event; + var turnEvent = (AssistantTurnStartEvent) event; assertEquals("turn-123", turnEvent.getData().getTurnId()); } @@ -338,7 +338,7 @@ void testParseAssistantReasoningEvent() { assertInstanceOf(AssistantReasoningEvent.class, event); assertEquals("assistant.reasoning", event.getType()); - AssistantReasoningEvent reasoningEvent = (AssistantReasoningEvent) event; + var reasoningEvent = (AssistantReasoningEvent) event; assertEquals("reason-123", reasoningEvent.getData().getReasoningId()); assertEquals("Analyzing the code structure...", reasoningEvent.getData().getContent()); } @@ -378,7 +378,7 @@ void testParseAssistantMessageEvent() { assertInstanceOf(AssistantMessageEvent.class, event); assertEquals("assistant.message", event.getType()); - AssistantMessageEvent msgEvent = (AssistantMessageEvent) event; + var msgEvent = (AssistantMessageEvent) event; assertEquals("Here is the code you requested.", msgEvent.getData().getContent()); } @@ -533,7 +533,7 @@ void testParseToolExecutionCompleteEvent() { assertInstanceOf(ToolExecutionCompleteEvent.class, event); assertEquals("tool.execution_complete", event.getType()); - ToolExecutionCompleteEvent completeEvent = (ToolExecutionCompleteEvent) event; + var completeEvent = (ToolExecutionCompleteEvent) event; assertTrue(completeEvent.getData().isSuccess()); } @@ -560,7 +560,7 @@ void testParseSubagentStartedEvent() { assertInstanceOf(SubagentStartedEvent.class, event); assertEquals("subagent.started", event.getType()); - SubagentStartedEvent startedEvent = (SubagentStartedEvent) event; + var startedEvent = (SubagentStartedEvent) event; assertEquals("code-review", startedEvent.getData().getAgentName()); assertEquals("Code Review Agent", startedEvent.getData().getAgentDisplayName()); } @@ -640,7 +640,7 @@ void testParseHookStartEvent() { assertInstanceOf(HookStartEvent.class, event); assertEquals("hook.start", event.getType()); - HookStartEvent hookEvent = (HookStartEvent) event; + var hookEvent = (HookStartEvent) event; assertEquals("hook-123", hookEvent.getData().getHookInvocationId()); assertEquals("preToolUse", hookEvent.getData().getHookType()); } @@ -727,7 +727,7 @@ void testParseSessionShutdownEvent() { assertInstanceOf(SessionShutdownEvent.class, event); assertEquals("session.shutdown", event.getType()); - SessionShutdownEvent shutdownEvent = (SessionShutdownEvent) event; + var shutdownEvent = (SessionShutdownEvent) event; assertEquals(SessionShutdownEvent.ShutdownType.ROUTINE, shutdownEvent.getData().getShutdownType()); assertEquals(5, shutdownEvent.getData().getTotalPremiumRequests()); assertEquals("gpt-4", shutdownEvent.getData().getCurrentModel()); @@ -754,7 +754,7 @@ void testParseSkillInvokedEvent() { assertInstanceOf(SkillInvokedEvent.class, event); assertEquals("skill.invoked", event.getType()); - SkillInvokedEvent skillEvent = (SkillInvokedEvent) event; + var skillEvent = (SkillInvokedEvent) event; assertEquals("code-review", skillEvent.getData().getName()); assertEquals("/path/to/skill", skillEvent.getData().getPath()); assertEquals("Skill instructions here", skillEvent.getData().getContent()); diff --git a/src/test/java/com/github/copilot/sdk/SessionEventsE2ETest.java b/src/test/java/com/github/copilot/sdk/SessionEventsE2ETest.java index 0317ef3df..1dfb4e236 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventsE2ETest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventsE2ETest.java @@ -10,7 +10,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; -import java.util.List; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.AfterAll; @@ -61,7 +60,7 @@ void testShouldReceiveSessionEvents_assistantTurnEvents() throws Exception { // Use existing session snapshot that emits turn events ctx.configureForTest("session", "should_receive_session_events"); - List allEvents = new ArrayList<>(); + var allEvents = new ArrayList(); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession().get(); @@ -102,7 +101,7 @@ void testShouldReceiveSessionEvents_userMessageEvent() throws Exception { // Use existing session snapshot ctx.configureForTest("session", "should_receive_session_events"); - List userMessages = new ArrayList<>(); + var userMessages = new ArrayList(); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession().get(); @@ -127,8 +126,8 @@ void testInvokesBuiltInTools_toolExecutionCompleteEvent() throws Exception { // Use existing tools snapshot for built-in tool invocation ctx.configureForTest("tools", "invokes_built_in_tools"); - List toolStarts = new ArrayList<>(); - List toolCompletes = new ArrayList<>(); + var toolStarts = new ArrayList(); + var toolCompletes = new ArrayList(); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession().get(); @@ -166,7 +165,7 @@ void testShouldReceiveSessionEvents_assistantUsageEvent() throws Exception { // Use existing session snapshot ctx.configureForTest("session", "should_receive_session_events"); - List usageEvents = new ArrayList<>(); + var usageEvents = new ArrayList(); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession().get(); @@ -192,7 +191,7 @@ void testShouldReceiveSessionEvents_sessionIdleAfterMessage() throws Exception { // Use existing session snapshot ctx.configureForTest("session", "should_receive_session_events"); - List allEvents = new ArrayList<>(); + var allEvents = new ArrayList(); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession().get(); @@ -233,7 +232,7 @@ void testInvokesBuiltInTools_eventOrderDuringToolExecution() throws Exception { // Use existing tools snapshot for built-in tool invocation ctx.configureForTest("tools", "invokes_built_in_tools"); - List eventTypes = new ArrayList<>(); + var eventTypes = new ArrayList(); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession().get(); diff --git a/src/test/java/com/github/copilot/sdk/ToolsTest.java b/src/test/java/com/github/copilot/sdk/ToolsTest.java index b3f2a1d66..bee3e3037 100644 --- a/src/test/java/com/github/copilot/sdk/ToolsTest.java +++ b/src/test/java/com/github/copilot/sdk/ToolsTest.java @@ -87,9 +87,9 @@ void testInvokesCustomTool(TestInfo testInfo) throws Exception { ctx.configureForTest("tools", "invokes_custom_tool"); // Define a simple encrypt_string tool - Map parameters = new HashMap<>(); - Map properties = new HashMap<>(); - Map inputProp = new HashMap<>(); + var parameters = new HashMap(); + var properties = new HashMap(); + var inputProp = new HashMap(); inputProp.put("type", "string"); inputProp.put("description", "String to encrypt"); properties.put("input", inputProp); @@ -129,7 +129,7 @@ void testHandlesToolCallingErrors(TestInfo testInfo) throws Exception { ctx.configureForTest("tools", "handles_tool_calling_errors"); // Define a tool that throws an error - Map parameters = new HashMap<>(); + var parameters = new HashMap(); parameters.put("type", "object"); parameters.put("properties", new HashMap<>()); @@ -169,8 +169,8 @@ void testCanReceiveAndReturnComplexTypes(TestInfo testInfo) throws Exception { ctx.configureForTest("tools", "can_receive_and_return_complex_types"); // Define a db_query tool with complex parameter and return types - Map querySchema = new HashMap<>(); - Map queryProps = new HashMap<>(); + var querySchema = new HashMap(); + var queryProps = new HashMap(); queryProps.put("table", Map.of("type", "string")); queryProps.put("ids", Map.of("type", "array", "items", Map.of("type", "integer"))); queryProps.put("sortAscending", Map.of("type", "boolean")); @@ -178,8 +178,8 @@ void testCanReceiveAndReturnComplexTypes(TestInfo testInfo) throws Exception { querySchema.put("properties", queryProps); querySchema.put("required", List.of("table", "ids", "sortAscending")); - Map parameters = new HashMap<>(); - Map properties = new HashMap<>(); + var parameters = new HashMap(); + var properties = new HashMap(); properties.put("query", querySchema); parameters.put("type", "object"); parameters.put("properties", properties); From c7ecec93ee3d72ecccba24e937355f94257c3cc5 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 21:47:40 -0800 Subject: [PATCH 027/109] Refactor: use var in documentation code examples --- src/site/markdown/advanced.md | 12 ++++++------ src/site/markdown/documentation.md | 12 ++++++------ src/site/markdown/getting-started.md | 12 ++++++------ src/site/markdown/hooks.md | 6 +++--- src/site/markdown/mcp.md | 2 +- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/site/markdown/advanced.md b/src/site/markdown/advanced.md index 83204519d..5b472acae 100644 --- a/src/site/markdown/advanced.md +++ b/src/site/markdown/advanced.md @@ -127,7 +127,7 @@ var session = client.createSession( ).get(); // Access the workspace where session state is persisted -String workspace = session.getWorkspacePath(); +var workspace = session.getWorkspacePath(); ``` ### Compaction Events @@ -247,7 +247,7 @@ var session = client.createSession( if (request.getChoices() != null && !request.getChoices().isEmpty()) { System.out.println("Options: " + request.getChoices()); // Return one of the provided choices - String selectedChoice = request.getChoices().get(0); + var selectedChoice = request.getChoices().get(0); return CompletableFuture.completedFuture( new UserInputResponse() .setAnswer(selectedChoice) @@ -256,7 +256,7 @@ var session = client.createSession( } // Freeform input - String userAnswer = getUserInput(); // your input method + var userAnswer = getUserInput(); // your input method return CompletableFuture.completedFuture( new UserInputResponse() .setAnswer(userAnswer) @@ -345,7 +345,7 @@ Subscribe to lifecycle events to be notified when sessions are created, deleted, ### Subscribing to All Lifecycle Events ```java -AutoCloseable subscription = client.onLifecycle(event -> { +var subscription = client.onLifecycle(event -> { System.out.println("Session " + event.getSessionId() + ": " + event.getType()); if (event.getMetadata() != null) { @@ -363,7 +363,7 @@ subscription.close(); import com.github.copilot.sdk.json.SessionLifecycleEventTypes; // Listen only for session creation -AutoCloseable subscription = client.onLifecycle( +var subscription = client.onLifecycle( SessionLifecycleEventTypes.CREATED, event -> System.out.println("New session: " + event.getSessionId()) ); @@ -385,7 +385,7 @@ When connecting to a server running in TUI+server mode (`--ui-server`), you can ### Getting the Foreground Session ```java -String sessionId = client.getForegroundSessionId().get(); +var sessionId = client.getForegroundSessionId().get(); if (sessionId != null) { System.out.println("TUI is displaying session: " + sessionId); } diff --git a/src/site/markdown/documentation.md b/src/site/markdown/documentation.md index 21706f139..ba4c9549f 100644 --- a/src/site/markdown/documentation.md +++ b/src/site/markdown/documentation.md @@ -217,9 +217,9 @@ done.get(); Retrieve all messages and events from a session using `getMessages()`: ```java -List history = session.getMessages().get(); +var history = session.getMessages().get(); -for (AbstractSessionEvent event : history) { +for (var event : history) { switch (event) { case UserMessageEvent user -> System.out.println("User: " + user.getData().getContent()); @@ -287,9 +287,9 @@ try { Query available models before creating a session: ```java -List models = client.listModels().get(); +var models = client.listModels().get(); -for (ModelInfo model : models) { +for (var model : models) { System.out.printf("%s (%s)%n", model.getId(), model.getName()); } ``` @@ -329,10 +329,10 @@ System.out.println("Claude: " + future2.get().getData().getContent()); ```java // Get the last session ID -String lastSessionId = client.getLastSessionId().get(); +var lastSessionId = client.getLastSessionId().get(); // Or list all sessions -List sessions = client.listSessions().get(); +var sessions = client.listSessions().get(); // Resume a session var session = client.resumeSession(lastSessionId).get(); diff --git a/src/site/markdown/getting-started.md b/src/site/markdown/getting-started.md index b78356afb..0dba98ad2 100644 --- a/src/site/markdown/getting-started.md +++ b/src/site/markdown/getting-started.md @@ -179,13 +179,13 @@ public class ToolExample { "required", List.of("city") ), invocation -> { - Map params = invocation.getArguments(); - String city = (String) params.get("city"); + var params = invocation.getArguments(); + var city = (String) params.get("city"); // In a real app, you'd call a weather API here String[] conditions = {"sunny", "cloudy", "rainy", "partly cloudy"}; int temp = new Random().nextInt(30) + 50; - String condition = conditions[new Random().nextInt(conditions.length)]; + var condition = conditions[new Random().nextInt(conditions.length)]; return CompletableFuture.completedFuture(Map.of( "city", city, @@ -258,11 +258,11 @@ public class WeatherAssistant { "required", List.of("city") ), invocation -> { - Map params = invocation.getArguments(); - String city = (String) params.get("city"); + var params = invocation.getArguments(); + var city = (String) params.get("city"); String[] conditions = {"sunny", "cloudy", "rainy", "partly cloudy"}; int temp = new Random().nextInt(30) + 50; - String condition = conditions[new Random().nextInt(conditions.length)]; + var condition = conditions[new Random().nextInt(conditions.length)]; return CompletableFuture.completedFuture(Map.of( "city", city, "temperature", temp + "Β°F", diff --git a/src/site/markdown/hooks.md b/src/site/markdown/hooks.md index d42be7131..68f6bbcfb 100644 --- a/src/site/markdown/hooks.md +++ b/src/site/markdown/hooks.md @@ -113,8 +113,8 @@ var hooks = new SessionHooks() .setOnPreToolUse((input, invocation) -> { if (input.getToolName().equals("search_code")) { // Add project root to search path - ObjectMapper mapper = new ObjectMapper(); - ObjectNode modifiedArgs = mapper.createObjectNode(); + var mapper = new ObjectMapper(); + var modifiedArgs = mapper.createObjectNode(); modifiedArgs.put("path", "/my/project/src"); modifiedArgs.set("query", input.getToolArgs().get("query")); @@ -283,7 +283,7 @@ var hooks = new SessionHooks() System.out.println("Session ended: " + input.getReason()); // Clean up session resources - ResourceManager resources = sessionResources.remove(invocation.getSessionId()); + var resources = sessionResources.remove(invocation.getSessionId()); if (resources != null) { resources.close(); } diff --git a/src/site/markdown/mcp.md b/src/site/markdown/mcp.md index d41ed87ac..b575e52a9 100644 --- a/src/site/markdown/mcp.md +++ b/src/site/markdown/mcp.md @@ -34,7 +34,7 @@ System.out.println(result.getData().getContent()); Run a tool as a subprocess (stdin/stdout communication). ```java -Map server = new HashMap<>(); +var server = new HashMap(); server.put("type", "local"); server.put("command", "node"); server.put("args", List.of("./mcp-server.js")); From 412c8be74ec8856f7a9f25a2353298e7f18e2834 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 22:00:25 -0800 Subject: [PATCH 028/109] Refactor: promote type-safe on(Class, Consumer) event handlers Update all examples and Javadoc to use the type-safe on(Class, Consumer) overload instead of the catch-all on(Consumer) with if/else instanceof blocks. This makes examples cleaner and more idiomatic. The catch-all on(Consumer) is still available for collecting all events or using switch expressions, but type-safe handlers are now the recommended pattern. --- README.md | 9 ++-- jbang-example.java | 22 +++++----- .../com/github/copilot/sdk/CopilotClient.java | 10 ++--- .../github/copilot/sdk/CopilotSession.java | 33 ++++++++------- .../com/github/copilot/sdk/package-info.java | 6 +-- src/site/markdown/advanced.md | 15 ++++--- src/site/markdown/documentation.md | 16 ++++---- src/site/markdown/getting-started.md | 41 +++++++++---------- src/site/markdown/index.md | 18 +++----- 9 files changed, 75 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index b980ef8b3..08a969e02 100644 --- a/README.md +++ b/README.md @@ -59,13 +59,10 @@ public class Example { ).get(); var done = new CompletableFuture(); - session.on(evt -> { - if (evt instanceof AssistantMessageEvent msg) { - System.out.println(msg.getData().getContent()); - } else if (evt instanceof SessionIdleEvent) { - done.complete(null); - } + session.on(AssistantMessageEvent.class, msg -> { + System.out.println(msg.getData().getContent()); }); + session.on(SessionIdleEvent.class, idle -> done.complete(null)); session.send(new MessageOptions().setPrompt("What is 2+2?")).get(); done.get(); diff --git a/jbang-example.java b/jbang-example.java index 854202dab..cdceed0d3 100644 --- a/jbang-example.java +++ b/jbang-example.java @@ -18,19 +18,17 @@ public static void main(String[] args) throws Exception { // Wait for response using session.idle event var done = new CompletableFuture(); - session.on(evt -> { - if (evt instanceof AssistantMessageEvent msg) { - System.out.println(msg.getData().getContent()); - } else if (evt instanceof SessionUsageInfoEvent usage) { - var data = usage.getData(); - System.out.println("\n--- Usage Metrics ---"); - System.out.println("Current tokens: " + (int) data.getCurrentTokens()); - System.out.println("Token limit: " + (int) data.getTokenLimit()); - System.out.println("Messages count: " + (int) data.getMessagesLength()); - } else if (evt instanceof SessionIdleEvent) { - done.complete(null); - } + session.on(AssistantMessageEvent.class, msg -> { + System.out.println(msg.getData().getContent()); }); + session.on(SessionUsageInfoEvent.class, usage -> { + var data = usage.getData(); + System.out.println("\n--- Usage Metrics ---"); + System.out.println("Current tokens: " + (int) data.getCurrentTokens()); + System.out.println("Token limit: " + (int) data.getTokenLimit()); + System.out.println("Messages count: " + (int) data.getMessagesLength()); + }); + session.on(SessionIdleEvent.class, idle -> done.complete(null)); // Send a message and wait for completion session.send(new MessageOptions().setPrompt("What is 2+2?")).get(); diff --git a/src/main/java/com/github/copilot/sdk/CopilotClient.java b/src/main/java/com/github/copilot/sdk/CopilotClient.java index 020fc4d53..f4294fef9 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -62,15 +62,13 @@ * Example usage: * *
{@code
- * try (CopilotClient client = new CopilotClient()) {
+ * try (var client = new CopilotClient()) {
  * 	client.start().get();
  *
- * 	CopilotSession session = client.createSession(new SessionConfig().setModel("gpt-5")).get();
+ * 	var session = client.createSession(new SessionConfig().setModel("gpt-5")).get();
  *
- * 	session.on(evt -> {
- * 		if (evt instanceof AssistantMessageEvent msg) {
- * 			System.out.println(msg.getData().getContent());
- * 		}
+ * 	session.on(AssistantMessageEvent.class, msg -> {
+ * 		System.out.println(msg.getData().getContent());
  * 	});
  *
  * 	session.send(new MessageOptions().setPrompt("Hello!")).get();
diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java
index 8e36bf127..3a52c5ef8 100644
--- a/src/main/java/com/github/copilot/sdk/CopilotSession.java
+++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java
@@ -59,13 +59,14 @@
  *
  * 
{@code
  * // Create a session
- * CopilotSession session = client.createSession(new SessionConfig().setModel("gpt-5")).get();
+ * var session = client.createSession(new SessionConfig().setModel("gpt-5")).get();
  *
- * // Register event handlers
- * session.on(evt -> {
- * 	if (evt instanceof AssistantMessageEvent msg) {
- * 		System.out.println(msg.getData().getContent());
- * 	}
+ * // Register type-safe event handlers
+ * session.on(AssistantMessageEvent.class, msg -> {
+ * 	System.out.println(msg.getData().getContent());
+ * });
+ * session.on(SessionIdleEvent.class, idle -> {
+ * 	System.out.println("Session is idle");
  * });
  *
  * // Send messages
@@ -288,28 +289,26 @@ public CompletableFuture sendAndWait(MessageOptions optio
     }
 
     /**
-     * Registers a callback for session events.
+     * Registers a callback for all session events.
      * 

- * The handler will be invoked for all events in this session, including - * assistant messages, tool calls, and session state changes. + * The handler will be invoked for every event in this session, including + * assistant messages, tool calls, and session state changes. For type-safe + * handling of specific event types, prefer {@link #on(Class, Consumer)} + * instead. * *

* Example: * *

{@code
-     * Closeable subscription = session.on(evt -> {
-     * 	if (evt instanceof AssistantMessageEvent msg) {
-     * 		System.out.println(msg.getData().getContent());
-     * 	}
-     * });
-     *
-     * // Later, to unsubscribe:
-     * subscription.close();
+     * // Collect all events
+     * var events = new ArrayList();
+     * session.on(events::add);
      * }
* * @param handler * a callback to be invoked when a session event occurs * @return a Closeable that, when closed, unsubscribes the handler + * @see #on(Class, Consumer) * @see AbstractSessionEvent */ public Closeable on(Consumer handler) { diff --git a/src/main/java/com/github/copilot/sdk/package-info.java b/src/main/java/com/github/copilot/sdk/package-info.java index cf2b4f3dc..deb436677 100644 --- a/src/main/java/com/github/copilot/sdk/package-info.java +++ b/src/main/java/com/github/copilot/sdk/package-info.java @@ -33,10 +33,8 @@ * * var session = client.createSession(new SessionConfig().setModel("gpt-4.1")).get(); * - * session.on(evt -> { - * if (evt instanceof AssistantMessageEvent msg) { - * System.out.println(msg.getData().getContent()); - * } + * session.on(AssistantMessageEvent.class, msg -> { + * System.out.println(msg.getData().getContent()); * }); * * session.send(new MessageOptions().setPrompt("Hello, Copilot!")).get(); diff --git a/src/site/markdown/advanced.md b/src/site/markdown/advanced.md index 5b472acae..919dc7955 100644 --- a/src/site/markdown/advanced.md +++ b/src/site/markdown/advanced.md @@ -135,14 +135,13 @@ var workspace = session.getWorkspacePath(); When compaction occurs, the session emits events that you can listen for: ```java -session.on(event -> { - if (event instanceof SessionCompactionStartEvent start) { - System.out.println("Compaction started"); - } else if (event instanceof SessionCompactionCompleteEvent complete) { - var data = complete.getData(); - System.out.println("Compaction completed - success: " + data.isSuccess() - + ", tokens removed: " + data.getTokensRemoved()); - } +session.on(SessionCompactionStartEvent.class, start -> { + System.out.println("Compaction started"); +}); +session.on(SessionCompactionCompleteEvent.class, complete -> { + var data = complete.getData(); + System.out.println("Compaction completed - success: " + data.isSuccess() + + ", tokens removed: " + data.getTokensRemoved()); }); ``` diff --git a/src/site/markdown/documentation.md b/src/site/markdown/documentation.md index ba4c9549f..42888aac8 100644 --- a/src/site/markdown/documentation.md +++ b/src/site/markdown/documentation.md @@ -194,14 +194,14 @@ var session = client.createSession( var done = new CompletableFuture(); -session.on(event -> { - if (event instanceof AssistantMessageDeltaEvent delta) { - // Print each chunk as it arrives - System.out.print(delta.getData().getDeltaContent()); - } else if (event instanceof SessionIdleEvent) { - System.out.println(); // Newline at end - done.complete(null); - } +session.on(AssistantMessageDeltaEvent.class, delta -> { + // Print each chunk as it arrives + System.out.print(delta.getData().getDeltaContent()); +}); + +session.on(SessionIdleEvent.class, idle -> { + System.out.println(); // Newline at end + done.complete(null); }); session.send("Write a haiku about Java").get(); diff --git a/src/site/markdown/getting-started.md b/src/site/markdown/getting-started.md index 0dba98ad2..d7205f765 100644 --- a/src/site/markdown/getting-started.md +++ b/src/site/markdown/getting-started.md @@ -127,13 +127,12 @@ public class StreamingExample { var done = new CompletableFuture(); // Listen for response chunks - session.on(event -> { - if (event instanceof AssistantMessageDeltaEvent delta) { - System.out.print(delta.getData().getDeltaContent()); - } else if (event instanceof SessionIdleEvent) { - System.out.println(); // New line when done - done.complete(null); - } + session.on(AssistantMessageDeltaEvent.class, delta -> { + System.out.print(delta.getData().getDeltaContent()); + }); + session.on(SessionIdleEvent.class, idle -> { + System.out.println(); // New line when done + done.complete(null); }); session.send( @@ -204,13 +203,12 @@ public class ToolExample { var done = new CompletableFuture(); - session.on(event -> { - if (event instanceof AssistantMessageDeltaEvent delta) { - System.out.print(delta.getData().getDeltaContent()); - } else if (event instanceof SessionIdleEvent) { - System.out.println(); - done.complete(null); - } + session.on(AssistantMessageDeltaEvent.class, delta -> { + System.out.print(delta.getData().getDeltaContent()); + }); + session.on(SessionIdleEvent.class, idle -> { + System.out.println(); + done.complete(null); }); session.send( @@ -284,14 +282,13 @@ public class WeatherAssistant { var done = new AtomicReference>(); // Register listener once, outside the loop - session.on(event -> { - if (event instanceof AssistantMessageDeltaEvent delta) { - System.out.print(delta.getData().getDeltaContent()); - } else if (event instanceof SessionIdleEvent) { - System.out.println(); - var f = done.get(); - if (f != null) f.complete(null); - } + session.on(AssistantMessageDeltaEvent.class, delta -> { + System.out.print(delta.getData().getDeltaContent()); + }); + session.on(SessionIdleEvent.class, idle -> { + System.out.println(); + var f = done.get(); + if (f != null) f.complete(null); }); while (true) { diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index 8fe2d2efe..e27d628bd 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -49,13 +49,10 @@ public class Example { ).get(); var done = new CompletableFuture(); - session.on(evt -> { - if (evt instanceof AssistantMessageEvent msg) { - System.out.println(msg.getData().getContent()); - } else if (evt instanceof SessionIdleEvent) { - done.complete(null); - } + session.on(AssistantMessageEvent.class, msg -> { + System.out.println(msg.getData().getContent()); }); + session.on(SessionIdleEvent.class, idle -> done.complete(null)); session.send(new MessageOptions().setPrompt("What is 2+2?")).get(); done.get(); @@ -96,13 +93,10 @@ class hello { client.start().get(); var session = client.createSession(new SessionConfig()).get(); var done = new CompletableFuture(); - session.on(evt -> { - if (evt instanceof AssistantMessageEvent msg) { - System.out.print(msg.getData().getContent()); - } else if (evt instanceof SessionIdleEvent) { - done.complete(null); - } + session.on(AssistantMessageEvent.class, msg -> { + System.out.print(msg.getData().getContent()); }); + session.on(SessionIdleEvent.class, idle -> done.complete(null)); session.send(new MessageOptions().setPrompt("Say hello!")).get(); done.get(); } From 2fb9d0f53ad4a8d93a136b9db6f88564df531fe8 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 22:19:42 -0800 Subject: [PATCH 029/109] Add event handler dispatch and threading tests - Add 7 JUnit tests to SessionEventHandlingTest: duplicate typed/generic handlers, unsubscribe-one-keeps-other, all-handlers-invoked, handlers-run-on-dispatch-thread, handlers-run-off-main-thread, and concurrent dispatch from multiple threads - Add JBang standalone test (jbang-test-duplicate-handlers.java) with the same coverage for quick out-of-build verification --- jbang-test-duplicate-handlers.java | 269 ++++++++++++++++++ .../copilot/sdk/SessionEventHandlingTest.java | 148 ++++++++++ 2 files changed, 417 insertions(+) create mode 100644 jbang-test-duplicate-handlers.java diff --git a/jbang-test-duplicate-handlers.java b/jbang-test-duplicate-handlers.java new file mode 100644 index 000000000..713e7c8d2 --- /dev/null +++ b/jbang-test-duplicate-handlers.java @@ -0,0 +1,269 @@ + +//DEPS io.github.copilot-community-sdk:copilot-sdk:1.0.7 +import com.github.copilot.sdk.*; +import com.github.copilot.sdk.events.*; +import java.lang.reflect.*; +import java.util.*; +import java.util.concurrent.atomic.*; +import java.util.logging.*; + +/** + * JBang test: verifies behavior when multiple handlers are registered for the same event type. + * + * Run with: jbang jbang-test-duplicate-handlers.java + */ +class DuplicateHandlersTest { + + private static int passed = 0; + private static int failed = 0; + + public static void main(String[] args) throws Exception { + var session = createTestSession(); + + testBothTypedHandlersReceiveEvent(session); + testBothGenericHandlersFire(createTestSession()); + testMixedGenericAndTypedBothFire(createTestSession()); + testUnsubscribeOneKeepsOther(createTestSession()); + testAllHandlersInvoked(createTestSession()); + testHandlerExceptionDoesNotBlockSecond(createTestSession()); + testHandlersRunOnDispatchThread(createTestSession()); + testHandlersRunOffMainThread(createTestSession()); + testConcurrentDispatchFromMultipleThreads(createTestSession()); + + System.out.println(); + System.out.println("========================================"); + System.out.printf("Results: %d passed, %d failed%n", passed, failed); + System.out.println("========================================"); + + if (failed > 0) { + System.exit(1); + } + } + + // --- Tests --- + + static void testBothTypedHandlersReceiveEvent(CopilotSession session) throws Exception { + var count1 = new AtomicInteger(); + var count2 = new AtomicInteger(); + + session.on(AssistantMessageEvent.class, msg -> count1.incrementAndGet()); + session.on(AssistantMessageEvent.class, msg -> count2.incrementAndGet()); + + dispatchEvent(session, createAssistantMessageEvent("hello")); + + assertEq("Both typed handlers called", 1, count1.get()); + assertEq("Both typed handlers called (2nd)", 1, count2.get()); + } + + static void testBothGenericHandlersFire(CopilotSession session) throws Exception { + var events1 = new ArrayList(); + var events2 = new ArrayList(); + + session.on(event -> events1.add(event.getType())); + session.on(event -> events2.add(event.getType())); + + dispatchEvent(session, createAssistantMessageEvent("test")); + + assertEq("Generic handler 1 received event", 1, events1.size()); + assertEq("Generic handler 2 received event", 1, events2.size()); + } + + static void testMixedGenericAndTypedBothFire(CopilotSession session) throws Exception { + var genericCount = new AtomicInteger(); + var typedCount = new AtomicInteger(); + + session.on(event -> genericCount.incrementAndGet()); + session.on(AssistantMessageEvent.class, msg -> typedCount.incrementAndGet()); + + dispatchEvent(session, createAssistantMessageEvent("test")); + + assertEq("Generic handler fired", 1, genericCount.get()); + assertEq("Typed handler fired", 1, typedCount.get()); + } + + static void testUnsubscribeOneKeepsOther(CopilotSession session) throws Exception { + var count1 = new AtomicInteger(); + var count2 = new AtomicInteger(); + + var sub1 = session.on(AssistantMessageEvent.class, msg -> count1.incrementAndGet()); + session.on(AssistantMessageEvent.class, msg -> count2.incrementAndGet()); + + dispatchEvent(session, createAssistantMessageEvent("before")); + assertEq("Handler 1 before unsub", 1, count1.get()); + assertEq("Handler 2 before unsub", 1, count2.get()); + + // Unsubscribe handler 1 + sub1.close(); + + dispatchEvent(session, createAssistantMessageEvent("after")); + assertEq("Handler 1 after unsub (unchanged)", 1, count1.get()); + assertEq("Handler 2 after unsub (incremented)", 2, count2.get()); + } + + static void testAllHandlersInvoked(CopilotSession session) throws Exception { + var called = new ArrayList(); + + session.on(AssistantMessageEvent.class, msg -> called.add("first")); + session.on(AssistantMessageEvent.class, msg -> called.add("second")); + session.on(AssistantMessageEvent.class, msg -> called.add("third")); + + dispatchEvent(session, createAssistantMessageEvent("test")); + + assertEq("Three handlers called", 3, called.size()); + assertEq("All handlers invoked", true, called.containsAll(List.of("first", "second", "third"))); + } + + static void testHandlerExceptionDoesNotBlockSecond(CopilotSession session) throws Exception { + var reached = new AtomicInteger(); + + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("Boom!"); + }); + session.on(AssistantMessageEvent.class, msg -> reached.incrementAndGet()); + + // Suppress CopilotSession logger to avoid noisy stack trace output + var logger = java.util.logging.Logger.getLogger(CopilotSession.class.getName()); + var originalLevel = logger.getLevel(); + logger.setLevel(java.util.logging.Level.OFF); + try { + dispatchEvent(session, createAssistantMessageEvent("test")); + } finally { + logger.setLevel(originalLevel); + } + + assertEq("Second handler still called after first threw", 1, reached.get()); + } + + /** + * Verifies handlers execute on the thread that calls dispatchEvent, + * simulating the real jsonrpc-reader thread. + */ + static void testHandlersRunOnDispatchThread(CopilotSession session) throws Exception { + var handlerThreadName = new AtomicReference(); + + session.on(AssistantMessageEvent.class, msg -> { + handlerThreadName.set(Thread.currentThread().getName()); + }); + + // Dispatch from a named thread to simulate the jsonrpc-reader + var t = new Thread(() -> { + try { + dispatchEvent(session, createAssistantMessageEvent("async")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }, "jsonrpc-reader-mock"); + t.start(); + t.join(5000); + + assertEq("Handler ran on dispatch thread", "jsonrpc-reader-mock", handlerThreadName.get()); + } + + /** + * Verifies that when dispatched from a background thread, handlers + * do NOT run on the main thread β€” proving async delivery. + */ + static void testHandlersRunOffMainThread(CopilotSession session) throws Exception { + var mainThreadName = Thread.currentThread().getName(); + var handlerThreadName = new AtomicReference(); + var latch = new java.util.concurrent.CountDownLatch(1); + + session.on(AssistantMessageEvent.class, msg -> { + handlerThreadName.set(Thread.currentThread().getName()); + latch.countDown(); + }); + + // Dispatch from a background thread (simulates jsonrpc-reader) + new Thread(() -> { + try { + dispatchEvent(session, createAssistantMessageEvent("bg")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }, "background-dispatcher").start(); + + var completed = latch.await(5, java.util.concurrent.TimeUnit.SECONDS); + assertEq("Handler was invoked", true, completed); + assertEq("Handler did NOT run on main thread", true, + !mainThreadName.equals(handlerThreadName.get())); + assertEq("Handler ran on background thread", "background-dispatcher", + handlerThreadName.get()); + } + + /** + * Verifies thread safety: concurrent dispatches from multiple threads + * all reach registered handlers without lost events. + */ + static void testConcurrentDispatchFromMultipleThreads(CopilotSession session) throws Exception { + var totalEvents = 100; + var receivedCount = new AtomicInteger(); + var threadNames = java.util.concurrent.ConcurrentHashMap.newKeySet(); + var latch = new java.util.concurrent.CountDownLatch(totalEvents); + + session.on(AssistantMessageEvent.class, msg -> { + receivedCount.incrementAndGet(); + threadNames.add(Thread.currentThread().getName()); + latch.countDown(); + }); + + // Fire events from 10 concurrent threads, 10 events each + var threads = new ArrayList(); + for (int i = 0; i < 10; i++) { + var threadIdx = i; + var t = new Thread(() -> { + for (int j = 0; j < 10; j++) { + try { + dispatchEvent(session, createAssistantMessageEvent("msg-" + threadIdx + "-" + j)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + }, "dispatcher-" + i); + threads.add(t); + } + + // Start all threads + for (var t : threads) t.start(); + + // Wait for all events to be delivered + var completed = latch.await(10, java.util.concurrent.TimeUnit.SECONDS); + for (var t : threads) t.join(5000); + + assertEq("All " + totalEvents + " events delivered", totalEvents, receivedCount.get()); + assertEq("Latch completed", true, completed); + assertEq("Multiple threads dispatched", true, threadNames.size() > 1); + } + + // --- Helpers --- + + static CopilotSession createTestSession() throws Exception { + var rpcClass = Class.forName("com.github.copilot.sdk.JsonRpcClient"); + var ctor = CopilotSession.class.getDeclaredConstructor(String.class, rpcClass, String.class); + ctor.setAccessible(true); + return ctor.newInstance("test-session", null, null); + } + + static void dispatchEvent(CopilotSession session, AbstractSessionEvent event) throws Exception { + var method = CopilotSession.class.getDeclaredMethod("dispatchEvent", AbstractSessionEvent.class); + method.setAccessible(true); + method.invoke(session, event); + } + + static AssistantMessageEvent createAssistantMessageEvent(String content) { + var event = new AssistantMessageEvent(); + var data = new AssistantMessageEvent.AssistantMessageData(); + data.setContent(content); + event.setData(data); + return event; + } + + static void assertEq(String testName, Object expected, Object actual) { + if (Objects.equals(expected, actual)) { + System.out.println(" βœ“ " + testName); + passed++; + } else { + System.out.println(" βœ— " + testName + " β€” expected: " + expected + ", got: " + actual); + failed++; + } + } +} diff --git a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java index 961f800de..bbd9c0aa2 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java @@ -9,6 +9,10 @@ import java.io.Closeable; import java.lang.reflect.Method; import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; @@ -227,6 +231,150 @@ void testNoHandlersDoesNotThrow() { }); } + @Test + void testDuplicateTypedHandlersBothReceiveEvent() { + var count1 = new AtomicInteger(); + var count2 = new AtomicInteger(); + + session.on(AssistantMessageEvent.class, msg -> count1.incrementAndGet()); + session.on(AssistantMessageEvent.class, msg -> count2.incrementAndGet()); + + dispatchEvent(createAssistantMessageEvent("hello")); + + assertEquals(1, count1.get(), "First typed handler should be called"); + assertEquals(1, count2.get(), "Second typed handler should be called"); + } + + @Test + void testDuplicateGenericHandlersBothFire() { + var events1 = new ArrayList(); + var events2 = new ArrayList(); + + session.on(event -> events1.add(event.getType())); + session.on(event -> events2.add(event.getType())); + + dispatchEvent(createAssistantMessageEvent("test")); + + assertEquals(1, events1.size(), "First generic handler should receive event"); + assertEquals(1, events2.size(), "Second generic handler should receive event"); + } + + @Test + void testUnsubscribeOneKeepsOther() { + var count1 = new AtomicInteger(); + var count2 = new AtomicInteger(); + + var sub1 = session.on(AssistantMessageEvent.class, msg -> count1.incrementAndGet()); + session.on(AssistantMessageEvent.class, msg -> count2.incrementAndGet()); + + dispatchEvent(createAssistantMessageEvent("before")); + assertEquals(1, count1.get()); + assertEquals(1, count2.get()); + + // Unsubscribe first handler + try { + sub1.close(); + } catch (Exception e) { + fail("Unsubscribe should not throw: " + e.getMessage()); + } + + dispatchEvent(createAssistantMessageEvent("after")); + assertEquals(1, count1.get(), "Unsubscribed handler should not be called again"); + assertEquals(2, count2.get(), "Remaining handler should still be called"); + } + + @Test + void testAllHandlersInvoked() { + var called = new ArrayList(); + + session.on(AssistantMessageEvent.class, msg -> called.add("first")); + session.on(AssistantMessageEvent.class, msg -> called.add("second")); + session.on(AssistantMessageEvent.class, msg -> called.add("third")); + + dispatchEvent(createAssistantMessageEvent("test")); + + assertEquals(3, called.size(), "All three handlers should be invoked"); + assertTrue(called.containsAll(List.of("first", "second", "third")), "All handler labels should be present"); + } + + @Test + void testHandlersRunOnDispatchThread() throws Exception { + var handlerThreadName = new AtomicReference(); + var latch = new CountDownLatch(1); + + session.on(AssistantMessageEvent.class, msg -> { + handlerThreadName.set(Thread.currentThread().getName()); + latch.countDown(); + }); + + // Dispatch from a named thread to simulate the jsonrpc-reader + var t = new Thread(() -> dispatchEvent(createAssistantMessageEvent("async")), "jsonrpc-reader-mock"); + t.start(); + assertTrue(latch.await(5, TimeUnit.SECONDS), "Handler should be invoked within timeout"); + t.join(5000); + + assertEquals("jsonrpc-reader-mock", handlerThreadName.get(), + "Handler should run on the dispatch thread, not a different one"); + } + + @Test + void testHandlersRunOffMainThread() throws Exception { + var mainThreadName = Thread.currentThread().getName(); + var handlerThreadName = new AtomicReference(); + var latch = new CountDownLatch(1); + + session.on(AssistantMessageEvent.class, msg -> { + handlerThreadName.set(Thread.currentThread().getName()); + latch.countDown(); + }); + + // Dispatch from a background thread (simulates jsonrpc-reader) + new Thread(() -> dispatchEvent(createAssistantMessageEvent("bg")), "background-dispatcher").start(); + + assertTrue(latch.await(5, TimeUnit.SECONDS), "Handler should be invoked within timeout"); + assertNotEquals(mainThreadName, handlerThreadName.get(), "Handler should NOT run on the main/test thread"); + assertEquals("background-dispatcher", handlerThreadName.get(), + "Handler should run on the background dispatch thread"); + } + + @Test + void testConcurrentDispatchFromMultipleThreads() throws Exception { + var totalEvents = 100; + var receivedCount = new AtomicInteger(); + var threadNames = ConcurrentHashMap.newKeySet(); + var latch = new CountDownLatch(totalEvents); + + session.on(AssistantMessageEvent.class, msg -> { + receivedCount.incrementAndGet(); + threadNames.add(Thread.currentThread().getName()); + latch.countDown(); + }); + + // Fire events from 10 concurrent threads, 10 events each + var threads = new ArrayList(); + for (int i = 0; i < 10; i++) { + var threadIdx = i; + var t = new Thread(() -> { + for (int j = 0; j < 10; j++) { + dispatchEvent(createAssistantMessageEvent("msg-" + threadIdx + "-" + j)); + } + }, "dispatcher-" + i); + threads.add(t); + } + + for (var t : threads) { + t.start(); + } + + assertTrue(latch.await(10, TimeUnit.SECONDS), "All events should be delivered within timeout"); + for (var t : threads) { + t.join(5000); + } + + assertEquals(totalEvents, receivedCount.get(), "All " + totalEvents + " events should be delivered"); + assertTrue(threadNames.size() > 1, "Events should have been dispatched from multiple threads"); + } + // Helper methods to dispatch events using reflection private void dispatchEvent(AbstractSessionEvent event) { try { From 4f80c5e965a969732a69038898c3ed4590d499fd Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 22:22:37 -0800 Subject: [PATCH 030/109] Remove duplicate handlers test file --- jbang-test-duplicate-handlers.java | 269 ----------------------------- 1 file changed, 269 deletions(-) delete mode 100644 jbang-test-duplicate-handlers.java diff --git a/jbang-test-duplicate-handlers.java b/jbang-test-duplicate-handlers.java deleted file mode 100644 index 713e7c8d2..000000000 --- a/jbang-test-duplicate-handlers.java +++ /dev/null @@ -1,269 +0,0 @@ - -//DEPS io.github.copilot-community-sdk:copilot-sdk:1.0.7 -import com.github.copilot.sdk.*; -import com.github.copilot.sdk.events.*; -import java.lang.reflect.*; -import java.util.*; -import java.util.concurrent.atomic.*; -import java.util.logging.*; - -/** - * JBang test: verifies behavior when multiple handlers are registered for the same event type. - * - * Run with: jbang jbang-test-duplicate-handlers.java - */ -class DuplicateHandlersTest { - - private static int passed = 0; - private static int failed = 0; - - public static void main(String[] args) throws Exception { - var session = createTestSession(); - - testBothTypedHandlersReceiveEvent(session); - testBothGenericHandlersFire(createTestSession()); - testMixedGenericAndTypedBothFire(createTestSession()); - testUnsubscribeOneKeepsOther(createTestSession()); - testAllHandlersInvoked(createTestSession()); - testHandlerExceptionDoesNotBlockSecond(createTestSession()); - testHandlersRunOnDispatchThread(createTestSession()); - testHandlersRunOffMainThread(createTestSession()); - testConcurrentDispatchFromMultipleThreads(createTestSession()); - - System.out.println(); - System.out.println("========================================"); - System.out.printf("Results: %d passed, %d failed%n", passed, failed); - System.out.println("========================================"); - - if (failed > 0) { - System.exit(1); - } - } - - // --- Tests --- - - static void testBothTypedHandlersReceiveEvent(CopilotSession session) throws Exception { - var count1 = new AtomicInteger(); - var count2 = new AtomicInteger(); - - session.on(AssistantMessageEvent.class, msg -> count1.incrementAndGet()); - session.on(AssistantMessageEvent.class, msg -> count2.incrementAndGet()); - - dispatchEvent(session, createAssistantMessageEvent("hello")); - - assertEq("Both typed handlers called", 1, count1.get()); - assertEq("Both typed handlers called (2nd)", 1, count2.get()); - } - - static void testBothGenericHandlersFire(CopilotSession session) throws Exception { - var events1 = new ArrayList(); - var events2 = new ArrayList(); - - session.on(event -> events1.add(event.getType())); - session.on(event -> events2.add(event.getType())); - - dispatchEvent(session, createAssistantMessageEvent("test")); - - assertEq("Generic handler 1 received event", 1, events1.size()); - assertEq("Generic handler 2 received event", 1, events2.size()); - } - - static void testMixedGenericAndTypedBothFire(CopilotSession session) throws Exception { - var genericCount = new AtomicInteger(); - var typedCount = new AtomicInteger(); - - session.on(event -> genericCount.incrementAndGet()); - session.on(AssistantMessageEvent.class, msg -> typedCount.incrementAndGet()); - - dispatchEvent(session, createAssistantMessageEvent("test")); - - assertEq("Generic handler fired", 1, genericCount.get()); - assertEq("Typed handler fired", 1, typedCount.get()); - } - - static void testUnsubscribeOneKeepsOther(CopilotSession session) throws Exception { - var count1 = new AtomicInteger(); - var count2 = new AtomicInteger(); - - var sub1 = session.on(AssistantMessageEvent.class, msg -> count1.incrementAndGet()); - session.on(AssistantMessageEvent.class, msg -> count2.incrementAndGet()); - - dispatchEvent(session, createAssistantMessageEvent("before")); - assertEq("Handler 1 before unsub", 1, count1.get()); - assertEq("Handler 2 before unsub", 1, count2.get()); - - // Unsubscribe handler 1 - sub1.close(); - - dispatchEvent(session, createAssistantMessageEvent("after")); - assertEq("Handler 1 after unsub (unchanged)", 1, count1.get()); - assertEq("Handler 2 after unsub (incremented)", 2, count2.get()); - } - - static void testAllHandlersInvoked(CopilotSession session) throws Exception { - var called = new ArrayList(); - - session.on(AssistantMessageEvent.class, msg -> called.add("first")); - session.on(AssistantMessageEvent.class, msg -> called.add("second")); - session.on(AssistantMessageEvent.class, msg -> called.add("third")); - - dispatchEvent(session, createAssistantMessageEvent("test")); - - assertEq("Three handlers called", 3, called.size()); - assertEq("All handlers invoked", true, called.containsAll(List.of("first", "second", "third"))); - } - - static void testHandlerExceptionDoesNotBlockSecond(CopilotSession session) throws Exception { - var reached = new AtomicInteger(); - - session.on(AssistantMessageEvent.class, msg -> { - throw new RuntimeException("Boom!"); - }); - session.on(AssistantMessageEvent.class, msg -> reached.incrementAndGet()); - - // Suppress CopilotSession logger to avoid noisy stack trace output - var logger = java.util.logging.Logger.getLogger(CopilotSession.class.getName()); - var originalLevel = logger.getLevel(); - logger.setLevel(java.util.logging.Level.OFF); - try { - dispatchEvent(session, createAssistantMessageEvent("test")); - } finally { - logger.setLevel(originalLevel); - } - - assertEq("Second handler still called after first threw", 1, reached.get()); - } - - /** - * Verifies handlers execute on the thread that calls dispatchEvent, - * simulating the real jsonrpc-reader thread. - */ - static void testHandlersRunOnDispatchThread(CopilotSession session) throws Exception { - var handlerThreadName = new AtomicReference(); - - session.on(AssistantMessageEvent.class, msg -> { - handlerThreadName.set(Thread.currentThread().getName()); - }); - - // Dispatch from a named thread to simulate the jsonrpc-reader - var t = new Thread(() -> { - try { - dispatchEvent(session, createAssistantMessageEvent("async")); - } catch (Exception e) { - throw new RuntimeException(e); - } - }, "jsonrpc-reader-mock"); - t.start(); - t.join(5000); - - assertEq("Handler ran on dispatch thread", "jsonrpc-reader-mock", handlerThreadName.get()); - } - - /** - * Verifies that when dispatched from a background thread, handlers - * do NOT run on the main thread β€” proving async delivery. - */ - static void testHandlersRunOffMainThread(CopilotSession session) throws Exception { - var mainThreadName = Thread.currentThread().getName(); - var handlerThreadName = new AtomicReference(); - var latch = new java.util.concurrent.CountDownLatch(1); - - session.on(AssistantMessageEvent.class, msg -> { - handlerThreadName.set(Thread.currentThread().getName()); - latch.countDown(); - }); - - // Dispatch from a background thread (simulates jsonrpc-reader) - new Thread(() -> { - try { - dispatchEvent(session, createAssistantMessageEvent("bg")); - } catch (Exception e) { - throw new RuntimeException(e); - } - }, "background-dispatcher").start(); - - var completed = latch.await(5, java.util.concurrent.TimeUnit.SECONDS); - assertEq("Handler was invoked", true, completed); - assertEq("Handler did NOT run on main thread", true, - !mainThreadName.equals(handlerThreadName.get())); - assertEq("Handler ran on background thread", "background-dispatcher", - handlerThreadName.get()); - } - - /** - * Verifies thread safety: concurrent dispatches from multiple threads - * all reach registered handlers without lost events. - */ - static void testConcurrentDispatchFromMultipleThreads(CopilotSession session) throws Exception { - var totalEvents = 100; - var receivedCount = new AtomicInteger(); - var threadNames = java.util.concurrent.ConcurrentHashMap.newKeySet(); - var latch = new java.util.concurrent.CountDownLatch(totalEvents); - - session.on(AssistantMessageEvent.class, msg -> { - receivedCount.incrementAndGet(); - threadNames.add(Thread.currentThread().getName()); - latch.countDown(); - }); - - // Fire events from 10 concurrent threads, 10 events each - var threads = new ArrayList(); - for (int i = 0; i < 10; i++) { - var threadIdx = i; - var t = new Thread(() -> { - for (int j = 0; j < 10; j++) { - try { - dispatchEvent(session, createAssistantMessageEvent("msg-" + threadIdx + "-" + j)); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - }, "dispatcher-" + i); - threads.add(t); - } - - // Start all threads - for (var t : threads) t.start(); - - // Wait for all events to be delivered - var completed = latch.await(10, java.util.concurrent.TimeUnit.SECONDS); - for (var t : threads) t.join(5000); - - assertEq("All " + totalEvents + " events delivered", totalEvents, receivedCount.get()); - assertEq("Latch completed", true, completed); - assertEq("Multiple threads dispatched", true, threadNames.size() > 1); - } - - // --- Helpers --- - - static CopilotSession createTestSession() throws Exception { - var rpcClass = Class.forName("com.github.copilot.sdk.JsonRpcClient"); - var ctor = CopilotSession.class.getDeclaredConstructor(String.class, rpcClass, String.class); - ctor.setAccessible(true); - return ctor.newInstance("test-session", null, null); - } - - static void dispatchEvent(CopilotSession session, AbstractSessionEvent event) throws Exception { - var method = CopilotSession.class.getDeclaredMethod("dispatchEvent", AbstractSessionEvent.class); - method.setAccessible(true); - method.invoke(session, event); - } - - static AssistantMessageEvent createAssistantMessageEvent(String content) { - var event = new AssistantMessageEvent(); - var data = new AssistantMessageEvent.AssistantMessageData(); - data.setContent(content); - event.setData(data); - return event; - } - - static void assertEq(String testName, Object expected, Object actual) { - if (Objects.equals(expected, actual)) { - System.out.println(" βœ“ " + testName); - passed++; - } else { - System.out.println(" βœ— " + testName + " β€” expected: " + expected + ", got: " + actual); - failed++; - } - } -} From a113688939b2f0e72097dacb5b77da97c9db6bf1 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 22:51:24 -0800 Subject: [PATCH 031/109] Enhance documentation: add exception isolation details for event handlers --- .../github/copilot/sdk/CopilotSession.java | 13 ++++++++++- src/site/markdown/advanced.md | 23 +++++++++++++++++++ src/site/markdown/documentation.md | 4 ++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 3a52c5ef8..b915a590e 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -297,6 +297,10 @@ public CompletableFuture sendAndWait(MessageOptions optio * instead. * *

+ * Exception isolation: If a handler throws an exception, the error is + * logged and remaining handlers still execute. + * + *

* Example: * *

{@code
@@ -324,6 +328,10 @@ public Closeable on(Consumer handler) {
      * matching the specified type.
      *
      * 

+ * Exception isolation: If a handler throws an exception, the error is + * logged and remaining handlers still execute. + * + *

* Example Usage *

* @@ -367,7 +375,10 @@ public Closeable on(Class eventType, Consume /** * Dispatches an event to all registered handlers. *

- * This is called internally when events are received from the server. + * This is called internally when events are received from the server. Each + * handler is invoked in its own try/catch block so that an exception thrown by + * one handler does not prevent subsequent handlers from executing. Exceptions + * are logged at {@link Level#SEVERE}. * * @param event * the event to dispatch diff --git a/src/site/markdown/advanced.md b/src/site/markdown/advanced.md index 919dc7955..c46eb64fb 100644 --- a/src/site/markdown/advanced.md +++ b/src/site/markdown/advanced.md @@ -419,3 +419,26 @@ session.send(new MessageOptions().setPrompt("Hello")) return null; }); ``` + +### Event Handler Exceptions + +If an event handler throws an exception, the SDK catches it, logs it at +`SEVERE` level, and continues dispatching to remaining handlers. This means +one faulty handler will never block others from receiving events: + +```java +// This handler throws, but the second handler still runs +session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("bug in handler 1"); +}); + +session.on(AssistantMessageEvent.class, msg -> { + // This handler executes normally despite the exception above + System.out.println(msg.getData().getContent()); +}); +``` + +> **Note:** This exception isolation behavior is consistent with the Node.js, +> Go, and Python Copilot SDKs, which all catch handler errors per-handler. The +> .NET SDK is an exception β€” handler errors propagate there and can prevent +> subsequent handlers from running. diff --git a/src/site/markdown/documentation.md b/src/site/markdown/documentation.md index 42888aac8..d8ccd0b95 100644 --- a/src/site/markdown/documentation.md +++ b/src/site/markdown/documentation.md @@ -57,6 +57,10 @@ System.out.println(response.getData().getContent()); For more control, subscribe to events and use `send()`: +> **Exception isolation:** If a handler throws an exception, the SDK logs the +> error and continues dispatching to remaining handlers. One misbehaving handler +> will never prevent others from executing. + ```java var done = new CompletableFuture(); From 61b44b94c84bfe29020ff8bfbcea2b63a9a34ced Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 23:10:29 -0800 Subject: [PATCH 032/109] Add EventErrorHandler for custom event handler error handling Introduce a session-level EventErrorHandler functional interface that lets developers react to event handler exceptions programmatically instead of relying on the default SEVERE logging. - Add EventErrorHandler @FunctionalInterface in com.github.copilot.sdk - Add setEventErrorHandler() method on CopilotSession - Update dispatchEvent() to delegate to custom handler when set - Add 6 JUnit tests covering custom handler, null reset, error handler throwing, and event type verification - Update advanced.md and documentation.md with usage examples --- .../github/copilot/sdk/CopilotSession.java | 52 +++++- .../github/copilot/sdk/EventErrorHandler.java | 49 ++++++ src/site/markdown/advanced.md | 25 +++ src/site/markdown/documentation.md | 4 +- .../copilot/sdk/SessionEventHandlingTest.java | 159 ++++++++++++++++++ 5 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/github/copilot/sdk/EventErrorHandler.java diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index b915a590e..38c7a96c8 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -95,6 +95,7 @@ public final class CopilotSession implements AutoCloseable { private final AtomicReference permissionHandler = new AtomicReference<>(); private final AtomicReference userInputHandler = new AtomicReference<>(); private final AtomicReference hooksHandler = new AtomicReference<>(); + private volatile EventErrorHandler eventErrorHandler; /** * Creates a new session with the given ID and RPC client. @@ -152,6 +153,38 @@ public String getWorkspacePath() { return workspacePath; } + /** + * Sets a custom error handler for exceptions thrown by event handlers. + *

+ * When an event handler registered via {@link #on(Consumer)} or + * {@link #on(Class, Consumer)} throws an exception during event dispatch, the + * error handler is invoked instead of the default behavior (logging at + * {@link Level#SEVERE}). + * + *

+ * If the error handler itself throws an exception, that exception is silently + * caught and logged to prevent cascading failures. + * + *

+ * Example: + * + *

{@code
+     * session.setEventErrorHandler((event, exception) -> {
+     * 	metrics.increment("handler.errors");
+     * 	logger.error("Handler failed on {}: {}", event.getType(), exception.getMessage());
+     * });
+     * }
+ * + * @param handler + * the error handler, or {@code null} to restore default logging + * behavior + * @see EventErrorHandler + * @since 1.0.8 + */ + public void setEventErrorHandler(EventErrorHandler handler) { + this.eventErrorHandler = handler; + } + /** * Sends a simple text message to the Copilot session. *

@@ -377,18 +410,31 @@ public Closeable on(Class eventType, Consume *

* This is called internally when events are received from the server. Each * handler is invoked in its own try/catch block so that an exception thrown by - * one handler does not prevent subsequent handlers from executing. Exceptions - * are logged at {@link Level#SEVERE}. + * one handler does not prevent subsequent handlers from executing. + *

+ * If a custom {@link EventErrorHandler} has been set via + * {@link #setEventErrorHandler(EventErrorHandler)}, it is called with the event + * and exception. Otherwise, exceptions are logged at {@link Level#SEVERE}. * * @param event * the event to dispatch + * @see #setEventErrorHandler(EventErrorHandler) */ void dispatchEvent(AbstractSessionEvent event) { for (Consumer handler : eventHandlers) { try { handler.accept(event); } catch (Exception e) { - LOG.log(Level.SEVERE, "Error in event handler", e); + EventErrorHandler errorHandler = this.eventErrorHandler; + if (errorHandler != null) { + try { + errorHandler.handleError(event, e); + } catch (Exception errorHandlerException) { + LOG.log(Level.SEVERE, "Error in event error handler", errorHandlerException); + } + } else { + LOG.log(Level.SEVERE, "Error in event handler", e); + } } } } diff --git a/src/main/java/com/github/copilot/sdk/EventErrorHandler.java b/src/main/java/com/github/copilot/sdk/EventErrorHandler.java new file mode 100644 index 000000000..f989a2945 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/EventErrorHandler.java @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import com.github.copilot.sdk.events.AbstractSessionEvent; + +/** + * A handler for errors thrown by event handlers during event dispatch. + *

+ * When an event handler registered via + * {@link CopilotSession#on(java.util.function.Consumer)} or + * {@link CopilotSession#on(Class, java.util.function.Consumer)} throws an + * exception, the {@code EventErrorHandler} is invoked with the event that was + * being dispatched and the exception that was thrown. + * + *

+ * The default behavior logs errors at {@link java.util.logging.Level#SEVERE}. + * You can override this to integrate with your own logging, metrics, or + * error-reporting systems: + * + *

{@code
+ * session.setEventErrorHandler((event, exception) -> {
+ * 	metrics.increment("handler.errors");
+ * 	logger.error("Handler failed on {}: {}", event.getType(), exception.getMessage());
+ * });
+ * }
+ * + *

+ * If the error handler itself throws an exception, that exception is silently + * caught and logged to prevent cascading failures. + * + * @see CopilotSession#setEventErrorHandler(EventErrorHandler) + * @since 1.0.8 + */ +@FunctionalInterface +public interface EventErrorHandler { + + /** + * Called when an event handler throws an exception during event dispatch. + * + * @param event + * the event that was being dispatched when the error occurred + * @param exception + * the exception thrown by the event handler + */ + void handleError(AbstractSessionEvent event, Exception exception); +} diff --git a/src/site/markdown/advanced.md b/src/site/markdown/advanced.md index c46eb64fb..f38ba01f5 100644 --- a/src/site/markdown/advanced.md +++ b/src/site/markdown/advanced.md @@ -442,3 +442,28 @@ session.on(AssistantMessageEvent.class, msg -> { > Go, and Python Copilot SDKs, which all catch handler errors per-handler. The > .NET SDK is an exception β€” handler errors propagate there and can prevent > subsequent handlers from running. + +### Custom Event Error Handler + +By default, handler exceptions are logged at `SEVERE` level using +`java.util.logging`. You can replace this with a custom +`EventErrorHandler` to integrate with your own logging, metrics, or +error-reporting systems: + +```java +session.setEventErrorHandler((event, exception) -> { + metrics.increment("handler.errors"); + logger.error("Handler failed on {}: {}", + event.getType(), exception.getMessage()); +}); +``` + +The error handler receives both the event that was being dispatched and the +exception that was thrown. If the error handler itself throws, that exception +is silently caught and logged to prevent cascading failures. + +Pass `null` to restore the default logging behavior: + +```java +session.setEventErrorHandler(null); +``` diff --git a/src/site/markdown/documentation.md b/src/site/markdown/documentation.md index d8ccd0b95..0e1e2c91d 100644 --- a/src/site/markdown/documentation.md +++ b/src/site/markdown/documentation.md @@ -59,7 +59,9 @@ For more control, subscribe to events and use `send()`: > **Exception isolation:** If a handler throws an exception, the SDK logs the > error and continues dispatching to remaining handlers. One misbehaving handler -> will never prevent others from executing. +> will never prevent others from executing. You can customize error handling with +> `session.setEventErrorHandler()` β€” see the +> [Advanced Usage](advanced.html#Custom_Event_Error_Handler) guide. ```java var done = new CompletableFuture(); diff --git a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java index bbd9c0aa2..09b0e2e53 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java @@ -376,6 +376,165 @@ void testConcurrentDispatchFromMultipleThreads() throws Exception { } // Helper methods to dispatch events using reflection + // ==================================================================== + // EventErrorHandler tests + // ==================================================================== + + @Test + void testDefaultErrorHandlerLogsException() { + // Without a custom error handler, exceptions should be logged (no crash) + Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); + Level originalLevel = sessionLogger.getLevel(); + sessionLogger.setLevel(Level.OFF); + + try { + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("boom"); + }); + + assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); + } finally { + sessionLogger.setLevel(originalLevel); + } + } + + @Test + void testCustomEventErrorHandlerReceivesEventAndException() { + var capturedEvents = new ArrayList(); + var capturedExceptions = new ArrayList(); + + session.setEventErrorHandler((event, exception) -> { + capturedEvents.add(event); + capturedExceptions.add(exception); + }); + + var thrownException = new RuntimeException("test error"); + session.on(AssistantMessageEvent.class, msg -> { + throw thrownException; + }); + + var event = createAssistantMessageEvent("Hello"); + dispatchEvent(event); + + assertEquals(1, capturedEvents.size()); + assertSame(event, capturedEvents.get(0)); + assertEquals(1, capturedExceptions.size()); + assertSame(thrownException, capturedExceptions.get(0)); + } + + @Test + void testCustomErrorHandlerReplacesDefaultLogging() { + var errorCount = new AtomicInteger(0); + + session.setEventErrorHandler((event, exception) -> { + errorCount.incrementAndGet(); + }); + + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("error 1"); + }); + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("error 2"); + }); + + dispatchEvent(createAssistantMessageEvent("Test")); + + // Both handler errors should be reported to the custom error handler + assertEquals(2, errorCount.get()); + } + + @Test + void testErrorHandlerItselfThrowingDoesNotBreakDispatch() { + var received = new ArrayList(); + + Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); + Level originalLevel = sessionLogger.getLevel(); + sessionLogger.setLevel(Level.OFF); + + try { + session.setEventErrorHandler((event, exception) -> { + throw new RuntimeException("error handler also broke"); + }); + + // First handler throws + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("handler error"); + }); + + // Second handler should still execute + session.on(AssistantMessageEvent.class, msg -> { + received.add(msg.getData().getContent()); + }); + + assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Still works"))); + assertEquals(1, received.size()); + assertEquals("Still works", received.get(0)); + } finally { + sessionLogger.setLevel(originalLevel); + } + } + + @Test + void testSetEventErrorHandlerToNullRestoresDefaultBehavior() { + var errorCount = new AtomicInteger(0); + + // Set custom handler + session.setEventErrorHandler((event, exception) -> { + errorCount.incrementAndGet(); + }); + + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("error"); + }); + + dispatchEvent(createAssistantMessageEvent("Test1")); + assertEquals(1, errorCount.get()); + + // Reset to null (restore default logging) + session.setEventErrorHandler(null); + + // Suppress default logging for the next dispatch + Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); + Level originalLevel = sessionLogger.getLevel(); + sessionLogger.setLevel(Level.OFF); + + try { + dispatchEvent(createAssistantMessageEvent("Test2")); + } finally { + sessionLogger.setLevel(originalLevel); + } + + // Custom handler should NOT have been called again + assertEquals(1, errorCount.get()); + } + + @Test + void testErrorHandlerReceivesCorrectEventType() { + var capturedEvents = new ArrayList(); + + session.setEventErrorHandler((event, exception) -> { + capturedEvents.add(event); + }); + + session.on(event -> { + throw new RuntimeException("always fails"); + }); + + var msgEvent = createAssistantMessageEvent("msg"); + var idleEvent = createSessionIdleEvent(); + + dispatchEvent(msgEvent); + dispatchEvent(idleEvent); + + assertEquals(2, capturedEvents.size()); + assertInstanceOf(AssistantMessageEvent.class, capturedEvents.get(0)); + assertInstanceOf(SessionIdleEvent.class, capturedEvents.get(1)); + } + + // ==================================================================== + // Helper methods + // ==================================================================== + private void dispatchEvent(AbstractSessionEvent event) { try { Method dispatchMethod = CopilotSession.class.getDeclaredMethod("dispatchEvent", AbstractSessionEvent.class); From d8101a4fe110ba5db7a45891240b5a2f126814a1 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 23:24:13 -0800 Subject: [PATCH 033/109] Refactor jbang-example.java: streamline session creation and message sending --- jbang-example.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/jbang-example.java b/jbang-example.java index cdceed0d3..6efcd15a9 100644 --- a/jbang-example.java +++ b/jbang-example.java @@ -12,15 +12,15 @@ public static void main(String[] args) throws Exception { client.start().get(); // Create a session - var sessionConfig = new SessionConfig().setModel("claude-sonnet-4.5"); - var session = client.createSession(sessionConfig).get(); - - // Wait for response using session.idle event - var done = new CompletableFuture(); + var session = client.createSession( + new SessionConfig().setModel("claude-sonnet-4.5")).get(); + // Handle assistant message events session.on(AssistantMessageEvent.class, msg -> { System.out.println(msg.getData().getContent()); }); + + // Handle session usage info events session.on(SessionUsageInfoEvent.class, usage -> { var data = usage.getData(); System.out.println("\n--- Usage Metrics ---"); @@ -28,11 +28,11 @@ public static void main(String[] args) throws Exception { System.out.println("Token limit: " + (int) data.getTokenLimit()); System.out.println("Messages count: " + (int) data.getMessagesLength()); }); - session.on(SessionIdleEvent.class, idle -> done.complete(null)); - // Send a message and wait for completion - session.send(new MessageOptions().setPrompt("What is 2+2?")).get(); - done.get(); + // Send a message + var completable = session.sendAndWait(new MessageOptions().setPrompt("What is 2+2?")); + // and wait for completion + completable.get(); } } } From 7eff8556188e625fc6686a203d4ea1bfd81d7d4d Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 23:26:54 -0800 Subject: [PATCH 034/109] Refactor example class: rename to CopilotSDK and enhance message handling with usage metrics --- README.md | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 08a969e02..cdc1b4666 100644 --- a/README.md +++ b/README.md @@ -49,23 +49,34 @@ import com.github.copilot.sdk.events.*; import com.github.copilot.sdk.json.*; import java.util.concurrent.CompletableFuture; -public class Example { +public class CopilotSDK { public static void main(String[] args) throws Exception { + // Create and start client try (var client = new CopilotClient()) { client.start().get(); - + + // Create a session var session = client.createSession( - new SessionConfig().setModel("claude-sonnet-4.5") - ).get(); + new SessionConfig().setModel("claude-sonnet-4.5")).get(); - var done = new CompletableFuture(); + // Handle assistant message events session.on(AssistantMessageEvent.class, msg -> { System.out.println(msg.getData().getContent()); }); - session.on(SessionIdleEvent.class, idle -> done.complete(null)); - session.send(new MessageOptions().setPrompt("What is 2+2?")).get(); - done.get(); + // Handle session usage info events + session.on(SessionUsageInfoEvent.class, usage -> { + var data = usage.getData(); + System.out.println("\n--- Usage Metrics ---"); + System.out.println("Current tokens: " + (int) data.getCurrentTokens()); + System.out.println("Token limit: " + (int) data.getTokenLimit()); + System.out.println("Messages count: " + (int) data.getMessagesLength()); + }); + + // Send a message + var completable = session.sendAndWait(new MessageOptions().setPrompt("What is 2+2?")); + // and wait for completion + completable.get(); } } } From c894502f3d3ee7bfe3812e0d3cc2b412cb7b7976 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 23:34:47 -0800 Subject: [PATCH 035/109] Update Copilot CLI minimum version requirement to 0.0.405 --- README.md | 2 +- src/site/markdown/index.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cdc1b4666..9bec447a2 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build A ### Requirements - Java 17 or later -- GitHub Copilot CLI 0.0.404 or later installed and in PATH (or provide custom `cliPath`) +- GitHub Copilot CLI 0.0.405 or later installed and in PATH (or provide custom `cliPath`) ### Maven diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index e27d628bd..d8ec341ee 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -9,7 +9,7 @@ Welcome to the documentation for the **Copilot SDK for Java** β€” a Java SDK for ### Requirements - Java 17 or later -- GitHub Copilot CLI 0.0.404 or later installed and in PATH (or provide custom `cliPath`) +- GitHub Copilot CLI 0.0.405 or later installed and in PATH (or provide custom `cliPath`) ### Installation From 25ad83ac108229f83308958921185f307b08bbbf Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 23:38:27 -0800 Subject: [PATCH 036/109] Add missing options to ResumeSessionConfig for parity with SessionConfig Port upstream PR #376: Add model, systemMessage, availableTools, excludedTools, configDir, and infiniteSessions to ResumeSessionConfig and ResumeSessionRequest. --- .../com/github/copilot/sdk/CopilotClient.java | 6 + .../copilot/sdk/json/ResumeSessionConfig.java | 147 ++++++++++++++++++ .../sdk/json/ResumeSessionRequest.java | 84 ++++++++++ 3 files changed, 237 insertions(+) diff --git a/src/main/java/com/github/copilot/sdk/CopilotClient.java b/src/main/java/com/github/copilot/sdk/CopilotClient.java index f4294fef9..039159d0a 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -630,23 +630,29 @@ public CompletableFuture resumeSession(String sessionId, ResumeS var request = new ResumeSessionRequest(); request.setSessionId(sessionId); if (config != null) { + request.setModel(config.getModel()); request.setReasoningEffort(config.getReasoningEffort()); request.setTools(config.getTools() != null ? config.getTools().stream() .map(t -> new ToolDef(t.getName(), t.getDescription(), t.getParameters())) .collect(Collectors.toList()) : null); + request.setSystemMessage(config.getSystemMessage()); + request.setAvailableTools(config.getAvailableTools()); + request.setExcludedTools(config.getExcludedTools()); request.setProvider(config.getProvider()); request.setRequestPermission(config.getOnPermissionRequest() != null ? true : null); request.setRequestUserInput(config.getOnUserInputRequest() != null ? true : null); request.setHooks(config.getHooks() != null && config.getHooks().hasHooks() ? true : null); request.setWorkingDirectory(config.getWorkingDirectory()); + request.setConfigDir(config.getConfigDir()); request.setDisableResume(config.isDisableResume() ? true : null); request.setStreaming(config.isStreaming() ? true : null); request.setMcpServers(config.getMcpServers()); request.setCustomAgents(config.getCustomAgents()); request.setSkillDirectories(config.getSkillDirectories()); request.setDisabledSkills(config.getDisabledSkills()); + request.setInfiniteSessions(config.getInfiniteSessions()); } return connection.rpc.invoke("session.resume", request, ResumeSessionResponse.class).thenApply(response -> { diff --git a/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java b/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java index 768c4fa3b..8b7d841f8 100644 --- a/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java +++ b/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java @@ -32,19 +32,48 @@ @JsonInclude(JsonInclude.Include.NON_NULL) public class ResumeSessionConfig { + private String model; private List tools; + private SystemMessageConfig systemMessage; + private List availableTools; + private List excludedTools; private ProviderConfig provider; private String reasoningEffort; private PermissionHandler onPermissionRequest; private UserInputHandler onUserInputRequest; private SessionHooks hooks; private String workingDirectory; + private String configDir; private boolean disableResume; private boolean streaming; private Map mcpServers; private List customAgents; private List skillDirectories; private List disabledSkills; + private InfiniteSessionConfig infiniteSessions; + + /** + * Gets the AI model to use. + * + * @return the model name + */ + public String getModel() { + return model; + } + + /** + * Sets the AI model to use for the resumed session. + *

+ * Can change the model when resuming an existing session. + * + * @param model + * the model name + * @return this config for method chaining + */ + public ResumeSessionConfig setModel(String model) { + this.model = model; + return this; + } /** * Gets the custom tools for this session. @@ -68,6 +97,78 @@ public ResumeSessionConfig setTools(List tools) { return this; } + /** + * Gets the system message configuration. + * + * @return the system message config + */ + public SystemMessageConfig getSystemMessage() { + return systemMessage; + } + + /** + * Sets the system message configuration. + *

+ * The system message controls the behavior and personality of the assistant. + * + * @param systemMessage + * the system message configuration + * @return this config for method chaining + * @see SystemMessageConfig + */ + public ResumeSessionConfig setSystemMessage(SystemMessageConfig systemMessage) { + this.systemMessage = systemMessage; + return this; + } + + /** + * Gets the list of allowed tool names. + * + * @return the list of available tool names + */ + public List getAvailableTools() { + return availableTools == null ? null : Collections.unmodifiableList(availableTools); + } + + /** + * Sets the list of tool names that are allowed in this session. + *

+ * When specified, only tools in this list will be available to the assistant. + * Takes precedence over excluded tools. + * + * @param availableTools + * the list of allowed tool names + * @return this config for method chaining + */ + public ResumeSessionConfig setAvailableTools(List availableTools) { + this.availableTools = availableTools; + return this; + } + + /** + * Gets the list of excluded tool names. + * + * @return the list of excluded tool names + */ + public List getExcludedTools() { + return excludedTools == null ? null : Collections.unmodifiableList(excludedTools); + } + + /** + * Sets the list of tool names to exclude from this session. + *

+ * Tools in this list will not be available to the assistant. Ignored if + * available tools is specified. + * + * @param excludedTools + * the list of tool names to exclude + * @return this config for method chaining + */ + public ResumeSessionConfig setExcludedTools(List excludedTools) { + this.excludedTools = excludedTools; + return this; + } + /** * Gets the custom API provider configuration. * @@ -200,6 +301,29 @@ public ResumeSessionConfig setWorkingDirectory(String workingDirectory) { return this; } + /** + * Gets the configuration directory path. + * + * @return the configuration directory path + */ + public String getConfigDir() { + return configDir; + } + + /** + * Sets the configuration directory path. + *

+ * Override the default configuration directory location. + * + * @param configDir + * the configuration directory path + * @return this config for method chaining + */ + public ResumeSessionConfig setConfigDir(String configDir) { + this.configDir = configDir; + return this; + } + /** * Returns whether the resume event is disabled. * @@ -328,4 +452,27 @@ public ResumeSessionConfig setDisabledSkills(List disabledSkills) { this.disabledSkills = disabledSkills; return this; } + + /** + * Gets the infinite session configuration. + * + * @return the infinite session config + */ + public InfiniteSessionConfig getInfiniteSessions() { + return infiniteSessions; + } + + /** + * Sets the infinite session configuration for persistent workspaces and + * automatic compaction. + * + * @param infiniteSessions + * the infinite session configuration + * @return this config for method chaining + * @see InfiniteSessionConfig + */ + public ResumeSessionConfig setInfiniteSessions(InfiniteSessionConfig infiniteSessions) { + this.infiniteSessions = infiniteSessions; + return this; + } } diff --git a/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java b/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java index 7c1999f90..86effad8f 100644 --- a/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java +++ b/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java @@ -29,12 +29,24 @@ public final class ResumeSessionRequest { @JsonProperty("sessionId") private String sessionId; + @JsonProperty("model") + private String model; + @JsonProperty("reasoningEffort") private String reasoningEffort; @JsonProperty("tools") private List tools; + @JsonProperty("systemMessage") + private SystemMessageConfig systemMessage; + + @JsonProperty("availableTools") + private List availableTools; + + @JsonProperty("excludedTools") + private List excludedTools; + @JsonProperty("provider") private ProviderConfig provider; @@ -50,6 +62,9 @@ public final class ResumeSessionRequest { @JsonProperty("workingDirectory") private String workingDirectory; + @JsonProperty("configDir") + private String configDir; + @JsonProperty("disableResume") private Boolean disableResume; @@ -68,6 +83,9 @@ public final class ResumeSessionRequest { @JsonProperty("disabledSkills") private List disabledSkills; + @JsonProperty("infiniteSessions") + private InfiniteSessionConfig infiniteSessions; + /** Gets the session ID. @return the session ID */ public String getSessionId() { return sessionId; @@ -78,6 +96,16 @@ public void setSessionId(String sessionId) { this.sessionId = sessionId; } + /** Gets the model name. @return the model */ + public String getModel() { + return model; + } + + /** Sets the model name. @param model the model */ + public void setModel(String model) { + this.model = model; + } + /** Gets the reasoning effort. @return the reasoning effort level */ public String getReasoningEffort() { return reasoningEffort; @@ -100,6 +128,39 @@ public void setTools(List tools) { this.tools = tools; } + /** Gets the system message config. @return the system message config */ + public SystemMessageConfig getSystemMessage() { + return systemMessage; + } + + /** + * Sets the system message config. @param systemMessage the system message + * config + */ + public void setSystemMessage(SystemMessageConfig systemMessage) { + this.systemMessage = systemMessage; + } + + /** Gets available tools. @return the available tool names */ + public List getAvailableTools() { + return availableTools == null ? null : Collections.unmodifiableList(availableTools); + } + + /** Sets available tools. @param availableTools the available tool names */ + public void setAvailableTools(List availableTools) { + this.availableTools = availableTools; + } + + /** Gets excluded tools. @return the excluded tool names */ + public List getExcludedTools() { + return excludedTools == null ? null : Collections.unmodifiableList(excludedTools); + } + + /** Sets excluded tools. @param excludedTools the excluded tool names */ + public void setExcludedTools(List excludedTools) { + this.excludedTools = excludedTools; + } + /** Gets the provider config. @return the provider */ public ProviderConfig getProvider() { return provider; @@ -150,6 +211,16 @@ public void setWorkingDirectory(String workingDirectory) { this.workingDirectory = workingDirectory; } + /** Gets config directory. @return the config directory */ + public String getConfigDir() { + return configDir; + } + + /** Sets config directory. @param configDir the config directory */ + public void setConfigDir(String configDir) { + this.configDir = configDir; + } + /** Gets disable resume flag. @return the flag */ public Boolean getDisableResume() { return disableResume; @@ -209,4 +280,17 @@ public List getDisabledSkills() { public void setDisabledSkills(List disabledSkills) { this.disabledSkills = disabledSkills; } + + /** Gets infinite sessions config. @return the infinite sessions config */ + public InfiniteSessionConfig getInfiniteSessions() { + return infiniteSessions; + } + + /** + * Sets infinite sessions config. @param infiniteSessions the infinite sessions + * config + */ + public void setInfiniteSessions(InfiniteSessionConfig infiniteSessions) { + this.infiniteSessions = infiniteSessions; + } } From b59375f7ee5176130cc70e20745d2454188f54dc Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 23:42:35 -0800 Subject: [PATCH 037/109] Document ResumeSessionConfig options for session resume --- CHANGELOG.md | 30 ++++++++++++++++++++++++++ src/site/markdown/documentation.md | 34 ++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b462889b..da35293e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,36 @@ All notable changes to the Copilot SDK for Java will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +#### ResumeSessionConfig Parity with SessionConfig +Added missing options to `ResumeSessionConfig` for parity with `SessionConfig` when resuming sessions. You can now change the model, system message, tool filters, and other settings when resuming: + +- `model` - Change the AI model when resuming +- `systemMessage` - Override or extend the system prompt +- `availableTools` - Restrict which tools are available +- `excludedTools` - Disable specific tools +- `configDir` - Override configuration directory +- `infiniteSessions` - Configure infinite session behavior + +**Example:** +```java +var config = new ResumeSessionConfig() + .setModel("claude-sonnet-4") + .setReasoningEffort("high") + .setSystemMessage(new SystemMessageConfig() + .setMode(SystemMessageMode.APPEND) + .setContent("Focus on security.")); + +var session = client.resumeSession(sessionId, config).get(); +``` + +### Changed + +- **Copilot CLI**: Minimum version updated to **0.0.405** + ## [1.0.7] - 2026-02-05 ### Added diff --git a/src/site/markdown/documentation.md b/src/site/markdown/documentation.md index 0e1e2c91d..b1b25be73 100644 --- a/src/site/markdown/documentation.md +++ b/src/site/markdown/documentation.md @@ -344,6 +344,40 @@ var sessions = client.listSessions().get(); var session = client.resumeSession(lastSessionId).get(); ``` +### Resume Options + +When resuming a session, you can optionally reconfigure many settings. This is useful when you need to change the model, update tool configurations, or modify behavior. + +| Option | Description | +|--------|-------------| +| `model` | Change the model for the resumed session | +| `systemMessage` | Override or extend the system prompt | +| `availableTools` | Restrict which tools are available | +| `excludedTools` | Disable specific tools | +| `provider` | Re-provide BYOK credentials (required for BYOK sessions) | +| `reasoningEffort` | Adjust reasoning effort level | +| `streaming` | Enable/disable streaming responses | +| `workingDirectory` | Change the working directory | +| `configDir` | Override configuration directory | +| `mcpServers` | Configure MCP servers | +| `customAgents` | Configure custom agents | +| `skillDirectories` | Directories to load skills from | +| `disabledSkills` | Skills to disable | +| `infiniteSessions` | Configure infinite session behavior | + +**Example: Changing Model on Resume** + +```java +// Resume with a different model +var config = new ResumeSessionConfig() + .setModel("claude-sonnet-4") // Switch to a different model + .setReasoningEffort("high"); // Increase reasoning effort + +var session = client.resumeSession("user-123-task-456", config).get(); +``` + +See [ResumeSessionConfig](apidocs/com/github/copilot/sdk/json/ResumeSessionConfig.html) Javadoc for complete options. + ### Clean Up Sessions ```java From c640e98389f3351ee7cefd373be92f65a302aba1 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 23:42:58 -0800 Subject: [PATCH 038/109] Update .lastmerge to 06ff2ae09fc44bf3db5c62c01a78f08e9d4c5e76 --- .lastmerge | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.lastmerge b/.lastmerge index d627ab8aa..973184229 100644 --- a/.lastmerge +++ b/.lastmerge @@ -1 +1 @@ -c7e0765e6eee56cc79fb18a94f719b578bf1bbb6 +06ff2ae09fc44bf3db5c62c01a78f08e9d4c5e76 From 010923db3c979b709704f9590f670aea27efa584 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 5 Feb 2026 23:47:28 -0800 Subject: [PATCH 039/109] Update changelog with all changes since v1.0.7 --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index da35293e3..77e324fdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,9 +31,40 @@ var config = new ResumeSessionConfig() var session = client.resumeSession(sessionId, config).get(); ``` +#### EventErrorHandler for Custom Error Handling +Added `EventErrorHandler` interface for custom handling of exceptions thrown by event handlers. Set via `session.setEventErrorHandler()` to receive the event and exception when a handler fails. + +```java +session.setEventErrorHandler((event, exception) -> { + logger.error("Handler failed for event: " + event.getType(), exception); +}); +``` + +#### Type-Safe Event Handlers +Promoted type-safe `on(Class, Consumer)` event handlers as the primary API. Handlers now receive strongly-typed events instead of raw `AbstractSessionEvent`. + +```java +session.on(AssistantMessageEvent.class, msg -> { + System.out.println(msg.getData().getContent()); +}); +``` + +#### SpotBugs Static Analysis +Integrated SpotBugs for static code analysis with exclusion filters for `events` and `json` packages. + ### Changed - **Copilot CLI**: Minimum version updated to **0.0.405** +- **CopilotClient**: Made `final` to prevent Finalizer attacks (security hardening) +- **JBang Example**: Refactored `jbang-example.java` with streamlined session creation and usage metrics display +- **Code Style**: Use `var` for local variable type inference throughout the codebase + +### Fixed + +- **SpotBugs OS_OPEN_STREAM**: Wrap `BufferedReader` in try-with-resources to prevent resource leaks +- **SpotBugs REC_CATCH_EXCEPTION**: Narrow exception catch in `JsonRpcClient.handleMessage()` +- **SpotBugs DM_DEFAULT_ENCODING**: Add explicit UTF-8 charset to `InputStreamReader` +- **SpotBugs EI_EXPOSE_REP**: Add defensive copies to collection getters in events and JSON packages ## [1.0.7] - 2026-02-05 From a2104f2b36fe4c78b364c473728d206b5caba2bc Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Fri, 6 Feb 2026 00:06:08 -0800 Subject: [PATCH 040/109] Add detailed Table of Contents to advanced usage guide --- src/site/markdown/advanced.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/site/markdown/advanced.md b/src/site/markdown/advanced.md index f38ba01f5..83bbb6f6f 100644 --- a/src/site/markdown/advanced.md +++ b/src/site/markdown/advanced.md @@ -4,6 +4,35 @@ This guide covers advanced scenarios for extending and customizing your Copilot integration. +## Table of Contents + +- [Custom Tools](#custom-tools) +- [System Messages](#system-messages) + - [Adding Rules](#adding-rules) + - [Full Control](#full-control) +- [File Attachments](#file-attachments) +- [Bring Your Own Key (BYOK)](#bring-your-own-key-byok) +- [Infinite Sessions](#infinite-sessions) + - [Compaction Events](#compaction-events) +- [MCP Servers](#mcp-servers) +- [Skills Configuration](#skills-configuration) + - [Loading Skills](#loading-skills) + - [Disabling Skills](#disabling-skills) +- [Custom Configuration Directory](#custom-configuration-directory) +- [User Input Handling](#user-input-handling) +- [Permission Handling](#permission-handling) +- [Session Hooks](#session-hooks) +- [Manual Server Control](#manual-server-control) +- [Session Lifecycle Events](#session-lifecycle-events) + - [Subscribing to All Lifecycle Events](#subscribing-to-all-lifecycle-events) + - [Subscribing to Specific Event Types](#subscribing-to-specific-event-types) +- [Foreground Session Control (TUI+Server Mode)](#foreground-session-control-tuiserver-mode) + - [Getting the Foreground Session](#getting-the-foreground-session) + - [Setting the Foreground Session](#setting-the-foreground-session) +- [Error Handling](#error-handling) + - [Event Handler Exceptions](#event-handler-exceptions) + - [Custom Event Error Handler](#custom-event-error-handler) + --- ## Custom Tools From 229cfe7d59ba5d58b374aee43991455a6e68609d Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Fri, 6 Feb 2026 00:21:08 -0800 Subject: [PATCH 041/109] feat: Add LifecycleEventManager, RpcHandlerDispatcher, and SessionRequestBuilder - Implement LifecycleEventManager for managing lifecycle event subscriptions and dispatching. - Create RpcHandlerDispatcher to handle incoming JSON-RPC method calls for session events, tool calls, permission requests, user input requests, hooks invocations, and lifecycle events. - Introduce SessionRequestBuilder to construct JSON-RPC request objects from session configurations for session creation and resumption. --- .../github/copilot/sdk/CliServerManager.java | 237 +++++++ .../com/github/copilot/sdk/CopilotClient.java | 607 +----------------- .../copilot/sdk/LifecycleEventManager.java | 104 +++ .../copilot/sdk/RpcHandlerDispatcher.java | 316 +++++++++ .../copilot/sdk/SessionRequestBuilder.java | 163 +++++ 5 files changed, 844 insertions(+), 583 deletions(-) create mode 100644 src/main/java/com/github/copilot/sdk/CliServerManager.java create mode 100644 src/main/java/com/github/copilot/sdk/LifecycleEventManager.java create mode 100644 src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java create mode 100644 src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java diff --git a/src/main/java/com/github/copilot/sdk/CliServerManager.java b/src/main/java/com/github/copilot/sdk/CliServerManager.java new file mode 100644 index 000000000..059fa283f --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/CliServerManager.java @@ -0,0 +1,237 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.Socket; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.github.copilot.sdk.json.CopilotClientOptions; + +/** + * Manages the lifecycle of the Copilot CLI server process. + *

+ * This class handles spawning the CLI server process, building command lines, + * detecting the listening port, and establishing connections. + */ +final class CliServerManager { + + private static final Logger LOG = Logger.getLogger(CliServerManager.class.getName()); + + private final CopilotClientOptions options; + + CliServerManager(CopilotClientOptions options) { + this.options = options; + } + + /** + * Starts the CLI server process. + * + * @return information about the started process including detected port + * @throws IOException + * if the process cannot be started + * @throws InterruptedException + * if interrupted while waiting for port detection + */ + ProcessInfo startCliServer() throws IOException, InterruptedException { + String cliPath = options.getCliPath() != null ? options.getCliPath() : "copilot"; + var args = new ArrayList(); + + if (options.getCliArgs() != null) { + args.addAll(Arrays.asList(options.getCliArgs())); + } + + args.add("--server"); + args.add("--log-level"); + args.add(options.getLogLevel()); + + if (options.isUseStdio()) { + args.add("--stdio"); + } else if (options.getPort() > 0) { + args.add("--port"); + args.add(String.valueOf(options.getPort())); + } + + // Add auth-related flags + if (options.getGithubToken() != null && !options.getGithubToken().isEmpty()) { + args.add("--auth-token-env"); + args.add("COPILOT_SDK_AUTH_TOKEN"); + } + + // Default UseLoggedInUser to false when GithubToken is provided + boolean useLoggedInUser = options.getUseLoggedInUser() != null + ? options.getUseLoggedInUser() + : (options.getGithubToken() == null || options.getGithubToken().isEmpty()); + if (!useLoggedInUser) { + args.add("--no-auto-login"); + } + + List command = resolveCliCommand(cliPath, args); + + var pb = new ProcessBuilder(command); + pb.redirectErrorStream(false); + + if (options.getCwd() != null) { + pb.directory(new File(options.getCwd())); + } + + if (options.getEnvironment() != null) { + pb.environment().clear(); + pb.environment().putAll(options.getEnvironment()); + } + pb.environment().remove("NODE_DEBUG"); + + // Set auth token in environment if provided + if (options.getGithubToken() != null && !options.getGithubToken().isEmpty()) { + pb.environment().put("COPILOT_SDK_AUTH_TOKEN", options.getGithubToken()); + } + + Process process = pb.start(); + + // Forward stderr to logger in background + startStderrReader(process); + + Integer detectedPort = null; + if (!options.isUseStdio()) { + detectedPort = waitForPortAnnouncement(process); + } + + return new ProcessInfo(process, detectedPort); + } + + /** + * Connects to a running Copilot server. + * + * @param process + * the CLI process (null if connecting to external server) + * @param tcpHost + * the host to connect to (null for stdio mode) + * @param tcpPort + * the port to connect to (null for stdio mode) + * @return the JSON-RPC client connected to the server + * @throws IOException + * if connection fails + */ + JsonRpcClient connectToServer(Process process, String tcpHost, Integer tcpPort) throws IOException { + if (options.isUseStdio()) { + if (process == null) { + throw new IllegalStateException("CLI process not started"); + } + return JsonRpcClient.fromProcess(process); + } else { + if (tcpHost == null || tcpPort == null) { + throw new IllegalStateException("Cannot connect because TCP host or port are not available"); + } + Socket socket = new Socket(tcpHost, tcpPort); + return JsonRpcClient.fromSocket(socket); + } + } + + private void startStderrReader(Process process) { + var stderrThread = new Thread(() -> { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + LOG.fine("[CLI] " + line); + } + } catch (IOException e) { + LOG.log(Level.FINE, "Error reading stderr", e); + } + }, "cli-stderr-reader"); + stderrThread.setDaemon(true); + stderrThread.start(); + } + + private Integer waitForPortAnnouncement(Process process) throws IOException { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + Pattern portPattern = Pattern.compile("listening on port (\\d+)", Pattern.CASE_INSENSITIVE); + long deadline = System.currentTimeMillis() + 30000; + + while (System.currentTimeMillis() < deadline) { + String line = reader.readLine(); + if (line == null) { + throw new IOException("CLI process exited unexpectedly"); + } + + Matcher matcher = portPattern.matcher(line); + if (matcher.find()) { + return Integer.parseInt(matcher.group(1)); + } + } + + process.destroyForcibly(); + throw new IOException("Timeout waiting for CLI to announce port"); + } + } + + private List resolveCliCommand(String cliPath, List args) { + boolean isJsFile = cliPath.toLowerCase().endsWith(".js"); + + if (isJsFile) { + var result = new ArrayList(); + result.add("node"); + result.add(cliPath); + result.addAll(args); + return result; + } + + // On Windows, use cmd /c to resolve the executable + String os = System.getProperty("os.name").toLowerCase(); + if (os.contains("win") && !new File(cliPath).isAbsolute()) { + var result = new ArrayList(); + result.add("cmd"); + result.add("/c"); + result.add(cliPath); + result.addAll(args); + return result; + } + + var result = new ArrayList(); + result.add(cliPath); + result.addAll(args); + return result; + } + + static URI parseCliUrl(String url) { + // If it's just a port number, treat as localhost + try { + int port = Integer.parseInt(url); + return URI.create("http://localhost:" + port); + } catch (NumberFormatException e) { + // Not a port number, continue + } + + // Add scheme if missing + if (!url.toLowerCase().startsWith("http://") && !url.toLowerCase().startsWith("https://")) { + url = "https://" + url; + } + + return URI.create(url); + } + + /** + * Information about a started CLI server process. + * + * @param process + * the CLI process + * @param port + * the detected TCP port (null for stdio mode) + */ + record ProcessInfo(Process process, Integer port) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/CopilotClient.java b/src/main/java/com/github/copilot/sdk/CopilotClient.java index 039159d0a..5f7013815 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -4,15 +4,8 @@ package com.github.copilot.sdk; -import java.io.BufferedReader; -import java.io.File; -import java.io.IOException; -import java.io.InputStreamReader; -import java.net.Socket; import java.net.URI; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; -import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -22,16 +15,8 @@ import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.github.copilot.sdk.events.AbstractSessionEvent; -import com.github.copilot.sdk.events.SessionEventParser; import com.github.copilot.sdk.json.CopilotClientOptions; -import com.github.copilot.sdk.json.CreateSessionRequest; import com.github.copilot.sdk.json.CreateSessionResponse; import com.github.copilot.sdk.json.DeleteSessionResponse; import com.github.copilot.sdk.json.GetAuthStatusResponse; @@ -40,17 +25,12 @@ import com.github.copilot.sdk.json.GetStatusResponse; import com.github.copilot.sdk.json.ListSessionsResponse; import com.github.copilot.sdk.json.ModelInfo; -import com.github.copilot.sdk.json.PermissionRequestResult; import com.github.copilot.sdk.json.PingResponse; import com.github.copilot.sdk.json.ResumeSessionConfig; -import com.github.copilot.sdk.json.ResumeSessionRequest; import com.github.copilot.sdk.json.ResumeSessionResponse; import com.github.copilot.sdk.json.SessionConfig; +import com.github.copilot.sdk.json.SessionLifecycleHandler; import com.github.copilot.sdk.json.SessionMetadata; -import com.github.copilot.sdk.json.ToolDef; -import com.github.copilot.sdk.json.ToolDefinition; -import com.github.copilot.sdk.json.ToolInvocation; -import com.github.copilot.sdk.json.ToolResultObject; /** * Provides a client for interacting with the Copilot CLI server. @@ -80,9 +60,10 @@ public final class CopilotClient implements AutoCloseable { private static final Logger LOG = Logger.getLogger(CopilotClient.class.getName()); - private static final ObjectMapper MAPPER = JsonRpcClient.getObjectMapper(); private final CopilotClientOptions options; + private final CliServerManager serverManager; + private final LifecycleEventManager lifecycleManager = new LifecycleEventManager(); private final Map sessions = new ConcurrentHashMap<>(); private volatile CompletableFuture connectionFuture; private volatile boolean disposed = false; @@ -90,9 +71,6 @@ public final class CopilotClient implements AutoCloseable { private final Integer optionsPort; private volatile List modelsCache; private final Object modelsCacheLock = new Object(); - private final List lifecycleHandlers = new ArrayList<>(); - private final Map> typedLifecycleHandlers = new ConcurrentHashMap<>(); - private final Object lifecycleHandlersLock = new Object(); /** * Creates a new CopilotClient with default options. @@ -127,30 +105,15 @@ public CopilotClient(CopilotClientOptions options) { // Parse CliUrl if provided if (this.options.getCliUrl() != null && !this.options.getCliUrl().isEmpty()) { - URI uri = parseCliUrl(this.options.getCliUrl()); + URI uri = CliServerManager.parseCliUrl(this.options.getCliUrl()); this.optionsHost = uri.getHost(); this.optionsPort = uri.getPort(); } else { this.optionsHost = null; this.optionsPort = null; } - } - - private static URI parseCliUrl(String url) { - // If it's just a port number, treat as localhost - try { - int port = Integer.parseInt(url); - return URI.create("http://localhost:" + port); - } catch (NumberFormatException e) { - // Not a port number, continue - } - - // Add scheme if missing - if (!url.toLowerCase().startsWith("http://") && !url.toLowerCase().startsWith("https://")) { - url = "https://" + url; - } - return URI.create(url); + this.serverManager = new CliServerManager(this.options); } /** @@ -174,20 +137,25 @@ private CompletableFuture startCore() { return CompletableFuture.supplyAsync(() -> { try { - Connection connection; + JsonRpcClient rpc; + Process process = null; if (optionsHost != null && optionsPort != null) { // External server (TCP) - connection = connectToServer(null, optionsHost, optionsPort); + rpc = serverManager.connectToServer(null, optionsHost, optionsPort); } else { // Child process (stdio or TCP) - ProcessInfo processInfo = startCliServer(); - connection = connectToServer(processInfo.process, processInfo.port != null ? "localhost" : null, - processInfo.port); + CliServerManager.ProcessInfo processInfo = serverManager.startCliServer(); + process = processInfo.process(); + rpc = serverManager.connectToServer(process, processInfo.port() != null ? "localhost" : null, + processInfo.port()); } + Connection connection = new Connection(rpc, process); + // Register handlers for server-to-client calls - registerRpcHandlers(connection.rpc); + RpcHandlerDispatcher dispatcher = new RpcHandlerDispatcher(sessions, lifecycleManager::dispatch); + dispatcher.registerHandlers(rpc); // Verify protocol version verifyProtocolVersion(connection); @@ -200,261 +168,6 @@ private CompletableFuture startCore() { }); } - private void registerRpcHandlers(JsonRpcClient rpc) { - // Handle session events - rpc.registerMethodHandler("session.event", (requestId, params) -> { - try { - String sessionId = params.get("sessionId").asText(); - JsonNode eventNode = params.get("event"); - LOG.fine("Received session.event: " + eventNode); - - CopilotSession session = sessions.get(sessionId); - if (session != null && eventNode != null) { - AbstractSessionEvent event = SessionEventParser.parse(eventNode.toString()); - if (event != null) { - session.dispatchEvent(event); - } - } - } catch (Exception e) { - LOG.log(Level.SEVERE, "Error handling session event", e); - } - }); - - // Handle session lifecycle events - rpc.registerMethodHandler("session.lifecycle", (requestId, params) -> { - try { - String type = params.has("type") ? params.get("type").asText() : ""; - String sessionId = params.has("sessionId") ? params.get("sessionId").asText() : ""; - - com.github.copilot.sdk.json.SessionLifecycleEvent event = new com.github.copilot.sdk.json.SessionLifecycleEvent(); - event.setType(type); - event.setSessionId(sessionId); - - if (params.has("metadata") && !params.get("metadata").isNull()) { - com.github.copilot.sdk.json.SessionLifecycleEventMetadata metadata = MAPPER.treeToValue( - params.get("metadata"), com.github.copilot.sdk.json.SessionLifecycleEventMetadata.class); - event.setMetadata(metadata); - } - - dispatchLifecycleEvent(event); - } catch (Exception e) { - LOG.log(Level.SEVERE, "Error handling session lifecycle event", e); - } - }); - - // Handle tool calls - rpc.registerMethodHandler("tool.call", (requestId, params) -> { - handleToolCall(rpc, requestId, params); - }); - - // Handle permission requests - rpc.registerMethodHandler("permission.request", (requestId, params) -> { - handlePermissionRequest(rpc, requestId, params); - }); - - // Handle user input requests - rpc.registerMethodHandler("userInput.request", (requestId, params) -> { - handleUserInputRequest(rpc, requestId, params); - }); - - // Handle hooks invocations - rpc.registerMethodHandler("hooks.invoke", (requestId, params) -> { - handleHooksInvoke(rpc, requestId, params); - }); - } - - private void handleToolCall(JsonRpcClient rpc, String requestId, JsonNode params) { - CompletableFuture.runAsync(() -> { - try { - String sessionId = params.get("sessionId").asText(); - String toolCallId = params.get("toolCallId").asText(); - String toolName = params.get("toolName").asText(); - JsonNode arguments = params.get("arguments"); - - CopilotSession session = sessions.get(sessionId); - if (session == null) { - rpc.sendErrorResponse(Long.parseLong(requestId), -32602, "Unknown session " + sessionId); - return; - } - - ToolDefinition tool = session.getTool(toolName); - if (tool == null || tool.getHandler() == null) { - var result = new ToolResultObject().setTextResultForLlm("Tool '" + toolName + "' is not supported.") - .setResultType("failure").setError("tool '" + toolName + "' not supported"); - rpc.sendResponse(Long.parseLong(requestId), Map.of("result", result)); - return; - } - - var invocation = new ToolInvocation().setSessionId(sessionId).setToolCallId(toolCallId) - .setToolName(toolName).setArguments(arguments); - - tool.getHandler().invoke(invocation).thenAccept(result -> { - try { - ToolResultObject toolResult; - if (result instanceof ToolResultObject tr) { - toolResult = tr; - } else { - toolResult = new ToolResultObject().setResultType("success").setTextResultForLlm( - result instanceof String s ? s : MAPPER.writeValueAsString(result)); - } - rpc.sendResponse(Long.parseLong(requestId), Map.of("result", toolResult)); - } catch (Exception e) { - LOG.log(Level.SEVERE, "Error sending tool result", e); - } - }).exceptionally(ex -> { - try { - var result = new ToolResultObject() - .setTextResultForLlm( - "Invoking this tool produced an error. Detailed information is not available.") - .setResultType("failure").setError(ex.getMessage()); - rpc.sendResponse(Long.parseLong(requestId), Map.of("result", result)); - } catch (Exception e) { - LOG.log(Level.SEVERE, "Error sending tool error", e); - } - return null; - }); - } catch (Exception e) { - LOG.log(Level.SEVERE, "Error handling tool call", e); - try { - rpc.sendErrorResponse(Long.parseLong(requestId), -32603, e.getMessage()); - } catch (IOException ioe) { - LOG.log(Level.SEVERE, "Failed to send error response", ioe); - } - } - }); - } - - private void handlePermissionRequest(JsonRpcClient rpc, String requestId, JsonNode params) { - CompletableFuture.runAsync(() -> { - try { - String sessionId = params.get("sessionId").asText(); - JsonNode permissionRequest = params.get("permissionRequest"); - - CopilotSession session = sessions.get(sessionId); - if (session == null) { - var result = new PermissionRequestResult() - .setKind("denied-no-approval-rule-and-could-not-request-from-user"); - rpc.sendResponse(Long.parseLong(requestId), Map.of("result", result)); - return; - } - - session.handlePermissionRequest(permissionRequest).thenAccept(result -> { - try { - rpc.sendResponse(Long.parseLong(requestId), Map.of("result", result)); - } catch (IOException e) { - LOG.log(Level.SEVERE, "Error sending permission result", e); - } - }).exceptionally(ex -> { - try { - var result = new PermissionRequestResult() - .setKind("denied-no-approval-rule-and-could-not-request-from-user"); - rpc.sendResponse(Long.parseLong(requestId), Map.of("result", result)); - } catch (IOException e) { - LOG.log(Level.SEVERE, "Error sending permission denied", e); - } - return null; - }); - } catch (Exception e) { - LOG.log(Level.SEVERE, "Error handling permission request", e); - } - }); - } - - private void handleUserInputRequest(JsonRpcClient rpc, String requestId, JsonNode params) { - LOG.fine("Received userInput.request: " + params); - CompletableFuture.runAsync(() -> { - try { - String sessionId = params.get("sessionId").asText(); - String question = params.get("question").asText(); - LOG.fine("Processing userInput for session " + sessionId + ", question: " + question); - JsonNode choicesNode = params.get("choices"); - JsonNode allowFreeformNode = params.get("allowFreeform"); - - CopilotSession session = sessions.get(sessionId); - LOG.fine("Found session: " + (session != null)); - if (session == null) { - LOG.fine("Session not found, sending error"); - rpc.sendErrorResponse(Long.parseLong(requestId), -32602, "Unknown session " + sessionId); - return; - } - - var request = new com.github.copilot.sdk.json.UserInputRequest().setQuestion(question); - if (choicesNode != null && choicesNode.isArray()) { - var choices = new ArrayList(); - for (JsonNode choice : choicesNode) { - choices.add(choice.asText()); - } - request.setChoices(choices); - } - if (allowFreeformNode != null) { - request.setAllowFreeform(allowFreeformNode.asBoolean()); - } - - session.handleUserInputRequest(request).thenAccept(response -> { - try { - // Ensure answer is never null - CLI requires a non-null string - String answer = response.getAnswer() != null ? response.getAnswer() : ""; - LOG.fine("Sending userInput response: answer=" + answer + ", wasFreeform=" - + response.isWasFreeform()); - rpc.sendResponse(Long.parseLong(requestId), - Map.of("answer", answer, "wasFreeform", response.isWasFreeform())); - } catch (IOException e) { - LOG.log(Level.SEVERE, "Error sending user input response", e); - } - }).exceptionally(ex -> { - LOG.log(Level.WARNING, "User input handler exception", ex); - try { - rpc.sendErrorResponse(Long.parseLong(requestId), -32603, - "User input handler error: " + ex.getMessage()); - } catch (IOException e) { - LOG.log(Level.SEVERE, "Error sending user input error", e); - } - return null; - }); - } catch (Exception e) { - LOG.log(Level.SEVERE, "Error handling user input request", e); - } - }); - } - - private void handleHooksInvoke(JsonRpcClient rpc, String requestId, JsonNode params) { - CompletableFuture.runAsync(() -> { - try { - String sessionId = params.get("sessionId").asText(); - String hookType = params.get("hookType").asText(); - JsonNode input = params.get("input"); - - CopilotSession session = sessions.get(sessionId); - if (session == null) { - rpc.sendErrorResponse(Long.parseLong(requestId), -32602, "Unknown session " + sessionId); - return; - } - - session.handleHooksInvoke(hookType, input).thenAccept(output -> { - try { - if (output != null) { - rpc.sendResponse(Long.parseLong(requestId), Map.of("output", output)); - } else { - rpc.sendResponse(Long.parseLong(requestId), Map.of("output", (Object) null)); - } - } catch (IOException e) { - LOG.log(Level.SEVERE, "Error sending hooks response", e); - } - }).exceptionally(ex -> { - try { - rpc.sendErrorResponse(Long.parseLong(requestId), -32603, - "Hooks handler error: " + ex.getMessage()); - } catch (IOException e) { - LOG.log(Level.SEVERE, "Error sending hooks error", e); - } - return null; - }); - } catch (Exception e) { - LOG.log(Level.SEVERE, "Error handling hooks invoke", e); - } - }); - } - private void verifyProtocolVersion(Connection connection) throws Exception { int expectedVersion = SdkProtocolVersion.get(); var params = new HashMap(); @@ -551,49 +264,11 @@ private CompletableFuture cleanupConnection() { */ public CompletableFuture createSession(SessionConfig config) { return ensureConnected().thenCompose(connection -> { - var request = new CreateSessionRequest(); - if (config != null) { - request.setModel(config.getModel()); - request.setSessionId(config.getSessionId()); - request.setReasoningEffort(config.getReasoningEffort()); - request.setTools(config.getTools() != null - ? config.getTools().stream() - .map(t -> new ToolDef(t.getName(), t.getDescription(), t.getParameters())) - .collect(Collectors.toList()) - : null); - request.setSystemMessage(config.getSystemMessage()); - request.setAvailableTools(config.getAvailableTools()); - request.setExcludedTools(config.getExcludedTools()); - request.setProvider(config.getProvider()); - request.setRequestPermission(config.getOnPermissionRequest() != null ? true : null); - boolean requestUserInput = config.getOnUserInputRequest() != null; - LOG.fine("Setting requestUserInput: " + requestUserInput + " for session.create"); - request.setRequestUserInput(requestUserInput ? true : null); - request.setHooks(config.getHooks() != null && config.getHooks().hasHooks() ? true : null); - request.setWorkingDirectory(config.getWorkingDirectory()); - request.setStreaming(config.isStreaming() ? true : null); - request.setMcpServers(config.getMcpServers()); - request.setCustomAgents(config.getCustomAgents()); - request.setInfiniteSessions(config.getInfiniteSessions()); - request.setSkillDirectories(config.getSkillDirectories()); - request.setDisabledSkills(config.getDisabledSkills()); - request.setConfigDir(config.getConfigDir()); - } + var request = SessionRequestBuilder.buildCreateRequest(config); return connection.rpc.invoke("session.create", request, CreateSessionResponse.class).thenApply(response -> { var session = new CopilotSession(response.getSessionId(), connection.rpc, response.getWorkspacePath()); - if (config != null && config.getTools() != null) { - session.registerTools(config.getTools()); - } - if (config != null && config.getOnPermissionRequest() != null) { - session.registerPermissionHandler(config.getOnPermissionRequest()); - } - if (config != null && config.getOnUserInputRequest() != null) { - session.registerUserInputHandler(config.getOnUserInputRequest()); - } - if (config != null && config.getHooks() != null) { - session.registerHooks(config.getHooks()); - } + SessionRequestBuilder.configureSession(session, config); sessions.put(response.getSessionId(), session); return session; }); @@ -627,48 +302,11 @@ public CompletableFuture createSession() { */ public CompletableFuture resumeSession(String sessionId, ResumeSessionConfig config) { return ensureConnected().thenCompose(connection -> { - var request = new ResumeSessionRequest(); - request.setSessionId(sessionId); - if (config != null) { - request.setModel(config.getModel()); - request.setReasoningEffort(config.getReasoningEffort()); - request.setTools(config.getTools() != null - ? config.getTools().stream() - .map(t -> new ToolDef(t.getName(), t.getDescription(), t.getParameters())) - .collect(Collectors.toList()) - : null); - request.setSystemMessage(config.getSystemMessage()); - request.setAvailableTools(config.getAvailableTools()); - request.setExcludedTools(config.getExcludedTools()); - request.setProvider(config.getProvider()); - request.setRequestPermission(config.getOnPermissionRequest() != null ? true : null); - request.setRequestUserInput(config.getOnUserInputRequest() != null ? true : null); - request.setHooks(config.getHooks() != null && config.getHooks().hasHooks() ? true : null); - request.setWorkingDirectory(config.getWorkingDirectory()); - request.setConfigDir(config.getConfigDir()); - request.setDisableResume(config.isDisableResume() ? true : null); - request.setStreaming(config.isStreaming() ? true : null); - request.setMcpServers(config.getMcpServers()); - request.setCustomAgents(config.getCustomAgents()); - request.setSkillDirectories(config.getSkillDirectories()); - request.setDisabledSkills(config.getDisabledSkills()); - request.setInfiniteSessions(config.getInfiniteSessions()); - } + var request = SessionRequestBuilder.buildResumeRequest(sessionId, config); return connection.rpc.invoke("session.resume", request, ResumeSessionResponse.class).thenApply(response -> { var session = new CopilotSession(response.getSessionId(), connection.rpc, response.getWorkspacePath()); - if (config != null && config.getTools() != null) { - session.registerTools(config.getTools()); - } - if (config != null && config.getOnPermissionRequest() != null) { - session.registerPermissionHandler(config.getOnPermissionRequest()); - } - if (config != null && config.getOnUserInputRequest() != null) { - session.registerUserInputHandler(config.getOnUserInputRequest()); - } - if (config != null && config.getHooks() != null) { - session.registerHooks(config.getHooks()); - } + SessionRequestBuilder.configureSession(session, config); sessions.put(response.getSessionId(), session); return session; }); @@ -884,15 +522,8 @@ public CompletableFuture setForegroundSessionId(String sessionId) { * a callback that receives lifecycle events * @return an AutoCloseable that, when closed, unsubscribes the handler */ - public AutoCloseable onLifecycle(com.github.copilot.sdk.json.SessionLifecycleHandler handler) { - synchronized (lifecycleHandlersLock) { - lifecycleHandlers.add(handler); - } - return () -> { - synchronized (lifecycleHandlersLock) { - lifecycleHandlers.remove(handler); - } - }; + public AutoCloseable onLifecycle(SessionLifecycleHandler handler) { + return lifecycleManager.subscribe(handler); } /** @@ -906,47 +537,8 @@ public AutoCloseable onLifecycle(com.github.copilot.sdk.json.SessionLifecycleHan * a callback that receives events of the specified type * @return an AutoCloseable that, when closed, unsubscribes the handler */ - public AutoCloseable onLifecycle(String eventType, com.github.copilot.sdk.json.SessionLifecycleHandler handler) { - synchronized (lifecycleHandlersLock) { - typedLifecycleHandlers.computeIfAbsent(eventType, k -> new ArrayList<>()).add(handler); - } - return () -> { - synchronized (lifecycleHandlersLock) { - List handlers = typedLifecycleHandlers - .get(eventType); - if (handlers != null) { - handlers.remove(handler); - } - } - }; - } - - void dispatchLifecycleEvent(com.github.copilot.sdk.json.SessionLifecycleEvent event) { - List typed; - List wildcard; - - synchronized (lifecycleHandlersLock) { - List handlers = typedLifecycleHandlers - .get(event.getType()); - typed = handlers != null ? new ArrayList<>(handlers) : new ArrayList<>(); - wildcard = new ArrayList<>(lifecycleHandlers); - } - - for (com.github.copilot.sdk.json.SessionLifecycleHandler handler : typed) { - try { - handler.onLifecycleEvent(event); - } catch (Exception e) { - LOG.log(Level.WARNING, "Lifecycle handler error", e); - } - } - - for (com.github.copilot.sdk.json.SessionLifecycleHandler handler : wildcard) { - try { - handler.onLifecycleEvent(event); - } catch (Exception e) { - LOG.log(Level.WARNING, "Lifecycle handler error", e); - } - } + public AutoCloseable onLifecycle(String eventType, SessionLifecycleHandler handler) { + return lifecycleManager.subscribe(eventType, handler); } private CompletableFuture ensureConnected() { @@ -958,154 +550,6 @@ private CompletableFuture ensureConnected() { return connectionFuture; } - private ProcessInfo startCliServer() throws IOException, InterruptedException { - String cliPath = options.getCliPath() != null ? options.getCliPath() : "copilot"; - var args = new ArrayList(); - - if (options.getCliArgs() != null) { - args.addAll(Arrays.asList(options.getCliArgs())); - } - - args.add("--server"); - args.add("--log-level"); - args.add(options.getLogLevel()); - - if (options.isUseStdio()) { - args.add("--stdio"); - } else if (options.getPort() > 0) { - args.add("--port"); - args.add(String.valueOf(options.getPort())); - } - - // Add auth-related flags - if (options.getGithubToken() != null && !options.getGithubToken().isEmpty()) { - args.add("--auth-token-env"); - args.add("COPILOT_SDK_AUTH_TOKEN"); - } - - // Default UseLoggedInUser to false when GithubToken is provided - boolean useLoggedInUser = options.getUseLoggedInUser() != null - ? options.getUseLoggedInUser() - : (options.getGithubToken() == null || options.getGithubToken().isEmpty()); - if (!useLoggedInUser) { - args.add("--no-auto-login"); - } - - List command = resolveCliCommand(cliPath, args); - - var pb = new ProcessBuilder(command); - pb.redirectErrorStream(false); - - if (options.getCwd() != null) { - pb.directory(new File(options.getCwd())); - } - - if (options.getEnvironment() != null) { - pb.environment().clear(); - pb.environment().putAll(options.getEnvironment()); - } - pb.environment().remove("NODE_DEBUG"); - - // Set auth token in environment if provided - if (options.getGithubToken() != null && !options.getGithubToken().isEmpty()) { - pb.environment().put("COPILOT_SDK_AUTH_TOKEN", options.getGithubToken()); - } - - Process process = pb.start(); - - // Forward stderr to logger in background - var stderrThread = new Thread(() -> { - try (BufferedReader reader = new BufferedReader( - new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) { - String line; - while ((line = reader.readLine()) != null) { - LOG.fine("[CLI] " + line); - } - } catch (IOException e) { - LOG.log(Level.FINE, "Error reading stderr", e); - } - }, "cli-stderr-reader"); - stderrThread.setDaemon(true); - stderrThread.start(); - - Integer detectedPort = null; - if (!options.isUseStdio()) { - // Wait for port announcement - try (BufferedReader reader = new BufferedReader( - new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { - Pattern portPattern = Pattern.compile("listening on port (\\d+)", Pattern.CASE_INSENSITIVE); - long deadline = System.currentTimeMillis() + 30000; - - while (System.currentTimeMillis() < deadline) { - String line = reader.readLine(); - if (line == null) { - throw new IOException("CLI process exited unexpectedly"); - } - - Matcher matcher = portPattern.matcher(line); - if (matcher.find()) { - detectedPort = Integer.parseInt(matcher.group(1)); - break; - } - } - - if (detectedPort == null) { - process.destroyForcibly(); - throw new IOException("Timeout waiting for CLI to announce port"); - } - } - } - - return new ProcessInfo(process, detectedPort); - } - - private List resolveCliCommand(String cliPath, List args) { - boolean isJsFile = cliPath.toLowerCase().endsWith(".js"); - - if (isJsFile) { - var result = new ArrayList(); - result.add("node"); - result.add(cliPath); - result.addAll(args); - return result; - } - - // On Windows, use cmd /c to resolve the executable - String os = System.getProperty("os.name").toLowerCase(); - if (os.contains("win") && !new File(cliPath).isAbsolute()) { - var result = new ArrayList(); - result.add("cmd"); - result.add("/c"); - result.add(cliPath); - result.addAll(args); - return result; - } - - var result = new ArrayList(); - result.add(cliPath); - result.addAll(args); - return result; - } - - private Connection connectToServer(Process process, String tcpHost, Integer tcpPort) throws IOException { - JsonRpcClient rpc; - - if (options.isUseStdio()) { - if (process == null) { - throw new IllegalStateException("CLI process not started"); - } - rpc = JsonRpcClient.fromProcess(process); - } else { - if (tcpHost == null || tcpPort == null) { - throw new IllegalStateException("Cannot connect because TCP host or port are not available"); - } - Socket socket = new Socket(tcpHost, tcpPort); - rpc = JsonRpcClient.fromSocket(socket); - } - - return new Connection(rpc, process); - } - @Override public void close() { if (disposed) @@ -1118,9 +562,6 @@ public void close() { } } - private static record ProcessInfo(Process process, Integer port) { - }; - private static record Connection(JsonRpcClient rpc, Process process) { }; diff --git a/src/main/java/com/github/copilot/sdk/LifecycleEventManager.java b/src/main/java/com/github/copilot/sdk/LifecycleEventManager.java new file mode 100644 index 000000000..21c00e233 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/LifecycleEventManager.java @@ -0,0 +1,104 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +import java.util.logging.Logger; + +import com.github.copilot.sdk.json.SessionLifecycleEvent; +import com.github.copilot.sdk.json.SessionLifecycleHandler; + +/** + * Manages lifecycle event subscriptions and dispatching. + *

+ * This class handles registration/unregistration of lifecycle event handlers + * and dispatches events to the appropriate handlers. + */ +final class LifecycleEventManager { + + private static final Logger LOG = Logger.getLogger(LifecycleEventManager.class.getName()); + + private final List wildcardHandlers = new ArrayList<>(); + private final Map> typedHandlers = new ConcurrentHashMap<>(); + private final Object handlersLock = new Object(); + + /** + * Subscribes to all session lifecycle events. + * + * @param handler + * a callback that receives lifecycle events + * @return an AutoCloseable that, when closed, unsubscribes the handler + */ + AutoCloseable subscribe(SessionLifecycleHandler handler) { + synchronized (handlersLock) { + wildcardHandlers.add(handler); + } + return () -> { + synchronized (handlersLock) { + wildcardHandlers.remove(handler); + } + }; + } + + /** + * Subscribes to a specific session lifecycle event type. + * + * @param eventType + * the event type to listen for + * @param handler + * a callback that receives events of the specified type + * @return an AutoCloseable that, when closed, unsubscribes the handler + */ + AutoCloseable subscribe(String eventType, SessionLifecycleHandler handler) { + synchronized (handlersLock) { + typedHandlers.computeIfAbsent(eventType, k -> new ArrayList<>()).add(handler); + } + return () -> { + synchronized (handlersLock) { + List handlers = typedHandlers.get(eventType); + if (handlers != null) { + handlers.remove(handler); + } + } + }; + } + + /** + * Dispatches a lifecycle event to all registered handlers. + * + * @param event + * the lifecycle event to dispatch + */ + void dispatch(SessionLifecycleEvent event) { + List typed; + List wildcard; + + synchronized (handlersLock) { + List handlers = typedHandlers.get(event.getType()); + typed = handlers != null ? new ArrayList<>(handlers) : new ArrayList<>(); + wildcard = new ArrayList<>(wildcardHandlers); + } + + for (SessionLifecycleHandler handler : typed) { + try { + handler.onLifecycleEvent(event); + } catch (Exception e) { + LOG.log(Level.WARNING, "Lifecycle handler error", e); + } + } + + for (SessionLifecycleHandler handler : wildcard) { + try { + handler.onLifecycleEvent(event); + } catch (Exception e) { + LOG.log(Level.WARNING, "Lifecycle handler error", e); + } + } + } +} diff --git a/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java b/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java new file mode 100644 index 000000000..1932ebe68 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java @@ -0,0 +1,316 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; +import java.util.logging.Logger; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.sdk.events.AbstractSessionEvent; +import com.github.copilot.sdk.events.SessionEventParser; +import com.github.copilot.sdk.json.PermissionRequestResult; +import com.github.copilot.sdk.json.SessionLifecycleEvent; +import com.github.copilot.sdk.json.SessionLifecycleEventMetadata; +import com.github.copilot.sdk.json.ToolDefinition; +import com.github.copilot.sdk.json.ToolInvocation; +import com.github.copilot.sdk.json.ToolResultObject; +import com.github.copilot.sdk.json.UserInputRequest; + +/** + * Dispatches incoming JSON-RPC method calls to the appropriate handlers. + *

+ * This class handles all server-to-client RPC calls including: + *

    + *
  • Session events
  • + *
  • Tool calls
  • + *
  • Permission requests
  • + *
  • User input requests
  • + *
  • Hooks invocations
  • + *
  • Lifecycle events
  • + *
+ */ +final class RpcHandlerDispatcher { + + private static final Logger LOG = Logger.getLogger(RpcHandlerDispatcher.class.getName()); + private static final ObjectMapper MAPPER = JsonRpcClient.getObjectMapper(); + + private final Map sessions; + private final LifecycleEventDispatcher lifecycleDispatcher; + + /** + * Creates a dispatcher with session registry and lifecycle dispatcher. + * + * @param sessions + * the session registry to look up sessions by ID + * @param lifecycleDispatcher + * callback for dispatching lifecycle events + */ + RpcHandlerDispatcher(Map sessions, LifecycleEventDispatcher lifecycleDispatcher) { + this.sessions = sessions; + this.lifecycleDispatcher = lifecycleDispatcher; + } + + /** + * Registers all RPC method handlers with the given JSON-RPC client. + * + * @param rpc + * the JSON-RPC client to register handlers with + */ + void registerHandlers(JsonRpcClient rpc) { + rpc.registerMethodHandler("session.event", (requestId, params) -> handleSessionEvent(params)); + rpc.registerMethodHandler("session.lifecycle", (requestId, params) -> handleLifecycleEvent(params)); + rpc.registerMethodHandler("tool.call", (requestId, params) -> handleToolCall(rpc, requestId, params)); + rpc.registerMethodHandler("permission.request", + (requestId, params) -> handlePermissionRequest(rpc, requestId, params)); + rpc.registerMethodHandler("userInput.request", + (requestId, params) -> handleUserInputRequest(rpc, requestId, params)); + rpc.registerMethodHandler("hooks.invoke", (requestId, params) -> handleHooksInvoke(rpc, requestId, params)); + } + + private void handleSessionEvent(JsonNode params) { + try { + String sessionId = params.get("sessionId").asText(); + JsonNode eventNode = params.get("event"); + LOG.fine("Received session.event: " + eventNode); + + CopilotSession session = sessions.get(sessionId); + if (session != null && eventNode != null) { + AbstractSessionEvent event = SessionEventParser.parse(eventNode.toString()); + if (event != null) { + session.dispatchEvent(event); + } + } + } catch (Exception e) { + LOG.log(Level.SEVERE, "Error handling session event", e); + } + } + + private void handleLifecycleEvent(JsonNode params) { + try { + String type = params.has("type") ? params.get("type").asText() : ""; + String sessionId = params.has("sessionId") ? params.get("sessionId").asText() : ""; + + SessionLifecycleEvent event = new SessionLifecycleEvent(); + event.setType(type); + event.setSessionId(sessionId); + + if (params.has("metadata") && !params.get("metadata").isNull()) { + SessionLifecycleEventMetadata metadata = MAPPER.treeToValue(params.get("metadata"), + SessionLifecycleEventMetadata.class); + event.setMetadata(metadata); + } + + lifecycleDispatcher.dispatch(event); + } catch (Exception e) { + LOG.log(Level.SEVERE, "Error handling session lifecycle event", e); + } + } + + private void handleToolCall(JsonRpcClient rpc, String requestId, JsonNode params) { + CompletableFuture.runAsync(() -> { + try { + String sessionId = params.get("sessionId").asText(); + String toolCallId = params.get("toolCallId").asText(); + String toolName = params.get("toolName").asText(); + JsonNode arguments = params.get("arguments"); + + CopilotSession session = sessions.get(sessionId); + if (session == null) { + rpc.sendErrorResponse(Long.parseLong(requestId), -32602, "Unknown session " + sessionId); + return; + } + + ToolDefinition tool = session.getTool(toolName); + if (tool == null || tool.getHandler() == null) { + var result = new ToolResultObject().setTextResultForLlm("Tool '" + toolName + "' is not supported.") + .setResultType("failure").setError("tool '" + toolName + "' not supported"); + rpc.sendResponse(Long.parseLong(requestId), Map.of("result", result)); + return; + } + + var invocation = new ToolInvocation().setSessionId(sessionId).setToolCallId(toolCallId) + .setToolName(toolName).setArguments(arguments); + + tool.getHandler().invoke(invocation).thenAccept(result -> { + try { + ToolResultObject toolResult; + if (result instanceof ToolResultObject tr) { + toolResult = tr; + } else { + toolResult = new ToolResultObject().setResultType("success").setTextResultForLlm( + result instanceof String s ? s : MAPPER.writeValueAsString(result)); + } + rpc.sendResponse(Long.parseLong(requestId), Map.of("result", toolResult)); + } catch (Exception e) { + LOG.log(Level.SEVERE, "Error sending tool result", e); + } + }).exceptionally(ex -> { + try { + var result = new ToolResultObject() + .setTextResultForLlm( + "Invoking this tool produced an error. Detailed information is not available.") + .setResultType("failure").setError(ex.getMessage()); + rpc.sendResponse(Long.parseLong(requestId), Map.of("result", result)); + } catch (Exception e) { + LOG.log(Level.SEVERE, "Error sending tool error", e); + } + return null; + }); + } catch (Exception e) { + LOG.log(Level.SEVERE, "Error handling tool call", e); + try { + rpc.sendErrorResponse(Long.parseLong(requestId), -32603, e.getMessage()); + } catch (IOException ioe) { + LOG.log(Level.SEVERE, "Failed to send error response", ioe); + } + } + }); + } + + private void handlePermissionRequest(JsonRpcClient rpc, String requestId, JsonNode params) { + CompletableFuture.runAsync(() -> { + try { + String sessionId = params.get("sessionId").asText(); + JsonNode permissionRequest = params.get("permissionRequest"); + + CopilotSession session = sessions.get(sessionId); + if (session == null) { + var result = new PermissionRequestResult() + .setKind("denied-no-approval-rule-and-could-not-request-from-user"); + rpc.sendResponse(Long.parseLong(requestId), Map.of("result", result)); + return; + } + + session.handlePermissionRequest(permissionRequest).thenAccept(result -> { + try { + rpc.sendResponse(Long.parseLong(requestId), Map.of("result", result)); + } catch (IOException e) { + LOG.log(Level.SEVERE, "Error sending permission result", e); + } + }).exceptionally(ex -> { + try { + var result = new PermissionRequestResult() + .setKind("denied-no-approval-rule-and-could-not-request-from-user"); + rpc.sendResponse(Long.parseLong(requestId), Map.of("result", result)); + } catch (IOException e) { + LOG.log(Level.SEVERE, "Error sending permission denied", e); + } + return null; + }); + } catch (Exception e) { + LOG.log(Level.SEVERE, "Error handling permission request", e); + } + }); + } + + private void handleUserInputRequest(JsonRpcClient rpc, String requestId, JsonNode params) { + LOG.fine("Received userInput.request: " + params); + CompletableFuture.runAsync(() -> { + try { + String sessionId = params.get("sessionId").asText(); + String question = params.get("question").asText(); + LOG.fine("Processing userInput for session " + sessionId + ", question: " + question); + JsonNode choicesNode = params.get("choices"); + JsonNode allowFreeformNode = params.get("allowFreeform"); + + CopilotSession session = sessions.get(sessionId); + LOG.fine("Found session: " + (session != null)); + if (session == null) { + LOG.fine("Session not found, sending error"); + rpc.sendErrorResponse(Long.parseLong(requestId), -32602, "Unknown session " + sessionId); + return; + } + + var request = new UserInputRequest().setQuestion(question); + if (choicesNode != null && choicesNode.isArray()) { + var choices = new ArrayList(); + for (JsonNode choice : choicesNode) { + choices.add(choice.asText()); + } + request.setChoices(choices); + } + if (allowFreeformNode != null) { + request.setAllowFreeform(allowFreeformNode.asBoolean()); + } + + session.handleUserInputRequest(request).thenAccept(response -> { + try { + // Ensure answer is never null - CLI requires a non-null string + String answer = response.getAnswer() != null ? response.getAnswer() : ""; + LOG.fine("Sending userInput response: answer=" + answer + ", wasFreeform=" + + response.isWasFreeform()); + rpc.sendResponse(Long.parseLong(requestId), + Map.of("answer", answer, "wasFreeform", response.isWasFreeform())); + } catch (IOException e) { + LOG.log(Level.SEVERE, "Error sending user input response", e); + } + }).exceptionally(ex -> { + LOG.log(Level.WARNING, "User input handler exception", ex); + try { + rpc.sendErrorResponse(Long.parseLong(requestId), -32603, + "User input handler error: " + ex.getMessage()); + } catch (IOException e) { + LOG.log(Level.SEVERE, "Error sending user input error", e); + } + return null; + }); + } catch (Exception e) { + LOG.log(Level.SEVERE, "Error handling user input request", e); + } + }); + } + + private void handleHooksInvoke(JsonRpcClient rpc, String requestId, JsonNode params) { + CompletableFuture.runAsync(() -> { + try { + String sessionId = params.get("sessionId").asText(); + String hookType = params.get("hookType").asText(); + JsonNode input = params.get("input"); + + CopilotSession session = sessions.get(sessionId); + if (session == null) { + rpc.sendErrorResponse(Long.parseLong(requestId), -32602, "Unknown session " + sessionId); + return; + } + + session.handleHooksInvoke(hookType, input).thenAccept(output -> { + try { + if (output != null) { + rpc.sendResponse(Long.parseLong(requestId), Map.of("output", output)); + } else { + rpc.sendResponse(Long.parseLong(requestId), Map.of("output", (Object) null)); + } + } catch (IOException e) { + LOG.log(Level.SEVERE, "Error sending hooks response", e); + } + }).exceptionally(ex -> { + try { + rpc.sendErrorResponse(Long.parseLong(requestId), -32603, + "Hooks handler error: " + ex.getMessage()); + } catch (IOException e) { + LOG.log(Level.SEVERE, "Error sending hooks error", e); + } + return null; + }); + } catch (Exception e) { + LOG.log(Level.SEVERE, "Error handling hooks invoke", e); + } + }); + } + + /** + * Functional interface for dispatching lifecycle events. + */ + @FunctionalInterface + interface LifecycleEventDispatcher { + + void dispatch(SessionLifecycleEvent event); + } +} diff --git a/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java b/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java new file mode 100644 index 000000000..041a04841 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java @@ -0,0 +1,163 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import java.util.stream.Collectors; + +import com.github.copilot.sdk.json.CreateSessionRequest; +import com.github.copilot.sdk.json.ResumeSessionConfig; +import com.github.copilot.sdk.json.ResumeSessionRequest; +import com.github.copilot.sdk.json.SessionConfig; +import com.github.copilot.sdk.json.ToolDef; + +/** + * Builds JSON-RPC request objects from session configuration. + *

+ * This class handles the conversion of SDK configuration objects + * ({@link SessionConfig}, {@link ResumeSessionConfig}) to JSON-RPC request + * objects for session creation and resumption. + */ +final class SessionRequestBuilder { + + private SessionRequestBuilder() { + // Utility class + } + + /** + * Builds a CreateSessionRequest from the given configuration. + * + * @param config + * the session configuration (may be null) + * @return the built request object + */ + static CreateSessionRequest buildCreateRequest(SessionConfig config) { + var request = new CreateSessionRequest(); + if (config == null) { + return request; + } + + request.setModel(config.getModel()); + request.setSessionId(config.getSessionId()); + request.setReasoningEffort(config.getReasoningEffort()); + request.setTools(config.getTools() != null + ? config.getTools().stream().map(t -> new ToolDef(t.getName(), t.getDescription(), t.getParameters())) + .collect(Collectors.toList()) + : null); + request.setSystemMessage(config.getSystemMessage()); + request.setAvailableTools(config.getAvailableTools()); + request.setExcludedTools(config.getExcludedTools()); + request.setProvider(config.getProvider()); + request.setRequestPermission(config.getOnPermissionRequest() != null ? true : null); + request.setRequestUserInput(config.getOnUserInputRequest() != null ? true : null); + request.setHooks(config.getHooks() != null && config.getHooks().hasHooks() ? true : null); + request.setWorkingDirectory(config.getWorkingDirectory()); + request.setStreaming(config.isStreaming() ? true : null); + request.setMcpServers(config.getMcpServers()); + request.setCustomAgents(config.getCustomAgents()); + request.setInfiniteSessions(config.getInfiniteSessions()); + request.setSkillDirectories(config.getSkillDirectories()); + request.setDisabledSkills(config.getDisabledSkills()); + request.setConfigDir(config.getConfigDir()); + + return request; + } + + /** + * Builds a ResumeSessionRequest from the given session ID and configuration. + * + * @param sessionId + * the ID of the session to resume + * @param config + * the resume configuration (may be null) + * @return the built request object + */ + static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionConfig config) { + var request = new ResumeSessionRequest(); + request.setSessionId(sessionId); + + if (config == null) { + return request; + } + + request.setModel(config.getModel()); + request.setReasoningEffort(config.getReasoningEffort()); + request.setTools(config.getTools() != null + ? config.getTools().stream().map(t -> new ToolDef(t.getName(), t.getDescription(), t.getParameters())) + .collect(Collectors.toList()) + : null); + request.setSystemMessage(config.getSystemMessage()); + request.setAvailableTools(config.getAvailableTools()); + request.setExcludedTools(config.getExcludedTools()); + request.setProvider(config.getProvider()); + request.setRequestPermission(config.getOnPermissionRequest() != null ? true : null); + request.setRequestUserInput(config.getOnUserInputRequest() != null ? true : null); + request.setHooks(config.getHooks() != null && config.getHooks().hasHooks() ? true : null); + request.setWorkingDirectory(config.getWorkingDirectory()); + request.setConfigDir(config.getConfigDir()); + request.setDisableResume(config.isDisableResume() ? true : null); + request.setStreaming(config.isStreaming() ? true : null); + request.setMcpServers(config.getMcpServers()); + request.setCustomAgents(config.getCustomAgents()); + request.setSkillDirectories(config.getSkillDirectories()); + request.setDisabledSkills(config.getDisabledSkills()); + request.setInfiniteSessions(config.getInfiniteSessions()); + + return request; + } + + /** + * Configures a session with handlers from the given config. + * + * @param session + * the session to configure + * @param config + * the session configuration + */ + static void configureSession(CopilotSession session, SessionConfig config) { + if (config == null) { + return; + } + + if (config.getTools() != null) { + session.registerTools(config.getTools()); + } + if (config.getOnPermissionRequest() != null) { + session.registerPermissionHandler(config.getOnPermissionRequest()); + } + if (config.getOnUserInputRequest() != null) { + session.registerUserInputHandler(config.getOnUserInputRequest()); + } + if (config.getHooks() != null) { + session.registerHooks(config.getHooks()); + } + } + + /** + * Configures a resumed session with handlers from the given config. + * + * @param session + * the session to configure + * @param config + * the resume session configuration + */ + static void configureSession(CopilotSession session, ResumeSessionConfig config) { + if (config == null) { + return; + } + + if (config.getTools() != null) { + session.registerTools(config.getTools()); + } + if (config.getOnPermissionRequest() != null) { + session.registerPermissionHandler(config.getOnPermissionRequest()); + } + if (config.getOnUserInputRequest() != null) { + session.registerUserInputHandler(config.getOnUserInputRequest()); + } + if (config.getHooks() != null) { + session.registerHooks(config.getHooks()); + } + } +} From 19545cfb549354e011b33bffd2462c0554d5290a Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Fri, 6 Feb 2026 00:24:47 -0800 Subject: [PATCH 042/109] fix: Update Table of Contents formatting for consistency --- src/site/markdown/advanced.md | 52 +++++++++++++++++------------------ 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/src/site/markdown/advanced.md b/src/site/markdown/advanced.md index 83bbb6f6f..7bd75932d 100644 --- a/src/site/markdown/advanced.md +++ b/src/site/markdown/advanced.md @@ -6,32 +6,32 @@ This guide covers advanced scenarios for extending and customizing your Copilot ## Table of Contents -- [Custom Tools](#custom-tools) -- [System Messages](#system-messages) - - [Adding Rules](#adding-rules) - - [Full Control](#full-control) -- [File Attachments](#file-attachments) -- [Bring Your Own Key (BYOK)](#bring-your-own-key-byok) -- [Infinite Sessions](#infinite-sessions) - - [Compaction Events](#compaction-events) -- [MCP Servers](#mcp-servers) -- [Skills Configuration](#skills-configuration) - - [Loading Skills](#loading-skills) - - [Disabling Skills](#disabling-skills) -- [Custom Configuration Directory](#custom-configuration-directory) -- [User Input Handling](#user-input-handling) -- [Permission Handling](#permission-handling) -- [Session Hooks](#session-hooks) -- [Manual Server Control](#manual-server-control) -- [Session Lifecycle Events](#session-lifecycle-events) - - [Subscribing to All Lifecycle Events](#subscribing-to-all-lifecycle-events) - - [Subscribing to Specific Event Types](#subscribing-to-specific-event-types) -- [Foreground Session Control (TUI+Server Mode)](#foreground-session-control-tuiserver-mode) - - [Getting the Foreground Session](#getting-the-foreground-session) - - [Setting the Foreground Session](#setting-the-foreground-session) -- [Error Handling](#error-handling) - - [Event Handler Exceptions](#event-handler-exceptions) - - [Custom Event Error Handler](#custom-event-error-handler) +- [Custom Tools](#Custom_Tools) +- [System Messages](#System_Messages) + - [Adding Rules](#Adding_Rules) + - [Full Control](#Full_Control) +- [File Attachments](#File_Attachments) +- [Bring Your Own Key (BYOK)](#Bring_Your_Own_Key_BYOK) +- [Infinite Sessions](#Infinite_Sessions) + - [Compaction Events](#Compaction_Events) +- [MCP Servers](#MCP_Servers) +- [Skills Configuration](#Skills_Configuration) + - [Loading Skills](#Loading_Skills) + - [Disabling Skills](#Disabling_Skills) +- [Custom Configuration Directory](#Custom_Configuration_Directory) +- [User Input Handling](#User_Input_Handling) +- [Permission Handling](#Permission_Handling) +- [Session Hooks](#Session_Hooks) +- [Manual Server Control](#Manual_Server_Control) +- [Session Lifecycle Events](#Session_Lifecycle_Events) + - [Subscribing to All Lifecycle Events](#Subscribing_to_All_Lifecycle_Events) + - [Subscribing to Specific Event Types](#Subscribing_to_Specific_Event_Types) +- [Foreground Session Control (TUI+Server Mode)](#Foreground_Session_Control_TUIServer_Mode) + - [Getting the Foreground Session](#Getting_the_Foreground_Session) + - [Setting the Foreground Session](#Setting_the_Foreground_Session) +- [Error Handling](#Error_Handling) + - [Event Handler Exceptions](#Event_Handler_Exceptions) + - [Custom Event Error Handler](#Custom_Event_Error_Handler) --- From 0e5893251cc49d16809f001773f61bafa747f8f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:36:26 +0000 Subject: [PATCH 043/109] Update EventErrorHandler to return boolean for error propagation control Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- .../github/copilot/sdk/CopilotSession.java | 70 +++- .../github/copilot/sdk/EventErrorHandler.java | 46 ++- .../copilot/sdk/SessionEventHandlingTest.java | 333 ++++++++++++++++-- 3 files changed, 393 insertions(+), 56 deletions(-) diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 38c7a96c8..ecfeecca7 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -158,26 +158,45 @@ public String getWorkspacePath() { *

* When an event handler registered via {@link #on(Consumer)} or * {@link #on(Class, Consumer)} throws an exception during event dispatch, the - * error handler is invoked instead of the default behavior (logging at - * {@link Level#SEVERE}). + * error handler is invoked to handle the error. The handler's return value + * controls whether dispatch continues to remaining handlers: + *

    + *
  • Return {@code true} to continue dispatching (default behavior for + * independent listeners)
  • + *
  • Return {@code false} to stop dispatching (short-circuit for validation + * or critical errors)
  • + *
* *

- * If the error handler itself throws an exception, that exception is silently - * caught and logged to prevent cascading failures. + * When no error handler is set, exceptions are silently caught and dispatch + * continues. Applications should set an error handler to log, track, or respond + * to handler failures. + * + *

+ * If the error handler itself throws an exception, that exception is caught, + * logged at {@link Level#SEVERE}, and dispatch is stopped to prevent cascading + * failures. * *

* Example: * *

{@code
+     * // Continue on error (log and keep dispatching)
+     * session.setEventErrorHandler((event, exception) -> {
+     * 	logger.error("Handler failed: {}", exception.getMessage(), exception);
+     * 	return true; // keep dispatching
+     * });
+     *
+     * // Short-circuit on error (stop at first failure)
      * session.setEventErrorHandler((event, exception) -> {
-     * 	metrics.increment("handler.errors");
-     * 	logger.error("Handler failed on {}: {}", event.getType(), exception.getMessage());
+     * 	logger.error("Handler failed, stopping: {}", exception.getMessage(), exception);
+     * 	return false; // stop dispatching
      * });
      * }
* * @param handler - * the error handler, or {@code null} to restore default logging - * behavior + * the error handler, or {@code null} to restore default silent + * continuation behavior * @see EventErrorHandler * @since 1.0.8 */ @@ -330,8 +349,10 @@ public CompletableFuture sendAndWait(MessageOptions optio * instead. * *

- * Exception isolation: If a handler throws an exception, the error is - * logged and remaining handlers still execute. + * Exception handling: If a handler throws an exception, the error is + * routed to the configured {@link EventErrorHandler} (if set), which can choose + * to continue or stop dispatching. When no error handler is set, exceptions are + * silently caught and remaining handlers still execute. * *

* Example: @@ -347,6 +368,7 @@ public CompletableFuture sendAndWait(MessageOptions optio * @return a Closeable that, when closed, unsubscribes the handler * @see #on(Class, Consumer) * @see AbstractSessionEvent + * @see #setEventErrorHandler(EventErrorHandler) */ public Closeable on(Consumer handler) { eventHandlers.add(handler); @@ -361,8 +383,10 @@ public Closeable on(Consumer handler) { * matching the specified type. * *

- * Exception isolation: If a handler throws an exception, the error is - * logged and remaining handlers still execute. + * Exception handling: If a handler throws an exception, the error is + * routed to the configured {@link EventErrorHandler} (if set), which can choose + * to continue or stop dispatching. When no error handler is set, exceptions are + * silently caught and remaining handlers still execute. * *

* Example Usage @@ -394,6 +418,7 @@ public Closeable on(Consumer handler) { * @return a Closeable that unsubscribes the handler when closed * @see #on(Consumer) * @see AbstractSessionEvent + * @see #setEventErrorHandler(EventErrorHandler) */ public Closeable on(Class eventType, Consumer handler) { Consumer wrapper = event -> { @@ -410,11 +435,20 @@ public Closeable on(Class eventType, Consume *

* This is called internally when events are received from the server. Each * handler is invoked in its own try/catch block so that an exception thrown by - * one handler does not prevent subsequent handlers from executing. + * one handler does not prevent subsequent handlers from executing by default. *

* If a custom {@link EventErrorHandler} has been set via * {@link #setEventErrorHandler(EventErrorHandler)}, it is called with the event - * and exception. Otherwise, exceptions are logged at {@link Level#SEVERE}. + * and exception. The error handler's return value controls whether dispatch + * continues: + *

    + *
  • {@code true} - continue dispatching to remaining handlers
  • + *
  • {@code false} - stop dispatching (short-circuit)
  • + *
+ *

+ * When no error handler is set, exceptions are silently caught and dispatch + * continues. If the error handler itself throws an exception, dispatch is + * stopped and the error is logged at {@link Level#SEVERE}. * * @param event * the event to dispatch @@ -428,13 +462,15 @@ void dispatchEvent(AbstractSessionEvent event) { EventErrorHandler errorHandler = this.eventErrorHandler; if (errorHandler != null) { try { - errorHandler.handleError(event, e); + if (!errorHandler.handleError(event, e)) { + break; // stop dispatching + } } catch (Exception errorHandlerException) { LOG.log(Level.SEVERE, "Error in event error handler", errorHandlerException); + break; // error handler itself failed β€” stop to be safe } - } else { - LOG.log(Level.SEVERE, "Error in event handler", e); } + // No error handler set: continue silently (no logging) } } } diff --git a/src/main/java/com/github/copilot/sdk/EventErrorHandler.java b/src/main/java/com/github/copilot/sdk/EventErrorHandler.java index f989a2945..231536945 100644 --- a/src/main/java/com/github/copilot/sdk/EventErrorHandler.java +++ b/src/main/java/com/github/copilot/sdk/EventErrorHandler.java @@ -16,20 +16,48 @@ * being dispatched and the exception that was thrown. * *

- * The default behavior logs errors at {@link java.util.logging.Level#SEVERE}. - * You can override this to integrate with your own logging, metrics, or - * error-reporting systems: + * The handler's return value controls whether event dispatch continues to + * remaining handlers: + *

    + *
  • Return {@code true} to continue dispatching to remaining handlers + * (default behavior for independent listeners)
  • + *
  • Return {@code false} to stop dispatching (short-circuit behavior for + * validation or critical errors)
  • + *
+ * + *

+ * When no error handler is set, exceptions are silently caught and dispatch + * continues to remaining handlers. This makes the SDK non-intrusive by default. + * Applications should set an error handler to log, track, or respond to handler + * failures. + * + *

+ * Example configurations: * *

{@code
+ * // Continue on error (log and keep dispatching)
+ * session.setEventErrorHandler((event, exception) -> {
+ *     logger.error("Handler failed: {}", exception.getMessage(), exception);
+ *     return true; // keep dispatching
+ * });
+ *
+ * // Short-circuit on error (stop at first failure)
+ * session.setEventErrorHandler((event, exception) -> {
+ *     logger.error("Handler failed, stopping: {}", exception.getMessage(), exception);
+ *     return false; // stop dispatching
+ * });
+ *
+ * // Selective: short-circuit only for critical events
  * session.setEventErrorHandler((event, exception) -> {
- * 	metrics.increment("handler.errors");
- * 	logger.error("Handler failed on {}: {}", event.getType(), exception.getMessage());
+ *     logger.error("Handler failed: {}", exception.getMessage(), exception);
+ *     return !(event instanceof SessionErrorEvent); // stop only for error events
  * });
  * }
* *

- * If the error handler itself throws an exception, that exception is silently - * caught and logged to prevent cascading failures. + * If the error handler itself throws an exception, that exception is caught, + * logged at {@link java.util.logging.Level#SEVERE}, and dispatch is stopped to + * prevent cascading failures. * * @see CopilotSession#setEventErrorHandler(EventErrorHandler) * @since 1.0.8 @@ -44,6 +72,8 @@ public interface EventErrorHandler { * the event that was being dispatched when the error occurred * @param exception * the exception thrown by the event handler + * @return {@code true} to continue dispatching to remaining handlers, + * {@code false} to stop dispatching (the exception is not rethrown) */ - void handleError(AbstractSessionEvent event, Exception exception); + boolean handleError(AbstractSessionEvent event, Exception exception); } diff --git a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java index 09b0e2e53..3c4d8cd55 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java @@ -381,21 +381,25 @@ void testConcurrentDispatchFromMultipleThreads() throws Exception { // ==================================================================== @Test - void testDefaultErrorHandlerLogsException() { - // Without a custom error handler, exceptions should be logged (no crash) - Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); - Level originalLevel = sessionLogger.getLevel(); - sessionLogger.setLevel(Level.OFF); + void testDefaultErrorHandlerContinuesSilently() { + // Without a custom error handler, exceptions should be caught silently (no crash, no logging) + var received = new ArrayList(); - try { - session.on(AssistantMessageEvent.class, msg -> { - throw new RuntimeException("boom"); - }); + // First handler throws + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("boom"); + }); - assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); - } finally { - sessionLogger.setLevel(originalLevel); - } + // Second handler should still execute (silent continuation) + session.on(AssistantMessageEvent.class, msg -> { + received.add(msg.getData().getContent()); + }); + + assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); + + // Second handler should have received the event + assertEquals(1, received.size()); + assertEquals("Test", received.get(0)); } @Test @@ -406,6 +410,7 @@ void testCustomEventErrorHandlerReceivesEventAndException() { session.setEventErrorHandler((event, exception) -> { capturedEvents.add(event); capturedExceptions.add(exception); + return true; // continue dispatching }); var thrownException = new RuntimeException("test error"); @@ -428,6 +433,7 @@ void testCustomErrorHandlerReplacesDefaultLogging() { session.setEventErrorHandler((event, exception) -> { errorCount.incrementAndGet(); + return true; // continue dispatching }); session.on(AssistantMessageEvent.class, msg -> { @@ -444,8 +450,9 @@ void testCustomErrorHandlerReplacesDefaultLogging() { } @Test - void testErrorHandlerItselfThrowingDoesNotBreakDispatch() { - var received = new ArrayList(); + void testErrorHandlerItselfThrowingStopsDispatch() { + var handler1Called = new AtomicInteger(0); + var handler2Called = new AtomicInteger(0); Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); Level originalLevel = sessionLogger.getLevel(); @@ -456,19 +463,21 @@ void testErrorHandlerItselfThrowingDoesNotBreakDispatch() { throw new RuntimeException("error handler also broke"); }); - // First handler throws + // Two handlers that throw session.on(AssistantMessageEvent.class, msg -> { + handler1Called.incrementAndGet(); throw new RuntimeException("handler error"); }); - // Second handler should still execute session.on(AssistantMessageEvent.class, msg -> { - received.add(msg.getData().getContent()); + handler2Called.incrementAndGet(); + throw new RuntimeException("handler error"); }); - assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Still works"))); - assertEquals(1, received.size()); - assertEquals("Still works", received.get(0)); + assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); + // Only one handler should have executed (dispatch stopped when error handler threw) + int totalCalls = handler1Called.get() + handler2Called.get(); + assertEquals(1, totalCalls, "Only one handler should have been called (dispatch stopped when error handler threw)"); } finally { sessionLogger.setLevel(originalLevel); } @@ -481,6 +490,7 @@ void testSetEventErrorHandlerToNullRestoresDefaultBehavior() { // Set custom handler session.setEventErrorHandler((event, exception) -> { errorCount.incrementAndGet(); + return true; // continue }); session.on(AssistantMessageEvent.class, msg -> { @@ -490,22 +500,22 @@ void testSetEventErrorHandlerToNullRestoresDefaultBehavior() { dispatchEvent(createAssistantMessageEvent("Test1")); assertEquals(1, errorCount.get()); - // Reset to null (restore default logging) + // Reset to null (restore default silent continuation) session.setEventErrorHandler(null); - // Suppress default logging for the next dispatch - Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); - Level originalLevel = sessionLogger.getLevel(); - sessionLogger.setLevel(Level.OFF); + // With no handler set, exceptions are caught silently (no logging) + // Second handler should still execute + var received = new ArrayList(); + session.on(AssistantMessageEvent.class, msg -> { + received.add(msg.getData().getContent()); + }); - try { - dispatchEvent(createAssistantMessageEvent("Test2")); - } finally { - sessionLogger.setLevel(originalLevel); - } + dispatchEvent(createAssistantMessageEvent("Test2")); // Custom handler should NOT have been called again assertEquals(1, errorCount.get()); + // Second handler should have executed (silent continuation) + assertEquals(1, received.size()); } @Test @@ -514,6 +524,7 @@ void testErrorHandlerReceivesCorrectEventType() { session.setEventErrorHandler((event, exception) -> { capturedEvents.add(event); + return true; // continue }); session.on(event -> { @@ -531,6 +542,266 @@ void testErrorHandlerReceivesCorrectEventType() { assertInstanceOf(SessionIdleEvent.class, capturedEvents.get(1)); } + // ==================================================================== + // Error propagation control tests (return true/false) + // ==================================================================== + + @Test + void testErrorHandlerReturningTrueContinuesDispatching() { + var handler1Called = new AtomicInteger(0); + var handler2Called = new AtomicInteger(0); + var handler3Called = new AtomicInteger(0); + + session.setEventErrorHandler((event, exception) -> { + return true; // continue dispatching + }); + + // First handler throws + session.on(AssistantMessageEvent.class, msg -> { + handler1Called.incrementAndGet(); + throw new RuntimeException("error"); + }); + + // Second handler should execute + session.on(AssistantMessageEvent.class, msg -> { + handler2Called.incrementAndGet(); + }); + + // Third handler should also execute + session.on(AssistantMessageEvent.class, msg -> { + handler3Called.incrementAndGet(); + }); + + dispatchEvent(createAssistantMessageEvent("Test")); + + // All handlers should have been called + assertEquals(1, handler1Called.get()); + assertEquals(1, handler2Called.get()); + assertEquals(1, handler3Called.get()); + } + + @Test + void testErrorHandlerReturningFalseStopsDispatching() { + var firstThrowingHandlerCalled = new AtomicInteger(0); + var secondThrowingHandlerCalled = new AtomicInteger(0); + var errorHandlerCalls = new AtomicInteger(0); + + session.setEventErrorHandler((event, exception) -> { + errorHandlerCalls.incrementAndGet(); + return false; // stop dispatching + }); + + // Two handlers that throw + session.on(AssistantMessageEvent.class, msg -> { + firstThrowingHandlerCalled.incrementAndGet(); + throw new RuntimeException("error 1"); + }); + + session.on(AssistantMessageEvent.class, msg -> { + secondThrowingHandlerCalled.incrementAndGet(); + throw new RuntimeException("error 2"); + }); + + dispatchEvent(createAssistantMessageEvent("Test")); + + // Error handler should be called only once (stopped after first error) + assertEquals(1, errorHandlerCalls.get()); + // At least one handler should have been called + int totalCalls = firstThrowingHandlerCalled.get() + secondThrowingHandlerCalled.get(); + assertEquals(1, totalCalls, "Only one handler should have been called (dispatch stopped after first error)"); + } + + @Test + void testErrorHandlerSelectiveShortCircuit() { + var handler1Called = new AtomicInteger(0); + var handler2Called = new AtomicInteger(0); + var errorHandlerCalls = new AtomicInteger(0); + + // Selective: short-circuit only for SessionIdleEvent + session.setEventErrorHandler((event, exception) -> { + errorHandlerCalls.incrementAndGet(); + return !(event instanceof SessionIdleEvent); // stop only for idle events + }); + + // Two handlers that throw + session.on(event -> { + handler1Called.incrementAndGet(); + throw new RuntimeException("error"); + }); + session.on(event -> { + handler2Called.incrementAndGet(); + throw new RuntimeException("error"); + }); + + // Dispatch AssistantMessageEvent - should continue + dispatchEvent(createAssistantMessageEvent("msg")); + + // Both handlers should be called for AssistantMessageEvent (continue = true) + assertEquals(2, errorHandlerCalls.get(), "Error handler should be called twice for AssistantMessageEvent"); + assertEquals(1, handler1Called.get()); + assertEquals(1, handler2Called.get()); + + handler1Called.set(0); + handler2Called.set(0); + + // Dispatch SessionIdleEvent - should stop + dispatchEvent(createSessionIdleEvent()); + + // Only one handler should throw for SessionIdleEvent (stopped after first) + assertEquals(3, errorHandlerCalls.get(), "Error handler should be called one more time for SessionIdleEvent"); + int totalCalls = handler1Called.get() + handler2Called.get(); + assertEquals(1, totalCalls, "Only one handler should have been called for SessionIdleEvent (dispatch stopped)"); + } + + @Test + void testMultipleErrorsWithContinueTrue() { + var errorHandlerCalls = new AtomicInteger(0); + var successfulHandlerCalls = new AtomicInteger(0); + + session.setEventErrorHandler((event, exception) -> { + errorHandlerCalls.incrementAndGet(); + return true; // continue + }); + + // Multiple handlers that throw + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("error 1"); + }); + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("error 2"); + }); + session.on(AssistantMessageEvent.class, msg -> { + successfulHandlerCalls.incrementAndGet(); + }); + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("error 3"); + }); + + dispatchEvent(createAssistantMessageEvent("Test")); + + // Error handler should be called 3 times (for 3 exceptions) + assertEquals(3, errorHandlerCalls.get()); + // Successful handler should be called once + assertEquals(1, successfulHandlerCalls.get()); + } + + @Test + void testMultipleErrorsWithContinueFalse() { + var errorHandlerCalls = new AtomicInteger(0); + var successfulHandlerCalls = new AtomicInteger(0); + + session.setEventErrorHandler((event, exception) -> { + errorHandlerCalls.incrementAndGet(); + return false; // stop + }); + + // Multiple handlers that throw + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("error 1"); + }); + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("error 2"); + }); + session.on(AssistantMessageEvent.class, msg -> { + successfulHandlerCalls.incrementAndGet(); + }); + + dispatchEvent(createAssistantMessageEvent("Test")); + + // Error handler should be called only once (stopped after first error) + assertEquals(1, errorHandlerCalls.get()); + // Successful handler should never be called + assertEquals(0, successfulHandlerCalls.get()); + } + + @Test + void testNoErrorHandlerSetContinuesSilently() { + var throwingHandlerCalled = new AtomicInteger(0); + var normalHandlerCalled = new AtomicInteger(0); + + // No error handler set + session.setEventErrorHandler(null); + + // First handler throws + session.on(AssistantMessageEvent.class, msg -> { + throwingHandlerCalled.incrementAndGet(); + throw new RuntimeException("error"); + }); + + // Second handler should execute (silent continuation) + session.on(AssistantMessageEvent.class, msg -> { + normalHandlerCalled.incrementAndGet(); + }); + + dispatchEvent(createAssistantMessageEvent("Test")); + + // Both handlers should have been called + assertEquals(1, throwingHandlerCalled.get()); + assertEquals(1, normalHandlerCalled.get()); + } + + @Test + void testErrorHandlerCanInspectExceptionToDecide() { + var illegalStateHandlerCalled = new AtomicInteger(0); + var runtimeHandlerCalled = new AtomicInteger(0); + var errorHandlerCalls = new AtomicInteger(0); + + // Continue on IllegalStateException, stop on RuntimeException + session.setEventErrorHandler((event, exception) -> { + errorHandlerCalls.incrementAndGet(); + return exception instanceof IllegalStateException; // continue only for IllegalStateException + }); + + // Two handlers that throw IllegalStateException + session.on(AssistantMessageEvent.class, msg -> { + illegalStateHandlerCalled.incrementAndGet(); + throw new IllegalStateException("state error"); + }); + + session.on(AssistantMessageEvent.class, msg -> { + illegalStateHandlerCalled.incrementAndGet(); + throw new IllegalStateException("state error"); + }); + + dispatchEvent(createAssistantMessageEvent("Test1")); + + // Both handlers should have been called (continued on IllegalStateException) + assertEquals(2, illegalStateHandlerCalled.get()); + assertEquals(2, errorHandlerCalls.get()); + + // Create a new session for the second part + try { + session = createTestSession(); + } catch (Exception e) { + fail("Failed to create test session: " + e.getMessage()); + } + + errorHandlerCalls.set(0); + + // Set same error handler + session.setEventErrorHandler((event, exception) -> { + errorHandlerCalls.incrementAndGet(); + return exception instanceof IllegalStateException; // continue only for IllegalStateException + }); + + // Two handlers that throw RuntimeException + session.on(AssistantMessageEvent.class, msg -> { + runtimeHandlerCalled.incrementAndGet(); + throw new RuntimeException("runtime error"); + }); + + session.on(AssistantMessageEvent.class, msg -> { + runtimeHandlerCalled.incrementAndGet(); + throw new RuntimeException("runtime error"); + }); + + dispatchEvent(createAssistantMessageEvent("Test2")); + + // Only one handler should have been called (stopped on RuntimeException) + assertEquals(1, runtimeHandlerCalled.get()); + assertEquals(1, errorHandlerCalls.get()); + } + // ==================================================================== // Helper methods // ==================================================================== From dec438614024a4277522ee77df4ec80295b60bca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:36:50 +0000 Subject: [PATCH 044/109] Apply spotless formatting Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- .../java/com/github/copilot/sdk/CopilotSession.java | 4 ++-- .../com/github/copilot/sdk/EventErrorHandler.java | 12 ++++++------ .../github/copilot/sdk/SessionEventHandlingTest.java | 9 ++++++--- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index ecfeecca7..5333477d0 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -163,8 +163,8 @@ public String getWorkspacePath() { *

    *
  • Return {@code true} to continue dispatching (default behavior for * independent listeners)
  • - *
  • Return {@code false} to stop dispatching (short-circuit for validation - * or critical errors)
  • + *
  • Return {@code false} to stop dispatching (short-circuit for validation or + * critical errors)
  • *
* *

diff --git a/src/main/java/com/github/copilot/sdk/EventErrorHandler.java b/src/main/java/com/github/copilot/sdk/EventErrorHandler.java index 231536945..42ab07771 100644 --- a/src/main/java/com/github/copilot/sdk/EventErrorHandler.java +++ b/src/main/java/com/github/copilot/sdk/EventErrorHandler.java @@ -37,20 +37,20 @@ *

{@code
  * // Continue on error (log and keep dispatching)
  * session.setEventErrorHandler((event, exception) -> {
- *     logger.error("Handler failed: {}", exception.getMessage(), exception);
- *     return true; // keep dispatching
+ * 	logger.error("Handler failed: {}", exception.getMessage(), exception);
+ * 	return true; // keep dispatching
  * });
  *
  * // Short-circuit on error (stop at first failure)
  * session.setEventErrorHandler((event, exception) -> {
- *     logger.error("Handler failed, stopping: {}", exception.getMessage(), exception);
- *     return false; // stop dispatching
+ * 	logger.error("Handler failed, stopping: {}", exception.getMessage(), exception);
+ * 	return false; // stop dispatching
  * });
  *
  * // Selective: short-circuit only for critical events
  * session.setEventErrorHandler((event, exception) -> {
- *     logger.error("Handler failed: {}", exception.getMessage(), exception);
- *     return !(event instanceof SessionErrorEvent); // stop only for error events
+ * 	logger.error("Handler failed: {}", exception.getMessage(), exception);
+ * 	return !(event instanceof SessionErrorEvent); // stop only for error events
  * });
  * }
* diff --git a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java index 3c4d8cd55..d5aa61d66 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java @@ -382,7 +382,8 @@ void testConcurrentDispatchFromMultipleThreads() throws Exception { @Test void testDefaultErrorHandlerContinuesSilently() { - // Without a custom error handler, exceptions should be caught silently (no crash, no logging) + // Without a custom error handler, exceptions should be caught silently (no + // crash, no logging) var received = new ArrayList(); // First handler throws @@ -475,9 +476,11 @@ void testErrorHandlerItselfThrowingStopsDispatch() { }); assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); - // Only one handler should have executed (dispatch stopped when error handler threw) + // Only one handler should have executed (dispatch stopped when error handler + // threw) int totalCalls = handler1Called.get() + handler2Called.get(); - assertEquals(1, totalCalls, "Only one handler should have been called (dispatch stopped when error handler threw)"); + assertEquals(1, totalCalls, + "Only one handler should have been called (dispatch stopped when error handler threw)"); } finally { sessionLogger.setLevel(originalLevel); } From 860038530373c283807c038d8803a87325f5b6f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:38:18 +0000 Subject: [PATCH 045/109] Address code review feedback Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- src/main/java/com/github/copilot/sdk/CopilotSession.java | 3 ++- src/main/java/com/github/copilot/sdk/EventErrorHandler.java | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 5333477d0..cc52c2287 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -469,8 +469,9 @@ void dispatchEvent(AbstractSessionEvent event) { LOG.log(Level.SEVERE, "Error in event error handler", errorHandlerException); break; // error handler itself failed β€” stop to be safe } + } else { + // No error handler set: continue silently (no logging) } - // No error handler set: continue silently (no logging) } } } diff --git a/src/main/java/com/github/copilot/sdk/EventErrorHandler.java b/src/main/java/com/github/copilot/sdk/EventErrorHandler.java index 42ab07771..1de3ca9fe 100644 --- a/src/main/java/com/github/copilot/sdk/EventErrorHandler.java +++ b/src/main/java/com/github/copilot/sdk/EventErrorHandler.java @@ -57,7 +57,8 @@ *

* If the error handler itself throws an exception, that exception is caught, * logged at {@link java.util.logging.Level#SEVERE}, and dispatch is stopped to - * prevent cascading failures. + * prevent cascading failures. When the error handler throws, its return value + * (if it would have been computed) is ignored. * * @see CopilotSession#setEventErrorHandler(EventErrorHandler) * @since 1.0.8 From 1ee3b61a9b567a0937128d90f5171396ba518021 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:43:27 +0000 Subject: [PATCH 046/109] Revert EventErrorHandler breaking change to implement enum-based approach Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- .../github/copilot/sdk/CopilotSession.java | 69 +--- .../github/copilot/sdk/EventErrorHandler.java | 47 +-- .../copilot/sdk/SessionEventHandlingTest.java | 336 ++---------------- 3 files changed, 55 insertions(+), 397 deletions(-) diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index cc52c2287..38c7a96c8 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -158,45 +158,26 @@ public String getWorkspacePath() { *

* When an event handler registered via {@link #on(Consumer)} or * {@link #on(Class, Consumer)} throws an exception during event dispatch, the - * error handler is invoked to handle the error. The handler's return value - * controls whether dispatch continues to remaining handlers: - *

    - *
  • Return {@code true} to continue dispatching (default behavior for - * independent listeners)
  • - *
  • Return {@code false} to stop dispatching (short-circuit for validation or - * critical errors)
  • - *
+ * error handler is invoked instead of the default behavior (logging at + * {@link Level#SEVERE}). * *

- * When no error handler is set, exceptions are silently caught and dispatch - * continues. Applications should set an error handler to log, track, or respond - * to handler failures. - * - *

- * If the error handler itself throws an exception, that exception is caught, - * logged at {@link Level#SEVERE}, and dispatch is stopped to prevent cascading - * failures. + * If the error handler itself throws an exception, that exception is silently + * caught and logged to prevent cascading failures. * *

* Example: * *

{@code
-     * // Continue on error (log and keep dispatching)
-     * session.setEventErrorHandler((event, exception) -> {
-     * 	logger.error("Handler failed: {}", exception.getMessage(), exception);
-     * 	return true; // keep dispatching
-     * });
-     *
-     * // Short-circuit on error (stop at first failure)
      * session.setEventErrorHandler((event, exception) -> {
-     * 	logger.error("Handler failed, stopping: {}", exception.getMessage(), exception);
-     * 	return false; // stop dispatching
+     * 	metrics.increment("handler.errors");
+     * 	logger.error("Handler failed on {}: {}", event.getType(), exception.getMessage());
      * });
      * }
* * @param handler - * the error handler, or {@code null} to restore default silent - * continuation behavior + * the error handler, or {@code null} to restore default logging + * behavior * @see EventErrorHandler * @since 1.0.8 */ @@ -349,10 +330,8 @@ public CompletableFuture sendAndWait(MessageOptions optio * instead. * *

- * Exception handling: If a handler throws an exception, the error is - * routed to the configured {@link EventErrorHandler} (if set), which can choose - * to continue or stop dispatching. When no error handler is set, exceptions are - * silently caught and remaining handlers still execute. + * Exception isolation: If a handler throws an exception, the error is + * logged and remaining handlers still execute. * *

* Example: @@ -368,7 +347,6 @@ public CompletableFuture sendAndWait(MessageOptions optio * @return a Closeable that, when closed, unsubscribes the handler * @see #on(Class, Consumer) * @see AbstractSessionEvent - * @see #setEventErrorHandler(EventErrorHandler) */ public Closeable on(Consumer handler) { eventHandlers.add(handler); @@ -383,10 +361,8 @@ public Closeable on(Consumer handler) { * matching the specified type. * *

- * Exception handling: If a handler throws an exception, the error is - * routed to the configured {@link EventErrorHandler} (if set), which can choose - * to continue or stop dispatching. When no error handler is set, exceptions are - * silently caught and remaining handlers still execute. + * Exception isolation: If a handler throws an exception, the error is + * logged and remaining handlers still execute. * *

* Example Usage @@ -418,7 +394,6 @@ public Closeable on(Consumer handler) { * @return a Closeable that unsubscribes the handler when closed * @see #on(Consumer) * @see AbstractSessionEvent - * @see #setEventErrorHandler(EventErrorHandler) */ public Closeable on(Class eventType, Consumer handler) { Consumer wrapper = event -> { @@ -435,20 +410,11 @@ public Closeable on(Class eventType, Consume *

* This is called internally when events are received from the server. Each * handler is invoked in its own try/catch block so that an exception thrown by - * one handler does not prevent subsequent handlers from executing by default. + * one handler does not prevent subsequent handlers from executing. *

* If a custom {@link EventErrorHandler} has been set via * {@link #setEventErrorHandler(EventErrorHandler)}, it is called with the event - * and exception. The error handler's return value controls whether dispatch - * continues: - *

    - *
  • {@code true} - continue dispatching to remaining handlers
  • - *
  • {@code false} - stop dispatching (short-circuit)
  • - *
- *

- * When no error handler is set, exceptions are silently caught and dispatch - * continues. If the error handler itself throws an exception, dispatch is - * stopped and the error is logged at {@link Level#SEVERE}. + * and exception. Otherwise, exceptions are logged at {@link Level#SEVERE}. * * @param event * the event to dispatch @@ -462,15 +428,12 @@ void dispatchEvent(AbstractSessionEvent event) { EventErrorHandler errorHandler = this.eventErrorHandler; if (errorHandler != null) { try { - if (!errorHandler.handleError(event, e)) { - break; // stop dispatching - } + errorHandler.handleError(event, e); } catch (Exception errorHandlerException) { LOG.log(Level.SEVERE, "Error in event error handler", errorHandlerException); - break; // error handler itself failed β€” stop to be safe } } else { - // No error handler set: continue silently (no logging) + LOG.log(Level.SEVERE, "Error in event handler", e); } } } diff --git a/src/main/java/com/github/copilot/sdk/EventErrorHandler.java b/src/main/java/com/github/copilot/sdk/EventErrorHandler.java index 1de3ca9fe..f989a2945 100644 --- a/src/main/java/com/github/copilot/sdk/EventErrorHandler.java +++ b/src/main/java/com/github/copilot/sdk/EventErrorHandler.java @@ -16,49 +16,20 @@ * being dispatched and the exception that was thrown. * *

- * The handler's return value controls whether event dispatch continues to - * remaining handlers: - *

    - *
  • Return {@code true} to continue dispatching to remaining handlers - * (default behavior for independent listeners)
  • - *
  • Return {@code false} to stop dispatching (short-circuit behavior for - * validation or critical errors)
  • - *
- * - *

- * When no error handler is set, exceptions are silently caught and dispatch - * continues to remaining handlers. This makes the SDK non-intrusive by default. - * Applications should set an error handler to log, track, or respond to handler - * failures. - * - *

- * Example configurations: + * The default behavior logs errors at {@link java.util.logging.Level#SEVERE}. + * You can override this to integrate with your own logging, metrics, or + * error-reporting systems: * *

{@code
- * // Continue on error (log and keep dispatching)
- * session.setEventErrorHandler((event, exception) -> {
- * 	logger.error("Handler failed: {}", exception.getMessage(), exception);
- * 	return true; // keep dispatching
- * });
- *
- * // Short-circuit on error (stop at first failure)
- * session.setEventErrorHandler((event, exception) -> {
- * 	logger.error("Handler failed, stopping: {}", exception.getMessage(), exception);
- * 	return false; // stop dispatching
- * });
- *
- * // Selective: short-circuit only for critical events
  * session.setEventErrorHandler((event, exception) -> {
- * 	logger.error("Handler failed: {}", exception.getMessage(), exception);
- * 	return !(event instanceof SessionErrorEvent); // stop only for error events
+ * 	metrics.increment("handler.errors");
+ * 	logger.error("Handler failed on {}: {}", event.getType(), exception.getMessage());
  * });
  * }
* *

- * If the error handler itself throws an exception, that exception is caught, - * logged at {@link java.util.logging.Level#SEVERE}, and dispatch is stopped to - * prevent cascading failures. When the error handler throws, its return value - * (if it would have been computed) is ignored. + * If the error handler itself throws an exception, that exception is silently + * caught and logged to prevent cascading failures. * * @see CopilotSession#setEventErrorHandler(EventErrorHandler) * @since 1.0.8 @@ -73,8 +44,6 @@ public interface EventErrorHandler { * the event that was being dispatched when the error occurred * @param exception * the exception thrown by the event handler - * @return {@code true} to continue dispatching to remaining handlers, - * {@code false} to stop dispatching (the exception is not rethrown) */ - boolean handleError(AbstractSessionEvent event, Exception exception); + void handleError(AbstractSessionEvent event, Exception exception); } diff --git a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java index d5aa61d66..09b0e2e53 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java @@ -381,26 +381,21 @@ void testConcurrentDispatchFromMultipleThreads() throws Exception { // ==================================================================== @Test - void testDefaultErrorHandlerContinuesSilently() { - // Without a custom error handler, exceptions should be caught silently (no - // crash, no logging) - var received = new ArrayList(); - - // First handler throws - session.on(AssistantMessageEvent.class, msg -> { - throw new RuntimeException("boom"); - }); - - // Second handler should still execute (silent continuation) - session.on(AssistantMessageEvent.class, msg -> { - received.add(msg.getData().getContent()); - }); + void testDefaultErrorHandlerLogsException() { + // Without a custom error handler, exceptions should be logged (no crash) + Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); + Level originalLevel = sessionLogger.getLevel(); + sessionLogger.setLevel(Level.OFF); - assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); + try { + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("boom"); + }); - // Second handler should have received the event - assertEquals(1, received.size()); - assertEquals("Test", received.get(0)); + assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); + } finally { + sessionLogger.setLevel(originalLevel); + } } @Test @@ -411,7 +406,6 @@ void testCustomEventErrorHandlerReceivesEventAndException() { session.setEventErrorHandler((event, exception) -> { capturedEvents.add(event); capturedExceptions.add(exception); - return true; // continue dispatching }); var thrownException = new RuntimeException("test error"); @@ -434,7 +428,6 @@ void testCustomErrorHandlerReplacesDefaultLogging() { session.setEventErrorHandler((event, exception) -> { errorCount.incrementAndGet(); - return true; // continue dispatching }); session.on(AssistantMessageEvent.class, msg -> { @@ -451,9 +444,8 @@ void testCustomErrorHandlerReplacesDefaultLogging() { } @Test - void testErrorHandlerItselfThrowingStopsDispatch() { - var handler1Called = new AtomicInteger(0); - var handler2Called = new AtomicInteger(0); + void testErrorHandlerItselfThrowingDoesNotBreakDispatch() { + var received = new ArrayList(); Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); Level originalLevel = sessionLogger.getLevel(); @@ -464,23 +456,19 @@ void testErrorHandlerItselfThrowingStopsDispatch() { throw new RuntimeException("error handler also broke"); }); - // Two handlers that throw + // First handler throws session.on(AssistantMessageEvent.class, msg -> { - handler1Called.incrementAndGet(); throw new RuntimeException("handler error"); }); + // Second handler should still execute session.on(AssistantMessageEvent.class, msg -> { - handler2Called.incrementAndGet(); - throw new RuntimeException("handler error"); + received.add(msg.getData().getContent()); }); - assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); - // Only one handler should have executed (dispatch stopped when error handler - // threw) - int totalCalls = handler1Called.get() + handler2Called.get(); - assertEquals(1, totalCalls, - "Only one handler should have been called (dispatch stopped when error handler threw)"); + assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Still works"))); + assertEquals(1, received.size()); + assertEquals("Still works", received.get(0)); } finally { sessionLogger.setLevel(originalLevel); } @@ -493,7 +481,6 @@ void testSetEventErrorHandlerToNullRestoresDefaultBehavior() { // Set custom handler session.setEventErrorHandler((event, exception) -> { errorCount.incrementAndGet(); - return true; // continue }); session.on(AssistantMessageEvent.class, msg -> { @@ -503,22 +490,22 @@ void testSetEventErrorHandlerToNullRestoresDefaultBehavior() { dispatchEvent(createAssistantMessageEvent("Test1")); assertEquals(1, errorCount.get()); - // Reset to null (restore default silent continuation) + // Reset to null (restore default logging) session.setEventErrorHandler(null); - // With no handler set, exceptions are caught silently (no logging) - // Second handler should still execute - var received = new ArrayList(); - session.on(AssistantMessageEvent.class, msg -> { - received.add(msg.getData().getContent()); - }); + // Suppress default logging for the next dispatch + Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); + Level originalLevel = sessionLogger.getLevel(); + sessionLogger.setLevel(Level.OFF); - dispatchEvent(createAssistantMessageEvent("Test2")); + try { + dispatchEvent(createAssistantMessageEvent("Test2")); + } finally { + sessionLogger.setLevel(originalLevel); + } // Custom handler should NOT have been called again assertEquals(1, errorCount.get()); - // Second handler should have executed (silent continuation) - assertEquals(1, received.size()); } @Test @@ -527,7 +514,6 @@ void testErrorHandlerReceivesCorrectEventType() { session.setEventErrorHandler((event, exception) -> { capturedEvents.add(event); - return true; // continue }); session.on(event -> { @@ -545,266 +531,6 @@ void testErrorHandlerReceivesCorrectEventType() { assertInstanceOf(SessionIdleEvent.class, capturedEvents.get(1)); } - // ==================================================================== - // Error propagation control tests (return true/false) - // ==================================================================== - - @Test - void testErrorHandlerReturningTrueContinuesDispatching() { - var handler1Called = new AtomicInteger(0); - var handler2Called = new AtomicInteger(0); - var handler3Called = new AtomicInteger(0); - - session.setEventErrorHandler((event, exception) -> { - return true; // continue dispatching - }); - - // First handler throws - session.on(AssistantMessageEvent.class, msg -> { - handler1Called.incrementAndGet(); - throw new RuntimeException("error"); - }); - - // Second handler should execute - session.on(AssistantMessageEvent.class, msg -> { - handler2Called.incrementAndGet(); - }); - - // Third handler should also execute - session.on(AssistantMessageEvent.class, msg -> { - handler3Called.incrementAndGet(); - }); - - dispatchEvent(createAssistantMessageEvent("Test")); - - // All handlers should have been called - assertEquals(1, handler1Called.get()); - assertEquals(1, handler2Called.get()); - assertEquals(1, handler3Called.get()); - } - - @Test - void testErrorHandlerReturningFalseStopsDispatching() { - var firstThrowingHandlerCalled = new AtomicInteger(0); - var secondThrowingHandlerCalled = new AtomicInteger(0); - var errorHandlerCalls = new AtomicInteger(0); - - session.setEventErrorHandler((event, exception) -> { - errorHandlerCalls.incrementAndGet(); - return false; // stop dispatching - }); - - // Two handlers that throw - session.on(AssistantMessageEvent.class, msg -> { - firstThrowingHandlerCalled.incrementAndGet(); - throw new RuntimeException("error 1"); - }); - - session.on(AssistantMessageEvent.class, msg -> { - secondThrowingHandlerCalled.incrementAndGet(); - throw new RuntimeException("error 2"); - }); - - dispatchEvent(createAssistantMessageEvent("Test")); - - // Error handler should be called only once (stopped after first error) - assertEquals(1, errorHandlerCalls.get()); - // At least one handler should have been called - int totalCalls = firstThrowingHandlerCalled.get() + secondThrowingHandlerCalled.get(); - assertEquals(1, totalCalls, "Only one handler should have been called (dispatch stopped after first error)"); - } - - @Test - void testErrorHandlerSelectiveShortCircuit() { - var handler1Called = new AtomicInteger(0); - var handler2Called = new AtomicInteger(0); - var errorHandlerCalls = new AtomicInteger(0); - - // Selective: short-circuit only for SessionIdleEvent - session.setEventErrorHandler((event, exception) -> { - errorHandlerCalls.incrementAndGet(); - return !(event instanceof SessionIdleEvent); // stop only for idle events - }); - - // Two handlers that throw - session.on(event -> { - handler1Called.incrementAndGet(); - throw new RuntimeException("error"); - }); - session.on(event -> { - handler2Called.incrementAndGet(); - throw new RuntimeException("error"); - }); - - // Dispatch AssistantMessageEvent - should continue - dispatchEvent(createAssistantMessageEvent("msg")); - - // Both handlers should be called for AssistantMessageEvent (continue = true) - assertEquals(2, errorHandlerCalls.get(), "Error handler should be called twice for AssistantMessageEvent"); - assertEquals(1, handler1Called.get()); - assertEquals(1, handler2Called.get()); - - handler1Called.set(0); - handler2Called.set(0); - - // Dispatch SessionIdleEvent - should stop - dispatchEvent(createSessionIdleEvent()); - - // Only one handler should throw for SessionIdleEvent (stopped after first) - assertEquals(3, errorHandlerCalls.get(), "Error handler should be called one more time for SessionIdleEvent"); - int totalCalls = handler1Called.get() + handler2Called.get(); - assertEquals(1, totalCalls, "Only one handler should have been called for SessionIdleEvent (dispatch stopped)"); - } - - @Test - void testMultipleErrorsWithContinueTrue() { - var errorHandlerCalls = new AtomicInteger(0); - var successfulHandlerCalls = new AtomicInteger(0); - - session.setEventErrorHandler((event, exception) -> { - errorHandlerCalls.incrementAndGet(); - return true; // continue - }); - - // Multiple handlers that throw - session.on(AssistantMessageEvent.class, msg -> { - throw new RuntimeException("error 1"); - }); - session.on(AssistantMessageEvent.class, msg -> { - throw new RuntimeException("error 2"); - }); - session.on(AssistantMessageEvent.class, msg -> { - successfulHandlerCalls.incrementAndGet(); - }); - session.on(AssistantMessageEvent.class, msg -> { - throw new RuntimeException("error 3"); - }); - - dispatchEvent(createAssistantMessageEvent("Test")); - - // Error handler should be called 3 times (for 3 exceptions) - assertEquals(3, errorHandlerCalls.get()); - // Successful handler should be called once - assertEquals(1, successfulHandlerCalls.get()); - } - - @Test - void testMultipleErrorsWithContinueFalse() { - var errorHandlerCalls = new AtomicInteger(0); - var successfulHandlerCalls = new AtomicInteger(0); - - session.setEventErrorHandler((event, exception) -> { - errorHandlerCalls.incrementAndGet(); - return false; // stop - }); - - // Multiple handlers that throw - session.on(AssistantMessageEvent.class, msg -> { - throw new RuntimeException("error 1"); - }); - session.on(AssistantMessageEvent.class, msg -> { - throw new RuntimeException("error 2"); - }); - session.on(AssistantMessageEvent.class, msg -> { - successfulHandlerCalls.incrementAndGet(); - }); - - dispatchEvent(createAssistantMessageEvent("Test")); - - // Error handler should be called only once (stopped after first error) - assertEquals(1, errorHandlerCalls.get()); - // Successful handler should never be called - assertEquals(0, successfulHandlerCalls.get()); - } - - @Test - void testNoErrorHandlerSetContinuesSilently() { - var throwingHandlerCalled = new AtomicInteger(0); - var normalHandlerCalled = new AtomicInteger(0); - - // No error handler set - session.setEventErrorHandler(null); - - // First handler throws - session.on(AssistantMessageEvent.class, msg -> { - throwingHandlerCalled.incrementAndGet(); - throw new RuntimeException("error"); - }); - - // Second handler should execute (silent continuation) - session.on(AssistantMessageEvent.class, msg -> { - normalHandlerCalled.incrementAndGet(); - }); - - dispatchEvent(createAssistantMessageEvent("Test")); - - // Both handlers should have been called - assertEquals(1, throwingHandlerCalled.get()); - assertEquals(1, normalHandlerCalled.get()); - } - - @Test - void testErrorHandlerCanInspectExceptionToDecide() { - var illegalStateHandlerCalled = new AtomicInteger(0); - var runtimeHandlerCalled = new AtomicInteger(0); - var errorHandlerCalls = new AtomicInteger(0); - - // Continue on IllegalStateException, stop on RuntimeException - session.setEventErrorHandler((event, exception) -> { - errorHandlerCalls.incrementAndGet(); - return exception instanceof IllegalStateException; // continue only for IllegalStateException - }); - - // Two handlers that throw IllegalStateException - session.on(AssistantMessageEvent.class, msg -> { - illegalStateHandlerCalled.incrementAndGet(); - throw new IllegalStateException("state error"); - }); - - session.on(AssistantMessageEvent.class, msg -> { - illegalStateHandlerCalled.incrementAndGet(); - throw new IllegalStateException("state error"); - }); - - dispatchEvent(createAssistantMessageEvent("Test1")); - - // Both handlers should have been called (continued on IllegalStateException) - assertEquals(2, illegalStateHandlerCalled.get()); - assertEquals(2, errorHandlerCalls.get()); - - // Create a new session for the second part - try { - session = createTestSession(); - } catch (Exception e) { - fail("Failed to create test session: " + e.getMessage()); - } - - errorHandlerCalls.set(0); - - // Set same error handler - session.setEventErrorHandler((event, exception) -> { - errorHandlerCalls.incrementAndGet(); - return exception instanceof IllegalStateException; // continue only for IllegalStateException - }); - - // Two handlers that throw RuntimeException - session.on(AssistantMessageEvent.class, msg -> { - runtimeHandlerCalled.incrementAndGet(); - throw new RuntimeException("runtime error"); - }); - - session.on(AssistantMessageEvent.class, msg -> { - runtimeHandlerCalled.incrementAndGet(); - throw new RuntimeException("runtime error"); - }); - - dispatchEvent(createAssistantMessageEvent("Test2")); - - // Only one handler should have been called (stopped on RuntimeException) - assertEquals(1, runtimeHandlerCalled.get()); - assertEquals(1, errorHandlerCalls.get()); - } - // ==================================================================== // Helper methods // ==================================================================== From 9b0fe25e94d70f223e38e95b74e5eed46bd606d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:49:14 +0000 Subject: [PATCH 047/109] Implement EventErrorPolicy enum instead of breaking EventErrorHandler change Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- .../github/copilot/sdk/CopilotSession.java | 91 +++++- .../github/copilot/sdk/EventErrorHandler.java | 17 +- .../github/copilot/sdk/EventErrorPolicy.java | 53 ++++ .../copilot/sdk/SessionEventHandlingTest.java | 273 +++++++++++++++--- 4 files changed, 380 insertions(+), 54 deletions(-) create mode 100644 src/main/java/com/github/copilot/sdk/EventErrorPolicy.java diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 38c7a96c8..b63e78790 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -96,6 +96,7 @@ public final class CopilotSession implements AutoCloseable { private final AtomicReference userInputHandler = new AtomicReference<>(); private final AtomicReference hooksHandler = new AtomicReference<>(); private volatile EventErrorHandler eventErrorHandler; + private volatile EventErrorPolicy eventErrorPolicy = EventErrorPolicy.CONTINUE; /** * Creates a new session with the given ID and RPC client. @@ -158,12 +159,18 @@ public String getWorkspacePath() { *

* When an event handler registered via {@link #on(Consumer)} or * {@link #on(Class, Consumer)} throws an exception during event dispatch, the - * error handler is invoked instead of the default behavior (logging at - * {@link Level#SEVERE}). + * error handler is invoked with the event and exception. When no error handler + * is set, exceptions are silently consumed. * *

- * If the error handler itself throws an exception, that exception is silently - * caught and logged to prevent cascading failures. + * Whether dispatch continues or stops after an error is controlled by the + * {@link EventErrorPolicy} set via {@link #setEventErrorPolicy}. The error + * handler is always invoked regardless of the policy. + * + *

+ * If the error handler itself throws an exception, that exception is caught and + * logged at {@link Level#SEVERE}, and dispatch is stopped regardless of the + * configured policy. * *

* Example: @@ -176,15 +183,53 @@ public String getWorkspacePath() { * }

* * @param handler - * the error handler, or {@code null} to restore default logging + * the error handler, or {@code null} to restore default silent * behavior * @see EventErrorHandler + * @see #setEventErrorPolicy(EventErrorPolicy) * @since 1.0.8 */ public void setEventErrorHandler(EventErrorHandler handler) { this.eventErrorHandler = handler; } + /** + * Sets the error propagation policy for event dispatch. + *

+ * Controls whether remaining event listeners continue to execute when a + * preceding listener throws an exception. + * + *

    + *
  • {@link EventErrorPolicy#CONTINUE} (default) β€” dispatch to all remaining + * listeners regardless of errors
  • + *
  • {@link EventErrorPolicy#STOP} β€” stop dispatching after the first + * error
  • + *
+ * + *

+ * The configured {@link EventErrorHandler} (if any) is always invoked + * regardless of the policy. + * + *

+ * Example: + * + *

{@code
+     * // Opt-in to short-circuit on first error
+     * session.setEventErrorPolicy(EventErrorPolicy.STOP);
+     * session.setEventErrorHandler(
+     * 		(event, ex) -> logger.error("Handler failed, stopping dispatch: {}", ex.getMessage(), ex));
+     * }
+ * + * @param policy + * the error policy (default is {@link EventErrorPolicy#CONTINUE}) + * @see EventErrorPolicy + * @see #setEventErrorHandler(EventErrorHandler) + * @since 1.0.8 + */ + public void setEventErrorPolicy(EventErrorPolicy policy) { + this.eventErrorPolicy = policy; + } + /** * Sends a simple text message to the Copilot session. *

@@ -330,8 +375,10 @@ public CompletableFuture sendAndWait(MessageOptions optio * instead. * *

- * Exception isolation: If a handler throws an exception, the error is - * logged and remaining handlers still execute. + * Exception handling: If a handler throws an exception, the error is + * routed to the configured {@link EventErrorHandler} (if set). Whether + * remaining handlers execute depends on the configured + * {@link EventErrorPolicy}. * *

* Example: @@ -347,6 +394,7 @@ public CompletableFuture sendAndWait(MessageOptions optio * @return a Closeable that, when closed, unsubscribes the handler * @see #on(Class, Consumer) * @see AbstractSessionEvent + * @see #setEventErrorPolicy(EventErrorPolicy) */ public Closeable on(Consumer handler) { eventHandlers.add(handler); @@ -361,8 +409,10 @@ public Closeable on(Consumer handler) { * matching the specified type. * *

- * Exception isolation: If a handler throws an exception, the error is - * logged and remaining handlers still execute. + * Exception handling: If a handler throws an exception, the error is + * routed to the configured {@link EventErrorHandler} (if set). Whether + * remaining handlers execute depends on the configured + * {@link EventErrorPolicy}. * *

* Example Usage @@ -409,16 +459,23 @@ public Closeable on(Class eventType, Consume * Dispatches an event to all registered handlers. *

* This is called internally when events are received from the server. Each - * handler is invoked in its own try/catch block so that an exception thrown by - * one handler does not prevent subsequent handlers from executing. + * handler is invoked in its own try/catch block. Whether dispatch continues + * after a handler error depends on the configured {@link EventErrorPolicy}: + *

    + *
  • {@link EventErrorPolicy#CONTINUE} (default) β€” remaining handlers still + * execute
  • + *
  • {@link EventErrorPolicy#STOP} β€” dispatch stops after the first error
  • + *
*

- * If a custom {@link EventErrorHandler} has been set via - * {@link #setEventErrorHandler(EventErrorHandler)}, it is called with the event - * and exception. Otherwise, exceptions are logged at {@link Level#SEVERE}. + * The configured {@link EventErrorHandler} is always invoked (if set), + * regardless of the policy. When no error handler is set, exceptions are + * silently consumed. If the error handler itself throws, dispatch stops + * regardless of policy and the error is logged at {@link Level#SEVERE}. * * @param event * the event to dispatch * @see #setEventErrorHandler(EventErrorHandler) + * @see #setEventErrorPolicy(EventErrorPolicy) */ void dispatchEvent(AbstractSessionEvent event) { for (Consumer handler : eventHandlers) { @@ -431,9 +488,11 @@ void dispatchEvent(AbstractSessionEvent event) { errorHandler.handleError(event, e); } catch (Exception errorHandlerException) { LOG.log(Level.SEVERE, "Error in event error handler", errorHandlerException); + break; // error handler itself failed β€” stop regardless of policy } - } else { - LOG.log(Level.SEVERE, "Error in event handler", e); + } + if (eventErrorPolicy == EventErrorPolicy.STOP) { + break; } } } diff --git a/src/main/java/com/github/copilot/sdk/EventErrorHandler.java b/src/main/java/com/github/copilot/sdk/EventErrorHandler.java index f989a2945..23e3a6b6c 100644 --- a/src/main/java/com/github/copilot/sdk/EventErrorHandler.java +++ b/src/main/java/com/github/copilot/sdk/EventErrorHandler.java @@ -16,9 +16,8 @@ * being dispatched and the exception that was thrown. * *

- * The default behavior logs errors at {@link java.util.logging.Level#SEVERE}. - * You can override this to integrate with your own logging, metrics, or - * error-reporting systems: + * When no error handler is set, exceptions are silently consumed. Applications + * should set an error handler to log, track, or respond to handler failures: * *

{@code
  * session.setEventErrorHandler((event, exception) -> {
@@ -28,10 +27,18 @@
  * }
* *

- * If the error handler itself throws an exception, that exception is silently - * caught and logged to prevent cascading failures. + * Whether dispatch continues or stops after an error is controlled by the + * {@link EventErrorPolicy} set via + * {@link CopilotSession#setEventErrorPolicy(EventErrorPolicy)}. The error + * handler is always invoked regardless of the policy. + * + *

+ * If the error handler itself throws an exception, that exception is caught and + * logged at {@link java.util.logging.Level#SEVERE}, and dispatch is stopped + * regardless of the configured policy. * * @see CopilotSession#setEventErrorHandler(EventErrorHandler) + * @see EventErrorPolicy * @since 1.0.8 */ @FunctionalInterface diff --git a/src/main/java/com/github/copilot/sdk/EventErrorPolicy.java b/src/main/java/com/github/copilot/sdk/EventErrorPolicy.java new file mode 100644 index 000000000..1d84bb032 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/EventErrorPolicy.java @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +/** + * Controls how event dispatch behaves when an event handler throws an + * exception. + *

+ * This policy is set via + * {@link CopilotSession#setEventErrorPolicy(EventErrorPolicy)} and determines + * whether remaining event listeners continue to execute after a preceding + * listener throws an exception. + * + *

+ * The configured {@link EventErrorHandler} (if any) is always invoked + * regardless of the policy β€” the policy only controls whether dispatch + * continues after the error handler has been called. + * + *

+ * Example: + * + *

{@code
+ * // Default: continue dispatching despite errors
+ * session.setEventErrorPolicy(EventErrorPolicy.CONTINUE);
+ *
+ * // Opt-in to short-circuit on first error
+ * session.setEventErrorPolicy(EventErrorPolicy.STOP);
+ * }
+ * + * @see CopilotSession#setEventErrorPolicy(EventErrorPolicy) + * @see EventErrorHandler + * @since 1.0.8 + */ +public enum EventErrorPolicy { + + /** + * Stop dispatching on first listener error. + *

+ * When a handler throws an exception, no further handlers are invoked. The + * configured {@link EventErrorHandler} is still called before dispatch stops. + */ + STOP, + + /** + * Continue dispatching to remaining listeners despite errors (default). + *

+ * When a handler throws an exception, remaining handlers still execute. The + * configured {@link EventErrorHandler} is called for each error. + */ + CONTINUE +} diff --git a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java index 09b0e2e53..6e9366081 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java @@ -381,21 +381,22 @@ void testConcurrentDispatchFromMultipleThreads() throws Exception { // ==================================================================== @Test - void testDefaultErrorHandlerLogsException() { - // Without a custom error handler, exceptions should be logged (no crash) - Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); - Level originalLevel = sessionLogger.getLevel(); - sessionLogger.setLevel(Level.OFF); + void testDefaultNoErrorHandlerSilentlyContinues() { + // Without a custom error handler, exceptions should be silently consumed + var received = new ArrayList(); - try { - session.on(AssistantMessageEvent.class, msg -> { - throw new RuntimeException("boom"); - }); + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("boom"); + }); - assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); - } finally { - sessionLogger.setLevel(originalLevel); - } + session.on(AssistantMessageEvent.class, msg -> { + received.add(msg.getData().getContent()); + }); + + assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); + + // Second handler should still execute (default CONTINUE policy) + assertEquals(1, received.size()); } @Test @@ -423,7 +424,7 @@ void testCustomEventErrorHandlerReceivesEventAndException() { } @Test - void testCustomErrorHandlerReplacesDefaultLogging() { + void testCustomErrorHandlerCalledForAllErrors() { var errorCount = new AtomicInteger(0); session.setEventErrorHandler((event, exception) -> { @@ -444,8 +445,9 @@ void testCustomErrorHandlerReplacesDefaultLogging() { } @Test - void testErrorHandlerItselfThrowingDoesNotBreakDispatch() { - var received = new ArrayList(); + void testErrorHandlerItselfThrowingStopsDispatch() { + var handler1Called = new AtomicInteger(0); + var handler2Called = new AtomicInteger(0); Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); Level originalLevel = sessionLogger.getLevel(); @@ -456,19 +458,22 @@ void testErrorHandlerItselfThrowingDoesNotBreakDispatch() { throw new RuntimeException("error handler also broke"); }); - // First handler throws + // Two handlers that throw session.on(AssistantMessageEvent.class, msg -> { + handler1Called.incrementAndGet(); throw new RuntimeException("handler error"); }); - // Second handler should still execute session.on(AssistantMessageEvent.class, msg -> { - received.add(msg.getData().getContent()); + handler2Called.incrementAndGet(); + throw new RuntimeException("handler error"); }); - assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Still works"))); - assertEquals(1, received.size()); - assertEquals("Still works", received.get(0)); + assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); + // Error handler threw β€” dispatch stops regardless of policy + int totalCalls = handler1Called.get() + handler2Called.get(); + assertEquals(1, totalCalls, + "Only one handler should have been called (dispatch stopped when error handler threw)"); } finally { sessionLogger.setLevel(originalLevel); } @@ -490,19 +495,10 @@ void testSetEventErrorHandlerToNullRestoresDefaultBehavior() { dispatchEvent(createAssistantMessageEvent("Test1")); assertEquals(1, errorCount.get()); - // Reset to null (restore default logging) + // Reset to null (restore default silent behavior) session.setEventErrorHandler(null); - // Suppress default logging for the next dispatch - Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); - Level originalLevel = sessionLogger.getLevel(); - sessionLogger.setLevel(Level.OFF); - - try { - dispatchEvent(createAssistantMessageEvent("Test2")); - } finally { - sessionLogger.setLevel(originalLevel); - } + dispatchEvent(createAssistantMessageEvent("Test2")); // Custom handler should NOT have been called again assertEquals(1, errorCount.get()); @@ -531,6 +527,217 @@ void testErrorHandlerReceivesCorrectEventType() { assertInstanceOf(SessionIdleEvent.class, capturedEvents.get(1)); } + // ==================================================================== + // EventErrorPolicy tests + // ==================================================================== + + @Test + void testDefaultPolicyContinuesOnError() { + var handler1Called = new AtomicInteger(0); + var handler2Called = new AtomicInteger(0); + var handler3Called = new AtomicInteger(0); + + session.setEventErrorHandler((event, exception) -> { + // just consume + }); + + session.on(AssistantMessageEvent.class, msg -> { + handler1Called.incrementAndGet(); + throw new RuntimeException("error"); + }); + + session.on(AssistantMessageEvent.class, msg -> { + handler2Called.incrementAndGet(); + }); + + session.on(AssistantMessageEvent.class, msg -> { + handler3Called.incrementAndGet(); + }); + + dispatchEvent(createAssistantMessageEvent("Test")); + + // All handlers should have been called (CONTINUE is default) + assertEquals(1, handler1Called.get()); + assertEquals(1, handler2Called.get()); + assertEquals(1, handler3Called.get()); + } + + @Test + void testStopPolicyStopsOnFirstError() { + var handler1Called = new AtomicInteger(0); + var handler2Called = new AtomicInteger(0); + var errorHandlerCalls = new AtomicInteger(0); + + session.setEventErrorPolicy(EventErrorPolicy.STOP); + session.setEventErrorHandler((event, exception) -> { + errorHandlerCalls.incrementAndGet(); + }); + + // Two handlers that throw + session.on(AssistantMessageEvent.class, msg -> { + handler1Called.incrementAndGet(); + throw new RuntimeException("error 1"); + }); + + session.on(AssistantMessageEvent.class, msg -> { + handler2Called.incrementAndGet(); + throw new RuntimeException("error 2"); + }); + + dispatchEvent(createAssistantMessageEvent("Test")); + + // Only one handler should have been called (STOP policy) + assertEquals(1, errorHandlerCalls.get()); + int totalCalls = handler1Called.get() + handler2Called.get(); + assertEquals(1, totalCalls, "Only one handler should execute with STOP policy"); + } + + @Test + void testStopPolicyErrorHandlerAlwaysInvoked() { + var errorHandlerCalls = new AtomicInteger(0); + + session.setEventErrorPolicy(EventErrorPolicy.STOP); + session.setEventErrorHandler((event, exception) -> { + errorHandlerCalls.incrementAndGet(); + }); + + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("error"); + }); + + dispatchEvent(createAssistantMessageEvent("Test")); + + // Error handler should be called even with STOP policy + assertEquals(1, errorHandlerCalls.get()); + } + + @Test + void testContinuePolicyWithMultipleErrors() { + var errorHandlerCalls = new AtomicInteger(0); + var successfulHandlerCalls = new AtomicInteger(0); + + session.setEventErrorPolicy(EventErrorPolicy.CONTINUE); + session.setEventErrorHandler((event, exception) -> { + errorHandlerCalls.incrementAndGet(); + }); + + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("error 1"); + }); + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("error 2"); + }); + session.on(AssistantMessageEvent.class, msg -> { + successfulHandlerCalls.incrementAndGet(); + }); + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("error 3"); + }); + + dispatchEvent(createAssistantMessageEvent("Test")); + + // All errors should be reported, successful handler should run + assertEquals(3, errorHandlerCalls.get()); + assertEquals(1, successfulHandlerCalls.get()); + } + + @Test + void testSwitchPolicyDynamically() throws Exception { + var handler1Called = new AtomicInteger(0); + var handler2Called = new AtomicInteger(0); + + session.setEventErrorHandler((event, exception) -> { + // just consume + }); + + // Two handlers that throw + session.on(AssistantMessageEvent.class, msg -> { + handler1Called.incrementAndGet(); + throw new RuntimeException("error"); + }); + session.on(AssistantMessageEvent.class, msg -> { + handler2Called.incrementAndGet(); + throw new RuntimeException("error"); + }); + + // With CONTINUE, both should fire + session.setEventErrorPolicy(EventErrorPolicy.CONTINUE); + dispatchEvent(createAssistantMessageEvent("Test1")); + assertEquals(1, handler1Called.get()); + assertEquals(1, handler2Called.get()); + + handler1Called.set(0); + handler2Called.set(0); + + // Switch to STOP β€” only one should fire + session.setEventErrorPolicy(EventErrorPolicy.STOP); + dispatchEvent(createAssistantMessageEvent("Test2")); + int totalCalls = handler1Called.get() + handler2Called.get(); + assertEquals(1, totalCalls, "Only one handler should execute after switching to STOP"); + } + + @Test + void testStopPolicyNoErrorHandlerSilentlyStops() { + var handler1Called = new AtomicInteger(0); + var handler2Called = new AtomicInteger(0); + + // No error handler set, STOP policy + session.setEventErrorPolicy(EventErrorPolicy.STOP); + + session.on(AssistantMessageEvent.class, msg -> { + handler1Called.incrementAndGet(); + throw new RuntimeException("error"); + }); + + session.on(AssistantMessageEvent.class, msg -> { + handler2Called.incrementAndGet(); + throw new RuntimeException("error"); + }); + + assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); + + // STOP policy should stop after first error, even without error handler + int totalCalls = handler1Called.get() + handler2Called.get(); + assertEquals(1, totalCalls, "Only one handler should execute with STOP policy and no error handler"); + } + + @Test + void testErrorHandlerThrowingStopsRegardlessOfPolicy() { + var handler1Called = new AtomicInteger(0); + var handler2Called = new AtomicInteger(0); + + Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); + Level originalLevel = sessionLogger.getLevel(); + sessionLogger.setLevel(Level.OFF); + + try { + // CONTINUE policy, but error handler throws + session.setEventErrorPolicy(EventErrorPolicy.CONTINUE); + session.setEventErrorHandler((event, exception) -> { + throw new RuntimeException("error handler broke"); + }); + + session.on(AssistantMessageEvent.class, msg -> { + handler1Called.incrementAndGet(); + throw new RuntimeException("error"); + }); + + session.on(AssistantMessageEvent.class, msg -> { + handler2Called.incrementAndGet(); + throw new RuntimeException("error"); + }); + + assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); + + // Error handler threw β€” should stop regardless of CONTINUE policy + int totalCalls = handler1Called.get() + handler2Called.get(); + assertEquals(1, totalCalls, + "Only one handler should execute when error handler throws, even with CONTINUE policy"); + } finally { + sessionLogger.setLevel(originalLevel); + } + } + // ==================================================================== // Helper methods // ==================================================================== From 45ac4c30042f659c2be6d49804c02b75cfc06c79 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:50:21 +0000 Subject: [PATCH 048/109] Add null check for setEventErrorPolicy and remove unused throws clause Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- src/main/java/com/github/copilot/sdk/CopilotSession.java | 3 +++ .../java/com/github/copilot/sdk/SessionEventHandlingTest.java | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index b63e78790..9c968f72c 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -227,6 +227,9 @@ public void setEventErrorHandler(EventErrorHandler handler) { * @since 1.0.8 */ public void setEventErrorPolicy(EventErrorPolicy policy) { + if (policy == null) { + throw new NullPointerException("policy must not be null"); + } this.eventErrorPolicy = policy; } diff --git a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java index 6e9366081..c0348a92f 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java @@ -642,7 +642,7 @@ void testContinuePolicyWithMultipleErrors() { } @Test - void testSwitchPolicyDynamically() throws Exception { + void testSwitchPolicyDynamically() { var handler1Called = new AtomicInteger(0); var handler2Called = new AtomicInteger(0); From e3989c46837a98c611bf3b2ffb2afe28d958ca40 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 19:31:00 +0000 Subject: [PATCH 049/109] Update documentation for EventErrorPolicy and silent error handling Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- CHANGELOG.md | 9 +++++ src/site/markdown/advanced.md | 65 ++++++++++++++++++++++++++++------- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77e324fdb..e02b4766a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,15 @@ session.setEventErrorHandler((event, exception) -> { }); ``` +#### EventErrorPolicy for Dispatch Control +Added `EventErrorPolicy` enum to control whether event dispatch continues or stops when a handler throws an exception. The default policy is `CONTINUE` (existing behavior). Set `STOP` to short-circuit dispatch on the first error: + +```java +session.setEventErrorPolicy(EventErrorPolicy.STOP); +``` + +The `EventErrorHandler` is always invoked regardless of the policy. When no error handler is set, exceptions are silently consumed (no logging). + #### Type-Safe Event Handlers Promoted type-safe `on(Class, Consumer)` event handlers as the primary API. Handlers now receive strongly-typed events instead of raw `AbstractSessionEvent`. diff --git a/src/site/markdown/advanced.md b/src/site/markdown/advanced.md index 7bd75932d..6a897a138 100644 --- a/src/site/markdown/advanced.md +++ b/src/site/markdown/advanced.md @@ -32,6 +32,7 @@ This guide covers advanced scenarios for extending and customizing your Copilot - [Error Handling](#Error_Handling) - [Event Handler Exceptions](#Event_Handler_Exceptions) - [Custom Event Error Handler](#Custom_Event_Error_Handler) + - [Event Error Policy](#Event_Error_Policy) --- @@ -451,9 +452,9 @@ session.send(new MessageOptions().setPrompt("Hello")) ### Event Handler Exceptions -If an event handler throws an exception, the SDK catches it, logs it at -`SEVERE` level, and continues dispatching to remaining handlers. This means -one faulty handler will never block others from receiving events: +If an event handler registered via `session.on()` throws an exception, the SDK +catches it and continues dispatching to remaining handlers by default. This +means one faulty handler will never block others from receiving events: ```java // This handler throws, but the second handler still runs @@ -467,17 +468,14 @@ session.on(AssistantMessageEvent.class, msg -> { }); ``` -> **Note:** This exception isolation behavior is consistent with the Node.js, -> Go, and Python Copilot SDKs, which all catch handler errors per-handler. The -> .NET SDK is an exception β€” handler errors propagate there and can prevent -> subsequent handlers from running. +When no `EventErrorHandler` is set, exceptions are **silently consumed** β€” the +SDK does not log by default. Applications should set an error handler if they +want visibility into handler failures (see below). ### Custom Event Error Handler -By default, handler exceptions are logged at `SEVERE` level using -`java.util.logging`. You can replace this with a custom -`EventErrorHandler` to integrate with your own logging, metrics, or -error-reporting systems: +Set a custom `EventErrorHandler` to integrate with your own logging, metrics, +or error-reporting systems: ```java session.setEventErrorHandler((event, exception) -> { @@ -489,10 +487,51 @@ session.setEventErrorHandler((event, exception) -> { The error handler receives both the event that was being dispatched and the exception that was thrown. If the error handler itself throws, that exception -is silently caught and logged to prevent cascading failures. +is caught and logged at `SEVERE`, and dispatch is stopped to prevent cascading +failures. -Pass `null` to restore the default logging behavior: +Pass `null` to restore the default silent behavior: ```java session.setEventErrorHandler(null); ``` + +### Event Error Policy + +By default, the SDK continues dispatching to remaining handlers after an error +(`EventErrorPolicy.CONTINUE`). You can opt in to **short-circuit** behavior so +that the first handler error stops dispatch: + +```java +session.setEventErrorPolicy(EventErrorPolicy.STOP); +``` + +The `EventErrorHandler` (if set) is always invoked regardless of the policy β€” +the policy only controls whether remaining handlers execute after the error +handler returns. + +| Policy | Behavior | +|---|---| +| `CONTINUE` (default) | All remaining handlers execute despite errors | +| `STOP` | Dispatch halts after the first handler error | + +You can combine both for full control: + +```java +// Log errors and stop dispatch on first failure +session.setEventErrorPolicy(EventErrorPolicy.STOP); +session.setEventErrorHandler((event, ex) -> + logger.error("Handler failed, stopping: {}", ex.getMessage(), ex)); +``` + +Or switch policies dynamically: + +```java +// Start lenient +session.setEventErrorPolicy(EventErrorPolicy.CONTINUE); + +// Later, switch to strict mode +session.setEventErrorPolicy(EventErrorPolicy.STOP); +``` + +See [EventErrorPolicy](apidocs/com/github/copilot/sdk/EventErrorPolicy.html) and [EventErrorHandler](apidocs/com/github/copilot/sdk/EventErrorHandler.html) Javadoc for details. From de45c8f9a0e224f534b070890173feceb98bafe5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:50:55 +0000 Subject: [PATCH 050/109] =?UTF-8?q?Rename=20EventErrorPolicy=20values:=20S?= =?UTF-8?q?TOP=E2=86=92PROPAGATE,=20CONTINUE=E2=86=92SUPPRESS=20(Spring-st?= =?UTF-8?q?yle)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- CHANGELOG.md | 4 +- .../github/copilot/sdk/CopilotSession.java | 23 ++++----- .../github/copilot/sdk/EventErrorPolicy.java | 32 ++++++++----- src/site/markdown/advanced.md | 24 +++++----- .../copilot/sdk/SessionEventHandlingTest.java | 48 +++++++++---------- 5 files changed, 70 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e02b4766a..e154f4805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,10 +41,10 @@ session.setEventErrorHandler((event, exception) -> { ``` #### EventErrorPolicy for Dispatch Control -Added `EventErrorPolicy` enum to control whether event dispatch continues or stops when a handler throws an exception. The default policy is `CONTINUE` (existing behavior). Set `STOP` to short-circuit dispatch on the first error: +Added `EventErrorPolicy` enum to control whether event dispatch continues or stops when a handler throws an exception. The default policy is `SUPPRESS` (existing behavior). Set `PROPAGATE` to stop dispatch on the first error: ```java -session.setEventErrorPolicy(EventErrorPolicy.STOP); +session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE); ``` The `EventErrorHandler` is always invoked regardless of the policy. When no error handler is set, exceptions are silently consumed (no logging). diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 9c968f72c..4e2309229 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -96,7 +96,7 @@ public final class CopilotSession implements AutoCloseable { private final AtomicReference userInputHandler = new AtomicReference<>(); private final AtomicReference hooksHandler = new AtomicReference<>(); private volatile EventErrorHandler eventErrorHandler; - private volatile EventErrorPolicy eventErrorPolicy = EventErrorPolicy.CONTINUE; + private volatile EventErrorPolicy eventErrorPolicy = EventErrorPolicy.SUPPRESS; /** * Creates a new session with the given ID and RPC client. @@ -200,10 +200,10 @@ public void setEventErrorHandler(EventErrorHandler handler) { * preceding listener throws an exception. * *

    - *
  • {@link EventErrorPolicy#CONTINUE} (default) β€” dispatch to all remaining - * listeners regardless of errors
  • - *
  • {@link EventErrorPolicy#STOP} β€” stop dispatching after the first - * error
  • + *
  • {@link EventErrorPolicy#SUPPRESS} (default) β€” suppress the error and + * dispatch to all remaining listeners regardless
  • + *
  • {@link EventErrorPolicy#PROPAGATE} β€” propagate the error effect by + * stopping dispatch after the first error
  • *
* *

@@ -214,14 +214,14 @@ public void setEventErrorHandler(EventErrorHandler handler) { * Example: * *

{@code
-     * // Opt-in to short-circuit on first error
-     * session.setEventErrorPolicy(EventErrorPolicy.STOP);
+     * // Opt-in to propagate errors (stop dispatch on first error)
+     * session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE);
      * session.setEventErrorHandler(
      * 		(event, ex) -> logger.error("Handler failed, stopping dispatch: {}", ex.getMessage(), ex));
      * }
* * @param policy - * the error policy (default is {@link EventErrorPolicy#CONTINUE}) + * the error policy (default is {@link EventErrorPolicy#SUPPRESS}) * @see EventErrorPolicy * @see #setEventErrorHandler(EventErrorHandler) * @since 1.0.8 @@ -465,9 +465,10 @@ public Closeable on(Class eventType, Consume * handler is invoked in its own try/catch block. Whether dispatch continues * after a handler error depends on the configured {@link EventErrorPolicy}: *
    - *
  • {@link EventErrorPolicy#CONTINUE} (default) β€” remaining handlers still + *
  • {@link EventErrorPolicy#SUPPRESS} (default) β€” remaining handlers still * execute
  • - *
  • {@link EventErrorPolicy#STOP} β€” dispatch stops after the first error
  • + *
  • {@link EventErrorPolicy#PROPAGATE} β€” dispatch stops after the first + * error
  • *
*

* The configured {@link EventErrorHandler} is always invoked (if set), @@ -494,7 +495,7 @@ void dispatchEvent(AbstractSessionEvent event) { break; // error handler itself failed β€” stop regardless of policy } } - if (eventErrorPolicy == EventErrorPolicy.STOP) { + if (eventErrorPolicy == EventErrorPolicy.PROPAGATE) { break; } } diff --git a/src/main/java/com/github/copilot/sdk/EventErrorPolicy.java b/src/main/java/com/github/copilot/sdk/EventErrorPolicy.java index 1d84bb032..9fe39a4ba 100644 --- a/src/main/java/com/github/copilot/sdk/EventErrorPolicy.java +++ b/src/main/java/com/github/copilot/sdk/EventErrorPolicy.java @@ -19,14 +19,19 @@ * continues after the error handler has been called. * *

+ * The naming follows the convention used by Spring Framework's + * {@code TaskUtils.LOG_AND_SUPPRESS_ERROR_HANDLER} and + * {@code TaskUtils.LOG_AND_PROPAGATE_ERROR_HANDLER}. + * + *

* Example: * *

{@code
- * // Default: continue dispatching despite errors
- * session.setEventErrorPolicy(EventErrorPolicy.CONTINUE);
+ * // Default: suppress errors and continue dispatching
+ * session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS);
  *
- * // Opt-in to short-circuit on first error
- * session.setEventErrorPolicy(EventErrorPolicy.STOP);
+ * // Opt-in to propagate errors (stop dispatch on first error)
+ * session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE);
  * }
* * @see CopilotSession#setEventErrorPolicy(EventErrorPolicy) @@ -36,18 +41,21 @@ public enum EventErrorPolicy { /** - * Stop dispatching on first listener error. + * Suppress errors and continue dispatching to remaining listeners (default). *

- * When a handler throws an exception, no further handlers are invoked. The - * configured {@link EventErrorHandler} is still called before dispatch stops. + * When a handler throws an exception, remaining handlers still execute. The + * configured {@link EventErrorHandler} is called for each error. This is + * analogous to Spring's {@code LOG_AND_SUPPRESS_ERROR_HANDLER} behavior. */ - STOP, + SUPPRESS, /** - * Continue dispatching to remaining listeners despite errors (default). + * Propagate the error effect by stopping dispatch on first listener error. *

- * When a handler throws an exception, remaining handlers still execute. The - * configured {@link EventErrorHandler} is called for each error. + * When a handler throws an exception, no further handlers are invoked. The + * configured {@link EventErrorHandler} is still called before dispatch stops. + * This is analogous to Spring's {@code LOG_AND_PROPAGATE_ERROR_HANDLER} + * behavior. */ - CONTINUE + PROPAGATE } diff --git a/src/site/markdown/advanced.md b/src/site/markdown/advanced.md index 6a897a138..ef78e70b2 100644 --- a/src/site/markdown/advanced.md +++ b/src/site/markdown/advanced.md @@ -498,12 +498,12 @@ session.setEventErrorHandler(null); ### Event Error Policy -By default, the SDK continues dispatching to remaining handlers after an error -(`EventErrorPolicy.CONTINUE`). You can opt in to **short-circuit** behavior so -that the first handler error stops dispatch: +By default, the SDK suppresses errors and continues dispatching to remaining +handlers (`EventErrorPolicy.SUPPRESS`). You can opt in to **propagate** errors +so that the first handler error stops dispatch: ```java -session.setEventErrorPolicy(EventErrorPolicy.STOP); +session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE); ``` The `EventErrorHandler` (if set) is always invoked regardless of the policy β€” @@ -512,14 +512,14 @@ handler returns. | Policy | Behavior | |---|---| -| `CONTINUE` (default) | All remaining handlers execute despite errors | -| `STOP` | Dispatch halts after the first handler error | +| `SUPPRESS` (default) | Suppress the error; all remaining handlers execute | +| `PROPAGATE` | Propagate the error effect; dispatch halts after the first error | You can combine both for full control: ```java -// Log errors and stop dispatch on first failure -session.setEventErrorPolicy(EventErrorPolicy.STOP); +// Log errors and propagate (stop dispatch on first failure) +session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE); session.setEventErrorHandler((event, ex) -> logger.error("Handler failed, stopping: {}", ex.getMessage(), ex)); ``` @@ -527,11 +527,11 @@ session.setEventErrorHandler((event, ex) -> Or switch policies dynamically: ```java -// Start lenient -session.setEventErrorPolicy(EventErrorPolicy.CONTINUE); +// Start lenient (suppress errors) +session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS); -// Later, switch to strict mode -session.setEventErrorPolicy(EventErrorPolicy.STOP); +// Later, switch to strict mode (propagate errors) +session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE); ``` See [EventErrorPolicy](apidocs/com/github/copilot/sdk/EventErrorPolicy.html) and [EventErrorHandler](apidocs/com/github/copilot/sdk/EventErrorHandler.html) Javadoc for details. diff --git a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java index c0348a92f..a6a2a62ab 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java @@ -556,19 +556,19 @@ void testDefaultPolicyContinuesOnError() { dispatchEvent(createAssistantMessageEvent("Test")); - // All handlers should have been called (CONTINUE is default) + // All handlers should have been called (SUPPRESS is default) assertEquals(1, handler1Called.get()); assertEquals(1, handler2Called.get()); assertEquals(1, handler3Called.get()); } @Test - void testStopPolicyStopsOnFirstError() { + void testPropagatePolicyStopsOnFirstError() { var handler1Called = new AtomicInteger(0); var handler2Called = new AtomicInteger(0); var errorHandlerCalls = new AtomicInteger(0); - session.setEventErrorPolicy(EventErrorPolicy.STOP); + session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE); session.setEventErrorHandler((event, exception) -> { errorHandlerCalls.incrementAndGet(); }); @@ -586,17 +586,17 @@ void testStopPolicyStopsOnFirstError() { dispatchEvent(createAssistantMessageEvent("Test")); - // Only one handler should have been called (STOP policy) + // Only one handler should have been called (PROPAGATE policy) assertEquals(1, errorHandlerCalls.get()); int totalCalls = handler1Called.get() + handler2Called.get(); - assertEquals(1, totalCalls, "Only one handler should execute with STOP policy"); + assertEquals(1, totalCalls, "Only one handler should execute with PROPAGATE policy"); } @Test - void testStopPolicyErrorHandlerAlwaysInvoked() { + void testPropagatePolicyErrorHandlerAlwaysInvoked() { var errorHandlerCalls = new AtomicInteger(0); - session.setEventErrorPolicy(EventErrorPolicy.STOP); + session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE); session.setEventErrorHandler((event, exception) -> { errorHandlerCalls.incrementAndGet(); }); @@ -607,16 +607,16 @@ void testStopPolicyErrorHandlerAlwaysInvoked() { dispatchEvent(createAssistantMessageEvent("Test")); - // Error handler should be called even with STOP policy + // Error handler should be called even with PROPAGATE policy assertEquals(1, errorHandlerCalls.get()); } @Test - void testContinuePolicyWithMultipleErrors() { + void testSuppressPolicyWithMultipleErrors() { var errorHandlerCalls = new AtomicInteger(0); var successfulHandlerCalls = new AtomicInteger(0); - session.setEventErrorPolicy(EventErrorPolicy.CONTINUE); + session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS); session.setEventErrorHandler((event, exception) -> { errorHandlerCalls.incrementAndGet(); }); @@ -660,8 +660,8 @@ void testSwitchPolicyDynamically() { throw new RuntimeException("error"); }); - // With CONTINUE, both should fire - session.setEventErrorPolicy(EventErrorPolicy.CONTINUE); + // With SUPPRESS, both should fire + session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS); dispatchEvent(createAssistantMessageEvent("Test1")); assertEquals(1, handler1Called.get()); assertEquals(1, handler2Called.get()); @@ -669,20 +669,20 @@ void testSwitchPolicyDynamically() { handler1Called.set(0); handler2Called.set(0); - // Switch to STOP β€” only one should fire - session.setEventErrorPolicy(EventErrorPolicy.STOP); + // Switch to PROPAGATE β€” only one should fire + session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE); dispatchEvent(createAssistantMessageEvent("Test2")); int totalCalls = handler1Called.get() + handler2Called.get(); - assertEquals(1, totalCalls, "Only one handler should execute after switching to STOP"); + assertEquals(1, totalCalls, "Only one handler should execute after switching to PROPAGATE"); } @Test - void testStopPolicyNoErrorHandlerSilentlyStops() { + void testPropagatePolicyNoErrorHandlerSilentlyStops() { var handler1Called = new AtomicInteger(0); var handler2Called = new AtomicInteger(0); - // No error handler set, STOP policy - session.setEventErrorPolicy(EventErrorPolicy.STOP); + // No error handler set, PROPAGATE policy + session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE); session.on(AssistantMessageEvent.class, msg -> { handler1Called.incrementAndGet(); @@ -696,9 +696,9 @@ void testStopPolicyNoErrorHandlerSilentlyStops() { assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); - // STOP policy should stop after first error, even without error handler + // PROPAGATE policy should stop after first error, even without error handler int totalCalls = handler1Called.get() + handler2Called.get(); - assertEquals(1, totalCalls, "Only one handler should execute with STOP policy and no error handler"); + assertEquals(1, totalCalls, "Only one handler should execute with PROPAGATE policy and no error handler"); } @Test @@ -711,8 +711,8 @@ void testErrorHandlerThrowingStopsRegardlessOfPolicy() { sessionLogger.setLevel(Level.OFF); try { - // CONTINUE policy, but error handler throws - session.setEventErrorPolicy(EventErrorPolicy.CONTINUE); + // SUPPRESS policy, but error handler throws + session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS); session.setEventErrorHandler((event, exception) -> { throw new RuntimeException("error handler broke"); }); @@ -729,10 +729,10 @@ void testErrorHandlerThrowingStopsRegardlessOfPolicy() { assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); - // Error handler threw β€” should stop regardless of CONTINUE policy + // Error handler threw β€” should stop regardless of SUPPRESS policy int totalCalls = handler1Called.get() + handler2Called.get(); assertEquals(1, totalCalls, - "Only one handler should execute when error handler throws, even with CONTINUE policy"); + "Only one handler should execute when error handler throws, even with SUPPRESS policy"); } finally { sessionLogger.setLevel(originalLevel); } From e265cf45b335f693e2c0dd11e46680292eac6208 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 22:50:44 +0000 Subject: [PATCH 051/109] Rename enums to PROPAGATE_AND_LOG_ERRORS/SUPPRESS_AND_LOG_ERRORS, default to PROPAGATE, always log errors Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- CHANGELOG.md | 6 +- .../github/copilot/sdk/CopilotSession.java | 49 +- .../github/copilot/sdk/EventErrorHandler.java | 6 +- .../github/copilot/sdk/EventErrorPolicy.java | 36 +- src/site/markdown/advanced.md | 49 +- .../copilot/sdk/SessionEventHandlingTest.java | 448 +++++++++++------- 6 files changed, 352 insertions(+), 242 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e154f4805..b195a7484 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,13 +41,13 @@ session.setEventErrorHandler((event, exception) -> { ``` #### EventErrorPolicy for Dispatch Control -Added `EventErrorPolicy` enum to control whether event dispatch continues or stops when a handler throws an exception. The default policy is `SUPPRESS` (existing behavior). Set `PROPAGATE` to stop dispatch on the first error: +Added `EventErrorPolicy` enum to control whether event dispatch continues or stops when a handler throws an exception. Errors are always logged at `WARNING` level. The default policy is `PROPAGATE_AND_LOG_ERRORS` which stops dispatch on the first error. Set `SUPPRESS_AND_LOG_ERRORS` to continue dispatching despite errors: ```java -session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE); +session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS); ``` -The `EventErrorHandler` is always invoked regardless of the policy. When no error handler is set, exceptions are silently consumed (no logging). +The `EventErrorHandler` is always invoked regardless of the policy. #### Type-Safe Event Handlers Promoted type-safe `on(Class, Consumer)` event handlers as the primary API. Handlers now receive strongly-typed events instead of raw `AbstractSessionEvent`. diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 4e2309229..61ff49174 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -96,7 +96,7 @@ public final class CopilotSession implements AutoCloseable { private final AtomicReference userInputHandler = new AtomicReference<>(); private final AtomicReference hooksHandler = new AtomicReference<>(); private volatile EventErrorHandler eventErrorHandler; - private volatile EventErrorPolicy eventErrorPolicy = EventErrorPolicy.SUPPRESS; + private volatile EventErrorPolicy eventErrorPolicy = EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS; /** * Creates a new session with the given ID and RPC client. @@ -159,8 +159,9 @@ public String getWorkspacePath() { *

* When an event handler registered via {@link #on(Consumer)} or * {@link #on(Class, Consumer)} throws an exception during event dispatch, the - * error handler is invoked with the event and exception. When no error handler - * is set, exceptions are silently consumed. + * error handler is invoked with the event and exception. The error is always + * logged at {@link Level#WARNING} regardless of whether a custom handler is + * set. * *

* Whether dispatch continues or stops after an error is controlled by the @@ -183,7 +184,7 @@ public String getWorkspacePath() { * }

* * @param handler - * the error handler, or {@code null} to restore default silent + * the error handler, or {@code null} to use only the default logging * behavior * @see EventErrorHandler * @see #setEventErrorPolicy(EventErrorPolicy) @@ -197,13 +198,14 @@ public void setEventErrorHandler(EventErrorHandler handler) { * Sets the error propagation policy for event dispatch. *

* Controls whether remaining event listeners continue to execute when a - * preceding listener throws an exception. + * preceding listener throws an exception. Errors are always logged at + * {@link Level#WARNING} regardless of the policy. * *

    - *
  • {@link EventErrorPolicy#SUPPRESS} (default) β€” suppress the error and - * dispatch to all remaining listeners regardless
  • - *
  • {@link EventErrorPolicy#PROPAGATE} β€” propagate the error effect by - * stopping dispatch after the first error
  • + *
  • {@link EventErrorPolicy#PROPAGATE_AND_LOG_ERRORS} (default) β€” log the + * error and stop dispatch after the first error
  • + *
  • {@link EventErrorPolicy#SUPPRESS_AND_LOG_ERRORS} β€” log the error and + * continue dispatching to all remaining listeners
  • *
* *

@@ -214,14 +216,14 @@ public void setEventErrorHandler(EventErrorHandler handler) { * Example: * *

{@code
-     * // Opt-in to propagate errors (stop dispatch on first error)
-     * session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE);
-     * session.setEventErrorHandler(
-     * 		(event, ex) -> logger.error("Handler failed, stopping dispatch: {}", ex.getMessage(), ex));
+     * // Opt-in to suppress errors (continue dispatching despite errors)
+     * session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS);
+     * session.setEventErrorHandler((event, ex) -> logger.error("Handler failed, continuing: {}", ex.getMessage(), ex));
      * }
* * @param policy - * the error policy (default is {@link EventErrorPolicy#SUPPRESS}) + * the error policy (default is + * {@link EventErrorPolicy#PROPAGATE_AND_LOG_ERRORS}) * @see EventErrorPolicy * @see #setEventErrorHandler(EventErrorHandler) * @since 1.0.8 @@ -462,18 +464,18 @@ public Closeable on(Class eventType, Consume * Dispatches an event to all registered handlers. *

* This is called internally when events are received from the server. Each - * handler is invoked in its own try/catch block. Whether dispatch continues - * after a handler error depends on the configured {@link EventErrorPolicy}: + * handler is invoked in its own try/catch block. Errors are always logged at + * {@link Level#WARNING}. Whether dispatch continues after a handler error + * depends on the configured {@link EventErrorPolicy}: *

    - *
  • {@link EventErrorPolicy#SUPPRESS} (default) β€” remaining handlers still - * execute
  • - *
  • {@link EventErrorPolicy#PROPAGATE} β€” dispatch stops after the first - * error
  • + *
  • {@link EventErrorPolicy#PROPAGATE_AND_LOG_ERRORS} (default) β€” dispatch + * stops after the first error
  • + *
  • {@link EventErrorPolicy#SUPPRESS_AND_LOG_ERRORS} β€” remaining handlers + * still execute
  • *
*

* The configured {@link EventErrorHandler} is always invoked (if set), - * regardless of the policy. When no error handler is set, exceptions are - * silently consumed. If the error handler itself throws, dispatch stops + * regardless of the policy. If the error handler itself throws, dispatch stops * regardless of policy and the error is logged at {@link Level#SEVERE}. * * @param event @@ -486,6 +488,7 @@ void dispatchEvent(AbstractSessionEvent event) { try { handler.accept(event); } catch (Exception e) { + LOG.log(Level.WARNING, "Error in event handler", e); EventErrorHandler errorHandler = this.eventErrorHandler; if (errorHandler != null) { try { @@ -495,7 +498,7 @@ void dispatchEvent(AbstractSessionEvent event) { break; // error handler itself failed β€” stop regardless of policy } } - if (eventErrorPolicy == EventErrorPolicy.PROPAGATE) { + if (eventErrorPolicy == EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS) { break; } } diff --git a/src/main/java/com/github/copilot/sdk/EventErrorHandler.java b/src/main/java/com/github/copilot/sdk/EventErrorHandler.java index 23e3a6b6c..8cab08ae1 100644 --- a/src/main/java/com/github/copilot/sdk/EventErrorHandler.java +++ b/src/main/java/com/github/copilot/sdk/EventErrorHandler.java @@ -16,8 +16,10 @@ * being dispatched and the exception that was thrown. * *

- * When no error handler is set, exceptions are silently consumed. Applications - * should set an error handler to log, track, or respond to handler failures: + * Errors are always logged at {@link java.util.logging.Level#WARNING} + * regardless of whether an error handler is set. The error handler provides + * additional custom handling such as metrics, alerts, or integration with + * external error-reporting systems: * *

{@code
  * session.setEventErrorHandler((event, exception) -> {
diff --git a/src/main/java/com/github/copilot/sdk/EventErrorPolicy.java b/src/main/java/com/github/copilot/sdk/EventErrorPolicy.java
index 9fe39a4ba..b7c3dca21 100644
--- a/src/main/java/com/github/copilot/sdk/EventErrorPolicy.java
+++ b/src/main/java/com/github/copilot/sdk/EventErrorPolicy.java
@@ -11,12 +11,14 @@
  * This policy is set via
  * {@link CopilotSession#setEventErrorPolicy(EventErrorPolicy)} and determines
  * whether remaining event listeners continue to execute after a preceding
- * listener throws an exception.
+ * listener throws an exception. Errors are always logged at
+ * {@link java.util.logging.Level#WARNING} regardless of the policy.
  *
  * 

* The configured {@link EventErrorHandler} (if any) is always invoked * regardless of the policy β€” the policy only controls whether dispatch - * continues after the error handler has been called. + * continues after the error has been logged and the error handler has been + * called. * *

* The naming follows the convention used by Spring Framework's @@ -27,11 +29,11 @@ * Example: * *

{@code
- * // Default: suppress errors and continue dispatching
- * session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS);
+ * // Default: propagate errors (stop dispatch on first error, log the error)
+ * session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS);
  *
- * // Opt-in to propagate errors (stop dispatch on first error)
- * session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE);
+ * // Opt-in to suppress errors (continue dispatching, log each error)
+ * session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS);
  * }
* * @see CopilotSession#setEventErrorPolicy(EventErrorPolicy) @@ -41,21 +43,25 @@ public enum EventErrorPolicy { /** - * Suppress errors and continue dispatching to remaining listeners (default). + * Suppress errors: log the error and continue dispatching to remaining + * listeners. *

- * When a handler throws an exception, remaining handlers still execute. The - * configured {@link EventErrorHandler} is called for each error. This is + * When a handler throws an exception, the error is logged at + * {@link java.util.logging.Level#WARNING} and remaining handlers still execute. + * The configured {@link EventErrorHandler} is called for each error. This is * analogous to Spring's {@code LOG_AND_SUPPRESS_ERROR_HANDLER} behavior. */ - SUPPRESS, + SUPPRESS_AND_LOG_ERRORS, /** - * Propagate the error effect by stopping dispatch on first listener error. + * Propagate errors: log the error and stop dispatch on first listener error + * (default). *

- * When a handler throws an exception, no further handlers are invoked. The - * configured {@link EventErrorHandler} is still called before dispatch stops. - * This is analogous to Spring's {@code LOG_AND_PROPAGATE_ERROR_HANDLER} + * When a handler throws an exception, the error is logged at + * {@link java.util.logging.Level#WARNING} and no further handlers are invoked. + * The configured {@link EventErrorHandler} is still called before dispatch + * stops. This is analogous to Spring's {@code LOG_AND_PROPAGATE_ERROR_HANDLER} * behavior. */ - PROPAGATE + PROPAGATE_AND_LOG_ERRORS } diff --git a/src/site/markdown/advanced.md b/src/site/markdown/advanced.md index ef78e70b2..619cff40f 100644 --- a/src/site/markdown/advanced.md +++ b/src/site/markdown/advanced.md @@ -453,11 +453,14 @@ session.send(new MessageOptions().setPrompt("Hello")) ### Event Handler Exceptions If an event handler registered via `session.on()` throws an exception, the SDK -catches it and continues dispatching to remaining handlers by default. This -means one faulty handler will never block others from receiving events: +catches it and logs it at `WARNING` level. By default, dispatch **stops** after +the first handler error (`PROPAGATE_AND_LOG_ERRORS` policy). You can opt in to +continue dispatching despite errors using `SUPPRESS_AND_LOG_ERRORS`: ```java -// This handler throws, but the second handler still runs +// With SUPPRESS_AND_LOG_ERRORS, second handler still runs +session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS); + session.on(AssistantMessageEvent.class, msg -> { throw new RuntimeException("bug in handler 1"); }); @@ -468,14 +471,14 @@ session.on(AssistantMessageEvent.class, msg -> { }); ``` -When no `EventErrorHandler` is set, exceptions are **silently consumed** β€” the -SDK does not log by default. Applications should set an error handler if they -want visibility into handler failures (see below). +Errors are **always logged** at `WARNING` level regardless of the policy or +whether a custom error handler is set. ### Custom Event Error Handler -Set a custom `EventErrorHandler` to integrate with your own logging, metrics, -or error-reporting systems: +Set a custom `EventErrorHandler` for additional handling beyond the default +logging β€” such as metrics, alerts, or integration with external +error-reporting systems: ```java session.setEventErrorHandler((event, exception) -> { @@ -490,7 +493,7 @@ exception that was thrown. If the error handler itself throws, that exception is caught and logged at `SEVERE`, and dispatch is stopped to prevent cascading failures. -Pass `null` to restore the default silent behavior: +Pass `null` to use only the default logging behavior: ```java session.setEventErrorHandler(null); @@ -498,40 +501,40 @@ session.setEventErrorHandler(null); ### Event Error Policy -By default, the SDK suppresses errors and continues dispatching to remaining -handlers (`EventErrorPolicy.SUPPRESS`). You can opt in to **propagate** errors -so that the first handler error stops dispatch: +By default, the SDK propagates errors and stops dispatch on the first handler +error (`EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS`). You can opt in to +**suppress** errors so that all handlers execute despite errors: ```java -session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE); +session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS); ``` The `EventErrorHandler` (if set) is always invoked regardless of the policy β€” the policy only controls whether remaining handlers execute after the error -handler returns. +handler returns. Errors are always logged at `WARNING` level. | Policy | Behavior | |---|---| -| `SUPPRESS` (default) | Suppress the error; all remaining handlers execute | -| `PROPAGATE` | Propagate the error effect; dispatch halts after the first error | +| `PROPAGATE_AND_LOG_ERRORS` (default) | Log the error; dispatch halts after the first error | +| `SUPPRESS_AND_LOG_ERRORS` | Log the error; all remaining handlers execute | You can combine both for full control: ```java -// Log errors and propagate (stop dispatch on first failure) -session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE); +// Log errors via custom handler and suppress (continue dispatching) +session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS); session.setEventErrorHandler((event, ex) -> - logger.error("Handler failed, stopping: {}", ex.getMessage(), ex)); + logger.error("Handler failed, continuing: {}", ex.getMessage(), ex)); ``` Or switch policies dynamically: ```java -// Start lenient (suppress errors) -session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS); +// Start strict (propagate errors, stop dispatch) +session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS); -// Later, switch to strict mode (propagate errors) -session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE); +// Later, switch to lenient mode (suppress errors, continue) +session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS); ``` See [EventErrorPolicy](apidocs/com/github/copilot/sdk/EventErrorPolicy.html) and [EventErrorHandler](apidocs/com/github/copilot/sdk/EventErrorHandler.html) Javadoc for details. diff --git a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java index a6a2a62ab..3743bf275 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java @@ -200,6 +200,9 @@ void testHandlerExceptionDoesNotBreakOtherHandlers() { sessionLogger.setLevel(Level.OFF); try { + // Use SUPPRESS policy so second handler still runs + session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS); + // First handler throws an exception session.on(AssistantMessageEvent.class, msg -> { throw new RuntimeException("Handler 1 error"); @@ -381,22 +384,35 @@ void testConcurrentDispatchFromMultipleThreads() throws Exception { // ==================================================================== @Test - void testDefaultNoErrorHandlerSilentlyContinues() { - // Without a custom error handler, exceptions should be silently consumed - var received = new ArrayList(); + void testDefaultPolicyPropagatesAndLogs() { + // Default policy is PROPAGATE_AND_LOG_ERRORS β€” stops dispatch on first error + var handler1Called = new AtomicInteger(0); + var handler2Called = new AtomicInteger(0); - session.on(AssistantMessageEvent.class, msg -> { - throw new RuntimeException("boom"); - }); + Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); + Level originalLevel = sessionLogger.getLevel(); + sessionLogger.setLevel(Level.OFF); - session.on(AssistantMessageEvent.class, msg -> { - received.add(msg.getData().getContent()); - }); + try { + // Both handlers throw β€” with PROPAGATE only one should execute + session.on(AssistantMessageEvent.class, msg -> { + handler1Called.incrementAndGet(); + throw new RuntimeException("boom 1"); + }); - assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); + session.on(AssistantMessageEvent.class, msg -> { + handler2Called.incrementAndGet(); + throw new RuntimeException("boom 2"); + }); + + assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); - // Second handler should still execute (default CONTINUE policy) - assertEquals(1, received.size()); + // Only one handler should execute (default PROPAGATE_AND_LOG_ERRORS policy) + int totalCalls = handler1Called.get() + handler2Called.get(); + assertEquals(1, totalCalls, "Only one handler should execute with default PROPAGATE_AND_LOG_ERRORS policy"); + } finally { + sessionLogger.setLevel(originalLevel); + } } @Test @@ -404,44 +420,61 @@ void testCustomEventErrorHandlerReceivesEventAndException() { var capturedEvents = new ArrayList(); var capturedExceptions = new ArrayList(); - session.setEventErrorHandler((event, exception) -> { - capturedEvents.add(event); - capturedExceptions.add(exception); - }); + Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); + Level originalLevel = sessionLogger.getLevel(); + sessionLogger.setLevel(Level.OFF); - var thrownException = new RuntimeException("test error"); - session.on(AssistantMessageEvent.class, msg -> { - throw thrownException; - }); + try { + session.setEventErrorHandler((event, exception) -> { + capturedEvents.add(event); + capturedExceptions.add(exception); + }); + + var thrownException = new RuntimeException("test error"); + session.on(AssistantMessageEvent.class, msg -> { + throw thrownException; + }); - var event = createAssistantMessageEvent("Hello"); - dispatchEvent(event); + var event = createAssistantMessageEvent("Hello"); + dispatchEvent(event); - assertEquals(1, capturedEvents.size()); - assertSame(event, capturedEvents.get(0)); - assertEquals(1, capturedExceptions.size()); - assertSame(thrownException, capturedExceptions.get(0)); + assertEquals(1, capturedEvents.size()); + assertSame(event, capturedEvents.get(0)); + assertEquals(1, capturedExceptions.size()); + assertSame(thrownException, capturedExceptions.get(0)); + } finally { + sessionLogger.setLevel(originalLevel); + } } @Test void testCustomErrorHandlerCalledForAllErrors() { var errorCount = new AtomicInteger(0); - session.setEventErrorHandler((event, exception) -> { - errorCount.incrementAndGet(); - }); + Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); + Level originalLevel = sessionLogger.getLevel(); + sessionLogger.setLevel(Level.OFF); - session.on(AssistantMessageEvent.class, msg -> { - throw new RuntimeException("error 1"); - }); - session.on(AssistantMessageEvent.class, msg -> { - throw new RuntimeException("error 2"); - }); + try { + session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS); + session.setEventErrorHandler((event, exception) -> { + errorCount.incrementAndGet(); + }); + + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("error 1"); + }); + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("error 2"); + }); - dispatchEvent(createAssistantMessageEvent("Test")); + dispatchEvent(createAssistantMessageEvent("Test")); - // Both handler errors should be reported to the custom error handler - assertEquals(2, errorCount.get()); + // Both handler errors should be reported to the custom error handler + assertEquals(2, errorCount.get()); + } finally { + sessionLogger.setLevel(originalLevel); + } } @Test @@ -483,48 +516,65 @@ void testErrorHandlerItselfThrowingStopsDispatch() { void testSetEventErrorHandlerToNullRestoresDefaultBehavior() { var errorCount = new AtomicInteger(0); - // Set custom handler - session.setEventErrorHandler((event, exception) -> { - errorCount.incrementAndGet(); - }); + Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); + Level originalLevel = sessionLogger.getLevel(); + sessionLogger.setLevel(Level.OFF); - session.on(AssistantMessageEvent.class, msg -> { - throw new RuntimeException("error"); - }); + try { + // Set custom handler + session.setEventErrorHandler((event, exception) -> { + errorCount.incrementAndGet(); + }); - dispatchEvent(createAssistantMessageEvent("Test1")); - assertEquals(1, errorCount.get()); + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("error"); + }); - // Reset to null (restore default silent behavior) - session.setEventErrorHandler(null); + dispatchEvent(createAssistantMessageEvent("Test1")); + assertEquals(1, errorCount.get()); - dispatchEvent(createAssistantMessageEvent("Test2")); + // Reset to null (restore default logging-only behavior) + session.setEventErrorHandler(null); - // Custom handler should NOT have been called again - assertEquals(1, errorCount.get()); + dispatchEvent(createAssistantMessageEvent("Test2")); + + // Custom handler should NOT have been called again + assertEquals(1, errorCount.get()); + } finally { + sessionLogger.setLevel(originalLevel); + } } @Test void testErrorHandlerReceivesCorrectEventType() { var capturedEvents = new ArrayList(); - session.setEventErrorHandler((event, exception) -> { - capturedEvents.add(event); - }); + Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); + Level originalLevel = sessionLogger.getLevel(); + sessionLogger.setLevel(Level.OFF); - session.on(event -> { - throw new RuntimeException("always fails"); - }); + try { + session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS); + session.setEventErrorHandler((event, exception) -> { + capturedEvents.add(event); + }); - var msgEvent = createAssistantMessageEvent("msg"); - var idleEvent = createSessionIdleEvent(); + session.on(event -> { + throw new RuntimeException("always fails"); + }); - dispatchEvent(msgEvent); - dispatchEvent(idleEvent); + var msgEvent = createAssistantMessageEvent("msg"); + var idleEvent = createSessionIdleEvent(); - assertEquals(2, capturedEvents.size()); - assertInstanceOf(AssistantMessageEvent.class, capturedEvents.get(0)); - assertInstanceOf(SessionIdleEvent.class, capturedEvents.get(1)); + dispatchEvent(msgEvent); + dispatchEvent(idleEvent); + + assertEquals(2, capturedEvents.size()); + assertInstanceOf(AssistantMessageEvent.class, capturedEvents.get(0)); + assertInstanceOf(SessionIdleEvent.class, capturedEvents.get(1)); + } finally { + sessionLogger.setLevel(originalLevel); + } } // ==================================================================== @@ -532,34 +582,38 @@ void testErrorHandlerReceivesCorrectEventType() { // ==================================================================== @Test - void testDefaultPolicyContinuesOnError() { + void testDefaultPolicyPropagatesOnError() { var handler1Called = new AtomicInteger(0); var handler2Called = new AtomicInteger(0); - var handler3Called = new AtomicInteger(0); - session.setEventErrorHandler((event, exception) -> { - // just consume - }); + Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); + Level originalLevel = sessionLogger.getLevel(); + sessionLogger.setLevel(Level.OFF); - session.on(AssistantMessageEvent.class, msg -> { - handler1Called.incrementAndGet(); - throw new RuntimeException("error"); - }); + try { + session.setEventErrorHandler((event, exception) -> { + // just consume + }); - session.on(AssistantMessageEvent.class, msg -> { - handler2Called.incrementAndGet(); - }); + // Both handlers throw β€” with PROPAGATE only one should execute + session.on(AssistantMessageEvent.class, msg -> { + handler1Called.incrementAndGet(); + throw new RuntimeException("error 1"); + }); - session.on(AssistantMessageEvent.class, msg -> { - handler3Called.incrementAndGet(); - }); + session.on(AssistantMessageEvent.class, msg -> { + handler2Called.incrementAndGet(); + throw new RuntimeException("error 2"); + }); - dispatchEvent(createAssistantMessageEvent("Test")); + dispatchEvent(createAssistantMessageEvent("Test")); - // All handlers should have been called (SUPPRESS is default) - assertEquals(1, handler1Called.get()); - assertEquals(1, handler2Called.get()); - assertEquals(1, handler3Called.get()); + // Default is PROPAGATE_AND_LOG_ERRORS β€” only one handler runs + int totalCalls = handler1Called.get() + handler2Called.get(); + assertEquals(1, totalCalls, "Only one handler should execute with default PROPAGATE_AND_LOG_ERRORS policy"); + } finally { + sessionLogger.setLevel(originalLevel); + } } @Test @@ -568,47 +622,63 @@ void testPropagatePolicyStopsOnFirstError() { var handler2Called = new AtomicInteger(0); var errorHandlerCalls = new AtomicInteger(0); - session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE); - session.setEventErrorHandler((event, exception) -> { - errorHandlerCalls.incrementAndGet(); - }); + Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); + Level originalLevel = sessionLogger.getLevel(); + sessionLogger.setLevel(Level.OFF); - // Two handlers that throw - session.on(AssistantMessageEvent.class, msg -> { - handler1Called.incrementAndGet(); - throw new RuntimeException("error 1"); - }); + try { + session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS); + session.setEventErrorHandler((event, exception) -> { + errorHandlerCalls.incrementAndGet(); + }); - session.on(AssistantMessageEvent.class, msg -> { - handler2Called.incrementAndGet(); - throw new RuntimeException("error 2"); - }); + // Two handlers that throw + session.on(AssistantMessageEvent.class, msg -> { + handler1Called.incrementAndGet(); + throw new RuntimeException("error 1"); + }); - dispatchEvent(createAssistantMessageEvent("Test")); + session.on(AssistantMessageEvent.class, msg -> { + handler2Called.incrementAndGet(); + throw new RuntimeException("error 2"); + }); - // Only one handler should have been called (PROPAGATE policy) - assertEquals(1, errorHandlerCalls.get()); - int totalCalls = handler1Called.get() + handler2Called.get(); - assertEquals(1, totalCalls, "Only one handler should execute with PROPAGATE policy"); + dispatchEvent(createAssistantMessageEvent("Test")); + + // Only one handler should have been called (PROPAGATE_AND_LOG_ERRORS policy) + assertEquals(1, errorHandlerCalls.get()); + int totalCalls = handler1Called.get() + handler2Called.get(); + assertEquals(1, totalCalls, "Only one handler should execute with PROPAGATE_AND_LOG_ERRORS policy"); + } finally { + sessionLogger.setLevel(originalLevel); + } } @Test void testPropagatePolicyErrorHandlerAlwaysInvoked() { var errorHandlerCalls = new AtomicInteger(0); - session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE); - session.setEventErrorHandler((event, exception) -> { - errorHandlerCalls.incrementAndGet(); - }); + Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); + Level originalLevel = sessionLogger.getLevel(); + sessionLogger.setLevel(Level.OFF); - session.on(AssistantMessageEvent.class, msg -> { - throw new RuntimeException("error"); - }); + try { + session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS); + session.setEventErrorHandler((event, exception) -> { + errorHandlerCalls.incrementAndGet(); + }); - dispatchEvent(createAssistantMessageEvent("Test")); + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("error"); + }); - // Error handler should be called even with PROPAGATE policy - assertEquals(1, errorHandlerCalls.get()); + dispatchEvent(createAssistantMessageEvent("Test")); + + // Error handler should be called even with PROPAGATE_AND_LOG_ERRORS policy + assertEquals(1, errorHandlerCalls.get()); + } finally { + sessionLogger.setLevel(originalLevel); + } } @Test @@ -616,29 +686,37 @@ void testSuppressPolicyWithMultipleErrors() { var errorHandlerCalls = new AtomicInteger(0); var successfulHandlerCalls = new AtomicInteger(0); - session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS); - session.setEventErrorHandler((event, exception) -> { - errorHandlerCalls.incrementAndGet(); - }); + Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); + Level originalLevel = sessionLogger.getLevel(); + sessionLogger.setLevel(Level.OFF); - session.on(AssistantMessageEvent.class, msg -> { - throw new RuntimeException("error 1"); - }); - session.on(AssistantMessageEvent.class, msg -> { - throw new RuntimeException("error 2"); - }); - session.on(AssistantMessageEvent.class, msg -> { - successfulHandlerCalls.incrementAndGet(); - }); - session.on(AssistantMessageEvent.class, msg -> { - throw new RuntimeException("error 3"); - }); + try { + session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS); + session.setEventErrorHandler((event, exception) -> { + errorHandlerCalls.incrementAndGet(); + }); + + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("error 1"); + }); + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("error 2"); + }); + session.on(AssistantMessageEvent.class, msg -> { + successfulHandlerCalls.incrementAndGet(); + }); + session.on(AssistantMessageEvent.class, msg -> { + throw new RuntimeException("error 3"); + }); - dispatchEvent(createAssistantMessageEvent("Test")); + dispatchEvent(createAssistantMessageEvent("Test")); - // All errors should be reported, successful handler should run - assertEquals(3, errorHandlerCalls.get()); - assertEquals(1, successfulHandlerCalls.get()); + // All errors should be reported, successful handler should run + assertEquals(3, errorHandlerCalls.get()); + assertEquals(1, successfulHandlerCalls.get()); + } finally { + sessionLogger.setLevel(originalLevel); + } } @Test @@ -646,59 +724,76 @@ void testSwitchPolicyDynamically() { var handler1Called = new AtomicInteger(0); var handler2Called = new AtomicInteger(0); - session.setEventErrorHandler((event, exception) -> { - // just consume - }); + Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); + Level originalLevel = sessionLogger.getLevel(); + sessionLogger.setLevel(Level.OFF); - // Two handlers that throw - session.on(AssistantMessageEvent.class, msg -> { - handler1Called.incrementAndGet(); - throw new RuntimeException("error"); - }); - session.on(AssistantMessageEvent.class, msg -> { - handler2Called.incrementAndGet(); - throw new RuntimeException("error"); - }); + try { + session.setEventErrorHandler((event, exception) -> { + // just consume + }); - // With SUPPRESS, both should fire - session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS); - dispatchEvent(createAssistantMessageEvent("Test1")); - assertEquals(1, handler1Called.get()); - assertEquals(1, handler2Called.get()); + // Two handlers that throw + session.on(AssistantMessageEvent.class, msg -> { + handler1Called.incrementAndGet(); + throw new RuntimeException("error"); + }); + session.on(AssistantMessageEvent.class, msg -> { + handler2Called.incrementAndGet(); + throw new RuntimeException("error"); + }); - handler1Called.set(0); - handler2Called.set(0); + // With SUPPRESS_AND_LOG_ERRORS, both should fire + session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS); + dispatchEvent(createAssistantMessageEvent("Test1")); + assertEquals(1, handler1Called.get()); + assertEquals(1, handler2Called.get()); - // Switch to PROPAGATE β€” only one should fire - session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE); - dispatchEvent(createAssistantMessageEvent("Test2")); - int totalCalls = handler1Called.get() + handler2Called.get(); - assertEquals(1, totalCalls, "Only one handler should execute after switching to PROPAGATE"); + handler1Called.set(0); + handler2Called.set(0); + + // Switch to PROPAGATE_AND_LOG_ERRORS β€” only one should fire + session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS); + dispatchEvent(createAssistantMessageEvent("Test2")); + int totalCalls = handler1Called.get() + handler2Called.get(); + assertEquals(1, totalCalls, "Only one handler should execute after switching to PROPAGATE_AND_LOG_ERRORS"); + } finally { + sessionLogger.setLevel(originalLevel); + } } @Test - void testPropagatePolicyNoErrorHandlerSilentlyStops() { + void testPropagatePolicyNoErrorHandlerStopsAndLogs() { var handler1Called = new AtomicInteger(0); var handler2Called = new AtomicInteger(0); - // No error handler set, PROPAGATE policy - session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE); + Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); + Level originalLevel = sessionLogger.getLevel(); + sessionLogger.setLevel(Level.OFF); - session.on(AssistantMessageEvent.class, msg -> { - handler1Called.incrementAndGet(); - throw new RuntimeException("error"); - }); + try { + // No error handler set, PROPAGATE_AND_LOG_ERRORS policy + session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS); - session.on(AssistantMessageEvent.class, msg -> { - handler2Called.incrementAndGet(); - throw new RuntimeException("error"); - }); + session.on(AssistantMessageEvent.class, msg -> { + handler1Called.incrementAndGet(); + throw new RuntimeException("error"); + }); + + session.on(AssistantMessageEvent.class, msg -> { + handler2Called.incrementAndGet(); + throw new RuntimeException("error"); + }); - assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); + assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); - // PROPAGATE policy should stop after first error, even without error handler - int totalCalls = handler1Called.get() + handler2Called.get(); - assertEquals(1, totalCalls, "Only one handler should execute with PROPAGATE policy and no error handler"); + // PROPAGATE_AND_LOG_ERRORS policy should stop after first error + int totalCalls = handler1Called.get() + handler2Called.get(); + assertEquals(1, totalCalls, + "Only one handler should execute with PROPAGATE_AND_LOG_ERRORS policy and no error handler"); + } finally { + sessionLogger.setLevel(originalLevel); + } } @Test @@ -711,8 +806,8 @@ void testErrorHandlerThrowingStopsRegardlessOfPolicy() { sessionLogger.setLevel(Level.OFF); try { - // SUPPRESS policy, but error handler throws - session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS); + // SUPPRESS_AND_LOG_ERRORS policy, but error handler throws + session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS); session.setEventErrorHandler((event, exception) -> { throw new RuntimeException("error handler broke"); }); @@ -729,10 +824,11 @@ void testErrorHandlerThrowingStopsRegardlessOfPolicy() { assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); - // Error handler threw β€” should stop regardless of SUPPRESS policy + // Error handler threw β€” should stop regardless of SUPPRESS_AND_LOG_ERRORS + // policy int totalCalls = handler1Called.get() + handler2Called.get(); assertEquals(1, totalCalls, - "Only one handler should execute when error handler throws, even with SUPPRESS policy"); + "Only one handler should execute when error handler throws, even with SUPPRESS_AND_LOG_ERRORS policy"); } finally { sessionLogger.setLevel(originalLevel); } From c57e7a3c7ef4ffd35bf4884858b976b7c869b4f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 23:25:06 +0000 Subject: [PATCH 052/109] Add closed-session guard implementation Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- .../github/copilot/sdk/CopilotSession.java | 45 ++- .../copilot/sdk/ClosedSessionGuardTest.java | 337 ++++++++++++++++++ 2 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/github/copilot/sdk/ClosedSessionGuardTest.java diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 61ff49174..83dc197e4 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -97,6 +97,9 @@ public final class CopilotSession implements AutoCloseable { private final AtomicReference hooksHandler = new AtomicReference<>(); private volatile EventErrorHandler eventErrorHandler; private volatile EventErrorPolicy eventErrorPolicy = EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS; + + /** Tracks whether this session instance has been terminated via close(). */ + private volatile boolean isTerminated = false; /** * Creates a new session with the given ID and RPC client. @@ -186,11 +189,13 @@ public String getWorkspacePath() { * @param handler * the error handler, or {@code null} to use only the default logging * behavior + * @throws IllegalStateException if this session has been terminated * @see EventErrorHandler * @see #setEventErrorPolicy(EventErrorPolicy) * @since 1.0.8 */ public void setEventErrorHandler(EventErrorHandler handler) { + ensureNotTerminated(); this.eventErrorHandler = handler; } @@ -224,11 +229,13 @@ public void setEventErrorHandler(EventErrorHandler handler) { * @param policy * the error policy (default is * {@link EventErrorPolicy#PROPAGATE_AND_LOG_ERRORS}) + * @throws IllegalStateException if this session has been terminated * @see EventErrorPolicy * @see #setEventErrorHandler(EventErrorHandler) * @since 1.0.8 */ public void setEventErrorPolicy(EventErrorPolicy policy) { + ensureNotTerminated(); if (policy == null) { throw new NullPointerException("policy must not be null"); } @@ -244,9 +251,11 @@ public void setEventErrorPolicy(EventErrorPolicy policy) { * @param prompt * the message text to send * @return a future that resolves with the message ID assigned by the server + * @throws IllegalStateException if this session has been terminated * @see #send(MessageOptions) */ public CompletableFuture send(String prompt) { + ensureNotTerminated(); return send(new MessageOptions().setPrompt(prompt)); } @@ -260,9 +269,11 @@ public CompletableFuture send(String prompt) { * the message text to send * @return a future that resolves with the final assistant message event, or * {@code null} if no assistant message was received + * @throws IllegalStateException if this session has been terminated * @see #sendAndWait(MessageOptions) */ public CompletableFuture sendAndWait(String prompt) { + ensureNotTerminated(); return sendAndWait(new MessageOptions().setPrompt(prompt)); } @@ -275,10 +286,12 @@ public CompletableFuture sendAndWait(String prompt) { * @param options * the message options containing the prompt and attachments * @return a future that resolves with the message ID assigned by the server + * @throws IllegalStateException if this session has been terminated * @see #sendAndWait(MessageOptions) * @see #send(String) */ public CompletableFuture send(MessageOptions options) { + ensureNotTerminated(); var request = new SendMessageRequest(); request.setSessionId(sessionId); request.setPrompt(options.getPrompt()); @@ -304,10 +317,12 @@ public CompletableFuture send(MessageOptions options) { * {@code null} if no assistant message was received. The future * completes exceptionally with a TimeoutException if the timeout * expires. + * @throws IllegalStateException if this session has been terminated * @see #sendAndWait(MessageOptions) * @see #send(MessageOptions) */ public CompletableFuture sendAndWait(MessageOptions options, long timeoutMs) { + ensureNotTerminated(); var future = new CompletableFuture(); var lastAssistantMessage = new AtomicReference(); @@ -365,9 +380,11 @@ public CompletableFuture sendAndWait(MessageOptions optio * the message options containing the prompt and attachments * @return a future that resolves with the final assistant message event, or * {@code null} if no assistant message was received + * @throws IllegalStateException if this session has been terminated * @see #sendAndWait(MessageOptions, long) */ public CompletableFuture sendAndWait(MessageOptions options) { + ensureNotTerminated(); return sendAndWait(options, 60000); } @@ -397,11 +414,13 @@ public CompletableFuture sendAndWait(MessageOptions optio * @param handler * a callback to be invoked when a session event occurs * @return a Closeable that, when closed, unsubscribes the handler + * @throws IllegalStateException if this session has been terminated * @see #on(Class, Consumer) * @see AbstractSessionEvent * @see #setEventErrorPolicy(EventErrorPolicy) */ public Closeable on(Consumer handler) { + ensureNotTerminated(); eventHandlers.add(handler); return () -> eventHandlers.remove(handler); } @@ -447,10 +466,12 @@ public Closeable on(Consumer handler) { * @param handler * a callback invoked when events of this type occur * @return a Closeable that unsubscribes the handler when closed + * @throws IllegalStateException if this session has been terminated * @see #on(Consumer) * @see AbstractSessionEvent */ public Closeable on(Class eventType, Consumer handler) { + ensureNotTerminated(); Consumer wrapper = event -> { if (eventType.isInstance(event)) { handler.accept(eventType.cast(event)); @@ -708,9 +729,11 @@ CompletableFuture handleHooksInvoke(String hookType, JsonNode input) { * assistant responses, tool invocations, and other session events. * * @return a future that resolves with a list of all session events + * @throws IllegalStateException if this session has been terminated * @see AbstractSessionEvent */ public CompletableFuture> getMessages() { + ensureNotTerminated(); return rpc.invoke("session.getMessages", Map.of("sessionId", sessionId), GetMessagesResponse.class) .thenApply(response -> { var events = new ArrayList(); @@ -737,20 +760,40 @@ public CompletableFuture> getMessages() { * continuing to generate a response. * * @return a future that completes when the abort is acknowledged + * @throws IllegalStateException if this session has been terminated */ public CompletableFuture abort() { + ensureNotTerminated(); return rpc.invoke("session.abort", Map.of("sessionId", sessionId), Void.class); } + + /** + * Verifies that this session has not yet been terminated. + * + * @throws IllegalStateException if close() has already been invoked + */ + private void ensureNotTerminated() { + if (isTerminated) { + throw new IllegalStateException("Session is closed"); + } + } /** * Disposes the session and releases all associated resources. *

* This destroys the session on the server, clears all event handlers, and * releases tool and permission handlers. After calling this method, the session - * cannot be used again. + * cannot be used again. Subsequent calls to this method have no effect. */ @Override public void close() { + synchronized (this) { + if (isTerminated) { + return; // Already terminated - no-op + } + isTerminated = true; + } + try { rpc.invoke("session.destroy", Map.of("sessionId", sessionId), Void.class).get(5, TimeUnit.SECONDS); } catch (Exception e) { diff --git a/src/test/java/com/github/copilot/sdk/ClosedSessionGuardTest.java b/src/test/java/com/github/copilot/sdk/ClosedSessionGuardTest.java new file mode 100644 index 000000000..485c39a52 --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/ClosedSessionGuardTest.java @@ -0,0 +1,337 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.github.copilot.sdk.events.AssistantMessageEvent; +import com.github.copilot.sdk.json.MessageOptions; +import com.github.copilot.sdk.json.SessionConfig; + +/** + * Tests for closed-session guard functionality in CopilotSession. + * + *

+ * Verifies that all public methods that interact with session state throw + * IllegalStateException when invoked after close(), and that close() itself + * is idempotent. + *

+ */ +public class ClosedSessionGuardTest { + + private static E2ETestContext ctx; + + @BeforeAll + static void setup() throws Exception { + ctx = E2ETestContext.create(); + } + + @AfterAll + static void teardown() throws Exception { + if (ctx != null) { + ctx.close(); + } + } + + /** + * Verifies that send(String) throws IllegalStateException after session is + * terminated. + */ + @Test + void testSendStringThrowsAfterTermination() throws Exception { + ctx.configureForTest("session", "should_receive_session_events"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(new SessionConfig().setModel("fake-test-model")).get(); + session.close(); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { + session.send("test message"); + }); + assert thrown.getMessage().contains("closed"); + } + } + + /** + * Verifies that send(MessageOptions) throws IllegalStateException after + * session is terminated. + */ + @Test + void testSendOptionsThrowsAfterTermination() throws Exception { + ctx.configureForTest("session", "should_receive_session_events"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(new SessionConfig().setModel("fake-test-model")).get(); + session.close(); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { + session.send(new MessageOptions().setPrompt("test message")); + }); + assert thrown.getMessage().contains("closed"); + } + } + + /** + * Verifies that sendAndWait(String) throws IllegalStateException after session + * is terminated. + */ + @Test + void testSendAndWaitStringThrowsAfterTermination() throws Exception { + ctx.configureForTest("session", "should_receive_session_events"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(new SessionConfig().setModel("fake-test-model")).get(); + session.close(); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { + session.sendAndWait("test message"); + }); + assert thrown.getMessage().contains("closed"); + } + } + + /** + * Verifies that sendAndWait(MessageOptions) throws IllegalStateException after + * session is terminated. + */ + @Test + void testSendAndWaitOptionsThrowsAfterTermination() throws Exception { + ctx.configureForTest("session", "should_receive_session_events"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(new SessionConfig().setModel("fake-test-model")).get(); + session.close(); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { + session.sendAndWait(new MessageOptions().setPrompt("test message")); + }); + assert thrown.getMessage().contains("closed"); + } + } + + /** + * Verifies that sendAndWait(MessageOptions, long) throws IllegalStateException + * after session is terminated. + */ + @Test + void testSendAndWaitWithTimeoutThrowsAfterTermination() throws Exception { + ctx.configureForTest("session", "should_receive_session_events"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(new SessionConfig().setModel("fake-test-model")).get(); + session.close(); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { + session.sendAndWait(new MessageOptions().setPrompt("test message"), 5000); + }); + assert thrown.getMessage().contains("closed"); + } + } + + /** + * Verifies that on(Consumer) throws IllegalStateException after session is + * terminated. + */ + @Test + void testOnConsumerThrowsAfterTermination() throws Exception { + ctx.configureForTest("session", "should_receive_session_events"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(new SessionConfig().setModel("fake-test-model")).get(); + session.close(); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { + session.on(evt -> { + // Handler should never be registered + }); + }); + assert thrown.getMessage().contains("closed"); + } + } + + /** + * Verifies that on(Class, Consumer) throws IllegalStateException after session + * is terminated. + */ + @Test + void testOnTypedConsumerThrowsAfterTermination() throws Exception { + ctx.configureForTest("session", "should_receive_session_events"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(new SessionConfig().setModel("fake-test-model")).get(); + session.close(); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { + session.on(AssistantMessageEvent.class, msg -> { + // Handler should never be registered + }); + }); + assert thrown.getMessage().contains("closed"); + } + } + + /** + * Verifies that getMessages() throws IllegalStateException after session is + * terminated. + */ + @Test + void testGetMessagesThrowsAfterTermination() throws Exception { + ctx.configureForTest("session", "should_receive_session_events"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(new SessionConfig().setModel("fake-test-model")).get(); + session.close(); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { + session.getMessages(); + }); + assert thrown.getMessage().contains("closed"); + } + } + + /** + * Verifies that abort() throws IllegalStateException after session is + * terminated. + */ + @Test + void testAbortThrowsAfterTermination() throws Exception { + ctx.configureForTest("session", "should_receive_session_events"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(new SessionConfig().setModel("fake-test-model")).get(); + session.close(); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { + session.abort(); + }); + assert thrown.getMessage().contains("closed"); + } + } + + /** + * Verifies that setEventErrorHandler() throws IllegalStateException after + * session is terminated. + */ + @Test + void testSetEventErrorHandlerThrowsAfterTermination() throws Exception { + ctx.configureForTest("session", "should_receive_session_events"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(new SessionConfig().setModel("fake-test-model")).get(); + session.close(); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { + session.setEventErrorHandler((event, ex) -> { + // Handler should never be set + }); + }); + assert thrown.getMessage().contains("closed"); + } + } + + /** + * Verifies that setEventErrorPolicy() throws IllegalStateException after + * session is terminated. + */ + @Test + void testSetEventErrorPolicyThrowsAfterTermination() throws Exception { + ctx.configureForTest("session", "should_receive_session_events"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(new SessionConfig().setModel("fake-test-model")).get(); + session.close(); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { + session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS); + }); + assert thrown.getMessage().contains("closed"); + } + } + + /** + * Verifies that getSessionId() still works after session is terminated (it's + * just a field read). + */ + @Test + void testGetSessionIdWorksAfterTermination() throws Exception { + ctx.configureForTest("session", "should_receive_session_events"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(new SessionConfig().setModel("fake-test-model")).get(); + String sessionIdBeforeClose = session.getSessionId(); + session.close(); + + String sessionIdAfterClose = session.getSessionId(); + assert sessionIdBeforeClose.equals(sessionIdAfterClose); + } + } + + /** + * Verifies that getWorkspacePath() still works after session is terminated + * (it's just a field read). + */ + @Test + void testGetWorkspacePathWorksAfterTermination() throws Exception { + ctx.configureForTest("session", "should_receive_session_events"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(new SessionConfig().setModel("fake-test-model")).get(); + String pathBeforeClose = session.getWorkspacePath(); + session.close(); + + String pathAfterClose = session.getWorkspacePath(); + assert pathBeforeClose == pathAfterClose; // Both should be null or same value + } + } + + /** + * Verifies that close() is idempotent and can be called multiple times safely. + */ + @Test + void testCloseIsIdempotent() throws Exception { + ctx.configureForTest("session", "should_receive_session_events"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(new SessionConfig().setModel("fake-test-model")).get(); + + // First close should succeed + assertDoesNotThrow(() -> session.close()); + + // Second close should also succeed (no-op) + assertDoesNotThrow(() -> session.close()); + + // Third close should also succeed (no-op) + assertDoesNotThrow(() -> session.close()); + } + } + + /** + * Verifies that try-with-resources double-close scenario works correctly. + */ + @Test + void testTryWithResourcesDoubleClose() throws Exception { + ctx.configureForTest("session", "should_receive_session_events"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(new SessionConfig().setModel("fake-test-model")).get(); + + try (session) { + // Manual close within try-with-resources + session.close(); + // Automatic close will happen at end of block + } // Second close happens here + + // Should be able to verify it's closed + assertThrows(IllegalStateException.class, () -> { + session.send("test"); + }); + } + } +} From 46b82044669695945b20ca81f42ddad5bfa216db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 23:31:45 +0000 Subject: [PATCH 053/109] Fix test to handle new IllegalStateException from closed sessions Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- .../github/copilot/sdk/CopilotSession.java | 42 ++++++++++++------- .../copilot/sdk/ClosedSessionGuardTest.java | 10 ++--- .../copilot/sdk/CopilotSessionTest.java | 8 ++-- 3 files changed, 37 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 83dc197e4..0a81cd719 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -97,7 +97,7 @@ public final class CopilotSession implements AutoCloseable { private final AtomicReference hooksHandler = new AtomicReference<>(); private volatile EventErrorHandler eventErrorHandler; private volatile EventErrorPolicy eventErrorPolicy = EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS; - + /** Tracks whether this session instance has been terminated via close(). */ private volatile boolean isTerminated = false; @@ -189,7 +189,8 @@ public String getWorkspacePath() { * @param handler * the error handler, or {@code null} to use only the default logging * behavior - * @throws IllegalStateException if this session has been terminated + * @throws IllegalStateException + * if this session has been terminated * @see EventErrorHandler * @see #setEventErrorPolicy(EventErrorPolicy) * @since 1.0.8 @@ -229,7 +230,8 @@ public void setEventErrorHandler(EventErrorHandler handler) { * @param policy * the error policy (default is * {@link EventErrorPolicy#PROPAGATE_AND_LOG_ERRORS}) - * @throws IllegalStateException if this session has been terminated + * @throws IllegalStateException + * if this session has been terminated * @see EventErrorPolicy * @see #setEventErrorHandler(EventErrorHandler) * @since 1.0.8 @@ -251,7 +253,8 @@ public void setEventErrorPolicy(EventErrorPolicy policy) { * @param prompt * the message text to send * @return a future that resolves with the message ID assigned by the server - * @throws IllegalStateException if this session has been terminated + * @throws IllegalStateException + * if this session has been terminated * @see #send(MessageOptions) */ public CompletableFuture send(String prompt) { @@ -269,7 +272,8 @@ public CompletableFuture send(String prompt) { * the message text to send * @return a future that resolves with the final assistant message event, or * {@code null} if no assistant message was received - * @throws IllegalStateException if this session has been terminated + * @throws IllegalStateException + * if this session has been terminated * @see #sendAndWait(MessageOptions) */ public CompletableFuture sendAndWait(String prompt) { @@ -286,7 +290,8 @@ public CompletableFuture sendAndWait(String prompt) { * @param options * the message options containing the prompt and attachments * @return a future that resolves with the message ID assigned by the server - * @throws IllegalStateException if this session has been terminated + * @throws IllegalStateException + * if this session has been terminated * @see #sendAndWait(MessageOptions) * @see #send(String) */ @@ -317,7 +322,8 @@ public CompletableFuture send(MessageOptions options) { * {@code null} if no assistant message was received. The future * completes exceptionally with a TimeoutException if the timeout * expires. - * @throws IllegalStateException if this session has been terminated + * @throws IllegalStateException + * if this session has been terminated * @see #sendAndWait(MessageOptions) * @see #send(MessageOptions) */ @@ -380,7 +386,8 @@ public CompletableFuture sendAndWait(MessageOptions optio * the message options containing the prompt and attachments * @return a future that resolves with the final assistant message event, or * {@code null} if no assistant message was received - * @throws IllegalStateException if this session has been terminated + * @throws IllegalStateException + * if this session has been terminated * @see #sendAndWait(MessageOptions, long) */ public CompletableFuture sendAndWait(MessageOptions options) { @@ -414,7 +421,8 @@ public CompletableFuture sendAndWait(MessageOptions optio * @param handler * a callback to be invoked when a session event occurs * @return a Closeable that, when closed, unsubscribes the handler - * @throws IllegalStateException if this session has been terminated + * @throws IllegalStateException + * if this session has been terminated * @see #on(Class, Consumer) * @see AbstractSessionEvent * @see #setEventErrorPolicy(EventErrorPolicy) @@ -466,7 +474,8 @@ public Closeable on(Consumer handler) { * @param handler * a callback invoked when events of this type occur * @return a Closeable that unsubscribes the handler when closed - * @throws IllegalStateException if this session has been terminated + * @throws IllegalStateException + * if this session has been terminated * @see #on(Consumer) * @see AbstractSessionEvent */ @@ -729,7 +738,8 @@ CompletableFuture handleHooksInvoke(String hookType, JsonNode input) { * assistant responses, tool invocations, and other session events. * * @return a future that resolves with a list of all session events - * @throws IllegalStateException if this session has been terminated + * @throws IllegalStateException + * if this session has been terminated * @see AbstractSessionEvent */ public CompletableFuture> getMessages() { @@ -760,17 +770,19 @@ public CompletableFuture> getMessages() { * continuing to generate a response. * * @return a future that completes when the abort is acknowledged - * @throws IllegalStateException if this session has been terminated + * @throws IllegalStateException + * if this session has been terminated */ public CompletableFuture abort() { ensureNotTerminated(); return rpc.invoke("session.abort", Map.of("sessionId", sessionId), Void.class); } - + /** * Verifies that this session has not yet been terminated. - * - * @throws IllegalStateException if close() has already been invoked + * + * @throws IllegalStateException + * if close() has already been invoked */ private void ensureNotTerminated() { if (isTerminated) { diff --git a/src/test/java/com/github/copilot/sdk/ClosedSessionGuardTest.java b/src/test/java/com/github/copilot/sdk/ClosedSessionGuardTest.java index 485c39a52..f7f74a4c3 100644 --- a/src/test/java/com/github/copilot/sdk/ClosedSessionGuardTest.java +++ b/src/test/java/com/github/copilot/sdk/ClosedSessionGuardTest.java @@ -17,11 +17,11 @@ /** * Tests for closed-session guard functionality in CopilotSession. - * + * *

* Verifies that all public methods that interact with session state throw - * IllegalStateException when invoked after close(), and that close() itself - * is idempotent. + * IllegalStateException when invoked after close(), and that close() itself is + * idempotent. *

*/ public class ClosedSessionGuardTest { @@ -60,8 +60,8 @@ void testSendStringThrowsAfterTermination() throws Exception { } /** - * Verifies that send(MessageOptions) throws IllegalStateException after - * session is terminated. + * Verifies that send(MessageOptions) throws IllegalStateException after session + * is terminated. */ @Test void testSendOptionsThrowsAfterTermination() throws Exception { diff --git a/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java b/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java index 398e6f2f8..02b8e6761 100644 --- a/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java +++ b/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java @@ -79,13 +79,15 @@ void testShouldReceiveSessionEvents_createAndDestroy() throws Exception { session.close(); - // Session should no longer be accessible + // Session should no longer be accessible - now throws IllegalStateException try { session.getMessages().get(); fail("Expected exception for closed session"); } catch (Exception e) { - assertTrue(e.getMessage().toLowerCase().contains("not found") - || e.getCause().getMessage().toLowerCase().contains("not found")); + // After our changes, we now get IllegalStateException directly + assertTrue(e.getMessage().toLowerCase().contains("closed") + || (e.getCause() != null + && e.getCause().getMessage().toLowerCase().contains("not found"))); } } } From 27c5fb59675af1fab99210afac1a25c754ed4964 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Fri, 6 Feb 2026 17:10:22 -0800 Subject: [PATCH 054/109] Update src/test/java/com/github/copilot/sdk/CopilotSessionTest.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../java/com/github/copilot/sdk/CopilotSessionTest.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java b/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java index 02b8e6761..781fb9a9a 100644 --- a/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java +++ b/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java @@ -85,9 +85,11 @@ void testShouldReceiveSessionEvents_createAndDestroy() throws Exception { fail("Expected exception for closed session"); } catch (Exception e) { // After our changes, we now get IllegalStateException directly - assertTrue(e.getMessage().toLowerCase().contains("closed") - || (e.getCause() != null - && e.getCause().getMessage().toLowerCase().contains("not found"))); + String message = e.getMessage(); + String causeMessage = e.getCause() != null ? e.getCause().getMessage() : null; + boolean matchesClosed = message != null && message.toLowerCase().contains("closed"); + boolean matchesNotFound = causeMessage != null && causeMessage.toLowerCase().contains("not found"); + assertTrue(matchesClosed || matchesNotFound); } } } From cbadfccda49f5a9ff582ebc336e78e2fd48277bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 01:14:10 +0000 Subject: [PATCH 055/109] Replace Java assert with JUnit assertions in tests Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- .../copilot/sdk/ClosedSessionGuardTest.java | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/test/java/com/github/copilot/sdk/ClosedSessionGuardTest.java b/src/test/java/com/github/copilot/sdk/ClosedSessionGuardTest.java index f7f74a4c3..f4606c646 100644 --- a/src/test/java/com/github/copilot/sdk/ClosedSessionGuardTest.java +++ b/src/test/java/com/github/copilot/sdk/ClosedSessionGuardTest.java @@ -5,7 +5,9 @@ package com.github.copilot.sdk; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -55,7 +57,7 @@ void testSendStringThrowsAfterTermination() throws Exception { IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { session.send("test message"); }); - assert thrown.getMessage().contains("closed"); + assertTrue(thrown.getMessage().contains("closed"), "Exception message should mention session is closed"); } } @@ -74,7 +76,7 @@ void testSendOptionsThrowsAfterTermination() throws Exception { IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { session.send(new MessageOptions().setPrompt("test message")); }); - assert thrown.getMessage().contains("closed"); + assertTrue(thrown.getMessage().contains("closed"), "Exception message should mention session is closed"); } } @@ -93,7 +95,7 @@ void testSendAndWaitStringThrowsAfterTermination() throws Exception { IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { session.sendAndWait("test message"); }); - assert thrown.getMessage().contains("closed"); + assertTrue(thrown.getMessage().contains("closed"), "Exception message should mention session is closed"); } } @@ -112,7 +114,7 @@ void testSendAndWaitOptionsThrowsAfterTermination() throws Exception { IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { session.sendAndWait(new MessageOptions().setPrompt("test message")); }); - assert thrown.getMessage().contains("closed"); + assertTrue(thrown.getMessage().contains("closed"), "Exception message should mention session is closed"); } } @@ -131,7 +133,7 @@ void testSendAndWaitWithTimeoutThrowsAfterTermination() throws Exception { IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { session.sendAndWait(new MessageOptions().setPrompt("test message"), 5000); }); - assert thrown.getMessage().contains("closed"); + assertTrue(thrown.getMessage().contains("closed"), "Exception message should mention session is closed"); } } @@ -152,7 +154,7 @@ void testOnConsumerThrowsAfterTermination() throws Exception { // Handler should never be registered }); }); - assert thrown.getMessage().contains("closed"); + assertTrue(thrown.getMessage().contains("closed"), "Exception message should mention session is closed"); } } @@ -173,7 +175,7 @@ void testOnTypedConsumerThrowsAfterTermination() throws Exception { // Handler should never be registered }); }); - assert thrown.getMessage().contains("closed"); + assertTrue(thrown.getMessage().contains("closed"), "Exception message should mention session is closed"); } } @@ -192,7 +194,7 @@ void testGetMessagesThrowsAfterTermination() throws Exception { IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { session.getMessages(); }); - assert thrown.getMessage().contains("closed"); + assertTrue(thrown.getMessage().contains("closed"), "Exception message should mention session is closed"); } } @@ -211,7 +213,7 @@ void testAbortThrowsAfterTermination() throws Exception { IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { session.abort(); }); - assert thrown.getMessage().contains("closed"); + assertTrue(thrown.getMessage().contains("closed"), "Exception message should mention session is closed"); } } @@ -232,7 +234,7 @@ void testSetEventErrorHandlerThrowsAfterTermination() throws Exception { // Handler should never be set }); }); - assert thrown.getMessage().contains("closed"); + assertTrue(thrown.getMessage().contains("closed"), "Exception message should mention session is closed"); } } @@ -251,7 +253,7 @@ void testSetEventErrorPolicyThrowsAfterTermination() throws Exception { IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS); }); - assert thrown.getMessage().contains("closed"); + assertTrue(thrown.getMessage().contains("closed"), "Exception message should mention session is closed"); } } @@ -269,7 +271,7 @@ void testGetSessionIdWorksAfterTermination() throws Exception { session.close(); String sessionIdAfterClose = session.getSessionId(); - assert sessionIdBeforeClose.equals(sessionIdAfterClose); + assertEquals(sessionIdBeforeClose, sessionIdAfterClose, "Session ID should remain accessible after close"); } } @@ -287,7 +289,7 @@ void testGetWorkspacePathWorksAfterTermination() throws Exception { session.close(); String pathAfterClose = session.getWorkspacePath(); - assert pathBeforeClose == pathAfterClose; // Both should be null or same value + assertEquals(pathBeforeClose, pathAfterClose, "Workspace path should remain accessible after close"); } } From 76a8daafd487159a216498149ba0d8959925cbc8 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 09:29:34 -0800 Subject: [PATCH 056/109] Update Copilot CLI minimum version requirement to 0.0.406 --- README.md | 2 +- src/site/markdown/index.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9bec447a2..3fa0bd5fe 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build A ### Requirements - Java 17 or later -- GitHub Copilot CLI 0.0.405 or later installed and in PATH (or provide custom `cliPath`) +- GitHub Copilot CLI 0.0.406 or later installed and in PATH (or provide custom `cliPath`) ### Maven diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index d8ec341ee..c3fe5ca4e 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -9,7 +9,7 @@ Welcome to the documentation for the **Copilot SDK for Java** β€” a Java SDK for ### Requirements - Java 17 or later -- GitHub Copilot CLI 0.0.405 or later installed and in PATH (or provide custom `cliPath`) +- GitHub Copilot CLI 0.0.406 or later installed and in PATH (or provide custom `cliPath`) ### Installation From 3fa2b3db4008532fabeda815c60046c88ae1d1db Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 09:33:19 -0800 Subject: [PATCH 057/109] Add --no-auto-update flag to CLI server startup args --- src/main/java/com/github/copilot/sdk/CliServerManager.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/github/copilot/sdk/CliServerManager.java b/src/main/java/com/github/copilot/sdk/CliServerManager.java index 059fa283f..c95d049d5 100644 --- a/src/main/java/com/github/copilot/sdk/CliServerManager.java +++ b/src/main/java/com/github/copilot/sdk/CliServerManager.java @@ -55,6 +55,7 @@ ProcessInfo startCliServer() throws IOException, InterruptedException { } args.add("--server"); + args.add("--no-auto-update"); args.add("--log-level"); args.add(options.getLogLevel()); From 8cf9d1d435b1ac1e9e821f2ea381dbfa1344163b Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 09:33:28 -0800 Subject: [PATCH 058/109] Add requestId and compactionTokensUsed to SessionCompactionCompleteData --- .../SessionCompactionCompleteEvent.java | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/main/java/com/github/copilot/sdk/events/SessionCompactionCompleteEvent.java b/src/main/java/com/github/copilot/sdk/events/SessionCompactionCompleteEvent.java index 4b0dc84eb..b47cc3eb5 100644 --- a/src/main/java/com/github/copilot/sdk/events/SessionCompactionCompleteEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/SessionCompactionCompleteEvent.java @@ -64,6 +64,12 @@ public static class SessionCompactionCompleteData { @JsonProperty("checkpointPath") private String checkpointPath; + @JsonProperty("compactionTokensUsed") + private CompactionTokensUsed compactionTokensUsed; + + @JsonProperty("requestId") + private String requestId; + public boolean isSuccess() { return success; } @@ -143,5 +149,61 @@ public String getCheckpointPath() { public void setCheckpointPath(String checkpointPath) { this.checkpointPath = checkpointPath; } + + public CompactionTokensUsed getCompactionTokensUsed() { + return compactionTokensUsed; + } + + public void setCompactionTokensUsed(CompactionTokensUsed compactionTokensUsed) { + this.compactionTokensUsed = compactionTokensUsed; + } + + public String getRequestId() { + return requestId; + } + + public void setRequestId(String requestId) { + this.requestId = requestId; + } + } + + /** + * Token usage information for the compaction operation. + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public static class CompactionTokensUsed { + + @JsonProperty("input") + private double input; + + @JsonProperty("output") + private double output; + + @JsonProperty("cachedInput") + private double cachedInput; + + public double getInput() { + return input; + } + + public void setInput(double input) { + this.input = input; + } + + public double getOutput() { + return output; + } + + public void setOutput(double output) { + this.output = output; + } + + public double getCachedInput() { + return cachedInput; + } + + public void setCachedInput(double cachedInput) { + this.cachedInput = cachedInput; + } } } From 6995f4beb1e4dbaa08c8a9a31f072c44807bf47f Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 09:37:25 -0800 Subject: [PATCH 059/109] Update .lastmerge to 2186bf290575321b34b672068608d8dff0671a76 --- .lastmerge | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.lastmerge b/.lastmerge index 973184229..43a50141d 100644 --- a/.lastmerge +++ b/.lastmerge @@ -1 +1 @@ -06ff2ae09fc44bf3db5c62c01a78f08e9d4c5e76 +2186bf290575321b34b672068608d8dff0671a76 From c84a3256cd41a1926c23c297dcc31c788adca150 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 09:42:44 -0800 Subject: [PATCH 060/109] Update merge-upstream prompt to auto-create PR via GitHub MCP tool --- .../prompts/agentic-merge-upstream.prompt.md | 52 +++++++++++++++---- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/.github/prompts/agentic-merge-upstream.prompt.md b/.github/prompts/agentic-merge-upstream.prompt.md index 485a8fae0..642973463 100644 --- a/.github/prompts/agentic-merge-upstream.prompt.md +++ b/.github/prompts/agentic-merge-upstream.prompt.md @@ -354,20 +354,52 @@ git commit -m "Update .lastmerge to $NEW_COMMIT" ## Step 12: Push Branch and Create Pull Request -Push the branch to remote so the changes can be reviewed via Pull Request: +Push the branch to remote and create a Pull Request automatically: ```bash # Push the branch to remote git push -u origin "$BRANCH_NAME" - -echo "Branch '$BRANCH_NAME' pushed to remote." -echo "Create a Pull Request to review and merge the changes." ``` -**Important:** After pushing, provide the user with: -1. The branch name that was pushed -2. A summary of the changes that were ported -3. Instructions to create a Pull Request comparing the branch against `main` +**After pushing, create the Pull Request using the GitHub MCP tool (`mcp_github_create_pull_request`).** + +Use `owner: copilot-community-sdk`, `repo: copilot-sdk-java`, `head: $BRANCH_NAME`, `base: main`. + +The PR body should include: +1. **Title**: `Merge upstream SDK changes (YYYY-MM-DD)` +2. **Body** with: + - Summary of upstream commits analyzed (with count and commit range) + - Table of changes ported (commit hash + description) + - List of changes intentionally not ported (with reasons) + - Verification status (test count, build status) + +### PR Body Template + +```markdown +## Upstream Merge + +Ports changes from the official Copilot SDK ([github/copilot-sdk](https://github.com/github/copilot-sdk)) since last merge (``β†’``). + +### Upstream commits analyzed (N commits) + +- Brief description of each upstream change and whether it was ported or not + +### Changes ported + +| Commit | Description | +|---|---| +| `` | Description of change | + +### Not ported (intentionally) + +- **Feature name** β€” Reason why it wasn't ported + +### Verification + +- All **N tests pass** (`mvn clean test`) +- Package builds successfully (`mvn clean package -DskipTests`) +- Code formatted with Spotless +``` ## Step 13: Final Review @@ -378,6 +410,7 @@ Before finishing: 3. Ensure no unintended changes were made 4. Verify code follows project conventions 5. Confirm the branch was pushed to remote +6. Confirm the Pull Request was created and provide the PR URL to the user --- @@ -404,7 +437,8 @@ Before finishing: - [ ] `src/site/site.xml` updated if new documentation pages were added - [ ] `.lastmerge` file updated with new commit hash - [ ] Branch pushed to remote -- [ ] User informed about Pull Request creation +- [ ] **Pull Request created** via GitHub MCP tool (`mcp_github_create_pull_request`) +- [ ] PR URL provided to user --- From 64ccbe373af377fb8ab5836547efe20c5eb0d151 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 09:51:08 -0800 Subject: [PATCH 061/109] Add upstream-sync label step to merge-upstream prompt --- .github/prompts/agentic-merge-upstream.prompt.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/prompts/agentic-merge-upstream.prompt.md b/.github/prompts/agentic-merge-upstream.prompt.md index 642973463..077b618e7 100644 --- a/.github/prompts/agentic-merge-upstream.prompt.md +++ b/.github/prompts/agentic-merge-upstream.prompt.md @@ -365,6 +365,12 @@ git push -u origin "$BRANCH_NAME" Use `owner: copilot-community-sdk`, `repo: copilot-sdk-java`, `head: $BRANCH_NAME`, `base: main`. +**After creating the PR, add the `upstream-sync` label** using the `gh` CLI: + +```bash +gh pr edit --add-label "upstream-sync" +``` + The PR body should include: 1. **Title**: `Merge upstream SDK changes (YYYY-MM-DD)` 2. **Body** with: @@ -438,6 +444,7 @@ Before finishing: - [ ] `.lastmerge` file updated with new commit hash - [ ] Branch pushed to remote - [ ] **Pull Request created** via GitHub MCP tool (`mcp_github_create_pull_request`) +- [ ] **`upstream-sync` label added** to the PR via `gh pr edit --add-label "upstream-sync"` - [ ] PR URL provided to user --- From fe24e30fd0bb1e2fde94eba78351906ab883d078 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 10:07:18 -0800 Subject: [PATCH 062/109] Add utility scripts for upstream merge process and update .gitignore - Introduced `merge-upstream-start.sh` to prepare workspace for upstream merges. - Added `merge-upstream-diff.sh` for detailed diff analysis of upstream changes. - Created `merge-upstream-finish.sh` to finalize merges and push branches. - Added `format-and-test.sh` for running formatting and testing in one command. - Updated `.gitignore` to exclude the generated `.merge-env` file. --- .../prompts/agentic-merge-upstream.prompt.md | 138 +++++++----------- .github/scripts/format-and-test.sh | 44 ++++++ .github/scripts/merge-upstream-diff.sh | 86 +++++++++++ .github/scripts/merge-upstream-finish.sh | 63 ++++++++ .github/scripts/merge-upstream-start.sh | 79 ++++++++++ .gitignore | 1 + 6 files changed, 324 insertions(+), 87 deletions(-) create mode 100755 .github/scripts/format-and-test.sh create mode 100755 .github/scripts/merge-upstream-diff.sh create mode 100755 .github/scripts/merge-upstream-finish.sh create mode 100755 .github/scripts/merge-upstream-start.sh diff --git a/.github/prompts/agentic-merge-upstream.prompt.md b/.github/prompts/agentic-merge-upstream.prompt.md index 077b618e7..470e1ba72 100644 --- a/.github/prompts/agentic-merge-upstream.prompt.md +++ b/.github/prompts/agentic-merge-upstream.prompt.md @@ -13,100 +13,74 @@ You are an expert Java developer tasked with porting changes from the official C Before making any changes, **read and understand the existing Java SDK implementation** to ensure new code integrates seamlessly. -## Workflow Overview - -1. Create a new branch from main -2. Update Copilot CLI to latest version -3. Update README with minimum CLI version requirement -4. Clone upstream repository -5. Analyze diff since last merge -6. Apply changes to Java SDK (commit as you go) -7. Test and fix issues -8. Update documentation -9. Push branch to remote for Pull Request review - ---- +## Utility Scripts -## Step 1: Create a New Branch +The `.github/scripts/` directory contains helper scripts that automate the repeatable parts of this workflow. **Use these scripts instead of running the commands manually.** -Before starting any work, create a new branch from `main` to isolate the merge changes: +| Script | Purpose | +|---|---| +| `.github/scripts/merge-upstream-start.sh` | Creates branch, updates CLI, clones upstream, reads `.lastmerge`, prints commit summary | +| `.github/scripts/merge-upstream-diff.sh` | Detailed diff analysis grouped by area (`.NET src`, tests, snapshots, docs, etc.) | +| `.github/scripts/merge-upstream-finish.sh` | Runs format + test + build, updates `.lastmerge`, commits, pushes branch | +| `.github/scripts/format-and-test.sh` | Standalone `spotless:apply` + `mvn clean verify` (useful during porting too) | -```bash -# Ensure we're on main and up to date -git checkout main -git pull origin main - -# Create a new branch with a descriptive name including the date -BRANCH_NAME="merge-upstream-$(date +%Y%m%d)" -git checkout -b "$BRANCH_NAME" -echo "Created branch: $BRANCH_NAME" -``` +All scripts write/read a `.merge-env` file (git-ignored) to share state (branch name, upstream dir, last-merge commit). -**Important:** All changes will be committed to this branch as you work. This allows for proper review via Pull Request. +## Workflow Overview -## Step 2: Update Copilot CLI +1. Run `./.github/scripts/merge-upstream-start.sh` (creates branch, clones upstream, shows summary) +2. Run `./.github/scripts/merge-upstream-diff.sh` (analyze changes) +3. Update README with minimum CLI version requirement +4. Port changes to Java SDK (commit as you go) +5. Run `./.github/scripts/format-and-test.sh` frequently while porting +6. Update documentation +7. Run `./.github/scripts/merge-upstream-finish.sh` (final test + push) +8. Create Pull Request -Update the locally installed GitHub Copilot CLI to the latest version: +--- -```bash -copilot update -``` +## Steps 1-2: Initialize and Analyze -After updating, capture the new version and update the README.md to reflect the minimum version requirement: +Run the start script to create a branch, update the CLI, clone the upstream repo, and see a summary of new commits: ```bash -# Get the current version -CLI_VERSION=$(copilot --version | head -n 1 | awk '{print $NF}') -echo "Updated Copilot CLI to version: $CLI_VERSION" +./.github/scripts/merge-upstream-start.sh ``` -Update the Requirements section in `README.md` to specify the minimum CLI version requirement. Locate the line mentioning "GitHub Copilot CLI installed" and update it to include the version information. +This writes a `.merge-env` file used by the other scripts. It outputs: +- The branch name created +- The Copilot CLI version +- The upstream dir path +- A short log of upstream commits since `.lastmerge` -Commit this change before proceeding: +Then run the diff script for a detailed breakdown by area: ```bash -git add README.md -git commit -m "Update Copilot CLI minimum version requirement to $CLI_VERSION" +./.github/scripts/merge-upstream-diff.sh # stat only +./.github/scripts/merge-upstream-diff.sh --full # full diffs ``` -## Step 3: Clone Upstream Repository - -Clone the official Copilot SDK repository into a temporary folder: +The diff script groups changes into: .NET source, .NET tests, test snapshots, documentation, protocol/config, Go/Node.js/Python SDKs, and other files. -```bash -UPSTREAM_REPO="https://github.com/github/copilot-sdk.git" -TEMP_DIR=$(mktemp -d) -git clone --depth=100 "$UPSTREAM_REPO" "$TEMP_DIR/copilot-sdk" -``` +## Step 3: Update README with CLI Version -## Step 4: Read Last Merge Commit +After the start script runs, check the CLI version it printed (also saved in `.merge-env` as `CLI_VERSION`). Update the Requirements section in `README.md` and `src/site/markdown/index.md` to specify the minimum CLI version requirement. -Read the commit hash from `.lastmerge` file in the Java SDK root: +Commit this change before proceeding: ```bash -LAST_MERGE_COMMIT=$(cat .lastmerge) -echo "Last merged commit: $LAST_MERGE_COMMIT" +git add README.md src/site/markdown/index.md +git commit -m "Update Copilot CLI minimum version requirement" ``` -## Step 5: Analyze Changes - -Generate a diff between the last merged commit and HEAD of main: +## Step 4: Identify Changes to Port -```bash -cd "$TEMP_DIR/copilot-sdk" -git fetch origin main -git log --oneline "$LAST_MERGE_COMMIT"..origin/main -git diff "$LAST_MERGE_COMMIT"..origin/main --stat -``` - -Focus on analyzing: +Using the output from `merge-upstream-diff.sh`, focus on: - `dotnet/src/` - Primary reference implementation - `dotnet/test/` - Test cases to port - `docs/` - Documentation updates - `sdk-protocol-version.json` - Protocol version changes -## Step 6: Identify Changes to Port - For each change in the upstream diff, determine: 1. **New Features**: New methods, classes, or capabilities added to the SDK @@ -243,14 +217,19 @@ Commit tests separately or together with their corresponding implementation chan ## Step 8: Format and Run Tests -After applying changes, format the code and run the test suite: +After applying changes, use the convenience script: ```bash -mvn spotless:apply -mvn clean test +./.github/scripts/format-and-test.sh # format + full verify +./.github/scripts/format-and-test.sh --debug # with debug logging ``` -**Important:** Always run `mvn spotless:apply` before testing to ensure code formatting is consistent with project standards. +Or for quicker iteration during porting: + +```bash +./.github/scripts/format-and-test.sh --format-only # just spotless +./.github/scripts/format-and-test.sh --test-only # skip formatting +``` ### If Tests Fail @@ -337,28 +316,13 @@ Ensure consistency across all documentation files: - Code examples should use the same patterns and be tested - Links to Javadoc should use correct paths (`apidocs/...`) -## Step 11: Update Last Merge Reference - -Update the `.lastmerge` file with the new HEAD commit and commit this change: - -```bash -cd "$TEMP_DIR/copilot-sdk" -NEW_COMMIT=$(git rev-parse origin/main) -cd - # Return to Java SDK directory -echo "$NEW_COMMIT" > .lastmerge - -# Commit the .lastmerge update -git add .lastmerge -git commit -m "Update .lastmerge to $NEW_COMMIT" -``` - -## Step 12: Push Branch and Create Pull Request +## Steps 11-12: Finish, Push, and Create Pull Request -Push the branch to remote and create a Pull Request automatically: +Run the finish script which updates `.lastmerge`, runs a final build, and pushes the branch: ```bash -# Push the branch to remote -git push -u origin "$BRANCH_NAME" +./.github/scripts/merge-upstream-finish.sh # full format + test + push +./.github/scripts/merge-upstream-finish.sh --skip-tests # if tests already passed ``` **After pushing, create the Pull Request using the GitHub MCP tool (`mcp_github_create_pull_request`).** diff --git a/.github/scripts/format-and-test.sh b/.github/scripts/format-and-test.sh new file mode 100755 index 000000000..857e6ce36 --- /dev/null +++ b/.github/scripts/format-and-test.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────────── +# format-and-test.sh +# +# Convenience script that runs the full quality pipeline: +# 1. spotless:apply (auto-format code) +# 2. mvn clean verify (compile + test + checkstyle + spotbugs) +# +# Usage: ./.github/scripts/format-and-test.sh +# ./.github/scripts/format-and-test.sh --format-only +# ./.github/scripts/format-and-test.sh --test-only +# ./.github/scripts/format-and-test.sh --debug (uses -Pdebug) +# ────────────────────────────────────────────────────────────── +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT_DIR" + +FORMAT=true +TEST=true +MVN_PROFILE="" + +for arg in "$@"; do + case "$arg" in + --format-only) TEST=false ;; + --test-only) FORMAT=false ;; + --debug) MVN_PROFILE="-Pdebug" ;; + *) echo "Unknown option: $arg"; exit 1 ;; + esac +done + +if $FORMAT; then + echo "β–Έ Running Spotless (format)…" + mvn spotless:apply +fi + +if $TEST; then + echo "β–Έ Running mvn clean verify $MVN_PROFILE …" + mvn clean verify $MVN_PROFILE + echo "" + echo "βœ… All checks passed." +else + echo "βœ… Formatting complete." +fi diff --git a/.github/scripts/merge-upstream-diff.sh b/.github/scripts/merge-upstream-diff.sh new file mode 100755 index 000000000..983cd7d0b --- /dev/null +++ b/.github/scripts/merge-upstream-diff.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────────── +# merge-upstream-diff.sh +# +# Generates a detailed diff analysis of upstream changes since +# the last merge, grouped by area of interest: +# β€’ .NET source (primary reference) +# β€’ .NET tests +# β€’ Test snapshots +# β€’ Documentation +# β€’ Protocol / config files +# +# Usage: ./.github/scripts/merge-upstream-diff.sh [--full] +# --full Show actual diffs, not just stats +# +# Requires: .merge-env written by merge-upstream-start.sh +# ────────────────────────────────────────────────────────────── +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +ENV_FILE="$ROOT_DIR/.merge-env" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "❌ $ENV_FILE not found. Run ./.github/scripts/merge-upstream-start.sh first." + exit 1 +fi + +# shellcheck source=/dev/null +source "$ENV_FILE" + +SHOW_FULL=false +if [[ "${1:-}" == "--full" ]]; then + SHOW_FULL=true +fi + +cd "$UPSTREAM_DIR" +git fetch origin main 2>/dev/null + +RANGE="$LAST_MERGE_COMMIT..origin/main" + +echo "════════════════════════════════════════════════════════════" +echo " Upstream diff analysis: $RANGE" +echo "════════════════════════════════════════════════════════════" + +# ── Commit log ──────────────────────────────────────────────── +echo "" +echo "── Commit log ──" +git log --oneline --no-decorate "$RANGE" +echo "" + +# Helper to print a section +section() { + local title="$1"; shift + local paths=("$@") + + echo "── $title ──" + local stat + stat=$(git diff "$RANGE" --stat -- "${paths[@]}" 2>/dev/null || true) + if [[ -z "$stat" ]]; then + echo " (no changes)" + else + echo "$stat" + if $SHOW_FULL; then + echo "" + git diff "$RANGE" -- "${paths[@]}" 2>/dev/null || true + fi + fi + echo "" +} + +# ── Sections ────────────────────────────────────────────────── +section ".NET source (dotnet/src)" "dotnet/src/" +section ".NET tests (dotnet/test)" "dotnet/test/" +section "Test snapshots" "test/snapshots/" +section "Documentation (docs/)" "docs/" +section "Protocol & config" "sdk-protocol-version.json" "package.json" "justfile" +section "Go SDK" "go/" +section "Node.js SDK" "nodejs/" +section "Python SDK" "python/" +section "Other files" "README.md" "CONTRIBUTING.md" "SECURITY.md" "SUPPORT.md" + +echo "════════════════════════════════════════════════════════════" +echo " To see full diffs: $0 --full" +echo " To see a specific path:" +echo " cd $UPSTREAM_DIR && git diff $RANGE -- " +echo "════════════════════════════════════════════════════════════" diff --git a/.github/scripts/merge-upstream-finish.sh b/.github/scripts/merge-upstream-finish.sh new file mode 100755 index 000000000..69ad51ed3 --- /dev/null +++ b/.github/scripts/merge-upstream-finish.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────────── +# merge-upstream-finish.sh +# +# Finalises an upstream merge: +# 1. Runs format + test + build (via format-and-test.sh) +# 2. Updates .lastmerge to upstream HEAD +# 3. Commits the .lastmerge update +# 4. Pushes the branch to origin +# +# Usage: ./.github/scripts/merge-upstream-finish.sh +# ./.github/scripts/merge-upstream-finish.sh --skip-tests +# +# Requires: .merge-env written by merge-upstream-start.sh +# ────────────────────────────────────────────────────────────── +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +ENV_FILE="$ROOT_DIR/.merge-env" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "❌ $ENV_FILE not found. Run ./.github/scripts/merge-upstream-start.sh first." + exit 1 +fi + +# shellcheck source=/dev/null +source "$ENV_FILE" + +SKIP_TESTS=false +if [[ "${1:-}" == "--skip-tests" ]]; then + SKIP_TESTS=true +fi + +cd "$ROOT_DIR" + +# ── 1. Format, test, build ─────────────────────────────────── +if $SKIP_TESTS; then + echo "β–Έ Formatting only (tests skipped)…" + mvn spotless:apply + mvn clean package -DskipTests +else + echo "β–Έ Running format + test + build…" + "$ROOT_DIR/.github/scripts/format-and-test.sh" +fi + +# ── 2. Update .lastmerge ───────────────────────────────────── +echo "β–Έ Updating .lastmerge…" +NEW_COMMIT=$(cd "$UPSTREAM_DIR" && git rev-parse origin/main) +echo "$NEW_COMMIT" > "$ROOT_DIR/.lastmerge" + +git add .lastmerge +git commit -m "Update .lastmerge to $NEW_COMMIT" + +# ── 3. Push branch ─────────────────────────────────────────── +echo "β–Έ Pushing branch $BRANCH_NAME to origin…" +git push -u origin "$BRANCH_NAME" + +echo "" +echo "βœ… Branch pushed. Next step:" +echo " Create a Pull Request (base: main, head: $BRANCH_NAME)" +echo "" +echo " Suggested title: Merge upstream SDK changes ($(date +%Y-%m-%d))" +echo " Don't forget to add the 'upstream-sync' label." diff --git a/.github/scripts/merge-upstream-start.sh b/.github/scripts/merge-upstream-start.sh new file mode 100755 index 000000000..d2918047e --- /dev/null +++ b/.github/scripts/merge-upstream-start.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────────── +# merge-upstream-start.sh +# +# Prepares the workspace for an upstream merge: +# 1. Creates a dated branch from main +# 2. Updates Copilot CLI and records the new version +# 3. Clones the upstream copilot-sdk repo into a temp dir +# 4. Reads .lastmerge and prints a short summary of new commits +# +# Usage: ./.github/scripts/merge-upstream-start.sh +# Output: Exports UPSTREAM_DIR and LAST_MERGE_COMMIT to a +# .merge-env file so other scripts can source it. +# ────────────────────────────────────────────────────────────── +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT_DIR" + +UPSTREAM_REPO="https://github.com/github/copilot-sdk.git" +ENV_FILE="$ROOT_DIR/.merge-env" + +# ── 1. Create branch ────────────────────────────────────────── +echo "β–Έ Ensuring we are on main and up to date…" +git checkout main +git pull origin main + +BRANCH_NAME="merge-upstream-$(date +%Y%m%d)" +echo "β–Έ Creating branch: $BRANCH_NAME" +git checkout -b "$BRANCH_NAME" + +# ── 2. Update Copilot CLI ──────────────────────────────────── +echo "β–Έ Updating Copilot CLI…" +if command -v copilot &>/dev/null; then + copilot update || echo " (copilot update returned non-zero – check manually)" + CLI_VERSION=$(copilot --version | head -n 1 | awk '{print $NF}') + echo " Copilot CLI version: $CLI_VERSION" +else + echo " ⚠ 'copilot' command not found – skipping CLI update." + CLI_VERSION="UNKNOWN" +fi + +# ── 3. Clone upstream ──────────────────────────────────────── +TEMP_DIR=$(mktemp -d) +UPSTREAM_DIR="$TEMP_DIR/copilot-sdk" +echo "β–Έ Cloning upstream into $UPSTREAM_DIR …" +git clone --depth=200 "$UPSTREAM_REPO" "$UPSTREAM_DIR" + +# ── 4. Read last merge commit ──────────────────────────────── +if [[ ! -f "$ROOT_DIR/.lastmerge" ]]; then + echo "❌ .lastmerge file not found in repo root." + exit 1 +fi +LAST_MERGE_COMMIT=$(tr -d '[:space:]' < "$ROOT_DIR/.lastmerge") +echo "β–Έ Last merged upstream commit: $LAST_MERGE_COMMIT" + +# Quick summary +echo "" +echo "── Upstream commits since last merge ──" +(cd "$UPSTREAM_DIR" && git fetch origin main && \ + git log --oneline "$LAST_MERGE_COMMIT"..origin/main) || \ + echo " (could not generate log – the commit may have been rebased)" +echo "" + +# ── 5. Write env file for other scripts ────────────────────── +cat > "$ENV_FILE" < Date: Sat, 7 Feb 2026 10:28:55 -0800 Subject: [PATCH 063/109] Update GitHub Actions workflow to ignore additional paths for push and pull_request events; add Star History section to README --- .github/workflows/build-test.yml | 20 ++++++++++++++++++++ README.md | 6 ++++++ 2 files changed, 26 insertions(+) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index f4ad4280a..a67359bde 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -6,7 +6,27 @@ on: - cron: '0 0 * * *' push: branches: [main] + paths-ignore: + - 'README.md' + - 'LICENSE' + - '.github/prompts/**' + - '.github/skills/**' + - '.github/scripts/**' + - '.github/templates/**' + - '.github/dependabot.yml' + - '.github/release.yml' + - '.github/workflows/publish-maven.yml' pull_request: + paths-ignore: + - 'README.md' + - 'LICENSE' + - '.github/prompts/**' + - '.github/skills/**' + - '.github/scripts/**' + - '.github/templates/**' + - '.github/dependabot.yml' + - '.github/release.yml' + - '.github/workflows/publish-maven.yml' workflow_dispatch: merge_group: diff --git a/README.md b/README.md index 3fa0bd5fe..056a73eb7 100644 --- a/README.md +++ b/README.md @@ -123,3 +123,9 @@ The tests require the official [copilot-sdk](https://github.com/github/copilot-s ## License MIT β€” see [LICENSE](LICENSE) for details. + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=copilot-community-sdk/copilot-sdk-java&type=Date)](https://www.star-history.com/#copilot-community-sdk/copilot-sdk-java&Date) + +⭐ Drop a star if you find this useful! \ No newline at end of file From 95eaa296890719286c7633032bedaa156ef5c8e5 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 11:36:08 -0800 Subject: [PATCH 064/109] Add weekly upstream sync workflow and update README with automation details --- .github/workflows/build-test.yml | 2 + .github/workflows/weekly-upstream-sync.yml | 145 +++++++++++++++++++++ README.md | 10 ++ 3 files changed, 157 insertions(+) create mode 100644 .github/workflows/weekly-upstream-sync.yml diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index a67359bde..0f3792612 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -16,6 +16,7 @@ on: - '.github/dependabot.yml' - '.github/release.yml' - '.github/workflows/publish-maven.yml' + - '.github/workflows/weekly-upstream-sync.yml' pull_request: paths-ignore: - 'README.md' @@ -27,6 +28,7 @@ on: - '.github/dependabot.yml' - '.github/release.yml' - '.github/workflows/publish-maven.yml' + - '.github/workflows/weekly-upstream-sync.yml' workflow_dispatch: merge_group: diff --git a/.github/workflows/weekly-upstream-sync.yml b/.github/workflows/weekly-upstream-sync.yml new file mode 100644 index 000000000..9d3a7ec9a --- /dev/null +++ b/.github/workflows/weekly-upstream-sync.yml @@ -0,0 +1,145 @@ +name: "Weekly Upstream Sync" + +on: + schedule: + # Every Monday at 10:00 UTC (5:00 AM EST / 6:00 AM EDT) + - cron: '0 10 * * 1' + workflow_dispatch: + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + + check-and-sync: + name: "Check upstream & trigger Copilot merge" + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Check for upstream changes + id: check + run: | + LAST_MERGE=$(cat .lastmerge) + echo "Last merged commit: $LAST_MERGE" + + git clone --quiet https://github.com/github/copilot-sdk.git /tmp/upstream + cd /tmp/upstream + + UPSTREAM_HEAD=$(git rev-parse HEAD) + echo "Upstream HEAD: $UPSTREAM_HEAD" + + if [ "$LAST_MERGE" = "$UPSTREAM_HEAD" ]; then + echo "No new upstream changes since last merge." + echo "has_changes=false" >> "$GITHUB_OUTPUT" + else + COMMIT_COUNT=$(git rev-list --count "$LAST_MERGE".."$UPSTREAM_HEAD") + echo "Found $COMMIT_COUNT new upstream commits." + echo "has_changes=true" >> "$GITHUB_OUTPUT" + echo "commit_count=$COMMIT_COUNT" >> "$GITHUB_OUTPUT" + echo "upstream_head=$UPSTREAM_HEAD" >> "$GITHUB_OUTPUT" + echo "last_merge=$LAST_MERGE" >> "$GITHUB_OUTPUT" + + # Generate a short summary of changes + SUMMARY=$(git log --oneline "$LAST_MERGE".."$UPSTREAM_HEAD" | head -20) + { + echo "summary<> "$GITHUB_OUTPUT" + fi + + - name: Close previous upstream-sync issues + if: steps.check.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Find all open issues with the upstream-sync label + OPEN_ISSUES=$(gh issue list \ + --repo "${{ github.repository }}" \ + --label "upstream-sync" \ + --state open \ + --json number \ + --jq '.[].number') + + for ISSUE_NUM in $OPEN_ISSUES; do + echo "Closing superseded issue #${ISSUE_NUM}" + gh issue comment "$ISSUE_NUM" \ + --repo "${{ github.repository }}" \ + --body "Superseded by a newer upstream sync issue. Closing this one." + gh issue close "$ISSUE_NUM" \ + --repo "${{ github.repository }}" \ + --reason "not planned" + done + + - name: Create issue and assign to Copilot + if: steps.check.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + COMMIT_COUNT="${{ steps.check.outputs.commit_count }}" + LAST_MERGE="${{ steps.check.outputs.last_merge }}" + UPSTREAM_HEAD="${{ steps.check.outputs.upstream_head }}" + SUMMARY="${{ steps.check.outputs.summary }}" + DATE=$(date -u +"%Y-%m-%d") + + BODY=$(cat <<'ISSUE_BODY' + ## Automated Upstream Sync + + There are **COMMIT_COUNT_PLACEHOLDER** new commits in the [official Copilot SDK](https://github.com/github/copilot-sdk) since the last merge. + + - **Last merged commit:** [`LAST_MERGE_PLACEHOLDER`](https://github.com/github/copilot-sdk/commit/LAST_MERGE_PLACEHOLDER) + - **Upstream HEAD:** [`UPSTREAM_HEAD_PLACEHOLDER`](https://github.com/github/copilot-sdk/commit/UPSTREAM_HEAD_PLACEHOLDER) + + ### Recent upstream commits + + ``` + SUMMARY_PLACEHOLDER + ``` + + ### Instructions + + Follow the [agentic-merge-upstream](.github/prompts/agentic-merge-upstream.prompt.md) prompt to port these changes to the Java SDK. + + **Key steps:** + 1. Run `.github/scripts/merge-upstream-start.sh` to initialize + 2. Run `.github/scripts/merge-upstream-diff.sh --full` to analyze changes + 3. Port relevant changes from the .NET SDK to Java, following existing patterns + 4. Run `.github/scripts/format-and-test.sh` to validate + 5. Run `.github/scripts/merge-upstream-finish.sh` to finalize + + Update documentation (README.md, CHANGELOG.md, src/site/markdown/) for any user-facing changes. + ISSUE_BODY + ) + + # Replace placeholders + BODY="${BODY//COMMIT_COUNT_PLACEHOLDER/$COMMIT_COUNT}" + BODY="${BODY//LAST_MERGE_PLACEHOLDER/$LAST_MERGE}" + BODY="${BODY//UPSTREAM_HEAD_PLACEHOLDER/$UPSTREAM_HEAD}" + BODY="${BODY//SUMMARY_PLACEHOLDER/$SUMMARY}" + + # Create issue and assign to Copilot coding agent + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + /repos/${{ github.repository }}/issues \ + --input - < Date: Sat, 7 Feb 2026 11:50:06 -0800 Subject: [PATCH 065/109] Add summary step to weekly upstream sync workflow for better visibility of changes --- .github/workflows/weekly-upstream-sync.yml | 36 ++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/.github/workflows/weekly-upstream-sync.yml b/.github/workflows/weekly-upstream-sync.yml index 9d3a7ec9a..3e1030cad 100644 --- a/.github/workflows/weekly-upstream-sync.yml +++ b/.github/workflows/weekly-upstream-sync.yml @@ -143,3 +143,39 @@ jobs: EOF echo "βœ… Issue created and assigned to Copilot coding agent." + + - name: Summary + if: always() + run: | + HAS_CHANGES="${{ steps.check.outputs.has_changes }}" + COMMIT_COUNT="${{ steps.check.outputs.commit_count }}" + LAST_MERGE="${{ steps.check.outputs.last_merge }}" + UPSTREAM_HEAD="${{ steps.check.outputs.upstream_head }}" + + { + echo "## Weekly Upstream Sync" + echo "" + if [ "$HAS_CHANGES" = "true" ]; then + echo "### βœ… New upstream changes detected" + echo "" + echo "| | |" + echo "|---|---|" + echo "| **New commits** | ${COMMIT_COUNT} |" + echo "| **Last merged** | \`${LAST_MERGE:0:12}\` |" + echo "| **Upstream HEAD** | \`${UPSTREAM_HEAD:0:12}\` |" + echo "" + echo "An issue has been created and assigned to the Copilot coding agent." + echo "" + echo "### Recent upstream commits" + echo "" + echo '```' + echo "${{ steps.check.outputs.summary }}" + echo '```' + else + echo "### ⏭️ No changes" + echo "" + echo "The Java SDK is already up to date with the upstream Copilot SDK." + echo "" + echo "**Last merged commit:** \`${LAST_MERGE:0:12}\`" + fi + } >> "$GITHUB_STEP_SUMMARY" From cb97024ff26fe87ff0594d7c4798546281a7b42a Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 12:18:23 -0800 Subject: [PATCH 066/109] Update custom instructions in upstream sync workflow to clarify handling of irrelevant changes --- .../prompts/agentic-merge-upstream.prompt.md | 20 ++++++++++++------- .../coding-agent-merge-instructions.md | 19 ++++++++++++++++++ .github/workflows/weekly-upstream-sync.yml | 5 ++++- 3 files changed, 36 insertions(+), 8 deletions(-) create mode 100644 .github/prompts/coding-agent-merge-instructions.md diff --git a/.github/prompts/agentic-merge-upstream.prompt.md b/.github/prompts/agentic-merge-upstream.prompt.md index 470e1ba72..4de004eae 100644 --- a/.github/prompts/agentic-merge-upstream.prompt.md +++ b/.github/prompts/agentic-merge-upstream.prompt.md @@ -35,7 +35,7 @@ All scripts write/read a `.merge-env` file (git-ignored) to share state (branch 5. Run `./.github/scripts/format-and-test.sh` frequently while porting 6. Update documentation 7. Run `./.github/scripts/merge-upstream-finish.sh` (final test + push) -8. Create Pull Request +8. Finalize Pull Request (see note below about coding agent vs. manual workflow) --- @@ -316,7 +316,7 @@ Ensure consistency across all documentation files: - Code examples should use the same patterns and be tested - Links to Javadoc should use correct paths (`apidocs/...`) -## Steps 11-12: Finish, Push, and Create Pull Request +## Steps 11-12: Finish, Push, and Finalize Pull Request Run the finish script which updates `.lastmerge`, runs a final build, and pushes the branch: @@ -325,11 +325,17 @@ Run the finish script which updates `.lastmerge`, runs a final build, and pushes ./.github/scripts/merge-upstream-finish.sh --skip-tests # if tests already passed ``` -**After pushing, create the Pull Request using the GitHub MCP tool (`mcp_github_create_pull_request`).** +### PR Handling: Coding Agent vs. Manual Workflow -Use `owner: copilot-community-sdk`, `repo: copilot-sdk-java`, `head: $BRANCH_NAME`, `base: main`. +**If running as a Copilot coding agent** (triggered via GitHub issue assignment by the weekly sync workflow), a pull request has **already been created automatically** for you. Do NOT create a new one. Just push your commits to the current branch β€” the existing PR will be updated. Add the `upstream-sync` label to the existing PR: -**After creating the PR, add the `upstream-sync` label** using the `gh` CLI: +```bash +gh pr edit --add-label "upstream-sync" +``` + +> **No-changes scenario (coding agent only):** If after analyzing the upstream diff there are no relevant changes to port to the Java SDK, close the auto-created pull request, then close the triggering issue as "not planned" with a comment explaining that no changes were applicable. + +**If running manually** (e.g., from VS Code via the reusable prompt), create the Pull Request using the GitHub MCP tool (`mcp_github_create_pull_request`). Use `owner: copilot-community-sdk`, `repo: copilot-sdk-java`, `head: $BRANCH_NAME`, `base: main`. Then add the label: ```bash gh pr edit --add-label "upstream-sync" @@ -380,7 +386,7 @@ Before finishing: 3. Ensure no unintended changes were made 4. Verify code follows project conventions 5. Confirm the branch was pushed to remote -6. Confirm the Pull Request was created and provide the PR URL to the user +6. Confirm the Pull Request is ready (created or updated) and provide the PR URL to the user --- @@ -407,7 +413,7 @@ Before finishing: - [ ] `src/site/site.xml` updated if new documentation pages were added - [ ] `.lastmerge` file updated with new commit hash - [ ] Branch pushed to remote -- [ ] **Pull Request created** via GitHub MCP tool (`mcp_github_create_pull_request`) +- [ ] **Pull Request finalized** (coding agent: push to existing PR; manual: create via `mcp_github_create_pull_request`) - [ ] **`upstream-sync` label added** to the PR via `gh pr edit --add-label "upstream-sync"` - [ ] PR URL provided to user diff --git a/.github/prompts/coding-agent-merge-instructions.md b/.github/prompts/coding-agent-merge-instructions.md new file mode 100644 index 000000000..409d5d98d --- /dev/null +++ b/.github/prompts/coding-agent-merge-instructions.md @@ -0,0 +1,19 @@ + + + +Follow the agentic-merge-upstream prompt at .github/prompts/agentic-merge-upstream.prompt.md +to port upstream changes to the Java SDK. + +Use the utility scripts in .github/scripts/ for initialization, diffing, formatting, and testing. +Commit changes incrementally. Update .lastmerge when done. + +IMPORTANT: A pull request has already been created automatically for you β€” do NOT create a new +one. Push your commits to the current branch, and the existing PR will be updated. + +Add the 'upstream-sync' label to the existing PR using: + + gh pr edit --add-label upstream-sync + +If after analyzing the upstream diff there are no relevant changes to port to the Java SDK, +close the pull request that was automatically created for you, then close this issue as +'not planned' with a comment explaining that no changes were applicable. diff --git a/.github/workflows/weekly-upstream-sync.yml b/.github/workflows/weekly-upstream-sync.yml index 3e1030cad..6603fdd2c 100644 --- a/.github/workflows/weekly-upstream-sync.yml +++ b/.github/workflows/weekly-upstream-sync.yml @@ -122,6 +122,9 @@ jobs: BODY="${BODY//UPSTREAM_HEAD_PLACEHOLDER/$UPSTREAM_HEAD}" BODY="${BODY//SUMMARY_PLACEHOLDER/$SUMMARY}" + # Read custom instructions from external file + CUSTOM_INSTRUCTIONS=$(cat .github/prompts/coding-agent-merge-instructions.md) + # Create issue and assign to Copilot coding agent gh api \ --method POST \ @@ -137,7 +140,7 @@ jobs: "agent_assignment": { "target_repo": "${{ github.repository }}", "base_branch": "main", - "custom_instructions": "Follow the agentic-merge-upstream prompt at .github/prompts/agentic-merge-upstream.prompt.md to port upstream changes to the Java SDK. Use the utility scripts in .github/scripts/ for initialization, diffing, formatting, and testing. Commit changes incrementally. Update .lastmerge when done." + "custom_instructions": $(echo "$CUSTOM_INSTRUCTIONS" | jq -Rs .) } } EOF From bd64c53557dbc754f23d29d4cc3b59b4a8dd83bb Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 12:28:14 -0800 Subject: [PATCH 067/109] Update merge instructions and setup workflow to utilize GitHub MCP tool for labeling PRs --- .github/prompts/agentic-merge-upstream.prompt.md | 12 ++++++------ .github/prompts/coding-agent-merge-instructions.md | 4 ++-- .github/workflows/copilot-setup-steps.yml | 3 +++ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/prompts/agentic-merge-upstream.prompt.md b/.github/prompts/agentic-merge-upstream.prompt.md index 4de004eae..d3ef025ef 100644 --- a/.github/prompts/agentic-merge-upstream.prompt.md +++ b/.github/prompts/agentic-merge-upstream.prompt.md @@ -327,18 +327,18 @@ Run the finish script which updates `.lastmerge`, runs a final build, and pushes ### PR Handling: Coding Agent vs. Manual Workflow -**If running as a Copilot coding agent** (triggered via GitHub issue assignment by the weekly sync workflow), a pull request has **already been created automatically** for you. Do NOT create a new one. Just push your commits to the current branch β€” the existing PR will be updated. Add the `upstream-sync` label to the existing PR: +**If running as a Copilot coding agent** (triggered via GitHub issue assignment by the weekly sync workflow), a pull request has **already been created automatically** for you. Do NOT create a new one. Just push your commits to the current branch β€” the existing PR will be updated. Add the `upstream-sync` label to the existing PR using the GitHub MCP tool: -```bash -gh pr edit --add-label "upstream-sync" +``` +mcp_github_add_issue_labels(owner: "copilot-community-sdk", repo: "copilot-sdk-java", issue_number: , labels: ["upstream-sync"]) ``` > **No-changes scenario (coding agent only):** If after analyzing the upstream diff there are no relevant changes to port to the Java SDK, close the auto-created pull request, then close the triggering issue as "not planned" with a comment explaining that no changes were applicable. **If running manually** (e.g., from VS Code via the reusable prompt), create the Pull Request using the GitHub MCP tool (`mcp_github_create_pull_request`). Use `owner: copilot-community-sdk`, `repo: copilot-sdk-java`, `head: $BRANCH_NAME`, `base: main`. Then add the label: -```bash -gh pr edit --add-label "upstream-sync" +``` +mcp_github_add_issue_labels(owner: "copilot-community-sdk", repo: "copilot-sdk-java", issue_number: , labels: ["upstream-sync"]) ``` The PR body should include: @@ -414,7 +414,7 @@ Before finishing: - [ ] `.lastmerge` file updated with new commit hash - [ ] Branch pushed to remote - [ ] **Pull Request finalized** (coding agent: push to existing PR; manual: create via `mcp_github_create_pull_request`) -- [ ] **`upstream-sync` label added** to the PR via `gh pr edit --add-label "upstream-sync"` +- [ ] **`upstream-sync` label added** to the PR via `mcp_github_add_issue_labels` - [ ] PR URL provided to user --- diff --git a/.github/prompts/coding-agent-merge-instructions.md b/.github/prompts/coding-agent-merge-instructions.md index 409d5d98d..f828b4fda 100644 --- a/.github/prompts/coding-agent-merge-instructions.md +++ b/.github/prompts/coding-agent-merge-instructions.md @@ -10,9 +10,9 @@ Commit changes incrementally. Update .lastmerge when done. IMPORTANT: A pull request has already been created automatically for you β€” do NOT create a new one. Push your commits to the current branch, and the existing PR will be updated. -Add the 'upstream-sync' label to the existing PR using: +Add the 'upstream-sync' label to the existing PR using the GitHub MCP tool: - gh pr edit --add-label upstream-sync + mcp_github_add_issue_labels(owner: "copilot-community-sdk", repo: "copilot-sdk-java", issue_number: , labels: ["upstream-sync"]) If after analyzing the upstream diff there are no relevant changes to port to the Java SDK, close the pull request that was automatically created for you, then close this issue as diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 9a2109eee..579c033ee 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -32,3 +32,6 @@ jobs: - name: Download dependencies run: mvn dependency:go-offline -B + + - name: Install Copilot CLI + id: setup-copilot From b38b5b7b2b662346893856b90a3cc42f5b693b6d Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 12:30:27 -0800 Subject: [PATCH 068/109] Remove Copilot CLI installation step from setup workflow --- .github/workflows/copilot-setup-steps.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 579c033ee..9a2109eee 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -32,6 +32,3 @@ jobs: - name: Download dependencies run: mvn dependency:go-offline -B - - - name: Install Copilot CLI - id: setup-copilot From c51d499369d0d3be11923ee00ff7873bb1bcf7f2 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 12:36:05 -0800 Subject: [PATCH 069/109] Refactor merge branch creation logic to reuse existing branches and ensure main is up to date --- .github/scripts/merge-upstream-start.sh | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/scripts/merge-upstream-start.sh b/.github/scripts/merge-upstream-start.sh index d2918047e..f7b9da16f 100755 --- a/.github/scripts/merge-upstream-start.sh +++ b/.github/scripts/merge-upstream-start.sh @@ -20,14 +20,23 @@ cd "$ROOT_DIR" UPSTREAM_REPO="https://github.com/github/copilot-sdk.git" ENV_FILE="$ROOT_DIR/.merge-env" -# ── 1. Create branch ────────────────────────────────────────── -echo "β–Έ Ensuring we are on main and up to date…" -git checkout main -git pull origin main +# ── 1. Create branch (or reuse existing) ───────────────────── +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) -BRANCH_NAME="merge-upstream-$(date +%Y%m%d)" -echo "β–Έ Creating branch: $BRANCH_NAME" -git checkout -b "$BRANCH_NAME" +if [[ "$CURRENT_BRANCH" != "main" ]]; then + # Already on a non-main branch (e.g., coding agent's auto-created PR branch). + # Stay on this branch β€” do not create a new one. + BRANCH_NAME="$CURRENT_BRANCH" + echo "β–Έ Already on branch '$BRANCH_NAME' β€” reusing it (coding agent mode)." + git pull origin main --no-edit 2>/dev/null || echo " (pull from main skipped or fast-forward not possible)" +else + echo "β–Έ Ensuring main is up to date…" + git pull origin main + + BRANCH_NAME="merge-upstream-$(date +%Y%m%d)" + echo "β–Έ Creating branch: $BRANCH_NAME" + git checkout -b "$BRANCH_NAME" +fi # ── 2. Update Copilot CLI ──────────────────────────────────── echo "β–Έ Updating Copilot CLI…" From a61753808935bc0f053665a284ccab2507dfdd6c Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 12:41:44 -0800 Subject: [PATCH 070/109] Update permissions in copilot setup workflow to allow issue and pull request management --- .github/workflows/copilot-setup-steps.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 9a2109eee..964672d48 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -15,6 +15,8 @@ jobs: permissions: contents: read + issues: write + pull-requests: write steps: - name: Checkout code From d65ffbf431d421e8030cc476fe17abda5c9bef64 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 12:54:54 -0800 Subject: [PATCH 071/109] Add claude-opus-4.6 model to agent_assignment in weekly sync workflow --- .github/workflows/weekly-upstream-sync.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/weekly-upstream-sync.yml b/.github/workflows/weekly-upstream-sync.yml index 6603fdd2c..874dd16a8 100644 --- a/.github/workflows/weekly-upstream-sync.yml +++ b/.github/workflows/weekly-upstream-sync.yml @@ -140,7 +140,8 @@ jobs: "agent_assignment": { "target_repo": "${{ github.repository }}", "base_branch": "main", - "custom_instructions": $(echo "$CUSTOM_INSTRUCTIONS" | jq -Rs .) + "custom_instructions": $(echo "$CUSTOM_INSTRUCTIONS" | jq -Rs .), + "model": "claude-opus-4.6" } } EOF From 2c0cd0f95c8f4f8da0edef1752d9e0d240952f7a Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 13:00:13 -0800 Subject: [PATCH 072/109] Add explicit MCP tool calls for no-changes cleanup scenario The coding agent was not closing the PR and issue when no upstream changes were found, even though instructions said to do so. Updated both the custom instructions and the main prompt to include exact mcp_github_update_pull_request, mcp_github_add_issue_comment, and mcp_github_update_issue tool call examples with all parameters. --- .../prompts/agentic-merge-upstream.prompt.md | 8 +++++++- .../prompts/coding-agent-merge-instructions.md | 17 +++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/prompts/agentic-merge-upstream.prompt.md b/.github/prompts/agentic-merge-upstream.prompt.md index d3ef025ef..439c83e11 100644 --- a/.github/prompts/agentic-merge-upstream.prompt.md +++ b/.github/prompts/agentic-merge-upstream.prompt.md @@ -333,7 +333,13 @@ Run the finish script which updates `.lastmerge`, runs a final build, and pushes mcp_github_add_issue_labels(owner: "copilot-community-sdk", repo: "copilot-sdk-java", issue_number: , labels: ["upstream-sync"]) ``` -> **No-changes scenario (coding agent only):** If after analyzing the upstream diff there are no relevant changes to port to the Java SDK, close the auto-created pull request, then close the triggering issue as "not planned" with a comment explaining that no changes were applicable. +> **No-changes scenario (coding agent only):** If after analyzing the upstream diff there are no relevant changes to port to the Java SDK, you **MUST** close the auto-created pull request and the triggering issue using these exact MCP tool calls: +> +> 1. Close the PR: `mcp_github_update_pull_request(owner: "copilot-community-sdk", repo: "copilot-sdk-java", pullNumber: , state: "closed")` +> 2. Comment on the issue: `mcp_github_add_issue_comment(owner: "copilot-community-sdk", repo: "copilot-sdk-java", issue_number: , body: "No relevant upstream changes to port. Closing as not planned.")` +> 3. Close the issue: `mcp_github_update_issue(owner: "copilot-community-sdk", repo: "copilot-sdk-java", issue_number: , state: "closed", state_reason: "not_planned")` +> +> **Do NOT skip these steps.** All three tool calls are required when there are no changes to port. **If running manually** (e.g., from VS Code via the reusable prompt), create the Pull Request using the GitHub MCP tool (`mcp_github_create_pull_request`). Use `owner: copilot-community-sdk`, `repo: copilot-sdk-java`, `head: $BRANCH_NAME`, `base: main`. Then add the label: diff --git a/.github/prompts/coding-agent-merge-instructions.md b/.github/prompts/coding-agent-merge-instructions.md index f828b4fda..fc9502aaf 100644 --- a/.github/prompts/coding-agent-merge-instructions.md +++ b/.github/prompts/coding-agent-merge-instructions.md @@ -15,5 +15,18 @@ Add the 'upstream-sync' label to the existing PR using the GitHub MCP tool: mcp_github_add_issue_labels(owner: "copilot-community-sdk", repo: "copilot-sdk-java", issue_number: , labels: ["upstream-sync"]) If after analyzing the upstream diff there are no relevant changes to port to the Java SDK, -close the pull request that was automatically created for you, then close this issue as -'not planned' with a comment explaining that no changes were applicable. +you MUST close the pull request and the issue. Use these exact GitHub MCP tool calls: + +1. Close the auto-created pull request: + + mcp_github_update_pull_request(owner: "copilot-community-sdk", repo: "copilot-sdk-java", pullNumber: , state: "closed") + +2. Add a comment to the issue explaining no changes were needed: + + mcp_github_add_issue_comment(owner: "copilot-community-sdk", repo: "copilot-sdk-java", issue_number: , body: "No relevant upstream changes to port to the Java SDK. Closing as not planned.") + +3. Close the issue as 'not planned': + + mcp_github_update_issue(owner: "copilot-community-sdk", repo: "copilot-sdk-java", issue_number: , state: "closed", state_reason: "not_planned") + +Do NOT skip these steps. All three tool calls are required. From 55cf37aa9969ba77a2c16ae90a1887985ed9cb82 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 14:19:20 -0800 Subject: [PATCH 073/109] Replace MCP tool calls with gh CLI commands for coding agent The coding agent doesn't have mcp_github_* prefixed tools available. Switch to gh CLI commands run in the terminal, which are universally available on GitHub-hosted runners. Also add gh auth status check in copilot-setup-steps. --- .../prompts/agentic-merge-upstream.prompt.md | 22 +++++++++---------- .../coding-agent-merge-instructions.md | 19 +++++++--------- .github/workflows/copilot-setup-steps.yml | 5 +++++ 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/.github/prompts/agentic-merge-upstream.prompt.md b/.github/prompts/agentic-merge-upstream.prompt.md index 439c83e11..ea0473a54 100644 --- a/.github/prompts/agentic-merge-upstream.prompt.md +++ b/.github/prompts/agentic-merge-upstream.prompt.md @@ -327,24 +327,24 @@ Run the finish script which updates `.lastmerge`, runs a final build, and pushes ### PR Handling: Coding Agent vs. Manual Workflow -**If running as a Copilot coding agent** (triggered via GitHub issue assignment by the weekly sync workflow), a pull request has **already been created automatically** for you. Do NOT create a new one. Just push your commits to the current branch β€” the existing PR will be updated. Add the `upstream-sync` label to the existing PR using the GitHub MCP tool: +**If running as a Copilot coding agent** (triggered via GitHub issue assignment by the weekly sync workflow), a pull request has **already been created automatically** for you. Do NOT create a new one. Just push your commits to the current branch β€” the existing PR will be updated. Add the `upstream-sync` label to the existing PR by running this command in a terminal: -``` -mcp_github_add_issue_labels(owner: "copilot-community-sdk", repo: "copilot-sdk-java", issue_number: , labels: ["upstream-sync"]) +```bash +gh pr edit --add-label "upstream-sync" ``` -> **No-changes scenario (coding agent only):** If after analyzing the upstream diff there are no relevant changes to port to the Java SDK, you **MUST** close the auto-created pull request and the triggering issue using these exact MCP tool calls: +> **No-changes scenario (coding agent only):** If after analyzing the upstream diff there are no relevant changes to port to the Java SDK, you **MUST** close the auto-created pull request and the triggering issue by running these commands in a terminal: > -> 1. Close the PR: `mcp_github_update_pull_request(owner: "copilot-community-sdk", repo: "copilot-sdk-java", pullNumber: , state: "closed")` -> 2. Comment on the issue: `mcp_github_add_issue_comment(owner: "copilot-community-sdk", repo: "copilot-sdk-java", issue_number: , body: "No relevant upstream changes to port. Closing as not planned.")` -> 3. Close the issue: `mcp_github_update_issue(owner: "copilot-community-sdk", repo: "copilot-sdk-java", issue_number: , state: "closed", state_reason: "not_planned")` +> 1. Close the PR: `gh pr close $(gh pr view --json number --jq .number) --comment "No relevant upstream changes to port to the Java SDK."` +> 2. Close the issue: `gh issue close ISSUE_NUMBER --reason "not planned" --comment "No relevant upstream changes to port to the Java SDK. Closing as not planned."` (replace ISSUE_NUMBER with the actual issue number) > -> **Do NOT skip these steps.** All three tool calls are required when there are no changes to port. +> **CRITICAL: You MUST run these `gh` CLI commands in a terminal. Do NOT skip these steps.** Both commands are required when there are no changes to port. -**If running manually** (e.g., from VS Code via the reusable prompt), create the Pull Request using the GitHub MCP tool (`mcp_github_create_pull_request`). Use `owner: copilot-community-sdk`, `repo: copilot-sdk-java`, `head: $BRANCH_NAME`, `base: main`. Then add the label: +**If running manually** (e.g., from VS Code via the reusable prompt), create the Pull Request using `gh` CLI or the GitHub MCP tool. Then add the label: -``` -mcp_github_add_issue_labels(owner: "copilot-community-sdk", repo: "copilot-sdk-java", issue_number: , labels: ["upstream-sync"]) +```bash +gh pr create --base main --title "Merge upstream SDK changes (YYYY-MM-DD)" --body-file /dev/stdin <<< "$PR_BODY" +gh pr edit --add-label "upstream-sync" ``` The PR body should include: diff --git a/.github/prompts/coding-agent-merge-instructions.md b/.github/prompts/coding-agent-merge-instructions.md index fc9502aaf..38c915a2d 100644 --- a/.github/prompts/coding-agent-merge-instructions.md +++ b/.github/prompts/coding-agent-merge-instructions.md @@ -10,23 +10,20 @@ Commit changes incrementally. Update .lastmerge when done. IMPORTANT: A pull request has already been created automatically for you β€” do NOT create a new one. Push your commits to the current branch, and the existing PR will be updated. -Add the 'upstream-sync' label to the existing PR using the GitHub MCP tool: +Add the 'upstream-sync' label to the existing PR by running this command in a terminal: - mcp_github_add_issue_labels(owner: "copilot-community-sdk", repo: "copilot-sdk-java", issue_number: , labels: ["upstream-sync"]) + gh pr edit --add-label "upstream-sync" If after analyzing the upstream diff there are no relevant changes to port to the Java SDK, -you MUST close the pull request and the issue. Use these exact GitHub MCP tool calls: +you MUST close the pull request and the issue. Run the following commands in a terminal: 1. Close the auto-created pull request: - mcp_github_update_pull_request(owner: "copilot-community-sdk", repo: "copilot-sdk-java", pullNumber: , state: "closed") + gh pr close $(gh pr view --json number --jq .number) --comment "No relevant upstream changes to port to the Java SDK." -2. Add a comment to the issue explaining no changes were needed: +2. Close the triggering issue as 'not planned' (replace ISSUE_NUMBER with the actual issue number from the issue that was assigned to you): - mcp_github_add_issue_comment(owner: "copilot-community-sdk", repo: "copilot-sdk-java", issue_number: , body: "No relevant upstream changes to port to the Java SDK. Closing as not planned.") + gh issue close ISSUE_NUMBER --reason "not planned" --comment "No relevant upstream changes to port to the Java SDK. Closing as not planned." -3. Close the issue as 'not planned': - - mcp_github_update_issue(owner: "copilot-community-sdk", repo: "copilot-sdk-java", issue_number: , state: "closed", state_reason: "not_planned") - -Do NOT skip these steps. All three tool calls are required. +CRITICAL: You MUST run these gh CLI commands in a terminal. Do NOT skip these steps. +Both the PR close and issue close commands are required when there are no changes to port. diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 964672d48..7a56566f3 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -34,3 +34,8 @@ jobs: - name: Download dependencies run: mvn dependency:go-offline -B + + - name: Verify gh CLI is authenticated + run: gh auth status + env: + GH_TOKEN: ${{ github.token }} From 39c37fb9c48349fcaa953bbe2672a631b1eb150b Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 14:20:53 -0800 Subject: [PATCH 074/109] Remove outdated instructions for upstream merge process in weekly sync workflow --- .github/workflows/weekly-upstream-sync.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/weekly-upstream-sync.yml b/.github/workflows/weekly-upstream-sync.yml index 874dd16a8..80643868c 100644 --- a/.github/workflows/weekly-upstream-sync.yml +++ b/.github/workflows/weekly-upstream-sync.yml @@ -105,14 +105,6 @@ jobs: Follow the [agentic-merge-upstream](.github/prompts/agentic-merge-upstream.prompt.md) prompt to port these changes to the Java SDK. - **Key steps:** - 1. Run `.github/scripts/merge-upstream-start.sh` to initialize - 2. Run `.github/scripts/merge-upstream-diff.sh --full` to analyze changes - 3. Port relevant changes from the .NET SDK to Java, following existing patterns - 4. Run `.github/scripts/format-and-test.sh` to validate - 5. Run `.github/scripts/merge-upstream-finish.sh` to finalize - - Update documentation (README.md, CHANGELOG.md, src/site/markdown/) for any user-facing changes. ISSUE_BODY ) From 4852b79d7e2684a67898dd6181aa2264a67d822b Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 14:22:34 -0800 Subject: [PATCH 075/109] Persist GH_TOKEN to GITHUB_ENV for coding agent terminal sessions The per-step env var only applies to that step. Writing to GITHUB_ENV makes it available to all subsequent commands the agent runs. --- .github/workflows/copilot-setup-steps.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 7a56566f3..9ed79dea8 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -35,7 +35,9 @@ jobs: - name: Download dependencies run: mvn dependency:go-offline -B - - name: Verify gh CLI is authenticated - run: gh auth status + - name: Configure gh CLI authentication + run: | + echo "GH_TOKEN=${{ github.token }}" >> "$GITHUB_ENV" + gh auth status env: GH_TOKEN: ${{ github.token }} From 618a0bbc6b596f547e52e3b5c510a34ebc913d94 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 14:31:05 -0800 Subject: [PATCH 076/109] Add Copilot setup steps workflow for JDK 17 and gh CLI authentication --- .../{copilot-setup-steps.yml => copilot-setup-steps.yml.txt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{copilot-setup-steps.yml => copilot-setup-steps.yml.txt} (100%) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml.txt similarity index 100% rename from .github/workflows/copilot-setup-steps.yml rename to .github/workflows/copilot-setup-steps.yml.txt From f102ca01e0fa026f8bf0c2850ed3ce0bff862395 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 14:37:39 -0800 Subject: [PATCH 077/109] Add Copilot setup steps workflow for JDK 17 and GitHub CLI integration --- ...-steps.yml.txt => copilot-setup-steps.yml} | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) rename .github/workflows/{copilot-setup-steps.yml.txt => copilot-setup-steps.yml} (53%) diff --git a/.github/workflows/copilot-setup-steps.yml.txt b/.github/workflows/copilot-setup-steps.yml similarity index 53% rename from .github/workflows/copilot-setup-steps.yml.txt rename to .github/workflows/copilot-setup-steps.yml index 9ed79dea8..dbf68e37d 100644 --- a/.github/workflows/copilot-setup-steps.yml.txt +++ b/.github/workflows/copilot-setup-steps.yml @@ -9,6 +9,9 @@ on: paths: - .github/workflows/copilot-setup-steps.yml +env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + jobs: copilot-setup-steps: runs-on: ubuntu-latest @@ -22,6 +25,10 @@ jobs: - name: Checkout code uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + - name: Set up JDK 17 uses: actions/setup-java@v5 with: @@ -35,9 +42,18 @@ jobs: - name: Download dependencies run: mvn dependency:go-offline -B - - name: Configure gh CLI authentication + # Install gh-aw extension for advanced GitHub CLI features + - name: Install gh-aw extension + run: | + curl -fsSL https://raw.githubusercontent.com/githubnext/gh-aw/refs/heads/main/install-gh-aw.sh | bash + + # Verify installations + - name: Verify tool installations run: | - echo "GH_TOKEN=${{ github.token }}" >> "$GITHUB_ENV" - gh auth status - env: - GH_TOKEN: ${{ github.token }} + echo "=== Verifying installations ===" + node --version + npm --version + java -version + gh --version + gh aw version + echo "βœ… All tools installed successfully" From 77db17220fef7a877ec6f268bfbba4518328424f Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 14:45:15 -0800 Subject: [PATCH 078/109] Add GITHUB_TOKEN to environment variables in copilot setup workflow --- .github/workflows/copilot-setup-steps.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index dbf68e37d..2d6fe6415 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -11,6 +11,7 @@ on: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} jobs: copilot-setup-steps: From 5feaf5642aca7b109807ef1811376eb751617f91 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 14:52:36 -0800 Subject: [PATCH 079/109] Remove environment variable definitions for GH_TOKEN and GITHUB_TOKEN in copilot setup workflow --- .github/workflows/copilot-setup-steps.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 2d6fe6415..f92d7f699 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -9,10 +9,6 @@ on: paths: - .github/workflows/copilot-setup-steps.yml -env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - jobs: copilot-setup-steps: runs-on: ubuntu-latest From 458995cdb980df83c510c31df603deb86d375df8 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 14:53:21 -0800 Subject: [PATCH 080/109] Update upload-artifact action to version 6 in build-test workflow --- .github/workflows/build-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 0f3792612..008c3367d 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -92,7 +92,7 @@ jobs: - name: Upload test results for site generation if: success() && github.ref == 'refs/heads/main' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: test-results-for-site path: | From 324ea718e4d6ff08f3ca3e5606c7a6e154dac07b Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 14:59:55 -0800 Subject: [PATCH 081/109] Add Copilot setup steps workflow for JDK 17 and GitHub CLI integration --- .../{copilot-setup-steps.yml => copilot-setup-steps.txt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{copilot-setup-steps.yml => copilot-setup-steps.txt} (100%) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.txt similarity index 100% rename from .github/workflows/copilot-setup-steps.yml rename to .github/workflows/copilot-setup-steps.txt From 173f27cd53432f26685b1d042bba731faf643696 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 15:28:55 -0800 Subject: [PATCH 082/109] Remove PR/issue closing instructions from coding agent prompts The Copilot coding agent cannot close PRs or issues. Instead, instruct it to push an empty commit explaining the no-changes outcome, and let the maintainer handle cleanup manually. --- .github/prompts/agentic-merge-upstream.prompt.md | 7 +------ .github/prompts/coding-agent-merge-instructions.md | 14 ++------------ 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/.github/prompts/agentic-merge-upstream.prompt.md b/.github/prompts/agentic-merge-upstream.prompt.md index ea0473a54..d962373c3 100644 --- a/.github/prompts/agentic-merge-upstream.prompt.md +++ b/.github/prompts/agentic-merge-upstream.prompt.md @@ -333,12 +333,7 @@ Run the finish script which updates `.lastmerge`, runs a final build, and pushes gh pr edit --add-label "upstream-sync" ``` -> **No-changes scenario (coding agent only):** If after analyzing the upstream diff there are no relevant changes to port to the Java SDK, you **MUST** close the auto-created pull request and the triggering issue by running these commands in a terminal: -> -> 1. Close the PR: `gh pr close $(gh pr view --json number --jq .number) --comment "No relevant upstream changes to port to the Java SDK."` -> 2. Close the issue: `gh issue close ISSUE_NUMBER --reason "not planned" --comment "No relevant upstream changes to port to the Java SDK. Closing as not planned."` (replace ISSUE_NUMBER with the actual issue number) -> -> **CRITICAL: You MUST run these `gh` CLI commands in a terminal. Do NOT skip these steps.** Both commands are required when there are no changes to port. +> **No-changes scenario (coding agent only):** If after analyzing the upstream diff there are no relevant changes to port to the Java SDK, push an empty commit with a message explaining why no changes were needed, so the PR reflects the analysis outcome. The repository maintainer will close the PR and issue manually. **If running manually** (e.g., from VS Code via the reusable prompt), create the Pull Request using `gh` CLI or the GitHub MCP tool. Then add the label: diff --git a/.github/prompts/coding-agent-merge-instructions.md b/.github/prompts/coding-agent-merge-instructions.md index 38c915a2d..429bb5382 100644 --- a/.github/prompts/coding-agent-merge-instructions.md +++ b/.github/prompts/coding-agent-merge-instructions.md @@ -15,15 +15,5 @@ Add the 'upstream-sync' label to the existing PR by running this command in a te gh pr edit --add-label "upstream-sync" If after analyzing the upstream diff there are no relevant changes to port to the Java SDK, -you MUST close the pull request and the issue. Run the following commands in a terminal: - -1. Close the auto-created pull request: - - gh pr close $(gh pr view --json number --jq .number) --comment "No relevant upstream changes to port to the Java SDK." - -2. Close the triggering issue as 'not planned' (replace ISSUE_NUMBER with the actual issue number from the issue that was assigned to you): - - gh issue close ISSUE_NUMBER --reason "not planned" --comment "No relevant upstream changes to port to the Java SDK. Closing as not planned." - -CRITICAL: You MUST run these gh CLI commands in a terminal. Do NOT skip these steps. -Both the PR close and issue close commands are required when there are no changes to port. +push an empty commit with a message explaining why no changes were needed, so the PR reflects +the analysis outcome. The repository maintainer will close the PR and issue manually. From c30d38174141d64026fe60ca9be1d5d63b442cfb Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 15:31:57 -0800 Subject: [PATCH 083/109] Remove assignees field from issue creation payload The GITHUB_TOKEN from workflows cannot assign copilot-swe-agent[bot]. The agent_assignment field is what actually triggers the coding agent, so the assignees field is unnecessary. --- .github/workflows/weekly-upstream-sync.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/weekly-upstream-sync.yml b/.github/workflows/weekly-upstream-sync.yml index 80643868c..aa0a99963 100644 --- a/.github/workflows/weekly-upstream-sync.yml +++ b/.github/workflows/weekly-upstream-sync.yml @@ -128,7 +128,6 @@ jobs: "title": "Upstream sync: ${COMMIT_COUNT} new commits (${DATE})", "body": $(echo "$BODY" | jq -Rs .), "labels": ["upstream-sync"], - "assignees": ["copilot-swe-agent[bot]"], "agent_assignment": { "target_repo": "${{ github.repository }}", "base_branch": "main", From 8be1349fead1aceb9b0cc00df285617cb9eca860 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 15:36:20 -0800 Subject: [PATCH 084/109] Rewrite issue creation to use jq -n for safe JSON construction Match the approach from the test script: use jq --arg to properly escape all values instead of fragile heredoc interpolation with inline jq -Rs. Restore assignees field since GITHUB_TOKEN should work for bot assignment when the JSON is properly formed. --- .github/workflows/weekly-upstream-sync.yml | 69 ++++++++++------------ 1 file changed, 32 insertions(+), 37 deletions(-) diff --git a/.github/workflows/weekly-upstream-sync.yml b/.github/workflows/weekly-upstream-sync.yml index aa0a99963..51a44370e 100644 --- a/.github/workflows/weekly-upstream-sync.yml +++ b/.github/workflows/weekly-upstream-sync.yml @@ -86,56 +86,51 @@ jobs: UPSTREAM_HEAD="${{ steps.check.outputs.upstream_head }}" SUMMARY="${{ steps.check.outputs.summary }}" DATE=$(date -u +"%Y-%m-%d") + REPO="${{ github.repository }}" - BODY=$(cat <<'ISSUE_BODY' - ## Automated Upstream Sync + BODY="## Automated Upstream Sync - There are **COMMIT_COUNT_PLACEHOLDER** new commits in the [official Copilot SDK](https://github.com/github/copilot-sdk) since the last merge. + There are **${COMMIT_COUNT}** new commits in the [official Copilot SDK](https://github.com/github/copilot-sdk) since the last merge. - - **Last merged commit:** [`LAST_MERGE_PLACEHOLDER`](https://github.com/github/copilot-sdk/commit/LAST_MERGE_PLACEHOLDER) - - **Upstream HEAD:** [`UPSTREAM_HEAD_PLACEHOLDER`](https://github.com/github/copilot-sdk/commit/UPSTREAM_HEAD_PLACEHOLDER) + - **Last merged commit:** [\`${LAST_MERGE}\`](https://github.com/github/copilot-sdk/commit/${LAST_MERGE}) + - **Upstream HEAD:** [\`${UPSTREAM_HEAD}\`](https://github.com/github/copilot-sdk/commit/${UPSTREAM_HEAD}) ### Recent upstream commits - ``` - SUMMARY_PLACEHOLDER - ``` + \`\`\` + ${SUMMARY} + \`\`\` ### Instructions - Follow the [agentic-merge-upstream](.github/prompts/agentic-merge-upstream.prompt.md) prompt to port these changes to the Java SDK. - - ISSUE_BODY - ) - - # Replace placeholders - BODY="${BODY//COMMIT_COUNT_PLACEHOLDER/$COMMIT_COUNT}" - BODY="${BODY//LAST_MERGE_PLACEHOLDER/$LAST_MERGE}" - BODY="${BODY//UPSTREAM_HEAD_PLACEHOLDER/$UPSTREAM_HEAD}" - BODY="${BODY//SUMMARY_PLACEHOLDER/$SUMMARY}" + Follow the [agentic-merge-upstream](.github/prompts/agentic-merge-upstream.prompt.md) prompt to port these changes to the Java SDK." # Read custom instructions from external file CUSTOM_INSTRUCTIONS=$(cat .github/prompts/coding-agent-merge-instructions.md) - # Create issue and assign to Copilot coding agent - gh api \ - --method POST \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - /repos/${{ github.repository }}/issues \ - --input - < Date: Sat, 7 Feb 2026 15:40:29 -0800 Subject: [PATCH 085/109] Split issue creation and Copilot assignment into two API calls Create the issue first with agent_assignment, then assign copilot-swe-agent[bot] via the Add Assignees endpoint separately. The single-call approach fails with GITHUB_TOKEN. --- .github/workflows/weekly-upstream-sync.yml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/weekly-upstream-sync.yml b/.github/workflows/weekly-upstream-sync.yml index 51a44370e..8d366d1f9 100644 --- a/.github/workflows/weekly-upstream-sync.yml +++ b/.github/workflows/weekly-upstream-sync.yml @@ -118,7 +118,6 @@ jobs: title: $title, body: $body, labels: ["upstream-sync"], - assignees: ["copilot-swe-agent[bot]"], agent_assignment: { target_repo: $repo, base_branch: "main", @@ -130,9 +129,21 @@ jobs: -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ "/repos/${REPO}/issues" \ - --input - + --input - \ + --jq '.number' > /tmp/issue_number.txt - echo "βœ… Issue created and assigned to Copilot coding agent." + ISSUE_NUMBER=$(cat /tmp/issue_number.txt) + echo "Created issue #${ISSUE_NUMBER}" + + # Assign Copilot to the issue (separate API call) + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "/repos/${REPO}/issues/${ISSUE_NUMBER}/assignees" \ + -f 'assignees[]=copilot-swe-agent[bot]' + + echo "βœ… Issue #${ISSUE_NUMBER} created and assigned to Copilot coding agent." - name: Summary if: always() From ba9f22c7fb33889b322634c393a56c407eabcd9c Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 15:43:46 -0800 Subject: [PATCH 086/109] Trigger Copilot via @mention comment instead of assignee API Create the issue first, then add a comment with @copilot and the custom instructions. This avoids the assignee permission issues with GITHUB_TOKEN. --- .github/workflows/weekly-upstream-sync.yml | 30 ++++++++++------------ 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/.github/workflows/weekly-upstream-sync.yml b/.github/workflows/weekly-upstream-sync.yml index 8d366d1f9..3f7fdc523 100644 --- a/.github/workflows/weekly-upstream-sync.yml +++ b/.github/workflows/weekly-upstream-sync.yml @@ -108,22 +108,14 @@ jobs: # Read custom instructions from external file CUSTOM_INSTRUCTIONS=$(cat .github/prompts/coding-agent-merge-instructions.md) - # Build JSON payload with jq to ensure proper escaping + # Create the issue (without assignee to avoid permission issues) jq -n \ --arg title "Upstream sync: ${COMMIT_COUNT} new commits (${DATE})" \ --arg body "$BODY" \ - --arg repo "$REPO" \ - --arg instructions "$CUSTOM_INSTRUCTIONS" \ '{ title: $title, body: $body, - labels: ["upstream-sync"], - agent_assignment: { - target_repo: $repo, - base_branch: "main", - custom_instructions: $instructions, - model: "claude-opus-4.6" - } + labels: ["upstream-sync"] }' | gh api \ --method POST \ -H "Accept: application/vnd.github+json" \ @@ -135,13 +127,17 @@ jobs: ISSUE_NUMBER=$(cat /tmp/issue_number.txt) echo "Created issue #${ISSUE_NUMBER}" - # Assign Copilot to the issue (separate API call) - gh api \ - --method POST \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "/repos/${REPO}/issues/${ISSUE_NUMBER}/assignees" \ - -f 'assignees[]=copilot-swe-agent[bot]' + # Trigger Copilot by @mentioning it with custom instructions + jq -n \ + --arg instructions "$CUSTOM_INSTRUCTIONS" \ + '{ + body: ("@copilot\n\n" + $instructions) + }' | gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "/repos/${REPO}/issues/${ISSUE_NUMBER}/comments" \ + --input - echo "βœ… Issue #${ISSUE_NUMBER} created and assigned to Copilot coding agent." From 7e323bfb47d9e190690c9f87178265b9ee5cb559 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 16:10:03 -0800 Subject: [PATCH 087/109] Add workflow to create issues for Copilot Coding Agent --- .github/workflows/copilot-issue.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/copilot-issue.yml diff --git a/.github/workflows/copilot-issue.yml b/.github/workflows/copilot-issue.yml new file mode 100644 index 000000000..37aaf0a13 --- /dev/null +++ b/.github/workflows/copilot-issue.yml @@ -0,0 +1,27 @@ +name: Create Issue for Copilot Coding Agent + +on: + workflow_dispatch: + inputs: + title: + description: 'Issue title' + required: true + type: string + +jobs: + create-issue: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Create issue and assign to Copilot + uses: actions/github-script@v8 + with: + script: | + const issue = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: '${{ inputs.title }}', + assignees: ['copilot'] + }); + core.notice(`Created issue #${issue.data.number}: ${issue.data.html_url}`); From efc3485855c48f9adf8c06d4f06e41b7d289ae9e Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 16:21:49 -0800 Subject: [PATCH 088/109] Change assignee --- .github/workflows/copilot-issue.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/copilot-issue.yml b/.github/workflows/copilot-issue.yml index 37aaf0a13..d983c4834 100644 --- a/.github/workflows/copilot-issue.yml +++ b/.github/workflows/copilot-issue.yml @@ -22,6 +22,6 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, title: '${{ inputs.title }}', - assignees: ['copilot'] + assignees: ['copilot-swe-bot'] }); core.notice(`Created issue #${issue.data.number}: ${issue.data.html_url}`); From ce50394d6cf2786699614964f48c32458d759b81 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 16:22:49 -0800 Subject: [PATCH 089/109] Ignore copilot-issue.yml in workflow triggers --- .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 008c3367d..2ed3ad968 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -17,6 +17,7 @@ on: - '.github/release.yml' - '.github/workflows/publish-maven.yml' - '.github/workflows/weekly-upstream-sync.yml' + - '.github/workflows/copilot-issue.yml' pull_request: paths-ignore: - 'README.md' From c2ec4abbe419e54b73aea5b93fdb901f88810452 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 16:26:20 -0800 Subject: [PATCH 090/109] Update assignee in issue creation workflow to use 'copilot-swe-agent' --- .github/workflows/copilot-issue.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/copilot-issue.yml b/.github/workflows/copilot-issue.yml index d983c4834..602d9ff95 100644 --- a/.github/workflows/copilot-issue.yml +++ b/.github/workflows/copilot-issue.yml @@ -22,6 +22,6 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, title: '${{ inputs.title }}', - assignees: ['copilot-swe-bot'] + assignees: ['copilot-swe-agent'] }); core.notice(`Created issue #${issue.data.number}: ${issue.data.html_url}`); From 2249b2a8d046da32cd8737c331e6363585697f77 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 16:31:01 -0800 Subject: [PATCH 091/109] Add script to create GitHub issues assigned to Copilot and update workflow to use it --- .../create-issue-assigned-to-copilot.py | 113 ++++++++++++++++++ .github/workflows/copilot-issue.yml | 25 ++++ 2 files changed, 138 insertions(+) create mode 100644 .github/scripts/create-issue-assigned-to-copilot.py diff --git a/.github/scripts/create-issue-assigned-to-copilot.py b/.github/scripts/create-issue-assigned-to-copilot.py new file mode 100644 index 000000000..8e46fd731 --- /dev/null +++ b/.github/scripts/create-issue-assigned-to-copilot.py @@ -0,0 +1,113 @@ +import { Octokit } from '@octokit/rest'; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const GITHUB_REPO_OWNER = process.env.GITHUB_REPO_OWNER; +const GITHUB_REPO_NAME = process.env.GITHUB_REPO_NAME; + +/** + * Creates a GitHub issue from the provided description and assigns it to @copilot-swe-agent if available. + * Uses GraphQL for efficient repo/actor queries and issue creation with assignment. + * Returns the issue URL on success, null on failure. + */ +export async function createIssueWithCopilot(description: string): Promise { + if (!GITHUB_TOKEN || !GITHUB_REPO_OWNER || !GITHUB_REPO_NAME) { + return null; + } + + if (!description.trim()) { + return null; + } + + const octokit = new Octokit({ auth: GITHUB_TOKEN }); + + try { + // Fetch repo ID and check for @copilot-swe-agent + const repoInfoQuery = ` + query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + id + suggestedActors(capabilities: [CAN_BE_ASSIGNED], first: 100) { + nodes { + login + id + } + } + } + } + `; + + const repoInfo: any = await octokit.graphql(repoInfoQuery, { + owner: GITHUB_REPO_OWNER, + name: GITHUB_REPO_NAME, + }); + + const repoId = repoInfo?.repository?.id; + if (!repoId) { + return null; + } + + const copilotBot = repoInfo.repository.suggestedActors.nodes.find( + (node: any) => node.login === 'copilot-swe-agent' + ); + + const title = description.split('\n')[0].slice(0, 100); + + if (!copilotBot) { + // Fallback: Create issue without assignment via REST + const issue = await octokit.issues.create({ + owner: GITHUB_REPO_OWNER, + repo: GITHUB_REPO_NAME, + title, + body: description, + }); + + return issue.data.html_url; + } + + // Create issue with assignment via GraphQL + const createIssueMutation = ` + mutation($repoId: ID!, $title: String!, $body: String!, $assigneeIds: [ID!]) { + createIssue(input: { repositoryId: $repoId, title: $title, body: $body, assigneeIds: $assigneeIds }) { + issue { + number + title + url + assignees(first: 10) { nodes { login } } + } + } + } + `; + + const response: any = await octokit.graphql(createIssueMutation, { + repoId, + title, + body: description, + assigneeIds: [copilotBot.id], + }); + + const issue = response?.createIssue?.issue; + if (!issue) { + return null; + } + + return issue.url; + } catch (error) { + console.error('Error creating issue:', error); + return null; + } +} + +// CLI entry point +const description = process.argv[2]; +if (!description) { + console.error('Usage: npx tsx create-issue-assigned-to-copilot.py '); + process.exit(1); +} +createIssueWithCopilot(description).then((url) => { + if (url) { + console.log(`Issue created: ${url}`); + } else { + console.error('Failed to create issue'); + process.exit(1); + } +}); \ No newline at end of file diff --git a/.github/workflows/copilot-issue.yml b/.github/workflows/copilot-issue.yml index 602d9ff95..281b4c9d8 100644 --- a/.github/workflows/copilot-issue.yml +++ b/.github/workflows/copilot-issue.yml @@ -25,3 +25,28 @@ jobs: assignees: ['copilot-swe-agent'] }); core.notice(`Created issue #${issue.data.number}: ${issue.data.html_url}`); + + create-issue-graphql: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + + - name: Install dependencies + run: npm install @octokit/rest tsx + working-directory: .github/scripts + + - name: Create issue via GraphQL + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPO_OWNER: ${{ github.repository_owner }} + GITHUB_REPO_NAME: ${{ github.event.repository.name }} + run: npx tsx create-issue-assigned-to-copilot.py "${{ inputs.title }}" + working-directory: .github/scripts From dede9e0f98ff2089f7c0e47bcfeb3e30a5475e8e Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 16:33:49 -0800 Subject: [PATCH 092/109] Add script to create GitHub issues assigned to @copilot-swe-agent --- ...assigned-to-copilot.py => create-issue-assigned-to-copilot.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/scripts/{create-issue-assigned-to-copilot.py => create-issue-assigned-to-copilot.ts} (100%) diff --git a/.github/scripts/create-issue-assigned-to-copilot.py b/.github/scripts/create-issue-assigned-to-copilot.ts similarity index 100% rename from .github/scripts/create-issue-assigned-to-copilot.py rename to .github/scripts/create-issue-assigned-to-copilot.ts From 3376be24b0401f70c740610af2731fd826e43ece Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 16:36:31 -0800 Subject: [PATCH 093/109] Fix script extension in issue creation workflow to use TypeScript --- .github/workflows/copilot-issue.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/copilot-issue.yml b/.github/workflows/copilot-issue.yml index 281b4c9d8..9afeab209 100644 --- a/.github/workflows/copilot-issue.yml +++ b/.github/workflows/copilot-issue.yml @@ -48,5 +48,5 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPO_OWNER: ${{ github.repository_owner }} GITHUB_REPO_NAME: ${{ github.event.repository.name }} - run: npx tsx create-issue-assigned-to-copilot.py "${{ inputs.title }}" + run: npx tsx create-issue-assigned-to-copilot.ts "${{ inputs.title }}" working-directory: .github/scripts From bea7a4caa1d2e24b11bfe3cc265cf641c682b0f1 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 16:38:51 -0800 Subject: [PATCH 094/109] Refactor GraphQL query to correctly fetch IDs for both User and Bot suggested actors --- .github/scripts/create-issue-assigned-to-copilot.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/scripts/create-issue-assigned-to-copilot.ts b/.github/scripts/create-issue-assigned-to-copilot.ts index 8e46fd731..d8cc6a49d 100644 --- a/.github/scripts/create-issue-assigned-to-copilot.ts +++ b/.github/scripts/create-issue-assigned-to-copilot.ts @@ -29,7 +29,8 @@ export async function createIssueWithCopilot(description: string): Promise Date: Sat, 7 Feb 2026 16:39:39 -0800 Subject: [PATCH 095/109] Update issue creation workflow to make title input optional with a default value --- .github/workflows/copilot-issue.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/copilot-issue.yml b/.github/workflows/copilot-issue.yml index 9afeab209..5c9201211 100644 --- a/.github/workflows/copilot-issue.yml +++ b/.github/workflows/copilot-issue.yml @@ -5,8 +5,13 @@ on: inputs: title: description: 'Issue title' - required: true + required: false + default: 'Investigation needed from Copilot Coding Agent' type: string + push: + branches: [main] + paths: + - '.github/workflows/copilot-issue.yml' jobs: create-issue: From eb8394007767316eb1e62649ac237cf104b46318 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 16:39:52 -0800 Subject: [PATCH 096/109] Add create-issue-assigned-to-copilot.ts to workflow paths --- .github/workflows/copilot-issue.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/copilot-issue.yml b/.github/workflows/copilot-issue.yml index 5c9201211..1ba4e524c 100644 --- a/.github/workflows/copilot-issue.yml +++ b/.github/workflows/copilot-issue.yml @@ -12,6 +12,7 @@ on: branches: [main] paths: - '.github/workflows/copilot-issue.yml' + - '.github/scripts/create-issue-assigned-to-copilot.ts' jobs: create-issue: From b88ba39f706e546b9641d455f71e7514bbe14957 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 16:41:32 -0800 Subject: [PATCH 097/109] Remove default value for issue title input and ensure fallback title is used in script --- .github/workflows/copilot-issue.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/copilot-issue.yml b/.github/workflows/copilot-issue.yml index 1ba4e524c..057c454c3 100644 --- a/.github/workflows/copilot-issue.yml +++ b/.github/workflows/copilot-issue.yml @@ -6,7 +6,6 @@ on: title: description: 'Issue title' required: false - default: 'Investigation needed from Copilot Coding Agent' type: string push: branches: [main] @@ -24,10 +23,11 @@ jobs: uses: actions/github-script@v8 with: script: | + const title = '${{ inputs.title || 'Investigation needed from Copilot Coding Agent' }}'; const issue = await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, - title: '${{ inputs.title }}', + title, assignees: ['copilot-swe-agent'] }); core.notice(`Created issue #${issue.data.number}: ${issue.data.html_url}`); @@ -54,5 +54,5 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPO_OWNER: ${{ github.repository_owner }} GITHUB_REPO_NAME: ${{ github.event.repository.name }} - run: npx tsx create-issue-assigned-to-copilot.ts "${{ inputs.title }}" + run: npx tsx create-issue-assigned-to-copilot.ts "${{ inputs.title || 'Investigation needed from Copilot Coding Agent' }}" working-directory: .github/scripts From 285a36ab0494cf5f47c6bd2e98da6f82d038858b Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 16:44:41 -0800 Subject: [PATCH 098/109] Update issue creation script to assign issues to the Copilot Coding Agent --- .../create-issue-assigned-to-copilot.ts | 54 ++++++------------- .github/workflows/copilot-issue.yml | 2 +- 2 files changed, 16 insertions(+), 40 deletions(-) diff --git a/.github/scripts/create-issue-assigned-to-copilot.ts b/.github/scripts/create-issue-assigned-to-copilot.ts index d8cc6a49d..f56390033 100644 --- a/.github/scripts/create-issue-assigned-to-copilot.ts +++ b/.github/scripts/create-issue-assigned-to-copilot.ts @@ -4,9 +4,13 @@ const GITHUB_TOKEN = process.env.GITHUB_TOKEN; const GITHUB_REPO_OWNER = process.env.GITHUB_REPO_OWNER; const GITHUB_REPO_NAME = process.env.GITHUB_REPO_NAME; +// Known Copilot Coding Agent bot identity +const COPILOT_BOT_LOGIN = 'Copilot'; +const COPILOT_BOT_NODE_ID = 'BOT_kgDOC9w8XQ'; + /** - * Creates a GitHub issue from the provided description and assigns it to @copilot-swe-agent if available. - * Uses GraphQL for efficient repo/actor queries and issue creation with assignment. + * Creates a GitHub issue and assigns it to the Copilot Coding Agent (@Copilot). + * Uses GraphQL to create the issue with the bot's known node ID as assignee. * Returns the issue URL on success, null on failure. */ export async function createIssueWithCopilot(description: string): Promise { @@ -21,23 +25,12 @@ export async function createIssueWithCopilot(description: string): Promise node.login === 'copilot-swe-agent' - ); - const title = description.split('\n')[0].slice(0, 100); - if (!copilotBot) { - // Fallback: Create issue without assignment via REST - const issue = await octokit.issues.create({ - owner: GITHUB_REPO_OWNER, - repo: GITHUB_REPO_NAME, - title, - body: description, - }); - - return issue.data.html_url; - } - - // Create issue with assignment via GraphQL - const createIssueMutation = ` + // Create issue with Copilot bot assigned via known node ID + const response: any = await octokit.graphql(` mutation($repoId: ID!, $title: String!, $body: String!, $assigneeIds: [ID!]) { createIssue(input: { repositoryId: $repoId, title: $title, body: $body, assigneeIds: $assigneeIds }) { issue { @@ -77,13 +54,11 @@ export async function createIssueWithCopilot(description: string): Promise a.login).join(', ')}`); return issue.url; } catch (error) { console.error('Error creating issue:', error); diff --git a/.github/workflows/copilot-issue.yml b/.github/workflows/copilot-issue.yml index 057c454c3..796722003 100644 --- a/.github/workflows/copilot-issue.yml +++ b/.github/workflows/copilot-issue.yml @@ -28,7 +28,7 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, title, - assignees: ['copilot-swe-agent'] + assignees: ['Copilot'] }); core.notice(`Created issue #${issue.data.number}: ${issue.data.html_url}`); From f05445eceb0033e180d0c43f820bc3ddfa1529a2 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 16:47:38 -0800 Subject: [PATCH 099/109] Refactor issue creation to use GitHub CLI and assign to Copilot Coding Agent with dynamic agent assignment --- .../create-issue-assigned-to-copilot.ts | 58 +++++++++++++++---- .github/workflows/copilot-issue.yml | 34 +++++++---- 2 files changed, 71 insertions(+), 21 deletions(-) diff --git a/.github/scripts/create-issue-assigned-to-copilot.ts b/.github/scripts/create-issue-assigned-to-copilot.ts index f56390033..aa9be0b26 100644 --- a/.github/scripts/create-issue-assigned-to-copilot.ts +++ b/.github/scripts/create-issue-assigned-to-copilot.ts @@ -4,13 +4,14 @@ const GITHUB_TOKEN = process.env.GITHUB_TOKEN; const GITHUB_REPO_OWNER = process.env.GITHUB_REPO_OWNER; const GITHUB_REPO_NAME = process.env.GITHUB_REPO_NAME; -// Known Copilot Coding Agent bot identity -const COPILOT_BOT_LOGIN = 'Copilot'; -const COPILOT_BOT_NODE_ID = 'BOT_kgDOC9w8XQ'; +const GRAPHQL_FEATURES_HEADER = 'issues_copilot_assignment_api_support,coding_agent_model_selection'; /** - * Creates a GitHub issue and assigns it to the Copilot Coding Agent (@Copilot). - * Uses GraphQL to create the issue with the bot's known node ID as assignee. + * Creates a GitHub issue and assigns it to the Copilot Coding Agent. + * Follows the official GitHub API docs: + * 1. Query suggestedActors to find copilot-swe-agent and get its node ID + * 2. Get the repository node ID + * 3. Create issue with assigneeIds + agentAssignment, including required GraphQL-Features header * Returns the issue URL on success, null on failure. */ export async function createIssueWithCopilot(description: string): Promise { @@ -25,10 +26,20 @@ export async function createIssueWithCopilot(description: string): Promise node.login === 'copilot-swe-agent' + ); + + if (!copilotBot) { + console.error('copilot-swe-agent not found in suggestedActors. Is Copilot coding agent enabled for this repo?'); return null; } + console.log(`Found Copilot bot: login=${copilotBot.login}, id=${copilotBot.id}, type=${copilotBot.__typename}`); + const title = description.split('\n')[0].slice(0, 100); - // Create issue with Copilot bot assigned via known node ID + // Step 2: Create issue with agentAssignment and required GraphQL-Features header const response: any = await octokit.graphql(` mutation($repoId: ID!, $title: String!, $body: String!, $assigneeIds: [ID!]) { - createIssue(input: { repositoryId: $repoId, title: $title, body: $body, assigneeIds: $assigneeIds }) { + createIssue(input: { + repositoryId: $repoId, + title: $title, + body: $body, + assigneeIds: $assigneeIds, + agentAssignment: { + targetRepositoryId: $repoId, + baseRef: "main", + customInstructions: "", + customAgent: "", + model: "" + } + }) { issue { number title @@ -58,7 +93,10 @@ export async function createIssueWithCopilot(description: string): Promise Date: Sat, 7 Feb 2026 16:49:22 -0800 Subject: [PATCH 100/109] Remove hardcoded assignee from issue creation workflow --- .github/workflows/copilot-issue.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/copilot-issue.yml b/.github/workflows/copilot-issue.yml index f7ca737ca..775d2304d 100644 --- a/.github/workflows/copilot-issue.yml +++ b/.github/workflows/copilot-issue.yml @@ -34,7 +34,6 @@ jobs: --arg repo "${{ github.repository }}" \ '{ title: $title, - assignees: ["copilot-swe-agent[bot]"], agent_assignment: { target_repo: $repo, base_branch: "main", From 4ce9b883cc95a3cac94ee9105e345d72b23ad564 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 16:52:11 -0800 Subject: [PATCH 101/109] Add GraphQL-Features header to issue creation request --- .github/scripts/create-issue-assigned-to-copilot.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/scripts/create-issue-assigned-to-copilot.ts b/.github/scripts/create-issue-assigned-to-copilot.ts index aa9be0b26..1027a29cc 100644 --- a/.github/scripts/create-issue-assigned-to-copilot.ts +++ b/.github/scripts/create-issue-assigned-to-copilot.ts @@ -44,6 +44,9 @@ export async function createIssueWithCopilot(description: string): Promise Date: Sat, 7 Feb 2026 16:56:44 -0800 Subject: [PATCH 102/109] Implement fallback for Copilot bot ID in issue creation script --- .../scripts/create-issue-assigned-to-copilot.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/scripts/create-issue-assigned-to-copilot.ts b/.github/scripts/create-issue-assigned-to-copilot.ts index 1027a29cc..28f02c6b1 100644 --- a/.github/scripts/create-issue-assigned-to-copilot.ts +++ b/.github/scripts/create-issue-assigned-to-copilot.ts @@ -59,13 +59,17 @@ export async function createIssueWithCopilot(description: string): Promise node.login === 'copilot-swe-agent' ); - if (!copilotBot) { - console.error('copilot-swe-agent not found in suggestedActors. Is Copilot coding agent enabled for this repo?'); - return null; + let botId: string; + if (copilotBot) { + botId = copilotBot.id; + console.log(`Found Copilot bot: login=${copilotBot.login}, id=${botId}, type=${copilotBot.__typename}`); + } else { + // Fallback: the GITHUB_TOKEN in Actions may lack permission to see suggestedActors. + // Use the known node ID for copilot-swe-agent. + botId = 'BOT_kgDOC9w8XQ'; + console.log(`copilot-swe-agent not found in suggestedActors, using known bot ID: ${botId}`); } - console.log(`Found Copilot bot: login=${copilotBot.login}, id=${copilotBot.id}, type=${copilotBot.__typename}`); - const title = description.split('\n')[0].slice(0, 100); // Step 2: Create issue with agentAssignment and required GraphQL-Features header @@ -96,7 +100,7 @@ export async function createIssueWithCopilot(description: string): Promise Date: Sat, 7 Feb 2026 17:00:56 -0800 Subject: [PATCH 103/109] Update GitHub token reference to use COPILOT_GITHUB_TOKEN in issue creation workflow --- .github/workflows/copilot-issue.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/copilot-issue.yml b/.github/workflows/copilot-issue.yml index 775d2304d..51ee70395 100644 --- a/.github/workflows/copilot-issue.yml +++ b/.github/workflows/copilot-issue.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Create issue and assign to Copilot env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} run: | TITLE="${{ inputs.title || 'Investigation needed from Copilot Coding Agent' }}" gh api \ @@ -62,7 +62,7 @@ jobs: - name: Create issue via GraphQL env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} GITHUB_REPO_OWNER: ${{ github.repository_owner }} GITHUB_REPO_NAME: ${{ github.event.repository.name }} run: npx tsx create-issue-assigned-to-copilot.ts "${{ inputs.title || 'Investigation needed from Copilot Coding Agent' }}" From 3c57345250dc7e97fbc0e6267c3cdb0d9fab574a Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 17:09:54 -0800 Subject: [PATCH 104/109] Refactor issue creation to build JSON payloads for issue and comment, enhancing clarity and maintainability --- .github/workflows/weekly-upstream-sync.yml | 23 ++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/workflows/weekly-upstream-sync.yml b/.github/workflows/weekly-upstream-sync.yml index 3f7fdc523..8fe3f3b78 100644 --- a/.github/workflows/weekly-upstream-sync.yml +++ b/.github/workflows/weekly-upstream-sync.yml @@ -108,31 +108,38 @@ jobs: # Read custom instructions from external file CUSTOM_INSTRUCTIONS=$(cat .github/prompts/coding-agent-merge-instructions.md) - # Create the issue (without assignee to avoid permission issues) - jq -n \ + # Build the issue JSON payload + ISSUE_PAYLOAD=$(jq -n \ --arg title "Upstream sync: ${COMMIT_COUNT} new commits (${DATE})" \ --arg body "$BODY" \ '{ title: $title, body: $body, labels: ["upstream-sync"] - }' | gh api \ + }') + + # Create the issue + ISSUE_NUMBER=$(echo "$ISSUE_PAYLOAD" | \ + gh api \ --method POST \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ "/repos/${REPO}/issues" \ --input - \ - --jq '.number' > /tmp/issue_number.txt + --jq '.number') - ISSUE_NUMBER=$(cat /tmp/issue_number.txt) echo "Created issue #${ISSUE_NUMBER}" - # Trigger Copilot by @mentioning it with custom instructions - jq -n \ + # Build the comment JSON payload to trigger Copilot + COMMENT_PAYLOAD=$(jq -n \ --arg instructions "$CUSTOM_INSTRUCTIONS" \ '{ body: ("@copilot\n\n" + $instructions) - }' | gh api \ + }') + + # Post the comment to assign Copilot + echo "$COMMENT_PAYLOAD" | \ + gh api \ --method POST \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ From d73799c9db12a94c91d53d14978429e25f1c7321 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Sat, 7 Feb 2026 17:21:14 -0800 Subject: [PATCH 105/109] Remove custom instructions and comment payload from upstream sync issue creation --- .github/workflows/weekly-upstream-sync.yml | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/.github/workflows/weekly-upstream-sync.yml b/.github/workflows/weekly-upstream-sync.yml index 8fe3f3b78..37af04c23 100644 --- a/.github/workflows/weekly-upstream-sync.yml +++ b/.github/workflows/weekly-upstream-sync.yml @@ -105,9 +105,6 @@ jobs: Follow the [agentic-merge-upstream](.github/prompts/agentic-merge-upstream.prompt.md) prompt to port these changes to the Java SDK." - # Read custom instructions from external file - CUSTOM_INSTRUCTIONS=$(cat .github/prompts/coding-agent-merge-instructions.md) - # Build the issue JSON payload ISSUE_PAYLOAD=$(jq -n \ --arg title "Upstream sync: ${COMMIT_COUNT} new commits (${DATE})" \ @@ -129,23 +126,6 @@ jobs: --jq '.number') echo "Created issue #${ISSUE_NUMBER}" - - # Build the comment JSON payload to trigger Copilot - COMMENT_PAYLOAD=$(jq -n \ - --arg instructions "$CUSTOM_INSTRUCTIONS" \ - '{ - body: ("@copilot\n\n" + $instructions) - }') - - # Post the comment to assign Copilot - echo "$COMMENT_PAYLOAD" | \ - gh api \ - --method POST \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "/repos/${REPO}/issues/${ISSUE_NUMBER}/comments" \ - --input - - echo "βœ… Issue #${ISSUE_NUMBER} created and assigned to Copilot coding agent." - name: Summary From 8d801c665f621b204466569093e0d8b0ed097e73 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 01:28:22 +0000 Subject: [PATCH 106/109] Port upstream BYOK documentation clarifications Add bearer token authentication section and limitations to BYOK documentation. Updates ProviderConfig Javadoc to clarify static token behavior. From upstream commit: 05e3c46 (Clarify BYOK token usage and limitations) Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- .../copilot/sdk/json/ProviderConfig.java | 5 +++ src/site/markdown/advanced.md | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/main/java/com/github/copilot/sdk/json/ProviderConfig.java b/src/main/java/com/github/copilot/sdk/json/ProviderConfig.java index a7b8465f9..96c70cf12 100644 --- a/src/main/java/com/github/copilot/sdk/json/ProviderConfig.java +++ b/src/main/java/com/github/copilot/sdk/json/ProviderConfig.java @@ -159,6 +159,11 @@ public String getBearerToken() { * Sets a bearer token for authentication. *

* This is an alternative to API key authentication. + *

+ * Note: The bearer token is a static token + * string. The SDK does not refresh this token automatically. If your + * token expires, requests will fail and you'll need to create a new session + * with a fresh token. * * @param bearerToken * the bearer token diff --git a/src/site/markdown/advanced.md b/src/site/markdown/advanced.md index 619cff40f..fc0878159 100644 --- a/src/site/markdown/advanced.md +++ b/src/site/markdown/advanced.md @@ -129,6 +129,8 @@ session.send(new MessageOptions() Use your own OpenAI or Azure OpenAI API key instead of GitHub Copilot. +### API Key Authentication + ```java var session = client.createSession( new SessionConfig() @@ -139,6 +141,38 @@ var session = client.createSession( ).get(); ``` +### Bearer Token Authentication + +Some providers require bearer token authentication instead of API keys: + +```java +var session = client.createSession( + new SessionConfig() + .setProvider(new ProviderConfig() + .setType("openai") + .setBaseUrl("https://my-custom-endpoint.example.com/v1") + .setBearerToken(System.getenv("MY_BEARER_TOKEN"))) +).get(); +``` + +> **Note:** The `bearerToken` option accepts a **static token string** only. The SDK does not refresh this token automatically. If your token expires, requests will fail and you'll need to create a new session with a fresh token. + +### Limitations + +When using BYOK, be aware of these limitations: + +#### Identity Limitations + +BYOK authentication uses **static credentials only**. The following identity providers are NOT supported: + +- ❌ **Microsoft Entra ID (Azure AD)** - No support for Entra managed identities or service principals +- ❌ **Third-party identity providers** - No OIDC, SAML, or other federated identity +- ❌ **Managed identities** - Azure Managed Identity is not supported + +You must use an API key or static bearer token that you manage yourself. + +**Why not Entra ID?** While Entra ID does issue bearer tokens, these tokens are short-lived (typically 1 hour) and require automatic refresh via the Azure Identity SDK. The `bearerToken` option only accepts a static stringβ€”there is no callback mechanism for the SDK to request fresh tokens. For long-running workloads requiring Entra authentication, you would need to implement your own token refresh logic and create new sessions with updated tokens. + --- ## Infinite Sessions From 4c762475c4368c13fbfc4a22c1f78eb7fd5b3c2f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 01:28:45 +0000 Subject: [PATCH 107/109] Update .lastmerge to 05e3c46c8c23130c9c064dc43d00ec78f7a75eab --- .lastmerge | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.lastmerge b/.lastmerge index 43a50141d..64a7ea328 100644 --- a/.lastmerge +++ b/.lastmerge @@ -1 +1 @@ -2186bf290575321b34b672068608d8dff0671a76 +05e3c46c8c23130c9c064dc43d00ec78f7a75eab From 9e7abfcc841014ab36ab9ea26a1c59d4e8e869d1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 8 Feb 2026 03:55:20 +0000 Subject: [PATCH 108/109] docs: update version references to 1.0.8 --- README.md | 4 ++-- jbang-example.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1bdaad4b8..b79b39c3e 100644 --- a/README.md +++ b/README.md @@ -31,14 +31,14 @@ Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build A io.github.copilot-community-sdk copilot-sdk - 1.0.7 + 1.0.8 ``` ### Gradle ```groovy -implementation 'io.github.copilot-community-sdk:copilot-sdk:1.0.7' +implementation 'io.github.copilot-community-sdk:copilot-sdk:1.0.8' ``` ## Quick Start diff --git a/jbang-example.java b/jbang-example.java index 6efcd15a9..e89e120be 100644 --- a/jbang-example.java +++ b/jbang-example.java @@ -1,5 +1,5 @@ -//DEPS io.github.copilot-community-sdk:copilot-sdk:1.0.7 +//DEPS io.github.copilot-community-sdk:copilot-sdk:1.0.8 import com.github.copilot.sdk.*; import com.github.copilot.sdk.events.*; import com.github.copilot.sdk.json.*; From 0b36d81b86f7050bbf8da2116d81cbb8bba3936d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 8 Feb 2026 03:55:40 +0000 Subject: [PATCH 109/109] [maven-release-plugin] prepare release v1.0.8 --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 5ea38832a..1710a7107 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ io.github.copilot-community-sdk copilot-sdk - 1.0.8-SNAPSHOT + 1.0.8 jar GitHub Copilot Community SDK :: Java @@ -33,7 +33,7 @@ scm:git:https://github.com/copilot-community-sdk/copilot-sdk-java.git scm:git:https://github.com/copilot-community-sdk/copilot-sdk-java.git https://github.com/copilot-community-sdk/copilot-sdk-java - HEAD + v1.0.8