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 }}") diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c3765c4d3..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 @@ -169,3 +178,74 @@ 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 + +## 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 diff --git a/.github/prompts/agentic-merge-upstream.prompt.md b/.github/prompts/agentic-merge-upstream.prompt.md index 485a8fae0..d962373c3 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 +## Utility Scripts -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 +The `.github/scripts/` directory contains helper scripts that automate the repeatable parts of this workflow. **Use these scripts instead of running the commands manually.** ---- +| 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) | -## Step 1: Create a New Branch +All scripts write/read a `.merge-env` file (git-ignored) to share state (branch name, upstream dir, last-merge commit). -Before starting any work, create a new branch from `main` to isolate the merge changes: +## Workflow Overview -```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" -``` +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. Finalize Pull Request (see note below about coding agent vs. manual workflow) -**Important:** All changes will be committed to this branch as you work. This allows for proper review via Pull Request. +--- -## Step 2: Update Copilot CLI +## Steps 1-2: Initialize and Analyze -Update the locally installed GitHub Copilot CLI to the latest version: +Run the start script to create a branch, update the CLI, clone the upstream repo, and see a summary of new commits: ```bash -copilot update +./.github/scripts/merge-upstream-start.sh ``` -After updating, capture the new version and update the README.md to reflect the minimum version requirement: - -```bash -# Get the current version -CLI_VERSION=$(copilot --version | head -n 1 | awk '{print $NF}') -echo "Updated Copilot CLI to version: $CLI_VERSION" -``` +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` -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. - -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,37 +316,67 @@ 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 +## Steps 11-12: Finish, Push, and Finalize Pull Request -Update the `.lastmerge` file with the new HEAD commit and commit this change: +Run the finish script which updates `.lastmerge`, runs a final build, and pushes the branch: ```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" +./.github/scripts/merge-upstream-finish.sh # full format + test + push +./.github/scripts/merge-upstream-finish.sh --skip-tests # if tests already passed ``` -## Step 12: Push Branch and Create Pull Request +### PR Handling: Coding Agent vs. Manual Workflow -Push the branch to remote so the changes can be reviewed via Pull Request: +**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: ```bash -# Push the branch to remote -git push -u origin "$BRANCH_NAME" +gh pr edit --add-label "upstream-sync" +``` -echo "Branch '$BRANCH_NAME' pushed to remote." -echo "Create a Pull Request to review and merge the changes." +> **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: + +```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" ``` -**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` +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 +387,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 is ready (created or updated) and provide the PR URL to the user --- @@ -404,7 +414,9 @@ 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 finalized** (coding agent: push to existing PR; manual: create via `mcp_github_create_pull_request`) +- [ ] **`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 new file mode 100644 index 000000000..429bb5382 --- /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 by running this command in a terminal: + + gh pr edit --add-label "upstream-sync" + +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. 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/.github/scripts/create-issue-assigned-to-copilot.ts b/.github/scripts/create-issue-assigned-to-copilot.ts new file mode 100644 index 000000000..28f02c6b1 --- /dev/null +++ b/.github/scripts/create-issue-assigned-to-copilot.ts @@ -0,0 +1,135 @@ +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; + +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. + * 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 { + if (!GITHUB_TOKEN || !GITHUB_REPO_OWNER || !GITHUB_REPO_NAME) { + return null; + } + + if (!description.trim()) { + return null; + } + + const octokit = new Octokit({ auth: GITHUB_TOKEN }); + + try { + // Step 1: Fetch repo ID and find copilot-swe-agent in suggestedActors + const repoInfo: any = await octokit.graphql(` + query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + id + suggestedActors(capabilities: [CAN_BE_ASSIGNED], first: 100) { + nodes { + login + __typename + ... on Bot { id } + ... on User { id } + } + } + } + } + `, { + owner: GITHUB_REPO_OWNER, + name: GITHUB_REPO_NAME, + headers: { + 'GraphQL-Features': GRAPHQL_FEATURES_HEADER, + }, + }); + + const repoId = repoInfo?.repository?.id; + if (!repoId) { + console.error('Could not fetch repository ID'); + return null; + } + + const copilotBot = repoInfo.repository.suggestedActors.nodes.find( + (node: any) => node.login === 'copilot-swe-agent' + ); + + 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}`); + } + + const title = description.split('\n')[0].slice(0, 100); + + // 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, + agentAssignment: { + targetRepositoryId: $repoId, + baseRef: "main", + customInstructions: "", + customAgent: "", + model: "" + } + }) { + issue { + number + title + url + assignees(first: 10) { nodes { login } } + } + } + } + `, { + repoId, + title, + body: description, + assigneeIds: [botId], + headers: { + 'GraphQL-Features': GRAPHQL_FEATURES_HEADER, + }, + }); + + const issue = response?.createIssue?.issue; + if (!issue) { + return null; + } + + console.log(`Assigned to: ${issue.assignees.nodes.map((a: any) => a.login).join(', ')}`); + 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/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..f7b9da16f --- /dev/null +++ b/.github/scripts/merge-upstream-start.sh @@ -0,0 +1,88 @@ +#!/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 (or reuse existing) ───────────────────── +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + +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…" +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" <> $GITHUB_OUTPUT fi + - 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: test-results-for-site + path: /tmp/test-results + 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: | - ./mvnw clean site -DskipTests -Dcheckstyle.skip=true + # Compile sources (needed for javadoc and other reports) + ./mvnw clean compile -DskipTests -Dcheckstyle.skip=true + + # Restore test results from Build & Test (for JaCoCo + Surefire reports) + # 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) + ./mvnw site -DskipTests -Dcheckstyle.skip=true rm -rf "site/snapshot" mkdir -p "site/snapshot" diff --git a/.github/workflows/weekly-upstream-sync.yml b/.github/workflows/weekly-upstream-sync.yml new file mode 100644 index 000000000..37af04c23 --- /dev/null +++ b/.github/workflows/weekly-upstream-sync.yml @@ -0,0 +1,165 @@ +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") + REPO="${{ github.repository }}" + + BODY="## Automated Upstream Sync + + 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}\`](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} + \`\`\` + + ### Instructions + + Follow the [agentic-merge-upstream](.github/prompts/agentic-merge-upstream.prompt.md) prompt to port these changes to the Java SDK." + + # 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"] + }') + + # 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') + + echo "Created issue #${ISSUE_NUMBER}" + echo "βœ… Issue #${ISSUE_NUMBER} 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" diff --git a/.gitignore b/.gitignore index f89c86398..a36367133 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store target examples-test/ +.merge-env diff --git a/.lastmerge b/.lastmerge index d627ab8aa..64a7ea328 100644 --- a/.lastmerge +++ b/.lastmerge @@ -1 +1 @@ -c7e0765e6eee56cc79fb18a94f719b578bf1bbb6 +05e3c46c8c23130c9c064dc43d00ec78f7a75eab diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..b195a7484 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,312 @@ +# 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). + +## [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(); +``` + +#### 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); +}); +``` + +#### EventErrorPolicy for Dispatch Control +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.SUPPRESS_AND_LOG_ERRORS); +``` + +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`. + +```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 + +### 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-02-02 + +### Added + +- 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` +- Custom tools with `ToolDefinition` and `ToolHandler` +- Event system with 30+ event types extending `AbstractSessionEvent` +- Permission handling with `PermissionHandler` +- BYOK (Bring Your Own Key) support with `ProviderConfig` +- MCP server integration via `McpServerConfig` +- System message customization with `SystemMessageConfig` +- File attachments support +- Streaming responses with delta events +- 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/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 diff --git a/README.md b/README.md index 405b44e1c..b79b39c3e 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,18 @@ [![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) +[![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) +[![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. Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build AI-powered applications and agentic workflows. @@ -15,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.406 or later installed and in PATH (or provide custom `cliPath`) ### Maven @@ -23,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 @@ -41,26 +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(); - - 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); - } + 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 ---"); + 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.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(); } } } @@ -88,6 +104,16 @@ public class Example { Contributions are welcome! Please see the [Contributing Guide](CONTRIBUTING.md) for details. +### Agentic Upstream Merge and Sync + +This SDK tracks the official [Copilot SDK](https://github.com/github/copilot-sdk) (.NET reference implementation) and ports changes to Java. The upstream merge process is automated with AI assistance: + +**Weekly automated sync** β€” A [scheduled GitHub Actions workflow](.github/workflows/weekly-upstream-sync.yml) runs every Monday at 5 AM ET. It checks for new upstream commits since the last merge (tracked in [`.lastmerge`](.lastmerge)), and if changes are found, creates an issue labeled `upstream-sync` and assigns it to the GitHub Copilot coding agent. Any previously open `upstream-sync` issues are automatically closed. + +**Reusable prompt** β€” The merge workflow is defined in [`agentic-merge-upstream.prompt.md`](.github/prompts/agentic-merge-upstream.prompt.md). It can be triggered manually from: +- **VS Code Copilot Chat** β€” type `/agentic-merge-upstream` +- **GitHub Copilot CLI** β€” use `copilot` CLI with the same skill reference + ### Development Setup ```bash @@ -107,3 +133,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 diff --git a/jbang-example.java b/jbang-example.java index 854202dab..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.*; @@ -12,29 +12,27 @@ 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(); + var session = client.createSession( + new SessionConfig().setModel("claude-sonnet-4.5")).get(); - // Wait for response using session.idle event - var done = new CompletableFuture(); + // Handle assistant message events + session.on(AssistantMessageEvent.class, msg -> { + System.out.println(msg.getData().getContent()); + }); - 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); - } + // 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 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(); } } } diff --git a/pom.xml b/pom.xml index 85826a9e3..1710a7107 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ io.github.copilot-community-sdk copilot-sdk - 1.0.7 + 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 - v1.0.7 + v1.0.8 @@ -62,6 +62,14 @@ 2.20.1 + + + com.github.spotbugs + spotbugs-annotations + 4.9.8 + provided + + org.junit.jupiter @@ -84,6 +92,14 @@ none + + com.github.spotbugs + spotbugs-maven-plugin + 4.9.8.2 + + spotbugs-exclude.xml + + @@ -426,6 +442,59 @@ + + + org.apache.maven.plugins + maven-surefire-report-plugin + 3.5.4 + + + + com.github.spotbugs + spotbugs-maven-plugin + 4.9.8.2 + + spotbugs-exclude.xml + + + + + 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 + + + + 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 @@ + + + + + + + + + + + + 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..c95d049d5 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/CliServerManager.java @@ -0,0 +1,238 @@ +/*--------------------------------------------------------------------------------------------- + * 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("--no-auto-update"); + 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 b8929d541..5f7013815 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -4,14 +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.util.ArrayList; -import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -21,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; @@ -39,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. @@ -61,15 +42,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();
@@ -78,12 +57,13 @@
  *
  * @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();
 
     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;
@@ -91,9 +71,6 @@ public 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.
@@ -128,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);
     }
 
     /**
@@ -175,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);
@@ -201,266 +168,9 @@ 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) {
-                    ToolResultObject 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)
-                        .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 {
-                        ToolResultObject 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) {
-                    PermissionRequestResult 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 {
-                        PermissionRequestResult 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;
-                }
-
-                com.github.copilot.sdk.json.UserInputRequest request = new com.github.copilot.sdk.json.UserInputRequest()
-                        .setQuestion(question);
-                if (choicesNode != null && choicesNode.isArray()) {
-                    List 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();
-        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);
 
@@ -483,7 +193,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(() -> {
@@ -554,50 +264,11 @@ private CompletableFuture cleanupConnection() {
      */
     public CompletableFuture createSession(SessionConfig config) {
         return ensureConnected().thenCompose(connection -> {
-            CreateSessionRequest 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 -> {
-                CopilotSession 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());
-                }
+                var session = new CopilotSession(response.getSessionId(), connection.rpc, response.getWorkspacePath());
+                SessionRequestBuilder.configureSession(session, config);
                 sessions.put(response.getSessionId(), session);
                 return session;
             });
@@ -631,43 +302,11 @@ public CompletableFuture createSession() {
      */
     public CompletableFuture resumeSession(String sessionId, ResumeSessionConfig config) {
         return ensureConnected().thenCompose(connection -> {
-            ResumeSessionRequest request = new ResumeSessionRequest();
-            request.setSessionId(sessionId);
-            if (config != null) {
-                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.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.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());
-            }
+            var request = SessionRequestBuilder.buildResumeRequest(sessionId, config);
 
             return connection.rpc.invoke("session.resume", request, ResumeSessionResponse.class).thenApply(response -> {
-                CopilotSession 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());
-                }
+                var session = new CopilotSession(response.getSessionId(), connection.rpc, response.getWorkspacePath());
+                SessionRequestBuilder.configureSession(session, config);
                 sessions.put(response.getSessionId(), session);
                 return session;
             });
@@ -883,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);
     }
 
     /**
@@ -905,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() {
@@ -957,151 +550,6 @@ private CompletableFuture ensureConnected() {
         return connectionFuture;
     }
 
-    private ProcessInfo startCliServer() throws IOException, InterruptedException {
-        String cliPath = options.getCliPath() != null ? options.getCliPath() : "copilot";
-        List 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);
-
-        ProcessBuilder 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
-        Thread stderrThread = new Thread(() -> {
-            try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
-                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
-            BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
-            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) {
-            List 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()) {
-            List result = new ArrayList<>();
-            result.add("cmd");
-            result.add("/c");
-            result.add(cliPath);
-            result.addAll(args);
-            return result;
-        }
-
-        List 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)
@@ -1114,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/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java
index 79a3f76a4..0a81cd719 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;
@@ -60,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
@@ -95,6 +95,11 @@ 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;
+    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.
@@ -152,6 +157,93 @@ 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 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 + * {@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: + * + *

{@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 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; + } + + /** + * Sets the error propagation policy for event dispatch. + *

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

    + *
  • {@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
  • + *
+ * + *

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

+ * Example: + * + *

{@code
+     * // 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#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"); + } + this.eventErrorPolicy = policy; + } + /** * Sends a simple text message to the Copilot session. *

@@ -161,9 +253,12 @@ public String getWorkspacePath() { * @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)); } @@ -177,9 +272,12 @@ 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)); } @@ -192,11 +290,14 @@ 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) { - SendMessageRequest request = new SendMessageRequest(); + ensureNotTerminated(); + var request = new SendMessageRequest(); request.setSessionId(sessionId); request.setPrompt(options.getPrompt()); request.setAttachments(options.getAttachments()); @@ -221,12 +322,15 @@ 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) { - CompletableFuture future = new CompletableFuture<>(); - AtomicReference lastAssistantMessage = new AtomicReference<>(); + ensureNotTerminated(); + var future = new CompletableFuture(); + var lastAssistantMessage = new AtomicReference(); Consumer handler = evt -> { if (evt instanceof AssistantMessageEvent msg) { @@ -252,8 +356,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; }); @@ -282,38 +386,49 @@ 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); } /** - * Registers a callback for session events. + * Registers a callback for all session events. + *

+ * 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. + * *

- * The handler will be invoked for all events in this session, including - * assistant messages, tool calls, and session state changes. + * 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: * *

{@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 + * @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); } @@ -326,6 +441,12 @@ 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). Whether + * remaining handlers execute depends on the configured + * {@link EventErrorPolicy}. + * + *

* Example Usage *

* @@ -353,10 +474,13 @@ 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)); @@ -369,17 +493,44 @@ 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. Errors are always logged at + * {@link Level#WARNING}. Whether dispatch continues after a handler error + * depends on the configured {@link EventErrorPolicy}: + *

    + *
  • {@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. 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) { try { handler.accept(event); } catch (Exception e) { - LOG.log(Level.SEVERE, "Error in event handler", e); + LOG.log(Level.WARNING, "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); + break; // error handler itself failed β€” stop regardless of policy + } + } + if (eventErrorPolicy == EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS) { + break; + } } } } @@ -444,7 +595,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 +640,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 +680,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) { @@ -587,12 +738,15 @@ 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 -> { - List events = new ArrayList<>(); + var events = new ArrayList(); if (response.getEvents() != null) { for (JsonNode eventNode : response.getEvents()) { try { @@ -616,20 +770,42 @@ 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/main/java/com/github/copilot/sdk/EventErrorHandler.java b/src/main/java/com/github/copilot/sdk/EventErrorHandler.java new file mode 100644 index 000000000..8cab08ae1 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/EventErrorHandler.java @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * 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. + * + *

+ * 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) -> {
+ * 	metrics.increment("handler.errors");
+ * 	logger.error("Handler failed on {}: {}", event.getType(), exception.getMessage());
+ * });
+ * }
+ * + *

+ * 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 +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/main/java/com/github/copilot/sdk/EventErrorPolicy.java b/src/main/java/com/github/copilot/sdk/EventErrorPolicy.java new file mode 100644 index 000000000..b7c3dca21 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/EventErrorPolicy.java @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * 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. 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 has been logged and 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: propagate errors (stop dispatch on first error, log the error)
+ * session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS);
+ *
+ * // Opt-in to suppress errors (continue dispatching, log each error)
+ * session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS);
+ * }
+ * + * @see CopilotSession#setEventErrorPolicy(EventErrorPolicy) + * @see EventErrorHandler + * @since 1.0.8 + */ +public enum EventErrorPolicy { + + /** + * Suppress errors: log the error and continue dispatching to remaining + * listeners. + *

+ * 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_AND_LOG_ERRORS, + + /** + * Propagate errors: log the error and stop dispatch on first listener error + * (default). + *

+ * 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_AND_LOG_ERRORS +} diff --git a/src/main/java/com/github/copilot/sdk/JsonRpcClient.java b/src/main/java/com/github/copilot/sdk/JsonRpcClient.java index ff4e10e1b..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; @@ -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); } } 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()); + } + } +} 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/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; + } } } 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/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/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/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); } /** 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/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java b/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java index 4e229d5a2..8b7d841f8 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; @@ -31,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. @@ -51,7 +81,7 @@ public class ResumeSessionConfig { * @return the list of tool definitions */ public List getTools() { - return tools; + return tools == null ? null : Collections.unmodifiableList(tools); } /** @@ -67,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. * @@ -199,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. * @@ -249,7 +374,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 +395,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 +417,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 +438,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); } /** @@ -327,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 9d38e702e..86effad8f 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; @@ -28,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; @@ -49,6 +62,9 @@ public final class ResumeSessionRequest { @JsonProperty("workingDirectory") private String workingDirectory; + @JsonProperty("configDir") + private String configDir; + @JsonProperty("disableResume") private Boolean disableResume; @@ -67,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; @@ -77,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; @@ -91,7 +120,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 */ @@ -99,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; @@ -149,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; @@ -171,7 +243,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 +253,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 +263,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,11 +273,24 @@ 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 */ 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; + } } 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); } /** 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. * 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 83204519d..fc0878159 100644 --- a/src/site/markdown/advanced.md +++ b/src/site/markdown/advanced.md @@ -4,6 +4,36 @@ 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) + - [Event Error Policy](#Event_Error_Policy) + --- ## Custom Tools @@ -99,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() @@ -109,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 @@ -127,7 +191,7 @@ var session = client.createSession( ).get(); // Access the workspace where session state is persisted -String workspace = session.getWorkspacePath(); +var workspace = session.getWorkspacePath(); ``` ### Compaction Events @@ -135,14 +199,13 @@ String 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()); }); ``` @@ -247,7 +310,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 +319,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 +408,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 +426,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 +448,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); } @@ -420,3 +483,92 @@ session.send(new MessageOptions().setPrompt("Hello")) return null; }); ``` + +### Event Handler Exceptions + +If an event handler registered via `session.on()` throws an exception, the SDK +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 +// 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"); +}); + +session.on(AssistantMessageEvent.class, msg -> { + // This handler executes normally despite the exception above + System.out.println(msg.getData().getContent()); +}); +``` + +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` for additional handling beyond the default +logging β€” such as metrics, alerts, or integration with external +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 caught and logged at `SEVERE`, and dispatch is stopped to prevent cascading +failures. + +Pass `null` to use only the default logging behavior: + +```java +session.setEventErrorHandler(null); +``` + +### Event Error Policy + +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.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. Errors are always logged at `WARNING` level. + +| Policy | Behavior | +|---|---| +| `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 via custom handler and suppress (continue dispatching) +session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS); +session.setEventErrorHandler((event, ex) -> + logger.error("Handler failed, continuing: {}", ex.getMessage(), ex)); +``` + +Or switch policies dynamically: + +```java +// Start strict (propagate errors, stop dispatch) +session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS); + +// 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/site/markdown/documentation.md b/src/site/markdown/documentation.md index 21706f139..b1b25be73 100644 --- a/src/site/markdown/documentation.md +++ b/src/site/markdown/documentation.md @@ -57,6 +57,12 @@ 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. You can customize error handling with +> `session.setEventErrorHandler()` β€” see the +> [Advanced Usage](advanced.html#Custom_Event_Error_Handler) guide. + ```java var done = new CompletableFuture(); @@ -194,14 +200,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(); @@ -217,9 +223,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 +293,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,15 +335,49 @@ 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(); ``` +### 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 diff --git a/src/site/markdown/getting-started.md b/src/site/markdown/getting-started.md index b78356afb..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( @@ -179,13 +178,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, @@ -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( @@ -258,11 +256,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", @@ -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/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/index.md b/src/site/markdown/index.md index fcbf78a0b..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.401 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 @@ -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(); } 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")); 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/ClosedSessionGuardTest.java b/src/test/java/com/github/copilot/sdk/ClosedSessionGuardTest.java new file mode 100644 index 000000000..f4606c646 --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/ClosedSessionGuardTest.java @@ -0,0 +1,339 @@ +/*--------------------------------------------------------------------------------------------- + * 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.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; +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"); + }); + assertTrue(thrown.getMessage().contains("closed"), "Exception message should mention session is 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")); + }); + assertTrue(thrown.getMessage().contains("closed"), "Exception message should mention session is 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"); + }); + assertTrue(thrown.getMessage().contains("closed"), "Exception message should mention session is 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")); + }); + assertTrue(thrown.getMessage().contains("closed"), "Exception message should mention session is 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); + }); + assertTrue(thrown.getMessage().contains("closed"), "Exception message should mention session is 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 + }); + }); + assertTrue(thrown.getMessage().contains("closed"), "Exception message should mention session is 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 + }); + }); + assertTrue(thrown.getMessage().contains("closed"), "Exception message should mention session is 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(); + }); + assertTrue(thrown.getMessage().contains("closed"), "Exception message should mention session is 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(); + }); + assertTrue(thrown.getMessage().contains("closed"), "Exception message should mention session is 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 + }); + }); + assertTrue(thrown.getMessage().contains("closed"), "Exception message should mention session is 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); + }); + assertTrue(thrown.getMessage().contains("closed"), "Exception message should mention session is 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(); + assertEquals(sessionIdBeforeClose, sessionIdAfterClose, "Session ID should remain accessible after close"); + } + } + + /** + * 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(); + assertEquals(pathBeforeClose, pathAfterClose, "Workspace path should remain accessible after close"); + } + } + + /** + * 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"); + }); + } + } +} 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..781fb9a9a 100644 --- a/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java +++ b/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java @@ -79,13 +79,17 @@ 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 + 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); } } } @@ -178,9 +182,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 +228,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 +395,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 +431,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..3743bf275 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java @@ -10,6 +10,9 @@ 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; @@ -49,7 +52,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 +69,7 @@ void testGenericEventHandler() { @Test void testTypedEventHandler() { - List receivedMessages = new ArrayList<>(); + var receivedMessages = new ArrayList(); session.on(AssistantMessageEvent.class, msg -> receivedMessages.add(msg)); @@ -84,9 +87,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 +107,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 +128,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 +147,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 +167,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 +191,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 @@ -197,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"); @@ -228,7 +234,610 @@ 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 + // ==================================================================== + // EventErrorHandler tests + // ==================================================================== + + @Test + void testDefaultPolicyPropagatesAndLogs() { + // Default policy is PROPAGATE_AND_LOG_ERRORS β€” stops dispatch on first error + 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 { + // Both handlers throw β€” with PROPAGATE only one should execute + session.on(AssistantMessageEvent.class, msg -> { + handler1Called.incrementAndGet(); + throw new RuntimeException("boom 1"); + }); + + session.on(AssistantMessageEvent.class, msg -> { + handler2Called.incrementAndGet(); + throw new RuntimeException("boom 2"); + }); + + assertDoesNotThrow(() -> dispatchEvent(createAssistantMessageEvent("Test"))); + + // 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 + void testCustomEventErrorHandlerReceivesEventAndException() { + var capturedEvents = new ArrayList(); + var capturedExceptions = new ArrayList(); + + Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); + Level originalLevel = sessionLogger.getLevel(); + sessionLogger.setLevel(Level.OFF); + + 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); + + 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); + + Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); + Level originalLevel = sessionLogger.getLevel(); + sessionLogger.setLevel(Level.OFF); + + 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")); + + // Both handler errors should be reported to the custom error handler + assertEquals(2, errorCount.get()); + } finally { + sessionLogger.setLevel(originalLevel); + } + } + + @Test + void testErrorHandlerItselfThrowingStopsDispatch() { + 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 { + session.setEventErrorHandler((event, exception) -> { + throw new RuntimeException("error handler also broke"); + }); + + // Two handlers that throw + session.on(AssistantMessageEvent.class, msg -> { + handler1Called.incrementAndGet(); + throw new RuntimeException("handler error"); + }); + + session.on(AssistantMessageEvent.class, msg -> { + handler2Called.incrementAndGet(); + throw new RuntimeException("handler error"); + }); + + 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); + } + } + + @Test + void testSetEventErrorHandlerToNullRestoresDefaultBehavior() { + var errorCount = new AtomicInteger(0); + + Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); + Level originalLevel = sessionLogger.getLevel(); + sessionLogger.setLevel(Level.OFF); + + try { + // 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-only behavior) + session.setEventErrorHandler(null); + + 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(); + + Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); + Level originalLevel = sessionLogger.getLevel(); + sessionLogger.setLevel(Level.OFF); + + try { + session.setEventErrorPolicy(EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS); + 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)); + } finally { + sessionLogger.setLevel(originalLevel); + } + } + + // ==================================================================== + // EventErrorPolicy tests + // ==================================================================== + + @Test + void testDefaultPolicyPropagatesOnError() { + 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 { + session.setEventErrorHandler((event, exception) -> { + // just consume + }); + + // 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 -> { + handler2Called.incrementAndGet(); + throw new RuntimeException("error 2"); + }); + + dispatchEvent(createAssistantMessageEvent("Test")); + + // 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 + void testPropagatePolicyStopsOnFirstError() { + var handler1Called = new AtomicInteger(0); + var handler2Called = new AtomicInteger(0); + var errorHandlerCalls = new AtomicInteger(0); + + Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); + Level originalLevel = sessionLogger.getLevel(); + sessionLogger.setLevel(Level.OFF); + + try { + session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS); + 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 (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); + + Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); + Level originalLevel = sessionLogger.getLevel(); + sessionLogger.setLevel(Level.OFF); + + try { + session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS); + 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 PROPAGATE_AND_LOG_ERRORS policy + assertEquals(1, errorHandlerCalls.get()); + } finally { + sessionLogger.setLevel(originalLevel); + } + } + + @Test + void testSuppressPolicyWithMultipleErrors() { + var errorHandlerCalls = new AtomicInteger(0); + var successfulHandlerCalls = new AtomicInteger(0); + + Logger sessionLogger = Logger.getLogger(CopilotSession.class.getName()); + Level originalLevel = sessionLogger.getLevel(); + sessionLogger.setLevel(Level.OFF); + + 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")); + + // All errors should be reported, successful handler should run + assertEquals(3, errorHandlerCalls.get()); + assertEquals(1, successfulHandlerCalls.get()); + } finally { + sessionLogger.setLevel(originalLevel); + } + } + + @Test + void testSwitchPolicyDynamically() { + 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 { + 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 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()); + + 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 testPropagatePolicyNoErrorHandlerStopsAndLogs() { + 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 { + // No error handler set, PROPAGATE_AND_LOG_ERRORS policy + session.setEventErrorPolicy(EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS); + + 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"))); + + // 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 + 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 { + // 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"); + }); + + 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 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_AND_LOG_ERRORS policy"); + } finally { + sessionLogger.setLevel(originalLevel); + } + } + + // ==================================================================== + // Helper methods + // ==================================================================== + private void dispatchEvent(AbstractSessionEvent event) { try { Method dispatchMethod = CopilotSession.class.getDeclaredMethod("dispatchEvent", AbstractSessionEvent.class); @@ -241,16 +850,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);