diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..6912dbc77 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* edburns@github.com diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 000000000..9e5a9238f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,41 @@ +name: Bug +description: File a bug report +title: "[BUG]: " +labels: ["Type: Bug", "Status: Triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you do? What happened? What did you expect to happen? + placeholder: Put your description of the bug here. + validations: + required: true + - type: textarea + id: versions + attributes: + label: Versions + description: What versions of the relevant software are you running? + placeholder: copilot-sdk-java v1.0.0, Java 17.0.10, Maven 3.9.6, Copilot CLI v1.0.0 + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant log output + description: | + Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + Please check your logs before submission to ensure sensitive information is redacted. + render: shell + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this issue, you agree to follow our [Code of Conduct](CODE_OF_CONDUCT.md) + options: + - label: I agree to follow this project's Code of Conduct + required: true diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml new file mode 100644 index 000000000..7f049a2ed --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -0,0 +1,41 @@ +name: Documentation +description: Update or add documentation +title: "[DOCS]: " +labels: ["Type: Documentation", "Status: Triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill this out! + - type: textarea + id: describe-need + attributes: + label: Describe the need + description: What do you wish was different about our docs? + placeholder: Describe the need for documentation updates here. + validations: + required: true + - type: input + id: sdk_version + attributes: + label: SDK Version + description: Do these docs apply to a specific SDK version? + placeholder: copilot-sdk-java v1.0.0 + validations: + required: false + - type: textarea + id: logs + attributes: + label: Relevant log output + description: | + Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + Please check your logs before submission to ensure sensitive information is redacted. + render: shell + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this issue, you agree to follow our [Code of Conduct](CODE_OF_CONDUCT.md) + options: + - label: I agree to follow this project's Code of Conduct + required: true diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 000000000..377cd12ab --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,41 @@ +name: Feature +description: Suggest an idea for a new feature or enhancement +title: "[FEAT]: " +labels: ["Type: Feature", "Status: Triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill this out! + - type: textarea + id: describe-need + attributes: + label: Describe the need + description: What do you want to happen? What problem are you trying to solve? + placeholder: Describe the need for the feature. + validations: + required: true + - type: input + id: sdk_version + attributes: + label: SDK Version + description: Does this feature suggestion apply to a specific SDK version? + placeholder: copilot-sdk-java v1.0.0 + validations: + required: false + - type: textarea + id: logs + attributes: + label: Relevant log output + description: | + Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + Please check your logs before submission to ensure sensitive information is redacted. + render: shell + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this issue, you agree to follow our [Code of Conduct](CODE_OF_CONDUCT.md) + options: + - label: I agree to follow this project's Code of Conduct + required: true diff --git a/.github/ISSUE_TEMPLATE/maintenance.yml b/.github/ISSUE_TEMPLATE/maintenance.yml new file mode 100644 index 000000000..de21996db --- /dev/null +++ b/.github/ISSUE_TEMPLATE/maintenance.yml @@ -0,0 +1,41 @@ +name: Maintenance +description: Dependencies, cleanup, refactoring, reworking of code +title: "[MAINT]: " +labels: ["Type: Maintenance", "Status: Triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill this out! + - type: textarea + id: describe-need + attributes: + label: Describe the need + description: What do you want to happen? + placeholder: Describe the maintenance need here. + validations: + required: true + - type: input + id: sdk_version + attributes: + label: SDK Version + description: Does this maintenance apply to a specific SDK version? + placeholder: copilot-sdk-java v1.0.0 + validations: + required: false + - type: textarea + id: logs + attributes: + label: Relevant log output + description: | + Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + Please check your logs before submission to ensure sensitive information is redacted. + render: shell + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this issue, you agree to follow our [Code of Conduct](CODE_OF_CONDUCT.md) + options: + - label: I agree to follow this project's Code of Conduct + required: true diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index 36881a0f5..d81867b96 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -14,6 +14,11 @@ "repo": "actions/setup-node", "version": "v4", "sha": "49933ea5288caeca8642d1e84afbd3f7d6820020" + }, + "github/gh-aw/actions/setup@v0.51.6": { + "repo": "github/gh-aw/actions/setup", + "version": "v0.51.6", + "sha": "33cd6c7f1fee588654ef19def2e6a4174be66197" } } } diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 388fa4643..8ef59f460 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -6,13 +6,13 @@ - + coverage coverage - 91% - 91% + 87.5% + 87.5% diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bc28a29fb..7112a3f51 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -124,7 +124,7 @@ session.createSession(new SessionConfig() Sessions emit typed events via `session.on()`: ```java -session.on(AssistantMessageEvent.class, msg -> System.out.println(msg.getData().getContent())); +session.on(AssistantMessageEvent.class, msg -> System.out.println(msg.getData().content())); session.on(SessionIdleEvent.class, idle -> done.complete(null)); ``` diff --git a/.github/prompts/agentic-merge-upstream.prompt.md b/.github/prompts/agentic-merge-upstream.prompt.md index 8db8e8446..c1d2c54a0 100644 --- a/.github/prompts/agentic-merge-upstream.prompt.md +++ b/.github/prompts/agentic-merge-upstream.prompt.md @@ -31,15 +31,18 @@ All scripts write/read a `.merge-env` file (git-ignored) to share state (branch 1. Run `./.github/scripts/upstream-sync/merge-upstream-start.sh` (creates branch, clones upstream, shows summary) 2. Run `./.github/scripts/upstream-sync/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/build/format-and-test.sh` frequently while porting -6. Update documentation -7. Run `./.github/scripts/upstream-sync/merge-upstream-finish.sh` (final test + push) -8. Finalize Pull Request (see note below about coding agent vs. manual workflow) +4. Identify upstream changes to port +5. Apply changes to Java SDK (commit as you go) +6. Port/adjust tests from upstream changes +7. Run `./.github/scripts/build/format-and-test.sh` frequently while porting +8. Build the package +9. Update documentation (**required for every user-facing upstream change**) +10. Run `./.github/scripts/upstream-sync/merge-upstream-finish.sh` (final test + push) and finalize Pull Request (see note below about coding agent vs. manual workflow) +11. Perform final review before handing off --- -## Steps 1-2: Initialize and Analyze +## Step 1: Initialize Upstream Sync Run the start script to create a branch, update the CLI, clone the upstream repo, and see a summary of new commits: @@ -53,7 +56,9 @@ This writes a `.merge-env` file used by the other scripts. It outputs: - The upstream dir path - A short log of upstream commits since `.lastmerge` -Then run the diff script for a detailed breakdown by area: +## Step 2: Analyze Upstream Changes + +Run the diff script for a detailed breakdown by area: ```bash ./.github/scripts/upstream-sync/merge-upstream-diff.sh # stat only @@ -104,7 +109,7 @@ For each change in the upstream diff, determine: > **⚠️ Important:** When adding new documentation pages, always update `src/site/site.xml` to include them in the navigation menu. -## Step 7: Apply Changes to Java SDK +## Step 5: Apply Changes to Java SDK When porting changes: @@ -168,7 +173,7 @@ Follow the existing Java SDK patterns: - **Match the style of surrounding code** - Consistency with existing code is more important than upstream patterns - **Prefer existing abstractions** - If the Java SDK already solves a problem differently than .NET, keep the Java approach -## Step 7.5: Port Tests +## Step 6: Port Tests After porting implementation changes, **always check for new or updated tests** in the upstream repository: @@ -215,7 +220,7 @@ If tests fail with errors like `TypeError: Cannot read properties of undefined`, Commit tests separately or together with their corresponding implementation changes. -## Step 8: Format and Run Tests +## Step 7: Format and Run Tests After applying changes, use the convenience script: @@ -246,7 +251,7 @@ Or for quicker iteration during porting: - **Null handling**: Add null checks where C# had nullable types - **JSON serialization**: Verify Jackson annotations are correct -## Step 9: Build the Package +## Step 8: Build the Package Once tests pass, build the complete package: @@ -259,9 +264,11 @@ Verify: - No warnings (if possible) - JAR file is generated in `target/` -## Step 10: Update Documentation +## Step 9: Update Documentation **Documentation is critical for new features.** Every new feature ported from upstream must be documented before the merge is complete. +Review and complete this documentation checklist before proceeding to Step 10. +If you determine no docs changes are needed, document that decision and rationale in the PR body under a clear heading (for example, `Documentation Impact`). ### Documentation Checklist @@ -316,7 +323,7 @@ Ensure consistency across all documentation files: - Code examples should use the same patterns and be tested - Links to Javadoc should use correct paths (`apidocs/...`) -## Steps 11-12: Finish, Push, and Finalize Pull Request +## Step 10: Finish, Push, and Finalize Pull Request Run the finish script which updates `.lastmerge`, runs a final build, and pushes the branch: @@ -378,7 +385,7 @@ Ports changes from the official Copilot SDK ([github/copilot-sdk](https://github - Code formatted with Spotless ``` -## Step 13: Final Review +## Step 11: Final Review Before finishing: @@ -411,6 +418,7 @@ Before finishing: - [ ] `src/site/markdown/documentation.md` updated for new basic usage - [ ] `src/site/markdown/advanced.md` updated for new advanced features - [ ] Javadoc added/updated for new public APIs +- [ ] If no documentation files were changed for user-facing upstream changes, PR body explicitly explains why documentation changes were not needed - [ ] `src/site/site.xml` updated if new documentation pages were added - [ ] `.lastmerge` file updated with new commit hash - [ ] Branch pushed to remote @@ -429,4 +437,3 @@ Before finishing: - Uses JUnit 5 for testing - **Java SDK design decisions take precedence over upstream patterns** - **Adapt upstream changes to fit Java idioms, not the other way around** - diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..8993ca146 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,29 @@ + + +Resolves #ISSUE_NUMBER + +---- + +### Before the change? + + +* + +### After the change? + + +* + +### Pull request checklist +- [ ] Tests for the changes have been added (for bug fixes / features) +- [ ] Docs have been reviewed and added / updated if needed (for bug fixes / features) +- [ ] `mvn spotless:apply` has been run to format the code +- [ ] `mvn clean verify` passes locally + +### Does this introduce a breaking change? + + +- [ ] Yes +- [ ] No + +---- diff --git a/.github/scripts/build/format-and-test.sh b/.github/scripts/build/format-and-test.sh index 6de3972f3..c827813bb 100755 --- a/.github/scripts/build/format-and-test.sh +++ b/.github/scripts/build/format-and-test.sh @@ -13,7 +13,7 @@ # ────────────────────────────────────────────────────────────── set -euo pipefail -ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +ROOT_DIR="$(cd "$(dirname "$0")/../../.." && pwd)" cd "$ROOT_DIR" FORMAT=true diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml new file mode 100644 index 000000000..bb7a31d5d --- /dev/null +++ b/.github/workflows/agentics-maintenance.yml @@ -0,0 +1,82 @@ +# +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# This file was automatically generated by pkg/workflow/maintenance_workflow.go (v0.51.6). DO NOT EDIT. +# +# To regenerate this workflow, run: +# gh aw compile +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# Alternative regeneration methods: +# make recompile +# +# Or use the gh-aw CLI directly: +# ./gh-aw compile --validate --verbose +# +# The workflow is generated when any workflow uses the 'expires' field +# in create-discussions, create-issues, or create-pull-request safe-outputs configuration. +# Schedule frequency is automatically determined by the shortest expiration time. +# +name: Agentic Maintenance + +on: + schedule: + - cron: "37 0 * * *" # Daily (based on minimum expires: 6 days) + workflow_dispatch: + +permissions: {} + +jobs: + close-expired-entities: + if: ${{ !github.event.repository.fork }} + runs-on: ubuntu-slim + permissions: + discussions: write + issues: write + pull-requests: write + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@33cd6c7f1fee588654ef19def2e6a4174be66197 # v0.51.6 + with: + destination: /opt/gh-aw/actions + + - name: Close expired discussions + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/close_expired_discussions.cjs'); + await main(); + + - name: Close expired issues + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/close_expired_issues.cjs'); + await main(); + + - name: Close expired pull requests + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/close_expired_pull_requests.cjs'); + await main(); diff --git a/.github/workflows/publish-maven.yml b/.github/workflows/publish-maven.yml index ea5ee4975..5596cbd11 100644 --- a/.github/workflows/publish-maven.yml +++ b/.github/workflows/publish-maven.yml @@ -59,8 +59,8 @@ jobs: server-id: central server-username: MAVEN_USERNAME server-password: MAVEN_PASSWORD - gpg-private-key: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} - gpg-passphrase: MAVEN_GPG_PASSPHRASE + gpg-private-key: ${{ secrets.GPG_SECRET_KEY }} + gpg-passphrase: GPG_PASSPHRASE - name: Determine versions id: versions @@ -141,7 +141,7 @@ jobs: env: MAVEN_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} MAVEN_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} - name: Perform Release and Deploy to Maven Central run: | @@ -151,7 +151,7 @@ jobs: env: MAVEN_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} MAVEN_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} - name: Rollback documentation commit on failure if: failure() && steps.update-docs.outputs.docs_commit_sha != '' @@ -177,16 +177,28 @@ jobs: VERSION="${{ needs.publish-maven.outputs.version }}" GROUP_ID="io.github.copilot-community-sdk" ARTIFACT_ID="copilot-sdk" + CURRENT_TAG="v${VERSION}" + + if gh release view "${CURRENT_TAG}" >/dev/null 2>&1; then + echo "Release ${CURRENT_TAG} already exists. Skipping creation." + exit 0 + fi # Generate release notes from template export VERSION GROUP_ID ARTIFACT_ID RELEASE_NOTES=$(envsubst < .github/workflows/notes.template) # Get the previous tag for generating notes - PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + PREV_TAG=$(git tag --list 'v*' --sort=-version:refname \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \ + | grep -Fxv "${CURRENT_TAG}" \ + | head -n 1) + + echo "Current tag: ${CURRENT_TAG}" + echo "Previous tag: ${PREV_TAG}" # Build the gh release command - GH_ARGS=("v${VERSION}") + GH_ARGS=("${CURRENT_TAG}") GH_ARGS+=("--title" "Copilot Java SDK ${VERSION}") GH_ARGS+=("--notes" "${RELEASE_NOTES}") GH_ARGS+=("--generate-notes") diff --git a/.github/workflows/weekly-upstream-sync.lock.yml b/.github/workflows/weekly-upstream-sync.lock.yml index 163600e1e..cfd1342fa 100644 --- a/.github/workflows/weekly-upstream-sync.lock.yml +++ b/.github/workflows/weekly-upstream-sync.lock.yml @@ -13,16 +13,18 @@ # \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ # \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ # -# This file was automatically generated by gh-aw (v0.43.2). DO NOT EDIT. +# This file was automatically generated by gh-aw (v0.51.6). DO NOT EDIT. # # To update this file, edit the corresponding .md file and run: # gh aw compile -# For more information: https://github.com/github/gh-aw/blob/main/.github/aw/github-agentic-workflows.md +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ # # Weekly upstream sync workflow. Checks for new commits in the official # Copilot SDK (github/copilot-sdk) and assigns to Copilot to port changes. # -# frontmatter-hash: 27b0f4284357c75008ce6e8d1d772fe9871a3548be6a6a4996125b6ca980cc2e +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"fc14b09206c7aeafcd52c843adce996a1c14cf15875f9b647ef71f631b3b296e","compiler_version":"v0.51.6"} name: "Weekly Upstream Sync Agentic Workflow" "on": @@ -46,11 +48,50 @@ jobs: outputs: comment_id: "" comment_repo: "" + model: ${{ steps.generate_aw_info.outputs.model }} + secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@v0.43.2 + uses: github/gh-aw/actions/setup@33cd6c7f1fee588654ef19def2e6a4174be66197 # v0.51.6 with: destination: /opt/gh-aw/actions + - name: Generate agentic run info + id: generate_aw_info + env: + GH_AW_INFO_ENGINE_ID: "copilot" + GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" + GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} + GH_AW_INFO_VERSION: "" + GH_AW_INFO_AGENT_VERSION: "0.0.420" + GH_AW_INFO_CLI_VERSION: "v0.51.6" + GH_AW_INFO_WORKFLOW_NAME: "Weekly Upstream Sync Agentic Workflow" + GH_AW_INFO_EXPERIMENTAL: "false" + GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" + GH_AW_INFO_STAGED: "false" + GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","github"]' + GH_AW_INFO_FIREWALL_ENABLED: "true" + GH_AW_INFO_AWF_VERSION: "v0.23.0" + GH_AW_INFO_AWMG_VERSION: "" + GH_AW_INFO_FIREWALL_TYPE: "squid" + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { main } = require('/opt/gh-aw/actions/generate_aw_info.cjs'); + await main(core, context); + - name: Validate COPILOT_GITHUB_TOKEN secret + id: validate-secret + run: /opt/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + - name: Checkout .github and .agents folders + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + .github + .agents + sparse-checkout-cone-mode: true + fetch-depth: 1 + persist-credentials: false - name: Check workflow file timestamps uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: @@ -61,6 +102,128 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs'); await main(); + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + run: | + bash /opt/gh-aw/actions/create_prompt_first.sh + { + cat << 'GH_AW_PROMPT_EOF' + + GH_AW_PROMPT_EOF + cat "/opt/gh-aw/prompts/xpia.md" + cat "/opt/gh-aw/prompts/temp_folder_prompt.md" + cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" + cat << 'GH_AW_PROMPT_EOF' + + Tools: add_comment, create_issue, close_issue, assign_to_agent, missing_tool, missing_data, noop + + + The following GitHub context information is available for this workflow: + {{#if __GH_AW_GITHUB_ACTOR__ }} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if __GH_AW_GITHUB_REPOSITORY__ }} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if __GH_AW_GITHUB_WORKSPACE__ }} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} + - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} + - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} + - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} + - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ + {{/if}} + {{#if __GH_AW_GITHUB_RUN_ID__ }} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' + {{#runtime-import .github/workflows/weekly-upstream-sync.md}} + GH_AW_PROMPT_EOF + } > "$GH_AW_PROMPT" + - name: Interpolate variables and render templates + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Substitute placeholders + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + + const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE + } + }); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/print_prompt_summary.sh + - name: Upload activation artifact + if: success() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: activation + path: | + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/aw-prompts/prompt.txt + retention-days: 1 agent: needs: activation @@ -80,25 +243,23 @@ jobs: GH_AW_SAFE_OUTPUTS: /opt/gh-aw/safeoutputs/outputs.jsonl GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json + GH_AW_WORKFLOW_ID_SANITIZED: weeklyupstreamsync outputs: checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} + detection_success: ${{ steps.detection_conclusion.outputs.success }} has_patch: ${{ steps.collect_output.outputs.has_patch }} - model: ${{ steps.generate_aw_info.outputs.model }} + model: ${{ needs.activation.outputs.model }} output: ${{ steps.collect_output.outputs.output }} output_types: ${{ steps.collect_output.outputs.output_types }} - secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@v0.43.2 + uses: github/gh-aw/actions/setup@33cd6c7f1fee588654ef19def2e6a4174be66197 # v0.51.6 with: destination: /opt/gh-aw/actions - - name: Checkout .github and .agents folders + - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - sparse-checkout: | - .github - .agents - fetch-depth: 1 persist-credentials: false - name: Create gh-aw temp directory run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh @@ -109,6 +270,7 @@ jobs: run: | git config --global user.email "github-actions[bot]@users.noreply.github.com" git config --global user.name "github-actions[bot]" + git config --global am.keepcr true # Re-authenticate git with GitHub token SERVER_URL_STRIPPED="${SERVER_URL#https://}" git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" @@ -116,7 +278,7 @@ jobs: - name: Checkout PR branch id: checkout-pr if: | - github.event.pull_request + (github.event.pull_request) || (github.event.issue.pull_request) uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} @@ -127,81 +289,31 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - - name: Generate agentic run info - id: generate_aw_info - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const fs = require('fs'); - - const awInfo = { - engine_id: "copilot", - engine_name: "GitHub Copilot CLI", - model: process.env.GH_AW_MODEL_AGENT_COPILOT || "", - version: "", - agent_version: "0.0.405", - cli_version: "v0.43.2", - workflow_name: "Weekly Upstream Sync Agentic Workflow", - experimental: false, - supports_tools_allowlist: true, - supports_http_transport: true, - run_id: context.runId, - run_number: context.runNumber, - run_attempt: process.env.GITHUB_RUN_ATTEMPT, - repository: context.repo.owner + '/' + context.repo.repo, - ref: context.ref, - sha: context.sha, - actor: context.actor, - event_name: context.eventName, - staged: false, - allowed_domains: ["defaults","github"], - firewall_enabled: true, - awf_version: "v0.13.12", - awmg_version: "", - steps: { - firewall: "squid" - }, - created_at: new Date().toISOString() - }; - - // Write to /tmp/gh-aw directory to avoid inclusion in PR - const tmpPath = '/tmp/gh-aw/aw_info.json'; - fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); - console.log('Generated aw_info.json at:', tmpPath); - console.log(JSON.stringify(awInfo, null, 2)); - - // Set model as output for reuse in other steps/jobs - core.setOutput('model', awInfo.model); - - name: Validate COPILOT_GITHUB_TOKEN secret - id: validate-secret - run: /opt/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default - env: - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - name: Install GitHub Copilot CLI - run: /opt/gh-aw/actions/install_copilot_cli.sh 0.0.405 + run: /opt/gh-aw/actions/install_copilot_cli.sh 0.0.420 - name: Install awf binary - run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.13.12 - - name: Determine automatic lockdown mode for GitHub MCP server + run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.23.0 + - name: Determine automatic lockdown mode for GitHub MCP Server id: determine-automatic-lockdown - env: - TOKEN_CHECK: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - if: env.TOKEN_CHECK != '' uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} with: script: | const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); await determineAutomaticLockdown(github, context, core); - name: Download container images - run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.13.12 ghcr.io/github/gh-aw-firewall/squid:0.13.12 ghcr.io/github/gh-aw-mcpg:v0.0.113 ghcr.io/github/github-mcp-server:v0.30.3 node:lts-alpine + run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.23.0 ghcr.io/github/gh-aw-firewall/api-proxy:0.23.0 ghcr.io/github/gh-aw-firewall/squid:0.23.0 ghcr.io/github/gh-aw-mcpg:v0.1.6 ghcr.io/github/github-mcp-server:v0.31.0 node:lts-alpine - name: Write Safe Outputs Config run: | mkdir -p /opt/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > /opt/gh-aw/safeoutputs/config.json << 'EOF' - {"add_comment":{"max":10,"target":"*"},"assign_to_agent":{"default_agent":"copilot","target":"*"},"close_issue":{"max":10,"required_labels":["upstream-sync"]},"create_issue":{"expires":144,"max":1},"missing_data":{},"missing_tool":{},"noop":{"max":1}} - EOF - cat > /opt/gh-aw/safeoutputs/tools.json << 'EOF' + cat > /opt/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF' + {"add_comment":{"max":10,"target":"*"},"assign_to_agent":{"default_agent":"copilot","max":1,"target":"*"},"close_issue":{"max":10,"required_labels":["upstream-sync"],"target":"*"},"create_issue":{"expires":144,"max":1},"missing_data":{},"missing_tool":{},"noop":{"max":1}} + GH_AW_SAFE_OUTPUTS_CONFIG_EOF + cat > /opt/gh-aw/safeoutputs/tools.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_EOF' [ { "description": "Create a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead. CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[upstream-sync] \". Labels [upstream-sync] will be automatically added. Assignees [copilot] will be automatically assigned.", @@ -220,14 +332,15 @@ jobs: "type": "array" }, "parent": { - "description": "Parent issue number for creating sub-issues. This is the numeric ID from the GitHub URL (e.g., 42 in github.com/owner/repo/issues/42). Can also be a temporary_id (e.g., 'aw_abc123def456') from a previously created issue in the same workflow run.", + "description": "Parent issue number for creating sub-issues. This is the numeric ID from the GitHub URL (e.g., 42 in github.com/owner/repo/issues/42). Can also be a temporary_id (e.g., 'aw_abc123', 'aw_Test123') from a previously created issue in the same workflow run.", "type": [ "number", "string" ] }, "temporary_id": { - "description": "Unique temporary identifier for referencing this issue before it's created. Format: 'aw_' followed by 12 hex characters (e.g., 'aw_abc123def456'). Use '#aw_ID' in body text to reference other issues by their temporary_id; these are replaced with actual issue numbers after creation.", + "description": "Unique temporary identifier for referencing this issue before it's created. Format: 'aw_' followed by 3 to 8 alphanumeric characters (e.g., 'aw_abc1', 'aw_Test123'). Use '#aw_ID' in body text to reference other issues by their temporary_id; these are replaced with actual issue numbers after creation.", + "pattern": "^aw_[A-Za-z0-9]{3,8}$", "type": "string" }, "title": { @@ -244,7 +357,7 @@ jobs: "name": "create_issue" }, { - "description": "Close a GitHub issue with a closing comment. Use this when work is complete, the issue is no longer relevant, or it's a duplicate. The closing comment should explain the resolution or reason for closing. CONSTRAINTS: Maximum 10 issue(s) can be closed. Target: *.", + "description": "Close a GitHub issue with a closing comment. You can and should always add a comment when closing an issue to explain the action or provide context. This tool is ONLY for closing issues - use update_issue if you need to change the title, body, labels, or other metadata without closing. Use close_issue when work is complete, the issue is no longer relevant, or it's a duplicate. The closing comment should explain the resolution or reason for closing. If the issue is already closed, a comment will still be posted. CONSTRAINTS: Maximum 10 issue(s) can be closed. Target: *.", "inputSchema": { "additionalProperties": false, "properties": { @@ -268,16 +381,16 @@ jobs: "name": "close_issue" }, { - "description": "Add a comment to an existing GitHub issue, pull request, or discussion. Use this to provide feedback, answer questions, or add information to an existing conversation. For creating new items, use create_issue, create_discussion, or create_pull_request instead. CONSTRAINTS: Maximum 10 comment(s) can be added. Target: *.", + "description": "Add a comment to an existing GitHub issue, pull request, or discussion. Use this to provide feedback, answer questions, or add information to an existing conversation. For creating new items, use create_issue, create_discussion, or create_pull_request instead. IMPORTANT: Comments are subject to validation constraints enforced by the MCP server - maximum 65536 characters for the complete comment (including footer which is added automatically), 10 mentions (@username), and 50 links. Exceeding these limits will result in an immediate error with specific guidance. NOTE: By default, this tool requires discussions:write permission. If your GitHub App lacks Discussions permission, set 'discussions: false' in the workflow's safe-outputs.add-comment configuration to exclude this permission. CONSTRAINTS: Maximum 10 comment(s) can be added. Target: *.", "inputSchema": { "additionalProperties": false, "properties": { "body": { - "description": "The comment text in Markdown format. This is the 'body' field - do not use 'comment_body' or other variations. Provide helpful, relevant information that adds value to the conversation.", + "description": "The comment text in Markdown format. This is the 'body' field - do not use 'comment_body' or other variations. Provide helpful, relevant information that adds value to the conversation. CONSTRAINTS: The complete comment (your body text + automatically added footer) must not exceed 65536 characters total. Maximum 10 mentions (@username), maximum 50 links (http/https URLs). A footer (~200-500 characters) is automatically appended with workflow attribution, so leave adequate space. If these limits are exceeded, the tool call will fail with a detailed error message indicating which constraint was violated.", "type": "string" }, "item_number": { - "description": "The issue, pull request, or discussion number to comment on. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123). If omitted, the tool will attempt to resolve the target from the current workflow context (triggering issue, PR, or discussion).", + "description": "The issue, pull request, or discussion number to comment on. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123). If omitted, the tool auto-targets the issue, PR, or discussion that triggered this workflow. Auto-targeting only works for issue, pull_request, discussion, and comment event triggers — it does NOT work for schedule, workflow_dispatch, push, or workflow_run triggers. For those trigger types, always provide item_number explicitly, or the comment will be silently discarded.", "type": "number" } }, @@ -289,7 +402,7 @@ jobs: "name": "add_comment" }, { - "description": "Assign the GitHub Copilot coding agent to work on an issue or pull request. The agent will analyze the issue/PR and attempt to implement a solution, creating a pull request when complete. Use this to delegate coding tasks to Copilot. Example usage: assign_to_agent(issue_number=123, agent=\"copilot\") or assign_to_agent(pull_number=456, agent=\"copilot\")", + "description": "Assign the GitHub Copilot coding agent to work on an issue or pull request. The agent will analyze the issue/PR and attempt to implement a solution, creating a pull request when complete. Use this to delegate coding tasks to Copilot. Example usage: assign_to_agent(issue_number=123, agent=\"copilot\") or assign_to_agent(pull_number=456, agent=\"copilot\", pull_request_repo=\"owner/repo\") CONSTRAINTS: Maximum 1 issue(s) can be assigned to agent.", "inputSchema": { "additionalProperties": false, "properties": { @@ -298,18 +411,22 @@ jobs: "type": "string" }, "issue_number": { - "description": "Issue number to assign the Copilot agent to. This is the numeric ID from the GitHub URL (e.g., 234 in github.com/owner/repo/issues/234). Can also be a temporary_id (e.g., 'aw_abc123def456') from an issue created earlier in the same workflow run. The issue should contain clear, actionable requirements. Either issue_number or pull_number must be provided, but not both.", + "description": "Issue number to assign the Copilot coding agent to. This is the numeric ID from the GitHub URL (e.g., 234 in github.com/owner/repo/issues/234). Can also be a temporary_id (e.g., 'aw_abc123', 'aw_Test123') from an issue created earlier in the same workflow run. The issue should contain clear, actionable requirements. Either issue_number or pull_number must be provided, but not both.", "type": [ "number", "string" ] }, "pull_number": { - "description": "Pull request number to assign the Copilot agent to. This is the numeric ID from the GitHub URL (e.g., 456 in github.com/owner/repo/pull/456). Either issue_number or pull_number must be provided, but not both.", + "description": "Pull request number to assign the Copilot coding agent to. This is the numeric ID from the GitHub URL (e.g., 456 in github.com/owner/repo/pull/456). Either issue_number or pull_number must be provided, but not both.", "type": [ "number", "string" ] + }, + "pull_request_repo": { + "description": "Target repository where the pull request should be created, in 'owner/repo' format. If omitted, the PR will be created in the same repository as the issue. This allows issues and code to live in different repositories. The global pull-request-repo configuration (if set) is automatically allowed; additional repositories must be listed in allowed-pull-request-repos.", + "type": "string" } }, "type": "object" @@ -386,8 +503,8 @@ jobs: "name": "missing_data" } ] - EOF - cat > /opt/gh-aw/safeoutputs/validation.json << 'EOF' + GH_AW_SAFE_OUTPUTS_TOOLS_EOF + cat > /opt/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_EOF' { "add_comment": { "defaultMax": 1, @@ -400,6 +517,10 @@ jobs: }, "item_number": { "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 } } }, @@ -416,6 +537,14 @@ jobs: }, "pull_number": { "optionalPositiveInteger": true + }, + "pull_request_repo": { + "type": "string", + "maxLength": 256 + }, + "repo": { + "type": "string", + "maxLength": 256 } }, "customValidation": "requiresOneOf:issue_number,pull_number" @@ -431,6 +560,10 @@ jobs: }, "issue_number": { "optionalPositiveInteger": true + }, + "repo": { + "type": "string", + "maxLength": 256 } } }, @@ -467,6 +600,31 @@ jobs: } } }, + "missing_data": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "context": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "data_type": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "reason": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } + } + }, "missing_tool": { "defaultMax": 20, "fields": { @@ -500,7 +658,7 @@ jobs: } } } - EOF + GH_AW_SAFE_OUTPUTS_VALIDATION_EOF - name: Generate Safe Outputs MCP Server Config id: safe-outputs-config run: | @@ -539,7 +697,7 @@ jobs: bash /opt/gh-aw/actions/start_safe_outputs_server.sh - - name: Start MCP gateway + - name: Start MCP Gateway id: start-mcp-gateway env: GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} @@ -559,18 +717,19 @@ jobs: export MCP_GATEWAY_API_KEY export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" export DEBUG="*" export GH_AW_ENGINE="copilot" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.0.113' + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.6' mkdir -p /home/runner/.copilot - cat << MCPCONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh + cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh { "mcpServers": { "github": { "type": "stdio", - "container": "ghcr.io/github/github-mcp-server:v0.30.3", + "container": "ghcr.io/github/github-mcp-server:v0.31.0", "env": { "GITHUB_LOCKDOWN_MODE": "$GITHUB_MCP_LOCKDOWN", "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", @@ -593,131 +752,12 @@ jobs: "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" } } - MCPCONFIG_EOF - - name: Generate workflow overview - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { generateWorkflowOverview } = require('/opt/gh-aw/actions/generate_workflow_overview.cjs'); - await generateWorkflowOverview(core); - - name: Create prompt with built-in context - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - run: | - bash /opt/gh-aw/actions/create_prompt_first.sh - cat << 'PROMPT_EOF' > "$GH_AW_PROMPT" - - PROMPT_EOF - cat "/opt/gh-aw/prompts/temp_folder_prompt.md" >> "$GH_AW_PROMPT" - cat "/opt/gh-aw/prompts/markdown.md" >> "$GH_AW_PROMPT" - cat << 'PROMPT_EOF' >> "$GH_AW_PROMPT" - - GitHub API Access Instructions - - The gh CLI is NOT authenticated. Do NOT use gh commands for GitHub operations. - - - To create or modify GitHub resources (issues, discussions, pull requests, etc.), you MUST call the appropriate safe output tool. Simply writing content will NOT work - the workflow requires actual tool calls. - - Discover available tools from the safeoutputs MCP server. - - **Critical**: Tool calls write structured data that downstream jobs process. Without tool calls, follow-up actions will be skipped. - - **Note**: If you made no other safe output tool calls during this workflow execution, call the "noop" tool to provide a status message indicating completion or that no actions were needed. - - - - The following GitHub context information is available for this workflow: - {{#if __GH_AW_GITHUB_ACTOR__ }} - - **actor**: __GH_AW_GITHUB_ACTOR__ - {{/if}} - {{#if __GH_AW_GITHUB_REPOSITORY__ }} - - **repository**: __GH_AW_GITHUB_REPOSITORY__ - {{/if}} - {{#if __GH_AW_GITHUB_WORKSPACE__ }} - - **workspace**: __GH_AW_GITHUB_WORKSPACE__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} - - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} - - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} - - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} - - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ - {{/if}} - {{#if __GH_AW_GITHUB_RUN_ID__ }} - - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ - {{/if}} - - - PROMPT_EOF - cat << 'PROMPT_EOF' >> "$GH_AW_PROMPT" - - PROMPT_EOF - cat << 'PROMPT_EOF' >> "$GH_AW_PROMPT" - {{#runtime-import .github/workflows/weekly-upstream-sync.md}} - PROMPT_EOF - - name: Substitute placeholders - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_MCP_CONFIG_EOF + - name: Download activation artifact + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8 with: - script: | - const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); - - // Call the substitution function - return await substitutePlaceholders({ - file: process.env.GH_AW_PROMPT, - substitutions: { - GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, - GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, - GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, - GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, - GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE - } - }); - - name: Interpolate variables and render templates - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); - await main(); - - name: Validate prompt placeholders - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh - - name: Print prompt - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash /opt/gh-aw/actions/print_prompt_summary.sh + name: activation + path: /tmp/gh-aw - name: Clean git credentials run: bash /opt/gh-aw/actions/clean_git_credentials.sh - name: Execute GitHub Copilot CLI @@ -726,9 +766,9 @@ jobs: timeout-minutes: 20 run: | set -o pipefail - sudo -E awf --enable-chroot --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains '*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.13.12 --skip-pull \ - -- '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --allow-all-paths --share /tmp/gh-aw/sandbox/agent/logs/conversation.md --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_COPILOT:+ --model "$GH_AW_MODEL_AGENT_COPILOT"}' \ - 2>&1 | tee /tmp/gh-aw/agent-stdio.log + # shellcheck disable=SC1003 + sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.23.0 --skip-pull --enable-api-proxy \ + -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --allow-all-paths --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_COPILOT:+ --model "$GH_AW_MODEL_AGENT_COPILOT"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} @@ -736,8 +776,11 @@ jobs: GH_AW_MODEL_AGENT_COPILOT: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GITHUB_API_URL: ${{ github.api_url }} GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} GITHUB_WORKSPACE: ${{ github.workspace }} XDG_CONFIG_HOME: /home/runner @@ -748,6 +791,7 @@ jobs: run: | git config --global user.email "github-actions[bot]@users.noreply.github.com" git config --global user.name "github-actions[bot]" + git config --global am.keepcr true # Re-authenticate git with GitHub token SERVER_URL_STRIPPED="${SERVER_URL#https://}" git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" @@ -769,7 +813,7 @@ jobs: else echo "No session-state directory found at $SESSION_STATE_DIR" fi - - name: Stop MCP gateway + - name: Stop MCP Gateway if: always() continue-on-error: true env: @@ -795,13 +839,14 @@ jobs: SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload Safe Outputs if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: safe-output path: ${{ env.GH_AW_SAFE_OUTPUTS }} if-no-files-found: warn - name: Ingest agent output id: collect_output + if: always() uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} @@ -816,13 +861,13 @@ jobs: await main(); - name: Upload sanitized agent output if: always() && env.GH_AW_AGENT_OUTPUT - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: agent-output path: ${{ env.GH_AW_AGENT_OUTPUT }} if-no-files-found: warn - name: Upload engine output files - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: agent_outputs path: | @@ -840,7 +885,7 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/parse_copilot_log.cjs'); await main(); - - name: Parse MCP gateway logs for step summary + - name: Parse MCP Gateway logs for step summary if: always() uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: @@ -858,27 +903,146 @@ jobs: # Fix permissions on firewall logs so they can be uploaded as artifacts # AWF runs with sudo, creating files owned by root sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true - awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) + if command -v awf &> /dev/null; then + awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + else + echo 'AWF binary not installed, skipping firewall log summary' + fi - name: Upload agent artifacts if: always() continue-on-error: true - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: agent-artifacts path: | /tmp/gh-aw/aw-prompts/prompt.txt - /tmp/gh-aw/aw_info.json /tmp/gh-aw/mcp-logs/ /tmp/gh-aw/sandbox/firewall/logs/ /tmp/gh-aw/agent-stdio.log /tmp/gh-aw/agent/ if-no-files-found: ignore + # --- Threat Detection (inline) --- + - name: Check if detection needed + id: detection_guard + if: always() + env: + OUTPUT_TYPES: ${{ steps.collect_output.outputs.output_types }} + HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} + run: | + if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then + echo "run_detection=true" >> "$GITHUB_OUTPUT" + echo "Detection will run: output_types=$OUTPUT_TYPES, has_patch=$HAS_PATCH" + else + echo "run_detection=false" >> "$GITHUB_OUTPUT" + echo "Detection skipped: no agent outputs or patches to analyze" + fi + - name: Clear MCP configuration for detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + rm -f /tmp/gh-aw/mcp-config/mcp-servers.json + rm -f /home/runner/.copilot/mcp-config.json + rm -f "$GITHUB_WORKSPACE/.gemini/settings.json" + - name: Prepare threat detection files + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection/aw-prompts + cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true + cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true + for f in /tmp/gh-aw/aw-*.patch; do + [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true + done + echo "Prepared threat detection files:" + ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true + - name: Setup threat detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + WORKFLOW_NAME: "Weekly Upstream Sync Agentic Workflow" + WORKFLOW_DESCRIPTION: "Weekly upstream sync workflow. Checks for new commits in the official\nCopilot SDK (github/copilot-sdk) and assigns to Copilot to port changes." + HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/setup_threat_detection.cjs'); + await main(); + - name: Ensure threat-detection directory and log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection + touch /tmp/gh-aw/threat-detection/detection.log + - name: Execute GitHub Copilot CLI + if: always() && steps.detection_guard.outputs.run_detection == 'true' + id: detection_agentic_execution + # Copilot CLI tool arguments (sorted): + # --allow-tool shell(cat) + # --allow-tool shell(grep) + # --allow-tool shell(head) + # --allow-tool shell(jq) + # --allow-tool shell(ls) + # --allow-tool shell(tail) + # --allow-tool shell(wc) + timeout-minutes: 20 + run: | + set -o pipefail + # shellcheck disable=SC1003 + sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,raw.githubusercontent.com,registry.npmjs.org,telemetry.enterprise.githubcopilot.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.23.0 --skip-pull --enable-api-proxy \ + -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(wc)'\'' --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_DETECTION_COPILOT:+ --model "$GH_AW_MODEL_DETECTION_COPILOT"}' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + env: + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + GH_AW_MODEL_DETECTION_COPILOT: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} + GITHUB_WORKSPACE: ${{ github.workspace }} + XDG_CONFIG_HOME: /home/runner + - name: Parse threat detection results + id: parse_detection_results + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_threat_detection_results.cjs'); + await main(); + - name: Upload threat detection log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: threat-detection.log + path: /tmp/gh-aw/threat-detection/detection.log + if-no-files-found: ignore + - name: Set detection conclusion + id: detection_conclusion + if: always() + env: + RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} + DETECTION_SUCCESS: ${{ steps.parse_detection_results.outputs.success }} + run: | + if [[ "$RUN_DETECTION" != "true" ]]; then + echo "conclusion=skipped" >> "$GITHUB_OUTPUT" + echo "success=true" >> "$GITHUB_OUTPUT" + echo "Detection was not needed, marking as skipped" + elif [[ "$DETECTION_SUCCESS" == "true" ]]; then + echo "conclusion=success" >> "$GITHUB_OUTPUT" + echo "success=true" >> "$GITHUB_OUTPUT" + echo "Detection passed successfully" + else + echo "conclusion=failure" >> "$GITHUB_OUTPUT" + echo "success=false" >> "$GITHUB_OUTPUT" + echo "Detection found issues" + fi conclusion: needs: - activation - agent - - detection - safe_outputs if: (always()) && (needs.agent.result != 'skipped') runs-on: ubuntu-slim @@ -893,23 +1057,12 @@ jobs: total_count: ${{ steps.missing_tool.outputs.total_count }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@v0.43.2 + uses: github/gh-aw/actions/setup@33cd6c7f1fee588654ef19def2e6a4174be66197 # v0.51.6 with: destination: /opt/gh-aw/actions - - name: Debug job inputs - env: - COMMENT_ID: ${{ needs.activation.outputs.comment_id }} - COMMENT_REPO: ${{ needs.activation.outputs.comment_repo }} - AGENT_OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }} - AGENT_CONCLUSION: ${{ needs.agent.result }} - run: | - echo "Comment ID: $COMMENT_ID" - echo "Comment Repo: $COMMENT_REPO" - echo "Agent Output Types: $AGENT_OUTPUT_TYPES" - echo "Agent Conclusion: $AGENT_CONCLUSION" - name: Download agent output artifact continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8 with: name: agent-output path: /tmp/gh-aw/safeoutputs/ @@ -923,7 +1076,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_NOOP_MAX: 1 + GH_AW_NOOP_MAX: "1" GH_AW_WORKFLOW_NAME: "Weekly Upstream Sync Agentic Workflow" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} @@ -954,10 +1107,11 @@ jobs: GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} GH_AW_WORKFLOW_ID: "weekly-upstream-sync" - GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.agent.outputs.secret_verification_result }} + GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} GH_AW_ASSIGNMENT_ERRORS: ${{ needs.safe_outputs.outputs.assign_to_agent_assignment_errors }} GH_AW_ASSIGNMENT_ERROR_COUNT: ${{ needs.safe_outputs.outputs.assign_to_agent_assignment_error_count }} + GH_AW_GROUP_REPORTS: "false" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | @@ -982,131 +1136,10 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/handle_noop_message.cjs'); await main(); - - name: Update reaction comment with completion status - id: conclusion - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }} - GH_AW_COMMENT_REPO: ${{ needs.activation.outputs.comment_repo }} - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GH_AW_WORKFLOW_NAME: "Weekly Upstream Sync Agentic Workflow" - GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} - GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.result }} - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/notify_comment_error.cjs'); - await main(); - - detection: - needs: agent - if: needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true' - runs-on: ubuntu-latest - permissions: {} - concurrency: - group: "gh-aw-copilot-${{ github.workflow }}" - timeout-minutes: 10 - outputs: - success: ${{ steps.parse_results.outputs.success }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@v0.43.2 - with: - destination: /opt/gh-aw/actions - - name: Download agent artifacts - continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 - with: - name: agent-artifacts - path: /tmp/gh-aw/threat-detection/ - - name: Download agent output artifact - continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 - with: - name: agent-output - path: /tmp/gh-aw/threat-detection/ - - name: Echo agent output types - env: - AGENT_OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }} - run: | - echo "Agent output-types: $AGENT_OUTPUT_TYPES" - - name: Setup threat detection - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - WORKFLOW_NAME: "Weekly Upstream Sync Agentic Workflow" - WORKFLOW_DESCRIPTION: "Weekly upstream sync workflow. Checks for new commits in the official\nCopilot SDK (github/copilot-sdk) and assigns to Copilot to port changes." - HAS_PATCH: ${{ needs.agent.outputs.has_patch }} - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/setup_threat_detection.cjs'); - await main(); - - name: Ensure threat-detection directory and log - run: | - mkdir -p /tmp/gh-aw/threat-detection - touch /tmp/gh-aw/threat-detection/detection.log - - name: Validate COPILOT_GITHUB_TOKEN secret - id: validate-secret - run: /opt/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default - env: - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - - name: Install GitHub Copilot CLI - run: /opt/gh-aw/actions/install_copilot_cli.sh 0.0.405 - - name: Execute GitHub Copilot CLI - id: agentic_execution - # Copilot CLI tool arguments (sorted): - # --allow-tool shell(cat) - # --allow-tool shell(grep) - # --allow-tool shell(head) - # --allow-tool shell(jq) - # --allow-tool shell(ls) - # --allow-tool shell(tail) - # --allow-tool shell(wc) - timeout-minutes: 20 - run: | - set -o pipefail - COPILOT_CLI_INSTRUCTION="$(cat /tmp/gh-aw/aw-prompts/prompt.txt)" - mkdir -p /tmp/ - mkdir -p /tmp/gh-aw/ - mkdir -p /tmp/gh-aw/agent/ - mkdir -p /tmp/gh-aw/sandbox/agent/logs/ - copilot --add-dir /tmp/ --add-dir /tmp/gh-aw/ --add-dir /tmp/gh-aw/agent/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --allow-tool 'shell(cat)' --allow-tool 'shell(grep)' --allow-tool 'shell(head)' --allow-tool 'shell(jq)' --allow-tool 'shell(ls)' --allow-tool 'shell(tail)' --allow-tool 'shell(wc)' --share /tmp/gh-aw/sandbox/agent/logs/conversation.md --prompt "$COPILOT_CLI_INSTRUCTION"${GH_AW_MODEL_DETECTION_COPILOT:+ --model "$GH_AW_MODEL_DETECTION_COPILOT"} 2>&1 | tee /tmp/gh-aw/threat-detection/detection.log - env: - COPILOT_AGENT_RUNNER_TYPE: STANDALONE - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - GH_AW_MODEL_DETECTION_COPILOT: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GITHUB_HEAD_REF: ${{ github.head_ref }} - GITHUB_REF_NAME: ${{ github.ref_name }} - GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} - GITHUB_WORKSPACE: ${{ github.workspace }} - XDG_CONFIG_HOME: /home/runner - - name: Parse threat detection results - id: parse_results - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_threat_detection_results.cjs'); - await main(); - - name: Upload threat detection log - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: threat-detection.log - path: /tmp/gh-aw/threat-detection/detection.log - if-no-files-found: ignore safe_outputs: - needs: - - agent - - detection - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.detection.outputs.success == 'true') + needs: agent + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.agent.outputs.detection_success == 'true') runs-on: ubuntu-slim permissions: contents: read @@ -1115,6 +1148,7 @@ jobs: pull-requests: write timeout-minutes: 15 env: + GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/${{ github.workflow }}" GH_AW_ENGINE_ID: "copilot" GH_AW_WORKFLOW_ID: "weekly-upstream-sync" GH_AW_WORKFLOW_NAME: "Weekly Upstream Sync Agentic Workflow" @@ -1122,18 +1156,24 @@ jobs: assign_to_agent_assigned: ${{ steps.assign_to_agent.outputs.assigned }} assign_to_agent_assignment_error_count: ${{ steps.assign_to_agent.outputs.assignment_error_count }} assign_to_agent_assignment_errors: ${{ steps.assign_to_agent.outputs.assignment_errors }} + code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} + code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + created_issue_number: ${{ steps.process_safe_outputs.outputs.created_issue_number }} + created_issue_url: ${{ steps.process_safe_outputs.outputs.created_issue_url }} process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@v0.43.2 + uses: github/gh-aw/actions/setup@33cd6c7f1fee588654ef19def2e6a4174be66197 # v0.51.6 with: destination: /opt/gh-aw/actions - name: Download agent output artifact continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8 with: name: agent-output path: /tmp/gh-aw/safeoutputs/ @@ -1147,6 +1187,9 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":10,\"target\":\"*\"},\"close_issue\":{\"max\":10,\"required_labels\":[\"upstream-sync\"],\"target\":\"*\"},\"create_issue\":{\"assignees\":[\"copilot\"],\"expires\":144,\"labels\":[\"upstream-sync\"],\"max\":1,\"title_prefix\":\"[upstream-sync] \"},\"missing_data\":{},\"missing_tool\":{}}" GH_AW_ASSIGN_COPILOT: "true" with: @@ -1168,13 +1211,15 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/assign_copilot_to_created_issues.cjs'); await main(); - - name: Assign To Agent + - name: Assign to agent id: assign_to_agent if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'assign_to_agent')) uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_AGENT_MAX_COUNT: 1 GH_AW_AGENT_DEFAULT: "copilot" + GH_AW_AGENT_DEFAULT_MODEL: "claude-opus-4.6" GH_AW_AGENT_TARGET: "*" GH_AW_TEMPORARY_ID_MAP: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} with: @@ -1184,4 +1229,11 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/assign_to_agent.cjs'); await main(); + - name: Upload safe output items manifest + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: safe-output-items + path: /tmp/safe-output-items.jsonl + if-no-files-found: warn diff --git a/.github/workflows/weekly-upstream-sync.md b/.github/workflows/weekly-upstream-sync.md index 31a445df4..641f29301 100644 --- a/.github/workflows/weekly-upstream-sync.md +++ b/.github/workflows/weekly-upstream-sync.md @@ -36,6 +36,7 @@ safe-outputs: max: 10 assign-to-agent: name: "copilot" + model: "claude-opus-4.6" target: "*" noop: report-as-issue: false diff --git a/.gitignore b/.gitignore index a36367133..f6b0681d3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ target examples-test/ .merge-env +blog-copilotsdk/ +.claude/worktrees +smoke-test diff --git a/.lastmerge b/.lastmerge index 6d9098bab..c5649a512 100644 --- a/.lastmerge +++ b/.lastmerge @@ -1 +1 @@ -e40d57c86e18b495722adbf42045288c03924342 +062b61c8aa63b9b5d45fa1d7b01723e6660ffa83 diff --git a/CHANGELOG.md b/CHANGELOG.md index 649255c77..1404ba336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,62 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] -> **Upstream sync:** [`github/copilot-sdk@e40d57c`](https://github.com/github/copilot-sdk/commit/e40d57c86e18b495722adbf42045288c03924342) +> **Upstream sync:** [`github/copilot-sdk@062b61c`](https://github.com/github/copilot-sdk/commit/062b61c8aa63b9b5d45fa1d7b01723e6660ffa83) + +### Notice + +- **This repository is now archived.** The official GitHub Copilot SDK for Java is maintained at [github/copilot-sdk-java](https://github.com/github/copilot-sdk-java). No further changes or releases will be made in this repository. + +## [1.0.11] - 2026-03-12 + +> **Upstream sync:** [`github/copilot-sdk@062b61c`](https://github.com/github/copilot-sdk/commit/062b61c8aa63b9b5d45fa1d7b01723e6660ffa83) +### Added + +- `CopilotClientOptions.setOnListModels(Supplier>>)` — custom handler for `listModels()` used in BYOK mode to return models from a custom provider instead of querying the CLI (upstream: [`e478657`](https://github.com/github/copilot-sdk/commit/e478657)) +- `SessionConfig.setAgent(String)` — pre-selects a custom agent by name when creating a session (upstream: [`7766b1a`](https://github.com/github/copilot-sdk/commit/7766b1a)) +- `ResumeSessionConfig.setAgent(String)` — pre-selects a custom agent by name when resuming a session (upstream: [`7766b1a`](https://github.com/github/copilot-sdk/commit/7766b1a)) +- `SessionConfig.setOnEvent(Consumer)` — registers an event handler before the `session.create` RPC is issued, ensuring no early events are missed (upstream: [`4125fe7`](https://github.com/github/copilot-sdk/commit/4125fe7)) +- `ResumeSessionConfig.setOnEvent(Consumer)` — registers an event handler before the `session.resume` RPC is issued (upstream: [`4125fe7`](https://github.com/github/copilot-sdk/commit/4125fe7)) +- New broadcast session event types (protocol v3): `ExternalToolRequestedEvent` (`external_tool.requested`), `ExternalToolCompletedEvent` (`external_tool.completed`), `PermissionRequestedEvent` (`permission.requested`), `PermissionCompletedEvent` (`permission.completed`), `CommandQueuedEvent` (`command.queued`), `CommandCompletedEvent` (`command.completed`), `ExitPlanModeRequestedEvent` (`exit_plan_mode.requested`), `ExitPlanModeCompletedEvent` (`exit_plan_mode.completed`), `SystemNotificationEvent` (`system.notification`) (upstream: [`1653812`](https://github.com/github/copilot-sdk/commit/1653812), [`396e8b3`](https://github.com/github/copilot-sdk/commit/396e8b3)) +- `CopilotSession.log(String)` and `CopilotSession.log(String, String, Boolean)` — log a message to the session timeline (upstream: [`4125fe7`](https://github.com/github/copilot-sdk/commit/4125fe7)) + +### Changed + +- **Protocol version bumped to v3.** The SDK now supports CLI servers running v2 or v3 (backward-compatible range). Sessions are now registered in the client's session map *before* the `session.create`/`session.resume` RPC is issued, ensuring broadcast events emitted immediately on session start are never dropped (upstream: [`4125fe7`](https://github.com/github/copilot-sdk/commit/4125fe7), [`1653812`](https://github.com/github/copilot-sdk/commit/1653812)) +- In protocol v3, tool calls and permission requests that have a registered handler are now handled automatically via `ExternalToolRequestedEvent` and `PermissionRequestedEvent` broadcast events; results are sent back via `session.tools.handlePendingToolCall` and `session.permissions.handlePendingPermissionRequest` RPC calls (upstream: [`1653812`](https://github.com/github/copilot-sdk/commit/1653812)) + +## [1.0.10] - 2026-03-03 + +> **Upstream sync:** [`github/copilot-sdk@dcd86c1`](https://github.com/github/copilot-sdk/commit/dcd86c189501ce1b46b787ca60d90f3f315f3079) +### Added + +- `CopilotSession.setModel(String)` — changes the model for an existing session mid-conversation; the new model takes effect for the next message, and conversation history is preserved (upstream: [`bd98e3a`](https://github.com/github/copilot-sdk/commit/bd98e3a)) +- `ToolDefinition.createOverride(String, String, Map, ToolHandler)` — creates a tool definition that overrides a built-in CLI tool with the same name (upstream: [`f843c80`](https://github.com/github/copilot-sdk/commit/f843c80)) +- `ToolDefinition` record now includes `overridesBuiltInTool` field; when `true`, signals to the CLI that the custom tool intentionally replaces a built-in (upstream: [`f843c80`](https://github.com/github/copilot-sdk/commit/f843c80)) +- `CopilotSession.listAgents()` — lists custom agents available for selection (upstream: [`9d998fb`](https://github.com/github/copilot-sdk/commit/9d998fb)) +- `CopilotSession.getCurrentAgent()` — gets the currently selected custom agent (upstream: [`9d998fb`](https://github.com/github/copilot-sdk/commit/9d998fb)) +- `CopilotSession.selectAgent(String)` — selects a custom agent for the session (upstream: [`9d998fb`](https://github.com/github/copilot-sdk/commit/9d998fb)) +- `CopilotSession.deselectAgent()` — deselects the current custom agent (upstream: [`9d998fb`](https://github.com/github/copilot-sdk/commit/9d998fb)) +- `CopilotSession.compact()` — triggers immediate session context compaction (upstream: [`9d998fb`](https://github.com/github/copilot-sdk/commit/9d998fb)) +- `AgentInfo` — new JSON type representing a custom agent with `name`, `displayName`, and `description` (upstream: [`9d998fb`](https://github.com/github/copilot-sdk/commit/9d998fb)) +- New event types: `SessionTaskCompleteEvent` (`session.task_complete`), `AssistantStreamingDeltaEvent` (`assistant.streaming_delta`), `SubagentDeselectedEvent` (`subagent.deselected`) (upstream: various commits) +- `AssistantTurnStartEvent` data now includes `interactionId` field +- `AssistantMessageEvent` data now includes `interactionId` field +- `ToolExecutionCompleteEvent` data now includes `model` and `interactionId` fields +- `SkillInvokedEvent` data now includes `pluginName` and `pluginVersion` fields +- `AssistantUsageEvent` data now includes `copilotUsage` field with `CopilotUsage` and `TokenDetails` nested types +- E2E tests for custom tool permission approval and denial flows (upstream: [`388f2f3`](https://github.com/github/copilot-sdk/commit/388f2f3)) + +### Changed + +- **Breaking:** `createSession(SessionConfig)` now requires a non-null `onPermissionRequest` handler; throws `IllegalArgumentException` if not provided (upstream: [`279f6c4`](https://github.com/github/copilot-sdk/commit/279f6c4)) +- **Breaking:** `resumeSession(String, ResumeSessionConfig)` now requires a non-null `onPermissionRequest` handler; throws `IllegalArgumentException` if not provided (upstream: [`279f6c4`](https://github.com/github/copilot-sdk/commit/279f6c4)) +- **Breaking:** The no-arg `createSession()` and `resumeSession(String)` overloads were removed (upstream: [`279f6c4`](https://github.com/github/copilot-sdk/commit/279f6c4)) +- `AssistantMessageDeltaEvent` data: `totalResponseSizeBytes` field moved to new `AssistantStreamingDeltaEvent` (upstream: various) + +### Fixed + +- Permission checks now also apply to SDK-registered custom tools, invoking the `onPermissionRequest` handler with `kind="custom-tool"` before executing tools (upstream: [`388f2f3`](https://github.com/github/copilot-sdk/commit/388f2f3)) ## [1.0.9] - 2026-02-16 @@ -376,7 +431,14 @@ New types: `GetForegroundSessionResponse`, `SetForegroundSessionResponse` - Pre-commit hook for Spotless code formatting - Comprehensive API documentation -[Unreleased]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.9...HEAD +[Unreleased]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.11...HEAD +[1.0.11]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.10...v1.0.11 +[Unreleased]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.11...HEAD +[1.0.11]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.10...v1.0.11 +[1.0.10]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.9...v1.0.10 +[Unreleased]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.11...HEAD +[1.0.11]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.10...v1.0.11 +[1.0.10]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.9...v1.0.10 [1.0.9]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.8...v1.0.9 [1.0.8]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.7...v1.0.8 [1.0.7]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.6...v1.0.7 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..c75895245 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,74 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery and unwelcome sexual attention or + advance +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic + address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at . All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..fe7c9ec09 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,67 @@ +## Contributing + +[fork]: https://github.com/github/copilot-sdk-java/fork +[pr]: https://github.com/github/copilot-sdk-java/compare + +Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. + +This repository contains the **Copilot SDK for Java**, the official Java variant of the official [GitHub Copilot SDK](https://github.com/github/copilot-sdk). For issues or features related to the upstream SDK, please contribute there instead. + +Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](LICENSE). + +Please note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. + +## What kinds of contributions we're looking for + +We'd love your help with: + + * Fixing any bugs in the existing feature set + * Making the SDK more idiomatic and nice to use for Java developers + * Improving documentation + +If you have ideas for entirely new features, please post an issue or start a discussion. We're very open to new features but need to make sure they align with the direction of the upstream [Copilot SDK](https://github.com/github/copilot-sdk) and can be maintained in sync. Note that this repo periodically merges upstream changes — see the [README](README.md#agentic-upstream-merge-and-sync) for details on how that works. + +## Prerequisites for running and testing code + +1. Install [Java 17+](https://openjdk.org/) (JDK) +1. Install [Maven 3.9+](https://maven.apache.org/download.cgi) (or use the included `mvnw` wrapper) +1. Install [Node.js](https://nodejs.org/) (v18+) — required for the E2E test harness + +## Submitting a pull request + +1. [Fork][fork] and clone the repository +1. Enable git hooks: `git config core.hooksPath .githooks` +1. Make sure the tests pass on your machine: `mvn clean verify` +1. Make sure formatting passes: `mvn spotless:check` +1. Create a new branch: `git checkout -b my-branch-name` +1. Make your change, add tests, and make sure the tests and linter still pass +1. Push to your fork and [submit a pull request][pr] +1. Pat yourself on the back and wait for your pull request to be reviewed and merged. + +### Running tests and linters + +```bash +# Build and run all tests +mvn clean verify + +# Run a single test class +mvn test -Dtest=CopilotClientTest + +# Format code (required before commit) +mvn spotless:apply + +# Check formatting only +mvn spotless:check +``` + +Here are a few things you can do that will increase the likelihood of your pull request being accepted: + +- Write tests. +- Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. +- Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). + +## Resources + +- [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) +- [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) +- [GitHub Help](https://help.github.com) diff --git a/LICENSE b/LICENSE index 4fa9fd93f..28a50fa22 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Bruno Borges and the Copilot Community SDK contributors +Copyright GitHub, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index e50b13b42..d4b0a54ab 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Copilot SDK for Java +> 🚨 **This repository is archived.** The official GitHub Copilot SDK for Java is now maintained at **[github/copilot-sdk-java](https://github.com/github/copilot-sdk-java)**. No further changes or releases will be made here. Please use the official SDK going forward. + [![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) [![Coverage](.github/badges/jacoco.svg)](https://copilot-community-sdk.github.io/copilot-sdk-java/snapshot/jacoco/index.html) @@ -11,12 +13,10 @@ [![GitHub Release Date](https://img.shields.io/github/release-date/copilot-community-sdk/copilot-sdk-java)](https://github.com/copilot-community-sdk/copilot-sdk-java/releases) [![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/latest/) +[![Documentation](https://img.shields.io/badge/docs-latest-brightgreen)](https://copilot-community-sdk.github.io/copilot-sdk-java/latest/) [![Javadoc](https://javadoc.io/badge2/io.github.copilot-community-sdk/copilot-sdk/javadoc.svg?q=1)](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. +## Background Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build AI-powered applications and agentic workflows. @@ -25,7 +25,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.409 or later installed and in PATH (or provide custom `cliPath`) +- GitHub Copilot CLI 0.0.411-1 or later installed and in PATH (or provide custom `cliPath`) ### Maven @@ -33,14 +33,14 @@ Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build A io.github.copilot-community-sdk copilot-sdk - 1.0.9 + 1.0.11 ``` ### Gradle ```groovy -implementation 'io.github.copilot-community-sdk:copilot-sdk:1.0.9' +implementation 'io.github.copilot-community-sdk:copilot-sdk:1.0.11' ``` ## Quick Start @@ -59,20 +59,20 @@ public class CopilotSDK { // Create a session var session = client.createSession( - new SessionConfig().setModel("claude-sonnet-4.5")).get(); + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("claude-sonnet-4.5")).get(); // Handle assistant message events session.on(AssistantMessageEvent.class, msg -> { - System.out.println(msg.getData().getContent()); + System.out.println(msg.getData().content()); }); // 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()); + System.out.println("Current tokens: " + (int) data.currentTokens()); + System.out.println("Token limit: " + (int) data.tokenLimit()); + System.out.println("Messages count: " + (int) data.messagesLength()); }); // Send a message @@ -102,9 +102,9 @@ jbang https://github.com/copilot-community-sdk/copilot-sdk-java/blob/latest/jban ### Quick Links -- [Getting Started](https://copilot-community-sdk.github.io/copilot-sdk-java/documentation.html) -- [Javadoc API Reference](https://copilot-community-sdk.github.io/copilot-sdk-java/apidocs/) -- [MCP Servers Integration](https://copilot-community-sdk.github.io/copilot-sdk-java/mcp.html) +- [Getting Started](https://copilot-community-sdk.github.io/copilot-sdk-java/latest/documentation.html) +- [Javadoc API Reference](https://copilot-community-sdk.github.io/copilot-sdk-java/latest/apidocs/) +- [MCP Servers Integration](https://copilot-community-sdk.github.io/copilot-sdk-java/latest/mcp.html) - [Cookbook](src/site/markdown/cookbook/) — Practical recipes for common use cases ## Projects Using This SDK @@ -135,27 +135,6 @@ This SDK tracks the official [Copilot SDK](https://github.com/github/copilot-sdk - **VS Code Copilot Chat** — type `/agentic-merge-upstream` - **GitHub Copilot CLI** — use `copilot` CLI with the same skill reference -### Automated Test Coverage Improvement - -The repository includes an [Agentic Workflow](https://github.com/github/gh-aw) that automatically identifies untested code and creates comprehensive tests to increase coverage. The workflow is triggered manually: - -**To run the workflow:** -1. Navigate to the repository on GitHub -2. Go to **Actions** → **Increase Test Coverage** workflow -3. Click "Run workflow" -4. Optionally specify: - - `target_package` — Focus on a specific package (e.g., `com.github.copilot.sdk.json`) - - `min_coverage_threshold` — Target coverage percentage (default: 80%) - -**What it does:** -- Analyzes current test coverage using JaCoCo -- Identifies methods, branches, and edge cases lacking tests -- Creates high-quality tests following repository patterns -- Groups related tests logically -- Creates a PR for human review with coverage metrics - -The workflow follows all repository conventions (Spotless formatting, existing test patterns, E2E test infrastructure) and ensures no production code is modified unnecessarily. - ### Development Setup ```bash @@ -172,10 +151,26 @@ mvn clean verify The tests require the official [copilot-sdk](https://github.com/github/copilot-sdk) test harness, which is automatically cloned during build. +## Support + +See [SUPPORT.md](SUPPORT.md) for how to file issues and get help. + +## Code of Conduct + +This project has adopted the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for details. + +## Security + +See [SECURITY.md](SECURITY.md) for reporting security vulnerabilities. + ## License MIT — see [LICENSE](LICENSE) for details. +## Acknowledgement + +- Initially developed with Copilot and [Bruno Borges](https://www.linkedin.com/in/brunocborges/). + ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=copilot-community-sdk/copilot-sdk-java&type=Date)](https://www.star-history.com/#copilot-community-sdk/copilot-sdk-java&Date) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..ef183e6fc --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,31 @@ +Thanks for helping make GitHub safe for everyone. + +# Security + +GitHub takes the security of our software products and services seriously, including all of the open source code repositories managed through our GitHub organizations, such as [GitHub](https://github.com/GitHub). + +Even though [open source repositories are outside of the scope of our bug bounty program](https://bounty.github.com/index.html#scope) and therefore not eligible for bounty rewards, we will ensure that your finding gets passed along to the appropriate maintainers for remediation. + +## Reporting Security Issues + +If you believe you have found a security vulnerability in any GitHub-owned repository, please report it to us through coordinated disclosure. + +**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.** + +Instead, please send an email to opensource-security[@]github.com. + +Please include as much of the information listed below as you can to help us better understand and resolve the issue: + +- The type of issue (e.g., buffer overflow, SQL injection, or cross-site scripting) +- Full paths of source file(s) related to the manifestation of the issue +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- Step-by-step instructions to reproduce the issue +- Proof-of-concept or exploit code (if possible) +- Impact of the issue, including how an attacker might exploit the issue + +This information will help us triage your report more quickly. + +## Policy + +See [GitHub's Safe Harbor Policy](https://docs.github.com/en/site-policy/security-policies/github-bug-bounty-program-legal-safe-harbor#1-safe-harbor-terms) diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 000000000..980b3dce8 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,13 @@ +# Support + +## How to file issues and get help + +This project uses GitHub issues to track bugs and feature requests. Please search the existing issues before filing new issues to avoid duplicates. For new issues, file your bug or feature request as a new issue. + +For help or questions about using this project, please file an issue. + +**Copilot SDK for Java** is under active development and maintained by the community. We will do our best to respond to support, feature requests, and community questions in a timely manner. + +## GitHub Support Policy + +Support for this project is limited to the resources listed above. diff --git a/docs/adr/adr-001-semver-pre-general-availability.md b/docs/adr/adr-001-semver-pre-general-availability.md new file mode 100644 index 000000000..44c9cf6db --- /dev/null +++ b/docs/adr/adr-001-semver-pre-general-availability.md @@ -0,0 +1,27 @@ +# SemVer requirements pre general-availability of Reference Implementation + +## Context and Problem Statement + +Steve Sanderson agreed that `copilot-sdk-java` will track reference implementation version numbers directly, with one exception: when the Java SDK needs to ship a breaking change before 1.0, the reference implementation will bump its minor version to accommodate, giving our release a clean version number that signals the change to users. + +The reference implementation makes no backward compatibility guarantees pre-1.0 — and neither will we. That said, we're choosing to hold ourselves to a higher standard as a matter of good practice: we'll use minor version bumps as a signal to users when we do ship something breaking. + +The 2026-02 state of `copilot-sdk-java` is that it takes Java 17+ as its baseline. This decision precludes the use of Java 21 features such as virtual threads. Our pre-analysis showed the **possibility** of a significant performance benefit when using Virtual Threads with Java 21. + +We took an architectural decision to enable us to pursue investigating this possibility immediately. + +## Considered Options + +* Track SemVer of reference implementation, with one exception. +* Completely avoid the need for this by doing no breaking changes pre-1.0. +* Abandon the policy of tracking the versions of the reference implementation directly, just do our own thing. + +## Decision Outcome + +Chosen option: "Track SemVer of reference implementation, with one exception.", because this enables us to pursue Virtual Threads without delaying the first public release of `copilot-sdk-java`. Also, we're supposed to be aggressively modernizing our customers. + +To some extent, I would use qualifiers to mark a release as having some feature that is awaiting an upstream full release before it goes full ga, i.e you put out 0.1.46-virtualthreads.3 until upstream is ready to move to 0.2.0 then you release your virtual threads change and go 0.2.0. So I would make your agreement that your version numbers would match with the exception of qualifiers that you might add in exceptional circumstances. + +## Related work items + +- https://devdiv.visualstudio.com/DevDiv/_workitems/edit/2745172 diff --git a/jbang-example.java b/jbang-example.java index 4a02cfb41..c99178c6d 100644 --- a/jbang-example.java +++ b/jbang-example.java @@ -1,10 +1,12 @@ -//DEPS io.github.copilot-community-sdk:copilot-sdk:1.0.9 +//DEPS io.github.copilot-community-sdk:copilot-sdk:1.0.11 import com.github.copilot.sdk.*; import com.github.copilot.sdk.events.*; import com.github.copilot.sdk.json.*; import java.util.concurrent.CompletableFuture; +import static java.lang.System.out; + class CopilotSDK { public static void main(String[] args) throws Exception { // Create and start client @@ -13,20 +15,20 @@ public static void main(String[] args) throws Exception { // Create a session var session = client.createSession( - new SessionConfig().setModel("claude-sonnet-4.5")).get(); + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("claude-sonnet-4.5")).get(); // Handle assistant message events session.on(AssistantMessageEvent.class, msg -> { - System.out.println(msg.getData().getContent()); + out.println(msg.getData().content()); }); // 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()); + out.println("\n--- Usage Metrics ---"); + out.println("Current tokens: " + (int) data.currentTokens()); + out.println("Token limit: " + (int) data.tokenLimit()); + out.println("Messages count: " + (int) data.messagesLength()); }); // Send a message diff --git a/pom.xml b/pom.xml index b0b72ab2f..cea1f9575 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ io.github.copilot-community-sdk copilot-sdk - 1.0.9 + 1.0.12-SNAPSHOT jar GitHub Copilot Community SDK :: Java @@ -33,7 +33,7 @@ scm:git:https://github.com/copilot-community-sdk/copilot-sdk-java.git scm:git:https://github.com/copilot-community-sdk/copilot-sdk-java.git https://github.com/copilot-community-sdk/copilot-sdk-java - v1.0.9 + HEAD @@ -49,17 +49,17 @@ com.fasterxml.jackson.core jackson-databind - 2.20.1 + 2.21.1 com.fasterxml.jackson.core jackson-annotations - 2.20 + 2.21 com.fasterxml.jackson.datatype jackson-datatype-jsr310 - 2.20.1 + 2.21.1 @@ -108,6 +108,18 @@ maven-compiler-plugin 3.14.1 + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + + com.github.copilot.sdk.java + + + + org.apache.maven.plugins diff --git a/src/main/java/com/github/copilot/sdk/CliServerManager.java b/src/main/java/com/github/copilot/sdk/CliServerManager.java index 1ac43c719..b2a798ada 100644 --- a/src/main/java/com/github/copilot/sdk/CliServerManager.java +++ b/src/main/java/com/github/copilot/sdk/CliServerManager.java @@ -32,6 +32,7 @@ final class CliServerManager { private static final Logger LOG = Logger.getLogger(CliServerManager.class.getName()); private final CopilotClientOptions options; + private final StringBuilder stderrBuffer = new StringBuilder(); CliServerManager(CopilotClientOptions options) { this.options = options; @@ -47,6 +48,8 @@ final class CliServerManager { * if interrupted while waiting for port detection */ ProcessInfo startCliServer() throws IOException, InterruptedException { + clearStderrBuffer(); + String cliPath = options.getCliPath() != null ? options.getCliPath() : "copilot"; var args = new ArrayList(); @@ -67,15 +70,15 @@ ProcessInfo startCliServer() throws IOException, InterruptedException { } // Add auth-related flags - if (options.getGithubToken() != null && !options.getGithubToken().isEmpty()) { + 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 + // Default UseLoggedInUser to false when GitHubToken is provided boolean useLoggedInUser = options.getUseLoggedInUser() != null ? options.getUseLoggedInUser() - : (options.getGithubToken() == null || options.getGithubToken().isEmpty()); + : (options.getGitHubToken() == null || options.getGitHubToken().isEmpty()); if (!useLoggedInUser) { args.add("--no-auto-login"); } @@ -85,6 +88,13 @@ ProcessInfo startCliServer() throws IOException, InterruptedException { var pb = new ProcessBuilder(command); pb.redirectErrorStream(false); + // Note: On Windows, console window visibility depends on how the parent Java + // process was launched. GUI applications started with 'javaw' will not create + // visible console windows for subprocesses. Console applications started with + // 'java' will share their console with subprocesses. Java's ProcessBuilder + // doesn't provide explicit CREATE_NO_WINDOW flags like native Windows APIs, + // but the default behavior is appropriate for most use cases. + if (options.getCwd() != null) { pb.directory(new File(options.getCwd())); } @@ -96,8 +106,8 @@ ProcessInfo startCliServer() throws IOException, InterruptedException { 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()); + if (options.getGitHubToken() != null && !options.getGitHubToken().isEmpty()) { + pb.environment().put("COPILOT_SDK_AUTH_TOKEN", options.getGitHubToken()); } Process process = pb.start(); @@ -145,6 +155,9 @@ private void startStderrReader(Process process) { new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { + synchronized (stderrBuffer) { + stderrBuffer.append(line).append('\n'); + } LOG.fine("[CLI] " + line); } } catch (IOException e) { @@ -164,6 +177,10 @@ private Integer waitForPortAnnouncement(Process process) throws IOException { while (System.currentTimeMillis() < deadline) { String line = reader.readLine(); if (line == null) { + String stderr = getStderrOutput(); + if (!stderr.isEmpty()) { + throw new IOException("CLI process exited unexpectedly. stderr: " + stderr); + } throw new IOException("CLI process exited unexpectedly"); } @@ -178,6 +195,18 @@ private Integer waitForPortAnnouncement(Process process) throws IOException { } } + String getStderrOutput() { + synchronized (stderrBuffer) { + return stderrBuffer.toString().trim(); + } + } + + private void clearStderrBuffer() { + synchronized (stderrBuffer) { + stderrBuffer.setLength(0); + } + } + private List resolveCliCommand(String cliPath, List args) { boolean isJsFile = cliPath.toLowerCase().endsWith(".js"); diff --git a/src/main/java/com/github/copilot/sdk/CopilotClient.java b/src/main/java/com/github/copilot/sdk/CopilotClient.java index 46190c469..39034c910 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -4,6 +4,7 @@ package com.github.copilot.sdk; +import java.io.IOException; import java.net.URI; import java.util.ArrayList; import java.util.HashMap; @@ -46,7 +47,10 @@ * try (var client = new CopilotClient()) { * client.start().get(); * - * var session = client.createSession(new SessionConfig().setModel("gpt-5")).get(); + * var session = client + * .createSession( + * new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("gpt-5")) + * .get(); * * session.on(AssistantMessageEvent.class, msg -> { * System.out.println(msg.getData().content()); @@ -109,9 +113,9 @@ public CopilotClient(CopilotClientOptions options) { // Validate auth options with external server if (this.options.getCliUrl() != null && !this.options.getCliUrl().isEmpty() - && (this.options.getGithubToken() != null || this.options.getUseLoggedInUser() != null)) { + && (this.options.getGitHubToken() != null || this.options.getUseLoggedInUser() != null)) { throw new IllegalArgumentException( - "GithubToken and UseLoggedInUser cannot be used with CliUrl (external server manages its own auth)"); + "GitHubToken and UseLoggedInUser cannot be used with CliUrl (external server manages its own auth)"); } // Parse CliUrl if provided @@ -174,11 +178,18 @@ private CompletableFuture startCore() { LOG.info("Copilot client connected"); return connection; } catch (Exception e) { + String stderr = serverManager.getStderrOutput(); + if (!stderr.isEmpty()) { + throw new CompletionException( + new IOException("CLI process exited unexpectedly. stderr: " + stderr, e)); + } throw new CompletionException(e); } }); } + private static final int MIN_PROTOCOL_VERSION = 2; + private void verifyProtocolVersion(Connection connection) throws Exception { int expectedVersion = SdkProtocolVersion.get(); var params = new HashMap(); @@ -191,15 +202,27 @@ private void verifyProtocolVersion(Connection connection) throws Exception { + "Please update your server to ensure compatibility."); } - if (pingResponse.protocolVersion() != expectedVersion) { + int serverVersion = pingResponse.protocolVersion(); + if (serverVersion < MIN_PROTOCOL_VERSION || serverVersion > expectedVersion) { throw new RuntimeException("SDK protocol version mismatch: SDK expects version " + expectedVersion - + ", but server reports version " + pingResponse.protocolVersion() + ". " + + " (minimum " + MIN_PROTOCOL_VERSION + "), but server reports version " + serverVersion + ". " + "Please update your SDK or server to ensure compatibility."); } } /** - * Stops the client and closes all sessions. + * Disconnects from the Copilot server and closes all active sessions. + *

+ * This method performs graceful cleanup: + *

    + *
  1. Closes all active sessions (releases in-memory resources)
  2. + *
  3. Closes the JSON-RPC connection
  4. + *
  5. Terminates the CLI server process (if spawned by this client)
  6. + *
+ *

+ * Note: session data on disk is preserved, so sessions can be resumed later. To + * permanently remove session data before stopping, call + * {@link #deleteSession(String)} for each session first. * * @return A future that completes when the client is stopped */ @@ -267,76 +290,125 @@ private CompletableFuture cleanupConnection() { *

* The session maintains conversation state and can be used to send messages and * receive responses. Remember to close the session when done. + *

+ * A permission handler is required when creating a session. Use + * {@link com.github.copilot.sdk.json.PermissionHandler#APPROVE_ALL} to approve + * all permission requests, or provide a custom handler to control permissions + * selectively. + * + *

+ * Example: + * + *

{@code
+     * var session = client.createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get();
+     * }
* * @param config - * configuration for the session (model, tools, etc.) + * configuration for the session, including the required + * {@link SessionConfig#setOnPermissionRequest(com.github.copilot.sdk.json.PermissionHandler)} + * handler * @return a future that resolves with the created CopilotSession - * @see #createSession() + * @throws IllegalArgumentException + * if {@code config} is {@code null} or does not have a permission + * handler set * @see SessionConfig + * @see com.github.copilot.sdk.json.PermissionHandler#APPROVE_ALL */ public CompletableFuture createSession(SessionConfig config) { + if (config == null || config.getOnPermissionRequest() == null) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("An onPermissionRequest handler is required when creating a session. " + + "For example, to allow all permissions, use: " + + "new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)")); + } return ensureConnected().thenCompose(connection -> { - var request = SessionRequestBuilder.buildCreateRequest(config); + // Pre-generate session ID so the session can be registered before the RPC call, + // ensuring no events emitted by the CLI during creation are lost. + String sessionId = config.getSessionId() != null + ? config.getSessionId() + : java.util.UUID.randomUUID().toString(); + + var session = new CopilotSession(sessionId, connection.rpc); + SessionRequestBuilder.configureSession(session, config); + sessions.put(sessionId, session); + + var request = SessionRequestBuilder.buildCreateRequest(config, sessionId); return connection.rpc.invoke("session.create", request, CreateSessionResponse.class).thenApply(response -> { - var session = new CopilotSession(response.sessionId(), connection.rpc, response.workspacePath()); - SessionRequestBuilder.configureSession(session, config); - sessions.put(response.sessionId(), session); + session.setWorkspacePath(response.workspacePath()); + // If the server returned a different sessionId (e.g. a v2 CLI that ignores + // the client-supplied ID), re-key the sessions map. + String returnedId = response.sessionId(); + if (returnedId != null && !returnedId.equals(sessionId)) { + sessions.remove(sessionId); + session.setActiveSessionId(returnedId); + sessions.put(returnedId, session); + } return session; + }).exceptionally(ex -> { + sessions.remove(sessionId); + throw ex instanceof RuntimeException re ? re : new RuntimeException(ex); }); }); } - /** - * Creates a new Copilot session with default configuration. - * - * @return a future that resolves with the created CopilotSession - * @see #createSession(SessionConfig) - */ - public CompletableFuture createSession() { - return createSession(null); - } - /** * Resumes an existing Copilot session. *

* This restores a previously saved session, allowing you to continue a * conversation. The session's history is preserved. + *

+ * A permission handler is required when resuming a session. Use + * {@link com.github.copilot.sdk.json.PermissionHandler#APPROVE_ALL} to approve + * all permission requests, or provide a custom handler to control permissions + * selectively. * * @param sessionId * the ID of the session to resume * @param config - * configuration for the resumed session + * configuration for the resumed session, including the required + * {@link ResumeSessionConfig#setOnPermissionRequest(com.github.copilot.sdk.json.PermissionHandler)} + * handler * @return a future that resolves with the resumed CopilotSession - * @see #resumeSession(String) + * @throws IllegalArgumentException + * if {@code config} is {@code null} or does not have a permission + * handler set * @see #listSessions() * @see #getLastSessionId() + * @see com.github.copilot.sdk.json.PermissionHandler#APPROVE_ALL */ public CompletableFuture resumeSession(String sessionId, ResumeSessionConfig config) { + if (config == null || config.getOnPermissionRequest() == null) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("An onPermissionRequest handler is required when resuming a session. " + + "For example, to allow all permissions, use: " + + "new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)")); + } return ensureConnected().thenCompose(connection -> { + // Register the session before the RPC call to avoid missing early events. + var session = new CopilotSession(sessionId, connection.rpc); + SessionRequestBuilder.configureSession(session, config); + sessions.put(sessionId, session); + var request = SessionRequestBuilder.buildResumeRequest(sessionId, config); return connection.rpc.invoke("session.resume", request, ResumeSessionResponse.class).thenApply(response -> { - var session = new CopilotSession(response.sessionId(), connection.rpc, response.workspacePath()); - SessionRequestBuilder.configureSession(session, config); - sessions.put(response.sessionId(), session); + session.setWorkspacePath(response.workspacePath()); + // If the server returned a different sessionId than what was requested, re-key. + String returnedId = response.sessionId(); + if (returnedId != null && !returnedId.equals(sessionId)) { + sessions.remove(sessionId); + session.setActiveSessionId(returnedId); + sessions.put(returnedId, session); + } return session; + }).exceptionally(ex -> { + sessions.remove(sessionId); + throw ex instanceof RuntimeException re ? re : new RuntimeException(ex); }); }); } - /** - * Resumes an existing session with default configuration. - * - * @param sessionId - * the ID of the session to resume - * @return a future that resolves with the resumed CopilotSession - * @see #resumeSession(String, ResumeSessionConfig) - */ - public CompletableFuture resumeSession(String sessionId) { - return resumeSession(sessionId, null); - } - /** * Gets the current connection state. * @@ -397,6 +469,10 @@ public CompletableFuture getAuthStatus() { *

* Results are cached after the first successful call to avoid rate limiting. * The cache is cleared when the client disconnects. + *

+ * If an {@code onListModels} handler was provided in + * {@link com.github.copilot.sdk.json.CopilotClientOptions}, it is called + * instead of querying the CLI server. This is useful in BYOK mode. * * @return a future that resolves with a list of available models * @see ModelInfo @@ -408,6 +484,22 @@ public CompletableFuture> listModels() { return CompletableFuture.completedFuture(new ArrayList<>(cached)); } + // If a custom handler is configured, use it instead of querying the CLI server + var onListModels = options.getOnListModels(); + if (onListModels != null) { + synchronized (modelsCacheLock) { + if (modelsCache != null) { + return CompletableFuture.completedFuture(new ArrayList<>(modelsCache)); + } + } + return onListModels.get().thenApply(models -> { + synchronized (modelsCacheLock) { + modelsCache = models; + } + return new ArrayList<>(models); + }); + } + return ensureConnected().thenCompose(connection -> { // Double-check cache inside lock synchronized (modelsCacheLock) { @@ -434,7 +526,7 @@ public CompletableFuture> listModels() { * * @return a future that resolves with the last session ID, or {@code null} if * no sessions exist - * @see #resumeSession(String) + * @see #resumeSession(String, com.github.copilot.sdk.json.ResumeSessionConfig) */ public CompletableFuture getLastSessionId() { return ensureConnected().thenCompose( @@ -443,9 +535,12 @@ public CompletableFuture getLastSessionId() { } /** - * Deletes a session by ID. + * Permanently deletes a session and all its data from disk, including + * conversation history, planning state, and artifacts. *

- * This permanently removes the session and its conversation history. + * Unlike {@link CopilotSession#close()}, which only releases in-memory + * resources and preserves session data for later resumption, this method is + * irreversible. The session cannot be resumed after deletion. * * @param sessionId * the ID of the session to delete @@ -472,7 +567,7 @@ public CompletableFuture deleteSession(String sessionId) { * * @return a future that resolves with a list of session metadata * @see SessionMetadata - * @see #resumeSession(String) + * @see #resumeSession(String, com.github.copilot.sdk.json.ResumeSessionConfig) */ public CompletableFuture> listSessions() { return listSessions(null); @@ -502,7 +597,7 @@ public CompletableFuture> listSessions() { * @return a future that resolves with a list of session metadata * @see SessionMetadata * @see SessionListFilter - * @see #resumeSession(String) + * @see #resumeSession(String, com.github.copilot.sdk.json.ResumeSessionConfig) */ public CompletableFuture> listSessions(SessionListFilter filter) { return ensureConnected().thenCompose(connection -> { diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 722407c50..452e82671 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -7,6 +7,7 @@ import java.io.Closeable; import java.io.IOException; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; @@ -20,13 +21,18 @@ import java.util.logging.Level; import java.util.logging.Logger; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; 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.AssistantMessageEvent; +import com.github.copilot.sdk.events.ExternalToolRequestedEvent; +import com.github.copilot.sdk.events.PermissionRequestedEvent; import com.github.copilot.sdk.events.SessionErrorEvent; import com.github.copilot.sdk.events.SessionEventParser; import com.github.copilot.sdk.events.SessionIdleEvent; +import com.github.copilot.sdk.json.AgentInfo; import com.github.copilot.sdk.json.GetMessagesResponse; import com.github.copilot.sdk.json.HookInvocation; import com.github.copilot.sdk.json.MessageOptions; @@ -34,6 +40,7 @@ import com.github.copilot.sdk.json.PermissionInvocation; import com.github.copilot.sdk.json.PermissionRequest; import com.github.copilot.sdk.json.PermissionRequestResult; +import com.github.copilot.sdk.json.PermissionRequestResultKind; import com.github.copilot.sdk.json.PostToolUseHookInput; import com.github.copilot.sdk.json.PreToolUseHookInput; import com.github.copilot.sdk.json.SendMessageRequest; @@ -42,6 +49,7 @@ import com.github.copilot.sdk.json.SessionHooks; import com.github.copilot.sdk.json.SessionStartHookInput; import com.github.copilot.sdk.json.ToolDefinition; +import com.github.copilot.sdk.json.ToolResultObject; import com.github.copilot.sdk.json.UserInputHandler; import com.github.copilot.sdk.json.UserInputInvocation; import com.github.copilot.sdk.json.UserInputRequest; @@ -54,12 +62,21 @@ * A session maintains conversation state, handles events, and manages tool * execution. Sessions are created via {@link CopilotClient#createSession} or * resumed via {@link CopilotClient#resumeSession}. + *

+ * {@code CopilotSession} implements {@link AutoCloseable}. Use the + * try-with-resources pattern for automatic cleanup, or call {@link #close()} + * explicitly. Closing a session releases in-memory resources but preserves + * session data on disk — the conversation can be resumed later via + * {@link CopilotClient#resumeSession}. To permanently delete session data, use + * {@link CopilotClient#deleteSession}. * *

Example Usage

* *
{@code
- * // Create a session
- * var session = client.createSession(new SessionConfig().setModel("gpt-5")).get();
+ * // Create a session with a permission handler (required)
+ * var session = client
+ * 		.createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("gpt-5"))
+ * 		.get();
  *
  * // Register type-safe event handlers
  * session.on(AssistantMessageEvent.class, msg -> {
@@ -87,8 +104,14 @@ public final class CopilotSession implements AutoCloseable {
     private static final Logger LOG = Logger.getLogger(CopilotSession.class.getName());
     private static final ObjectMapper MAPPER = JsonRpcClient.getObjectMapper();
 
-    private final String sessionId;
-    private final String workspacePath;
+    /**
+     * The current active session ID. Initialized to the pre-generated value and may
+     * be updated after session.create / session.resume if the server returns a
+     * different ID (e.g. when working against a v2 CLI that ignores the
+     * client-supplied sessionId).
+     */
+    private volatile String sessionId;
+    private volatile String workspacePath;
     private final JsonRpcClient rpc;
     private final Set> eventHandlers = ConcurrentHashMap.newKeySet();
     private final Map toolHandlers = new ConcurrentHashMap<>();
@@ -144,6 +167,18 @@ public String getSessionId() {
         return sessionId;
     }
 
+    /**
+     * Updates the active session ID. Package-private; called by CopilotClient if
+     * the server returns a different session ID than the pre-generated one (e.g.
+     * when a v2 CLI ignores the client-supplied sessionId).
+     *
+     * @param sessionId
+     *            the server-confirmed session ID
+     */
+    void setActiveSessionId(String sessionId) {
+        this.sessionId = sessionId;
+    }
+
     /**
      * Gets the path to the session workspace directory when infinite sessions are
      * enabled.
@@ -157,6 +192,17 @@ public String getWorkspacePath() {
         return workspacePath;
     }
 
+    /**
+     * Sets the workspace path. Package-private; called by CopilotClient after
+     * session.create or session.resume RPC response.
+     *
+     * @param workspacePath
+     *            the workspace path
+     */
+    void setWorkspacePath(String workspacePath) {
+        this.workspacePath = workspacePath;
+    }
+
     /**
      * Sets a custom error handler for exceptions thrown by event handlers.
      * 

@@ -312,6 +358,12 @@ public CompletableFuture send(MessageOptions options) { * This method blocks until the assistant finishes processing the message or * until the timeout expires. It's suitable for simple request/response * interactions where you don't need to process streaming events. + *

+ * The returned future can be cancelled via + * {@link java.util.concurrent.Future#cancel(boolean)}. If cancelled externally, + * the future completes with {@link java.util.concurrent.CancellationException}. + * If the timeout expires first, the future completes exceptionally with a + * {@link TimeoutException}. * * @param options * the message options containing the prompt and attachments @@ -320,7 +372,7 @@ public CompletableFuture send(MessageOptions options) { * @return a future that resolves with the final assistant message event, or * {@code null} if no assistant message was received. The future * completes exceptionally with a TimeoutException if the timeout - * expires. + * expires, or with CancellationException if cancelled externally. * @throws IllegalStateException * if this session has been terminated * @see #sendAndWait(MessageOptions) @@ -367,14 +419,33 @@ public CompletableFuture sendAndWait(MessageOptions optio scheduler.shutdown(); }, timeoutMs, TimeUnit.MILLISECONDS); - return future.whenComplete((result, ex) -> { + var result = new CompletableFuture(); + + // When inner future completes, run cleanup and propagate to result + future.whenComplete((r, ex) -> { try { subscription.close(); } catch (IOException e) { LOG.log(Level.SEVERE, "Error closing subscription", e); } scheduler.shutdown(); + if (!result.isDone()) { + if (ex != null) { + result.completeExceptionally(ex); + } else { + result.complete(r); + } + } + }); + + // When result is cancelled externally, cancel inner future to trigger cleanup + result.whenComplete((v, ex) -> { + if (result.isCancelled() && !future.isDone()) { + future.cancel(true); + } }); + + return result; } /** @@ -513,6 +584,10 @@ public Closeable on(Class eventType, Consume * @see #setEventErrorPolicy(EventErrorPolicy) */ void dispatchEvent(AbstractSessionEvent event) { + // Handle broadcast request events (protocol v3) before dispatching to user + // handlers. These are fire-and-forget: the response is sent asynchronously. + handleBroadcastEventAsync(event); + for (Consumer handler : eventHandlers) { try { handler.accept(event); @@ -534,6 +609,136 @@ void dispatchEvent(AbstractSessionEvent event) { } } + /** + * Handles broadcast request events by executing local handlers and responding + * via RPC (protocol v3). + *

+ * Fire-and-forget: the response is sent asynchronously. + * + * @param event + * the event to handle + */ + private void handleBroadcastEventAsync(AbstractSessionEvent event) { + if (event instanceof ExternalToolRequestedEvent toolEvent) { + var data = toolEvent.getData(); + if (data == null || data.requestId() == null || data.toolName() == null) { + return; + } + ToolDefinition tool = getTool(data.toolName()); + if (tool == null) { + return; // This client doesn't handle this tool; another client will + } + executeToolAndRespondAsync(data.requestId(), data.toolName(), data.toolCallId(), data.arguments(), tool); + + } else if (event instanceof PermissionRequestedEvent permEvent) { + var data = permEvent.getData(); + if (data == null || data.requestId() == null || data.permissionRequest() == null) { + return; + } + PermissionHandler handler = permissionHandler.get(); + if (handler == null) { + return; // This client doesn't handle permissions; another client will + } + executePermissionAndRespondAsync(data.requestId(), data.permissionRequest(), handler); + } + } + + /** + * Executes a tool handler and sends the result back via + * {@code session.tools.handlePendingToolCall}. + */ + private void executeToolAndRespondAsync(String requestId, String toolName, String toolCallId, Object arguments, + ToolDefinition tool) { + CompletableFuture.runAsync(() -> { + try { + JsonNode argumentsNode = arguments instanceof JsonNode jn + ? jn + : (arguments != null ? MAPPER.valueToTree(arguments) : null); + var invocation = new com.github.copilot.sdk.json.ToolInvocation().setSessionId(sessionId) + .setToolCallId(toolCallId).setToolName(toolName).setArguments(argumentsNode); + + tool.handler().invoke(invocation).thenAccept(result -> { + try { + ToolResultObject toolResult; + if (result instanceof ToolResultObject tr) { + toolResult = tr; + } else { + toolResult = ToolResultObject + .success(result instanceof String s ? s : MAPPER.writeValueAsString(result)); + } + rpc.invoke("session.tools.handlePendingToolCall", + Map.of("sessionId", sessionId, "requestId", requestId, "result", toolResult), + Object.class); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error sending tool result for requestId=" + requestId, e); + } + }).exceptionally(ex -> { + try { + rpc.invoke( + "session.tools.handlePendingToolCall", Map.of("sessionId", sessionId, "requestId", + requestId, "error", ex.getMessage() != null ? ex.getMessage() : ex.toString()), + Object.class); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error sending tool error for requestId=" + requestId, e); + } + return null; + }); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error executing tool for requestId=" + requestId, e); + try { + rpc.invoke( + "session.tools.handlePendingToolCall", Map.of("sessionId", sessionId, "requestId", + requestId, "error", e.getMessage() != null ? e.getMessage() : e.toString()), + Object.class); + } catch (Exception sendEx) { + LOG.log(Level.WARNING, "Error sending tool error for requestId=" + requestId, sendEx); + } + } + }); + } + + /** + * Executes a permission handler and sends the result back via + * {@code session.permissions.handlePendingPermissionRequest}. + */ + private void executePermissionAndRespondAsync(String requestId, PermissionRequest permissionRequest, + PermissionHandler handler) { + CompletableFuture.runAsync(() -> { + try { + var invocation = new PermissionInvocation(); + invocation.setSessionId(sessionId); + handler.handle(permissionRequest, invocation).thenAccept(result -> { + try { + rpc.invoke("session.permissions.handlePendingPermissionRequest", + Map.of("sessionId", sessionId, "requestId", requestId, "result", result), Object.class); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error sending permission result for requestId=" + requestId, e); + } + }).exceptionally(ex -> { + try { + PermissionRequestResult denied = new PermissionRequestResult(); + denied.setKind(PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER); + rpc.invoke("session.permissions.handlePendingPermissionRequest", + Map.of("sessionId", sessionId, "requestId", requestId, "result", denied), Object.class); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error sending permission denied for requestId=" + requestId, e); + } + return null; + }); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error executing permission handler for requestId=" + requestId, e); + try { + PermissionRequestResult denied = new PermissionRequestResult(); + denied.setKind(PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER); + rpc.invoke("session.permissions.handlePendingPermissionRequest", + Map.of("sessionId", sessionId, "requestId", requestId, "result", denied), Object.class); + } catch (Exception sendEx) { + LOG.log(Level.WARNING, "Error sending permission denied for requestId=" + requestId, sendEx); + } + } + }); + } + /** * Registers custom tool handlers for this session. *

@@ -777,6 +982,161 @@ public CompletableFuture abort() { return rpc.invoke("session.abort", Map.of("sessionId", sessionId), Void.class); } + /** + * Changes the model for this session. + *

+ * The new model takes effect for the next message. Conversation history is + * preserved. + * + *

{@code
+     * session.setModel("gpt-4.1").get();
+     * }
+ * + * @param model + * the model ID to switch to (e.g., {@code "gpt-4.1"}) + * @return a future that completes when the model switch is acknowledged + * @throws IllegalStateException + * if this session has been terminated + * @since 1.0.11 + */ + public CompletableFuture setModel(String model) { + ensureNotTerminated(); + return rpc.invoke("session.model.switchTo", Map.of("sessionId", sessionId, "modelId", model), Void.class); + } + + /** + * Logs a message to the session timeline. + *

+ * The message appears in the session event stream and is visible to SDK + * consumers. Non-ephemeral messages are also persisted to the session event log + * on disk. + * + *

Example Usage

+ * + *
{@code
+     * session.log("Build completed successfully").get();
+     * session.log("Disk space low", "warning", null).get();
+     * session.log("Temporary status", null, true).get();
+     * }
+ * + * @param message + * the message to log + * @param level + * the log severity level ({@code "info"}, {@code "warning"}, + * {@code "error"}), or {@code null} to use the default + * ({@code "info"}) + * @param ephemeral + * when {@code true}, the message is transient and not persisted to + * disk; {@code null} uses default behavior + * @return a future that completes when the message is logged + * @throws IllegalStateException + * if this session has been terminated + */ + public CompletableFuture log(String message, String level, Boolean ephemeral) { + ensureNotTerminated(); + var params = new java.util.HashMap(); + params.put("sessionId", sessionId); + params.put("message", message); + if (level != null) { + params.put("level", level); + } + if (ephemeral != null) { + params.put("ephemeral", ephemeral); + } + return rpc.invoke("session.log", params, Void.class); + } + + /** + * Logs an informational message to the session timeline. + * + * @param message + * the message to log + * @return a future that completes when the message is logged + * @throws IllegalStateException + * if this session has been terminated + */ + public CompletableFuture log(String message) { + return log(message, null, null); + } + + /** + * Lists the custom agents available for selection in this session. + * + * @return a future that resolves with the list of available agents + * @throws IllegalStateException + * if this session has been terminated + * @since 1.0.11 + */ + public CompletableFuture> listAgents() { + ensureNotTerminated(); + return rpc.invoke("session.agent.list", Map.of("sessionId", sessionId), AgentListResponse.class) + .thenApply(response -> response.agents() != null + ? Collections.unmodifiableList(response.agents()) + : Collections.emptyList()); + } + + /** + * Gets the currently selected custom agent for this session, or {@code null} if + * no custom agent is selected. + * + * @return a future that resolves with the current agent, or {@code null} if + * using the default agent + * @throws IllegalStateException + * if this session has been terminated + * @since 1.0.11 + */ + public CompletableFuture getCurrentAgent() { + ensureNotTerminated(); + return rpc.invoke("session.agent.getCurrent", Map.of("sessionId", sessionId), AgentGetCurrentResponse.class) + .thenApply(AgentGetCurrentResponse::agent); + } + + /** + * Selects a custom agent for this session. + * + * @param agentName + * the name/identifier of the agent to select + * @return a future that resolves with the selected agent information + * @throws IllegalStateException + * if this session has been terminated + * @since 1.0.11 + */ + public CompletableFuture selectAgent(String agentName) { + ensureNotTerminated(); + return rpc.invoke("session.agent.select", Map.of("sessionId", sessionId, "name", agentName), + AgentSelectResponse.class).thenApply(AgentSelectResponse::agent); + } + + /** + * Deselects the currently selected custom agent, returning to the default + * agent. + * + * @return a future that completes when the agent is deselected + * @throws IllegalStateException + * if this session has been terminated + * @since 1.0.11 + */ + public CompletableFuture deselectAgent() { + ensureNotTerminated(); + return rpc.invoke("session.agent.deselect", Map.of("sessionId", sessionId), Void.class); + } + + /** + * Compacts the session context to reduce token usage. + *

+ * This triggers an immediate session compaction, summarizing the conversation + * history to free up context window space. + * + * @return a future that completes when compaction finishes + * @throws IllegalStateException + * if this session has been terminated + * @since 1.0.11 + */ + public CompletableFuture compact() { + ensureNotTerminated(); + return rpc.invoke("session.compaction.compact", Map.of("sessionId", sessionId), Void.class); + } + /** * Verifies that this session has not yet been terminated. * @@ -818,4 +1178,18 @@ public void close() { hooksHandler.set(null); } + // ===== Internal response types for agent API ===== + + @JsonIgnoreProperties(ignoreUnknown = true) + private record AgentListResponse(@JsonProperty("agents") List agents) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record AgentGetCurrentResponse(@JsonProperty("agent") AgentInfo agent) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record AgentSelectResponse(@JsonProperty("agent") AgentInfo agent) { + } + } diff --git a/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java b/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java index 671aea593..2f041c7e4 100644 --- a/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java +++ b/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java @@ -17,6 +17,7 @@ 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.PermissionRequestResultKind; import com.github.copilot.sdk.json.SessionLifecycleEvent; import com.github.copilot.sdk.json.SessionLifecycleEventMetadata; import com.github.copilot.sdk.json.ToolDefinition; @@ -183,7 +184,7 @@ private void handlePermissionRequest(JsonRpcClient rpc, String requestId, JsonNo CopilotSession session = sessions.get(sessionId); if (session == null) { var result = new PermissionRequestResult() - .setKind("denied-no-approval-rule-and-could-not-request-from-user"); + .setKind(PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER); rpc.sendResponse(Long.parseLong(requestId), Map.of("result", result)); return; } @@ -197,7 +198,7 @@ private void handlePermissionRequest(JsonRpcClient rpc, String requestId, JsonNo }).exceptionally(ex -> { try { var result = new PermissionRequestResult() - .setKind("denied-no-approval-rule-and-could-not-request-from-user"); + .setKind(PermissionRequestResultKind.DENIED_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); diff --git a/src/main/java/com/github/copilot/sdk/SdkProtocolVersion.java b/src/main/java/com/github/copilot/sdk/SdkProtocolVersion.java index 40a7f9d56..3b00a88ae 100644 --- a/src/main/java/com/github/copilot/sdk/SdkProtocolVersion.java +++ b/src/main/java/com/github/copilot/sdk/SdkProtocolVersion.java @@ -14,7 +14,7 @@ */ public enum SdkProtocolVersion { - LATEST(2); + LATEST(3); private int versionNumber; diff --git a/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java b/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java index a59940bcf..7ea0be880 100644 --- a/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java +++ b/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java @@ -27,29 +27,36 @@ private SessionRequestBuilder() { * * @param config * the session configuration (may be null) + * @param sessionId + * the pre-generated session ID to use * @return the built request object */ - static CreateSessionRequest buildCreateRequest(SessionConfig config) { + static CreateSessionRequest buildCreateRequest(SessionConfig config, String sessionId) { var request = new CreateSessionRequest(); + // Always request permission callbacks to enable deny-by-default behavior + request.setRequestPermission(true); + // Always send envValueMode=direct for MCP servers + request.setEnvValueMode("direct"); + request.setSessionId(sessionId); if (config == null) { return request; } request.setModel(config.getModel()); - request.setSessionId(config.getSessionId()); + request.setClientName(config.getClientName()); request.setReasoningEffort(config.getReasoningEffort()); request.setTools(config.getTools()); 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.setAgent(config.getAgent()); request.setInfiniteSessions(config.getInfiniteSessions()); request.setSkillDirectories(config.getSkillDirectories()); request.setDisabledSkills(config.getDisabledSkills()); @@ -58,6 +65,22 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config) { return request; } + /** + * Builds a CreateSessionRequest from the given configuration. + * + * @param config + * the session configuration (may be null) + * @return the built request object + * @deprecated Use {@link #buildCreateRequest(SessionConfig, String)} instead. + */ + @Deprecated + static CreateSessionRequest buildCreateRequest(SessionConfig config) { + String sessionId = (config != null && config.getSessionId() != null) + ? config.getSessionId() + : java.util.UUID.randomUUID().toString(); + return buildCreateRequest(config, sessionId); + } + /** * Builds a ResumeSessionRequest from the given session ID and configuration. * @@ -70,19 +93,23 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config) { static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionConfig config) { var request = new ResumeSessionRequest(); request.setSessionId(sessionId); + // Always request permission callbacks to enable deny-by-default behavior + request.setRequestPermission(true); + // Always send envValueMode=direct for MCP servers + request.setEnvValueMode("direct"); if (config == null) { return request; } request.setModel(config.getModel()); + request.setClientName(config.getClientName()); request.setReasoningEffort(config.getReasoningEffort()); request.setTools(config.getTools()); 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()); @@ -91,6 +118,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo request.setStreaming(config.isStreaming() ? true : null); request.setMcpServers(config.getMcpServers()); request.setCustomAgents(config.getCustomAgents()); + request.setAgent(config.getAgent()); request.setSkillDirectories(config.getSkillDirectories()); request.setDisabledSkills(config.getDisabledSkills()); request.setInfiniteSessions(config.getInfiniteSessions()); @@ -123,6 +151,9 @@ static void configureSession(CopilotSession session, SessionConfig config) { if (config.getHooks() != null) { session.registerHooks(config.getHooks()); } + if (config.getOnEvent() != null) { + session.on(config.getOnEvent()); + } } /** @@ -150,5 +181,8 @@ static void configureSession(CopilotSession session, ResumeSessionConfig config) if (config.getHooks() != null) { session.registerHooks(config.getHooks()); } + if (config.getOnEvent() != null) { + session.on(config.getOnEvent()); + } } } diff --git a/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java b/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java index 00c95ed4b..5127f6eee 100644 --- a/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java @@ -52,22 +52,28 @@ public abstract sealed class AbstractSessionEvent permits // Session events SessionStartEvent, SessionResumeEvent, SessionErrorEvent, SessionIdleEvent, SessionInfoEvent, - SessionModelChangeEvent, SessionHandoffEvent, SessionTruncationEvent, SessionSnapshotRewindEvent, - SessionUsageInfoEvent, SessionCompactionStartEvent, SessionCompactionCompleteEvent, SessionShutdownEvent, - SessionContextChangedEvent, + SessionModelChangeEvent, SessionModeChangedEvent, SessionPlanChangedEvent, SessionWorkspaceFileChangedEvent, + SessionHandoffEvent, SessionTruncationEvent, SessionSnapshotRewindEvent, SessionUsageInfoEvent, + SessionCompactionStartEvent, SessionCompactionCompleteEvent, SessionShutdownEvent, SessionContextChangedEvent, + SessionTaskCompleteEvent, // Assistant events AssistantTurnStartEvent, AssistantIntentEvent, AssistantReasoningEvent, AssistantReasoningDeltaEvent, - AssistantMessageEvent, AssistantMessageDeltaEvent, AssistantTurnEndEvent, AssistantUsageEvent, AbortEvent, + AssistantMessageEvent, AssistantMessageDeltaEvent, AssistantStreamingDeltaEvent, AssistantTurnEndEvent, + AssistantUsageEvent, AbortEvent, // Tool events ToolUserRequestedEvent, ToolExecutionStartEvent, ToolExecutionPartialResultEvent, ToolExecutionProgressEvent, ToolExecutionCompleteEvent, + // Broadcast request/completion events (protocol v3) + ExternalToolRequestedEvent, ExternalToolCompletedEvent, PermissionRequestedEvent, PermissionCompletedEvent, + CommandQueuedEvent, CommandCompletedEvent, ExitPlanModeRequestedEvent, ExitPlanModeCompletedEvent, + SystemNotificationEvent, // User events UserMessageEvent, PendingMessagesModifiedEvent, // Skill events SkillInvokedEvent, // Other events - SubagentStartedEvent, SubagentCompletedEvent, SubagentFailedEvent, SubagentSelectedEvent, HookStartEvent, - HookEndEvent, SystemMessageEvent { + SubagentStartedEvent, SubagentCompletedEvent, SubagentFailedEvent, SubagentSelectedEvent, + SubagentDeselectedEvent, HookStartEvent, HookEndEvent, SystemMessageEvent { @JsonProperty("id") private UUID id; diff --git a/src/main/java/com/github/copilot/sdk/events/AssistantMessageDeltaEvent.java b/src/main/java/com/github/copilot/sdk/events/AssistantMessageDeltaEvent.java index 901b05236..f9b166186 100644 --- a/src/main/java/com/github/copilot/sdk/events/AssistantMessageDeltaEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/AssistantMessageDeltaEvent.java @@ -34,7 +34,6 @@ public void setData(AssistantMessageDeltaData data) { @JsonIgnoreProperties(ignoreUnknown = true) public record AssistantMessageDeltaData(@JsonProperty("messageId") String messageId, @JsonProperty("deltaContent") String deltaContent, - @JsonProperty("totalResponseSizeBytes") Double totalResponseSizeBytes, @JsonProperty("parentToolCallId") String parentToolCallId) { } } 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 22191a5f4..075d3cd1b 100644 --- a/src/main/java/com/github/copilot/sdk/events/AssistantMessageEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/AssistantMessageEvent.java @@ -73,6 +73,7 @@ public void setData(AssistantMessageData data) { public record AssistantMessageData(@JsonProperty("messageId") String messageId, @JsonProperty("content") String content, @JsonProperty("toolRequests") List toolRequests, @JsonProperty("parentToolCallId") String parentToolCallId, + @JsonProperty("interactionId") String interactionId, @JsonProperty("reasoningOpaque") String reasoningOpaque, @JsonProperty("reasoningText") String reasoningText, @JsonProperty("encryptedContent") String encryptedContent) { diff --git a/src/main/java/com/github/copilot/sdk/events/AssistantStreamingDeltaEvent.java b/src/main/java/com/github/copilot/sdk/events/AssistantStreamingDeltaEvent.java new file mode 100644 index 000000000..ff5c2f646 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/AssistantStreamingDeltaEvent.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: assistant.streaming_delta + * + * @since 1.0.11 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class AssistantStreamingDeltaEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private AssistantStreamingDeltaData data; + + @Override + public String getType() { + return "assistant.streaming_delta"; + } + + public AssistantStreamingDeltaData getData() { + return data; + } + + public void setData(AssistantStreamingDeltaData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record AssistantStreamingDeltaData(@JsonProperty("totalResponseSizeBytes") double totalResponseSizeBytes) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/AssistantTurnStartEvent.java b/src/main/java/com/github/copilot/sdk/events/AssistantTurnStartEvent.java index ab5e18653..3a1d6e5ef 100644 --- a/src/main/java/com/github/copilot/sdk/events/AssistantTurnStartEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/AssistantTurnStartEvent.java @@ -32,6 +32,7 @@ public void setData(AssistantTurnStartData data) { } @JsonIgnoreProperties(ignoreUnknown = true) - public record AssistantTurnStartData(@JsonProperty("turnId") String turnId) { + public record AssistantTurnStartData(@JsonProperty("turnId") String turnId, + @JsonProperty("interactionId") String interactionId) { } } 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 6e51064e6..9aa21925b 100644 --- a/src/main/java/com/github/copilot/sdk/events/AssistantUsageEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/AssistantUsageEvent.java @@ -8,6 +8,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.Collections; +import java.util.List; import java.util.Map; /** @@ -42,7 +43,8 @@ public record AssistantUsageData(@JsonProperty("model") String model, @JsonProperty("duration") Double duration, @JsonProperty("initiator") String initiator, @JsonProperty("apiCallId") String apiCallId, @JsonProperty("providerCallId") String providerCallId, @JsonProperty("parentToolCallId") String parentToolCallId, - @JsonProperty("quotaSnapshots") Map quotaSnapshots) { + @JsonProperty("quotaSnapshots") Map quotaSnapshots, + @JsonProperty("copilotUsage") CopilotUsage copilotUsage) { /** Returns a defensive copy of the quota snapshots map. */ @Override @@ -50,4 +52,27 @@ public Map quotaSnapshots() { return quotaSnapshots == null ? Collections.emptyMap() : Collections.unmodifiableMap(quotaSnapshots); } } + + /** + * Copilot usage details including token cost information. + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record CopilotUsage(@JsonProperty("tokenDetails") List tokenDetails, + @JsonProperty("totalNanoAiu") double totalNanoAiu) { + + /** Returns a defensive copy of the token details list. */ + @Override + public List tokenDetails() { + return tokenDetails == null ? null : Collections.unmodifiableList(tokenDetails); + } + } + + /** + * Token details for a specific token type. + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record TokenDetails(@JsonProperty("batchSize") double batchSize, + @JsonProperty("costPerBatch") double costPerBatch, @JsonProperty("tokenCount") double tokenCount, + @JsonProperty("tokenType") String tokenType) { + } } diff --git a/src/main/java/com/github/copilot/sdk/events/CommandCompletedEvent.java b/src/main/java/com/github/copilot/sdk/events/CommandCompletedEvent.java new file mode 100644 index 000000000..f9aeb0f3f --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/CommandCompletedEvent.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: command.completed + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class CommandCompletedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private CommandCompletedData data; + + @Override + public String getType() { + return "command.completed"; + } + + public CommandCompletedData getData() { + return data; + } + + public void setData(CommandCompletedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record CommandCompletedData(@JsonProperty("requestId") String requestId) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/CommandQueuedEvent.java b/src/main/java/com/github/copilot/sdk/events/CommandQueuedEvent.java new file mode 100644 index 000000000..acd35a89c --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/CommandQueuedEvent.java @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: command.queued + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class CommandQueuedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private CommandQueuedData data; + + @Override + public String getType() { + return "command.queued"; + } + + public CommandQueuedData getData() { + return data; + } + + public void setData(CommandQueuedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record CommandQueuedData(@JsonProperty("requestId") String requestId, + @JsonProperty("command") String command) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/ExitPlanModeCompletedEvent.java b/src/main/java/com/github/copilot/sdk/events/ExitPlanModeCompletedEvent.java new file mode 100644 index 000000000..217859e43 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/ExitPlanModeCompletedEvent.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: exit_plan_mode.completed + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ExitPlanModeCompletedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private ExitPlanModeCompletedData data; + + @Override + public String getType() { + return "exit_plan_mode.completed"; + } + + public ExitPlanModeCompletedData getData() { + return data; + } + + public void setData(ExitPlanModeCompletedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ExitPlanModeCompletedData(@JsonProperty("requestId") String requestId) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/ExitPlanModeRequestedEvent.java b/src/main/java/com/github/copilot/sdk/events/ExitPlanModeRequestedEvent.java new file mode 100644 index 000000000..b0019c3ce --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/ExitPlanModeRequestedEvent.java @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: exit_plan_mode.requested + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ExitPlanModeRequestedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private ExitPlanModeRequestedData data; + + @Override + public String getType() { + return "exit_plan_mode.requested"; + } + + public ExitPlanModeRequestedData getData() { + return data; + } + + public void setData(ExitPlanModeRequestedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ExitPlanModeRequestedData(@JsonProperty("requestId") String requestId, + @JsonProperty("summary") String summary, @JsonProperty("planContent") String planContent, + @JsonProperty("actions") String[] actions, @JsonProperty("recommendedAction") String recommendedAction) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/ExternalToolCompletedEvent.java b/src/main/java/com/github/copilot/sdk/events/ExternalToolCompletedEvent.java new file mode 100644 index 000000000..83a582720 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/ExternalToolCompletedEvent.java @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: external_tool.completed + *

+ * Broadcast when a pending tool call has been resolved by a client (protocol + * v3). + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ExternalToolCompletedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private ExternalToolCompletedData data; + + @Override + public String getType() { + return "external_tool.completed"; + } + + public ExternalToolCompletedData getData() { + return data; + } + + public void setData(ExternalToolCompletedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ExternalToolCompletedData(@JsonProperty("requestId") String requestId) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/ExternalToolRequestedEvent.java b/src/main/java/com/github/copilot/sdk/events/ExternalToolRequestedEvent.java new file mode 100644 index 000000000..8eb11f5b8 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/ExternalToolRequestedEvent.java @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: external_tool.requested + *

+ * Broadcast when the CLI needs a client to handle a tool call (protocol v3). + * Clients that own the named tool should respond via + * {@code session.tools.handlePendingToolCall}. + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ExternalToolRequestedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private ExternalToolRequestedData data; + + @Override + public String getType() { + return "external_tool.requested"; + } + + public ExternalToolRequestedData getData() { + return data; + } + + public void setData(ExternalToolRequestedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ExternalToolRequestedData(@JsonProperty("requestId") String requestId, + @JsonProperty("sessionId") String sessionId, @JsonProperty("toolCallId") String toolCallId, + @JsonProperty("toolName") String toolName, @JsonProperty("arguments") Object arguments) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/PermissionCompletedEvent.java b/src/main/java/com/github/copilot/sdk/events/PermissionCompletedEvent.java new file mode 100644 index 000000000..90daf3b49 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/PermissionCompletedEvent.java @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: permission.completed + *

+ * Broadcast when a pending permission request has been resolved by a client + * (protocol v3). + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class PermissionCompletedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private PermissionCompletedData data; + + @Override + public String getType() { + return "permission.completed"; + } + + public PermissionCompletedData getData() { + return data; + } + + public void setData(PermissionCompletedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record PermissionCompletedData(@JsonProperty("requestId") String requestId, + @JsonProperty("result") PermissionCompletedResult result) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record PermissionCompletedResult(@JsonProperty("kind") String kind) { + } + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/PermissionRequestedEvent.java b/src/main/java/com/github/copilot/sdk/events/PermissionRequestedEvent.java new file mode 100644 index 000000000..d8f9ec147 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/PermissionRequestedEvent.java @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.sdk.json.PermissionRequest; + +/** + * Event: permission.requested + *

+ * Broadcast when the CLI needs a client to handle a permission request + * (protocol v3). Clients that have a permission handler should respond via + * {@code session.permissions.handlePendingPermissionRequest}. + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class PermissionRequestedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private PermissionRequestedData data; + + @Override + public String getType() { + return "permission.requested"; + } + + public PermissionRequestedData getData() { + return data; + } + + public void setData(PermissionRequestedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record PermissionRequestedData(@JsonProperty("requestId") String requestId, + @JsonProperty("permissionRequest") PermissionRequest permissionRequest) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java b/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java index 4cc1763cf..75971b29e 100644 --- a/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java +++ b/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java @@ -55,6 +55,9 @@ public class SessionEventParser { TYPE_MAP.put("session.idle", SessionIdleEvent.class); TYPE_MAP.put("session.info", SessionInfoEvent.class); TYPE_MAP.put("session.model_change", SessionModelChangeEvent.class); + TYPE_MAP.put("session.mode_changed", SessionModeChangedEvent.class); + TYPE_MAP.put("session.plan_changed", SessionPlanChangedEvent.class); + TYPE_MAP.put("session.workspace_file_changed", SessionWorkspaceFileChangedEvent.class); TYPE_MAP.put("session.handoff", SessionHandoffEvent.class); TYPE_MAP.put("session.truncation", SessionTruncationEvent.class); TYPE_MAP.put("session.snapshot_rewind", SessionSnapshotRewindEvent.class); @@ -62,6 +65,7 @@ public class SessionEventParser { TYPE_MAP.put("session.compaction_start", SessionCompactionStartEvent.class); TYPE_MAP.put("session.compaction_complete", SessionCompactionCompleteEvent.class); TYPE_MAP.put("session.context_changed", SessionContextChangedEvent.class); + TYPE_MAP.put("session.task_complete", SessionTaskCompleteEvent.class); TYPE_MAP.put("user.message", UserMessageEvent.class); TYPE_MAP.put("pending_messages.modified", PendingMessagesModifiedEvent.class); TYPE_MAP.put("assistant.turn_start", AssistantTurnStartEvent.class); @@ -70,6 +74,7 @@ public class SessionEventParser { TYPE_MAP.put("assistant.reasoning_delta", AssistantReasoningDeltaEvent.class); TYPE_MAP.put("assistant.message", AssistantMessageEvent.class); TYPE_MAP.put("assistant.message_delta", AssistantMessageDeltaEvent.class); + TYPE_MAP.put("assistant.streaming_delta", AssistantStreamingDeltaEvent.class); TYPE_MAP.put("assistant.turn_end", AssistantTurnEndEvent.class); TYPE_MAP.put("assistant.usage", AssistantUsageEvent.class); TYPE_MAP.put("abort", AbortEvent.class); @@ -82,11 +87,21 @@ public class SessionEventParser { TYPE_MAP.put("subagent.completed", SubagentCompletedEvent.class); TYPE_MAP.put("subagent.failed", SubagentFailedEvent.class); TYPE_MAP.put("subagent.selected", SubagentSelectedEvent.class); + TYPE_MAP.put("subagent.deselected", SubagentDeselectedEvent.class); TYPE_MAP.put("hook.start", HookStartEvent.class); TYPE_MAP.put("hook.end", HookEndEvent.class); TYPE_MAP.put("system.message", SystemMessageEvent.class); TYPE_MAP.put("session.shutdown", SessionShutdownEvent.class); TYPE_MAP.put("skill.invoked", SkillInvokedEvent.class); + TYPE_MAP.put("external_tool.requested", ExternalToolRequestedEvent.class); + TYPE_MAP.put("external_tool.completed", ExternalToolCompletedEvent.class); + TYPE_MAP.put("permission.requested", PermissionRequestedEvent.class); + TYPE_MAP.put("permission.completed", PermissionCompletedEvent.class); + TYPE_MAP.put("command.queued", CommandQueuedEvent.class); + TYPE_MAP.put("command.completed", CommandCompletedEvent.class); + TYPE_MAP.put("exit_plan_mode.requested", ExitPlanModeRequestedEvent.class); + TYPE_MAP.put("exit_plan_mode.completed", ExitPlanModeCompletedEvent.class); + TYPE_MAP.put("system.notification", SystemNotificationEvent.class); } /** diff --git a/src/main/java/com/github/copilot/sdk/events/SessionModeChangedEvent.java b/src/main/java/com/github/copilot/sdk/events/SessionModeChangedEvent.java new file mode 100644 index 000000000..3c5b5d661 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/SessionModeChangedEvent.java @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: session.mode_changed + * + * @since 1.0.10 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class SessionModeChangedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private SessionModeChangedData data; + + @Override + public String getType() { + return "session.mode_changed"; + } + + public SessionModeChangedData getData() { + return data; + } + + public void setData(SessionModeChangedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record SessionModeChangedData(@JsonProperty("previousMode") String previousMode, + @JsonProperty("newMode") String newMode) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/SessionPlanChangedEvent.java b/src/main/java/com/github/copilot/sdk/events/SessionPlanChangedEvent.java new file mode 100644 index 000000000..2010cf146 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/SessionPlanChangedEvent.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: session.plan_changed + * + * @since 1.0.10 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class SessionPlanChangedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private SessionPlanChangedData data; + + @Override + public String getType() { + return "session.plan_changed"; + } + + public SessionPlanChangedData getData() { + return data; + } + + public void setData(SessionPlanChangedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record SessionPlanChangedData(@JsonProperty("operation") String operation) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/SessionTaskCompleteEvent.java b/src/main/java/com/github/copilot/sdk/events/SessionTaskCompleteEvent.java new file mode 100644 index 000000000..d82b516ab --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/SessionTaskCompleteEvent.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: session.task_complete + * + * @since 1.0.11 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class SessionTaskCompleteEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private SessionTaskCompleteData data; + + @Override + public String getType() { + return "session.task_complete"; + } + + public SessionTaskCompleteData getData() { + return data; + } + + public void setData(SessionTaskCompleteData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record SessionTaskCompleteData(@JsonProperty("summary") String summary) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/SessionWorkspaceFileChangedEvent.java b/src/main/java/com/github/copilot/sdk/events/SessionWorkspaceFileChangedEvent.java new file mode 100644 index 000000000..0ed30aeb8 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/SessionWorkspaceFileChangedEvent.java @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: session.workspace_file_changed + * + * @since 1.0.10 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class SessionWorkspaceFileChangedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private SessionWorkspaceFileChangedData data; + + @Override + public String getType() { + return "session.workspace_file_changed"; + } + + public SessionWorkspaceFileChangedData getData() { + return data; + } + + public void setData(SessionWorkspaceFileChangedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record SessionWorkspaceFileChangedData(@JsonProperty("path") String path, + @JsonProperty("operation") String operation) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/SkillInvokedEvent.java b/src/main/java/com/github/copilot/sdk/events/SkillInvokedEvent.java index febe01253..4ed8e2327 100644 --- a/src/main/java/com/github/copilot/sdk/events/SkillInvokedEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/SkillInvokedEvent.java @@ -40,6 +40,7 @@ public void setData(SkillInvokedData data) { */ @JsonIgnoreProperties(ignoreUnknown = true) public record SkillInvokedData(@JsonProperty("name") String name, @JsonProperty("path") String path, - @JsonProperty("content") String content, @JsonProperty("allowedTools") List allowedTools) { + @JsonProperty("content") String content, @JsonProperty("allowedTools") List allowedTools, + @JsonProperty("pluginName") String pluginName, @JsonProperty("pluginVersion") String pluginVersion) { } } diff --git a/src/main/java/com/github/copilot/sdk/events/SubagentDeselectedEvent.java b/src/main/java/com/github/copilot/sdk/events/SubagentDeselectedEvent.java new file mode 100644 index 000000000..f49f46330 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/SubagentDeselectedEvent.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: subagent.deselected + * + * @since 1.0.11 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class SubagentDeselectedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private SubagentDeselectedData data; + + @Override + public String getType() { + return "subagent.deselected"; + } + + public SubagentDeselectedData getData() { + return data; + } + + public void setData(SubagentDeselectedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record SubagentDeselectedData() { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/SystemNotificationEvent.java b/src/main/java/com/github/copilot/sdk/events/SystemNotificationEvent.java new file mode 100644 index 000000000..38711f276 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/SystemNotificationEvent.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: system.notification + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class SystemNotificationEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private SystemNotificationData data; + + @Override + public String getType() { + return "system.notification"; + } + + public SystemNotificationData getData() { + return data; + } + + public void setData(SystemNotificationData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record SystemNotificationData(@JsonProperty("content") String content, @JsonProperty("kind") Object kind) { + } +} 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 d6cc1662f..f085f43ce 100644 --- a/src/main/java/com/github/copilot/sdk/events/ToolExecutionCompleteEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/ToolExecutionCompleteEvent.java @@ -36,9 +36,10 @@ public void setData(ToolExecutionCompleteData data) { @JsonIgnoreProperties(ignoreUnknown = true) public record ToolExecutionCompleteData(@JsonProperty("toolCallId") String toolCallId, - @JsonProperty("success") boolean success, @JsonProperty("isUserRequested") Boolean isUserRequested, - @JsonProperty("result") Result result, @JsonProperty("error") Error error, - @JsonProperty("toolTelemetry") Map toolTelemetry, + @JsonProperty("success") boolean success, @JsonProperty("model") String model, + @JsonProperty("interactionId") String interactionId, + @JsonProperty("isUserRequested") Boolean isUserRequested, @JsonProperty("result") Result result, + @JsonProperty("error") Error error, @JsonProperty("toolTelemetry") Map toolTelemetry, @JsonProperty("parentToolCallId") String parentToolCallId) { /** Returns a defensive copy of the tool telemetry map. */ diff --git a/src/main/java/com/github/copilot/sdk/json/AgentInfo.java b/src/main/java/com/github/copilot/sdk/json/AgentInfo.java new file mode 100644 index 000000000..1cc10a91d --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/AgentInfo.java @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Represents a custom agent available for selection in a session. + * + * @since 1.0.11 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class AgentInfo { + + @JsonProperty("name") + private String name; + + @JsonProperty("displayName") + private String displayName; + + @JsonProperty("description") + private String description; + + /** + * Gets the unique identifier of the agent. + * + * @return the agent name/identifier + */ + public String getName() { + return name; + } + + /** + * Sets the unique identifier of the agent. + * + * @param name + * the agent name/identifier + * @return this instance for chaining + */ + public AgentInfo setName(String name) { + this.name = name; + return this; + } + + /** + * Gets the human-readable display name of the agent. + * + * @return the display name + */ + public String getDisplayName() { + return displayName; + } + + /** + * Sets the human-readable display name of the agent. + * + * @param displayName + * the display name + * @return this instance for chaining + */ + public AgentInfo setDisplayName(String displayName) { + this.displayName = displayName; + return this; + } + + /** + * Gets the description of the agent's purpose. + * + * @return the description + */ + public String getDescription() { + return description; + } + + /** + * Sets the description of the agent's purpose. + * + * @param description + * the description + * @return this instance for chaining + */ + public AgentInfo setDescription(String description) { + this.description = description; + return this; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java index b51ab7a19..4fd55d3ba 100644 --- a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java +++ b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java @@ -4,7 +4,10 @@ package com.github.copilot.sdk.json; +import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; import com.fasterxml.jackson.annotation.JsonInclude; @@ -41,8 +44,9 @@ public class CopilotClientOptions { private boolean autoStart = true; private boolean autoRestart = true; private Map environment; - private String githubToken; + private String gitHubToken; private Boolean useLoggedInUser; + private Supplier>> onListModels; /** * Gets the path to the Copilot CLI executable. @@ -279,8 +283,8 @@ public CopilotClientOptions setEnvironment(Map environment) { * * @return the GitHub token, or {@code null} to use other authentication methods */ - public String getGithubToken() { - return githubToken; + public String getGitHubToken() { + return gitHubToken; } /** @@ -289,12 +293,37 @@ public String getGithubToken() { * When provided, the token is passed to the CLI server via environment * variable. This takes priority over other authentication methods. * + * @param gitHubToken + * the GitHub token + * @return this options instance for method chaining + */ + public CopilotClientOptions setGitHubToken(String gitHubToken) { + this.gitHubToken = gitHubToken; + return this; + } + + /** + * Gets the GitHub token for authentication. + * + * @return the GitHub token, or {@code null} to use other authentication methods + * @deprecated Use {@link #getGitHubToken()} instead. + */ + @Deprecated + public String getGithubToken() { + return gitHubToken; + } + + /** + * Sets the GitHub token to use for authentication. + * * @param githubToken * the GitHub token * @return this options instance for method chaining + * @deprecated Use {@link #setGitHubToken(String)} instead. */ + @Deprecated public CopilotClientOptions setGithubToken(String githubToken) { - this.githubToken = githubToken; + this.gitHubToken = githubToken; return this; } @@ -312,8 +341,8 @@ public Boolean getUseLoggedInUser() { * Sets whether to use the logged-in user for authentication. *

* When true, the CLI server will attempt to use stored OAuth tokens or gh CLI - * auth. When false, only explicit tokens (githubToken or environment variables) - * are used. Default: true (but defaults to false when githubToken is provided). + * auth. When false, only explicit tokens (gitHubToken or environment variables) + * are used. Default: true (but defaults to false when gitHubToken is provided). * * @param useLoggedInUser * {@code true} to use logged-in user auth, {@code false} otherwise @@ -323,4 +352,58 @@ public CopilotClientOptions setUseLoggedInUser(Boolean useLoggedInUser) { this.useLoggedInUser = useLoggedInUser; return this; } + + /** + * Gets the custom handler for listing available models. + * + * @return the handler, or {@code null} if not set + */ + public Supplier>> getOnListModels() { + return onListModels; + } + + /** + * Sets a custom handler for listing available models. + *

+ * When provided, {@code listModels()} calls this handler instead of querying + * the CLI server. Useful in BYOK (Bring Your Own Key) mode to return models + * available from your custom provider. + * + * @param onListModels + * the handler that returns the list of available models + * @return this options instance for method chaining + */ + public CopilotClientOptions setOnListModels(Supplier>> onListModels) { + this.onListModels = onListModels; + return this; + } + + /** + * Creates a shallow clone of this {@code CopilotClientOptions} instance. + *

+ * Array properties (like {@code cliArgs}) are copied into new arrays so that + * modifications to the clone do not affect the original. The + * {@code environment} map is also copied to a new map instance. Other + * reference-type properties are shared between the original and clone. + * + * @return a clone of this options instance + */ + @Override + public CopilotClientOptions clone() { + CopilotClientOptions copy = new CopilotClientOptions(); + copy.cliPath = this.cliPath; + copy.cliArgs = this.cliArgs != null ? this.cliArgs.clone() : null; + copy.cwd = this.cwd; + copy.port = this.port; + copy.useStdio = this.useStdio; + copy.cliUrl = this.cliUrl; + copy.logLevel = this.logLevel; + copy.autoStart = this.autoStart; + copy.autoRestart = this.autoRestart; + copy.environment = this.environment != null ? new java.util.HashMap<>(this.environment) : null; + copy.gitHubToken = this.gitHubToken; + copy.useLoggedInUser = this.useLoggedInUser; + copy.onListModels = this.onListModels; + return copy; + } } 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 1e1ee621a..c0243f14b 100644 --- a/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java +++ b/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java @@ -31,6 +31,9 @@ public final class CreateSessionRequest { @JsonProperty("sessionId") private String sessionId; + @JsonProperty("clientName") + private String clientName; + @JsonProperty("reasoningEffort") private String reasoningEffort; @@ -67,9 +70,15 @@ public final class CreateSessionRequest { @JsonProperty("mcpServers") private Map mcpServers; + @JsonProperty("envValueMode") + private String envValueMode; + @JsonProperty("customAgents") private List customAgents; + @JsonProperty("agent") + private String agent; + @JsonProperty("infiniteSessions") private InfiniteSessionConfig infiniteSessions; @@ -102,6 +111,16 @@ public void setSessionId(String sessionId) { this.sessionId = sessionId; } + /** Gets the client name. @return the client name */ + public String getClientName() { + return clientName; + } + + /** Sets the client name. @param clientName the client name */ + public void setClientName(String clientName) { + this.clientName = clientName; + } + /** Gets the reasoning effort. @return the reasoning effort level */ public String getReasoningEffort() { return reasoningEffort; @@ -224,6 +243,16 @@ public void setMcpServers(Map mcpServers) { this.mcpServers = mcpServers; } + /** Gets MCP environment variable value mode. @return the mode */ + public String getEnvValueMode() { + return envValueMode; + } + + /** Sets MCP environment variable value mode. @param envValueMode the mode */ + public void setEnvValueMode(String envValueMode) { + this.envValueMode = envValueMode; + } + /** Gets custom agents. @return the agents */ public List getCustomAgents() { return customAgents == null ? null : Collections.unmodifiableList(customAgents); @@ -234,6 +263,16 @@ public void setCustomAgents(List customAgents) { this.customAgents = customAgents; } + /** Gets the pre-selected agent name. @return the agent name */ + public String getAgent() { + return agent; + } + + /** Sets the pre-selected agent name. @param agent the agent name */ + public void setAgent(String agent) { + this.agent = agent; + } + /** Gets infinite sessions config. @return the config */ public InfiniteSessionConfig getInfiniteSessions() { return infiniteSessions; 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 99c4214b8..6ba629b7b 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.ArrayList; import java.util.Collections; import java.util.List; @@ -107,4 +108,23 @@ public String getMode() { return mode; } + /** + * Creates a shallow clone of this {@code MessageOptions} instance. + *

+ * Mutable collection properties are copied into new collection instances so + * that modifications to those collections on the clone do not affect the + * original. Other reference-type properties (like attachment items) are not + * deep-cloned; the original and the clone will share those objects. + * + * @return a clone of this options instance + */ + @Override + public MessageOptions clone() { + MessageOptions copy = new MessageOptions(); + copy.prompt = this.prompt; + copy.attachments = this.attachments != null ? new ArrayList<>(this.attachments) : null; + copy.mode = this.mode; + return copy; + } + } diff --git a/src/main/java/com/github/copilot/sdk/json/PermissionHandler.java b/src/main/java/com/github/copilot/sdk/json/PermissionHandler.java index 259c058f3..21b822e41 100644 --- a/src/main/java/com/github/copilot/sdk/json/PermissionHandler.java +++ b/src/main/java/com/github/copilot/sdk/json/PermissionHandler.java @@ -28,6 +28,10 @@ * }; * }

* + *

+ * A pre-built handler that approves all requests is available as + * {@link #APPROVE_ALL}. + * * @see SessionConfig#setOnPermissionRequest(PermissionHandler) * @see PermissionRequest * @see PermissionRequestResult @@ -36,6 +40,14 @@ @FunctionalInterface public interface PermissionHandler { + /** + * A pre-built handler that approves all permission requests. + * + * @since 1.0.11 + */ + PermissionHandler APPROVE_ALL = (request, invocation) -> CompletableFuture + .completedFuture(new PermissionRequestResult().setKind(PermissionRequestResultKind.APPROVED)); + /** * Handles a permission request from the assistant. *

diff --git a/src/main/java/com/github/copilot/sdk/json/PermissionRequestResult.java b/src/main/java/com/github/copilot/sdk/json/PermissionRequestResult.java index 12e2ec2c2..3d7390f03 100644 --- a/src/main/java/com/github/copilot/sdk/json/PermissionRequestResult.java +++ b/src/main/java/com/github/copilot/sdk/json/PermissionRequestResult.java @@ -17,13 +17,17 @@ * *

Common Result Kinds

*
    - *
  • "user-approved" - User approved the permission request
  • - *
  • "user-denied" - User denied the permission request
  • - *
  • "denied-no-approval-rule-and-could-not-request-from-user" - No handler - * and couldn't ask user
  • + *
  • {@link PermissionRequestResultKind#APPROVED} — approved
  • + *
  • {@link PermissionRequestResultKind#DENIED_BY_RULES} — denied by + * rules
  • + *
  • {@link PermissionRequestResultKind#DENIED_COULD_NOT_REQUEST_FROM_USER} — + * no handler and couldn't ask user
  • + *
  • {@link PermissionRequestResultKind#DENIED_INTERACTIVELY_BY_USER} — denied + * by the user interactively
  • *
* * @see PermissionHandler + * @see PermissionRequestResultKind * @since 1.0.0 */ @JsonInclude(JsonInclude.Include.NON_NULL) @@ -36,7 +40,7 @@ public final class PermissionRequestResult { private List rules; /** - * Gets the result kind. + * Gets the result kind as a string. * * @return the result kind indicating approval or denial */ @@ -45,11 +49,24 @@ public String getKind() { } /** - * Sets the result kind. + * Sets the result kind using a {@link PermissionRequestResultKind} value. * * @param kind * the result kind * @return this result for method chaining + * @since 1.1.0 + */ + public PermissionRequestResult setKind(PermissionRequestResultKind kind) { + this.kind = kind != null ? kind.getValue() : null; + return this; + } + + /** + * Sets the result kind using a raw string value. + * + * @param kind + * the result kind string + * @return this result for method chaining */ public PermissionRequestResult setKind(String kind) { this.kind = kind; diff --git a/src/main/java/com/github/copilot/sdk/json/PermissionRequestResultKind.java b/src/main/java/com/github/copilot/sdk/json/PermissionRequestResultKind.java new file mode 100644 index 000000000..f85a0df1c --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/PermissionRequestResultKind.java @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Describes the outcome kind of a permission request result. + * + *

+ * This is a string-backed value type that can hold both well-known kinds (via + * the static constants) and arbitrary extension values forwarded by the server. + * Comparisons are case-insensitive to match server behaviour. + * + *

Well-known kinds

+ *
    + *
  • {@link #APPROVED} — the permission was approved.
  • + *
  • {@link #DENIED_BY_RULES} — the permission was denied by policy + * rules.
  • + *
  • {@link #DENIED_COULD_NOT_REQUEST_FROM_USER} — the permission was denied + * because no approval rule was found and the user could not be prompted.
  • + *
  • {@link #DENIED_INTERACTIVELY_BY_USER} — the permission was denied + * interactively by the user.
  • + *
+ * + * @see PermissionRequestResult + * @since 1.1.0 + */ +public final class PermissionRequestResultKind { + + /** The permission was approved. */ + public static final PermissionRequestResultKind APPROVED = new PermissionRequestResultKind("approved"); + + /** The permission was denied by policy rules. */ + public static final PermissionRequestResultKind DENIED_BY_RULES = new PermissionRequestResultKind( + "denied-by-rules"); + + /** + * The permission was denied because no approval rule was found and the user + * could not be prompted. + */ + public static final PermissionRequestResultKind DENIED_COULD_NOT_REQUEST_FROM_USER = new PermissionRequestResultKind( + "denied-no-approval-rule-and-could-not-request-from-user"); + + /** The permission was denied interactively by the user. */ + public static final PermissionRequestResultKind DENIED_INTERACTIVELY_BY_USER = new PermissionRequestResultKind( + "denied-interactively-by-user"); + + private final String value; + + /** + * Creates a new {@code PermissionRequestResultKind} with the given string + * value. Useful for extension kinds not covered by the well-known constants. + * + * @param value + * the string value; {@code null} is treated as an empty string + */ + @JsonCreator + public PermissionRequestResultKind(String value) { + this.value = value != null ? value : ""; + } + + /** + * Returns the underlying string value of this kind. + * + * @return the string value, never {@code null} + */ + @JsonValue + public String getValue() { + return value; + } + + @Override + public String toString() { + return value; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof PermissionRequestResultKind)) { + return false; + } + PermissionRequestResultKind other = (PermissionRequestResultKind) obj; + return value.equalsIgnoreCase(other.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value.toLowerCase(java.util.Locale.ROOT)); + } +} 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 8b7d841f8..eab3c789c 100644 --- a/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java +++ b/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java @@ -4,12 +4,16 @@ package com.github.copilot.sdk.json; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import com.fasterxml.jackson.annotation.JsonInclude; +import com.github.copilot.sdk.events.AbstractSessionEvent; + /** * Configuration for resuming an existing Copilot session. *

@@ -32,6 +36,7 @@ @JsonInclude(JsonInclude.Include.NON_NULL) public class ResumeSessionConfig { + private String clientName; private String model; private List tools; private SystemMessageConfig systemMessage; @@ -48,9 +53,11 @@ public class ResumeSessionConfig { private boolean streaming; private Map mcpServers; private List customAgents; + private String agent; private List skillDirectories; private List disabledSkills; private InfiniteSessionConfig infiniteSessions; + private Consumer onEvent; /** * Gets the AI model to use. @@ -75,6 +82,29 @@ public ResumeSessionConfig setModel(String model) { return this; } + /** + * Gets the client name used to identify the application using the SDK. + * + * @return the client name, or {@code null} if not set + */ + public String getClientName() { + return clientName; + } + + /** + * Sets the client name to identify the application using the SDK. + *

+ * This value is included in the User-Agent header for API requests. + * + * @param clientName + * the client name + * @return this config for method chaining + */ + public ResumeSessionConfig setClientName(String clientName) { + this.clientName = clientName; + return this; + } + /** * Gets the custom tools for this session. * @@ -411,6 +441,29 @@ public ResumeSessionConfig setCustomAgents(List customAgents) return this; } + /** + * Gets the name of the custom agent to activate at session start. + * + * @return the agent name, or {@code null} if not set + */ + public String getAgent() { + return agent; + } + + /** + * Sets the name of the custom agent to activate when the session starts. + *

+ * Must match the name of one of the agents in {@link #setCustomAgents(List)}. + * + * @param agent + * the agent name to pre-select + * @return this config for method chaining + */ + public ResumeSessionConfig setAgent(String agent) { + this.agent = agent; + return this; + } + /** * Gets the skill directories. * @@ -475,4 +528,69 @@ public ResumeSessionConfig setInfiniteSessions(InfiniteSessionConfig infiniteSes this.infiniteSessions = infiniteSessions; return this; } + + /** + * Gets the event handler registered before the session.resume RPC is issued. + * + * @return the event handler, or {@code null} if not set + */ + public Consumer getOnEvent() { + return onEvent; + } + + /** + * Sets an event handler that is registered on the session before the + * {@code session.resume} RPC is issued. + *

+ * Equivalent to calling + * {@link com.github.copilot.sdk.CopilotSession#on(Consumer)} immediately after + * resumption, but executes earlier in the lifecycle so no events are missed. + * + * @param onEvent + * the event handler to register before session resumption + * @return this config for method chaining + */ + public ResumeSessionConfig setOnEvent(Consumer onEvent) { + this.onEvent = onEvent; + return this; + } + + /** + * Creates a shallow clone of this {@code ResumeSessionConfig} instance. + *

+ * Mutable collection properties are copied into new collection instances so + * that modifications to those collections on the clone do not affect the + * original. Other reference-type properties (like provider configuration, + * system messages, hooks, infinite session configuration, and handlers) are not + * deep-cloned; the original and the clone will share those objects. + * + * @return a clone of this config instance + */ + @Override + public ResumeSessionConfig clone() { + ResumeSessionConfig copy = new ResumeSessionConfig(); + copy.clientName = this.clientName; + copy.model = this.model; + copy.tools = this.tools != null ? new ArrayList<>(this.tools) : null; + copy.systemMessage = this.systemMessage; + copy.availableTools = this.availableTools != null ? new ArrayList<>(this.availableTools) : null; + copy.excludedTools = this.excludedTools != null ? new ArrayList<>(this.excludedTools) : null; + copy.provider = this.provider; + copy.reasoningEffort = this.reasoningEffort; + copy.onPermissionRequest = this.onPermissionRequest; + copy.onUserInputRequest = this.onUserInputRequest; + copy.hooks = this.hooks; + copy.workingDirectory = this.workingDirectory; + copy.configDir = this.configDir; + copy.disableResume = this.disableResume; + copy.streaming = this.streaming; + copy.mcpServers = this.mcpServers != null ? new java.util.HashMap<>(this.mcpServers) : null; + copy.customAgents = this.customAgents != null ? new ArrayList<>(this.customAgents) : null; + copy.agent = this.agent; + copy.skillDirectories = this.skillDirectories != null ? new ArrayList<>(this.skillDirectories) : null; + copy.disabledSkills = this.disabledSkills != null ? new ArrayList<>(this.disabledSkills) : null; + copy.infiniteSessions = this.infiniteSessions; + copy.onEvent = this.onEvent; + return copy; + } } 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 19c51aef0..31d88399a 100644 --- a/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java +++ b/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java @@ -29,6 +29,9 @@ public final class ResumeSessionRequest { @JsonProperty("sessionId") private String sessionId; + @JsonProperty("clientName") + private String clientName; + @JsonProperty("model") private String model; @@ -74,9 +77,15 @@ public final class ResumeSessionRequest { @JsonProperty("mcpServers") private Map mcpServers; + @JsonProperty("envValueMode") + private String envValueMode; + @JsonProperty("customAgents") private List customAgents; + @JsonProperty("agent") + private String agent; + @JsonProperty("skillDirectories") private List skillDirectories; @@ -96,6 +105,16 @@ public void setSessionId(String sessionId) { this.sessionId = sessionId; } + /** Gets the client name. @return the client name */ + public String getClientName() { + return clientName; + } + + /** Sets the client name. @param clientName the client name */ + public void setClientName(String clientName) { + this.clientName = clientName; + } + /** Gets the model name. @return the model */ public String getModel() { return model; @@ -251,6 +270,16 @@ public void setMcpServers(Map mcpServers) { this.mcpServers = mcpServers; } + /** Gets MCP environment variable value mode. @return the mode */ + public String getEnvValueMode() { + return envValueMode; + } + + /** Sets MCP environment variable value mode. @param envValueMode the mode */ + public void setEnvValueMode(String envValueMode) { + this.envValueMode = envValueMode; + } + /** Gets custom agents. @return the agents */ public List getCustomAgents() { return customAgents == null ? null : Collections.unmodifiableList(customAgents); @@ -261,6 +290,16 @@ public void setCustomAgents(List customAgents) { this.customAgents = customAgents; } + /** Gets the pre-selected agent name. @return the agent name */ + public String getAgent() { + return agent; + } + + /** Sets the pre-selected agent name. @param agent the agent name */ + public void setAgent(String agent) { + this.agent = agent; + } + /** Gets skill directories. @return the directories */ public List getSkillDirectories() { return skillDirectories == null ? null : Collections.unmodifiableList(skillDirectories); 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 9d7e7367f..76c15660d 100644 --- a/src/main/java/com/github/copilot/sdk/json/SessionConfig.java +++ b/src/main/java/com/github/copilot/sdk/json/SessionConfig.java @@ -4,12 +4,16 @@ package com.github.copilot.sdk.json; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import com.fasterxml.jackson.annotation.JsonInclude; +import com.github.copilot.sdk.events.AbstractSessionEvent; + /** * Configuration for creating a new Copilot session. *

@@ -33,6 +37,7 @@ public class SessionConfig { private String sessionId; + private String clientName; private String model; private String reasoningEffort; private List tools; @@ -47,10 +52,12 @@ public class SessionConfig { private boolean streaming; private Map mcpServers; private List customAgents; + private String agent; private InfiniteSessionConfig infiniteSessions; private List skillDirectories; private List disabledSkills; private String configDir; + private Consumer onEvent; /** * Gets the custom session ID. @@ -75,6 +82,29 @@ public SessionConfig setSessionId(String sessionId) { return this; } + /** + * Gets the client name used to identify the application using the SDK. + * + * @return the client name, or {@code null} if not set + */ + public String getClientName() { + return clientName; + } + + /** + * Sets the client name to identify the application using the SDK. + *

+ * This value is included in the User-Agent header for API requests. + * + * @param clientName + * the client name + * @return this config instance for method chaining + */ + public SessionConfig setClientName(String clientName) { + this.clientName = clientName; + return this; + } + /** * Gets the AI model to use. * @@ -413,6 +443,29 @@ public SessionConfig setCustomAgents(List customAgents) { return this; } + /** + * Gets the name of the custom agent to activate at session start. + * + * @return the agent name, or {@code null} if not set + */ + public String getAgent() { + return agent; + } + + /** + * Sets the name of the custom agent to activate when the session starts. + *

+ * Must match the name of one of the agents in {@link #setCustomAgents(List)}. + * + * @param agent + * the agent name to pre-select + * @return this config instance for method chaining + */ + public SessionConfig setAgent(String agent) { + this.agent = agent; + return this; + } + /** * Gets the infinite sessions configuration. * @@ -512,4 +565,72 @@ public SessionConfig setConfigDir(String configDir) { this.configDir = configDir; return this; } + + /** + * Gets the event handler registered before the session.create RPC is issued. + * + * @return the event handler, or {@code null} if not set + */ + public Consumer getOnEvent() { + return onEvent; + } + + /** + * Sets an event handler that is registered on the session before the + * {@code session.create} RPC is issued. + *

+ * Equivalent to calling + * {@link com.github.copilot.sdk.CopilotSession#on(Consumer)} immediately after + * creation, but executes earlier in the lifecycle so no events are missed. + * Using this property rather than {@code CopilotSession.on()} guarantees that + * early events emitted by the CLI during session creation (e.g. + * {@code session.start}) are delivered to the handler. + * + * @param onEvent + * the event handler to register before session creation + * @return this config instance for method chaining + */ + public SessionConfig setOnEvent(Consumer onEvent) { + this.onEvent = onEvent; + return this; + } + + /** + * Creates a shallow clone of this {@code SessionConfig} instance. + *

+ * Mutable collection properties are copied into new collection instances so + * that modifications to those collections on the clone do not affect the + * original. Other reference-type properties (like provider configuration, + * system messages, hooks, infinite session configuration, and handlers) are not + * deep-cloned; the original and the clone will share those objects. + * + * @return a clone of this config instance + */ + @Override + public SessionConfig clone() { + SessionConfig copy = new SessionConfig(); + copy.sessionId = this.sessionId; + copy.clientName = this.clientName; + copy.model = this.model; + copy.reasoningEffort = this.reasoningEffort; + copy.tools = this.tools != null ? new ArrayList<>(this.tools) : null; + copy.systemMessage = this.systemMessage; + copy.availableTools = this.availableTools != null ? new ArrayList<>(this.availableTools) : null; + copy.excludedTools = this.excludedTools != null ? new ArrayList<>(this.excludedTools) : null; + copy.provider = this.provider; + copy.onPermissionRequest = this.onPermissionRequest; + copy.onUserInputRequest = this.onUserInputRequest; + copy.hooks = this.hooks; + copy.workingDirectory = this.workingDirectory; + copy.streaming = this.streaming; + copy.mcpServers = this.mcpServers != null ? new java.util.HashMap<>(this.mcpServers) : null; + copy.customAgents = this.customAgents != null ? new ArrayList<>(this.customAgents) : null; + copy.agent = this.agent; + copy.infiniteSessions = this.infiniteSessions; + copy.skillDirectories = this.skillDirectories != null ? new ArrayList<>(this.skillDirectories) : null; + copy.disabledSkills = this.disabledSkills != null ? new ArrayList<>(this.disabledSkills) : null; + copy.configDir = this.configDir; + copy.onEvent = this.onEvent; + return copy; + } } diff --git a/src/main/java/com/github/copilot/sdk/json/ToolDefinition.java b/src/main/java/com/github/copilot/sdk/json/ToolDefinition.java index f81c26d90..9b3087a42 100644 --- a/src/main/java/com/github/copilot/sdk/json/ToolDefinition.java +++ b/src/main/java/com/github/copilot/sdk/json/ToolDefinition.java @@ -48,13 +48,18 @@ * the JSON Schema defining the tool's parameters * @param handler * the handler function to execute when invoked + * @param overridesBuiltInTool + * when {@code true}, indicates that this tool intentionally + * overrides a built-in CLI tool with the same name; {@code null} or + * {@code false} means the tool is purely custom * @see SessionConfig#setTools(java.util.List) * @see ToolHandler * @since 1.0.0 */ @JsonInclude(JsonInclude.Include.NON_NULL) public record ToolDefinition(@JsonProperty("name") String name, @JsonProperty("description") String description, - @JsonProperty("parameters") Object parameters, @JsonIgnore ToolHandler handler) { + @JsonProperty("parameters") Object parameters, @JsonIgnore ToolHandler handler, + @JsonProperty("overridesBuiltInTool") Boolean overridesBuiltInTool) { /** * Creates a tool definition with a JSON schema for parameters. @@ -74,6 +79,30 @@ public record ToolDefinition(@JsonProperty("name") String name, @JsonProperty("d */ public static ToolDefinition create(String name, String description, Map schema, ToolHandler handler) { - return new ToolDefinition(name, description, schema, handler); + return new ToolDefinition(name, description, schema, handler, null); + } + + /** + * Creates a tool definition that overrides a built-in CLI tool. + *

+ * Use this factory method when you want your custom tool to replace a built-in + * tool (e.g., {@code grep}, {@code read_file}) with the same name. Setting + * {@code overridesBuiltInTool} to {@code true} signals to the CLI that this is + * intentional. + * + * @param name + * the name of the built-in tool to override + * @param description + * a description of what the tool does + * @param schema + * the JSON Schema as a {@code Map} + * @param handler + * the handler function to execute when invoked + * @return a new tool definition with the override flag set + * @since 1.0.11 + */ + public static ToolDefinition createOverride(String name, String description, Map schema, + ToolHandler handler) { + return new ToolDefinition(name, description, schema, handler, true); } } diff --git a/src/site/markdown/advanced.md b/src/site/markdown/advanced.md index 0c43f347e..926df5d72 100644 --- a/src/site/markdown/advanced.md +++ b/src/site/markdown/advanced.md @@ -1,25 +1,31 @@ # Advanced Usage -> ⚠️ **Disclaimer:** This is an **unofficial, community-driven SDK** and is **not supported or endorsed by GitHub**. Use at your own risk. +> 🚨 **This repository is archived.** The official GitHub Copilot SDK for Java is now maintained at **[github/copilot-sdk-java](https://github.com/github/copilot-sdk-java)**. No further changes or releases will be made here. Please use the official SDK going forward. This guide covers advanced scenarios for extending and customizing your Copilot integration. ## Table of Contents - [Custom Tools](#Custom_Tools) + - [Overriding Built-in Tools](#Overriding_Built-in_Tools) +- [Switching Models Mid-Session](#Switching_Models_Mid-Session) - [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) + - [Manual Compaction](#Manual_Compaction) - [Compaction Events](#Compaction_Events) - [MCP Servers](#MCP_Servers) - [Custom Agents](#Custom_Agents) + - [Programmatic Agent Selection](#Programmatic_Agent_Selection) - [Skills Configuration](#Skills_Configuration) - [Loading Skills](#Loading_Skills) - [Disabling Skills](#Disabling_Skills) - [Custom Configuration Directory](#Custom_Configuration_Directory) +- [Session Logging](#Session_Logging) +- [Early Event Registration](#Early_Event_Registration) - [User Input Handling](#User_Input_Handling) - [Permission Handling](#Permission_Handling) - [Session Hooks](#Session_Hooks) @@ -66,13 +72,69 @@ var lookupTool = ToolDefinition.create( ); var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setTools(List.of(lookupTool)) ).get(); ``` See [ToolDefinition](apidocs/com/github/copilot/sdk/json/ToolDefinition.html) Javadoc for schema details. +### Overriding Built-in Tools + +You can replace a built-in CLI tool (such as `grep` or `read_file`) with your own implementation +by using `ToolDefinition.createOverride()`. This signals to the CLI that the name collision is +intentional and your custom implementation should be used instead. + +```java +var customGrep = ToolDefinition.createOverride( + "grep", + "Project-aware search with custom filtering", + Map.of( + "type", "object", + "properties", Map.of( + "query", Map.of("type", "string", "description", "Search query") + ), + "required", List.of("query") + ), + invocation -> { + String query = (String) invocation.getArguments().get("query"); + // Your custom search logic here + return CompletableFuture.completedFuture("Results for: " + query); + } +); + +var session = client.createSession( + new SessionConfig() + .setTools(List.of(customGrep)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) +).get(); +``` + +--- + +## Switching Models Mid-Session + +You can change the model used by an existing session without losing conversation history. +The new model takes effect starting with the next message sent. + +```java +var session = client.createSession( + new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) +).get(); + +// Switch to a different model mid-conversation +session.setModel("gpt-4.1").get(); + +// Next message will use the new model +session.sendAndWait(new MessageOptions().setPrompt("Continue with the new model")).get(); +``` + +The session emits a [`SessionModelChangeEvent`](apidocs/com/github/copilot/sdk/events/SessionModelChangeEvent.html) +when the switch completes, which you can observe with `session.on(SessionModelChangeEvent.class, event -> ...)`. + +See [CopilotSession.setModel()](apidocs/com/github/copilot/sdk/CopilotSession.html#setModel(java.lang.String)) Javadoc for details. + --- ## System Messages @@ -85,7 +147,7 @@ Use `APPEND` mode to add constraints while keeping default guardrails: ```java var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setSystemMessage(new SystemMessageConfig() .setMode(SystemMessageMode.APPEND) .setContent(""" @@ -103,7 +165,7 @@ Use `REPLACE` mode for complete control (removes default guardrails): ```java var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setSystemMessage(new SystemMessageConfig() .setMode(SystemMessageMode.REPLACE) .setContent("You are a helpful coding assistant.")) @@ -149,11 +211,22 @@ session.send(new MessageOptions() Use your own OpenAI or Azure OpenAI API key instead of GitHub Copilot. +Supported providers: + +| Provider | Type value | Notes | +|----------|-----------|-------| +| OpenAI | `"openai"` | Standard OpenAI API | +| Azure OpenAI / Azure AI Foundry | `"azure"` | Azure-hosted models | +| Anthropic | `"anthropic"` | Claude models | +| Ollama | `"openai"` | Local models via OpenAI-compatible API | +| Microsoft Foundry Local | `"openai"` | Run AI models locally on your device via OpenAI-compatible API | +| Other OpenAI-compatible | `"openai"` | vLLM, LiteLLM, etc. | + ### API Key Authentication ```java var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setProvider(new ProviderConfig() .setType("openai") .setBaseUrl("https://api.openai.com/v1") @@ -167,7 +240,7 @@ Some providers require bearer token authentication instead of API keys: ```java var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setProvider(new ProviderConfig() .setType("openai") .setBaseUrl("https://my-custom-endpoint.example.com/v1") @@ -177,6 +250,40 @@ var session = client.createSession( > **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. +### Microsoft Foundry Local + +[Microsoft Foundry Local](https://foundrylocal.ai) lets you run AI models locally on your own device with an OpenAI-compatible API. Install it via the Foundry Local CLI, then point the SDK at your local endpoint: + +```java +var session = client.createSession( + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setProvider(new ProviderConfig() + .setType("openai") + .setBaseUrl("http://localhost:/v1")) + // No apiKey needed for local Foundry Local +).get(); +``` + +> **Note:** Foundry Local starts on a **dynamic port** — the port is not fixed. Use `foundry service status` to confirm the port the service is currently listening on, then use that port in your `baseUrl`. + +To get started with Foundry Local: + +```bash +# Windows: Install Foundry Local CLI (requires winget) +winget install Microsoft.FoundryLocal + +# macOS / Linux: see https://foundrylocal.ai for installation instructions + +# List available models +foundry model list + +# Run a model (starts the local server automatically) +foundry model run phi-4-mini + +# Check the port the service is running on +foundry service status +``` + ### Limitations When using BYOK, be aware of these limitations: @@ -203,7 +310,7 @@ When enabled (default), the session automatically compacts older messages as the ```java var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setInfiniteSessions(new InfiniteSessionConfig() .setEnabled(true) .setBackgroundCompactionThreshold(0.80) // Start compacting at 80% @@ -214,6 +321,14 @@ var session = client.createSession( var workspace = session.getWorkspacePath(); ``` +### Manual Compaction + +Trigger compaction immediately when you want to reduce context usage before the automatic threshold is reached: + +```java +session.compact().get(); +``` + ### Compaction Events When compaction occurs, the session emits events that you can listen for: @@ -224,8 +339,8 @@ session.on(SessionCompactionStartEvent.class, start -> { }); session.on(SessionCompactionCompleteEvent.class, complete -> { var data = complete.getData(); - System.out.println("Compaction completed - success: " + data.isSuccess() - + ", tokens removed: " + data.getTokensRemoved()); + System.out.println("Compaction completed - success: " + data.success() + + ", tokens removed: " + data.tokensRemoved()); }); ``` @@ -250,7 +365,7 @@ Map server = Map.of( ); var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setMcpServers(Map.of("filesystem", server)) ).get(); ``` @@ -272,7 +387,7 @@ var reviewer = new CustomAgentConfig() .setTools(List.of("read_file", "search_code")); var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setCustomAgents(List.of(reviewer)) ).get(); @@ -310,12 +425,30 @@ var agents = List.of( ); var session = client.createSession( - new SessionConfig().setCustomAgents(agents) + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setCustomAgents(agents) ).get(); ``` See [CustomAgentConfig](apidocs/com/github/copilot/sdk/json/CustomAgentConfig.html) Javadoc for full details. +### Programmatic Agent Selection + +You can inspect and switch agents at runtime: + +```java +var available = session.listAgents().get(); + +var current = session.getCurrentAgent().get(); +if (current != null) { + System.out.println("Current agent: " + current.name()); +} + +var selected = session.selectAgent("reviewer").get(); +System.out.println("Selected: " + selected.name()); + +session.deselectAgent().get(); // Return to the default agent +``` + --- ## Skills Configuration @@ -328,7 +461,7 @@ Skills are loaded from `SKILL.md` files in subdirectories of the specified skill ```java var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setSkillDirectories(List.of("/path/to/skills")) ).get(); ``` @@ -352,7 +485,7 @@ Disable specific skills by name: ```java var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setSkillDirectories(List.of("/path/to/skills")) .setDisabledSkills(List.of("my-skill")) ).get(); @@ -366,7 +499,7 @@ Use a custom configuration directory for session settings: ```java var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setConfigDir("/path/to/custom/config") ).get(); ``` @@ -375,13 +508,61 @@ This is useful when you need to isolate session configuration or use different s --- +## Session Logging + +Send log messages to the session for debugging, status updates, or UI feedback. + +```java +// Simple log message (defaults to "info" level) +session.log("Processing step 1 of 3").get(); + +// Log with explicit level and ephemeral flag +session.log("Downloading dependencies...", "info", true).get(); +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `message` | String | The log message text | +| `level` | String | Log level: `"info"`, `"warning"`, `"error"` | +| `ephemeral` | Boolean | If `true`, the message is transient and may not be persisted | + +Use cases: +- Displaying progress in a UI while the session processes a request +- Sending status updates to the session log +- Debugging session behavior with contextual messages + +See [CopilotSession.log()](apidocs/com/github/copilot/sdk/CopilotSession.html#log(java.lang.String)) Javadoc for details. + +--- + +## Early Event Registration + +Register an event handler *before* the `session.create` RPC is issued, ensuring no early events are missed. + +When you register handlers with `session.on()` after `createSession()` returns, you may miss events emitted during session creation (e.g., `SessionStartEvent`). Use `SessionConfig.setOnEvent()` to guarantee delivery of all events from the very start: + +```java +var events = new CopyOnWriteArrayList(); + +var session = client.createSession( + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setOnEvent(events::add) // Registered before session.create RPC +).get(); + +// events list now includes SessionStartEvent and any other early events +``` + +This is equivalent to calling `session.on(handler)` immediately after creation, but executes earlier in the lifecycle. The same option is available on `ResumeSessionConfig.setOnEvent()` for resumed sessions. + +--- + ## User Input Handling Handle user input requests when the AI uses the `ask_user` tool to gather information from the user. ```java var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setOnUserInputRequest((request, invocation) -> { System.out.println("Agent asks: " + request.getQuestion()); @@ -426,16 +607,28 @@ Approve or deny permission requests from the AI. ```java var session = client.createSession( - new SessionConfig() - .setOnPermissionRequest((request, invocation) -> { - // Inspect request and approve/deny - var result = new PermissionRequestResult(); - result.setKind("user-approved"); - return CompletableFuture.completedFuture(result); - }) + new SessionConfig().setOnPermissionRequest((request, invocation) -> { + // Inspect request and approve/deny using typed constants + var result = new PermissionRequestResult(); + result.setKind(PermissionRequestResultKind.APPROVED); + return CompletableFuture.completedFuture(result); + }) ).get(); ``` +The `PermissionRequestResultKind` class provides well-known constants for common outcomes: + +| Constant | Value | Meaning | +|---|---|---| +| `PermissionRequestResultKind.APPROVED` | `"approved"` | The permission was approved | +| `PermissionRequestResultKind.DENIED_BY_RULES` | `"denied-by-rules"` | Denied by policy rules | +| `PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER` | `"denied-no-approval-rule-and-could-not-request-from-user"` | No rule and user could not be prompted | +| `PermissionRequestResultKind.DENIED_INTERACTIVELY_BY_USER` | `"denied-interactively-by-user"` | User denied interactively | + +You can also pass a raw string to `setKind(String)` for custom or extension values. Use +[`PermissionHandler.APPROVE_ALL`](apidocs/com/github/copilot/sdk/json/PermissionHandler.html) to approve all +requests without writing a handler. + --- ## Session Hooks @@ -454,7 +647,7 @@ var hooks = new SessionHooks() }); var session = client.createSession( - new SessionConfig().setHooks(hooks) + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setHooks(hooks) ).get(); ``` @@ -663,7 +856,7 @@ session.on(AssistantMessageEvent.class, msg -> { session.on(AssistantMessageEvent.class, msg -> { // This handler executes normally despite the exception above - System.out.println(msg.getData().getContent()); + System.out.println(msg.getData().content()); }); ``` diff --git a/src/site/markdown/cookbook/error-handling.md b/src/site/markdown/cookbook/error-handling.md index cb66b4934..1e44e702f 100644 --- a/src/site/markdown/cookbook/error-handling.md +++ b/src/site/markdown/cookbook/error-handling.md @@ -41,10 +41,10 @@ public class BasicErrorHandling { client.start().get(); var session = client.createSession( - new SessionConfig().setModel("gpt-5")).get(); + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("gpt-5")).get(); session.on(AssistantMessageEvent.class, msg -> { - System.out.println(msg.getData().getContent()); + System.out.println(msg.getData().content()); }); session.sendAndWait(new MessageOptions() @@ -110,7 +110,7 @@ public class TimeoutHandling { public static void sendWithTimeout(CopilotSession session) { try { session.on(AssistantMessageEvent.class, msg -> { - System.out.println(msg.getData().getContent()); + System.out.println(msg.getData().content()); }); // Wait up to 30 seconds for response @@ -206,10 +206,10 @@ public class TryWithResources { client.start().get(); try (var session = client.createSession( - new SessionConfig().setModel("gpt-5")).get()) { + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("gpt-5")).get()) { session.on(AssistantMessageEvent.class, msg -> { - System.out.println(msg.getData().getContent()); + System.out.println(msg.getData().content()); }); session.sendAndWait(new MessageOptions() @@ -251,10 +251,10 @@ public class ToolErrorHandling { client.start().get(); var session = client.createSession( - new SessionConfig().setTools(List.of(errorTool))).get(); + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setTools(List.of(errorTool))).get(); session.on(AssistantMessageEvent.class, msg -> { - System.out.println(msg.getData().getContent()); + System.out.println(msg.getData().content()); }); // Session continues even when tool fails diff --git a/src/site/markdown/cookbook/managing-local-files.md b/src/site/markdown/cookbook/managing-local-files.md index e66427704..c978ccbad 100644 --- a/src/site/markdown/cookbook/managing-local-files.md +++ b/src/site/markdown/cookbook/managing-local-files.md @@ -48,21 +48,21 @@ public class ManagingLocalFiles { // Create session var session = client.createSession( - new SessionConfig().setModel("gpt-5")).get(); + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("gpt-5")).get(); // Set up event handlers var done = new CountDownLatch(1); session.on(AssistantMessageEvent.class, msg -> - System.out.println("\nCopilot: " + msg.getData().getContent()) + System.out.println("\nCopilot: " + msg.getData().content()) ); session.on(ToolExecutionStartEvent.class, evt -> - System.out.println(" → Running: " + evt.getData().getToolName()) + System.out.println(" → Running: " + evt.getData().toolName()) ); session.on(ToolExecutionCompleteEvent.class, evt -> - System.out.println(" ✓ Completed: " + evt.getData().getToolCallId()) + System.out.println(" ✓ Completed: " + evt.getData().toolCallId()) ); session.on(SessionIdleEvent.class, evt -> done.countDown()); @@ -168,10 +168,10 @@ public class InteractiveFileOrganizer { client.start().get(); var session = client.createSession( - new SessionConfig().setModel("gpt-5")).get(); + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("gpt-5")).get(); session.on(AssistantMessageEvent.class, msg -> - System.out.println("\nCopilot: " + msg.getData().getContent()) + System.out.println("\nCopilot: " + msg.getData().content()) ); System.out.print("Enter folder path to organize: "); diff --git a/src/site/markdown/cookbook/multiple-sessions.md b/src/site/markdown/cookbook/multiple-sessions.md index be5cb78b6..b03c69c2c 100644 --- a/src/site/markdown/cookbook/multiple-sessions.md +++ b/src/site/markdown/cookbook/multiple-sessions.md @@ -42,19 +42,19 @@ public class MultipleSessions { // Create multiple independent sessions var session1 = client.createSession( - new SessionConfig().setModel("gpt-5")).get(); + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("gpt-5")).get(); var session2 = client.createSession( - new SessionConfig().setModel("gpt-5")).get(); + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("gpt-5")).get(); var session3 = client.createSession( - new SessionConfig().setModel("claude-sonnet-4.5")).get(); + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("claude-sonnet-4.5")).get(); // Set up event handlers for each session session1.on(AssistantMessageEvent.class, msg -> - System.out.println("Session 1: " + msg.getData().getContent())); + System.out.println("Session 1: " + msg.getData().content())); session2.on(AssistantMessageEvent.class, msg -> - System.out.println("Session 2: " + msg.getData().getContent())); + System.out.println("Session 2: " + msg.getData().content())); session3.on(AssistantMessageEvent.class, msg -> - System.out.println("Session 3: " + msg.getData().getContent())); + System.out.println("Session 3: " + msg.getData().content())); // Each session maintains its own conversation history session1.send(new MessageOptions() @@ -90,7 +90,7 @@ Use custom IDs for easier tracking: ```java var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setSessionId("user-123-chat") .setModel("gpt-5") ).get(); @@ -129,9 +129,9 @@ public class ParallelSessions { public static void runParallelSessions(CopilotClient client) throws Exception { // Create sessions in parallel var sessionFutures = List.of( - client.createSession(new SessionConfig().setModel("gpt-5")), - client.createSession(new SessionConfig().setModel("gpt-5")), - client.createSession(new SessionConfig().setModel("claude-sonnet-4.5")) + client.createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("gpt-5")), + client.createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("gpt-5")), + client.createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("claude-sonnet-4.5")) ); // Wait for all sessions to be created diff --git a/src/site/markdown/cookbook/persisting-sessions.md b/src/site/markdown/cookbook/persisting-sessions.md index 47b989940..a3191005d 100644 --- a/src/site/markdown/cookbook/persisting-sessions.md +++ b/src/site/markdown/cookbook/persisting-sessions.md @@ -42,13 +42,13 @@ public class PersistingSessions { // Create session with a memorable ID var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setSessionId("user-123-conversation") .setModel("gpt-5") ).get(); session.on(AssistantMessageEvent.class, msg -> - System.out.println(msg.getData().getContent()) + System.out.println(msg.getData().content()) ); session.sendAndWait(new MessageOptions() @@ -73,10 +73,10 @@ public class ResumeSession { client.start().get(); // Resume the previous session - var session = client.resumeSession("user-123-conversation").get(); + var session = client.resumeSession("user-123-conversation", new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); session.on(AssistantMessageEvent.class, msg -> - System.out.println(msg.getData().getContent()) + System.out.println(msg.getData().content()) ); // Previous context is restored @@ -135,13 +135,13 @@ public class SessionHistory { try (var client = new CopilotClient()) { client.start().get(); - var session = client.resumeSession("user-123-conversation").get(); + var session = client.resumeSession("user-123-conversation", new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); var messages = session.getMessages().get(); for (var event : messages) { // Print different event types appropriately if (event instanceof AssistantMessageEvent msg) { - System.out.printf("[assistant] %s%n", msg.getData().getContent()); + System.out.printf("[assistant] %s%n", msg.getData().content()); } else if (event instanceof UserMessageEvent userMsg) { System.out.printf("[user] %s%n", userMsg.getData().content()); } else { @@ -184,7 +184,7 @@ public class SessionManager { System.out.print("Enter session ID: "); String sessionId = scanner.nextLine(); session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setSessionId(sessionId) .setModel("gpt-5") ).get(); @@ -195,7 +195,7 @@ public class SessionManager { System.out.print("Enter session ID to resume: "); String resumeId = scanner.nextLine(); try { - session = client.resumeSession(resumeId).get(); + session = client.resumeSession(resumeId, new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); System.out.println("Resumed session: " + resumeId); } catch (Exception ex) { System.err.println("Failed to resume session: " + ex.getMessage()); @@ -218,7 +218,7 @@ public class SessionManager { if (session != null) { session.on(AssistantMessageEvent.class, msg -> - System.out.println("\nCopilot: " + msg.getData().getContent()) + System.out.println("\nCopilot: " + msg.getData().content()) ); // Interactive conversation loop @@ -264,13 +264,13 @@ public class CheckSession { if (sessionExists(client, sessionId)) { System.out.println("Session exists, resuming..."); - var session = client.resumeSession(sessionId).get(); + var session = client.resumeSession(sessionId, new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); // ... use session ... session.close(); } else { System.out.println("Session doesn't exist, creating new one..."); var session = client.createSession( - new SessionConfig().setSessionId(sessionId).setModel("gpt-5") + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setSessionId(sessionId).setModel("gpt-5") ).get(); // ... use session ... session.close(); diff --git a/src/site/markdown/cookbook/pr-visualization.md b/src/site/markdown/cookbook/pr-visualization.md index 002a9f073..6fabf5c35 100644 --- a/src/site/markdown/cookbook/pr-visualization.md +++ b/src/site/markdown/cookbook/pr-visualization.md @@ -94,18 +94,18 @@ public class PRVisualization { """, owner, repoName, cwd); var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setModel("gpt-5") .setSystemMessage(new SystemMessageConfig().setContent(systemMessage)) ).get(); // Set up event handling session.on(AssistantMessageEvent.class, msg -> - System.out.println("\n🤖 " + msg.getData().getContent() + "\n") + System.out.println("\n🤖 " + msg.getData().content() + "\n") ); session.on(ToolExecutionStartEvent.class, evt -> - System.out.println(" ⚙️ " + evt.getData().getToolName()) + System.out.println(" ⚙️ " + evt.getData().toolName()) ); // Initial prompt - let Copilot figure out the details diff --git a/src/site/markdown/documentation.md b/src/site/markdown/documentation.md index 9cdd5b9a2..366b04627 100644 --- a/src/site/markdown/documentation.md +++ b/src/site/markdown/documentation.md @@ -1,6 +1,6 @@ # Copilot SDK for Java - Documentation -> ⚠️ **Disclaimer:** This is an **unofficial, community-driven SDK** and is **not supported or endorsed by GitHub**. Use at your own risk. +> 🚨 **This repository is archived.** The official GitHub Copilot SDK for Java is now maintained at **[github/copilot-sdk-java](https://github.com/github/copilot-sdk-java)**. No further changes or releases will be made here. Please use the official SDK going forward. This guide covers common use cases for the Copilot SDK for Java. For complete API details, see the [Javadoc](apidocs/index.html). @@ -20,6 +20,7 @@ This guide covers common use cases for the Copilot SDK for Java. For complete AP - [Message Delivery Mode](#Message_Delivery_Mode) - [Session Management](#Session_Management) - [SessionConfig Reference](#SessionConfig_Reference) + - [Cloning SessionConfig](#Cloning_SessionConfig) --- @@ -35,11 +36,11 @@ try (var client = new CopilotClient()) { client.start().get(); var session = client.createSession( - new SessionConfig().setModel("gpt-4.1") + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("gpt-4.1") ).get(); var response = session.sendAndWait("Explain Java records in one sentence").get(); - System.out.println(response.getData().getContent()); + System.out.println(response.getData().content()); session.close(); } @@ -57,7 +58,7 @@ For straightforward interactions, use `sendAndWait()`: ```java var response = session.sendAndWait("What is the capital of France?").get(); -System.out.println(response.getData().getContent()); +System.out.println(response.getData().content()); ``` ### Event-Based Processing @@ -102,7 +103,7 @@ var done = new CompletableFuture(); // Type-safe event handlers (recommended) session.on(AssistantMessageEvent.class, msg -> { - System.out.println("Response: " + msg.getData().getContent()); + System.out.println("Response: " + msg.getData().content()); }); session.on(SessionErrorEvent.class, err -> { @@ -123,7 +124,7 @@ You can also use a single handler for all events: session.on(event -> { switch (event) { case AssistantMessageEvent msg -> - System.out.println("Response: " + msg.getData().getContent()); + System.out.println("Response: " + msg.getData().content()); case SessionErrorEvent err -> System.err.println("Error: " + err.getData().message()); case SessionIdleEvent idle -> @@ -161,12 +162,17 @@ The SDK supports event types organized by category. All events extend `AbstractS | `SessionInfoEvent` | `session.info` | Informational message from the session | | `SessionShutdownEvent` | `session.shutdown` | Session is shutting down (includes reason and exit code) | | `SessionModelChangeEvent` | `session.model_change` | The model was changed mid-session | +| `SessionModeChangedEvent` | `session.mode_changed` | Session mode changed (e.g., plan mode) | +| `SessionPlanChangedEvent` | `session.plan_changed` | Session plan was updated | +| `SessionWorkspaceFileChangedEvent` | `session.workspace_file_changed` | A file in the workspace was modified | | `SessionHandoffEvent` | `session.handoff` | Session handed off to another agent | | `SessionTruncationEvent` | `session.truncation` | Context was truncated due to limits | | `SessionSnapshotRewindEvent` | `session.snapshot_rewind` | Session rewound to a previous snapshot | | `SessionUsageInfoEvent` | `session.usage_info` | Token usage information | | `SessionCompactionStartEvent` | `session.compaction_start` | Context compaction started (infinite sessions) | | `SessionCompactionCompleteEvent` | `session.compaction_complete` | Context compaction completed | +| `SessionContextChangedEvent` | `session.context_changed` | Working directory context changed | +| `SessionTaskCompleteEvent` | `session.task_complete` | Task completed with summary | ### Assistant Events @@ -178,6 +184,7 @@ The SDK supports event types organized by category. All events extend `AbstractS | `AssistantReasoningDeltaEvent` | `assistant.reasoning_delta` | Streaming reasoning chunk | | `AssistantMessageEvent` | `assistant.message` | Complete assistant message | | `AssistantMessageDeltaEvent` | `assistant.message_delta` | Streaming message chunk | +| `AssistantStreamingDeltaEvent` | `assistant.streaming_delta` | Streaming progress with size metrics | | `AssistantTurnEndEvent` | `assistant.turn_end` | Assistant finished processing | | `AssistantUsageEvent` | `assistant.usage` | Token usage for this turn | @@ -204,6 +211,7 @@ The SDK supports event types organized by category. All events extend `AbstractS |-------|-------------|-------------| | `SubagentStartedEvent` | `subagent.started` | Subagent was spawned | | `SubagentSelectedEvent` | `subagent.selected` | Subagent was selected for task | +| `SubagentDeselectedEvent` | `subagent.deselected` | Subagent was deselected | | `SubagentCompletedEvent` | `subagent.completed` | Subagent completed its task | | `SubagentFailedEvent` | `subagent.failed` | Subagent failed | @@ -215,8 +223,37 @@ The SDK supports event types organized by category. All events extend `AbstractS | `HookStartEvent` | `hook.start` | Hook execution started | | `HookEndEvent` | `hook.end` | Hook execution completed | | `SystemMessageEvent` | `system.message` | System-level message | +| `SystemNotificationEvent` | `system.notification` | System notification (informational) | | `SkillInvokedEvent` | `skill.invoked` | A skill was invoked | +### External Tool Events + +| Event | Type String | Description | +|-------|-------------|-------------| +| `ExternalToolRequestedEvent` | `external_tool.requested` | An external tool invocation was requested | +| `ExternalToolCompletedEvent` | `external_tool.completed` | An external tool invocation completed | + +### Permission Events + +| Event | Type String | Description | +|-------|-------------|-------------| +| `PermissionRequestedEvent` | `permission.requested` | A permission request was issued | +| `PermissionCompletedEvent` | `permission.completed` | A permission request was resolved | + +### Command Events + +| Event | Type String | Description | +|-------|-------------|-------------| +| `CommandQueuedEvent` | `command.queued` | A command was queued for execution | +| `CommandCompletedEvent` | `command.completed` | A queued command completed | + +### Plan Mode Events + +| Event | Type String | Description | +|-------|-------------|-------------| +| `ExitPlanModeRequestedEvent` | `exit_plan_mode.requested` | Exit from plan mode was requested | +| `ExitPlanModeCompletedEvent` | `exit_plan_mode.completed` | Exit from plan mode completed | + See the [events package Javadoc](apidocs/com/github/copilot/sdk/events/package-summary.html) for detailed event data structures. --- @@ -227,7 +264,7 @@ Enable streaming to receive response chunks as they're generated: ```java var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setModel("gpt-4.1") .setStreaming(true) ).get(); @@ -262,11 +299,11 @@ var history = session.getMessages().get(); for (var event : history) { switch (event) { case UserMessageEvent user -> - System.out.println("User: " + user.getData().getContent()); + System.out.println("User: " + user.getData().content()); case AssistantMessageEvent assistant -> - System.out.println("Assistant: " + assistant.getData().getContent()); + System.out.println("Assistant: " + assistant.getData().content()); case ToolExecutionCompleteEvent tool -> - System.out.println("Tool: " + tool.getData().getToolName()); + System.out.println("Tool: " + tool.getData().toolCallId()); default -> { } } } @@ -338,7 +375,7 @@ for (var model : models) { ```java var session = client.createSession( - new SessionConfig().setModel("claude-sonnet-4") + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("claude-sonnet-4") ).get(); ``` @@ -350,7 +387,7 @@ For models that support it, control how much effort the model spends reasoning b ```java var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setModel("o3-mini") .setReasoningEffort("high") ).get(); @@ -377,7 +414,7 @@ Restrict the session to only specific tools: ```java var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setAvailableTools(List.of("read_file", "search_code", "list_dir")) ).get(); ``` @@ -390,7 +427,7 @@ Allow all tools except specific ones: ```java var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setExcludedTools(List.of("execute_command", "write_file")) ).get(); ``` @@ -405,7 +442,7 @@ Tool filtering applies to built-in tools. Your custom tools (registered via `set var lookupTool = ToolDefinition.create("lookup_issue", "Fetch issue", schema, handler); var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setTools(List.of(lookupTool)) // Custom tool always available .setAvailableTools(List.of("read_file")) // Only allow read_file from built-ins ).get(); @@ -419,7 +456,7 @@ Set the working directory for file operations in the session: ```java var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setWorkingDirectory("/path/to/project") ).get(); ``` @@ -548,19 +585,19 @@ session.send(new MessageOptions() ```java var session1 = client.createSession( - new SessionConfig().setModel("gpt-4.1") + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("gpt-4.1") ).get(); var session2 = client.createSession( - new SessionConfig().setModel("claude-sonnet-4") + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("claude-sonnet-4") ).get(); // Send messages concurrently var future1 = session1.sendAndWait("Summarize REST APIs"); var future2 = session2.sendAndWait("Summarize GraphQL"); -System.out.println("GPT: " + future1.get().getData().getContent()); -System.out.println("Claude: " + future2.get().getData().getContent()); +System.out.println("GPT: " + future1.get().getData().content()); +System.out.println("Claude: " + future2.get().getData().content()); ``` ### Resume a Previous Session @@ -573,7 +610,7 @@ var lastSessionId = client.getLastSessionId().get(); var sessions = client.listSessions().get(); // Resume a session -var session = client.resumeSession(lastSessionId).get(); +var session = client.resumeSession(lastSessionId, new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); ``` ### Resume Options @@ -593,15 +630,18 @@ When resuming a session, you can optionally reconfigure many settings. This is u | `configDir` | Override configuration directory | | `mcpServers` | Configure MCP servers | | `customAgents` | Configure custom agents | +| `agent` | Pre-select a custom agent at session start | | `skillDirectories` | Directories to load skills from | | `disabledSkills` | Skills to disable | | `infiniteSessions` | Configure infinite session behavior | +| `disableResume` | When `true`, resumes without emitting a `session.resume` event | +| `onEvent` | Event handler registered before session resumption | **Example: Changing Model on Resume** ```java // Resume with a different model -var config = new ResumeSessionConfig() +var config = new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setModel("claude-sonnet-4") // Switch to a different model .setReasoningEffort("high"); // Increase reasoning effort @@ -612,8 +652,14 @@ See [ResumeSessionConfig](apidocs/com/github/copilot/sdk/json/ResumeSessionConfi ### Clean Up Sessions +Closing a session releases its in-memory resources but **preserves session data on disk**, so +it can be resumed later. Use `deleteSession()` to permanently remove session data from disk: + ```java -// Delete a specific session +// Close a session (releases in-memory resources; session can be resumed later) +session.close(); + +// Permanently delete a session and all its data from disk (cannot be resumed) client.deleteSession(sessionId).get(); ``` @@ -626,6 +672,7 @@ Complete list of all `SessionConfig` options for `createSession()`: | Option | Type | Description | Guide | |--------|------|-------------|-------| | `sessionId` | String | Custom session ID (auto-generated if omitted) | — | +| `clientName` | String | Client name for User-Agent identification | — | | `model` | String | AI model to use | [Choosing a Model](#Choosing_a_Model) | | `reasoningEffort` | String | Reasoning depth: `"low"`, `"medium"`, `"high"`, `"xhigh"` | [Reasoning Effort](#Reasoning_Effort) | | `tools` | List<ToolDefinition> | Custom tools the assistant can invoke | [Custom Tools](advanced.html#Custom_Tools) | @@ -640,10 +687,27 @@ Complete list of all `SessionConfig` options for `createSession()`: | `streaming` | boolean | Enable streaming response chunks | [Streaming Responses](#Streaming_Responses) | | `mcpServers` | Map<String, Object> | MCP server configurations | [MCP Servers](mcp.html) | | `customAgents` | List<CustomAgentConfig> | Custom agent definitions | [Custom Agents](advanced.html#Custom_Agents) | +| `agent` | String | Pre-select a custom agent at session start | [Custom Agents](advanced.html#Custom_Agents) | | `infiniteSessions` | InfiniteSessionConfig | Auto-compaction for long conversations | [Infinite Sessions](advanced.html#Infinite_Sessions) | | `skillDirectories` | List<String> | Directories to load skills from | [Skills](advanced.html#Skills_Configuration) | | `disabledSkills` | List<String> | Skills to disable by name | [Skills](advanced.html#Skills_Configuration) | | `configDir` | String | Custom configuration directory | [Config Dir](advanced.html#Custom_Configuration_Directory) | +| `onEvent` | Consumer<AbstractSessionEvent> | Event handler registered before session creation | [Early Event Registration](advanced.html#Early_Event_Registration) | + +### Cloning SessionConfig + +Use `clone()` to copy a base configuration before making per-session changes: + +```java +var base = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setModel("gpt-4.1") + .setStreaming(true); + +var derived = base.clone() + .setWorkingDirectory("/repo-a"); +``` + +`clone()` creates a shallow copy. Collection fields are copied into new collection instances, while nested objects/handlers are shared references. See [SessionConfig](apidocs/com/github/copilot/sdk/json/SessionConfig.html) Javadoc for full details. diff --git a/src/site/markdown/getting-started.md b/src/site/markdown/getting-started.md index 9077426f7..cbbad8d46 100644 --- a/src/site/markdown/getting-started.md +++ b/src/site/markdown/getting-started.md @@ -74,14 +74,14 @@ public class HelloCopilot { client.start().get(); var session = client.createSession( - new SessionConfig().setModel("gpt-4.1") + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("gpt-4.1") ).get(); var response = session.sendAndWait( new MessageOptions().setPrompt("What is 2 + 2?") ).get(); - System.out.println(response.getData().getContent()); + System.out.println(response.getData().content()); } } } @@ -119,7 +119,7 @@ public class StreamingExample { client.start().get(); var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setModel("gpt-4.1") .setStreaming(true) ).get(); @@ -195,7 +195,7 @@ public class ToolExample { ); var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setModel("gpt-4.1") .setStreaming(true) .setTools(List.of(getWeather)) @@ -270,7 +270,7 @@ public class WeatherAssistant { ); var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setModel("gpt-4.1") .setStreaming(true) .setTools(List.of(getWeather)) diff --git a/src/site/markdown/hooks.md b/src/site/markdown/hooks.md index 153362c98..4dde96fdd 100644 --- a/src/site/markdown/hooks.md +++ b/src/site/markdown/hooks.md @@ -1,6 +1,6 @@ # Session Hooks -> ⚠️ **Disclaimer:** This is an **unofficial, community-driven SDK** and is **not supported or endorsed by GitHub**. Use at your own risk. +> 🚨 **This repository is archived.** The official GitHub Copilot SDK for Java is now maintained at **[github/copilot-sdk-java](https://github.com/github/copilot-sdk-java)**. No further changes or releases will be made here. Please use the official SDK going forward. Session hooks allow you to intercept and modify tool execution, user prompts, and session lifecycle events. Use hooks to implement custom logic like logging, security controls, or context injection. @@ -15,6 +15,7 @@ Session hooks allow you to intercept and modify tool execution, user prompts, an | [User Prompt Submitted](#User_Prompt_Submitted_Hook) | When user sends a message | Nothing (observation only) | | [Session Start](#Session_Start_Hook) | When session begins | Nothing (observation only) | | [Session End](#Session_End_Hook) | When session ends | Nothing (observation only) | +| [Checking Whether Hooks Are Registered](#Checking_Whether_Hooks_Are_Registered) | Before session creation | Whether any handlers are configured | --- @@ -34,7 +35,7 @@ var hooks = new SessionHooks() }); var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setModel("gpt-4.1") .setHooks(hooks) ).get(); @@ -133,7 +134,7 @@ Called **after** a tool executes. Use this to: |-------|------|-------------| | `getToolName()` | `String` | Name of the tool that was called | | `getToolArgs()` | `JsonNode` | Arguments that were passed | -| `getToolResult()` | `String` | Result from the tool | +| `getToolResult()` | `JsonNode` | Result from the tool | | `getCwd()` | `String` | Current working directory | | `getTimestamp()` | `long` | Timestamp in milliseconds | @@ -188,7 +189,7 @@ Called when the user submits a prompt, before the LLM processes it. This is an o | Field | Type | Description | |-------|------|-------------| -| `getPrompt()` | `String` | The user's prompt text | +| `prompt()` | `String` | The user's prompt text | | `getTimestamp()` | `long` | Timestamp in milliseconds | ### Output @@ -222,7 +223,7 @@ Called when a session starts (either new or resumed). | Field | Type | Description | |-------|------|-------------| -| `getSource()` | `String` | `"new"` or `"resumed"` | +| `source()` | `String` | `"startup"`, `"resume"`, or `"new"` | | `getTimestamp()` | `long` | Timestamp in milliseconds | ### Output @@ -254,7 +255,7 @@ Called when a session ends. | Field | Type | Description | |-------|------|-------------| -| `getReason()` | `String` | Why the session ended | +| `reason()` | `String` | Why the session ended | | `getTimestamp()` | `long` | Timestamp in milliseconds | ### Output @@ -332,13 +333,13 @@ public class HooksExample { }); var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setModel("gpt-4.1") .setHooks(hooks) ).get(); var response = session.sendAndWait("List files in /tmp").get(); - System.out.println(response.getData().getContent()); + System.out.println(response.getData().content()); session.close(); } @@ -358,6 +359,22 @@ All hook handlers receive a `HookInvocation` object as the second parameter: This allows you to correlate hooks with specific sessions when managing multiple concurrent sessions. +## Checking Whether Hooks Are Registered + +Use `hasHooks()` to quickly verify that at least one hook handler is configured: + +```java +var hooks = new SessionHooks() + .setOnPreToolUse((input, invocation) -> + CompletableFuture.completedFuture(PreToolUseHookOutput.allow())); + +if (hooks.hasHooks()) { + var session = client.createSession( + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setHooks(hooks) + ).get(); +} +``` + --- ## Error Handling diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index 736405ee4..83a589824 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -1,6 +1,6 @@ # Copilot SDK for Java -> ⚠️ **Disclaimer:** This is an **unofficial, community-driven SDK** and is **not supported or endorsed by GitHub**. Use at your own risk. +> 🚨 **This repository is archived.** The official GitHub Copilot SDK for Java is now maintained at **[github/copilot-sdk-java](https://github.com/github/copilot-sdk-java)**. No further changes or releases will be made here. Please use the official SDK going forward. Welcome to the documentation for the **Copilot SDK for Java** — a Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build AI-powered applications and agentic workflows. @@ -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.409 or later installed and in PATH (or provide custom `cliPath`) +- GitHub Copilot CLI 0.0.411-1 or later installed and in PATH (or provide custom `cliPath`) ### Installation @@ -47,12 +47,12 @@ public class Example { client.start().get(); var session = client.createSession( - new SessionConfig().setModel("claude-sonnet-4.5") + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("claude-sonnet-4.5") ).get(); var done = new CompletableFuture(); session.on(AssistantMessageEvent.class, msg -> { - System.out.println(msg.getData().getContent()); + System.out.println(msg.getData().content()); }); session.on(SessionIdleEvent.class, idle -> done.complete(null)); @@ -93,10 +93,10 @@ class hello { public static void main(String[] args) throws Exception { try (var client = new CopilotClient()) { client.start().get(); - var session = client.createSession(new SessionConfig()).get(); + var session = client.createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); var done = new CompletableFuture(); session.on(AssistantMessageEvent.class, msg -> { - System.out.print(msg.getData().getContent()); + System.out.print(msg.getData().content()); }); session.on(SessionIdleEvent.class, idle -> done.complete(null)); session.send(new MessageOptions().setPrompt("Say hello!")).get(); diff --git a/src/site/markdown/mcp.md b/src/site/markdown/mcp.md index 262b67372..8f830da57 100644 --- a/src/site/markdown/mcp.md +++ b/src/site/markdown/mcp.md @@ -17,12 +17,12 @@ Map server = Map.of( ); var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setMcpServers(Map.of("filesystem", server)) ).get(); var result = session.sendAndWait("List files in the directory").get(); -System.out.println(result.getData().getContent()); +System.out.println(result.getData().content()); ``` > **Tip:** Browse the [MCP Servers Directory](https://github.com/modelcontextprotocol/servers) for community servers like GitHub, SQLite, and Puppeteer. @@ -83,7 +83,7 @@ Combine tools from several sources. ```java var session = client.createSession( - new SessionConfig() + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) .setMcpServers(Map.of( "filesystem", filesystemServer, "github", githubServer, diff --git a/src/site/markdown/setup.md b/src/site/markdown/setup.md index eebec5bcd..10c1cb3d8 100644 --- a/src/site/markdown/setup.md +++ b/src/site/markdown/setup.md @@ -7,7 +7,7 @@ This guide explains how to configure the Copilot SDK for different deployment sc | Scenario | Configuration | Guide Section | |----------|--------------|---------------| | Local development | Default (no options) | [Local CLI](#local-cli) | -| Multi-user app | `setGithubToken(userToken)` | [GitHub OAuth](#github-oauth-authentication) | +| Multi-user app | `setGitHubToken(userToken)` | [GitHub OAuth](#github-oauth-authentication) | | Server deployment | `setCliUrl("host:port")` | [Backend Services](#backend-services) | | Custom CLI location | `setCliPath("/path/to/copilot")` | [Bundled CLI](#bundled-cli) | | Own model keys | Provider configuration | [BYOK](advanced.html#Bring_Your_Own_Key_BYOK) | @@ -22,7 +22,7 @@ The simplest setup uses the Copilot CLI already signed in on your development ma try (var client = new CopilotClient()) { client.start().get(); var session = client.createSession( - new SessionConfig().setModel("gpt-4.1") + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("gpt-4.1") ).get(); // Use session... } @@ -48,13 +48,13 @@ After obtaining a user's GitHub OAuth token, pass it to the SDK: ```java var options = new CopilotClientOptions() - .setGithubToken(userAccessToken) + .setGitHubToken(userAccessToken) .setUseLoggedInUser(false); try (var client = new CopilotClient(options)) { client.start().get(); var session = client.createSession( - new SessionConfig().setModel("gpt-4.1") + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("gpt-4.1") ).get(); // Requests are made on behalf of the authenticated user } @@ -67,7 +67,7 @@ Your application handles the OAuth flow: 1. Create a GitHub OAuth App in your GitHub settings 2. Redirect users to GitHub's authorization URL 3. Exchange the authorization code for an access token -4. Pass the token to `CopilotClientOptions.setGithubToken()` +4. Pass the token to `CopilotClientOptions.setGitHubToken()` ### Per-User Client Management @@ -76,10 +76,10 @@ Each authenticated user should get their own client instance: ```java private final Map clients = new ConcurrentHashMap<>(); -public CopilotClient getClientForUser(String userId, String githubToken) { +public CopilotClient getClientForUser(String userId, String gitHubToken) { return clients.computeIfAbsent(userId, id -> { var options = new CopilotClientOptions() - .setGithubToken(githubToken) + .setGitHubToken(gitHubToken) .setUseLoggedInUser(false); var client = new CopilotClient(options); try { @@ -340,7 +340,7 @@ Complete list of `CopilotClientOptions` settings: | `cliPath` | String | Path to CLI executable | `"copilot"` from PATH | | `cliUrl` | String | External CLI server URL | `null` (spawn process) | | `cliArgs` | String[] | Extra CLI arguments | `null` | -| `githubToken` | String | GitHub OAuth token | `null` | +| `gitHubToken` | String | GitHub OAuth token | `null` | | `useLoggedInUser` | Boolean | Use system credentials | `true` | | `useStdio` | boolean | Use stdio transport | `true` | | `port` | int | TCP port for CLI | `0` (random) | @@ -349,6 +349,7 @@ Complete list of `CopilotClientOptions` settings: | `logLevel` | String | CLI log level | `"info"` | | `environment` | Map | Environment variables | inherited | | `cwd` | String | Working directory | current dir | +| `onListModels` | Supplier | Custom model listing implementation | `null` (use CLI) | ### Extra CLI Arguments diff --git a/src/test/java/com/github/copilot/sdk/AskUserTest.java b/src/test/java/com/github/copilot/sdk/AskUserTest.java index 5d45e3b2a..a2ad13b18 100644 --- a/src/test/java/com/github/copilot/sdk/AskUserTest.java +++ b/src/test/java/com/github/copilot/sdk/AskUserTest.java @@ -16,6 +16,7 @@ import com.github.copilot.sdk.json.MessageOptions; import com.github.copilot.sdk.json.SessionConfig; +import com.github.copilot.sdk.json.PermissionHandler; import com.github.copilot.sdk.json.UserInputRequest; import com.github.copilot.sdk.json.UserInputResponse; @@ -56,19 +57,20 @@ void testShouldInvokeUserInputHandlerWhenModelUsesAskUserTool() throws Exception var userInputRequests = new ArrayList(); final String[] sessionIdHolder = new String[1]; - var config = new SessionConfig().setOnUserInputRequest((request, invocation) -> { - userInputRequests.add(request); - assertEquals(sessionIdHolder[0], invocation.getSessionId()); + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setOnUserInputRequest((request, invocation) -> { + userInputRequests.add(request); + assertEquals(sessionIdHolder[0], invocation.getSessionId()); - // Return the first choice if available, otherwise a freeform answer - String answer = (request.getChoices() != null && !request.getChoices().isEmpty()) - ? request.getChoices().get(0) - : "freeform answer"; - boolean wasFreeform = request.getChoices() == null || request.getChoices().isEmpty(); + // Return the first choice if available, otherwise a freeform answer + String answer = (request.getChoices() != null && !request.getChoices().isEmpty()) + ? request.getChoices().get(0) + : "freeform answer"; + boolean wasFreeform = request.getChoices() == null || request.getChoices().isEmpty(); - return CompletableFuture - .completedFuture(new UserInputResponse().setAnswer(answer).setWasFreeform(wasFreeform)); - }); + return CompletableFuture + .completedFuture(new UserInputResponse().setAnswer(answer).setWasFreeform(wasFreeform)); + }); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession(config).get(); @@ -98,16 +100,18 @@ void testShouldReceiveChoicesInUserInputRequest() throws Exception { var userInputRequests = new ArrayList(); - var config = new SessionConfig().setOnUserInputRequest((request, invocation) -> { - userInputRequests.add(request); + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setOnUserInputRequest((request, invocation) -> { + userInputRequests.add(request); - // Pick the first choice - String answer = (request.getChoices() != null && !request.getChoices().isEmpty()) - ? request.getChoices().get(0) - : "default"; + // Pick the first choice + String answer = (request.getChoices() != null && !request.getChoices().isEmpty()) + ? request.getChoices().get(0) + : "default"; - return CompletableFuture.completedFuture(new UserInputResponse().setAnswer(answer).setWasFreeform(false)); - }); + return CompletableFuture + .completedFuture(new UserInputResponse().setAnswer(answer).setWasFreeform(false)); + }); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession(config).get(); @@ -137,13 +141,14 @@ void testShouldHandleFreeformUserInputResponse() throws Exception { final var userInputRequests = new ArrayList(); String freeformAnswer = "This is my custom freeform answer that was not in the choices"; - var config = new SessionConfig().setOnUserInputRequest((request, invocation) -> { - userInputRequests.add(request); + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setOnUserInputRequest((request, invocation) -> { + userInputRequests.add(request); - // Return a freeform answer (not from choices) - return CompletableFuture - .completedFuture(new UserInputResponse().setAnswer(freeformAnswer).setWasFreeform(true)); - }); + // Return a freeform answer (not from choices) + return CompletableFuture + .completedFuture(new UserInputResponse().setAnswer(freeformAnswer).setWasFreeform(true)); + }); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession(config).get(); diff --git a/src/test/java/com/github/copilot/sdk/CliServerManagerTest.java b/src/test/java/com/github/copilot/sdk/CliServerManagerTest.java index 61ce938cf..86d6be875 100644 --- a/src/test/java/com/github/copilot/sdk/CliServerManagerTest.java +++ b/src/test/java/com/github/copilot/sdk/CliServerManagerTest.java @@ -165,9 +165,9 @@ void startCliServerWithExplicitPort() throws Exception { } @Test - void startCliServerWithGithubToken() throws Exception { + void startCliServerWithGitHubToken() throws Exception { // Test the github token branch - var options = new CopilotClientOptions().setCliPath("/nonexistent/copilot").setGithubToken("ghp_test123") + var options = new CopilotClientOptions().setCliPath("/nonexistent/copilot").setGitHubToken("ghp_test123") .setUseStdio(true); var manager = new CliServerManager(options); @@ -187,9 +187,9 @@ void startCliServerWithUseLoggedInUserExplicit() throws Exception { } @Test - void startCliServerWithGithubTokenAndNoExplicitUseLoggedInUser() throws Exception { - // When githubToken is set and useLoggedInUser is null, defaults to false - var options = new CopilotClientOptions().setCliPath("/nonexistent/copilot").setGithubToken("ghp_test123") + void startCliServerWithGitHubTokenAndNoExplicitUseLoggedInUser() throws Exception { + // When gitHubToken is set and useLoggedInUser is null, defaults to false + var options = new CopilotClientOptions().setCliPath("/nonexistent/copilot").setGitHubToken("ghp_test123") .setUseStdio(true); var manager = new CliServerManager(options); diff --git a/src/test/java/com/github/copilot/sdk/ClosedSessionGuardTest.java b/src/test/java/com/github/copilot/sdk/ClosedSessionGuardTest.java index f4606c646..069cd01cb 100644 --- a/src/test/java/com/github/copilot/sdk/ClosedSessionGuardTest.java +++ b/src/test/java/com/github/copilot/sdk/ClosedSessionGuardTest.java @@ -15,6 +15,7 @@ import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.json.MessageOptions; +import com.github.copilot.sdk.json.PermissionHandler; import com.github.copilot.sdk.json.SessionConfig; /** @@ -51,7 +52,8 @@ 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(); + CopilotSession session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("fake-test-model")).get(); session.close(); IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { @@ -70,7 +72,8 @@ 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(); + CopilotSession session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("fake-test-model")).get(); session.close(); IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { @@ -89,7 +92,8 @@ 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(); + CopilotSession session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("fake-test-model")).get(); session.close(); IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { @@ -108,7 +112,8 @@ 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(); + CopilotSession session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("fake-test-model")).get(); session.close(); IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { @@ -127,7 +132,8 @@ 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(); + CopilotSession session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("fake-test-model")).get(); session.close(); IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { @@ -146,7 +152,8 @@ 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(); + CopilotSession session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("fake-test-model")).get(); session.close(); IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { @@ -167,7 +174,8 @@ 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(); + CopilotSession session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("fake-test-model")).get(); session.close(); IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { @@ -188,7 +196,8 @@ 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(); + CopilotSession session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("fake-test-model")).get(); session.close(); IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { @@ -207,7 +216,8 @@ 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(); + CopilotSession session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("fake-test-model")).get(); session.close(); IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { @@ -226,7 +236,8 @@ 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(); + CopilotSession session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("fake-test-model")).get(); session.close(); IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { @@ -247,7 +258,8 @@ 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(); + CopilotSession session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("fake-test-model")).get(); session.close(); IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> { @@ -266,7 +278,8 @@ 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(); + CopilotSession session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("fake-test-model")).get(); String sessionIdBeforeClose = session.getSessionId(); session.close(); @@ -284,7 +297,8 @@ 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(); + CopilotSession session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("fake-test-model")).get(); String pathBeforeClose = session.getWorkspacePath(); session.close(); @@ -301,7 +315,8 @@ 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(); + CopilotSession session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("fake-test-model")).get(); // First close should succeed assertDoesNotThrow(() -> session.close()); @@ -322,7 +337,8 @@ 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(); + CopilotSession session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("fake-test-model")).get(); try (session) { // Manual close within try-with-resources @@ -336,4 +352,23 @@ void testTryWithResourcesDoubleClose() throws Exception { }); } } + + /** + * Verifies that setModel() throws IllegalStateException after session is + * terminated. + */ + @Test + void testSetModelThrowsAfterTermination() throws Exception { + ctx.configureForTest("session", "should_receive_session_events"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("fake-test-model")).get(); + session.close(); + + assertThrows(IllegalStateException.class, () -> { + session.setModel("gpt-4.1"); + }); + } + } } diff --git a/src/test/java/com/github/copilot/sdk/CompactionTest.java b/src/test/java/com/github/copilot/sdk/CompactionTest.java index 33cdc56d1..a3a665e95 100644 --- a/src/test/java/com/github/copilot/sdk/CompactionTest.java +++ b/src/test/java/com/github/copilot/sdk/CompactionTest.java @@ -20,6 +20,7 @@ import com.github.copilot.sdk.events.SessionCompactionStartEvent; import com.github.copilot.sdk.json.InfiniteSessionConfig; import com.github.copilot.sdk.json.MessageOptions; +import com.github.copilot.sdk.json.PermissionHandler; import com.github.copilot.sdk.json.SessionConfig; /** @@ -66,7 +67,8 @@ void testShouldTriggerCompactionWithLowThresholdAndEmitEvents() throws Exception // Block at 1% to ensure compaction runs .setBufferExhaustionThreshold(0.01); - var config = new SessionConfig().setInfiniteSessions(infiniteConfig); + var config = new SessionConfig().setInfiniteSessions(infiniteConfig) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL); var events = new ArrayList(); @@ -77,9 +79,8 @@ void testShouldTriggerCompactionWithLowThresholdAndEmitEvents() throws Exception // Send multiple messages to fill up the context window // With such low thresholds, even a few messages should trigger compaction - session.sendAndWait( - new MessageOptions().setPrompt("Tell me a long story about a dragon. Be very detailed.")) - .get(60, TimeUnit.SECONDS); + session.sendAndWait(new MessageOptions().setPrompt("Tell me a story about a dragon. Be detailed.")).get(60, + TimeUnit.SECONDS); session.sendAndWait( new MessageOptions().setPrompt("Continue the story with more details about the dragon's castle.")) .get(60, TimeUnit.SECONDS); @@ -132,7 +133,8 @@ void testShouldNotEmitCompactionEventsWhenInfiniteSessionsDisabled() throws Exce var infiniteConfig = new InfiniteSessionConfig().setEnabled(false); - var config = new SessionConfig().setInfiniteSessions(infiniteConfig); + var config = new SessionConfig().setInfiniteSessions(infiniteConfig) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL); var compactionEvents = new ArrayList(); diff --git a/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java b/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java new file mode 100644 index 000000000..f3eceb4c2 --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java @@ -0,0 +1,187 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +import com.github.copilot.sdk.events.AbstractSessionEvent; +import com.github.copilot.sdk.json.CopilotClientOptions; +import com.github.copilot.sdk.json.MessageOptions; +import com.github.copilot.sdk.json.ModelInfo; +import com.github.copilot.sdk.json.ResumeSessionConfig; +import com.github.copilot.sdk.json.SessionConfig; + +class ConfigCloneTest { + + @Test + void copilotClientOptionsCloneBasic() { + CopilotClientOptions original = new CopilotClientOptions(); + original.setCliPath("/usr/local/bin/copilot"); + original.setLogLevel("debug"); + original.setPort(9000); + original.setGitHubToken("ghp_test"); + original.setUseLoggedInUser(false); + + CopilotClientOptions cloned = original.clone(); + + assertEquals(original.getCliPath(), cloned.getCliPath()); + assertEquals(original.getLogLevel(), cloned.getLogLevel()); + assertEquals(original.getPort(), cloned.getPort()); + assertEquals(original.getGitHubToken(), cloned.getGitHubToken()); + assertEquals(original.getUseLoggedInUser(), cloned.getUseLoggedInUser()); + } + + @Test + void copilotClientOptionsArrayIndependence() { + CopilotClientOptions original = new CopilotClientOptions(); + String[] args = {"--flag1", "--flag2"}; + original.setCliArgs(args); + + CopilotClientOptions cloned = original.clone(); + cloned.getCliArgs()[0] = "--changed"; + + assertEquals("--flag1", original.getCliArgs()[0]); + assertEquals("--changed", cloned.getCliArgs()[0]); + } + + @Test + void copilotClientOptionsEnvironmentIndependence() { + CopilotClientOptions original = new CopilotClientOptions(); + Map env = new HashMap<>(); + env.put("KEY1", "value1"); + original.setEnvironment(env); + + CopilotClientOptions cloned = original.clone(); + + // Mutate the original environment map to test independence + env.put("KEY2", "value2"); + + // The cloned config should be unaffected by mutations to the original map + assertEquals(1, cloned.getEnvironment().size()); + assertEquals(2, original.getEnvironment().size()); + } + + @Test + void copilotClientOptionsOnListModelsCloned() { + CopilotClientOptions original = new CopilotClientOptions(); + List models = List.of(new ModelInfo()); + original.setOnListModels(() -> CompletableFuture.completedFuture(models)); + + CopilotClientOptions cloned = original.clone(); + + assertNotNull(cloned.getOnListModels()); + assertSame(original.getOnListModels(), cloned.getOnListModels()); + } + + @Test + void sessionConfigCloneBasic() { + SessionConfig original = new SessionConfig(); + original.setSessionId("my-session"); + original.setClientName("my-app"); + original.setModel("gpt-4o"); + original.setStreaming(true); + + SessionConfig cloned = original.clone(); + + assertEquals(original.getSessionId(), cloned.getSessionId()); + assertEquals(original.getClientName(), cloned.getClientName()); + assertEquals(original.getModel(), cloned.getModel()); + assertEquals(original.isStreaming(), cloned.isStreaming()); + } + + @Test + void sessionConfigListIndependence() { + SessionConfig original = new SessionConfig(); + List toolList = new ArrayList<>(); + toolList.add("grep"); + toolList.add("bash"); + original.setAvailableTools(toolList); + + SessionConfig cloned = original.clone(); + + // Mutate the original list directly to test independence + toolList.add("web"); + + // The cloned config should be unaffected by mutations to the original list + assertEquals(2, cloned.getAvailableTools().size()); + assertEquals(3, original.getAvailableTools().size()); + } + + @Test + void sessionConfigAgentAndOnEventCloned() { + Consumer handler = event -> { + }; + SessionConfig original = new SessionConfig(); + original.setAgent("my-agent"); + original.setOnEvent(handler); + + SessionConfig cloned = original.clone(); + + assertEquals("my-agent", cloned.getAgent()); + assertSame(handler, cloned.getOnEvent()); + } + + @Test + void resumeSessionConfigCloneBasic() { + ResumeSessionConfig original = new ResumeSessionConfig(); + original.setModel("o1"); + original.setStreaming(false); + + ResumeSessionConfig cloned = original.clone(); + + assertEquals(original.getModel(), cloned.getModel()); + assertEquals(original.isStreaming(), cloned.isStreaming()); + } + + @Test + void resumeSessionConfigAgentAndOnEventCloned() { + Consumer handler = event -> { + }; + ResumeSessionConfig original = new ResumeSessionConfig(); + original.setAgent("my-agent"); + original.setOnEvent(handler); + + ResumeSessionConfig cloned = original.clone(); + + assertEquals("my-agent", cloned.getAgent()); + assertSame(handler, cloned.getOnEvent()); + } + + @Test + void messageOptionsCloneBasic() { + MessageOptions original = new MessageOptions(); + original.setPrompt("What is 2+2?"); + original.setMode("immediate"); + + MessageOptions cloned = original.clone(); + + assertEquals(original.getPrompt(), cloned.getPrompt()); + assertEquals(original.getMode(), cloned.getMode()); + } + + @Test + void clonePreservesNullFields() { + CopilotClientOptions opts = new CopilotClientOptions(); + CopilotClientOptions optsClone = opts.clone(); + assertNull(optsClone.getCliPath()); + + SessionConfig cfg = new SessionConfig(); + SessionConfig cfgClone = cfg.clone(); + assertNull(cfgClone.getModel()); + + MessageOptions msg = new MessageOptions(); + MessageOptions msgClone = msg.clone(); + assertNull(msgClone.getMode()); + } +} diff --git a/src/test/java/com/github/copilot/sdk/CopilotClientTest.java b/src/test/java/com/github/copilot/sdk/CopilotClientTest.java index 05e7290b3..bc5869fda 100644 --- a/src/test/java/com/github/copilot/sdk/CopilotClientTest.java +++ b/src/test/java/com/github/copilot/sdk/CopilotClientTest.java @@ -8,7 +8,9 @@ import org.junit.jupiter.api.Test; import com.github.copilot.sdk.json.CopilotClientOptions; +import com.github.copilot.sdk.json.PermissionHandler; import com.github.copilot.sdk.json.PingResponse; +import com.github.copilot.sdk.json.SessionConfig; import com.github.copilot.sdk.json.SessionLifecycleEvent; import com.github.copilot.sdk.json.SessionLifecycleEventTypes; @@ -149,6 +151,26 @@ void testStartAndConnectUsingStdio() throws Exception { } } + @Test + void testShouldReportErrorWithStderrWhenCliFailsToStart() throws Exception { + if (cliPath == null) { + System.out.println("Skipping test: CLI not found"); + return; + } + + var options = new CopilotClientOptions().setCliPath(cliPath) + .setCliArgs(new String[]{"--nonexistent-flag-for-testing"}).setUseStdio(true); + + try (var client = new CopilotClient(options)) { + Exception ex = assertThrows(Exception.class, () -> client.start().get()); + Throwable root = ex instanceof ExecutionException && ex.getCause() != null ? ex.getCause() : ex; + String message = root.getMessage(); + assertNotNull(message); + assertTrue(message.toLowerCase().contains("stderr") || message.toLowerCase().contains("unexpectedly"), + "Error should include stderr or unexpected exit details: " + message); + } + } + @Test void testStartAndConnectUsingTcp() throws Exception { if (cliPath == null) { @@ -175,7 +197,7 @@ void testForceStopWithoutCleanup() throws Exception { } try (var client = new CopilotClient(new CopilotClientOptions().setCliPath(cliPath))) { - client.createSession().get(); + client.createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); client.forceStop().get(); assertEquals(ConnectionState.DISCONNECTED, client.getState()); @@ -183,10 +205,10 @@ void testForceStopWithoutCleanup() throws Exception { } @Test - void testGithubTokenOptionAccepted() { - var options = new CopilotClientOptions().setCliPath("/path/to/cli").setGithubToken("gho_test_token"); + void testGitHubTokenOptionAccepted() { + var options = new CopilotClientOptions().setCliPath("/path/to/cli").setGitHubToken("gho_test_token"); - assertEquals("gho_test_token", options.getGithubToken()); + assertEquals("gho_test_token", options.getGitHubToken()); } @Test @@ -204,16 +226,16 @@ void testExplicitUseLoggedInUserFalse() { } @Test - void testExplicitUseLoggedInUserTrueWithGithubToken() { - var options = new CopilotClientOptions().setCliPath("/path/to/cli").setGithubToken("gho_test_token") + void testExplicitUseLoggedInUserTrueWithGitHubToken() { + var options = new CopilotClientOptions().setCliPath("/path/to/cli").setGitHubToken("gho_test_token") .setUseLoggedInUser(true); assertEquals(true, options.getUseLoggedInUser()); } @Test - void testGithubTokenWithCliUrlThrows() { - var options = new CopilotClientOptions().setCliUrl("localhost:8080").setGithubToken("gho_test_token"); + void testGitHubTokenWithCliUrlThrows() { + var options = new CopilotClientOptions().setCliUrl("localhost:8080").setGitHubToken("gho_test_token"); assertThrows(IllegalArgumentException.class, () -> new CopilotClient(options)); } @@ -424,6 +446,29 @@ void testForceStopWithNoConnectionCompletes() throws Exception { } } + @Test + void testCloseSessionAfterStoppingClientDoesNotThrow() throws Exception { + if (cliPath == null) { + System.out.println("Skipping test: CLI not found"); + return; + } + + try (var client = new CopilotClient(new CopilotClientOptions().setCliPath(cliPath))) { + var session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + + // Stop the client first (which closes the RPC connection) + client.stop().get(); + + // Then close the session - should not throw even though RPC is closed + assertDoesNotThrow(() -> session.close(), "Closing session after client.stop() should not throw exception"); + + // Verify session is terminated + assertThrows(IllegalStateException.class, () -> session.send("test"), + "Session should be terminated after close()"); + } + } + // ===== start() idempotency ===== @Test @@ -449,4 +494,69 @@ void testNullOptionsDefaultsToEmpty() { assertEquals(ConnectionState.DISCONNECTED, client.getState()); } } + + // ===== OnListModels ===== + + @Test + void testListModels_WithCustomHandler_CallsHandler() throws Exception { + var customModels = new ArrayList(); + var model = new com.github.copilot.sdk.json.ModelInfo(); + model.setId("my-custom-model"); + customModels.add(model); + + var callCount = new int[]{0}; + var options = new CopilotClientOptions().setOnListModels(() -> { + callCount[0]++; + return CompletableFuture.completedFuture(new ArrayList<>(customModels)); + }); + + try (var client = new CopilotClient(options)) { + var models = client.listModels().get(); + assertEquals(1, callCount[0]); + assertEquals(1, models.size()); + assertEquals("my-custom-model", models.get(0).getId()); + } + } + + @Test + void testListModels_WithCustomHandler_CachesResults() throws Exception { + var customModels = new ArrayList(); + var model = new com.github.copilot.sdk.json.ModelInfo(); + model.setId("cached-model"); + customModels.add(model); + + var callCount = new int[]{0}; + var options = new CopilotClientOptions().setOnListModels(() -> { + callCount[0]++; + return CompletableFuture.completedFuture(new ArrayList<>(customModels)); + }); + + try (var client = new CopilotClient(options)) { + client.listModels().get(); + client.listModels().get(); + assertEquals(1, callCount[0], "Handler should be called only once due to caching"); + } + } + + @Test + void testListModels_WithCustomHandler_WorksWithoutStart() throws Exception { + var customModels = new ArrayList(); + var model = new com.github.copilot.sdk.json.ModelInfo(); + model.setId("no-start-model"); + customModels.add(model); + + var callCount = new int[]{0}; + var options = new CopilotClientOptions().setOnListModels(() -> { + callCount[0]++; + return CompletableFuture.completedFuture(new ArrayList<>(customModels)); + }); + + // No start() needed when onListModels is provided + try (var client = new CopilotClient(options)) { + var models = client.listModels().get(); + assertEquals(1, callCount[0]); + assertEquals(1, models.size()); + assertEquals("no-start-model", models.get(0).getId()); + } + } } diff --git a/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java b/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java index d4357e111..787312cef 100644 --- a/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java +++ b/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java @@ -23,13 +23,14 @@ import com.github.copilot.sdk.events.AbstractSessionEvent; import com.github.copilot.sdk.events.AbortEvent; -import com.github.copilot.sdk.events.AssistantMessageDeltaEvent; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.events.SessionIdleEvent; import com.github.copilot.sdk.events.SessionStartEvent; import com.github.copilot.sdk.events.ToolExecutionStartEvent; import com.github.copilot.sdk.events.UserMessageEvent; import com.github.copilot.sdk.json.MessageOptions; +import com.github.copilot.sdk.json.PermissionHandler; +import com.github.copilot.sdk.json.ResumeSessionConfig; import com.github.copilot.sdk.json.SessionConfig; import com.github.copilot.sdk.json.SystemMessageConfig; import com.github.copilot.sdk.json.ToolDefinition; @@ -59,7 +60,7 @@ static void teardown() throws Exception { } /** - * Verifies that a session can be created and destroyed properly. + * Verifies that a session can be created and closed properly. * * @see Snapshot: session/should_receive_session_events */ @@ -68,7 +69,8 @@ void testShouldReceiveSessionEvents_createAndDestroy() 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(); + CopilotSession session = client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setModel("fake-test-model")).get(); assertNotNull(session.getSessionId()); assertTrue(session.getSessionId().matches("^[a-f0-9-]+$")); @@ -104,7 +106,8 @@ void testShouldHaveStatefulConversation() throws Exception { ctx.configureForTest("session", "should_have_stateful_conversation"); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession().get(); + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); AssistantMessageEvent response1 = session.sendAndWait(new MessageOptions().setPrompt("What is 1+1?"), 60000) .get(90, TimeUnit.SECONDS); @@ -136,7 +139,8 @@ void testShouldReceiveSessionEvents() throws Exception { ctx.configureForTest("session", "should_receive_session_events"); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession().get(); + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); List receivedEvents = new ArrayList<>(); CompletableFuture idleReceived = new CompletableFuture<>(); @@ -180,7 +184,8 @@ void testSendReturnsImmediatelyWhileEventsStreamInBackground() throws Exception ctx.configureForTest("session", "send_returns_immediately_while_events_stream_in_background"); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession().get(); + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); var events = new ArrayList(); var lastMessage = new AtomicReference(); @@ -227,7 +232,8 @@ void testSendAndWaitBlocksUntilSessionIdleAndReturnsFinalAssistantMessage() thro ctx.configureForTest("session", "sendandwait_blocks_until_session_idle_and_returns_final_assistant_message"); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession().get(); + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); var events = new ArrayList(); session.on(evt -> events.add(evt.getType())); @@ -257,7 +263,8 @@ void testShouldResumeSessionUsingTheSameClient() throws Exception { try (CopilotClient client = ctx.createClient()) { // Create initial session - CopilotSession session1 = client.createSession().get(); + CopilotSession session1 = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); String sessionId = session1.getSessionId(); AssistantMessageEvent answer = session1.sendAndWait(new MessageOptions().setPrompt("What is 1+1?")).get(60, @@ -267,7 +274,8 @@ void testShouldResumeSessionUsingTheSameClient() throws Exception { "Response should contain 2: " + answer.getData().content()); // Resume using the same client - CopilotSession session2 = client.resumeSession(sessionId).get(); + CopilotSession session2 = client.resumeSession(sessionId, + new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); assertEquals(sessionId, session2.getSessionId()); @@ -277,6 +285,14 @@ void testShouldResumeSessionUsingTheSameClient() throws Exception { .map(m -> (AssistantMessageEvent) m).anyMatch(m -> m.getData().content().contains("2")); assertTrue(hasAssistantMessage, "Should find previous assistant message containing 2"); + // Can continue the conversation statefully + AssistantMessageEvent answer2 = session2 + .sendAndWait(new MessageOptions().setPrompt("Now if you double that, what do you get?")) + .get(60, TimeUnit.SECONDS); + assertNotNull(answer2); + assertTrue(answer2.getData().content().contains("4"), + "Follow-up response should contain 4: " + answer2.getData().content()); + session2.close(); } } @@ -294,7 +310,8 @@ void testShouldResumeSessionUsingNewClient() throws Exception { // throughout the test, matching the behavior of other SDK implementations try (CopilotClient client1 = ctx.createClient()) { // Create initial session - CopilotSession session1 = client1.createSession().get(); + CopilotSession session1 = client1 + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); String sessionId = session1.getSessionId(); AssistantMessageEvent answer = session1.sendAndWait(new MessageOptions().setPrompt("What is 1+1?")).get(60, @@ -305,7 +322,8 @@ void testShouldResumeSessionUsingNewClient() throws Exception { // Resume using a new client (keeping client1 alive) try (CopilotClient client2 = ctx.createClient()) { - CopilotSession session2 = client2.resumeSession(sessionId).get(); + CopilotSession session2 = client2.resumeSession(sessionId, + new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); assertEquals(sessionId, session2.getSessionId()); @@ -316,6 +334,14 @@ void testShouldResumeSessionUsingNewClient() throws Exception { assertTrue(messages.stream().anyMatch(m -> "session.resume".equals(m.getType())), "Should contain session.resume event"); + // Can continue the conversation statefully + AssistantMessageEvent answer2 = session2 + .sendAndWait(new MessageOptions().setPrompt("Now if you double that, what do you get?")) + .get(60, TimeUnit.SECONDS); + assertNotNull(answer2); + assertTrue(answer2.getData().content().contains("4"), + "Follow-up response should contain 4: " + answer2.getData().content()); + session2.close(); } } @@ -333,8 +359,9 @@ void testShouldCreateSessionWithAppendedSystemMessageConfig() throws Exception { try (CopilotClient client = ctx.createClient()) { String systemMessageSuffix = "End each response with the phrase 'Have a nice day!'"; - SessionConfig config = new SessionConfig().setSystemMessage( - new SystemMessageConfig().setContent(systemMessageSuffix).setMode(SystemMessageMode.APPEND)); + SessionConfig config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setSystemMessage(new SystemMessageConfig().setContent(systemMessageSuffix) + .setMode(SystemMessageMode.APPEND)); CopilotSession session = client.createSession(config).get(); @@ -364,8 +391,9 @@ void testShouldCreateSessionWithReplacedSystemMessageConfig() throws Exception { try (CopilotClient client = ctx.createClient()) { String testSystemMessage = "You are an assistant called Testy McTestface. Reply succinctly."; - SessionConfig config = new SessionConfig().setSystemMessage( - new SystemMessageConfig().setContent(testSystemMessage).setMode(SystemMessageMode.REPLACE)); + SessionConfig config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setSystemMessage( + new SystemMessageConfig().setContent(testSystemMessage).setMode(SystemMessageMode.REPLACE)); CopilotSession session = client.createSession(config).get(); @@ -381,43 +409,6 @@ void testShouldCreateSessionWithReplacedSystemMessageConfig() throws Exception { } } - /** - * Verifies that streaming delta events are received when streaming is enabled. - * - * @see Snapshot: - * session/should_receive_streaming_delta_events_when_streaming_is_enabled - */ - @Test - void testShouldReceiveStreamingDeltaEventsWhenStreamingIsEnabled() throws Exception { - ctx.configureForTest("session", "should_receive_streaming_delta_events_when_streaming_is_enabled"); - - try (CopilotClient client = ctx.createClient()) { - SessionConfig config = new SessionConfig().setStreaming(true); - - CopilotSession session = client.createSession(config).get(); - - var receivedEvents = new ArrayList(); - var idleReceived = new CompletableFuture(); - - session.on(evt -> { - receivedEvents.add(evt); - if (evt instanceof SessionIdleEvent) { - idleReceived.complete(null); - } - }); - - session.send(new MessageOptions().setPrompt("What is 2+2?")).get(); - - idleReceived.get(60, TimeUnit.SECONDS); - - // Should have received delta events when streaming is enabled - boolean hasDeltaEvents = receivedEvents.stream().anyMatch(e -> e instanceof AssistantMessageDeltaEvent); - assertTrue(hasDeltaEvents, "Should receive streaming delta events when streaming is enabled"); - - session.close(); - } - } - /** * Verifies that a session can be aborted during tool execution. * @@ -428,7 +419,8 @@ void testShouldAbortSession() throws Exception { ctx.configureForTest("session", "should_abort_a_session"); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession().get(); + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); assertNotNull(session.getSessionId()); // Set up wait for tool execution to start BEFORE sending @@ -485,7 +477,8 @@ void testShouldCreateSessionWithAvailableTools() throws Exception { ctx.configureForTest("session", "should_create_a_session_with_availabletools"); try (CopilotClient client = ctx.createClient()) { - SessionConfig config = new SessionConfig().setAvailableTools(List.of("view", "edit")); + SessionConfig config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setAvailableTools(List.of("view", "edit")); CopilotSession session = client.createSession(config).get(); @@ -509,7 +502,8 @@ void testShouldCreateSessionWithExcludedTools() throws Exception { ctx.configureForTest("session", "should_create_a_session_with_excludedtools"); try (CopilotClient client = ctx.createClient()) { - SessionConfig config = new SessionConfig().setExcludedTools(List.of("view")); + SessionConfig config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setExcludedTools(List.of("view")); CopilotSession session = client.createSession(config).get(); @@ -536,7 +530,9 @@ void testShouldThrowErrorWhenResumingNonExistentSession() throws Exception { try (CopilotClient client = ctx.createClient()) { try { - client.resumeSession("non-existent-session-id").get(30, TimeUnit.SECONDS); + client.resumeSession("non-existent-session-id", + new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(30, TimeUnit.SECONDS); fail("Expected exception when resuming non-existent session"); } catch (Exception e) { // Should throw an error @@ -557,7 +553,8 @@ void testShouldCreateSessionWithCustomConfigDir() throws Exception { try (CopilotClient client = ctx.createClient()) { String customConfigDir = ctx.getWorkDir().resolve("custom-config").toString(); - SessionConfig config = new SessionConfig().setConfigDir(customConfigDir); + SessionConfig config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setConfigDir(customConfigDir); CopilotSession session = client.createSession(config).get(); assertNotNull(session.getSessionId()); @@ -589,7 +586,8 @@ void testSendAndWaitThrowsOnTimeout() throws Exception { ctx.configureForTest("session", "sendandwait_throws_on_timeout"); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession().get(); + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); // Use a short timeout that will trigger before any response try { @@ -624,10 +622,12 @@ void testShouldListSessions() throws Exception { try (CopilotClient client = ctx.createClient()) { // Create two sessions and send one message to each (matches snapshot format) - CopilotSession session1 = client.createSession().get(); + CopilotSession session1 = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); session1.sendAndWait(new MessageOptions().setPrompt("Say hello")).get(60, TimeUnit.SECONDS); - CopilotSession session2 = client.createSession().get(); + CopilotSession session2 = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); session2.sendAndWait(new MessageOptions().setPrompt("Say goodbye")).get(60, TimeUnit.SECONDS); // Small delay to ensure session files are written to disk @@ -661,7 +661,8 @@ void testShouldDeleteSession() throws Exception { try (CopilotClient client = ctx.createClient()) { // Create a session - CopilotSession session = client.createSession().get(); + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); String sessionId = session.getSessionId(); session.sendAndWait(new MessageOptions().setPrompt("Hello")).get(60, TimeUnit.SECONDS); @@ -681,7 +682,9 @@ void testShouldDeleteSession() throws Exception { // Trying to resume the deleted session should fail try { - client.resumeSession(sessionId).get(30, TimeUnit.SECONDS); + client.resumeSession(sessionId, + new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(30, TimeUnit.SECONDS); fail("Expected exception when resuming deleted session"); } catch (Exception e) { // Should throw an error indicating session not found @@ -721,7 +724,9 @@ void testShouldCreateSessionWithCustomTool() throws Exception { try (CopilotClient client = ctx.createClient()) { CopilotSession session = client - .createSession(new SessionConfig().setTools(java.util.List.of(getSecretNumberTool))).get(); + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setTools(java.util.List.of(getSecretNumberTool))) + .get(); AssistantMessageEvent response = session .sendAndWait(new MessageOptions().setPrompt("What is the secret number for key ALPHA?")) @@ -736,28 +741,24 @@ void testShouldCreateSessionWithCustomTool() throws Exception { } /** - * Verifies that streaming option is passed to session creation. + * Verifies that getLastSessionId returns the ID of the most recently used + * session. * - * @see Snapshot: session/should_pass_streaming_option_to_session_creation + * @see Snapshot: session/should_get_last_session_id */ @Test - void testShouldPassStreamingOptionToSessionCreation() throws Exception { - ctx.configureForTest("session", "should_pass_streaming_option_to_session_creation"); + void testShouldGetLastSessionId() throws Exception { + ctx.configureForTest("session", "should_get_last_session_id"); try (CopilotClient client = ctx.createClient()) { - // Verify that the streaming option is accepted without errors - CopilotSession session = client.createSession(new SessionConfig().setStreaming(true)).get(); + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); - assertNotNull(session.getSessionId()); - assertTrue(session.getSessionId().matches("^[a-f0-9-]+$")); + session.sendAndWait(new MessageOptions().setPrompt("Say hello")).get(60, TimeUnit.SECONDS); - // Session should still work normally - AssistantMessageEvent response = session.sendAndWait(new MessageOptions().setPrompt("What is 1+1?")).get(60, - TimeUnit.SECONDS); - - assertNotNull(response); - assertTrue(response.getData().content().contains("2"), - "Response should contain 2: " + response.getData().content()); + String lastId = client.getLastSessionId().get(30, TimeUnit.SECONDS); + assertNotNull(lastId, "Last session ID should not be null"); + assertEquals(session.getSessionId(), lastId, "Last session ID should match the current session ID"); session.close(); } @@ -774,7 +775,8 @@ void testListSessionsIncludesContextWhenAvailable() throws Exception { ctx.configureForTest("session", "should_list_sessions"); try (CopilotClient client = ctx.createClient()) { - var session = client.createSession().get(); + var session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); var sessions = client.listSessions().get(30, TimeUnit.SECONDS); assertNotNull(sessions); @@ -802,7 +804,8 @@ void testSessionListFilterFluentAPI() throws Exception { ctx.configureForTest("session", "should_list_sessions"); try (CopilotClient client = ctx.createClient()) { - var session = client.createSession().get(); + var session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); var filter = new com.github.copilot.sdk.json.SessionListFilter().setCwd("/test/path") .setRepository("owner/repo").setBranch("main").setGitRoot("/test"); diff --git a/src/test/java/com/github/copilot/sdk/DocumentationSamplesTest.java b/src/test/java/com/github/copilot/sdk/DocumentationSamplesTest.java new file mode 100644 index 000000000..bb8a6f07e --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/DocumentationSamplesTest.java @@ -0,0 +1,145 @@ +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; + +class DocumentationSamplesTest { + + @Test + void docsAndJbangSamplesUseRequiredPermissionHandler() throws IOException { + for (Path path : documentationFiles()) { + String content = stripStringsAndComments(Files.readString(path)); + assertFalse(hasConfigWithoutPermissionHandler(content, "SessionConfig"), + () -> path + " contains SessionConfig sample without setOnPermissionRequest"); + assertFalse(hasConfigWithoutPermissionHandler(content, "ResumeSessionConfig"), + () -> path + " contains ResumeSessionConfig sample without setOnPermissionRequest"); + assertFalse(hasSingleArgumentResumeSessionCall(content), + () -> path + " contains removed resumeSession(String) overload"); + } + } + + private static boolean hasConfigWithoutPermissionHandler(String content, String configType) { + String constructor = "new " + configType + "()"; + int fromIndex = 0; + while (true) { + int start = content.indexOf(constructor, fromIndex); + if (start < 0) { + return false; + } + int end = content.indexOf(';', start); + if (end < 0) { + end = content.length(); + } + if (!content.substring(start, end).contains("setOnPermissionRequest(")) { + return true; + } + fromIndex = start + constructor.length(); + } + } + + private static boolean hasSingleArgumentResumeSessionCall(String content) { + int fromIndex = 0; + while (true) { + int callStart = content.indexOf("resumeSession(", fromIndex); + if (callStart < 0) { + return false; + } + int index = callStart + "resumeSession(".length(); + int depth = 1; + int topLevelCommaCount = 0; + while (index < content.length() && depth > 0) { + char c = content.charAt(index); + if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + } else if (c == ',' && depth == 1) { + topLevelCommaCount++; + } + index++; + } + + if (depth == 0 && topLevelCommaCount == 0) { + return true; + } + fromIndex = callStart + 1; + } + } + + private static String stripStringsAndComments(String input) { + StringBuilder out = new StringBuilder(input.length()); + int i = 0; + while (i < input.length()) { + char c = input.charAt(i); + if (c == '"' || c == '\'') { + char quote = c; + out.append(' '); + i++; + while (i < input.length()) { + char current = input.charAt(i); + out.append(' '); + if (current == '\\') { + i++; + if (i < input.length()) { + out.append(' '); + } + } else if (current == quote) { + i++; + break; + } + i++; + } + continue; + } + if (c == '/' && i + 1 < input.length()) { + char next = input.charAt(i + 1); + if (next == '/') { + out.append(' ').append(' '); + i += 2; + while (i < input.length() && input.charAt(i) != '\n') { + out.append(' '); + i++; + } + continue; + } + if (next == '*') { + out.append(' ').append(' '); + i += 2; + while (i + 1 < input.length() && !(input.charAt(i) == '*' && input.charAt(i + 1) == '/')) { + out.append(' '); + i++; + } + if (i + 1 < input.length()) { + out.append(' ').append(' '); + i += 2; + } + continue; + } + } + out.append(c); + i++; + } + return out.toString(); + } + + private static List documentationFiles() throws IOException { + Path root = Path.of("").toAbsolutePath(); + List files = new ArrayList<>(); + files.add(root.resolve("README.md")); + files.add(root.resolve("jbang-example.java")); + + try (Stream markdownFiles = Files.walk(root.resolve("src/site/markdown"))) { + markdownFiles.filter(Files::isRegularFile).filter(path -> path.toString().endsWith(".md")) + .forEach(files::add); + } + return files; + } +} diff --git a/src/test/java/com/github/copilot/sdk/E2ETestContext.java b/src/test/java/com/github/copilot/sdk/E2ETestContext.java index 0ec14dc0b..74c65245b 100644 --- a/src/test/java/com/github/copilot/sdk/E2ETestContext.java +++ b/src/test/java/com/github/copilot/sdk/E2ETestContext.java @@ -44,7 +44,8 @@ * ctx.configureForTest("tools", "my_test_name"); * * try (CopilotClient client = ctx.createClient()) { - * CopilotSession session = client.createSession().get(); + * CopilotSession session = client + * .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); * // ... run test ... * } * } @@ -262,10 +263,10 @@ public CopilotClient createClient() { CopilotClientOptions options = new CopilotClientOptions().setCliPath(cliPath).setCwd(workDir.toString()) .setEnvironment(getEnvironment()); - // In CI, use a fake token to avoid auth issues - String ci = System.getenv("CI"); + // In CI (GitHub Actions), use a fake token to avoid auth issues + String ci = System.getenv("GITHUB_ACTIONS"); if (ci != null && !ci.isEmpty()) { - options.setGithubToken("fake-token-for-e2e-tests"); + options.setGitHubToken("fake-token-for-e2e-tests"); } return new CopilotClient(options); diff --git a/src/test/java/com/github/copilot/sdk/ErrorHandlingTest.java b/src/test/java/com/github/copilot/sdk/ErrorHandlingTest.java index e111d8854..8adc8a173 100644 --- a/src/test/java/com/github/copilot/sdk/ErrorHandlingTest.java +++ b/src/test/java/com/github/copilot/sdk/ErrorHandlingTest.java @@ -20,6 +20,7 @@ import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.events.SessionErrorEvent; import com.github.copilot.sdk.json.MessageOptions; +import com.github.copilot.sdk.json.PermissionHandler; import com.github.copilot.sdk.json.SessionConfig; import com.github.copilot.sdk.json.ToolDefinition; @@ -69,7 +70,8 @@ void testHandlesToolCallingErrors_toolErrorDoesNotCrashSession() throws Exceptio }); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession(new SessionConfig().setTools(List.of(errorTool))).get(); + CopilotSession session = client.createSession(new SessionConfig().setTools(List.of(errorTool)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); session.on(event -> allEvents.add(event)); @@ -107,7 +109,8 @@ void testHandlesToolCallingErrors_toolReturnsFailureResult() throws Exception { }); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession(new SessionConfig().setTools(List.of(failTool))).get(); + CopilotSession session = client.createSession(new SessionConfig().setTools(List.of(failTool)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); AssistantMessageEvent response = session .sendAndWait(new MessageOptions() @@ -216,7 +219,8 @@ void testHandlesToolCallingErrors_sessionContinuesAfterToolError() throws Except }); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession(new SessionConfig().setTools(List.of(errorTool))).get(); + CopilotSession session = client.createSession(new SessionConfig().setTools(List.of(errorTool)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); // First request that will cause tool error AssistantMessageEvent response = session diff --git a/src/test/java/com/github/copilot/sdk/HooksTest.java b/src/test/java/com/github/copilot/sdk/HooksTest.java index c728b9b99..05cd3292b 100644 --- a/src/test/java/com/github/copilot/sdk/HooksTest.java +++ b/src/test/java/com/github/copilot/sdk/HooksTest.java @@ -19,6 +19,7 @@ import org.junit.jupiter.api.Test; import com.github.copilot.sdk.json.MessageOptions; +import com.github.copilot.sdk.json.PermissionHandler; import com.github.copilot.sdk.json.PostToolUseHookInput; import com.github.copilot.sdk.json.PreToolUseHookInput; import com.github.copilot.sdk.json.PreToolUseHookOutput; @@ -67,11 +68,12 @@ void testInvokePreToolUseHookWhenModelRunsATool() throws Exception { var preToolUseInputs = new ArrayList(); final String[] sessionIdHolder = new String[1]; - var config = new SessionConfig().setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> { - preToolUseInputs.add(input); - assertEquals(sessionIdHolder[0], invocation.getSessionId()); - return CompletableFuture.completedFuture(PreToolUseHookOutput.allow()); - })); + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> { + preToolUseInputs.add(input); + assertEquals(sessionIdHolder[0], invocation.getSessionId()); + return CompletableFuture.completedFuture(PreToolUseHookOutput.allow()); + })); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession(config).get(); @@ -106,11 +108,12 @@ void testInvokePostToolUseHookAfterModelRunsATool() throws Exception { var postToolUseInputs = new ArrayList(); final String[] sessionIdHolder = new String[1]; - var config = new SessionConfig().setHooks(new SessionHooks().setOnPostToolUse((input, invocation) -> { - postToolUseInputs.add(input); - assertEquals(sessionIdHolder[0], invocation.getSessionId()); - return CompletableFuture.completedFuture(null); - })); + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setHooks(new SessionHooks().setOnPostToolUse((input, invocation) -> { + postToolUseInputs.add(input); + assertEquals(sessionIdHolder[0], invocation.getSessionId()); + return CompletableFuture.completedFuture(null); + })); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession(config).get(); @@ -147,13 +150,14 @@ void testInvokeBothHooksForSingleToolCall() throws Exception { var preToolUseInputs = new ArrayList(); var postToolUseInputs = new ArrayList(); - var config = new SessionConfig().setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> { - preToolUseInputs.add(input); - return CompletableFuture.completedFuture(PreToolUseHookOutput.allow()); - }).setOnPostToolUse((input, invocation) -> { - postToolUseInputs.add(input); - return CompletableFuture.completedFuture(null); - })); + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> { + preToolUseInputs.add(input); + return CompletableFuture.completedFuture(PreToolUseHookOutput.allow()); + }).setOnPostToolUse((input, invocation) -> { + postToolUseInputs.add(input); + return CompletableFuture.completedFuture(null); + })); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession(config).get(); @@ -192,11 +196,12 @@ void testDenyToolExecutionWhenPreToolUseReturnsDeny() throws Exception { var preToolUseInputs = new ArrayList(); - var config = new SessionConfig().setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> { - preToolUseInputs.add(input); - // Deny all tool calls - return CompletableFuture.completedFuture(PreToolUseHookOutput.deny()); - })); + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> { + preToolUseInputs.add(input); + // Deny all tool calls + return CompletableFuture.completedFuture(PreToolUseHookOutput.deny()); + })); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession(config).get(); diff --git a/src/test/java/com/github/copilot/sdk/McpAndAgentsTest.java b/src/test/java/com/github/copilot/sdk/McpAndAgentsTest.java index faa3deafa..84aa3b341 100644 --- a/src/test/java/com/github/copilot/sdk/McpAndAgentsTest.java +++ b/src/test/java/com/github/copilot/sdk/McpAndAgentsTest.java @@ -18,6 +18,7 @@ import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.json.CustomAgentConfig; import com.github.copilot.sdk.json.MessageOptions; +import com.github.copilot.sdk.json.PermissionHandler; import com.github.copilot.sdk.json.ResumeSessionConfig; import com.github.copilot.sdk.json.SessionConfig; @@ -71,7 +72,9 @@ void testShouldAcceptMcpServerConfigurationOnSessionCreate() throws Exception { mcpServers.put("test-server", createLocalMcpServer("echo", List.of("hello"))); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession(new SessionConfig().setMcpServers(mcpServers)).get(); + CopilotSession session = client.createSession( + new SessionConfig().setMcpServers(mcpServers).setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(); assertNotNull(session.getSessionId()); @@ -99,7 +102,8 @@ void testShouldAcceptMcpServerConfigurationOnSessionResume() throws Exception { try (CopilotClient client = ctx.createClient()) { // Create a session first - CopilotSession session1 = client.createSession().get(); + CopilotSession session1 = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); String sessionId = session1.getSessionId(); session1.sendAndWait(new MessageOptions().setPrompt("What is 1+1?")).get(60, TimeUnit.SECONDS); @@ -107,8 +111,8 @@ void testShouldAcceptMcpServerConfigurationOnSessionResume() throws Exception { var mcpServers = new HashMap(); mcpServers.put("test-server", createLocalMcpServer("echo", List.of("hello"))); - CopilotSession session2 = client - .resumeSession(sessionId, new ResumeSessionConfig().setMcpServers(mcpServers)).get(); + CopilotSession session2 = client.resumeSession(sessionId, new ResumeSessionConfig() + .setMcpServers(mcpServers).setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); assertEquals(sessionId, session2.getSessionId()); @@ -140,7 +144,9 @@ void testShouldHandleMultipleMcpServers() throws Exception { mcpServers.put("server2", createLocalMcpServer("echo", List.of("server2"))); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession(new SessionConfig().setMcpServers(mcpServers)).get(); + CopilotSession session = client.createSession( + new SessionConfig().setMcpServers(mcpServers).setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(); assertNotNull(session.getSessionId()); session.close(); @@ -164,7 +170,8 @@ void testShouldAcceptCustomAgentConfigurationOnSessionCreate() throws Exception .setPrompt("You are a helpful test agent.").setInfer(true)); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession(new SessionConfig().setCustomAgents(customAgents)).get(); + CopilotSession session = client.createSession(new SessionConfig().setCustomAgents(customAgents) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); assertNotNull(session.getSessionId()); @@ -192,7 +199,8 @@ void testShouldAcceptCustomAgentConfigurationOnSessionResume() throws Exception try (CopilotClient client = ctx.createClient()) { // Create a session first - CopilotSession session1 = client.createSession().get(); + CopilotSession session1 = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); String sessionId = session1.getSessionId(); session1.sendAndWait(new MessageOptions().setPrompt("What is 1+1?")).get(60, TimeUnit.SECONDS); @@ -201,8 +209,8 @@ void testShouldAcceptCustomAgentConfigurationOnSessionResume() throws Exception .of(new CustomAgentConfig().setName("resume-agent").setDisplayName("Resume Agent") .setDescription("An agent added on resume").setPrompt("You are a resume test agent.")); - CopilotSession session2 = client - .resumeSession(sessionId, new ResumeSessionConfig().setCustomAgents(customAgents)).get(); + CopilotSession session2 = client.resumeSession(sessionId, new ResumeSessionConfig() + .setCustomAgents(customAgents).setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); assertEquals(sessionId, session2.getSessionId()); @@ -234,7 +242,8 @@ void testShouldAcceptCustomAgentWithToolsConfiguration() throws Exception { .setPrompt("You are an agent with specific tools.").setTools(List.of("bash", "edit")).setInfer(true)); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession(new SessionConfig().setCustomAgents(customAgents)).get(); + CopilotSession session = client.createSession(new SessionConfig().setCustomAgents(customAgents) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); assertNotNull(session.getSessionId()); session.close(); @@ -260,7 +269,8 @@ void testShouldAcceptCustomAgentWithMcpServers() throws Exception { .setPrompt("You are an agent with MCP servers.").setMcpServers(agentMcpServers)); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession(new SessionConfig().setCustomAgents(customAgents)).get(); + CopilotSession session = client.createSession(new SessionConfig().setCustomAgents(customAgents) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); assertNotNull(session.getSessionId()); session.close(); @@ -285,7 +295,8 @@ void testShouldAcceptMultipleCustomAgents() throws Exception { .setPrompt("You are agent two.").setInfer(false)); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession(new SessionConfig().setCustomAgents(customAgents)).get(); + CopilotSession session = client.createSession(new SessionConfig().setCustomAgents(customAgents) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); assertNotNull(session.getSessionId()); session.close(); @@ -312,8 +323,8 @@ void testShouldAcceptBothMcpServersAndCustomAgents() throws Exception { .setPrompt("You are a combined test agent.")); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client - .createSession(new SessionConfig().setMcpServers(mcpServers).setCustomAgents(customAgents)).get(); + CopilotSession session = client.createSession(new SessionConfig().setMcpServers(mcpServers) + .setCustomAgents(customAgents).setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); assertNotNull(session.getSessionId()); diff --git a/src/test/java/com/github/copilot/sdk/MetadataApiTest.java b/src/test/java/com/github/copilot/sdk/MetadataApiTest.java index b3eb8fcb7..580c58153 100644 --- a/src/test/java/com/github/copilot/sdk/MetadataApiTest.java +++ b/src/test/java/com/github/copilot/sdk/MetadataApiTest.java @@ -327,7 +327,7 @@ void testListModels() throws Exception { // ===== Protocol Version Test ===== @Test - void testProtocolVersionIsTwo() { - assertEquals(2, SdkProtocolVersion.get()); + void testProtocolVersionIsThree() { + assertEquals(3, SdkProtocolVersion.get()); } } diff --git a/src/test/java/com/github/copilot/sdk/PermissionRequestResultKindTest.java b/src/test/java/com/github/copilot/sdk/PermissionRequestResultKindTest.java new file mode 100644 index 000000000..b21f96e83 --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/PermissionRequestResultKindTest.java @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.sdk.json.PermissionRequestResult; +import com.github.copilot.sdk.json.PermissionRequestResultKind; + +/** + * Unit tests for {@link PermissionRequestResultKind}. + *

+ * Covers well-known kind values, equality, hash code, serialization, and + * backward-compatible {@link PermissionRequestResult} integration. + */ +public class PermissionRequestResultKindTest { + + @Test + void wellKnownKinds_haveExpectedValues() { + assertEquals("approved", PermissionRequestResultKind.APPROVED.getValue()); + assertEquals("denied-by-rules", PermissionRequestResultKind.DENIED_BY_RULES.getValue()); + assertEquals("denied-no-approval-rule-and-could-not-request-from-user", + PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER.getValue()); + assertEquals("denied-interactively-by-user", + PermissionRequestResultKind.DENIED_INTERACTIVELY_BY_USER.getValue()); + } + + @Test + void equals_sameValue_returnsTrue() { + var a = new PermissionRequestResultKind("approved"); + assertEquals(PermissionRequestResultKind.APPROVED, a); + assertEquals(a, PermissionRequestResultKind.APPROVED); + } + + @Test + void equals_differentValue_returnsFalse() { + assertNotEquals(PermissionRequestResultKind.APPROVED, PermissionRequestResultKind.DENIED_BY_RULES); + } + + @Test + void equals_isCaseInsensitive() { + var upper = new PermissionRequestResultKind("APPROVED"); + assertEquals(PermissionRequestResultKind.APPROVED, upper); + } + + @Test + void hashCode_isCaseInsensitive() { + var upper = new PermissionRequestResultKind("APPROVED"); + assertEquals(PermissionRequestResultKind.APPROVED.hashCode(), upper.hashCode()); + } + + @Test + void toString_returnsValue() { + assertEquals("approved", PermissionRequestResultKind.APPROVED.toString()); + assertEquals("denied-by-rules", PermissionRequestResultKind.DENIED_BY_RULES.toString()); + } + + @Test + void customValue_isPreserved() { + var custom = new PermissionRequestResultKind("custom-kind"); + assertEquals("custom-kind", custom.getValue()); + assertEquals("custom-kind", custom.toString()); + } + + @Test + void constructor_nullValue_treatedAsEmpty() { + var kind = new PermissionRequestResultKind(null); + assertEquals("", kind.getValue()); + assertEquals("", kind.toString()); + } + + @Test + void equals_nonKindObject_returnsFalse() { + assertNotEquals(PermissionRequestResultKind.APPROVED, "approved"); + } + + @Test + void jsonSerialize_writesStringValue() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + var result = new PermissionRequestResult().setKind(PermissionRequestResultKind.APPROVED); + String json = mapper.writeValueAsString(result); + assertTrue(json.contains("\"kind\":\"approved\""), "Expected kind to be serialized as string: " + json); + } + + @Test + void jsonDeserialize_readsStringValue() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + String json = "{\"kind\":\"denied-by-rules\"}"; + var result = mapper.readValue(json, PermissionRequestResult.class); + assertEquals("denied-by-rules", result.getKind()); + } + + @Test + void permissionRequestResult_setKindWithKindType() { + var result = new PermissionRequestResult().setKind(PermissionRequestResultKind.APPROVED); + assertEquals("approved", result.getKind()); + } + + @Test + void permissionRequestResult_setKindWithString_backwardCompatible() { + var result = new PermissionRequestResult().setKind("approved"); + assertEquals("approved", result.getKind()); + } + + @Test + void jsonRoundTrip_allWellKnownKinds() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + PermissionRequestResultKind[] kinds = {PermissionRequestResultKind.APPROVED, + PermissionRequestResultKind.DENIED_BY_RULES, + PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER, + PermissionRequestResultKind.DENIED_INTERACTIVELY_BY_USER,}; + for (PermissionRequestResultKind kind : kinds) { + var result = new PermissionRequestResult().setKind(kind); + String json = mapper.writeValueAsString(result); + var deserialized = mapper.readValue(json, PermissionRequestResult.class); + assertEquals(kind.getValue(), deserialized.getKind()); + } + } +} diff --git a/src/test/java/com/github/copilot/sdk/PermissionsTest.java b/src/test/java/com/github/copilot/sdk/PermissionsTest.java index d07b7c500..af9347e33 100644 --- a/src/test/java/com/github/copilot/sdk/PermissionsTest.java +++ b/src/test/java/com/github/copilot/sdk/PermissionsTest.java @@ -18,6 +18,8 @@ import org.junit.jupiter.api.TestInfo; import com.github.copilot.sdk.events.AssistantMessageEvent; +import com.github.copilot.sdk.events.ToolExecutionCompleteEvent; +import com.github.copilot.sdk.json.PermissionHandler; import com.github.copilot.sdk.json.PermissionRequest; import com.github.copilot.sdk.json.PermissionRequestResult; import com.github.copilot.sdk.json.SessionConfig; @@ -125,17 +127,17 @@ void testDenyPermission(TestInfo testInfo) throws Exception { } /** - * Verifies that sessions work without a permission handler. + * Verifies that sessions work with the approve-all permission handler. * - * @see Snapshot: permissions/without_permission_handler + * @see Snapshot: permissions/should_work_with_approve_all_permission_handler */ @Test - void testWithoutPermissionHandler(TestInfo testInfo) throws Exception { - ctx.configureForTest("permissions", "without_permission_handler"); + void testShouldWorkWithApproveAllPermissionHandler(TestInfo testInfo) throws Exception { + ctx.configureForTest("permissions", "should_work_with_approve_all_permission_handler"); try (CopilotClient client = ctx.createClient()) { - // Create session without onPermissionRequest handler - CopilotSession session = client.createSession().get(); + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); AssistantMessageEvent response = session.sendAndWait(new MessageOptions().setPrompt("What is 2+2?")).get(60, TimeUnit.SECONDS); @@ -198,8 +200,9 @@ void testResumeSessionWithPermissionHandler(TestInfo testInfo) throws Exception var permissionRequests = new ArrayList(); try (CopilotClient client = ctx.createClient()) { - // Create session without permission handler - CopilotSession session1 = client.createSession().get(); + // Create session with approve-all handler for initial exchange + CopilotSession session1 = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); String sessionId = session1.getSessionId(); session1.sendAndWait(new MessageOptions().setPrompt("What is 1+1?")).get(60, TimeUnit.SECONDS); @@ -286,4 +289,78 @@ void testShouldHandlePermissionHandlerErrorsGracefully(TestInfo testInfo) throws session.close(); } } + + /** + * Verifies that tool operations are denied when the handler explicitly denies. + * + * @see Snapshot: + * permissions/should_deny_tool_operations_when_handler_explicitly_denies + */ + @Test + void testShouldDenyToolOperationsWhenHandlerExplicitlyDenies(TestInfo testInfo) throws Exception { + ctx.configureForTest("permissions", "should_deny_tool_operations_when_handler_explicitly_denies"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client + .createSession( + new SessionConfig().setOnPermissionRequest((request, + invocation) -> CompletableFuture.completedFuture(new PermissionRequestResult() + .setKind("denied-no-approval-rule-and-could-not-request-from-user")))) + .get(); + + final boolean[] permissionDenied = {false}; + session.on(ToolExecutionCompleteEvent.class, evt -> { + if (!evt.getData().success() && evt.getData().error() != null && evt.getData().error().message() != null + && evt.getData().error().message().contains("Permission denied")) { + permissionDenied[0] = true; + } + }); + + session.sendAndWait(new MessageOptions().setPrompt("Run 'node --version'")).get(60, TimeUnit.SECONDS); + + assertTrue(permissionDenied[0], "Expected a tool.execution_complete event with Permission denied result"); + + session.close(); + } + } + + /** + * Verifies that tool operations are denied when the handler explicitly denies + * after resuming a session. + * + * @see Snapshot: + * permissions/should_deny_tool_operations_when_handler_explicitly_denies_after_resume + */ + @Test + void testShouldDenyToolOperationsWhenHandlerExplicitlyDeniesAfterResume(TestInfo testInfo) throws Exception { + ctx.configureForTest("permissions", "should_deny_tool_operations_when_handler_explicitly_denies_after_resume"); + + try (CopilotClient client = ctx.createClient()) { + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL); + CopilotSession session1 = client.createSession(config).get(); + String sessionId = session1.getSessionId(); + session1.sendAndWait(new MessageOptions().setPrompt("What is 1+1?")).get(60, TimeUnit.SECONDS); + + CopilotSession session2 = client + .resumeSession(sessionId, + new ResumeSessionConfig().setOnPermissionRequest((request, + invocation) -> CompletableFuture.completedFuture(new PermissionRequestResult() + .setKind("denied-no-approval-rule-and-could-not-request-from-user")))) + .get(); + + final boolean[] permissionDenied = {false}; + session2.on(ToolExecutionCompleteEvent.class, evt -> { + if (!evt.getData().success() && evt.getData().error() != null && evt.getData().error().message() != null + && evt.getData().error().message().contains("Permission denied")) { + permissionDenied[0] = true; + } + }); + + session2.sendAndWait(new MessageOptions().setPrompt("Run 'node --version'")).get(60, TimeUnit.SECONDS); + + assertTrue(permissionDenied[0], "Expected a tool.execution_complete event with Permission denied result"); + + session2.close(); + } + } } diff --git a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java index 6a6737754..2e9da4fd2 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java @@ -862,7 +862,7 @@ private SessionStartEvent createSessionStartEvent(String sessionId) { private AssistantMessageEvent createAssistantMessageEvent(String content) { var event = new AssistantMessageEvent(); - var data = new AssistantMessageEvent.AssistantMessageData(null, content, null, null, null, null, null); + var data = new AssistantMessageEvent.AssistantMessageData(null, content, null, null, null, null, null, null); 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 a102dfe27..d4770a721 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java @@ -158,6 +158,59 @@ void testParseSessionModelChangeEvent() throws Exception { assertEquals("session.model_change", event.getType()); } + @Test + void testParseSessionModeChangedEvent() throws Exception { + String json = """ + { + "type": "session.mode_changed", + "data": { + "previousMode": "interactive", + "newMode": "plan" + } + } + """; + + AbstractSessionEvent event = parseJson(json); + assertNotNull(event); + assertInstanceOf(SessionModeChangedEvent.class, event); + assertEquals("session.mode_changed", event.getType()); + } + + @Test + void testParseSessionPlanChangedEvent() throws Exception { + String json = """ + { + "type": "session.plan_changed", + "data": { + "operation": "update" + } + } + """; + + AbstractSessionEvent event = parseJson(json); + assertNotNull(event); + assertInstanceOf(SessionPlanChangedEvent.class, event); + assertEquals("session.plan_changed", event.getType()); + } + + @Test + void testParseSessionWorkspaceFileChangedEvent() throws Exception { + String json = """ + { + "type": "session.workspace_file_changed", + "data": { + "path": "plan.md", + "operation": "create" + } + } + """; + + AbstractSessionEvent event = parseJson(json); + assertNotNull(event); + assertInstanceOf(SessionWorkspaceFileChangedEvent.class, event); + assertEquals("session.workspace_file_changed", event.getType()); + } + @Test void testParseSessionHandoffEvent() throws Exception { String json = """ @@ -861,7 +914,8 @@ void testParseEmptyJson() throws Exception { @Test void testParseAllEventTypes() throws Exception { String[] types = {"session.start", "session.resume", "session.error", "session.idle", "session.info", - "session.model_change", "session.handoff", "session.truncation", "session.snapshot_rewind", + "session.model_change", "session.mode_changed", "session.plan_changed", + "session.workspace_file_changed", "session.handoff", "session.truncation", "session.snapshot_rewind", "session.usage_info", "session.compaction_start", "session.compaction_complete", "user.message", "pending_messages.modified", "assistant.turn_start", "assistant.intent", "assistant.reasoning", "assistant.reasoning_delta", "assistant.message", "assistant.message_delta", "assistant.turn_end", @@ -1289,6 +1343,7 @@ void testAssistantMessageEventAllFields() throws Exception { } ], "parentToolCallId": "parent-tc", + "interactionId": "interaction-msg-1", "reasoningOpaque": "opaque-data", "reasoningText": "My reasoning", "encryptedContent": "enc123" @@ -1302,6 +1357,7 @@ void testAssistantMessageEventAllFields() throws Exception { assertEquals("msg-rich", data.messageId()); assertEquals("Full response", data.content()); assertEquals("parent-tc", data.parentToolCallId()); + assertEquals("interaction-msg-1", data.interactionId()); assertEquals("opaque-data", data.reasoningOpaque()); assertEquals("My reasoning", data.reasoningText()); assertEquals("enc123", data.encryptedContent()); @@ -1323,7 +1379,6 @@ void testAssistantMessageDeltaEventAllFields() throws Exception { "data": { "messageId": "msg-delta-1", "deltaContent": "partial text", - "totalResponseSizeBytes": 4096.0, "parentToolCallId": "ptc-1" } } @@ -1334,10 +1389,62 @@ void testAssistantMessageDeltaEventAllFields() throws Exception { var data = event.getData(); assertEquals("msg-delta-1", data.messageId()); assertEquals("partial text", data.deltaContent()); - assertEquals(4096.0, data.totalResponseSizeBytes()); assertEquals("ptc-1", data.parentToolCallId()); } + @Test + void testAssistantStreamingDeltaEventAllFields() throws Exception { + String json = """ + { + "type": "assistant.streaming_delta", + "data": { + "totalResponseSizeBytes": 4096.0 + } + } + """; + + var event = (AssistantStreamingDeltaEvent) parseJson(json); + assertNotNull(event); + assertEquals("assistant.streaming_delta", event.getType()); + assertEquals(4096.0, event.getData().totalResponseSizeBytes()); + } + + @Test + void testAssistantMessageEventIncludesInteractionId() throws Exception { + String json = """ + { + "type": "assistant.message", + "data": { + "messageId": "msg-with-interaction", + "content": "Response", + "interactionId": "interaction-abc-123" + } + } + """; + + var event = (AssistantMessageEvent) parseJson(json); + assertNotNull(event); + assertEquals("interaction-abc-123", event.getData().interactionId()); + } + + @Test + void testAssistantTurnStartEventIncludesInteractionId() throws Exception { + String json = """ + { + "type": "assistant.turn_start", + "data": { + "turnId": "turn-with-interaction", + "interactionId": "interaction-xyz-456" + } + } + """; + + var event = (AssistantTurnStartEvent) parseJson(json); + assertNotNull(event); + assertEquals("turn-with-interaction", event.getData().turnId()); + assertEquals("interaction-xyz-456", event.getData().interactionId()); + } + @Test void testAssistantUsageEventAllFields() throws Exception { String json = """ @@ -1358,6 +1465,23 @@ void testAssistantUsageEventAllFields() throws Exception { "quotaSnapshots": { "premium": 100, "standard": 500 + }, + "copilotUsage": { + "totalNanoAiu": 1234567.0, + "tokenDetails": [ + { + "tokenType": "input", + "tokenCount": 500.0, + "batchSize": 100.0, + "costPerBatch": 0.001 + }, + { + "tokenType": "output", + "tokenCount": 200.0, + "batchSize": 100.0, + "costPerBatch": 0.002 + } + ] } } } @@ -1379,6 +1503,15 @@ void testAssistantUsageEventAllFields() throws Exception { assertEquals("ptc-usage", data.parentToolCallId()); assertNotNull(data.quotaSnapshots()); assertEquals(2, data.quotaSnapshots().size()); + + // Verify copilotUsage + assertNotNull(data.copilotUsage()); + assertEquals(1234567.0, data.copilotUsage().totalNanoAiu()); + assertNotNull(data.copilotUsage().tokenDetails()); + assertEquals(2, data.copilotUsage().tokenDetails().size()); + assertEquals("input", data.copilotUsage().tokenDetails().get(0).tokenType()); + assertEquals(500.0, data.copilotUsage().tokenDetails().get(0).tokenCount()); + assertEquals("output", data.copilotUsage().tokenDetails().get(1).tokenType()); } @Test @@ -1494,6 +1627,8 @@ void testToolExecutionCompleteEventWithError() throws Exception { "data": { "toolCallId": "tc-err-1", "success": false, + "model": "claude-3-5-sonnet", + "interactionId": "interaction-tool-1", "isUserRequested": true, "error": { "message": "File not found", @@ -1513,6 +1648,8 @@ void testToolExecutionCompleteEventWithError() throws Exception { var data = event.getData(); assertEquals("tc-err-1", data.toolCallId()); assertFalse(data.success()); + assertEquals("claude-3-5-sonnet", data.model()); + assertEquals("interaction-tool-1", data.interactionId()); assertTrue(data.isUserRequested()); assertEquals("ptc-complete", data.parentToolCallId()); @@ -2061,4 +2198,186 @@ void testParseJsonNodeUserMessageWithAttachment() throws Exception { assertEquals(0, att.selection().start().line()); assertEquals(14, att.selection().end().character()); } + + @Test + void testParseExternalToolRequestedEvent() throws Exception { + String json = """ + { + "type": "external_tool.requested", + "data": { + "requestId": "req-123", + "sessionId": "sess-456", + "toolCallId": "call-789", + "toolName": "get_weather", + "arguments": {"location": "Seattle"} + } + } + """; + + var event = (ExternalToolRequestedEvent) parseJson(json); + assertNotNull(event); + assertEquals("external_tool.requested", event.getType()); + assertNotNull(event.getData()); + assertEquals("req-123", event.getData().requestId()); + assertEquals("sess-456", event.getData().sessionId()); + assertEquals("call-789", event.getData().toolCallId()); + assertEquals("get_weather", event.getData().toolName()); + } + + @Test + void testParseExternalToolCompletedEvent() throws Exception { + String json = """ + { + "type": "external_tool.completed", + "data": { + "requestId": "req-123" + } + } + """; + + var event = (ExternalToolCompletedEvent) parseJson(json); + assertNotNull(event); + assertEquals("external_tool.completed", event.getType()); + assertEquals("req-123", event.getData().requestId()); + } + + @Test + void testParsePermissionRequestedEvent() throws Exception { + String json = """ + { + "type": "permission.requested", + "data": { + "requestId": "perm-req-456", + "permissionRequest": { + "kind": "shell", + "toolCallId": "call-001" + } + } + } + """; + + var event = (PermissionRequestedEvent) parseJson(json); + assertNotNull(event); + assertEquals("permission.requested", event.getType()); + assertEquals("perm-req-456", event.getData().requestId()); + assertNotNull(event.getData().permissionRequest()); + assertEquals("shell", event.getData().permissionRequest().getKind()); + } + + @Test + void testParsePermissionCompletedEvent() throws Exception { + String json = """ + { + "type": "permission.completed", + "data": { + "requestId": "perm-req-456", + "result": { + "kind": "approved" + } + } + } + """; + + var event = (PermissionCompletedEvent) parseJson(json); + assertNotNull(event); + assertEquals("permission.completed", event.getType()); + assertEquals("perm-req-456", event.getData().requestId()); + assertEquals("approved", event.getData().result().kind()); + } + + @Test + void testParseCommandQueuedEvent() throws Exception { + String json = """ + { + "type": "command.queued", + "data": { + "requestId": "cmd-req-789", + "command": "/help" + } + } + """; + + var event = (CommandQueuedEvent) parseJson(json); + assertNotNull(event); + assertEquals("command.queued", event.getType()); + assertEquals("cmd-req-789", event.getData().requestId()); + assertEquals("/help", event.getData().command()); + } + + @Test + void testParseCommandCompletedEvent() throws Exception { + String json = """ + { + "type": "command.completed", + "data": { + "requestId": "cmd-req-789" + } + } + """; + + var event = (CommandCompletedEvent) parseJson(json); + assertNotNull(event); + assertEquals("command.completed", event.getType()); + assertEquals("cmd-req-789", event.getData().requestId()); + } + + @Test + void testParseExitPlanModeRequestedEvent() throws Exception { + String json = """ + { + "type": "exit_plan_mode.requested", + "data": { + "requestId": "plan-req-001", + "summary": "Plan is ready", + "planContent": "## Plan\\n1. Do thing", + "actions": ["approve", "edit", "reject"], + "recommendedAction": "approve" + } + } + """; + + var event = (ExitPlanModeRequestedEvent) parseJson(json); + assertNotNull(event); + assertEquals("exit_plan_mode.requested", event.getType()); + assertEquals("plan-req-001", event.getData().requestId()); + assertEquals("Plan is ready", event.getData().summary()); + assertEquals(3, event.getData().actions().length); + assertEquals("approve", event.getData().recommendedAction()); + } + + @Test + void testParseExitPlanModeCompletedEvent() throws Exception { + String json = """ + { + "type": "exit_plan_mode.completed", + "data": { + "requestId": "plan-req-001" + } + } + """; + + var event = (ExitPlanModeCompletedEvent) parseJson(json); + assertNotNull(event); + assertEquals("exit_plan_mode.completed", event.getType()); + assertEquals("plan-req-001", event.getData().requestId()); + } + + @Test + void testParseSystemNotificationEvent() throws Exception { + String json = """ + { + "type": "system.notification", + "data": { + "content": "Agent completed", + "kind": {"type": "agent_completed", "agentId": "agent-1", "agentType": "task", "status": "completed"} + } + } + """; + + var event = (SystemNotificationEvent) parseJson(json); + assertNotNull(event); + assertEquals("system.notification", event.getType()); + assertNotNull(event.getData()); + assertTrue(event.getData().content().contains("Agent completed")); + } } diff --git a/src/test/java/com/github/copilot/sdk/SessionEventsE2ETest.java b/src/test/java/com/github/copilot/sdk/SessionEventsE2ETest.java index 5af1b445b..dad3f5907 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventsE2ETest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventsE2ETest.java @@ -26,6 +26,8 @@ import com.github.copilot.sdk.events.ToolExecutionStartEvent; import com.github.copilot.sdk.events.UserMessageEvent; import com.github.copilot.sdk.json.MessageOptions; +import com.github.copilot.sdk.json.PermissionHandler; +import com.github.copilot.sdk.json.SessionConfig; /** * E2E tests for session events to verify event lifecycle. @@ -63,7 +65,8 @@ void testShouldReceiveSessionEvents_assistantTurnEvents() throws Exception { var allEvents = new ArrayList(); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession().get(); + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); session.on(event -> allEvents.add(event)); @@ -104,7 +107,8 @@ void testShouldReceiveSessionEvents_userMessageEvent() throws Exception { var userMessages = new ArrayList(); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession().get(); + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); session.on(UserMessageEvent.class, userMessages::add); @@ -130,7 +134,8 @@ void testInvokesBuiltInTools_toolExecutionCompleteEvent() throws Exception { var toolCompletes = new ArrayList(); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession().get(); + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); session.on(ToolExecutionStartEvent.class, toolStarts::add); session.on(ToolExecutionCompleteEvent.class, toolCompletes::add); @@ -168,7 +173,8 @@ void testShouldReceiveSessionEvents_assistantUsageEvent() throws Exception { var usageEvents = new ArrayList(); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession().get(); + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); session.on(AssistantUsageEvent.class, usageEvents::add); @@ -194,7 +200,8 @@ void testShouldReceiveSessionEvents_sessionIdleAfterMessage() throws Exception { var allEvents = new ArrayList(); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession().get(); + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); session.on(event -> allEvents.add(event)); @@ -235,7 +242,8 @@ void testInvokesBuiltInTools_eventOrderDuringToolExecution() throws Exception { var eventTypes = new ArrayList(); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession().get(); + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); session.on(event -> eventTypes.add(event.getType())); diff --git a/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java b/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java index f6de79a0f..c00355211 100644 --- a/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java @@ -37,6 +37,8 @@ void testBuildCreateRequestNullConfig() { CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(null); assertNotNull(request); assertNull(request.getModel()); + assertTrue(request.getRequestPermission(), "requestPermission should be true even for null config"); + assertEquals("direct", request.getEnvValueMode(), "envValueMode should be 'direct' even for null config"); } @Test @@ -59,6 +61,27 @@ void testBuildCreateRequestHooksWithHandler() { assertTrue(request.getHooks(), "Should be true when hooks have handlers"); } + @Test + void testBuildCreateRequestSetsEnvValueModeToDirect() { + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(new SessionConfig()); + assertEquals("direct", request.getEnvValueMode()); + } + + @Test + void testBuildCreateRequestAlwaysSetsRequestPermissionTrue() { + // No permission handler set - requestPermission should still be true + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(new SessionConfig()); + assertTrue(request.getRequestPermission(), + "requestPermission should always be true to enable deny-by-default behavior"); + } + + @Test + void testBuildCreateRequestSetsClientName() { + var config = new SessionConfig().setClientName("my-app"); + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config); + assertEquals("my-app", request.getClientName()); + } + // ========================================================================= // buildResumeRequest // ========================================================================= @@ -68,6 +91,8 @@ void testBuildResumeRequestNullConfig() { ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-1", null); assertEquals("sid-1", request.getSessionId()); assertNull(request.getModel()); + assertTrue(request.getRequestPermission(), "requestPermission should be true even for null config"); + assertEquals("direct", request.getEnvValueMode(), "envValueMode should be 'direct' even for null config"); } @Test @@ -130,6 +155,27 @@ void testBuildResumeRequestStreaming() { assertTrue(request.getStreaming()); } + @Test + void testBuildResumeRequestSetsEnvValueModeToDirect() { + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-8", new ResumeSessionConfig()); + assertEquals("direct", request.getEnvValueMode()); + } + + @Test + void testBuildResumeRequestAlwaysSetsRequestPermissionTrue() { + // No permission handler set - requestPermission should still be true + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-9", new ResumeSessionConfig()); + assertTrue(request.getRequestPermission(), + "requestPermission should always be true to enable deny-by-default behavior"); + } + + @Test + void testBuildResumeRequestSetsClientName() { + var config = new ResumeSessionConfig().setClientName("my-app"); + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-10", config); + assertEquals("my-app", request.getClientName()); + } + // ========================================================================= // configureSession (ResumeSessionConfig overload) // ========================================================================= @@ -191,4 +237,18 @@ private CopilotSession createTestSession() throws Exception { constructor.setAccessible(true); return constructor.newInstance("builder-test-session", null, null); } + + @Test + void testBuildCreateRequestWithAgent() { + var config = new SessionConfig().setAgent("my-agent"); + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config, "test-session-id"); + assertEquals("my-agent", request.getAgent()); + } + + @Test + void testBuildResumeRequestWithAgent() { + var config = new ResumeSessionConfig().setAgent("my-agent"); + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("session-id", config); + assertEquals("my-agent", request.getAgent()); + } } diff --git a/src/test/java/com/github/copilot/sdk/SkillsTest.java b/src/test/java/com/github/copilot/sdk/SkillsTest.java index 9367145d7..e2900a927 100644 --- a/src/test/java/com/github/copilot/sdk/SkillsTest.java +++ b/src/test/java/com/github/copilot/sdk/SkillsTest.java @@ -19,6 +19,7 @@ import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.json.MessageOptions; +import com.github.copilot.sdk.json.PermissionHandler; import com.github.copilot.sdk.json.SessionConfig; /** @@ -105,7 +106,8 @@ void testShouldLoadAndApplySkillFromSkillDirectories() throws Exception { Path skillsDir = createSkillDir(); - SessionConfig config = new SessionConfig().setSkillDirectories(List.of(skillsDir.toString())); + SessionConfig config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setSkillDirectories(List.of(skillsDir.toString())); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession(config).get(); @@ -136,8 +138,8 @@ void testShouldNotApplySkillWhenDisabledViaDisabledSkills() throws Exception { Path skillsDir = createSkillDir(); - SessionConfig config = new SessionConfig().setSkillDirectories(List.of(skillsDir.toString())) - .setDisabledSkills(List.of("test-skill")); + SessionConfig config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setSkillDirectories(List.of(skillsDir.toString())).setDisabledSkills(List.of("test-skill")); try (CopilotClient client = ctx.createClient()) { CopilotSession session = client.createSession(config).get(); diff --git a/src/test/java/com/github/copilot/sdk/StreamingFidelityTest.java b/src/test/java/com/github/copilot/sdk/StreamingFidelityTest.java new file mode 100644 index 000000000..d6bae399d --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/StreamingFidelityTest.java @@ -0,0 +1,185 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.github.copilot.sdk.events.AbstractSessionEvent; +import com.github.copilot.sdk.events.AssistantMessageDeltaEvent; +import com.github.copilot.sdk.events.AssistantMessageEvent; +import com.github.copilot.sdk.json.MessageOptions; +import com.github.copilot.sdk.json.PermissionHandler; +import com.github.copilot.sdk.json.ResumeSessionConfig; +import com.github.copilot.sdk.json.SessionConfig; + +/** + * E2E tests for streaming fidelity — verifying that delta events are produced + * when streaming is enabled and absent when it is disabled. + * + *

+ * Snapshots are stored in {@code test/snapshots/streaming_fidelity/}. + *

+ */ +public class StreamingFidelityTest { + + 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 assistant.message_delta events are produced when streaming is + * enabled. + * + * @see Snapshot: + * streaming_fidelity/should_produce_delta_events_when_streaming_is_enabled + */ + @Test + void testShouldProduceDeltaEventsWhenStreamingIsEnabled() throws Exception { + ctx.configureForTest("streaming_fidelity", "should_produce_delta_events_when_streaming_is_enabled"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession( + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setStreaming(true)).get(); + + List events = new ArrayList<>(); + session.on(events::add); + + session.sendAndWait(new MessageOptions().setPrompt("Count from 1 to 5, separated by commas.")).get(60, + TimeUnit.SECONDS); + + List types = events.stream().map(AbstractSessionEvent::getType).toList(); + + // Should have streaming deltas before the final message + List deltaEvents = events.stream() + .filter(e -> e instanceof AssistantMessageDeltaEvent).map(e -> (AssistantMessageDeltaEvent) e) + .toList(); + assertFalse(deltaEvents.isEmpty(), "Should have received delta events when streaming is enabled"); + + // Deltas should have content + for (AssistantMessageDeltaEvent delta : deltaEvents) { + assertFalse(delta.getData().deltaContent() == null || delta.getData().deltaContent().isEmpty(), + "Delta event should have content"); + } + + // Should still have a final assistant.message + assertTrue(types.contains("assistant.message"), "Should have a final assistant.message event"); + + // Deltas should come before the final message + int firstDeltaIdx = types.indexOf("assistant.message_delta"); + int lastAssistantIdx = types.lastIndexOf("assistant.message"); + assertTrue(firstDeltaIdx < lastAssistantIdx, "Delta events should come before the final assistant.message"); + + session.close(); + } + } + + /** + * Verifies that no delta events are produced when streaming is disabled. + * + * @see Snapshot: + * streaming_fidelity/should_not_produce_deltas_when_streaming_is_disabled + */ + @Test + void testShouldNotProduceDeltasWhenStreamingIsDisabled() throws Exception { + ctx.configureForTest("streaming_fidelity", "should_not_produce_deltas_when_streaming_is_disabled"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession( + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setStreaming(false)) + .get(); + + List events = new ArrayList<>(); + session.on(events::add); + + session.sendAndWait(new MessageOptions().setPrompt("Say 'hello world'.")).get(60, TimeUnit.SECONDS); + + List deltaEvents = events.stream() + .filter(e -> e instanceof AssistantMessageDeltaEvent).map(e -> (AssistantMessageDeltaEvent) e) + .toList(); + + // No deltas when streaming is off + assertTrue(deltaEvents.isEmpty(), "Should not receive delta events when streaming is disabled"); + + // But should still have a final assistant.message + List assistantEvents = events.stream() + .filter(e -> e instanceof AssistantMessageEvent).map(e -> (AssistantMessageEvent) e).toList(); + assertFalse(assistantEvents.isEmpty(), + "Should still have a final assistant.message when streaming is disabled"); + + session.close(); + } + } + + /** + * Verifies that delta events are produced after resuming a session with + * streaming enabled. + * + * @see Snapshot: streaming_fidelity/should_produce_deltas_after_session_resume + */ + @Test + void testShouldProduceDeltasAfterSessionResume() throws Exception { + ctx.configureForTest("streaming_fidelity", "should_produce_deltas_after_session_resume"); + + try (CopilotClient client = ctx.createClient()) { + // Create a non-streaming session and send an initial message + CopilotSession session = client.createSession( + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setStreaming(false)) + .get(); + session.sendAndWait(new MessageOptions().setPrompt("What is 3 + 6?")).get(60, TimeUnit.SECONDS); + String sessionId = session.getSessionId(); + session.close(); + + // Resume using a new client with streaming enabled + try (CopilotClient newClient = ctx.createClient()) { + CopilotSession session2 = newClient.resumeSession(sessionId, new ResumeSessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setStreaming(true)).get(); + + List events = new ArrayList<>(); + session2.on(events::add); + + AssistantMessageEvent answer = session2 + .sendAndWait(new MessageOptions().setPrompt("Now if you double that, what do you get?")) + .get(60, TimeUnit.SECONDS); + assertNotNull(answer); + assertTrue(answer.getData().content().contains("18"), + "Follow-up response should contain 18: " + answer.getData().content()); + + // Should have streaming deltas before the final message + List deltaEvents = events.stream() + .filter(e -> e instanceof AssistantMessageDeltaEvent).map(e -> (AssistantMessageDeltaEvent) e) + .toList(); + assertFalse(deltaEvents.isEmpty(), "Should have received delta events after session resume"); + + // Deltas should have content + for (AssistantMessageDeltaEvent delta : deltaEvents) { + assertFalse(delta.getData().deltaContent() == null || delta.getData().deltaContent().isEmpty(), + "Delta event should have content"); + } + + session2.close(); + } + } + } +} diff --git a/src/test/java/com/github/copilot/sdk/ToolsTest.java b/src/test/java/com/github/copilot/sdk/ToolsTest.java index bc392d9f0..538da74d2 100644 --- a/src/test/java/com/github/copilot/sdk/ToolsTest.java +++ b/src/test/java/com/github/copilot/sdk/ToolsTest.java @@ -8,6 +8,7 @@ import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -21,6 +22,9 @@ import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.json.MessageOptions; +import com.github.copilot.sdk.json.PermissionHandler; +import com.github.copilot.sdk.json.PermissionRequest; +import com.github.copilot.sdk.json.PermissionRequestResult; import com.github.copilot.sdk.json.SessionConfig; import com.github.copilot.sdk.json.ToolDefinition; @@ -62,7 +66,8 @@ void testInvokesBuiltInTools(TestInfo testInfo) throws Exception { Files.writeString(readmeFile, "# ELIZA, the only chatbot you'll ever need"); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession().get(); + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); AssistantMessageEvent response = session .sendAndWait( @@ -105,7 +110,8 @@ void testInvokesCustomTool(TestInfo testInfo) throws Exception { }); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession(new SessionConfig().setTools(List.of(encryptTool))).get(); + CopilotSession session = client.createSession(new SessionConfig().setTools(List.of(encryptTool)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); AssistantMessageEvent response = session .sendAndWait(new MessageOptions().setPrompt("Use encrypt_string to encrypt this string: Hello")) @@ -141,7 +147,8 @@ void testHandlesToolCallingErrors(TestInfo testInfo) throws Exception { }); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession(new SessionConfig().setTools(List.of(errorTool))).get(); + CopilotSession session = client.createSession(new SessionConfig().setTools(List.of(errorTool)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); AssistantMessageEvent response = session .sendAndWait(new MessageOptions() @@ -202,7 +209,8 @@ void testCanReceiveAndReturnComplexTypes(TestInfo testInfo) throws Exception { }); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession(new SessionConfig().setTools(List.of(dbQueryTool))).get(); + CopilotSession session = client.createSession(new SessionConfig().setTools(List.of(dbQueryTool)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); AssistantMessageEvent response = session .sendAndWait(new MessageOptions().setPrompt( @@ -218,4 +226,138 @@ void testCanReceiveAndReturnComplexTypes(TestInfo testInfo) throws Exception { session.close(); } } + + /** + * Verifies that a custom tool is invoked with the permission handler being + * called and can inspect the permission request kind. + * + * @see Snapshot: tools/invokes_custom_tool_with_permission_handler + */ + @Test + void testInvokesCustomToolWithPermissionHandler(TestInfo testInfo) throws Exception { + ctx.configureForTest("tools", "invokes_custom_tool_with_permission_handler"); + + var permissionRequests = new ArrayList(); + + var parameters = new HashMap(); + parameters.put("type", "object"); + var props = new HashMap(); + props.put("input", Map.of("type", "string")); + parameters.put("properties", props); + parameters.put("required", List.of("input")); + + ToolDefinition encryptTool = ToolDefinition.create("encrypt_string", "Encrypts a string", parameters, + (invocation) -> { + Map args = invocation.getArguments(); + String input = (String) args.get("input"); + return CompletableFuture.completedFuture(input.toUpperCase()); + }); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession( + new SessionConfig().setTools(List.of(encryptTool)).setOnPermissionRequest((request, invocation) -> { + permissionRequests.add(request); + return PermissionHandler.APPROVE_ALL.handle(request, invocation); + })).get(); + + AssistantMessageEvent response = session + .sendAndWait(new MessageOptions().setPrompt("Use encrypt_string to encrypt this string: Hello")) + .get(60, TimeUnit.SECONDS); + + assertNotNull(response); + assertTrue(response.getData().content().contains("HELLO"), + "Response should contain HELLO: " + response.getData().content()); + + // Should have received a custom-tool permission request + boolean hasCustomToolRequest = permissionRequests.stream() + .anyMatch(req -> "custom-tool".equals(req.getKind())); + assertTrue(hasCustomToolRequest, "Should have received a custom-tool permission request"); + + session.close(); + } + } + + /** + * Verifies that a custom tool is denied when the permission handler denies it. + * + * @see Snapshot: tools/denies_custom_tool_when_permission_denied + */ + @Test + void testDeniesCustomToolWhenPermissionDenied(TestInfo testInfo) throws Exception { + ctx.configureForTest("tools", "denies_custom_tool_when_permission_denied"); + + final boolean[] toolHandlerCalled = {false}; + + var parameters = new HashMap(); + parameters.put("type", "object"); + var props = new HashMap(); + props.put("input", Map.of("type", "string")); + parameters.put("properties", props); + parameters.put("required", List.of("input")); + + ToolDefinition encryptTool = ToolDefinition.create("encrypt_string", "Encrypts a string", parameters, + (invocation) -> { + toolHandlerCalled[0] = true; + Map args = invocation.getArguments(); + String input = (String) args.get("input"); + return CompletableFuture.completedFuture(input.toUpperCase()); + }); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client + .createSession( + new SessionConfig().setTools(List.of(encryptTool)) + .setOnPermissionRequest((request, invocation) -> CompletableFuture.completedFuture( + new PermissionRequestResult().setKind("denied-interactively-by-user")))) + .get(); + + session.sendAndWait(new MessageOptions().setPrompt("Use encrypt_string to encrypt this string: Hello")) + .get(60, TimeUnit.SECONDS); + + // The tool handler should NOT have been called since permission was denied + assertFalse(toolHandlerCalled[0], "Tool handler should not be called when permission is denied"); + + session.close(); + } + } + + /** + * Verifies that a custom tool can override a built-in CLI tool with the same + * name when {@code overridesBuiltInTool} is set to {@code true}. + * + * @see Snapshot: tools/overrides_built_in_tool_with_custom_tool + */ + @Test + void testOverridesBuiltInToolWithCustomTool() throws Exception { + ctx.configureForTest("tools", "overrides_built_in_tool_with_custom_tool"); + + var parameters = new HashMap(); + var properties = new HashMap(); + properties.put("query", Map.of("type", "string", "description", "Search query")); + parameters.put("type", "object"); + parameters.put("properties", properties); + parameters.put("required", List.of("query")); + + ToolDefinition customGrep = ToolDefinition.createOverride("grep", "A custom grep implementation", parameters, + (invocation) -> { + Map args = invocation.getArguments(); + String query = (String) args.get("query"); + return CompletableFuture.completedFuture("CUSTOM_GREP_RESULT: " + query); + }); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(new SessionConfig().setTools(List.of(customGrep)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + + AssistantMessageEvent response = session + .sendAndWait(new MessageOptions().setPrompt("Use grep to search for the word 'hello'")) + .get(60, TimeUnit.SECONDS); + + assertNotNull(response); + assertTrue(response.getData().content().contains("CUSTOM_GREP_RESULT"), + "Response should contain CUSTOM_GREP_RESULT: " + response.getData().content()); + + session.close(); + } + } } diff --git a/src/test/prompts/PROMPT-smoke-test.md b/src/test/prompts/PROMPT-smoke-test.md new file mode 100644 index 000000000..4013002ac --- /dev/null +++ b/src/test/prompts/PROMPT-smoke-test.md @@ -0,0 +1,135 @@ +# Prompt: Generate and Run the copilot-sdk-java Smoke Test + +## Objective + +Create a Maven project that acts as a smoke test for `copilot-sdk-java`. The project must compile, build, and run to completion with exit code 0 as the definition of success. + +## Step 1 — Read the source README + +Read the file `README.md` at the top level of this repository. You will need two sections from it: + +- **"Snapshot Builds"** — provides the Maven GAV (groupId, artifactId, version) and the Maven Central Snapshots repository configuration to use for the dependency under test. +- **"Quick Start"** — provides the exact Java source code for the smoke test program. Use this code verbatim. Do not modify it, fix it, or improve it. If it does not compile or run correctly against the artifact under test, that is itself a smoke test failure and must be reported as such rather than silently corrected. + +## Step 2 — Create the Maven project + +Create the following file layout in a subdirectory named `smoke-test/` at the top level of this repository: + +``` +smoke-test/ + pom.xml + src/main/java/(Class name taken from the code in the "Quick Start" section in the README).java ← verbatim from README "Quick Start" +``` + +### `pom.xml` requirements + +- **groupId**: `com.github` (or any reasonable value) +- **artifactId**: `copilot-sdk-smoketest` +- **version**: `1.0-SNAPSHOT` +- **packaging**: `jar` +- **Java source/target**: (taken from the "Requirements" section in the README) (via `maven.compiler.source` and `maven.compiler.target` properties) +- **`mainClass` property**: (taken from the "Quick Start" section in the README) (the class is in the default package) + +#### Snapshot repository + +Configure the Maven Central Snapshots repository exactly as specified in the "Snapshot Builds" section of `README.md`, and add `always` inside the `` block so that every build fetches the latest snapshot without requiring `-U`: + +```xml + + central-snapshots + https://central.sonatype.com/repository/maven-snapshots/ + + true + always + + +``` + +#### Dependency + +Use the GAV from the "Snapshot Builds" section of `README.md` verbatim — do not substitute the release version from the "Maven" section. + +#### Plugins — REQUIRED configuration + +**Do not use `maven-shade-plugin`.** Use the `Class-Path` manifest approach instead: + +1. **`maven-jar-plugin`** (version **3.4.1** — pin explicitly to suppress Maven version warnings): + + ```xml + + org.apache.maven.plugins + maven-jar-plugin + 3.4.1 + + + + ${mainClass} + true + lib/ + false + + + + + ``` + + **Critical**: `false` is mandatory. Without it, the manifest `Class-Path:` entry uses the timestamped SNAPSHOT filename (e.g. `copilot-sdk-java-0.1.33-20260312.125508-3.jar`) while `copy-dependencies` writes the base SNAPSHOT filename (`copilot-sdk-java-0.1.33-SNAPSHOT.jar`), causing `NoClassDefFoundError` at runtime. + +2. **`maven-dependency-plugin`** (version **3.6.1**): + + ```xml + + org.apache.maven.plugins + maven-dependency-plugin + 3.6.1 + + + copy-dependencies + package + copy-dependencies + + ${project.build.directory}/lib + + + + + ``` + + This copies all runtime dependency JARs into `target/lib/`, which is where the manifest `Class-Path:` points. + +## Step 3 — Build + +```bash +mvn -U clean package +``` + +The `-U` flag forces a fresh snapshot metadata check regardless of local cache. The `always` already handles this for normal invocations, but `-U` is the safest choice for CI. + +Build must succeed with `BUILD SUCCESS` before proceeding. + +## Step 4 — Run + +```bash +java -jar ./target/copilot-sdk-smoketest-1.0-SNAPSHOT.jar +``` + +The JAR must be run from the `smoke-test/` directory so that the relative `lib/` path in the manifest resolves correctly. Do not use `-cp` or `-classpath` — the test specifically validates that `java -jar` works with the manifest `Class-Path:` approach. + +## Step 5 — Verify success + +The smoke test passes if and only if the process exits with code **0**. + +The "Quick Start" code in `README.md` already contains the exit-code logic: it captures the last assistant message and calls `System.exit(0)` if it contains `"4"` (the expected answer to "What is 2+2?"), or `System.exit(-1)` otherwise. + +Check the exit code: +```bash +echo "Exit code: $?" +``` + +Expected: `Exit code: 0` + +## Important API notes (do not apply these as fixes — they are here for diagnostic context only) + +If the build fails with compilation errors such as `cannot find symbol` on methods like `getContent()`, `getCurrentTokens()`, `getTokenLimit()`, or `getMessagesLength()`, this indicates a mismatch between the Quick Start code and the SDK implementation. **Do not silently fix the code.** Report the failure. The purpose of this smoke test is precisely to catch such regressions. + +For reference: the data classes in `copilot-sdk-java` are Java **records**. Record accessor methods have no `get` prefix — they are named `content()`, `currentTokens()`, `tokenLimit()`, and `messagesLength()`. If the README Quick Start uses `getContent()` etc., that is a bug in the README that must be surfaced, not silently corrected. diff --git a/test b/test new file mode 100644 index 000000000..e69de29bb