diff --git a/.github/lsp.json b/.github/lsp.json index 7535212849..e58456ac43 100644 --- a/.github/lsp.json +++ b/.github/lsp.json @@ -21,24 +21,6 @@ ".go": "go" }, "rootUri": "go" - }, - "rust-analyzer": { - "command": "rust-analyzer", - "fileExtensions": { - ".rs": "rust" - }, - "initializationOptions": { - "cargo": { - "buildScripts": { - "enable": true - }, - "allFeatures": true - }, - "checkOnSave": true, - "check": { - "command": "clippy" - } - } } } } diff --git a/.github/workflows/go-sdk-tests.yml b/.github/workflows/go-sdk-tests.yml index e262961096..365e60373f 100644 --- a/.github/workflows/go-sdk-tests.yml +++ b/.github/workflows/go-sdk-tests.yml @@ -29,7 +29,7 @@ permissions: jobs: test: - name: "Go SDK Tests" + name: "Go SDK Tests (${{ matrix.os }}, ${{ matrix.transport }})" if: github.event.repository.fork == false env: POWERSHELL_UPDATECHECK: Off @@ -37,6 +37,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] + transport: ["default", "inprocess"] runs-on: ${{ matrix.os }} defaults: run: @@ -78,6 +79,12 @@ jobs: if: runner.os == 'Windows' run: pwsh.exe -Command "Write-Host 'PowerShell ready'" + - name: Select inprocess transport + if: matrix.transport == 'inprocess' + run: | + echo "COPILOT_SDK_DEFAULT_CONNECTION=inprocess" >> "$GITHUB_ENV" + echo "GOFLAGS=-tags=copilot_inprocess" >> "$GITHUB_ENV" + - name: Run Go SDK tests env: COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} diff --git a/.github/workflows/java-publish-maven.yml b/.github/workflows/java-publish-maven.yml index e22cd05a5b..944a0ee38e 100644 --- a/.github/workflows/java-publish-maven.yml +++ b/.github/workflows/java-publish-maven.yml @@ -22,6 +22,34 @@ on: type: boolean required: false default: false + workflow_call: + inputs: + releaseVersion: + description: "Release version (e.g., 1.0.0). If empty, derives from pom.xml by removing -SNAPSHOT" + required: false + type: string + developmentVersion: + description: "Next development version (e.g., 1.0.1-SNAPSHOT). If empty, increments patch version" + required: false + type: string + prerelease: + description: "Is this a prerelease?" + type: boolean + required: false + default: false + secrets: + JAVA_RELEASE_TOKEN: + required: true + JAVA_RELEASE_GITHUB_TOKEN: + required: true + JAVA_MAVEN_CENTRAL_USERNAME: + required: true + JAVA_MAVEN_CENTRAL_PASSWORD: + required: true + JAVA_GPG_SECRET_KEY: + required: true + JAVA_GPG_PASSPHRASE: + required: true permissions: contents: write @@ -144,10 +172,10 @@ jobs: exit 1 fi else - # Split version: supports "0.1.32", "0.1.32-java.0", and "0.1.32-java-preview.0" formats + # Split version: supports "0.1.32", "0.1.32-preview.0", "0.1.32-java.0", and "0.1.32-java-preview.0" formats # Validate RELEASE_VERSION format explicitly to provide clear errors - if ! echo "$RELEASE_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-(beta-)?java(-preview)?\.[0-9]+)?$'; then - echo "Error: RELEASE_VERSION '$RELEASE_VERSION' is invalid. Expected format: M.M.P, M.M.P-java.N, M.M.P-java-preview.N, M.M.P-beta-java.N, or M.M.P-beta-java-preview.N (e.g., 1.2.3, 1.2.3-java.0, 1.2.3-java-preview.0, 1.2.3-beta-java.0, or 1.2.3-beta-java-preview.0)." >&2 + if ! echo "$RELEASE_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-(preview|(beta-)?java(-preview)?)\.[0-9]+)?$'; then + echo "Error: RELEASE_VERSION '$RELEASE_VERSION' is invalid. Expected format: M.M.P, M.M.P-preview.N, M.M.P-java.N, M.M.P-java-preview.N, M.M.P-beta-java.N, or M.M.P-beta-java-preview.N (e.g., 1.2.3, 1.2.3-preview.0, 1.2.3-java.0, 1.2.3-java-preview.0, 1.2.3-beta-java.0, or 1.2.3-beta-java-preview.0)." >&2 exit 1 fi # Extract the base M.M.P portion (before any qualifier) diff --git a/.github/workflows/nodejs-sdk-tests.yml b/.github/workflows/nodejs-sdk-tests.yml index 8880cadfa3..647345e0ea 100644 --- a/.github/workflows/nodejs-sdk-tests.yml +++ b/.github/workflows/nodejs-sdk-tests.yml @@ -31,7 +31,7 @@ permissions: jobs: test: - name: "Node.js SDK Tests" + name: "Node.js SDK Tests (${{ matrix.os }}, ${{ matrix.transport }})" if: github.event.repository.fork == false env: POWERSHELL_UPDATECHECK: Off @@ -39,6 +39,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] + transport: ["default", "inprocess"] runs-on: ${{ matrix.os }} defaults: run: @@ -75,6 +76,11 @@ jobs: if: runner.os == 'Windows' run: pwsh.exe -Command "Write-Host 'PowerShell ready'" + - name: Select inprocess transport + if: matrix.transport == 'inprocess' + run: | + echo "COPILOT_SDK_DEFAULT_CONNECTION=inprocess" >> "$GITHUB_ENV" + - name: Run Node.js SDK tests env: COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e5b23a5816..cd96dd0fa9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -171,13 +171,17 @@ jobs: - name: Set version run: sed -i -E 's/^version = ".*"$/version = "${{ needs.version.outputs.version }}"/' Cargo.toml - name: Snapshot CLI version + hashes for build.rs - run: bash scripts/snapshot-bundled-cli-version.sh - - name: Verify cli-version.txt exists run: | - if [[ ! -f cli-version.txt ]]; then - echo "::error::cli-version.txt was not generated. The Snapshot step must run before packaging." - exit 1 - fi + bash scripts/snapshot-bundled-cli-version.sh + bash scripts/snapshot-bundled-in-process-version.sh + - name: Verify CLI version snapshots exist + run: | + for snapshot in cli-version.txt cli-version-in-process.txt; do + if [[ ! -f "${snapshot}" ]]; then + echo "::error::${snapshot} was not generated. The Snapshot step must run before packaging." + exit 1 + fi + done - name: Package (dry run) run: cargo publish --dry-run --allow-dirty - name: Upload artifact @@ -229,10 +233,28 @@ jobs: with: packages-dir: python/dist/ + publish-java: + name: Publish Java SDK + if: github.event.inputs.dist-tag != 'unstable' && github.ref == 'refs/heads/main' + needs: version + uses: ./.github/workflows/java-publish-maven.yml + with: + releaseVersion: ${{ needs.version.outputs.version }} + prerelease: ${{ github.event.inputs.dist-tag == 'prerelease' }} + secrets: inherit + github-release: name: Create GitHub Release - needs: [version, publish-nodejs, publish-dotnet, publish-python, publish-rust] - if: github.ref == 'refs/heads/main' && github.event.inputs.dist-tag != 'unstable' + needs: [version, publish-nodejs, publish-dotnet, publish-python, publish-rust, publish-java] + if: | + always() && + github.ref == 'refs/heads/main' && + github.event.inputs.dist-tag != 'unstable' && + needs.version.result == 'success' && + needs.publish-nodejs.result == 'success' && + needs.publish-dotnet.result == 'success' && + needs.publish-python.result == 'success' && + needs.publish-rust.result == 'success' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 diff --git a/.github/workflows/python-sdk-tests.yml b/.github/workflows/python-sdk-tests.yml index e6260dd0b2..8d3ea07154 100644 --- a/.github/workflows/python-sdk-tests.yml +++ b/.github/workflows/python-sdk-tests.yml @@ -31,7 +31,7 @@ permissions: jobs: test: - name: "Python SDK Tests" + name: "Python SDK Tests (${{ matrix.os }}, ${{ matrix.transport }})" if: github.event.repository.fork == false env: POWERSHELL_UPDATECHECK: Off @@ -41,6 +41,7 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] # Test the oldest supported Python version to make sure compatibility is maintained. python-version: ["3.11"] + transport: ["default", "inprocess"] runs-on: ${{ matrix.os }} defaults: run: @@ -86,6 +87,11 @@ jobs: if: runner.os == 'Windows' run: pwsh.exe -Command "Write-Host 'PowerShell ready'" + - name: Select inprocess transport + if: matrix.transport == 'inprocess' + run: | + echo "COPILOT_SDK_DEFAULT_CONNECTION=inprocess" >> "$GITHUB_ENV" + - name: Run Python SDK tests env: COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} diff --git a/.github/workflows/rust-sdk-tests.yml b/.github/workflows/rust-sdk-tests.yml index f75a5d6a29..8e13e16b23 100644 --- a/.github/workflows/rust-sdk-tests.yml +++ b/.github/workflows/rust-sdk-tests.yml @@ -29,7 +29,7 @@ permissions: jobs: test: - name: "Rust SDK Tests" + name: "Rust SDK Tests (${{ matrix.os }}, default)" if: github.event.repository.fork == false env: POWERSHELL_UPDATECHECK: Off @@ -84,7 +84,7 @@ jobs: # Share the bundled-CLI archive cache with the `bundle` job: build.rs # now downloads in both modes (embed for `bundle`, extract-to-cache # for this `test` job's `--no-default-features` build). - - name: Cache bundled CLI tarball + - name: Cache bundled CLI archives uses: actions/cache@v4 with: path: ./rust/.bundled-cli-cache @@ -98,7 +98,7 @@ jobs: if: runner.os == 'Linux' env: BUNDLED_CLI_CACHE_DIR: ${{ github.workspace }}/rust/.bundled-cli-cache - run: cargo clippy --all-targets --features test-support -- --no-deps -D warnings -D clippy::unwrap_used -D clippy::disallowed_macros -D clippy::await_holding_invalid_type + run: cargo clippy --all-targets --features test-support,bundled-in-process -- --no-deps -D warnings -D clippy::unwrap_used -D clippy::disallowed_macros -D clippy::await_holding_invalid_type - name: cargo doc if: runner.os == 'Linux' @@ -129,6 +129,84 @@ jobs: # The dedicated `bundle` job below exercises the embed pipeline. run: cargo test --no-default-features --features test-support -- --test-threads=4 --nocapture + # Exercises the in-process FFI transport (`Transport::InProcess`, the Rust + # analogue of the .NET `RuntimeConnection.ForInProcess()`), mirroring the + # `inprocess` transport cell in dotnet-sdk-tests.yml. Sets + # COPILOT_SDK_DEFAULT_CONNECTION=inprocess so the client hosts the runtime + # cdylib in-process instead of spawning a stdio child, then runs the whole + # E2E suite over the in-process transport. The suite runs serially in-process + # (the harness forces concurrency to 1) because it mirrors each test's + # environment onto the shared process environment the in-process worker inherits. + # Runs the whole E2E suite over the in-process transport on supported hosts. + test-inprocess: + name: "Rust SDK Tests (${{ matrix.os }}, inprocess)" + if: github.event.repository.fork == false + env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + strategy: + fail-fast: false + matrix: + # TODO: Re-enable Windows after fixing the napi-oop peer shutdown crash. + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + working-directory: ./rust + steps: + - uses: actions/checkout@v6.0.2 + + - uses: ./.github/actions/setup-copilot + id: setup-copilot + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: "1.94.0" + + - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 + with: + workspaces: "rust" + prefix-key: v1-rust-no-bin + cache-bin: false + + - name: Read pinned @github/copilot CLI version + id: cli-version + working-directory: ./nodejs + run: | + version=$(node -p "require('./package-lock.json').packages['node_modules/@github/copilot'].version") + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Pinned CLI version: $version" + + - name: Cache bundled CLI archives + uses: actions/cache@v4 + with: + path: ./rust/.bundled-cli-cache + key: bundled-cli-${{ matrix.os }}-${{ steps.cli-version.outputs.version }} + + - name: Install test harness dependencies + working-directory: ./test/harness + run: npm ci --ignore-scripts + + - name: Warm up PowerShell + if: runner.os == 'Windows' + run: pwsh.exe -Command "Write-Host 'PowerShell ready'" + + - name: Select in-process transport + run: echo "COPILOT_SDK_DEFAULT_CONNECTION=inprocess" >> "$GITHUB_ENV" + + - name: cargo test (in-process transport, full E2E suite) + timeout-minutes: 60 + env: + COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} + COPILOT_CLI_PATH: ${{ steps.setup-copilot.outputs.cli-path }} + BUNDLED_CLI_CACHE_DIR: ${{ github.workspace }}/rust/.bundled-cli-cache + # The harness forces serial execution in-process (both the async semaphore and + # libtest via --test-threads=1) because it mirrors each test's environment onto + # the shared process environment, so RUST_E2E_CONCURRENCY is not set here. + run: cargo test --no-default-features --features test-support,bundled-in-process --test e2e -- --test-threads=1 --nocapture + # Validates the bundled-CLI build path on all three supported # platforms. While the regular `cargo test` job above also exercises # build.rs (bundling is on by default now), this matrix job is the @@ -136,7 +214,7 @@ jobs: # extract / embed pipeline. Catches regressions before they ship to # crates.io and before bundling consumers hit them downstream. bundle: - name: "Rust SDK Bundled CLI Build" + name: "Rust SDK Bundled CLI Build (${{ matrix.os }})" if: github.event.repository.fork == false env: CARGO_TERM_COLOR: always @@ -180,13 +258,15 @@ jobs: # ~130 MB on every CI invocation. Keyed by OS + CLI version so old # archives drop out when the pinned version bumps, keeping the # cache bounded. - - name: Cache bundled CLI tarball + - name: Cache bundled CLI archives uses: actions/cache@v4 with: path: ./rust/.bundled-cli-cache key: bundled-cli-${{ matrix.os }}-${{ steps.cli-version.outputs.version }} - - name: cargo build (bundled-cli is the default feature) + - name: Test bundled CLI build paths env: BUNDLED_CLI_CACHE_DIR: ${{ github.workspace }}/rust/.bundled-cli-cache - run: cargo build + run: | + cargo build + cargo test --features bundled-in-process --lib embedded_archive_contains_only_expected_files diff --git a/.github/workflows/sdk-canary.yml b/.github/workflows/sdk-canary.yml new file mode 100644 index 0000000000..95f8b1c926 --- /dev/null +++ b/.github/workflows/sdk-canary.yml @@ -0,0 +1,391 @@ +name: "SDK Canary Test/Publish" + +# Nightly-style canary pipeline. First installs an explicit version of the +# @github/copilot runtime, builds the Node SDK, and runs the Node e2e suite +# against it to prove runtime <-> SDK compatibility. When that gate passes (and +# mode allows), publishes an SDK canary pinned to the tested runtime to the +# internal Azure Artifacts feed only (never public npm). + +env: + HUSKY: 0 + # Internal org-scoped Azure Artifacts feed — single source of truth so the + # feed name isn't repeated across steps. The SDK canary publishes here and + # (when runtime_source=internal) installs the runtime from here; it must NEVER + # reach public npm (@github/copilot-sdk is a live public package). + FEED_URL: https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/ + # Azure DevOps resource ID used to mint an ADO access token for the feed. + ADO_RESOURCE: 499b84ac-1321-427f-aa17-267ca6975798 + +on: + workflow_dispatch: + inputs: + runtime_version: + description: "Exact @github/copilot version to test (e.g. 1.0.69 or 1.0.70-canary.)" + required: true + type: string + runtime_source: + description: "Where to install the runtime from" + required: true + type: choice + options: + - public + - internal + default: public + mode: + description: "publish (tests must pass), publish-force (publish even if tests fail), or tests-only (run gate, never publish)" + required: false + type: choice + default: publish + options: + - publish + - publish-force + - tests-only + repository_dispatch: + types: [runtime-canary] + +permissions: + contents: read + id-token: write + +# Serialize runs per ref so two overlapping canary runs can't race the feed +# publish. cancel-in-progress: false — never kill an in-flight publish. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + resolve: + name: "Resolve runtime inputs" + if: github.event.repository.fork == false + runs-on: ubuntu-latest + permissions: {} + outputs: + RUNTIME_VERSION: ${{ steps.normalize.outputs.RUNTIME_VERSION }} + RUNTIME_SOURCE: ${{ steps.normalize.outputs.RUNTIME_SOURCE }} + PUBLISH_MODE: ${{ steps.normalize.outputs.PUBLISH_MODE }} + steps: + # Normalize whichever trigger fired into a single (RUNTIME_VERSION, + # RUNTIME_SOURCE, PUBLISH_MODE) triple that every downstream step + # references. workflow_dispatch reads the human-supplied inputs; + # repository_dispatch reads client_payload and forces source=internal + # (a runtime canary only exists on the feed), defaulting mode to publish. + - name: Normalize inputs + id: normalize + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_VERSION: ${{ inputs.runtime_version }} + INPUT_SOURCE: ${{ inputs.runtime_source }} + INPUT_MODE: ${{ inputs.mode }} + PAYLOAD_VERSION: ${{ github.event.client_payload.runtime_version }} + PAYLOAD_SOURCE: ${{ github.event.client_payload.runtime_source }} + PAYLOAD_MODE: ${{ github.event.client_payload.mode }} + run: | + set -euo pipefail + case "$EVENT_NAME" in + workflow_dispatch) + VERSION="$INPUT_VERSION" + SOURCE="$INPUT_SOURCE" + MODE="$INPUT_MODE" + ;; + repository_dispatch) + VERSION="$PAYLOAD_VERSION" + # A runtime canary only ever exists on the internal feed. + SOURCE="${PAYLOAD_SOURCE:-internal}" + MODE="${PAYLOAD_MODE:-publish}" + ;; + *) + echo "::error::Unsupported event '$EVENT_NAME'." + exit 1 + ;; + esac + if [ -z "$VERSION" ]; then echo "::error::Could not determine runtime version."; exit 1; fi + if [ -z "$SOURCE" ]; then SOURCE="public"; fi + case "$SOURCE" in + public|internal) ;; + *) echo "::error::Invalid runtime source '$SOURCE'. Expected one of: public, internal."; exit 1 ;; + esac + if [ -z "$MODE" ]; then MODE="publish"; fi + case "$MODE" in + publish|publish-force|tests-only) ;; + *) echo "::error::Invalid publish mode '$MODE'. Expected one of: publish, publish-force, tests-only."; exit 1 ;; + esac + echo "Resolved RUNTIME_VERSION=$VERSION RUNTIME_SOURCE=$SOURCE PUBLISH_MODE=$MODE" + echo "RUNTIME_VERSION=$VERSION" >> "$GITHUB_OUTPUT" + echo "RUNTIME_SOURCE=$SOURCE" >> "$GITHUB_OUTPUT" + echo "PUBLISH_MODE=$MODE" >> "$GITHUB_OUTPUT" + + - name: Validate runtime version (semver) + env: + RUNTIME_VERSION: ${{ steps.normalize.outputs.RUNTIME_VERSION }} + run: | + if [[ ! "$RUNTIME_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then + echo "::error::Invalid runtime version '$RUNTIME_VERSION'. Expected semver (e.g. 1.0.69 or 1.0.70-canary.abc123)." + exit 1 + fi + + test: + name: "E2E tests (${{ matrix.os }})" + needs: resolve + if: github.event.repository.fork == false + environment: cicd + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + env: + POWERSHELL_UPDATECHECK: Off + RUNTIME_VERSION: ${{ needs.resolve.outputs.RUNTIME_VERSION }} + RUNTIME_SOURCE: ${{ needs.resolve.outputs.RUNTIME_SOURCE }} + defaults: + run: + shell: bash + working-directory: ./nodejs + steps: + - uses: actions/checkout@v6.0.2 + + - uses: actions/setup-node@v6 + with: + cache: "npm" + cache-dependency-path: "./nodejs/package-lock.json" + node-version: 22 + + - name: Install SDK dependencies + run: npm ci --ignore-scripts + + - name: Install test harness dependencies + working-directory: ./test/harness + run: npm ci --ignore-scripts + + - name: Azure Login (OIDC -> id-cpd-ci) + if: env.RUNTIME_SOURCE == 'internal' + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + client-id: "${{ vars.CPD_ID_CLIENT_ID }}" # id-cpd-ci + tenant-id: "${{ vars.CPD_ID_TENANT_ID }}" + allow-no-subscriptions: true + + # Route ONLY @github/* (the runtime + its 8 platform packages) to the + # internal feed via a scoped registry. All other deps (e.g. detect-libc) + # still resolve from public npm. A global --registry would break because + # detect-libc is not on the feed. + - name: Configure canary feed (.npmrc) + if: env.RUNTIME_SOURCE == 'internal' + run: | + set -euo pipefail + TOKEN="$(az account get-access-token --resource "$ADO_RESOURCE" --query accessToken -o tsv)" + echo "::add-mask::$TOKEN" + # Derive the protocol-relative auth scopes from FEED_URL so the feed + # name lives in exactly one place (the workflow-level env). + FEED_AUTH_REGISTRY="${FEED_URL#https:}" + FEED_AUTH_BASE="${FEED_AUTH_REGISTRY%registry/}" + NPMRC="$(printf '%s\n' \ + "@github:registry=${FEED_URL}" \ + "${FEED_AUTH_REGISTRY}:_authToken=${TOKEN}" \ + "${FEED_AUTH_BASE}:_authToken=${TOKEN}")" + printf '%s\n' "$NPMRC" > .npmrc + echo "Wrote scoped @github registry .npmrc to ./nodejs" + + - name: Override runtime version + run: | + set -euo pipefail + echo "Installing @github/copilot@${RUNTIME_VERSION} (source: ${RUNTIME_SOURCE})" + npm install "@github/copilot@${RUNTIME_VERSION}" --save-exact --ignore-scripts + + - name: Verify installed runtime + run: | + set -euo pipefail + node -e ' + const fs = require("fs"); + const expected = process.env.RUNTIME_VERSION; + const pkg = require("./node_modules/@github/copilot/package.json"); + if (pkg.version !== expected) { + console.error(`::error::Installed @github/copilot version ${pkg.version} does not match requested ${expected}`); + process.exit(1); + } + const dir = "./node_modules/@github"; + const entries = fs.readdirSync(dir).filter((d) => d.startsWith("copilot-")); + const plat = process.platform === "win32" ? "win32" : process.platform === "darwin" ? "darwin" : "linux"; + const arch = process.arch; + const match = entries.find((d) => d.includes(plat) && d.includes(arch)); + if (!match) { + console.error(`::error::No @github/copilot platform optional dep for ${plat}-${arch}. Present: ${entries.join(", ") || "(none)"}`); + process.exit(1); + } + const platPkg = require(`${dir}/${match}/package.json`); + if (platPkg.version !== expected) { + console.error(`::error::Platform package @github/${match} version ${platPkg.version} does not match requested ${expected}`); + process.exit(1); + } + console.log(`Verified @github/copilot@${pkg.version} with platform package @github/${match}@${platPkg.version}`); + ' + + - name: Build SDK + run: npm run build + + - name: Warm up PowerShell + if: runner.os == 'Windows' + run: pwsh.exe -Command "Write-Host 'PowerShell ready'" + + - name: Run Node.js SDK e2e tests + env: + COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} + run: npm test + + publish: + name: "Publish SDK canary (internal feed)" + needs: [resolve, test] + # Publish runs only when the gate permits it. Mode governs behavior: + # - tests-only: never publish (skips this job entirely). + # - publish: publish only when the e2e gate is green (the default for both + # the human and automated triggers). + # - publish-force: publish even on a non-green gate — a human-acknowledged + # flake override, audited via the ::warning:: step below and the run actor. + # publish-force only skips the e2e *signal* — the publish job still runs the + # build (so a broken build can't publish) and enforces the feed-only guards. + if: > + !cancelled() && + github.event.repository.fork == false && + needs.resolve.result == 'success' && + needs.resolve.outputs.PUBLISH_MODE != 'tests-only' && + (needs.test.result == 'success' || + needs.resolve.outputs.PUBLISH_MODE == 'publish-force') + environment: cicd + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + env: + RUNTIME_VERSION: ${{ needs.resolve.outputs.RUNTIME_VERSION }} + defaults: + run: + shell: bash + working-directory: ./nodejs + steps: + - name: Warn — publishing despite failed e2e gate (publish-force) + # always() so this audit is never skipped by prior-step status; it fires + # specifically when publish proceeded on a non-green gate via publish-force. + # Runs at the workspace root because it executes before checkout, so the + # job's default working-directory (./nodejs) does not exist yet. + if: always() && needs.test.result != 'success' && needs.resolve.outputs.PUBLISH_MODE == 'publish-force' + working-directory: ${{ github.workspace }} + run: | + echo "::warning title=e2e gate bypassed::Publishing SDK canary despite a non-passing e2e gate (test job result: ${{ needs.test.result }}) via publish-force. Triggered by '${{ github.actor }}' through '${{ github.event_name }}'. The e2e signal was bypassed; build + feed-only guards still apply." + + - uses: actions/checkout@v6.0.2 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + + # Default public registry: installs build deps and the currently pinned + # runtime. Do NOT write any feed .npmrc or scoped @github:registry line + # here, or npm ci would try to fetch the runtime from the upstream-less + # feed and 404. + - name: Install SDK dependencies + run: npm ci --ignore-scripts + + - name: Compute SDK canary version + id: sdkver + env: + RUN_NUMBER: ${{ github.run_number }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + SHORT_SHA="${SHA:0:7}" + # Base the canary on the NEXT patch of the public SDK latest so canaries + # correlate with public releases: they sort ABOVE the current public + # latest and BELOW the eventual real release of that next patch (a + # prerelease of X.Y.Z always sorts below X.Y.Z), so a canary can never + # shadow the real release when it ships. + # Reuse the repo's own version helper (scripts/get-version.js) so this + # stays consistent with publish.yml: `current` returns the latest public + # dist-tag version, read-only from public npm (never the feed), then + # we bump the patch ourselves to keep strict patch+1 semantics. + PUBLIC_LATEST="$(node scripts/get-version.js current || true)" + BASE="${PUBLIC_LATEST%%-*}"; BASE="${BASE%%+*}" + if [[ "$BASE" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + NEXT="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$(( BASH_REMATCH[3] + 1 ))" + else + echo "::error::Could not resolve public SDK latest version (got '$PUBLIC_LATEST'); refusing to publish a canary with an unknown base." + exit 1 + fi + SDK_VERSION="${NEXT}-canary.${RUN_NUMBER}.g${SHORT_SHA}" + if [[ ! "$SDK_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then + echo "::error::Computed SDK canary version '$SDK_VERSION' is not valid semver." + exit 1 + fi + echo "SDK canary version: $SDK_VERSION" + echo "SDK_VERSION=$SDK_VERSION" >> "$GITHUB_OUTPUT" + + - name: Set package version and pin runtime dependency + env: + SDK_VERSION: ${{ steps.sdkver.outputs.SDK_VERSION }} + run: | + set -euo pipefail + npm version "$SDK_VERSION" --no-git-tag-version --allow-same-version + # Exact pin (no caret) so the published SDK canary depends on precisely + # the runtime version that was just tested by the e2e gate. + npm pkg set "dependencies.@github/copilot=$RUNTIME_VERSION" + echo "Pinned @github/copilot to $(npm pkg get dependencies.@github/copilot)" + + - name: Build SDK + run: npm run build + + - name: Azure Login (OIDC -> id-cpd-ci) + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + client-id: "${{ vars.CPD_ID_CLIENT_ID }}" # id-cpd-ci + tenant-id: "${{ vars.CPD_ID_TENANT_ID }}" + allow-no-subscriptions: true + + # Auth-only .npmrc: just the two token lines, NO scoped registry line. + # The publish target is supplied explicitly via publishConfig + --registry. + - name: Configure feed auth (.npmrc) + run: | + set -euo pipefail + TOKEN="$(az account get-access-token --resource "$ADO_RESOURCE" --query accessToken -o tsv)" + echo "::add-mask::$TOKEN" + # Derive the protocol-relative auth scopes from FEED_URL (single source + # of truth). NO scoped @github:registry line here — publish target is + # supplied explicitly via publishConfig + --registry. + FEED_AUTH_REGISTRY="${FEED_URL#https:}" + FEED_AUTH_BASE="${FEED_AUTH_REGISTRY%registry/}" + printf '%s\n' \ + "${FEED_AUTH_REGISTRY}:_authToken=${TOKEN}" \ + "${FEED_AUTH_BASE}:_authToken=${TOKEN}" > .npmrc + echo "Wrote auth-only .npmrc to ./nodejs" + + # Belt and suspenders (2 of 3): pin the publish target in the package too. + - name: Set publishConfig registry + run: npm pkg set "publishConfig.registry=$FEED_URL" + + # Belt and suspenders (3 of 3): fail loudly unless the effective publish + # target is the internal feed. Guards against ever reaching public npm. + - name: Assert publish target is the internal feed + run: | + set -euo pipefail + EFFECTIVE="$(npm pkg get publishConfig.registry | tr -d '"')" + echo "Effective publishConfig.registry: $EFFECTIVE" + if [ "$EFFECTIVE" != "$FEED_URL" ]; then + echo "::error::publishConfig.registry ('$EFFECTIVE') is not the internal feed ('$FEED_URL'). Refusing to publish." + exit 1 + fi + + - name: Publish SDK canary to internal feed + run: npm publish --registry "$FEED_URL" + + - name: Summarize published canary + env: + SDK_VERSION: ${{ steps.sdkver.outputs.SDK_VERSION }} + run: | + set -euo pipefail + { + echo "## SDK canary published" + echo "" + echo "| | |" + echo "| --- | --- |" + echo "| Runtime consumed | \`@github/copilot@${RUNTIME_VERSION}\` |" + echo "| Canary SDK produced | \`@github/copilot-sdk@${SDK_VERSION}\` |" + echo "| Feed | ${FEED_URL} |" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 1485d3a9c4..4aff9be11d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ docs/.validation/ # Visual Studio .vs/ +# Intellij IDEA +.idea/ + # C# Dev Kit *.csproj.lscache diff --git a/CHANGELOG.md b/CHANGELOG.md index acf014c538..e2368ec96d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to the Copilot SDK are documented in this file. This changelog is automatically generated by an AI agent when stable releases are published. See [GitHub Releases](https://github.com/github/copilot-sdk/releases) for the full list. +## [java/v1.0.6](https://github.com/github/copilot-sdk/releases/tag/java/v1.0.6) (2026-07-08) + +### Feature: inline lambda tool definitions + +Developers can now define tools directly at the call site using `ToolDefinition.from(...)` with typed lambda handlers and `Param.of(...)` parameter metadata — no separate annotated class required. Async variants (`fromAsync`) and `ToolInvocation` context injection (`fromWithToolInvocation`) are also available. ([#1895](https://github.com/github/copilot-sdk/pull/1895)) + +```java +ToolDefinition greet = ToolDefinition.from( + "greet", "Greets a user by name", + Param.of(String.class, "name", "The user's name"), + name -> "Hello, " + name + "!"); +``` + +### Other changes + +- bugfix: **[Java]** preserve explicit null map values in JSON-RPC params so user setting clears reach the CLI ([#1906](https://github.com/github/copilot-sdk/pull/1906)) +- feature: **[Java]** add experimental `onGitHubTelemetry` callback on `CopilotClientOptions` for receiving forwarded GitHub telemetry events ([#1835](https://github.com/github/copilot-sdk/pull/1835)) + ## [java/v1.0.5-01](https://github.com/github/copilot-sdk/releases/tag/java/v1.0.5-01) (2026-07-01) ### Feature: new session options — citations, agent exclusions, and credit limits diff --git a/docs/README.md b/docs/README.md index 2533d74c88..9e0d8fddff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,4 +1,4 @@ -# Copilot SDK +# Copilot SDK Welcome to the GitHub Copilot SDK docs. Whether you're building your first Copilot-powered app or deploying to production, you'll find what you need here. diff --git a/docs/auth/README.md b/docs/auth/README.md index b09646d5d7..282bcb0192 100644 --- a/docs/auth/README.md +++ b/docs/auth/README.md @@ -7,6 +7,6 @@ Choose the authentication method that best fits your deployment scenario for the ## Authentication priority -When multiple credentials are configured, an explicit SDK token takes priority, followed by HMAC or direct Copilot API environment authentication, environment variable GitHub tokens, stored Copilot CLI credentials, and then GitHub CLI credentials. See [Authenticate Copilot SDK](authenticate.md#authentication-priority) for details. +When multiple credentials are configured, an explicit SDK token takes priority, followed by direct Copilot API environment authentication, environment variable GitHub tokens, stored Copilot CLI credentials, and then GitHub CLI credentials. See [Authenticate Copilot SDK](authenticate.md#authentication-priority) for details. For multi-user server mode, pass a per-session `gitHubToken` so each session runs with the correct GitHub identity; see [Multi-user and server deployments](../setup/multi-tenancy.md). diff --git a/docs/auth/authenticate.md b/docs/auth/authenticate.md index 0c4d706992..1a01901863 100644 --- a/docs/auth/authenticate.md +++ b/docs/auth/authenticate.md @@ -9,7 +9,7 @@ The GitHub Copilot SDK supports multiple authentication methods to fit different | [GitHub Signed-in User](#github-signed-in-user) | Interactive apps where users sign in with GitHub | Yes | | [OAuth GitHub App](#oauth-github-app) | Apps acting on behalf of users via OAuth | Yes | | [Environment Variables](#environment-variables) | CI/CD, automation, server-to-server | Yes | -| [BYOK (Bring Your Own Key)](./byok.md) | Using your own API keys (Azure AI Foundry, OpenAI, etc.) | No | +| [BYOK (Bring Your Own Key)](./byok.md) | Using your own API keys (Azure AI Foundry, OpenAI, and more) | No | ## GitHub signed-in user @@ -221,7 +221,7 @@ client.start().get(); **Supported token types:** * `gho_` - OAuth user access tokens -* `ghu_` - GitHub App user access tokens +* `ghu_` - GitHub App user access tokens * `github_pat_` - Fine-grained personal access tokens **Not supported:** @@ -275,7 +275,7 @@ await client.start() **When to use:** -* CI/CD pipelines (GitHub Actions, Jenkins, etc.) +* CI/CD pipelines (GitHub Actions, Jenkins, and more) * Automated testing * Server-side applications with service accounts * Development when you don't want to use interactive login @@ -301,7 +301,6 @@ BYOK allows you to use your own API keys from model providers like Azure AI Foun When multiple authentication methods are available, the SDK uses them in this priority order: 1. **Explicit `gitHubToken`** - Token passed directly to the SDK client or session configuration -1. **HMAC key** - `CAPI_HMAC_KEY` or `COPILOT_HMAC_KEY` environment variables 1. **Direct API token** - `GITHUB_COPILOT_API_TOKEN` with `COPILOT_API_URL` 1. **Environment variable tokens** - `COPILOT_GITHUB_TOKEN` → `GH_TOKEN` → `GITHUB_TOKEN` 1. **Stored OAuth credentials** - From previous `copilot` CLI login diff --git a/docs/auth/byok.md b/docs/auth/byok.md index 1bf3646640..01d954a40b 100644 --- a/docs/auth/byok.md +++ b/docs/auth/byok.md @@ -529,7 +529,7 @@ import { CopilotClient } from "@github/copilot-sdk"; const client = new CopilotClient(); const session = await client.createSession({ - model: "gpt-4.1", + model: "gpt-5.4", provider: { type: "azure", baseUrl: "https://my-resource.openai.azure.com", @@ -560,7 +560,7 @@ import { CopilotClient } from "@github/copilot-sdk"; const client = new CopilotClient(); const session = await client.createSession({ - model: "gpt-4.1", + model: "gpt-5.4", provider: { type: "openai", baseUrl: "https://your-resource.openai.azure.com/openai/v1/", diff --git a/docs/features/cloud-sessions.md b/docs/features/cloud-sessions.md index 0d670b996f..863f9456b9 100644 --- a/docs/features/cloud-sessions.md +++ b/docs/features/cloud-sessions.md @@ -235,7 +235,7 @@ Capture the URL by subscribing to `session.info` and filtering by `infoType: "re session.on("session.info", (event) => { if (event.data?.infoType === "remote" && event.data.url) { console.log("Open from web or mobile:", event.data.url); - // e.g. surface in your UI as a shareable link or QR code. + // For example, surface in your UI as a shareable link or QR code. } }); ``` diff --git a/docs/features/custom-agents.md b/docs/features/custom-agents.md index fb2f81fd1c..e43aca1b80 100644 --- a/docs/features/custom-agents.md +++ b/docs/features/custom-agents.md @@ -37,7 +37,7 @@ const client = new CopilotClient(); await client.start(); const session = await client.createSession({ - model: "gpt-4.1", + model: "gpt-5.4", customAgents: [ { name: "researcher", @@ -71,7 +71,7 @@ await client.start() session = await client.create_session( on_permission_request=lambda req, inv: PermissionDecisionApproveOnce(), - model="gpt-4.1", + model="gpt-5.4", custom_agents=[ { "name": "researcher", @@ -112,7 +112,7 @@ func main() { client.Start(ctx) session, _ := client.CreateSession(ctx, &copilot.SessionConfig{ - Model: "gpt-4.1", + Model: "gpt-5.4", CustomAgents: []copilot.CustomAgentConfig{ { Name: "researcher", @@ -144,7 +144,7 @@ client := copilot.NewClient(nil) client.Start(ctx) session, _ := client.CreateSession(ctx, &copilot.SessionConfig{ - Model: "gpt-4.1", + Model: "gpt-5.4", CustomAgents: []copilot.CustomAgentConfig{ { Name: "researcher", @@ -179,7 +179,7 @@ using GitHub.Copilot.Rpc; await using var client = new CopilotClient(); await using var session = await client.CreateSessionAsync(new SessionConfig { - Model = "gpt-4.1", + Model = "gpt-5.4", CustomAgents = new List { new() @@ -219,7 +219,7 @@ try (var client = new CopilotClient()) { var session = client.createSession( new SessionConfig() - .setModel("gpt-4.1") + .setModel("gpt-5.4") .setCustomAgents(List.of( new CustomAgentConfig() .setName("researcher") @@ -529,7 +529,7 @@ func main() { client.Start(ctx) session, _ := client.CreateSession(ctx, &copilot.SessionConfig{ - Model: "gpt-4.1", + Model: "gpt-5.4", OnPermissionRequest: func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) (rpc.PermissionDecision, error) { return &rpc.PermissionDecisionApproveOnce{}, nil }, diff --git a/docs/features/fleet-mode.md b/docs/features/fleet-mode.md index a830f4931d..cbb737b78d 100644 --- a/docs/features/fleet-mode.md +++ b/docs/features/fleet-mode.md @@ -8,18 +8,18 @@ Fleet mode is useful when the work can be decomposed before execution and each u Good fits include: -- Multi-file refactors where each worker owns a file, package, or language SDK. -- Batch reviews where each worker checks a separate diff, module, or alert group. -- Parallel research across independent repositories, services, or feature areas. -- Documentation refreshes where each worker owns a page or topic. -- Migration tasks where each worker can validate its own slice and report back. +* Multi-file refactors where each worker owns a file, package, or language SDK. +* Batch reviews where each worker checks a separate diff, module, or alert group. +* Parallel research across independent repositories, services, or feature areas. +* Documentation refreshes where each worker owns a page or topic. +* Migration tasks where each worker can validate its own slice and report back. Avoid fleet mode for: -- Sequential tasks where step 2 needs the concrete output from step 1. -- Tightly coupled edits where workers would contend for the same files. -- Small tasks that one synchronous sub-agent or the parent agent can finish quickly. -- Tasks that require continuous shared reasoning rather than clear ownership. +* Sequential tasks where step 2 needs the concrete output from step 1. +* Tightly coupled edits where workers would contend for the same files. +* Small tasks that one synchronous sub-agent or the parent agent can finish quickly. +* Tasks that require continuous shared reasoning rather than clear ownership. Fleet mode works best when the parent session can create clear units of work, assign one owner per unit, and define what each worker must return. @@ -321,29 +321,29 @@ Keep plugin-provided sub-agent types narrow and descriptive so the orchestrator ## Best practices -- Decompose the work into independent units before starting fleet mode. -- Minimize dependencies between todos; dependencies reduce parallelism. -- Give each todo a durable ID, a clear title, and a complete description. -- Make each sub-agent own exactly one todo at a time. -- Use background sub-agents for truly parallel work. -- Use synchronous sub-agent calls for serialized steps or validation gates. -- Provide each sub-agent with complete context; sub-agents are stateless across calls. -- Include file paths, commands, expected outputs, and constraints in each worker prompt. -- Do not dispatch a single background sub-agent; prefer a synchronous call or batch multiple workers in parallel. -- Avoid assigning overlapping files to different workers unless the parent agent will reconcile conflicts explicitly. -- Require every worker to report what it changed, how it validated the change, and what remains blocked. -- Have the parent agent verify the combined result after workers finish. +* Decompose the work into independent units before starting fleet mode. +* Minimize dependencies between todos; dependencies reduce parallelism. +* Give each todo a durable ID, a clear title, and a complete description. +* Make each sub-agent own exactly one todo at a time. +* Use background sub-agents for truly parallel work. +* Use synchronous sub-agent calls for serialized steps or validation gates. +* Provide each sub-agent with complete context; sub-agents are stateless across calls. +* Include file paths, commands, expected outputs, and constraints in each worker prompt. +* Do not dispatch a single background sub-agent; prefer a synchronous call or batch multiple workers in parallel. +* Avoid assigning overlapping files to different workers unless the parent agent will reconcile conflicts explicitly. +* Require every worker to report what it changed, how it validated the change, and what remains blocked. +* Have the parent agent verify the combined result after workers finish. ## Limitations and open questions -- Fleet mode is exposed through generated session RPC bindings and is marked experimental in several SDKs. -- The SQL todos pattern is the canonical coordination model in the runtime guidance, but whether it is a stable extensibility contract for SDK consumers is still an open question. -- `subagentStart` and `subagentStop` are runtime hook names; this branch exposes sub-agent lifecycle to SDK consumers through the generic session event stream, not dedicated hook callbacks. -- Plugin sub-agent registration is configured at the runtime layer through `--plugin-dir`; no SDK-level plugin registration helper was verified on this branch. -- Java native typed bindings for `session.fleet.start` were not found in the Java SDK source on this branch. -- Fleet mode does not remove the need for parent-agent review. Parallel workers can produce inconsistent assumptions that the orchestrator must reconcile. +* Fleet mode is exposed through generated session RPC bindings and is marked experimental in several SDKs. +* The SQL todos pattern is the canonical coordination model in the runtime guidance, but whether it is a stable extensibility contract for SDK consumers is still an open question. +* `subagentStart` and `subagentStop` are runtime hook names; this branch exposes sub-agent lifecycle to SDK consumers through the generic session event stream, not dedicated hook callbacks. +* Plugin sub-agent registration is configured at the runtime layer through `--plugin-dir`; no SDK-level plugin registration helper was verified on this branch. +* Java native typed bindings for `session.fleet.start` were not found in the Java SDK source on this branch. +* Fleet mode does not remove the need for parent-agent review. Parallel workers can produce inconsistent assumptions that the orchestrator must reconcile. ## See also -- [Custom agents and sub-agent orchestration](custom-agents.md) -- [Hooks](hooks.md) +* [Custom agents and sub-agent orchestration](custom-agents.md) +* [Hooks](hooks.md) diff --git a/docs/features/hooks.md b/docs/features/hooks.md index 6af5232a68..feee55546d 100644 --- a/docs/features/hooks.md +++ b/docs/features/hooks.md @@ -1051,16 +1051,16 @@ const session = await client.createSession({ For full type definitions, input/output field tables, and additional examples for every hook, see the API reference: -- [Hooks Overview](../hooks/hooks-overview.md) -- [Pre-Tool Use](../hooks/pre-tool-use.md) -- [Post-Tool Use](../hooks/post-tool-use.md) -- [User Prompt Submitted](../hooks/user-prompt-submitted.md) -- [Session Lifecycle](../hooks/session-lifecycle.md) -- [Error Handling](../hooks/error-handling.md) +* [Hooks Overview](../hooks/hooks-overview.md) +* [Pre-Tool Use](../hooks/pre-tool-use.md) +* [Post-Tool Use](../hooks/post-tool-use.md) +* [User Prompt Submitted](../hooks/user-prompt-submitted.md) +* [Session Lifecycle](../hooks/session-lifecycle.md) +* [Error Handling](../hooks/error-handling.md) ## See also -- [Getting Started](../getting-started.md) -- [Custom Agents & Sub-Agent Orchestration](./custom-agents.md) -- [Streaming Session Events](./streaming-events.md) -- [Debugging Guide](../troubleshooting/debugging.md) \ No newline at end of file +* [Getting Started](../getting-started.md) +* [Custom Agents & Sub-Agent Orchestration](./custom-agents.md) +* [Streaming Session Events](./streaming-events.md) +* [Debugging Guide](../troubleshooting/debugging.md) \ No newline at end of file diff --git a/docs/features/image-input.md b/docs/features/image-input.md index c63a80c11d..321e5d2fc6 100644 --- a/docs/features/image-input.md +++ b/docs/features/image-input.md @@ -47,7 +47,7 @@ const client = new CopilotClient(); await client.start(); const session = await client.createSession({ - model: "gpt-4.1", + model: "gpt-5.4", onPermissionRequest: async () => ({ kind: "approve-once" }), }); @@ -75,7 +75,7 @@ await client.start() session = await client.create_session( on_permission_request=lambda req, inv: PermissionDecisionApproveOnce(), - model="gpt-4.1", + model="gpt-5.4", ) await session.send( @@ -110,7 +110,7 @@ func main() { client.Start(ctx) session, _ := client.CreateSession(ctx, &copilot.SessionConfig{ - Model: "gpt-4.1", + Model: "gpt-5.4", OnPermissionRequest: func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) (rpc.PermissionDecision, error) { return &rpc.PermissionDecisionApproveOnce{}, nil }, @@ -136,7 +136,7 @@ client := copilot.NewClient(nil) client.Start(ctx) session, _ := client.CreateSession(ctx, &copilot.SessionConfig{ - Model: "gpt-4.1", + Model: "gpt-5.4", OnPermissionRequest: func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) (rpc.PermissionDecision, error) { return &rpc.PermissionDecisionApproveOnce{}, nil }, @@ -171,7 +171,7 @@ public static class ImageInputExample await using var client = new CopilotClient(); await using var session = await client.CreateSessionAsync(new SessionConfig { - Model = "gpt-4.1", + Model = "gpt-5.4", OnPermissionRequest = (req, inv) => Task.FromResult(PermissionDecision.ApproveOnce()), }); @@ -200,7 +200,7 @@ using GitHub.Copilot.Rpc; await using var client = new CopilotClient(); await using var session = await client.CreateSessionAsync(new SessionConfig { - Model = "gpt-4.1", + Model = "gpt-5.4", OnPermissionRequest = (req, inv) => Task.FromResult(PermissionDecision.ApproveOnce()), }); @@ -234,7 +234,7 @@ try (var client = new CopilotClient()) { var session = client.createSession( new SessionConfig() - .setModel("gpt-4.1") + .setModel("gpt-5.4") .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) ).get(); @@ -263,7 +263,7 @@ const client = new CopilotClient(); await client.start(); const session = await client.createSession({ - model: "gpt-4.1", + model: "gpt-5.4", onPermissionRequest: async () => ({ kind: "approve-once" }), }); @@ -294,7 +294,7 @@ await client.start() session = await client.create_session( on_permission_request=lambda req, inv: PermissionDecisionApproveOnce(), - model="gpt-4.1", + model="gpt-5.4", ) base64_image_data = "..." # your base64-encoded image @@ -332,7 +332,7 @@ func main() { client.Start(ctx) session, _ := client.CreateSession(ctx, &copilot.SessionConfig{ - Model: "gpt-4.1", + Model: "gpt-5.4", OnPermissionRequest: func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) (rpc.PermissionDecision, error) { return &rpc.PermissionDecisionApproveOnce{}, nil }, @@ -387,7 +387,7 @@ public static class BlobAttachmentExample await using var client = new CopilotClient(); await using var session = await client.CreateSessionAsync(new SessionConfig { - Model = "gpt-4.1", + Model = "gpt-5.4", OnPermissionRequest = (req, inv) => Task.FromResult(PermissionDecision.ApproveOnce()), }); @@ -442,7 +442,7 @@ try (var client = new CopilotClient()) { var session = client.createSession( new SessionConfig() - .setModel("gpt-4.1") + .setModel("gpt-5.4") .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) ).get(); diff --git a/docs/features/skills.md b/docs/features/skills.md index 6db955e743..5b8388162a 100644 --- a/docs/features/skills.md +++ b/docs/features/skills.md @@ -24,7 +24,7 @@ import { CopilotClient } from "@github/copilot-sdk"; const client = new CopilotClient(); const session = await client.createSession({ - model: "gpt-4.1", + model: "gpt-5.4", skillDirectories: [ "./skills/code-review", "./skills/documentation", @@ -50,7 +50,7 @@ async def main(): session = await client.create_session( on_permission_request=lambda req, inv: PermissionDecisionApproveOnce(), - model="gpt-4.1", + model="gpt-5.4", skill_directories=[ "./skills/code-review", "./skills/documentation", @@ -87,7 +87,7 @@ func main() { defer client.Stop() session, err := client.CreateSession(ctx, &copilot.SessionConfig{ - Model: "gpt-4.1", + Model: "gpt-5.4", SkillDirectories: []string{ "./skills/code-review", "./skills/documentation", @@ -122,7 +122,7 @@ using GitHub.Copilot.Rpc; await using var client = new CopilotClient(); await using var session = await client.CreateSessionAsync(new SessionConfig { - Model = "gpt-4.1", + Model = "gpt-5.4", SkillDirectories = new List { "./skills/code-review", @@ -154,7 +154,7 @@ try (var client = new CopilotClient()) { var session = client.createSession( new SessionConfig() - .setModel("gpt-4.1") + .setModel("gpt-5.4") .setSkillDirectories(List.of( "./skills/code-review", "./skills/documentation" diff --git a/docs/features/steering-and-queueing.md b/docs/features/steering-and-queueing.md index 7dbdc17b7c..7bfffc433d 100644 --- a/docs/features/steering-and-queueing.md +++ b/docs/features/steering-and-queueing.md @@ -47,7 +47,7 @@ const client = new CopilotClient(); await client.start(); const session = await client.createSession({ - model: "gpt-4.1", + model: "gpt-5.4", onPermissionRequest: async () => ({ kind: "approve-once" }), }); @@ -77,7 +77,7 @@ async def main(): session = await client.create_session( on_permission_request=lambda req, inv: PermissionDecisionApproveOnce(), - model="gpt-4.1", + model="gpt-5.4", ) # Start a long-running task @@ -118,7 +118,7 @@ func main() { defer client.Stop() session, err := client.CreateSession(ctx, &copilot.SessionConfig{ - Model: "gpt-4.1", + Model: "gpt-5.4", OnPermissionRequest: func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) (rpc.PermissionDecision, error) { return &rpc.PermissionDecisionApproveOnce{}, nil }, @@ -158,7 +158,7 @@ using GitHub.Copilot.Rpc; await using var client = new CopilotClient(); await using var session = await client.CreateSessionAsync(new SessionConfig { - Model = "gpt-4.1", + Model = "gpt-5.4", OnPermissionRequest = (req, inv) => Task.FromResult(PermissionDecision.ApproveOnce()), }); @@ -191,7 +191,7 @@ try (var client = new CopilotClient()) { var session = client.createSession( new SessionConfig() - .setModel("gpt-4.1") + .setModel("gpt-5.4") .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) ).get(); @@ -234,7 +234,7 @@ const client = new CopilotClient(); await client.start(); const session = await client.createSession({ - model: "gpt-4.1", + model: "gpt-5.4", onPermissionRequest: async () => ({ kind: "approve-once" }), }); @@ -269,7 +269,7 @@ async def main(): session = await client.create_session( on_permission_request=lambda req, inv: PermissionDecisionApproveOnce(), - model="gpt-4.1", + model="gpt-5.4", ) # Send an initial task @@ -311,7 +311,7 @@ func main() { client.Start(ctx) session, _ := client.CreateSession(ctx, &copilot.SessionConfig{ - Model: "gpt-4.1", + Model: "gpt-5.4", OnPermissionRequest: func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) (rpc.PermissionDecision, error) { return &rpc.PermissionDecisionApproveOnce{}, nil }, @@ -371,7 +371,7 @@ public static class QueueingExample await using var client = new CopilotClient(); await using var session = await client.CreateSessionAsync(new SessionConfig { - Model = "gpt-4.1", + Model = "gpt-5.4", OnPermissionRequest = (req, inv) => Task.FromResult(PermissionDecision.ApproveOnce()), }); @@ -434,7 +434,7 @@ try (var client = new CopilotClient()) { var session = client.createSession( new SessionConfig() - .setModel("gpt-4.1") + .setModel("gpt-5.4") .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) ).get(); @@ -475,7 +475,7 @@ You can use both patterns together in a single session. Steering affects the cur ```typescript const session = await client.createSession({ - model: "gpt-4.1", + model: "gpt-5.4", onPermissionRequest: async () => ({ kind: "approve-once" }), }); @@ -503,7 +503,7 @@ await session.send({ ```python session = await client.create_session( on_permission_request=lambda req, inv: PermissionDecisionApproveOnce(), - model="gpt-4.1", + model="gpt-5.4", ) # Start a task diff --git a/docs/features/streaming-events.md b/docs/features/streaming-events.md index 3703f871d3..0c2dafdefb 100644 --- a/docs/features/streaming-events.md +++ b/docs/features/streaming-events.md @@ -130,7 +130,7 @@ func main() { client := copilot.NewClient(nil) session, _ := client.CreateSession(ctx, &copilot.SessionConfig{ - Model: "gpt-4.1", + Model: "gpt-5.4", Streaming: copilot.Bool(true), OnPermissionRequest: func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) (rpc.PermissionDecision, error) { return &rpc.PermissionDecisionApproveOnce{}, nil @@ -306,7 +306,7 @@ Ephemeral. Token usage and cost information for an individual API call. | Data Field | Type | Required | Description | |------------|------|----------|-------------| -| `model` | `string` | ✅ | Model identifier (e.g., `"gpt-4.1"`) | +| `model` | `string` | ✅ | Model identifier (e.g., `"gpt-5.4"`) | | `inputTokens` | `number` | | Input tokens consumed | | `outputTokens` | `number` | | Output tokens produced | | `cacheReadTokens` | `number` | | Tokens read from prompt cache | diff --git a/docs/getting-started.md b/docs/getting-started.md index c258c50e9c..b14fb73e52 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -150,7 +150,7 @@ Create `index.ts`: import { CopilotClient } from "@github/copilot-sdk"; const client = new CopilotClient(); -const session = await client.createSession({ model: "gpt-4.1" }); +const session = await client.createSession({ model: "auto" }); const response = await session.sendAndWait({ prompt: "What is 2 + 2?" }); console.log(response?.data.content); @@ -181,7 +181,7 @@ async def main(): client = CopilotClient() await client.start() - session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="gpt-4.1") + session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="auto") response = await session.send_and_wait("What is 2 + 2?") print(response.data.content) @@ -223,7 +223,7 @@ func main() { } defer client.Stop() - session, err := client.CreateSession(ctx, &copilot.SessionConfig{Model: "gpt-4.1"}) + session, err := client.CreateSession(ctx, &copilot.SessionConfig{Model: "auto"}) if err != nil { log.Fatal(err) } @@ -304,7 +304,7 @@ using GitHub.Copilot; await using var client = new CopilotClient(); await using var session = await client.CreateSessionAsync(new SessionConfig { - Model = "gpt-4.1", + Model = "auto", OnPermissionRequest = PermissionHandler.ApproveAll }); @@ -337,7 +337,7 @@ public class HelloCopilot { var session = client.createSession( new SessionConfig() - .setModel("gpt-4.1") + .setModel("auto") .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) ).get(); @@ -383,7 +383,7 @@ import { CopilotClient } from "@github/copilot-sdk"; const client = new CopilotClient(); const session = await client.createSession({ - model: "gpt-4.1", + model: "auto", streaming: true, }); @@ -419,7 +419,7 @@ async def main(): client = CopilotClient() await client.start() - session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="gpt-4.1", streaming=True) + session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="auto", streaming=True) # Listen for response chunks def handle_event(event): @@ -466,7 +466,7 @@ func main() { defer client.Stop() session, err := client.CreateSession(ctx, &copilot.SessionConfig{ - Model: "gpt-4.1", + Model: "auto", Streaming: copilot.Bool(true), }) if err != nil { @@ -562,7 +562,7 @@ using GitHub.Copilot; await using var client = new CopilotClient(); await using var session = await client.CreateSessionAsync(new SessionConfig { - Model = "gpt-4.1", + Model = "auto", OnPermissionRequest = PermissionHandler.ApproveAll, Streaming = true, }); @@ -602,7 +602,7 @@ public class HelloCopilot { var session = client.createSession( new SessionConfig() - .setModel("gpt-4.1") + .setModel("auto") .setStreaming(true) .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) ).get(); @@ -912,7 +912,7 @@ const getWeather = defineTool("get_weather", { const client = new CopilotClient(); const session = await client.createSession({ - model: "gpt-4.1", + model: "auto", streaming: true, tools: [getWeather], }); @@ -968,7 +968,7 @@ async def main(): client = CopilotClient() await client.start() - session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="gpt-4.1", streaming=True, tools=[get_weather]) + session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="auto", streaming=True, tools=[get_weather]) def handle_event(event): if event.type == SessionEventType.ASSISTANT_MESSAGE_DELTA: @@ -1045,7 +1045,7 @@ func main() { defer client.Stop() session, err := client.CreateSession(ctx, &copilot.SessionConfig{ - Model: "gpt-4.1", + Model: "auto", Streaming: copilot.Bool(true), Tools: []copilot.Tool{getWeather}, }) @@ -1185,7 +1185,7 @@ var getWeather = CopilotTool.DefineTool( await using var session = await client.CreateSessionAsync(new SessionConfig { - Model = "gpt-4.1", + Model = "auto", OnPermissionRequest = PermissionHandler.ApproveAll, Streaming = true, Tools = [getWeather], @@ -1259,7 +1259,7 @@ public class HelloCopilot { var session = client.createSession( new SessionConfig() - .setModel("gpt-4.1") + .setModel("auto") .setStreaming(true) .setTools(List.of(getWeather)) .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) @@ -1316,7 +1316,7 @@ const getWeather = defineTool("get_weather", { const client = new CopilotClient(); const session = await client.createSession({ - model: "gpt-4.1", + model: "auto", streaming: true, tools: [getWeather], }); @@ -1389,7 +1389,7 @@ async def main(): client = CopilotClient() await client.start() - session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="gpt-4.1", streaming=True, tools=[get_weather]) + session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="auto", streaming=True, tools=[get_weather]) def handle_event(event): if event.type == SessionEventType.ASSISTANT_MESSAGE_DELTA: @@ -1482,7 +1482,7 @@ func main() { defer client.Stop() session, err := client.CreateSession(ctx, &copilot.SessionConfig{ - Model: "gpt-4.1", + Model: "auto", Streaming: copilot.Bool(true), Tools: []copilot.Tool{getWeather}, }) @@ -1671,7 +1671,7 @@ var getWeather = CopilotTool.DefineTool( await using var client = new CopilotClient(); await using var session = await client.CreateSessionAsync(new SessionConfig { - Model = "gpt-4.1", + Model = "auto", OnPermissionRequest = PermissionHandler.ApproveAll, Streaming = true, Tools = [getWeather] @@ -1765,7 +1765,7 @@ public class WeatherAssistant { var session = client.createSession( new SessionConfig() - .setModel("gpt-4.1") + .setModel("auto") .setStreaming(true) .setOnPermissionRequest(request -> CompletableFuture.completedFuture(PermissionDecision.allow()) diff --git a/docs/hooks/post-tool-use.md b/docs/hooks/post-tool-use.md index 45964b0344..b7ef3af1c4 100644 --- a/docs/hooks/post-tool-use.md +++ b/docs/hooks/post-tool-use.md @@ -2,10 +2,10 @@ The `onPostToolUse` hook is called **after** a tool executes **successfully**. Use it to: -- Transform or filter tool results -- Log tool execution for auditing -- Add context based on results -- Suppress results from the conversation +* Transform or filter tool results +* Log tool execution for auditing +* Add context based on results +* Suppress results from the conversation > **Failure variant** — `onPostToolUse` only fires for successful tool executions. To observe **failed** tool calls, register `onPostToolUseFailure` (`on_post_tool_use_failure` in Python, `OnPostToolUseFailure` in Go/.NET, `on_post_tool_use_failure` in Rust). The handler receives `{ sessionId, toolName, toolArgs, error, timestamp, workingDirectory }` — the `error` field is a string extracted from the tool's failure result — and may return `{ additionalContext: string }` to inject extra guidance for the model (e.g. retry hints). See the [hooks overview](./hooks-overview.md) for the full list. > @@ -507,6 +507,6 @@ const session = await client.createSession({ ## See also -- [Hooks Overview](./README.md) -- [Pre-Tool Use Hook](./pre-tool-use.md) -- [Error Handling Hook](./error-handling.md) \ No newline at end of file +* [Hooks Overview](./README.md) +* [Pre-Tool Use Hook](./pre-tool-use.md) +* [Error Handling Hook](./error-handling.md) \ No newline at end of file diff --git a/docs/integrations/microsoft-agent-framework.md b/docs/integrations/microsoft-agent-framework.md index 3d6d990860..663a20a799 100644 --- a/docs/integrations/microsoft-agent-framework.md +++ b/docs/integrations/microsoft-agent-framework.md @@ -123,7 +123,7 @@ var client = new CopilotClient(); client.start().get(); var session = client.createSession(new SessionConfig() - .setModel("gpt-4.1") + .setModel("gpt-5.4") .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) ).get(); @@ -217,7 +217,7 @@ const getWeather = DefineTool({ const client = new CopilotClient(); const session = await client.createSession({ - model: "gpt-4.1", + model: "gpt-5.4", tools: [getWeather], onPermissionRequest: async () => ({ kind: "approve-once" }), }); @@ -255,7 +255,7 @@ try (var client = new CopilotClient()) { client.start().get(); var session = client.createSession(new SessionConfig() - .setModel("gpt-4.1") + .setModel("gpt-5.4") .setTools(List.of(getWeather)) .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) ).get(); @@ -296,7 +296,7 @@ AIAgent reviewer = copilotClient.AsAIAgent(new AIAgentOptions // Azure OpenAI agent for generating documentation AIAgent documentor = AIAgent.FromOpenAI(new OpenAIAgentOptions { - Model = "gpt-4.1", + Model = "gpt-5.4", Instructions = "You write clear, concise documentation for code changes.", }); @@ -330,7 +330,7 @@ async def main(): # OpenAI agent for documentation documentor = OpenAIAgent( - model="gpt-4.1", + model="gpt-5.4", instructions="You write clear, concise documentation for code changes.", ) @@ -360,7 +360,7 @@ client.start().get(); // Step 1: Code review session var reviewer = client.createSession(new SessionConfig() - .setModel("gpt-4.1") + .setModel("gpt-5.4") .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) ).get(); @@ -370,7 +370,7 @@ var review = reviewer.sendAndWait(new MessageOptions() // Step 2: Documentation session using review output var documentor = client.createSession(new SessionConfig() - .setModel("gpt-4.1") + .setModel("gpt-5.4") .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) ).get(); @@ -434,12 +434,12 @@ var client = new CopilotClient(); client.start().get(); var securitySession = client.createSession(new SessionConfig() - .setModel("gpt-4.1") + .setModel("gpt-5.4") .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) ).get(); var perfSession = client.createSession(new SessionConfig() - .setModel("gpt-4.1") + .setModel("gpt-5.4") .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) ).get(); @@ -518,7 +518,7 @@ import { CopilotClient } from "@github/copilot-sdk"; const client = new CopilotClient(); const session = await client.createSession({ - model: "gpt-4.1", + model: "gpt-5.4", streaming: true, onPermissionRequest: async () => ({ kind: "approve-once" }), }); @@ -544,7 +544,7 @@ var client = new CopilotClient(); client.start().get(); var session = client.createSession(new SessionConfig() - .setModel("gpt-4.1") + .setModel("gpt-5.4") .setStreaming(true) .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) ).get(); @@ -598,7 +598,7 @@ import { CopilotClient } from "@github/copilot-sdk"; const client = new CopilotClient(); const session = await client.createSession({ - model: "gpt-4.1", + model: "gpt-5.4", onPermissionRequest: async () => ({ kind: "approve-once" }), }); const response = await session.sendAndWait({ prompt: "Explain this code" }); diff --git a/docs/setup/azure-managed-identity.md b/docs/setup/azure-managed-identity.md index 51ca0dc0be..8afac6e1d3 100644 --- a/docs/setup/azure-managed-identity.md +++ b/docs/setup/azure-managed-identity.md @@ -240,7 +240,7 @@ class ManagedIdentityCopilotAgent: | Variable | Description | Example | |----------|-------------|---------| -| `AZURE_TOKEN_CREDENTIALS` | When running in **Azure**, set it to `ManagedIdentityCredential`. When running **locally**, set it to either `dev` or a developer tool credential name, such as `AzureCliCredential`. | | +| `AZURE_TOKEN_CREDENTIALS` | When running in **Azure**, set it to `ManagedIdentityCredential`. When running **locally**, set it to either `dev` or a developer tool credential name, such as `AzureCliCredential`. | `ManagedIdentityCredential` | | `FOUNDRY_RESOURCE_URL` | Your Microsoft Foundry resource URL | `https://.openai.azure.com` | No API key environment variable is needed—authentication is handled by `DefaultAzureCredential`, which automatically supports: diff --git a/docs/setup/backend-services.md b/docs/setup/backend-services.md index ca4a310b6f..7f1da36e82 100644 --- a/docs/setup/backend-services.md +++ b/docs/setup/backend-services.md @@ -134,7 +134,7 @@ const client = new CopilotClient({ const session = await client.createSession({ sessionId: `user-${userId}-${Date.now()}`, - model: "gpt-4.1", + model: "gpt-5.4", availableTools: ["custom:*"], gitHubToken: user.githubToken, }); @@ -157,7 +157,7 @@ client = CopilotClient( ) await client.start() -session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="gpt-4.1", session_id=f"user-{user_id}-{int(time.time())}") +session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="gpt-5.4", session_id=f"user-{user_id}-{int(time.time())}") response = await session.send_and_wait(message) ``` @@ -191,7 +191,7 @@ func main() { session, _ := client.CreateSession(ctx, &copilot.SessionConfig{ SessionID: fmt.Sprintf("user-%s-%d", userID, time.Now().Unix()), - Model: "gpt-4.1", + Model: "gpt-5.4", }) response, _ := session.SendAndWait(ctx, copilot.MessageOptions{Prompt: message}) @@ -209,7 +209,7 @@ defer client.Stop() session, _ := client.CreateSession(ctx, &copilot.SessionConfig{ SessionID: fmt.Sprintf("user-%s-%d", userID, time.Now().Unix()), - Model: "gpt-4.1", + Model: "gpt-5.4", }) response, _ := session.SendAndWait(ctx, copilot.MessageOptions{Prompt: message}) @@ -235,7 +235,7 @@ var client = new CopilotClient(new CopilotClientOptions await using var session = await client.CreateSessionAsync(new SessionConfig { SessionId = $"user-{userId}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}", - Model = "gpt-4.1", + Model = "gpt-5.4", }); var response = await session.SendAndWaitAsync( @@ -252,7 +252,7 @@ var client = new CopilotClient(new CopilotClientOptions await using var session = await client.CreateSessionAsync(new SessionConfig { SessionId = $"user-{userId}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}", - Model = "gpt-4.1", + Model = "gpt-5.4", }); var response = await session.SendAndWaitAsync( @@ -280,7 +280,7 @@ try { var session = client.createSession(new SessionConfig() .setSessionId(String.format("user-%s-%d", userId, System.currentTimeMillis() / 1000)) - .setModel("gpt-4.1") + .setModel("gpt-5.4") .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) ).get(); @@ -332,7 +332,7 @@ const client = new CopilotClient({ app.post("/chat", authMiddleware, async (req, res) => { const session = await client.createSession({ sessionId: `user-${req.user.id}-chat`, - model: "gpt-4.1", + model: "gpt-5.4", availableTools: ["custom:*"], gitHubToken: req.user.githubToken, }); @@ -355,7 +355,7 @@ const client = new CopilotClient({ }); const session = await client.createSession({ - model: "gpt-4.1", + model: "gpt-5.4", provider: { type: "openai", baseUrl: "https://api.openai.com/v1", @@ -407,7 +407,7 @@ app.post("/api/chat", async (req, res) => { } catch { session = await client.createSession({ sessionId, - model: "gpt-4.1", + model: "gpt-5.4", availableTools: ["custom:*"], gitHubToken: req.user.githubToken, }); @@ -436,7 +436,7 @@ const client = new CopilotClient({ async function processJob(job: Job) { const session = await client.createSession({ sessionId: `job-${job.id}`, - model: "gpt-4.1", + model: "gpt-5.4", }); const response = await session.sendAndWait({ diff --git a/docs/setup/bundled-cli.md b/docs/setup/bundled-cli.md index 26eb62c3f4..7c7d2fbbc4 100644 --- a/docs/setup/bundled-cli.md +++ b/docs/setup/bundled-cli.md @@ -47,7 +47,7 @@ import { CopilotClient } from "@github/copilot-sdk"; const client = new CopilotClient(); -const session = await client.createSession({ model: "gpt-4.1" }); +const session = await client.createSession({ model: "gpt-5.4" }); const response = await session.sendAndWait({ prompt: "Hello!" }); console.log(response?.data.content); @@ -66,7 +66,7 @@ from copilot.session import PermissionHandler client = CopilotClient() await client.start() -session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="gpt-4.1") +session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="gpt-5.4") response = await session.send_and_wait("Hello!") print(response.data.content) @@ -101,7 +101,7 @@ func main() { } defer client.Stop() - session, _ := client.CreateSession(ctx, &copilot.SessionConfig{Model: "gpt-4.1"}) + session, _ := client.CreateSession(ctx, &copilot.SessionConfig{Model: "gpt-5.4"}) response, _ := session.SendAndWait(ctx, copilot.MessageOptions{Prompt: "Hello!"}) if d, ok := response.Data.(*copilot.AssistantMessageData); ok { fmt.Println(d.Content) @@ -117,7 +117,7 @@ if err := client.Start(ctx); err != nil { } defer client.Stop() -session, _ := client.CreateSession(ctx, &copilot.SessionConfig{Model: "gpt-4.1"}) +session, _ := client.CreateSession(ctx, &copilot.SessionConfig{Model: "gpt-5.4"}) response, _ := session.SendAndWait(ctx, copilot.MessageOptions{Prompt: "Hello!"}) if d, ok := response.Data.(*copilot.AssistantMessageData); ok { fmt.Println(d.Content) @@ -132,7 +132,7 @@ if d, ok := response.Data.(*copilot.AssistantMessageData); ok { ```csharp await using var client = new CopilotClient(); await using var session = await client.CreateSessionAsync( - new SessionConfig { Model = "gpt-4.1" }); + new SessionConfig { Model = "gpt-5.4" }); var response = await session.SendAndWaitAsync( new MessageOptions { Prompt = "Hello!" }); @@ -158,7 +158,7 @@ var client = new CopilotClient(new CopilotClientOptions() client.start().get(); var session = client.createSession(new SessionConfig() - .setModel("gpt-4.1") + .setModel("gpt-5.4") .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) ).get(); @@ -219,7 +219,7 @@ If you manage your own model provider keys, users don't need GitHub accounts at const client = new CopilotClient(); const session = await client.createSession({ - model: "gpt-4.1", + model: "gpt-5.4", provider: { type: "openai", baseUrl: "https://api.openai.com/v1", @@ -241,7 +241,7 @@ const client = new CopilotClient(); const sessionId = `project-${projectName}`; const session = await client.createSession({ sessionId, - model: "gpt-4.1", + model: "gpt-5.4", }); // User closes app... diff --git a/docs/setup/choosing-a-setup-path.md b/docs/setup/choosing-a-setup-path.md index 7fe28be8d4..17c971e657 100644 --- a/docs/setup/choosing-a-setup-path.md +++ b/docs/setup/choosing-a-setup-path.md @@ -88,7 +88,7 @@ Use this table to find the right guides based on what you need to do: | Getting started quickly | [Default Setup (Bundled CLI)](./bundled-cli.md) | | Use your own CLI binary or server | [Local CLI](./local-cli.md) | | Users sign in with GitHub | [GitHub OAuth](./github-oauth.md) | -| Use your own model keys (OpenAI, Azure, etc.) | [BYOK](../auth/byok.md) | +| Use your own model keys (OpenAI, Azure, and more) | [BYOK](../auth/byok.md) | | Azure BYOK with Managed Identity (no API keys) | [Azure Managed Identity](./azure-managed-identity.md) | | Run the SDK on a server | [Backend Services](./backend-services.md) | | Configure SDK options for concurrent users | [Multi-tenancy and server deployments](./multi-tenancy.md) | diff --git a/docs/setup/github-oauth.md b/docs/setup/github-oauth.md index 25b6aa80e3..5b44024b14 100644 --- a/docs/setup/github-oauth.md +++ b/docs/setup/github-oauth.md @@ -133,7 +133,7 @@ function createClientForUser(userToken: string): CopilotClient { const client = createClientForUser("gho_user_access_token"); const session = await client.createSession({ sessionId: `user-${userId}-session`, - model: "gpt-4.1", + model: "gpt-5.4", }); const response = await session.sendAndWait({ prompt: "Hello!" }); @@ -158,7 +158,7 @@ def create_client_for_user(user_token: str) -> CopilotClient: client = create_client_for_user("gho_user_access_token") await client.start() -session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="gpt-4.1", session_id=f"user-{user_id}-session") +session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="gpt-5.4", session_id=f"user-{user_id}-session") response = await session.send_and_wait("Hello!") ``` @@ -195,7 +195,7 @@ func main() { session, _ := client.CreateSession(ctx, &copilot.SessionConfig{ SessionID: fmt.Sprintf("user-%s-session", userID), - Model: "gpt-4.1", + Model: "gpt-5.4", }) response, _ := session.SendAndWait(ctx, copilot.MessageOptions{Prompt: "Hello!"}) _ = response @@ -218,7 +218,7 @@ defer client.Stop() session, _ := client.CreateSession(ctx, &copilot.SessionConfig{ SessionID: fmt.Sprintf("user-%s-session", userID), - Model: "gpt-4.1", + Model: "gpt-5.4", }) response, _ := session.SendAndWait(ctx, copilot.MessageOptions{Prompt: "Hello!"}) ``` @@ -245,7 +245,7 @@ await using var client = CreateClientForUser("gho_user_access_token"); await using var session = await client.CreateSessionAsync(new SessionConfig { SessionId = $"user-{userId}-session", - Model = "gpt-4.1", + Model = "gpt-5.4", }); var response = await session.SendAndWaitAsync( @@ -266,7 +266,7 @@ await using var client = CreateClientForUser("gho_user_access_token"); await using var session = await client.CreateSessionAsync(new SessionConfig { SessionId = $"user-{userId}-session", - Model = "gpt-4.1", + Model = "gpt-5.4", }); var response = await session.SendAndWaitAsync( @@ -297,7 +297,7 @@ var userId = "user1"; try (var client = createClientForUser("gho_user_access_token")) { var session = client.createSession(new SessionConfig() .setSessionId(String.format("user-%s-session", userId)) - .setModel("gpt-4.1") + .setModel("gpt-5.4") .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) ).get(); diff --git a/docs/setup/local-cli.md b/docs/setup/local-cli.md index b8dd735b38..72394b3481 100644 --- a/docs/setup/local-cli.md +++ b/docs/setup/local-cli.md @@ -40,7 +40,7 @@ const client = new CopilotClient({ cliPath: "/usr/local/bin/copilot", }); -const session = await client.createSession({ model: "gpt-4.1" }); +const session = await client.createSession({ model: "gpt-5.4" }); const response = await session.sendAndWait({ prompt: "Hello!" }); console.log(response?.data.content); @@ -62,7 +62,7 @@ client = CopilotClient({ }) await client.start() -session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="gpt-4.1") +session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="gpt-5.4") response = await session.send_and_wait("Hello!") if response: match response.data: @@ -102,7 +102,7 @@ func main() { } defer client.Stop() - session, _ := client.CreateSession(ctx, &copilot.SessionConfig{Model: "gpt-4.1"}) + session, _ := client.CreateSession(ctx, &copilot.SessionConfig{Model: "gpt-5.4"}) response, _ := session.SendAndWait(ctx, copilot.MessageOptions{Prompt: "Hello!"}) if response != nil { if d, ok := response.Data.(*copilot.AssistantMessageData); ok { @@ -122,7 +122,7 @@ if err := client.Start(ctx); err != nil { } defer client.Stop() -session, _ := client.CreateSession(ctx, &copilot.SessionConfig{Model: "gpt-4.1"}) +session, _ := client.CreateSession(ctx, &copilot.SessionConfig{Model: "gpt-5.4"}) response, _ := session.SendAndWait(ctx, copilot.MessageOptions{Prompt: "Hello!"}) if response != nil { if d, ok := response.Data.(*copilot.AssistantMessageData); ok { @@ -143,7 +143,7 @@ var client = new CopilotClient(new CopilotClientOptions }); await using var session = await client.CreateSessionAsync( - new SessionConfig { Model = "gpt-4.1" }); + new SessionConfig { Model = "gpt-5.4" }); var response = await session.SendAndWaitAsync( new MessageOptions { Prompt = "Hello!" }); @@ -190,7 +190,7 @@ Sessions default to ephemeral. To create resumable sessions, provide your own se // Create a named session const session = await client.createSession({ sessionId: "my-project-analysis", - model: "gpt-4.1", + model: "gpt-5.4", }); // Later, resume it diff --git a/docs/setup/multi-tenancy.md b/docs/setup/multi-tenancy.md index def44746e6..2f82dde0bf 100644 --- a/docs/setup/multi-tenancy.md +++ b/docs/setup/multi-tenancy.md @@ -48,7 +48,7 @@ const client = new CopilotClient({ const session = await client.createSession({ sessionId: `user-${user.id}-${crypto.randomUUID()}`, - model: "gpt-4.1", + model: "gpt-5.4", availableTools: ["custom:lookupOrder", "custom:createTicket"], gitHubToken: user.githubToken, }); @@ -73,7 +73,7 @@ await client.start() session = await client.create_session( session_id=f"user-{user.id}-{request_id}", - model="gpt-4.1", + model="gpt-5.4", available_tools=["custom:lookupOrder", "custom:createTicket"], github_token=user.github_token, on_permission_request=PermissionHandler.approve_all, @@ -117,7 +117,7 @@ func main() { session, err := client.CreateSession(ctx, &copilot.SessionConfig{ SessionID: fmt.Sprintf("user-%s-%s", user.ID, requestID), - Model: "gpt-4.1", + Model: "gpt-5.4", AvailableTools: []string{"custom:lookupOrder", "custom:createTicket"}, GitHubToken: user.GitHubToken, }) @@ -137,7 +137,7 @@ client := copilot.NewClient(&copilot.ClientOptions{ session, err := client.CreateSession(ctx, &copilot.SessionConfig{ SessionID: fmt.Sprintf("user-%s-%s", user.ID, requestID), - Model: "gpt-4.1", + Model: "gpt-5.4", AvailableTools: []string{"custom:lookupOrder", "custom:createTicket"}, GitHubToken: user.GitHubToken, }) @@ -168,7 +168,7 @@ var client = new CopilotClient(new CopilotClientOptions await using var session = await client.CreateSessionAsync(new SessionConfig { SessionId = $"user-{user.Id}-{requestId}", - Model = "gpt-4.1", + Model = "gpt-5.4", AvailableTools = ["custom:lookupOrder", "custom:createTicket"], GitHubToken = user.GitHubToken, }); @@ -187,7 +187,7 @@ var client = new CopilotClient(new CopilotClientOptions await using var session = await client.CreateSessionAsync(new SessionConfig { SessionId = $"user-{user.Id}-{requestId}", - Model = "gpt-4.1", + Model = "gpt-5.4", AvailableTools = ["custom:lookupOrder", "custom:createTicket"], GitHubToken = user.GitHubToken, }); @@ -223,7 +223,7 @@ public class MultiTenancyExample { var session = client.createSession(new SessionConfig() .setSessionId("user-" + user.id() + "-" + requestId) - .setModel("gpt-4.1") + .setModel("gpt-5.4") .setAvailableTools(List.of("custom:lookupOrder", "custom:createTicket")) .setGitHubToken(user.gitHubToken()) ).get(); @@ -242,7 +242,7 @@ var client = new CopilotClient(new CopilotClientOptions() var session = client.createSession(new SessionConfig() .setSessionId("user-" + user.id() + "-" + requestId) - .setModel("gpt-4.1") + .setModel("gpt-5.4") .setAvailableTools(List.of("custom:lookupOrder", "custom:createTicket")) .setGitHubToken(user.gitHubToken()) ).get(); @@ -276,7 +276,7 @@ let client = Client::start( let session = client.create_session( SessionConfig::default() .with_session_id(format!("user-{}-{request_id}", user.id)) - .with_model("gpt-4.1") + .with_model("gpt-5.4") .with_available_tools(["custom:lookupOrder", "custom:createTicket"]) .with_github_token(user.github_token), ).await?; @@ -366,7 +366,7 @@ Set `gitHubToken` on each session to scope GitHub auth to the requesting user. T ```typescript const session = await client.createSession({ sessionId: `user-${user.id}-support`, - model: "gpt-4.1", + model: "gpt-5.4", availableTools: ["custom:*"], gitHubToken: user.githubToken, }); diff --git a/docs/setup/scaling.md b/docs/setup/scaling.md index d960eb94ea..c4a7a0953f 100644 --- a/docs/setup/scaling.md +++ b/docs/setup/scaling.md @@ -324,7 +324,7 @@ app.post("/chat", async (req, res) => { const session = await client.createSession({ sessionId: `user-${req.user.id}-chat`, - model: "gpt-4.1", + model: "gpt-5.4", }); const response = await session.sendAndWait({ prompt: req.body.message }); @@ -404,7 +404,7 @@ class SessionManager { // Create or resume const session = await client.createSession({ sessionId, - model: "gpt-4.1", + model: "gpt-5.4", }); this.activeSessions.set(sessionId, session); @@ -450,7 +450,7 @@ For stateless API endpoints where each request is independent: ```typescript app.post("/api/analyze", async (req, res) => { const session = await client.createSession({ - model: "gpt-4.1", + model: "gpt-5.4", }); try { @@ -475,7 +475,7 @@ app.post("/api/chat/start", async (req, res) => { const session = await client.createSession({ sessionId, - model: "gpt-4.1", + model: "gpt-5.4", infiniteSessions: { enabled: true, backgroundCompactionThreshold: 0.80, diff --git a/docs/troubleshooting/mcp-debugging.md b/docs/troubleshooting/mcp-debugging.md index 3447ed9210..f93f7acae2 100644 --- a/docs/troubleshooting/mcp-debugging.md +++ b/docs/troubleshooting/mcp-debugging.md @@ -446,10 +446,10 @@ When opening an issue or asking for help, collect: * [ ] SDK language and version * [ ] CLI version (`copilot --version`) -* [ ] MCP server type (Node.js, Python, .NET, Go, Rust, etc.) +* [ ] MCP server type (Node.js, Python, .NET, Go, Rust, and more) * [ ] Full MCP server configuration (redact secrets) * [ ] Result of manual `initialize` test -* [ ] Result of manual `tools/list` test +* [ ] Result of manual `tools/list` test * [ ] Debug logs from SDK * [ ] Any error messages diff --git a/dotnet/src/Canvas.cs b/dotnet/src/Canvas.cs index b4e63f1b31..6bf8be984e 100644 --- a/dotnet/src/Canvas.cs +++ b/dotnet/src/Canvas.cs @@ -57,6 +57,32 @@ public sealed class ExtensionInfo public string Name { get; set; } = string.Empty; } +/// +/// Stable identity for a host/SDK connection that supplies built-in canvases. +/// +/// +/// When set on session create or resume, the runtime uses +/// verbatim as the agent-facing canvas extension id, so canvases declared on a +/// control connection survive stdio reconnect and CLI process restart instead +/// of being re-keyed to a per-connection id. The id is opaque to the runtime; a +/// per-window-stable value such as app:builtin:<windowId> is +/// recommended. An id beginning with connection: is reserved and ignored +/// by the runtime. +/// +[Experimental(Diagnostics.Experimental)] +public sealed class CanvasProviderIdentity +{ + /// + /// Opaque, stable provider id used verbatim as the canvas extension id. + /// + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// Optional display name surfaced as the canvas extension name. + [JsonPropertyName("name")] + public string? Name { get; set; } +} + /// Structured exception returned from canvas handlers. /// /// Throw this from implementations to surface a diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 94b0921995..a512978225 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -15,6 +15,7 @@ using System.Runtime.InteropServices; using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Text.RegularExpressions; @@ -237,6 +238,17 @@ private static void ValidateEnvironmentOptions(CopilotClientOptions options, Run nameof(options)); } + if (options.WorkingDirectory is not null) + { + throw new ArgumentException( + $"{nameof(CopilotClientOptions)}.{nameof(CopilotClientOptions.WorkingDirectory)} is not supported with " + + $"{nameof(RuntimeConnection)}.{nameof(RuntimeConnection.ForInProcess)}(): the in-process transport hosts " + + "the native runtime in the shared host process and spawns the worker without a working-directory " + + "parameter, so a per-client working directory cannot be honored in-process. Use a child-process " + + "transport, or set the process working directory before creating the client.", + nameof(options)); + } + return; } @@ -338,16 +350,49 @@ async Task StartCoreAsync(CancellationToken ct) { if (_connection is InProcessRuntimeConnection) { - // In-process FFI hosting: load the Rust cdylib and let it spawn - // the CLI worker, instead of the SDK launching a CLI child process. - // The worker reads its configuration (telemetry export, etc.) from - // the environment passed here, so apply the same telemetry-derived - // vars the child-process path sets on its startInfo.Environment. - var ffiEnvironment = _options.Environment?.ToDictionary(kvp => kvp.Key, kvp => (string?)kvp.Value) - ?? new Dictionary(); - ApplyTelemetryEnvironment(ffiEnvironment, _options.Telemetry); - var resolvedFfiEnvironment = ffiEnvironment.ToDictionary(kvp => kvp.Key, kvp => kvp.Value!); - var ffiHost = FfiRuntimeHost.Create(ResolveCliPathForFfi(), GetNapiPrebuildsFolderOrThrow(), resolvedFfiEnvironment, _logger); + var ffiEnvironment = new Dictionary(); + if (!string.IsNullOrEmpty(_options.GitHubToken)) + { + ffiEnvironment["COPILOT_SDK_AUTH_TOKEN"] = _options.GitHubToken!; + } + if (!string.IsNullOrEmpty(_options.BaseDirectory)) + { + ffiEnvironment["COPILOT_HOME"] = _options.BaseDirectory!; + } + if (_options.Mode == CopilotClientMode.Empty) + { + ffiEnvironment["COPILOT_DISABLE_KEYTAR"] = "1"; + } + + var ffiArgs = new List(); + if (_options.LogLevel is { } logLevel && !string.IsNullOrEmpty(logLevel.Value)) + { + ffiArgs.AddRange(["--log-level", logLevel.Value]); + } + if (!string.IsNullOrEmpty(_options.GitHubToken)) + { + ffiArgs.AddRange(["--auth-token-env", "COPILOT_SDK_AUTH_TOKEN"]); + } + var useLoggedInUser = _options.UseLoggedInUser ?? string.IsNullOrEmpty(_options.GitHubToken); + if (!useLoggedInUser) + { + ffiArgs.Add("--no-auto-login"); + } + if (_options.SessionIdleTimeoutSeconds is > 0) + { + ffiArgs.AddRange(["--session-idle-timeout", _options.SessionIdleTimeoutSeconds.Value.ToString(CultureInfo.InvariantCulture)]); + } + if (_options.EnableRemoteSessions) + { + ffiArgs.Add("--remote"); + } + + var ffiHost = FfiRuntimeHost.Create( + ResolveCliPathForFfi(), + GetNapiPrebuildsFolderOrThrow(), + ffiEnvironment, + ffiArgs, + _logger); _ffiHost = ffiHost; await ffiHost.StartAsync(ct); connection = await ConnectToServerAsync(null, null, null, null, ct, ffiHost); @@ -1137,16 +1182,19 @@ public async Task CreateSessionAsync(SessionConfig config, Cance InstructionDirectories: config.InstructionDirectories, PluginDirectories: config.PluginDirectories, LargeOutput: config.LargeOutput, + ToolSearch: config.ToolSearch, Memory: config.Memory, Canvases: config.Canvases, RequestCanvasRenderer: config.RequestCanvasRenderer, RequestExtensions: config.RequestExtensions, ExtensionSdkPath: config.ExtensionSdkPath, ExtensionInfo: config.ExtensionInfo, + CanvasProvider: config.CanvasProvider, Providers: config.Providers, Models: config.Models, ToolFilterPrecedence: toolFilter.ToolFilterPrecedence, ExpAssignments: config.ExpAssignments, + EnableManagedSettings: config.EnableManagedSettings, EnableGitHubTelemetryForwarding: _options.OnGitHubTelemetry != null ? true : null); var rpcTimestamp = Stopwatch.GetTimestamp(); @@ -1346,17 +1394,20 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes InstructionDirectories: config.InstructionDirectories, PluginDirectories: config.PluginDirectories, LargeOutput: config.LargeOutput, + ToolSearch: config.ToolSearch, Memory: config.Memory, Canvases: config.Canvases, RequestCanvasRenderer: config.RequestCanvasRenderer, RequestExtensions: config.RequestExtensions, ExtensionSdkPath: config.ExtensionSdkPath, ExtensionInfo: config.ExtensionInfo, + CanvasProvider: config.CanvasProvider, OpenCanvases: config.OpenCanvases, Providers: config.Providers, Models: config.Models, ToolFilterPrecedence: toolFilter.ToolFilterPrecedence, ExpAssignments: config.ExpAssignments, + EnableManagedSettings: config.EnableManagedSettings, EnableGitHubTelemetryForwarding: _options.OnGitHubTelemetry != null ? true : null); var rpcTimestamp = Stopwatch.GetTimestamp(); @@ -2197,7 +2248,12 @@ private static void ApplyTelemetryEnvironment(IDictionary envir { string os; if (OperatingSystem.IsWindows()) os = "win"; - else if (OperatingSystem.IsLinux()) os = "linux"; + else if (OperatingSystem.IsLinux()) + { + os = RuntimeInformation.RuntimeIdentifier.StartsWith("linux-musl-", StringComparison.Ordinal) + ? "linux-musl" + : "linux"; + } else if (OperatingSystem.IsMacOS()) os = "osx"; else return null; @@ -2244,7 +2300,12 @@ private string ResolveCliPathForFfi() { string platform; if (OperatingSystem.IsWindows()) platform = "win32"; - else if (OperatingSystem.IsLinux()) platform = "linux"; + else if (OperatingSystem.IsLinux()) + { + platform = RuntimeInformation.RuntimeIdentifier.StartsWith("linux-musl-", StringComparison.Ordinal) + ? "linuxmusl" + : "linux"; + } else if (OperatingSystem.IsMacOS()) platform = "darwin"; else return null; @@ -2685,6 +2746,7 @@ internal record CreateSessionRequest( IList? InstructionDirectories = null, IList? PluginDirectories = null, LargeToolOutputConfig? LargeOutput = null, + ToolSearchConfig? ToolSearch = null, MemoryConfiguration? Memory = null, #pragma warning disable GHCP001 IList? Canvases = null, @@ -2692,10 +2754,12 @@ internal record CreateSessionRequest( bool? RequestExtensions = null, string? ExtensionSdkPath = null, ExtensionInfo? ExtensionInfo = null, + CanvasProviderIdentity? CanvasProvider = null, IList? Providers = null, IList? Models = null, OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null, [property: JsonPropertyName("expAssignments")] JsonElement? ExpAssignments = null, + [property: JsonPropertyName("enableManagedSettings")] bool? EnableManagedSettings = null, bool? EnableGitHubTelemetryForwarding = null); #pragma warning restore GHCP001 @@ -2705,17 +2769,20 @@ internal record ToolDefinition( JsonElement Parameters, /* JSON schema */ bool? OverridesBuiltInTool = null, bool? SkipPermission = null, - CopilotToolDefer? Defer = null) + CopilotToolDefer? Defer = null, + IDictionary? Metadata = null) { public static ToolDefinition FromAIFunction(AIFunctionDeclaration function) { var overrides = function.AdditionalProperties.TryGetValue(CopilotTool.OverridesBuiltInToolKey, out var val) && val is true; var skipPerm = function.AdditionalProperties.TryGetValue(CopilotTool.SkipPermissionKey, out var skipVal) && skipVal is true; var defer = function.AdditionalProperties.TryGetValue(CopilotTool.DeferKey, out var deferVal) && deferVal is CopilotToolDefer d ? d : (CopilotToolDefer?)null; + var metadata = function.AdditionalProperties.TryGetValue(CopilotTool.MetadataKey, out var metaVal) && metaVal is IDictionary m ? m : null; return new ToolDefinition(function.Name, function.Description, function.JsonSchema, overrides ? true : null, skipPerm ? true : null, - defer); + defer, + metadata); } } @@ -2784,6 +2851,7 @@ internal record ResumeSessionRequest( IList? InstructionDirectories = null, IList? PluginDirectories = null, LargeToolOutputConfig? LargeOutput = null, + ToolSearchConfig? ToolSearch = null, MemoryConfiguration? Memory = null, #pragma warning disable GHCP001 IList? Canvases = null, @@ -2791,11 +2859,13 @@ internal record ResumeSessionRequest( bool? RequestExtensions = null, string? ExtensionSdkPath = null, ExtensionInfo? ExtensionInfo = null, + CanvasProviderIdentity? CanvasProvider = null, IList? OpenCanvases = null, IList? Providers = null, IList? Models = null, OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null, [property: JsonPropertyName("expAssignments")] JsonElement? ExpAssignments = null, + [property: JsonPropertyName("enableManagedSettings")] bool? EnableManagedSettings = null, bool? EnableGitHubTelemetryForwarding = null); #pragma warning restore GHCP001 diff --git a/dotnet/src/CopilotRequestHandler.cs b/dotnet/src/CopilotRequestHandler.cs index f26fc70e0a..514d77da6f 100644 --- a/dotnet/src/CopilotRequestHandler.cs +++ b/dotnet/src/CopilotRequestHandler.cs @@ -49,6 +49,9 @@ public CopilotRequestContext(CopilotRequestContext original) : this(original.RequestId, original.Url, original.Headers) { SessionId = original.SessionId; + AgentId = original.AgentId; + ParentAgentId = original.ParentAgentId; + InteractionType = original.InteractionType; Transport = original.Transport; CancellationToken = original.CancellationToken; WebSocketResponse = original.WebSocketResponse; @@ -67,6 +70,15 @@ internal CopilotRequestContext(string requestId, string url, IReadOnlyDictionary /// Runtime session id that triggered the request, if any. public string? SessionId { get; init; } + /// Stable per-agent-instance id for the agent trajectory that issued this request. + public string? AgentId { get; init; } + + /// Id of the parent agent when this request was issued by a subagent. + public string? ParentAgentId { get; init; } + + /// Runtime classification for the interaction that produced this request. + public string? InteractionType { get; init; } + /// Transport the runtime would otherwise use. public CopilotRequestTransport Transport { get; init; } @@ -526,6 +538,12 @@ private static async Task BuildHttpRequestAsync(LlmInference if (!message.Headers.TryAddWithoutValidation(name, values)) { +#if NETSTANDARD2_0 + if (!hasBody) + { + continue; + } +#endif message.Content ??= new ByteArrayContent([]); message.Content.Headers.TryAddWithoutValidation(name, values); } @@ -870,6 +888,9 @@ public Task HttpRequestStartAsync(LlmInferen exchange.Context = new CopilotRequestContext(request.RequestId, request.Url, ToReadOnlyHeaders(request.Headers)) { SessionId = request.SessionId, + AgentId = request.AgentId, + ParentAgentId = request.ParentAgentId, + InteractionType = request.InteractionType, Transport = transport, CancellationToken = exchange.Abort.Token, }; diff --git a/dotnet/src/CopilotTool.cs b/dotnet/src/CopilotTool.cs index d6dc0351e4..e22296bccd 100644 --- a/dotnet/src/CopilotTool.cs +++ b/dotnet/src/CopilotTool.cs @@ -3,6 +3,7 @@ *--------------------------------------------------------------------------------------------*/ using Microsoft.Extensions.AI; +using System.Text.Json.Nodes; namespace GitHub.Copilot; @@ -20,6 +21,9 @@ public static class CopilotTool /// The key used in to carry the tool's deferral mode. internal const string DeferKey = "defer"; + /// The key used in to carry the tool's opaque host-defined metadata. + internal const string MetadataKey = "metadata"; + /// /// Defines a tool for use in a . /// @@ -87,7 +91,7 @@ static void ApplyToolInvocationBinding(AIFunctionFactoryOptions factoryOptions) static void ApplyToolOptions(AIFunctionFactoryOptions factoryOptions, CopilotToolOptions? toolOptions) { - if (toolOptions is not null && (toolOptions.OverridesBuiltInTool || toolOptions.SkipPermission || toolOptions.Defer is not null)) + if (toolOptions is not null && (toolOptions.OverridesBuiltInTool || toolOptions.SkipPermission || toolOptions.Defer is not null || toolOptions.Metadata is not null)) { Dictionary additionalProperties = new(StringComparer.Ordinal); if (factoryOptions.AdditionalProperties is not null) @@ -113,6 +117,11 @@ static void ApplyToolOptions(AIFunctionFactoryOptions factoryOptions, CopilotToo additionalProperties[DeferKey] = defer; } + if (toolOptions.Metadata is { } metadata) + { + additionalProperties[MetadataKey] = metadata; + } + factoryOptions.AdditionalProperties = additionalProperties; } } @@ -151,6 +160,11 @@ public sealed class CopilotToolOptions /// SDK forwards it to the CLI as the tool's defer mode. Defaults to "auto". /// public CopilotToolDefer? Defer { get; set; } + + /// + /// Gets or sets opaque, host-defined metadata associated with the tool definition. + /// + public IDictionary? Metadata { get; set; } } /// diff --git a/dotnet/src/FfiRuntimeHost.cs b/dotnet/src/FfiRuntimeHost.cs index 210819de67..a838b9fd17 100644 --- a/dotnet/src/FfiRuntimeHost.cs +++ b/dotnet/src/FfiRuntimeHost.cs @@ -44,6 +44,7 @@ internal sealed partial class FfiRuntimeHost : IDisposable private readonly string _cliEntrypoint; private readonly string _libraryPath; private readonly IReadOnlyDictionary? _environment; + private readonly IReadOnlyList _args; private readonly CallbackReceiveStream _receiveStream = new(); private CallbackSendStream? _sendStream; @@ -52,11 +53,12 @@ internal sealed partial class FfiRuntimeHost : IDisposable private uint _connectionId; private bool _disposed; - private FfiRuntimeHost(string libraryPath, string cliEntrypoint, IReadOnlyDictionary? environment, ILogger logger) + private FfiRuntimeHost(string libraryPath, string cliEntrypoint, IReadOnlyDictionary? environment, IReadOnlyList args, ILogger logger) { _libraryPath = libraryPath; _cliEntrypoint = cliEntrypoint; _environment = environment; + _args = args; _logger = logger; } @@ -79,7 +81,7 @@ private FfiRuntimeHost(string libraryPath, string cliEntrypoint, IReadOnlyDictio /// is the napi-rs /// <node-platform>-<arch> folder name (e.g. win32-x64). /// - public static FfiRuntimeHost Create(string cliEntrypoint, string prebuildsFolder, IReadOnlyDictionary? environment, ILogger logger) + public static FfiRuntimeHost Create(string cliEntrypoint, string prebuildsFolder, IReadOnlyDictionary? environment, IReadOnlyList args, ILogger logger) { var fullEntrypoint = Path.GetFullPath(cliEntrypoint); var distDir = Path.GetDirectoryName(fullEntrypoint) @@ -96,7 +98,7 @@ public static FfiRuntimeHost Create(string cliEntrypoint, string prebuildsFolder $"FFI runtime library not found. Looked for '{flatLibraryPath}' and '{prebuildsLibraryPath}'."); PrepareNativeLibrary(libraryPath); - return new FfiRuntimeHost(libraryPath, fullEntrypoint, environment, logger); + return new FfiRuntimeHost(libraryPath, fullEntrypoint, environment, args, logger); } /// @@ -122,7 +124,7 @@ public async Task StartAsync(CancellationToken cancellationToken) // perform the blocking FFI handshake on a background thread. await Task.Run(() => { - var argvJson = BuildArgvJson(_cliEntrypoint); + var argvJson = BuildArgvJson(_cliEntrypoint, _args); var envJson = BuildEnvJson(_environment); _serverId = NativeHostStart(argvJson, envJson); @@ -152,7 +154,7 @@ await Task.Run(() => } } - private static byte[] BuildArgvJson(string cliEntrypoint) + private static byte[] BuildArgvJson(string cliEntrypoint, IReadOnlyList args) { // A .js entrypoint (dev / dist-cli) is launched via node; the packaged // single-file CLI binary embeds its own Node and is invoked directly. @@ -167,6 +169,13 @@ private static byte[] BuildArgvJson(string cliEntrypoint) } writer.WriteStringValue(cliEntrypoint); writer.WriteStringValue("--embedded-host"); + // Pin the worker to the bundled pkg matching the loaded cdylib, instead of + // drifting to a newer version under the user's ~/.copilot/pkg (ABI skew). + writer.WriteStringValue("--no-auto-update"); + foreach (var arg in args) + { + writer.WriteStringValue(arg); + } writer.WriteEndArray(); } return stream.ToArray(); diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index 308d9bc0d2..bf73bdfaf7 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -74,6 +74,27 @@ internal sealed class ConnectRequest public string? Token { get; set; } } +/// Active server-driven promotion for a model, including its discount and expiry. +[Experimental(Diagnostics.Experimental)] +public sealed class ModelBillingPromo +{ + /// Percentage discount (0-100) applied while the promotion is active. May be fractional. + [JsonPropertyName("discountPercent")] + public double? DiscountPercent { get; set; } + + /// UTC ISO 8601 timestamp marking when the promotion ends. Always present: the API only surfaces a promo whose expiry parses and is in the future. Consumers should treat a past value as expired. + [JsonPropertyName("endsAt")] + public string EndsAt { get; set; } = string.Empty; + + /// Stable identifier for the promotion campaign. + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// Human-readable promotion message. Does not include the expiry timestamp; consumers may format endsAt and append it. + [JsonPropertyName("message")] + public string? Message { get; set; } +} + /// Long context tier pricing (available for models with extended context windows). [Experimental(Diagnostics.Experimental)] public sealed class ModelBillingTokenPricesLongContext @@ -176,6 +197,10 @@ public sealed class ModelBilling [JsonPropertyName("multiplier")] public double? Multiplier { get; set; } + /// Active server-driven promotion for this model, if any. Present when the model is being promoted with a time-boxed discount. + [JsonPropertyName("promo")] + public ModelBillingPromo? Promo { get; set; } + /// Token-level pricing information for this model. [JsonPropertyName("tokenPrices")] public ModelBillingTokenPrices? TokenPrices { get; set; } @@ -1339,13 +1364,17 @@ public sealed class MarketplaceAddResult public string Name { get; set; } = string.Empty; } -/// Marketplace source to register. +/// Marketplace source and optional working directory for relative-path resolution. [Experimental(Diagnostics.Experimental)] internal sealed class PluginsMarketplacesAddRequest { /// Marketplace source. Accepts the same forms as the CLI: "owner/repo" or "owner/repo#ref" (GitHub), an http/https/ssh URL (optionally with #ref), a git scp-style URL (user@host:path), or a local path. The marketplace's own name (from its manifest) is used as the registration key. [JsonPropertyName("source")] public string Source { get; set; } = string.Empty; + + /// Working directory used to resolve relative local paths in `source`. Defaults to the server's current working directory. + [JsonPropertyName("workingDirectory")] + public string? WorkingDirectory { get; set; } } /// Outcome of the remove attempt, including dependent-plugin info when applicable. @@ -1784,6 +1813,90 @@ internal sealed class InstructionsGetDiscoveryPathsRequest public IList? ProjectPaths { get; set; } } +/// A literal choice the command input accepts, with a human-facing description. +[Experimental(Diagnostics.Experimental)] +public sealed class SlashCommandInputChoice +{ + /// Human-readable description shown alongside the choice. + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + /// The literal choice value (e.g. 'on', 'off', 'show'). + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; +} + +/// Optional unstructured input hint. +[Experimental(Diagnostics.Experimental)] +public sealed class SlashCommandInput +{ + /// Optional literal choices the input accepts, each with a human-facing description; clients may render these as selectable options. + [JsonPropertyName("choices")] + public IList? Choices { get; set; } + + /// Optional completion hint for the input (e.g. 'directory' for filesystem path completion). + [JsonPropertyName("completion")] + public SlashCommandInputCompletion? Completion { get; set; } + + /// Hint to display when command input has not been provided. + [JsonPropertyName("hint")] + public string Hint { get; set; } = string.Empty; + + /// When true, clients should pass the full text after the command name as a single argument rather than splitting on whitespace. + [JsonPropertyName("preserveMultilineInput")] + public bool? PreserveMultilineInput { get; set; } + + /// When true, the command requires non-empty input; clients should render the input hint as required. + [JsonPropertyName("required")] + public bool? Required { get; set; } +} + +/// Slash-command metadata with name, aliases, description, kind, input hint, execution allowance, and schedulability. +[Experimental(Diagnostics.Experimental)] +public sealed class SlashCommandInfo +{ + /// Canonical aliases without leading slashes. + [JsonPropertyName("aliases")] + public IList? Aliases { get; set; } + + /// Whether the command may run while an agent turn is active. + [JsonPropertyName("allowDuringAgentExecution")] + public bool AllowDuringAgentExecution { get; set; } + + /// Human-readable command description. + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + /// Whether the command is experimental. + [JsonPropertyName("experimental")] + public bool? Experimental { get; set; } + + /// Optional unstructured input hint. + [JsonPropertyName("input")] + public SlashCommandInput? Input { get; set; } + + /// Coarse command category for grouping and behavior: runtime built-in, skill-backed command, or SDK/client-owned command. + [JsonPropertyName("kind")] + public SlashCommandKind Kind { get; set; } + + /// Canonical command name without a leading slash. + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Whether the command may be the target of `/every` / `/after` schedules. Resolution happens at every tick, so only set this when the command is safe to re-invoke and produces an agent prompt. + [JsonPropertyName("schedulable")] + public bool? Schedulable { get; set; } +} + +/// Slash commands available in the session, after applying any include/exclude filters. +[Experimental(Diagnostics.Experimental)] +public sealed class CommandList +{ + /// Commands available in this session. + [JsonPropertyName("commands")] + public IList Commands { get => field ??= []; set; } +} + /// A single user setting's effective value alongside its default, so consumers can render settings left at their default. [Experimental(Diagnostics.Experimental)] public sealed class UserSettingMetadata @@ -3353,6 +3466,88 @@ internal sealed class SendRequest public bool? Wait { get; set; } } +/// Result of sending zero or more user messages. +[Experimental(Diagnostics.Experimental)] +public sealed class SendMessagesResult +{ + /// Unique identifiers assigned to the messages, one per provided message in order. Empty when no messages were provided. + [JsonPropertyName("messageIds")] + public IList MessageIds { get => field ??= []; set; } +} + +/// A single user message to append to the session as part of a `session.sendMessages` turn. +[Experimental(Diagnostics.Experimental)] +public sealed class SendMessageItem +{ + /// Optional attachments (files, directories, selections, blobs, GitHub references) to include with this message. + [JsonPropertyName("attachments")] + public IList? Attachments { get; set; } + + /// If false, this message will not trigger a Premium Request Unit charge. User messages default to billable. + [JsonInclude] + [JsonPropertyName("billable")] + internal bool? Billable { get; set; } + + /// If provided, this is shown in the timeline instead of `prompt`. + [JsonPropertyName("displayPrompt")] + public string? DisplayPrompt { get; set; } + + /// The user message text. + [JsonPropertyName("prompt")] + public string Prompt { get; set; } = string.Empty; + + /// If set, the request will fail if the named tool is not available when this message is among the user messages at the start of the current exchange. + [JsonPropertyName("requiredTool")] + public string? RequiredTool { get; set; } + + /// Optional provenance tag copied to the resulting user.message event. Must match one of three forms: the literal `system`, `command-<command-id>` for messages originating from a command (e.g. slash command, Mission Control command), or `schedule-<numeric-id>` for messages originating from a scheduled job. + [RegularExpression("^(system|command-.*|schedule-\\d+)$")] + [JsonInclude] + [JsonPropertyName("source")] + internal string? Source { get; set; } +} + +/// Parameters for sending zero or more user messages to the session in a single turn. Remote-backed (Mission Control) sessions do not support this method and will return an error. +[Experimental(Diagnostics.Experimental)] +internal sealed class SendMessagesRequest +{ + /// The UI mode the agent was in when these messages were sent. Defaults to the session's current mode. + [JsonPropertyName("agentMode")] + public SendAgentMode? AgentMode { get; set; } + + /// The user messages to append to the conversation, in order. May be empty, in which case a single turn runs over the existing history with no new user message. + [JsonPropertyName("messages")] + public IList Messages { get => field ??= []; set; } + + /// How to deliver the messages. `enqueue` (default) appends to the message queue. `immediate` interjects during an in-progress turn. + [JsonPropertyName("mode")] + public SendMode? Mode { get; set; } + + /// If true, adds the messages to the front of the queue instead of the end. + [JsonPropertyName("prepend")] + public bool? Prepend { get; set; } + + /// Custom HTTP headers to include in outbound model requests for this turn. Merged with session-level provider headers; per-turn headers augment and overwrite session-level headers with the same key. + [JsonPropertyName("requestHeaders")] + public IDictionary? RequestHeaders { get; set; } + + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + /// W3C Trace Context traceparent header for distributed tracing of this agent turn. + [JsonPropertyName("traceparent")] + public string? Traceparent { get; set; } + + /// W3C Trace Context tracestate header for distributed tracing. + [JsonPropertyName("tracestate")] + public string? Tracestate { get; set; } + + /// If true, await completion of the agentic loop for this turn before returning. Defaults to false (fire-and-forget). When true, the result still contains the same `messageIds`; the caller can rely on the agent having processed the messages before the call resolves. + [JsonPropertyName("wait")] + public bool? Wait { get; set; } +} + /// Result of aborting the current turn. [Experimental(Diagnostics.Experimental)] public sealed class AbortResult @@ -3734,6 +3929,10 @@ public sealed class DiscoveredCanvas [JsonPropertyName("extensionName")] public string? ExtensionName { get; set; } + /// Host-local PNG path for the canvas icon, when supplied. + [JsonPropertyName("icon")] + public string? Icon { get; set; } + /// JSON Schema for canvas open input. [JsonPropertyName("inputSchema")] public JsonElement? InputSchema { get; set; } @@ -3773,6 +3972,10 @@ public sealed class OpenCanvasInstance [JsonPropertyName("extensionName")] public string? ExtensionName { get; set; } + /// Host-local PNG path for the canvas icon, when supplied. + [JsonPropertyName("icon")] + public string? Icon { get; set; } + /// Input supplied when the instance was opened. [JsonPropertyName("input")] public JsonElement? Input { get; set; } @@ -4038,6 +4241,19 @@ internal sealed class ModelSetReasoningEffortRequest public string SessionId { get; set; } = string.Empty; } +/// Cost-category metadata for a CAPI model. +[Experimental(Diagnostics.Experimental)] +public sealed class SessionModelPriceCategory +{ + /// Gets or sets the id value. + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// Gets or sets the priceCategory value. + [JsonPropertyName("priceCategory")] + public ModelPickerPriceCategory PriceCategory { get; set; } +} + /// The list of models available to this session. [Experimental(Diagnostics.Experimental)] public sealed class SessionModelList @@ -4046,6 +4262,10 @@ public sealed class SessionModelList [JsonPropertyName("list")] public IList List { get => field ??= []; set; } + /// Cost categories for the full CAPI catalog, including picker-disabled models that Auto may select. Metadata only; entries absent from `list` are not manually selectable. + [JsonPropertyName("modelPriceCategories")] + public IList? ModelPriceCategories { get; set; } + /// Per-quota snapshots returned alongside the model list, keyed by quota type. [JsonPropertyName("quotaSnapshots")] public IDictionary? QuotaSnapshots { get; set; } @@ -5522,7 +5742,20 @@ internal sealed class SessionMcpListRequest public string SessionId { get; set; } = string.Empty; } -/// MCP tool metadata with tool name and optional description. +/// Normalized MCP Apps discovery metadata from a tool's `_meta.ui` block. +[Experimental(Diagnostics.Experimental)] +public sealed class McpToolUi +{ + /// URI of the tool's MCP App resource, typically a `ui://` resource identifier. Use `session.mcp.resources.read` to fetch its HTML and resource metadata. + [JsonPropertyName("resourceUri")] + public string? ResourceUri { get; set; } + + /// Tool visibility advertised by the server. When absent, MCP Apps defaults apply. + [JsonPropertyName("visibility")] + public IList? Visibility { get; set; } +} + +/// MCP tool metadata with tool name, optional description, and normalized MCP Apps discovery metadata. [Experimental(Diagnostics.Experimental)] public sealed class McpTools { @@ -5533,6 +5766,10 @@ public sealed class McpTools /// Tool name. [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; + + /// Normalized MCP Apps discovery metadata. An empty object indicates that a valid `_meta.ui` block was present without recognized fields. + [JsonPropertyName("ui")] + public McpToolUi? Ui { get; set; } } /// Tools exposed by the connected MCP server. Throws when the server is not connected. @@ -5801,14 +6038,13 @@ internal sealed class McpConfigureGitHubRequest public string SessionId { get; set; } = string.Empty; } -/// Server name and opaque configuration for an individual MCP server start. +/// Server name and configuration for an individual MCP server start. [Experimental(Diagnostics.Experimental)] internal sealed class McpStartServerRequest { - /// Opaque server configuration (MCPServerConfig). Marked internal: an in-process runtime shape supplied only by in-process CLI callers. - [JsonInclude] + /// MCP server configuration (stdio process or remote HTTP/SSE). [JsonPropertyName("config")] - internal JsonElement Config { get; set; } + public JsonElement Config { get; set; } /// Name of the MCP server to start. [JsonPropertyName("serverName")] @@ -5819,14 +6055,13 @@ internal sealed class McpStartServerRequest public string SessionId { get; set; } = string.Empty; } -/// Server name and opaque configuration for an individual MCP server restart. +/// Server name and optional replacement configuration for an individual MCP server restart. Omit `config` for a config-free restart-by-name of an already-configured server. [Experimental(Diagnostics.Experimental)] internal sealed class McpRestartServerRequest { - /// Opaque server configuration (MCPServerConfig). Marked internal: an in-process runtime shape supplied only by in-process CLI callers. - [JsonInclude] + /// Replacement MCP server configuration (stdio process or remote HTTP/SSE). Omit to restart the server with its already-registered configuration (config-free restart-by-name). [JsonPropertyName("config")] - internal JsonElement Config { get; set; } + public JsonElement? Config { get; set; } /// Name of the MCP server to restart. [JsonPropertyName("serverName")] @@ -6112,7 +6347,7 @@ public sealed class McpAppsResourceContent { /// Resource-level metadata (CSP, permissions, etc.). [JsonPropertyName("_meta")] - public IDictionary? _meta { get; set; } + public IDictionary? Meta { get; set; } /// Base64-encoded binary content. [JsonPropertyName("blob")] @@ -6337,45 +6572,297 @@ public sealed class McpAppsDiagnoseCapability public bool SessionHasMcpApps { get; set; } } -/// What the server returned for this session. +/// What the server returned for this session. +[Experimental(Diagnostics.Experimental)] +public sealed class McpAppsDiagnoseServer +{ + /// Whether the named server is currently connected. + [JsonPropertyName("connected")] + public bool Connected { get; set; } + + /// Up to 5 tool names with `_meta.ui` for quick inspection. + [JsonPropertyName("sampleToolNames")] + public IList SampleToolNames { get => field ??= []; set; } + + /// Total tools returned by the server's tools/list. + [JsonPropertyName("toolCount")] + public double ToolCount { get; set; } + + /// Tools whose `_meta.ui` is populated (resourceUri and/or visibility set). + [JsonPropertyName("toolsWithUiMeta")] + public double ToolsWithUiMeta { get; set; } +} + +/// Diagnostic snapshot of MCP Apps wiring for the named server. +[Experimental(Diagnostics.Experimental)] +public sealed class McpAppsDiagnoseResult +{ + /// Capability negotiation snapshot. + [JsonPropertyName("capability")] + public McpAppsDiagnoseCapability Capability { get => field ??= new(); set; } + + /// What the server returned for this session. + [JsonPropertyName("server")] + public McpAppsDiagnoseServer Server { get => field ??= new(); set; } +} + +/// MCP server to diagnose MCP Apps wiring for. +[Experimental(Diagnostics.Experimental)] +internal sealed class McpAppsDiagnoseRequest +{ + /// MCP server to probe. + [RegularExpression("^[^\\x00-\\x1f/\\x7f-\\x9f}]+(?:\\/[^\\x00-\\x1f/\\x7f-\\x9f}]+)*$")] + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Safe for generated string properties: JSON Schema minLength/maxLength map to string length validation, not reflection over trimmed Count members")] + [MinLength(1)] + [JsonPropertyName("serverName")] + public string ServerName { get; set; } = string.Empty; + + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +/// MCP resource content with URI, optional MIME type, text or base64 blob, and resource metadata. +[Experimental(Diagnostics.Experimental)] +public sealed class McpResourceContent +{ + /// Resource-level metadata (CSP, permissions, etc.). + [JsonPropertyName("_meta")] + public IDictionary? Meta { get; set; } + + /// Base64-encoded binary content. + [JsonPropertyName("blob")] + public string? Blob { get; set; } + + /// MIME type of the content. + [JsonPropertyName("mimeType")] + public string? MimeType { get; set; } + + /// Text content (e.g. HTML). + [JsonPropertyName("text")] + public string? Text { get; set; } + + /// The resource URI. + [JsonPropertyName("uri")] + public string Uri { get; set; } = string.Empty; +} + +/// Resource contents returned by the MCP server. +[Experimental(Diagnostics.Experimental)] +public sealed class McpResourcesReadResult +{ + /// Resource contents returned by the server. + [JsonPropertyName("contents")] + public IList Contents { get => field ??= []; set; } +} + +/// MCP server and resource URI to fetch. +[Experimental(Diagnostics.Experimental)] +internal sealed class McpResourcesReadRequest +{ + /// Name of the MCP server hosting the resource. + [RegularExpression("^[^\\x00-\\x1f/\\x7f-\\x9f}]+(?:\\/[^\\x00-\\x1f/\\x7f-\\x9f}]+)*$")] + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Safe for generated string properties: JSON Schema minLength/maxLength map to string length validation, not reflection over trimmed Count members")] + [MinLength(1)] + [JsonPropertyName("serverName")] + public string ServerName { get; set; } = string.Empty; + + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + /// Resource URI. + [JsonPropertyName("uri")] + public string Uri { get; set; } = string.Empty; +} + +/// Standard MCP resource annotations plus preserved non-standard annotation fields. +[Experimental(Diagnostics.Experimental)] +public sealed class McpResourceAnnotations +{ + /// Server-provided non-standard annotation fields preserved from the MCP response. + [JsonPropertyName("additionalProperties")] + public IDictionary? AdditionalProperties { get; set; } + + /// Intended audience roles for this resource. + [JsonPropertyName("audience")] + public IList? Audience { get; set; } + + /// Last-modified timestamp hint. + [JsonPropertyName("lastModified")] + public string? LastModified { get; set; } + + /// Priority hint for model/client use. + [JsonPropertyName("priority")] + public double? Priority { get; set; } +} + +/// A resource icon descriptor plus preserved non-standard icon fields. +[Experimental(Diagnostics.Experimental)] +public sealed class McpResourceIcon +{ + /// Server-provided non-standard icon fields preserved from the MCP response. + [JsonPropertyName("additionalProperties")] + public IDictionary? AdditionalProperties { get; set; } + + /// Icon MIME type, when known. + [JsonPropertyName("mimeType")] + public string? MimeType { get; set; } + + /// Icon sizes hint. + [JsonPropertyName("sizes")] + public string? Sizes { get; set; } + + /// Icon URI. + [JsonPropertyName("src")] + public string Src { get; set; } = string.Empty; + + /// Theme hint for this icon. + [JsonPropertyName("theme")] + public string? Theme { get; set; } +} + +/// An MCP resource descriptor (spec `Resource`): URI, name, and optional title, description, MIME type, size, icons, annotations, and metadata. Server-provided fields outside the standard descriptor shape are exposed under `additionalProperties`. +[Experimental(Diagnostics.Experimental)] +public sealed class McpResource +{ + /// Resource-level metadata. + [JsonPropertyName("_meta")] + public IDictionary? Meta { get; set; } + + /// Server-provided non-standard descriptor fields preserved from the MCP response. + [JsonPropertyName("additionalProperties")] + public IDictionary? AdditionalProperties { get; set; } + + /// Model/client annotations associated with this resource. + [JsonPropertyName("annotations")] + public McpResourceAnnotations? Annotations { get; set; } + + /// Optional description of what this resource represents. + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// Icons associated with this resource. + [JsonPropertyName("icons")] + public IList? Icons { get; set; } + + /// MIME type of the resource, if known. + [JsonPropertyName("mimeType")] + public string? MimeType { get; set; } + + /// The programmatic name of the resource. + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Resource size in bytes, when known. + [JsonPropertyName("size")] + public long? Size { get; set; } + + /// Optional human-readable display title. + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// The resource URI (e.g. ui://... or file:///...). + [JsonPropertyName("uri")] + public string Uri { get; set; } = string.Empty; +} + +/// One page of resources advertised by the named MCP server. +[Experimental(Diagnostics.Experimental)] +public sealed class McpResourcesListResult +{ + /// Opaque cursor for the next page, if the server has more resources. + [JsonPropertyName("nextCursor")] + public string? NextCursor { get; set; } + + /// Resources advertised by the server (proxied MCP `resources/list`). + [JsonPropertyName("resources")] + public IList Resources { get => field ??= []; set; } +} + +/// MCP server whose resources to enumerate. +[Experimental(Diagnostics.Experimental)] +internal sealed class McpResourcesListRequest +{ + /// Opaque MCP pagination cursor from a prior `nextCursor` value. + [JsonPropertyName("cursor")] + public string? Cursor { get; set; } + + /// Name of the MCP server whose resources to enumerate. + [RegularExpression("^[^\\x00-\\x1f/\\x7f-\\x9f}]+(?:\\/[^\\x00-\\x1f/\\x7f-\\x9f}]+)*$")] + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Safe for generated string properties: JSON Schema minLength/maxLength map to string length validation, not reflection over trimmed Count members")] + [MinLength(1)] + [JsonPropertyName("serverName")] + public string ServerName { get; set; } = string.Empty; + + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +/// An MCP resource template descriptor (spec `ResourceTemplate`): an RFC 6570 URI template, name, and optional title, description, MIME type, icons, annotations, and metadata. Server-provided fields outside the standard descriptor shape are exposed under `additionalProperties`. [Experimental(Diagnostics.Experimental)] -public sealed class McpAppsDiagnoseServer +public sealed class McpResourceTemplate { - /// Whether the named server is currently connected. - [JsonPropertyName("connected")] - public bool Connected { get; set; } + /// Resource-template-level metadata. + [JsonPropertyName("_meta")] + public IDictionary? Meta { get; set; } - /// Up to 5 tool names with `_meta.ui` for quick inspection. - [JsonPropertyName("sampleToolNames")] - public IList SampleToolNames { get => field ??= []; set; } + /// Server-provided non-standard descriptor fields preserved from the MCP response. + [JsonPropertyName("additionalProperties")] + public IDictionary? AdditionalProperties { get; set; } - /// Total tools returned by the server's tools/list. - [JsonPropertyName("toolCount")] - public double ToolCount { get; set; } + /// Model/client annotations associated with this template. + [JsonPropertyName("annotations")] + public McpResourceAnnotations? Annotations { get; set; } - /// Tools whose `_meta.ui` is populated (resourceUri and/or visibility set). - [JsonPropertyName("toolsWithUiMeta")] - public double ToolsWithUiMeta { get; set; } + /// Optional description of what this template is for. + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// Icons associated with resources matching this template. + [JsonPropertyName("icons")] + public IList? Icons { get; set; } + + /// MIME type for resources matching this template, if uniform. + [JsonPropertyName("mimeType")] + public string? MimeType { get; set; } + + /// The programmatic name of the resource template. + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Optional human-readable display title. + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// An RFC 6570 URI template for constructing resource URIs. + [JsonPropertyName("uriTemplate")] + public string UriTemplate { get; set; } = string.Empty; } -/// Diagnostic snapshot of MCP Apps wiring for the named server. +/// One page of resource templates advertised by the named MCP server. [Experimental(Diagnostics.Experimental)] -public sealed class McpAppsDiagnoseResult +public sealed class McpResourcesListTemplatesResult { - /// Capability negotiation snapshot. - [JsonPropertyName("capability")] - public McpAppsDiagnoseCapability Capability { get => field ??= new(); set; } + /// Opaque cursor for the next page, if the server has more resource templates. + [JsonPropertyName("nextCursor")] + public string? NextCursor { get; set; } - /// What the server returned for this session. - [JsonPropertyName("server")] - public McpAppsDiagnoseServer Server { get => field ??= new(); set; } + /// Resource templates advertised by the server (proxied MCP `resources/templates/list`). + [JsonPropertyName("resourceTemplates")] + public IList ResourceTemplates { get => field ??= []; set; } } -/// MCP server to diagnose MCP Apps wiring for. +/// MCP server whose resource templates to enumerate. [Experimental(Diagnostics.Experimental)] -internal sealed class McpAppsDiagnoseRequest +internal sealed class McpResourcesListTemplatesRequest { - /// MCP server to probe. + /// Opaque MCP pagination cursor from a prior `nextCursor` value. + [JsonPropertyName("cursor")] + public string? Cursor { get; set; } + + /// Name of the MCP server whose resource templates to enumerate. [RegularExpression("^[^\\x00-\\x1f/\\x7f-\\x9f}]+(?:\\/[^\\x00-\\x1f/\\x7f-\\x9f}]+)*$")] [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Safe for generated string properties: JSON Schema minLength/maxLength map to string length validation, not reflection over trimmed Count members")] [MinLength(1)] @@ -6683,6 +7170,10 @@ internal sealed class ProviderAddRequest [Experimental(Diagnostics.Experimental)] public sealed class SessionUpdateOptionsResult { + /// Number of hooks loaded from installed plugins, returned when installedPlugins is updated. + [JsonPropertyName("pluginHookCount")] + public long? PluginHookCount { get; set; } + /// Whether the operation succeeded. [JsonPropertyName("success")] public bool Success { get; set; } @@ -7054,6 +7545,10 @@ internal sealed class SessionUpdateOptionsParams [JsonPropertyName("featureFlags")] public IDictionary? FeatureFlags { get; set; } + /// Built-in subagent names to include in this session. When specified, only these built-ins are available, subject to runtime availability and exclusions. Custom agents with the same name remain available. Set to null to remove the allowlist restriction. + [JsonPropertyName("includedBuiltinAgents")] + public IList? IncludedBuiltinAgents { get; set; } + /// Full set of installed plugins for the session. Replaces the existing list; the runtime invalidates the skills cache only when the list materially changes. [JsonPropertyName("installedPlugins")] public IList? InstalledPlugins { get; set; } @@ -7935,90 +8430,6 @@ internal sealed class UpdateSubagentSettingsRequest public UpdateSubagentSettingsRequestSubagents? Subagents { get; set; } } -/// A literal choice the command input accepts, with a human-facing description. -[Experimental(Diagnostics.Experimental)] -public sealed class SlashCommandInputChoice -{ - /// Human-readable description shown alongside the choice. - [JsonPropertyName("description")] - public string Description { get; set; } = string.Empty; - - /// The literal choice value (e.g. 'on', 'off', 'show'). - [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; -} - -/// Optional unstructured input hint. -[Experimental(Diagnostics.Experimental)] -public sealed class SlashCommandInput -{ - /// Optional literal choices the input accepts, each with a human-facing description; clients may render these as selectable options. - [JsonPropertyName("choices")] - public IList? Choices { get; set; } - - /// Optional completion hint for the input (e.g. 'directory' for filesystem path completion). - [JsonPropertyName("completion")] - public SlashCommandInputCompletion? Completion { get; set; } - - /// Hint to display when command input has not been provided. - [JsonPropertyName("hint")] - public string Hint { get; set; } = string.Empty; - - /// When true, clients should pass the full text after the command name as a single argument rather than splitting on whitespace. - [JsonPropertyName("preserveMultilineInput")] - public bool? PreserveMultilineInput { get; set; } - - /// When true, the command requires non-empty input; clients should render the input hint as required. - [JsonPropertyName("required")] - public bool? Required { get; set; } -} - -/// Slash-command metadata with name, aliases, description, kind, input hint, execution allowance, and schedulability. -[Experimental(Diagnostics.Experimental)] -public sealed class SlashCommandInfo -{ - /// Canonical aliases without leading slashes. - [JsonPropertyName("aliases")] - public IList? Aliases { get; set; } - - /// Whether the command may run while an agent turn is active. - [JsonPropertyName("allowDuringAgentExecution")] - public bool AllowDuringAgentExecution { get; set; } - - /// Human-readable command description. - [JsonPropertyName("description")] - public string Description { get; set; } = string.Empty; - - /// Whether the command is experimental. - [JsonPropertyName("experimental")] - public bool? Experimental { get; set; } - - /// Optional unstructured input hint. - [JsonPropertyName("input")] - public SlashCommandInput? Input { get; set; } - - /// Coarse command category for grouping and behavior: runtime built-in, skill-backed command, or SDK/client-owned command. - [JsonPropertyName("kind")] - public SlashCommandKind Kind { get; set; } - - /// Canonical command name without a leading slash. - [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; - - /// Whether the command may be the target of `/every` / `/after` schedules. Resolution happens at every tick, so only set this when the command is safe to re-invoke and produces an agent prompt. - [JsonPropertyName("schedulable")] - public bool? Schedulable { get; set; } -} - -/// Slash commands available in the session, after applying any include/exclude filters. -[Experimental(Diagnostics.Experimental)] -public sealed class CommandList -{ - /// Commands available in this session. - [JsonPropertyName("commands")] - public IList Commands { get => field ??= []; set; } -} - /// Optional filters controlling which command sources to include in the listing. [Experimental(Diagnostics.Experimental)] public sealed class CommandsListRequest @@ -8104,7 +8515,7 @@ public partial class SlashCommandInvocationResultText : SlashCommandInvocationRe public required string Text { get; set; } } -/// Slash-command invocation result that submits an agent prompt, with display prompt, optional mode, and settings-change flag. +/// Slash-command invocation result that submits an agent prompt, with display prompt, optional mode, optional user-facing notice, and settings-change flag. /// The agent-prompt variant of . [Experimental(Diagnostics.Experimental)] public partial class SlashCommandInvocationResultAgentPrompt : SlashCommandInvocationResult @@ -8122,6 +8533,11 @@ public partial class SlashCommandInvocationResultAgentPrompt : SlashCommandInvoc [JsonPropertyName("mode")] public SessionMode? Mode { get; set; } + /// Optional user-facing notice to show before the prompt is submitted. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("notice")] + public string? Notice { get; set; } + /// Prompt to submit to the agent. [JsonPropertyName("prompt")] public required string Prompt { get; set; } @@ -10434,7 +10850,7 @@ internal sealed class MetadataRecordContextChangeRequest public string SessionId { get; set; } = string.Empty; } -/// Update the session's working directory. Used by the host when the user explicitly changes cwd (e.g., the `/cd` slash command). The host is responsible for `process.chdir` and any related side-effects (file index, etc.); this method only updates the session's own recorded path. +/// Update the session's working directory. Used by the host when the user explicitly changes cwd (e.g., the `/cd` slash command). The host is responsible for any related side-effects (file index, etc.); it does NOT change the process working directory (a session's cwd is per-session, not process-global). For local sessions the runtime validates the target first (an absolute path that exists on disk) and re-bases the permission primary directory; a rejected validation fails the call before anything is mutated, persisted, or emitted. Location-scoped permission rules are then re-keyed to the new directory (best-effort). Remote sessions only record the path. [Experimental(Diagnostics.Experimental)] public sealed class MetadataSetWorkingDirectoryResult { @@ -10443,7 +10859,7 @@ public sealed class MetadataSetWorkingDirectoryResult public string WorkingDirectory { get; set; } = string.Empty; } -/// Absolute path to set as the session's new working directory. +/// Absolute path to set as the session's new working directory. For local sessions the path must be absolute and exist on disk: it is validated before any session state changes, and a failing validation rejects the call with nothing mutated, persisted, or emitted. Remote sessions record the path as-is. [Experimental(Diagnostics.Experimental)] internal sealed class MetadataSetWorkingDirectoryRequest { @@ -11141,7 +11557,7 @@ public sealed class RegisterEventInterestResult [Experimental(Diagnostics.Experimental)] internal sealed class RegisterEventInterestParams { - /// The event type the consumer wants the runtime to treat as 'observed' for behavior-switching gating. Some runtime code paths inspect whether any consumer is interested in a specific event type and choose a different implementation accordingly (e.g. `mcp.oauth_required`: when interest is registered the runtime delegates OAuth token acquisition to the consumer; when no interest is registered OAuth-required servers become needs-auth). SDK clients that long-poll events do NOT automatically appear as listeners to these gating checks — they must explicitly call `registerInterest` for each event type they want the runtime to count as having a consumer. Multiple registrations for the same event type from the same or different consumers are tracked independently and must each be released. See: `mcp.oauth_required`, `sampling.requested`, `auto_mode_switch.requested`, `session_limits_exhausted.requested`, `user_input.requested`, `elicitation.requested`, `command.queued`, `exit_plan_mode.requested`. + /// The event type the consumer wants the runtime to treat as 'observed' for behavior-switching gating. Some runtime code paths inspect whether any consumer is interested in a specific event type and choose a different implementation accordingly (e.g. `mcp.oauth_required`: when interest is registered the runtime delegates interactive OAuth token acquisition to the consumer via `mcp.oauth_required` events; when no interest is registered the runtime still attempts non-interactive reconnect from cached or refreshable tokens, and only marks the server `needs-auth` if usable credentials are unavailable — it does not open a browser or start interactive OAuth without a consumer). SDK clients that long-poll events do NOT automatically appear as listeners to these gating checks — they must explicitly call `registerInterest` for each event type they want the runtime to count as having a consumer. Multiple registrations for the same event type from the same or different consumers are tracked independently and must each be released. See: `mcp.oauth_required`, `sampling.requested`, `auto_mode_switch.requested`, `session_limits_exhausted.requested`, `user_input.requested`, `elicitation.requested`, `command.queued`, `exit_plan_mode.requested`. [JsonPropertyName("eventType")] public string EventType { get; set; } = string.Empty; @@ -13005,14 +13421,140 @@ public InstructionDiscoveryPathLocation(string value) /// Returns a value indicating whether two instances are equivalent. public static bool operator ==(InstructionDiscoveryPathLocation left, InstructionDiscoveryPathLocation right) => left.Equals(right); - /// Returns a value indicating whether two instances are not equivalent. - public static bool operator !=(InstructionDiscoveryPathLocation left, InstructionDiscoveryPathLocation right) => !(left == right); + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(InstructionDiscoveryPathLocation left, InstructionDiscoveryPathLocation right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is InstructionDiscoveryPathLocation other && Equals(other); + + /// + public bool Equals(InstructionDiscoveryPathLocation other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override InstructionDiscoveryPathLocation Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, InstructionDiscoveryPathLocation value, JsonSerializerOptions options) + { + GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(InstructionDiscoveryPathLocation)); + } + } +} + + +/// Optional completion hint for the input (e.g. 'directory' for filesystem path completion). +[Experimental(Diagnostics.Experimental)] +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct SlashCommandInputCompletion : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public SlashCommandInputCompletion(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Input should complete filesystem directories. + public static SlashCommandInputCompletion Directory { get; } = new("directory"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(SlashCommandInputCompletion left, SlashCommandInputCompletion right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(SlashCommandInputCompletion left, SlashCommandInputCompletion right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is SlashCommandInputCompletion other && Equals(other); + + /// + public bool Equals(SlashCommandInputCompletion other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override SlashCommandInputCompletion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, SlashCommandInputCompletion value, JsonSerializerOptions options) + { + GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(SlashCommandInputCompletion)); + } + } +} + + +/// Coarse command category for grouping and behavior: runtime built-in, skill-backed command, or SDK/client-owned command. +[Experimental(Diagnostics.Experimental)] +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct SlashCommandKind : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public SlashCommandKind(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Command implemented by the runtime. + public static SlashCommandKind Builtin { get; } = new("builtin"); + + /// Command backed by a skill. + public static SlashCommandKind Skill { get; } = new("skill"); + + /// Command registered by an SDK client or extension. + public static SlashCommandKind Client { get; } = new("client"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(SlashCommandKind left, SlashCommandKind right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(SlashCommandKind left, SlashCommandKind right) => !(left == right); /// - public override bool Equals(object? obj) => obj is InstructionDiscoveryPathLocation other && Equals(other); + public override bool Equals(object? obj) => obj is SlashCommandKind other && Equals(other); /// - public bool Equals(InstructionDiscoveryPathLocation other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + public bool Equals(SlashCommandKind other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); /// public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); @@ -13020,20 +13562,20 @@ public InstructionDiscoveryPathLocation(string value) /// public override string ToString() => Value; - /// Provides a for serializing instances. + /// Provides a for serializing instances. [EditorBrowsable(EditorBrowsableState.Never)] - public sealed class Converter : JsonConverter + public sealed class Converter : JsonConverter { /// - public override InstructionDiscoveryPathLocation Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override SlashCommandKind Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { return new(GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); } /// - public override void Write(Utf8JsonWriter writer, InstructionDiscoveryPathLocation value, JsonSerializerOptions options) + public override void Write(Utf8JsonWriter writer, SlashCommandKind value, JsonSerializerOptions options) { - GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(InstructionDiscoveryPathLocation)); + GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(SlashCommandKind)); } } } @@ -15040,6 +15582,69 @@ public override void Write(Utf8JsonWriter writer, TaskShellInfoAttachmentMode va } +/// Consumer allowed to call an MCP tool. +[Experimental(Diagnostics.Experimental)] +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct McpToolUiVisibility : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public McpToolUiVisibility(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// The model may call the tool. + public static McpToolUiVisibility Model { get; } = new("model"); + + /// An MCP App view may call the tool. + public static McpToolUiVisibility App { get; } = new("app"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(McpToolUiVisibility left, McpToolUiVisibility right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(McpToolUiVisibility left, McpToolUiVisibility right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is McpToolUiVisibility other && Equals(other); + + /// + public bool Equals(McpToolUiVisibility other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override McpToolUiVisibility Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, McpToolUiVisibility value, JsonSerializerOptions options) + { + GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(McpToolUiVisibility)); + } + } +} + + /// Outcome of the sampling inference. 'success' produced a response; 'failure' encountered an error (including agent-side rejection by content filter or criteria); 'cancelled' the caller cancelled this execution via cancelSamplingExecution. [Experimental(Diagnostics.Experimental)] [JsonConverter(typeof(Converter))] @@ -16816,132 +17421,6 @@ public override void Write(Utf8JsonWriter writer, SubagentSettingsEntryContextTi } -/// Optional completion hint for the input (e.g. 'directory' for filesystem path completion). -[Experimental(Diagnostics.Experimental)] -[JsonConverter(typeof(Converter))] -[DebuggerDisplay("{Value,nq}")] -public readonly struct SlashCommandInputCompletion : IEquatable -{ - private readonly string? _value; - - /// Initializes a new instance of the struct. - /// The value to associate with this . - [JsonConstructor] - public SlashCommandInputCompletion(string value) - { - ArgumentException.ThrowIfNullOrWhiteSpace(value); - _value = value; - } - - /// Gets the value associated with this . - public string Value => _value ?? string.Empty; - - /// Input should complete filesystem directories. - public static SlashCommandInputCompletion Directory { get; } = new("directory"); - - /// Returns a value indicating whether two instances are equivalent. - public static bool operator ==(SlashCommandInputCompletion left, SlashCommandInputCompletion right) => left.Equals(right); - - /// Returns a value indicating whether two instances are not equivalent. - public static bool operator !=(SlashCommandInputCompletion left, SlashCommandInputCompletion right) => !(left == right); - - /// - public override bool Equals(object? obj) => obj is SlashCommandInputCompletion other && Equals(other); - - /// - public bool Equals(SlashCommandInputCompletion other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); - - /// - public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); - - /// - public override string ToString() => Value; - - /// Provides a for serializing instances. - [EditorBrowsable(EditorBrowsableState.Never)] - public sealed class Converter : JsonConverter - { - /// - public override SlashCommandInputCompletion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - return new(GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); - } - - /// - public override void Write(Utf8JsonWriter writer, SlashCommandInputCompletion value, JsonSerializerOptions options) - { - GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(SlashCommandInputCompletion)); - } - } -} - - -/// Coarse command category for grouping and behavior: runtime built-in, skill-backed command, or SDK/client-owned command. -[Experimental(Diagnostics.Experimental)] -[JsonConverter(typeof(Converter))] -[DebuggerDisplay("{Value,nq}")] -public readonly struct SlashCommandKind : IEquatable -{ - private readonly string? _value; - - /// Initializes a new instance of the struct. - /// The value to associate with this . - [JsonConstructor] - public SlashCommandKind(string value) - { - ArgumentException.ThrowIfNullOrWhiteSpace(value); - _value = value; - } - - /// Gets the value associated with this . - public string Value => _value ?? string.Empty; - - /// Command implemented by the runtime. - public static SlashCommandKind Builtin { get; } = new("builtin"); - - /// Command backed by a skill. - public static SlashCommandKind Skill { get; } = new("skill"); - - /// Command registered by an SDK client or extension. - public static SlashCommandKind Client { get; } = new("client"); - - /// Returns a value indicating whether two instances are equivalent. - public static bool operator ==(SlashCommandKind left, SlashCommandKind right) => left.Equals(right); - - /// Returns a value indicating whether two instances are not equivalent. - public static bool operator !=(SlashCommandKind left, SlashCommandKind right) => !(left == right); - - /// - public override bool Equals(object? obj) => obj is SlashCommandKind other && Equals(other); - - /// - public bool Equals(SlashCommandKind other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); - - /// - public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); - - /// - public override string ToString() => Value; - - /// Provides a for serializing instances. - [EditorBrowsable(EditorBrowsableState.Never)] - public sealed class Converter : JsonConverter - { - /// - public override SlashCommandKind Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - return new(GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); - } - - /// - public override void Write(Utf8JsonWriter writer, SlashCommandKind value, JsonSerializerOptions options) - { - GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(SlashCommandKind)); - } - } -} - - /// The user's response: accept (submitted), decline (rejected), or cancel (dismissed). [Experimental(Diagnostics.Experimental)] [JsonConverter(typeof(Converter))] @@ -18700,6 +19179,12 @@ internal async Task ConnectAsync(string? token = null, bool? enab Interlocked.CompareExchange(ref field, new(_rpc), null) ?? field; + /// Commands APIs. + public ServerCommandsApi Commands => + field ?? + Interlocked.CompareExchange(ref field, new(_rpc), null) ?? + field; + /// User APIs. public ServerUserApi User => field ?? @@ -19099,13 +19584,14 @@ public async Task ListAsync(CancellationToken cancellatio /// Registers a new marketplace from a source (owner/repo, URL, or local path). /// Marketplace source. Accepts the same forms as the CLI: "owner/repo" or "owner/repo#ref" (GitHub), an http/https/ssh URL (optionally with #ref), a git scp-style URL (user@host:path), or a local path. The marketplace's own name (from its manifest) is used as the registration key. + /// Working directory used to resolve relative local paths in `source`. Defaults to the server's current working directory. /// The to monitor for cancellation requests. The default is . /// Result of registering a new marketplace. - public async Task AddAsync(string source, CancellationToken cancellationToken = default) + public async Task AddAsync(string source, string? workingDirectory = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(source); - var request = new PluginsMarketplacesAddRequest { Source = source }; + var request = new PluginsMarketplacesAddRequest { Source = source, WorkingDirectory = workingDirectory }; return await CopilotClient.InvokeRpcAsync(_rpc, "plugins.marketplaces.add", [request], cancellationToken); } @@ -19277,6 +19763,26 @@ public async Task GetDiscoveryPathsAsync(IListProvides server-scoped Commands APIs. +[Experimental(Diagnostics.Experimental)] +public sealed class ServerCommandsApi +{ + private readonly JsonRpc _rpc; + + internal ServerCommandsApi(JsonRpc rpc) + { + _rpc = rpc; + } + + /// Lists the well-known built-in slash commands that work as the first message in a new session (e.g. /plan, /env), without requiring an active session. Commands that depend on session state, authentication, or a synced session are omitted. + /// The to monitor for cancellation requests. The default is . + /// Slash commands available in the session, after applying any include/exclude filters. + public async Task ListAsync(CancellationToken cancellationToken = default) + { + return await CopilotClient.InvokeRpcAsync(_rpc, "commands.list", [], cancellationToken); + } +} + /// Provides server-scoped User APIs. [Experimental(Diagnostics.Experimental)] public sealed class ServerUserApi @@ -20065,6 +20571,27 @@ public async Task SendAsync(string prompt, string? displayPrompt = n return await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.send", [request], cancellationToken); } + /// Sends zero or more user messages to the session in a single turn and returns their message IDs. All provided messages are appended to the conversation in order, then exactly one agent turn runs over the resulting history. When the list is empty, one turn runs over the existing history with no new user message. Remote-backed (Mission Control) sessions do not support this method and will return an error. + /// The user messages to append to the conversation, in order. May be empty, in which case a single turn runs over the existing history with no new user message. + /// How to deliver the messages. `enqueue` (default) appends to the message queue. `immediate` interjects during an in-progress turn. + /// If true, adds the messages to the front of the queue instead of the end. + /// The UI mode the agent was in when these messages were sent. Defaults to the session's current mode. + /// Custom HTTP headers to include in outbound model requests for this turn. Merged with session-level provider headers; per-turn headers augment and overwrite session-level headers with the same key. + /// W3C Trace Context traceparent header for distributed tracing of this agent turn. + /// W3C Trace Context tracestate header for distributed tracing. + /// If true, await completion of the agentic loop for this turn before returning. Defaults to false (fire-and-forget). When true, the result still contains the same `messageIds`; the caller can rely on the agent having processed the messages before the call resolves. + /// The to monitor for cancellation requests. The default is . + /// Result of sending zero or more user messages. + [Experimental(Diagnostics.Experimental)] + public async Task SendMessagesAsync(IList messages, SendMode? mode = null, bool? prepend = null, SendAgentMode? agentMode = null, IDictionary? requestHeaders = null, string? traceparent = null, string? tracestate = null, bool? wait = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(messages); + _session.ThrowIfDisposed(); + + var request = new SendMessagesRequest { SessionId = _session.SessionId, Messages = messages, Mode = mode, Prepend = prepend, AgentMode = agentMode, RequestHeaders = requestHeaders, Traceparent = traceparent, Tracestate = tracestate, Wait = wait }; + return await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.sendMessages", [request], cancellationToken); + } + /// Aborts the current agent turn. /// Finite reason code describing why the current turn was aborted. /// The to monitor for cancellation requests. The default is . @@ -21002,7 +21529,7 @@ public async Task ListAsync(CancellationToken cancellationToken = return await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.mcp.list", [request], cancellationToken); } - /// Lists the tools exposed by a connected MCP server on this session's host. + /// Lists the tools exposed by a connected MCP server on this session's host. This performs a live `tools/list` request. Tool UI metadata is returned independently of whether MCP Apps rendering is enabled for the session. /// Name of the connected MCP server whose tools to list. /// The to monitor for cancellation requests. The default is . /// Tools exposed by the connected MCP server. Throws when the server is not connected. @@ -21130,11 +21657,11 @@ internal async Task ConfigureGitHubAsync(object authIn return await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.mcp.configureGitHub", [request], cancellationToken); } - /// Starts an individual MCP server on the session's host. + /// Starts an individual MCP server on the live session from a caller-supplied config. Session-scoped and ephemeral: the server is added to this session's running set only and is reaped when the session ends. Does NOT modify persistent user configuration (`mcp.config.*`), so it does not affect future sessions. The server surfaces through `session.mcp.list` and the `session.mcp_servers_loaded` / `session.mcp_server_status_changed` events like any other server. /// Name of the MCP server to start. - /// Opaque server configuration (MCPServerConfig). Marked internal: an in-process runtime shape supplied only by in-process CLI callers. + /// MCP server configuration (stdio process or remote HTTP/SSE). /// The to monitor for cancellation requests. The default is . - internal async Task StartServerAsync(string serverName, object config, CancellationToken cancellationToken = default) + public async Task StartServerAsync(string serverName, object config, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(serverName); ArgumentNullException.ThrowIfNull(config); @@ -21144,17 +21671,16 @@ internal async Task StartServerAsync(string serverName, object config, Cancellat await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.mcp.startServer", [request], cancellationToken); } - /// Restarts an individual MCP server on the session's host (stops then starts). + /// Restarts an individual MCP server on the live session (stops then starts). Omit `config` for a config-free restart-by-name of an already-configured server; supply `config` to restart with a replacement configuration. Session-scoped and ephemeral: does NOT modify persistent user configuration (`mcp.config.*`). /// Name of the MCP server to restart. - /// Opaque server configuration (MCPServerConfig). Marked internal: an in-process runtime shape supplied only by in-process CLI callers. + /// Replacement MCP server configuration (stdio process or remote HTTP/SSE). Omit to restart the server with its already-registered configuration (config-free restart-by-name). /// The to monitor for cancellation requests. The default is . - internal async Task RestartServerAsync(string serverName, object config, CancellationToken cancellationToken = default) + public async Task RestartServerAsync(string serverName, object? config = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(serverName); - ArgumentNullException.ThrowIfNull(config); _session.ThrowIfDisposed(); - var request = new McpRestartServerRequest { SessionId = _session.SessionId, ServerName = serverName, Config = CopilotClient.ToJsonElementForWire(config)!.Value }; + var request = new McpRestartServerRequest { SessionId = _session.SessionId, ServerName = serverName, Config = CopilotClient.ToJsonElementForWire(config) }; await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.mcp.restartServer", [request], cancellationToken); } @@ -21230,6 +21756,12 @@ public async Task IsServerRunningAsync(string serverNa field ?? Interlocked.CompareExchange(ref field, new(_session), null) ?? field; + + /// Resources APIs. + public McpResourcesApi Resources => + field ?? + Interlocked.CompareExchange(ref field, new(_session), null) ?? + field; } /// Provides session-scoped McpOauth APIs. @@ -21402,6 +21934,61 @@ public async Task DiagnoseAsync(string serverName, Cancel } } +/// Provides session-scoped McpResources APIs. +[Experimental(Diagnostics.Experimental)] +public sealed class McpResourcesApi +{ + private readonly CopilotSession _session; + + internal McpResourcesApi(CopilotSession session) + { + _session = session; + } + + /// Fetch an MCP resource from a connected server by URI (proxies MCP `resources/read`). + /// Name of the MCP server hosting the resource. + /// Resource URI. + /// The to monitor for cancellation requests. The default is . + /// Resource contents returned by the MCP server. + public async Task ReadAsync(string serverName, string uri, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(serverName); + ArgumentNullException.ThrowIfNull(uri); + _session.ThrowIfDisposed(); + + var request = new McpResourcesReadRequest { SessionId = _session.SessionId, ServerName = serverName, Uri = uri }; + return await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.mcp.resources.read", [request], cancellationToken); + } + + /// Enumerate one page of resources a connected MCP server exposes (proxies MCP `resources/list`). Pass `cursor` to continue from a prior result's `nextCursor`. + /// Name of the MCP server whose resources to enumerate. + /// Opaque MCP pagination cursor from a prior `nextCursor` value. + /// The to monitor for cancellation requests. The default is . + /// One page of resources advertised by the named MCP server. + public async Task ListAsync(string serverName, string? cursor = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(serverName); + _session.ThrowIfDisposed(); + + var request = new McpResourcesListRequest { SessionId = _session.SessionId, ServerName = serverName, Cursor = cursor }; + return await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.mcp.resources.list", [request], cancellationToken); + } + + /// Enumerate one page of resource templates a connected MCP server exposes (proxies MCP `resources/templates/list`). Pass `cursor` to continue from a prior result's `nextCursor`. + /// Name of the MCP server whose resource templates to enumerate. + /// Opaque MCP pagination cursor from a prior `nextCursor` value. + /// The to monitor for cancellation requests. The default is . + /// One page of resource templates advertised by the named MCP server. + public async Task ListTemplatesAsync(string serverName, string? cursor = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(serverName); + _session.ThrowIfDisposed(); + + var request = new McpResourcesListTemplatesRequest { SessionId = _session.SessionId, ServerName = serverName, Cursor = cursor }; + return await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.mcp.resources.listTemplates", [request], cancellationToken); + } +} + /// Provides session-scoped Plugins APIs. [Experimental(Diagnostics.Experimental)] public sealed class PluginsApi @@ -21500,6 +22087,7 @@ internal OptionsApi(CopilotSession session) /// Absolute working-directory path for shell tools. /// Allowlist of tool names available to this session. /// Denylist of tool names for this session. + /// Built-in subagent names to include in this session. When specified, only these built-ins are available, subject to runtime availability and exclusions. Custom agents with the same name remain available. Set to null to remove the allowlist restriction. /// Built-in subagent names to exclude from this session. Excluded built-ins are hidden from agent discovery and cannot be dispatched unless a custom agent with the same name is available. /// Controls how availableTools (allowlist) and excludedTools (denylist) combine when both are set. /// Whether shell-script safety heuristics are enabled. @@ -21541,11 +22129,11 @@ internal OptionsApi(CopilotSession session) /// Optional session limits. Pass null to clear the session limits. /// The to monitor for cancellation requests. The default is . /// Indicates whether the session options patch was applied successfully. - public async Task UpdateAsync(string? model = null, ModelCapabilitiesOverride? modelCapabilitiesOverrides = null, string? reasoningEffort = null, OptionsUpdateReasoningSummary? reasoningSummary = null, Verbosity? verbosity = null, string? clientName = null, string? lspClientName = null, string? integrationId = null, IDictionary? featureFlags = null, bool? isExperimentalMode = null, ProviderConfig? provider = null, CapiSessionOptions? capi = null, string? workingDirectory = null, IList? availableTools = null, IList? excludedTools = null, IList? excludedBuiltinAgents = null, OptionsUpdateToolFilterPrecedence? toolFilterPrecedence = null, bool? enableScriptSafety = null, string? shellInitProfile = null, IList? shellProcessFlags = null, SandboxConfig? sandboxConfig = null, bool? logInteractiveShells = null, OptionsUpdateEnvValueMode? envValueMode = null, bool? allowAllMcpServerInstructions = null, IList? skillDirectories = null, IList? disabledSkills = null, bool? enableOnDemandInstructionDiscovery = null, long? maxInlineBinaryBytes = null, IList? installedPlugins = null, bool? customAgentsLocalOnly = null, bool? suppressCustomAgentPrompt = null, bool? skipCustomInstructions = null, IList? disabledInstructionSources = null, bool? coauthorEnabled = null, string? trajectoryFile = null, bool? enableStreaming = null, string? copilotUrl = null, bool? askUserDisabled = null, bool? continueOnAutoMode = null, bool? runningInInteractiveMode = null, bool? enableReasoningSummaries = null, string? agentContext = null, string? eventsLogDirectory = null, IList? additionalContentExclusionPolicies = null, bool? manageScheduleEnabled = null, IList? sessionCapabilities = null, bool? skipEmbeddingRetrieval = null, string? organizationCustomInstructions = null, bool? enableFileHooks = null, bool? enableHostGitOperations = null, bool? enableSessionStore = null, bool? enableSkills = null, OptionsUpdateContextTier? contextTier = null, SessionLimitsConfig? sessionLimits = null, CancellationToken cancellationToken = default) + public async Task UpdateAsync(string? model = null, ModelCapabilitiesOverride? modelCapabilitiesOverrides = null, string? reasoningEffort = null, OptionsUpdateReasoningSummary? reasoningSummary = null, Verbosity? verbosity = null, string? clientName = null, string? lspClientName = null, string? integrationId = null, IDictionary? featureFlags = null, bool? isExperimentalMode = null, ProviderConfig? provider = null, CapiSessionOptions? capi = null, string? workingDirectory = null, IList? availableTools = null, IList? excludedTools = null, IList? includedBuiltinAgents = null, IList? excludedBuiltinAgents = null, OptionsUpdateToolFilterPrecedence? toolFilterPrecedence = null, bool? enableScriptSafety = null, string? shellInitProfile = null, IList? shellProcessFlags = null, SandboxConfig? sandboxConfig = null, bool? logInteractiveShells = null, OptionsUpdateEnvValueMode? envValueMode = null, bool? allowAllMcpServerInstructions = null, IList? skillDirectories = null, IList? disabledSkills = null, bool? enableOnDemandInstructionDiscovery = null, long? maxInlineBinaryBytes = null, IList? installedPlugins = null, bool? customAgentsLocalOnly = null, bool? suppressCustomAgentPrompt = null, bool? skipCustomInstructions = null, IList? disabledInstructionSources = null, bool? coauthorEnabled = null, string? trajectoryFile = null, bool? enableStreaming = null, string? copilotUrl = null, bool? askUserDisabled = null, bool? continueOnAutoMode = null, bool? runningInInteractiveMode = null, bool? enableReasoningSummaries = null, string? agentContext = null, string? eventsLogDirectory = null, IList? additionalContentExclusionPolicies = null, bool? manageScheduleEnabled = null, IList? sessionCapabilities = null, bool? skipEmbeddingRetrieval = null, string? organizationCustomInstructions = null, bool? enableFileHooks = null, bool? enableHostGitOperations = null, bool? enableSessionStore = null, bool? enableSkills = null, OptionsUpdateContextTier? contextTier = null, SessionLimitsConfig? sessionLimits = null, CancellationToken cancellationToken = default) { _session.ThrowIfDisposed(); - var request = new SessionUpdateOptionsParams { SessionId = _session.SessionId, Model = model, ModelCapabilitiesOverrides = modelCapabilitiesOverrides, ReasoningEffort = reasoningEffort, ReasoningSummary = reasoningSummary, Verbosity = verbosity, ClientName = clientName, LspClientName = lspClientName, IntegrationId = integrationId, FeatureFlags = featureFlags, IsExperimentalMode = isExperimentalMode, Provider = provider, Capi = capi, WorkingDirectory = workingDirectory, AvailableTools = availableTools, ExcludedTools = excludedTools, ExcludedBuiltinAgents = excludedBuiltinAgents, ToolFilterPrecedence = toolFilterPrecedence, EnableScriptSafety = enableScriptSafety, ShellInitProfile = shellInitProfile, ShellProcessFlags = shellProcessFlags, SandboxConfig = sandboxConfig, LogInteractiveShells = logInteractiveShells, EnvValueMode = envValueMode, AllowAllMcpServerInstructions = allowAllMcpServerInstructions, SkillDirectories = skillDirectories, DisabledSkills = disabledSkills, EnableOnDemandInstructionDiscovery = enableOnDemandInstructionDiscovery, MaxInlineBinaryBytes = maxInlineBinaryBytes, InstalledPlugins = installedPlugins, CustomAgentsLocalOnly = customAgentsLocalOnly, SuppressCustomAgentPrompt = suppressCustomAgentPrompt, SkipCustomInstructions = skipCustomInstructions, DisabledInstructionSources = disabledInstructionSources, CoauthorEnabled = coauthorEnabled, TrajectoryFile = trajectoryFile, EnableStreaming = enableStreaming, CopilotUrl = copilotUrl, AskUserDisabled = askUserDisabled, ContinueOnAutoMode = continueOnAutoMode, RunningInInteractiveMode = runningInInteractiveMode, EnableReasoningSummaries = enableReasoningSummaries, AgentContext = agentContext, EventsLogDirectory = eventsLogDirectory, AdditionalContentExclusionPolicies = additionalContentExclusionPolicies, ManageScheduleEnabled = manageScheduleEnabled, SessionCapabilities = sessionCapabilities, SkipEmbeddingRetrieval = skipEmbeddingRetrieval, OrganizationCustomInstructions = organizationCustomInstructions, EnableFileHooks = enableFileHooks, EnableHostGitOperations = enableHostGitOperations, EnableSessionStore = enableSessionStore, EnableSkills = enableSkills, ContextTier = contextTier, SessionLimits = sessionLimits }; + var request = new SessionUpdateOptionsParams { SessionId = _session.SessionId, Model = model, ModelCapabilitiesOverrides = modelCapabilitiesOverrides, ReasoningEffort = reasoningEffort, ReasoningSummary = reasoningSummary, Verbosity = verbosity, ClientName = clientName, LspClientName = lspClientName, IntegrationId = integrationId, FeatureFlags = featureFlags, IsExperimentalMode = isExperimentalMode, Provider = provider, Capi = capi, WorkingDirectory = workingDirectory, AvailableTools = availableTools, ExcludedTools = excludedTools, IncludedBuiltinAgents = includedBuiltinAgents, ExcludedBuiltinAgents = excludedBuiltinAgents, ToolFilterPrecedence = toolFilterPrecedence, EnableScriptSafety = enableScriptSafety, ShellInitProfile = shellInitProfile, ShellProcessFlags = shellProcessFlags, SandboxConfig = sandboxConfig, LogInteractiveShells = logInteractiveShells, EnvValueMode = envValueMode, AllowAllMcpServerInstructions = allowAllMcpServerInstructions, SkillDirectories = skillDirectories, DisabledSkills = disabledSkills, EnableOnDemandInstructionDiscovery = enableOnDemandInstructionDiscovery, MaxInlineBinaryBytes = maxInlineBinaryBytes, InstalledPlugins = installedPlugins, CustomAgentsLocalOnly = customAgentsLocalOnly, SuppressCustomAgentPrompt = suppressCustomAgentPrompt, SkipCustomInstructions = skipCustomInstructions, DisabledInstructionSources = disabledInstructionSources, CoauthorEnabled = coauthorEnabled, TrajectoryFile = trajectoryFile, EnableStreaming = enableStreaming, CopilotUrl = copilotUrl, AskUserDisabled = askUserDisabled, ContinueOnAutoMode = continueOnAutoMode, RunningInInteractiveMode = runningInInteractiveMode, EnableReasoningSummaries = enableReasoningSummaries, AgentContext = agentContext, EventsLogDirectory = eventsLogDirectory, AdditionalContentExclusionPolicies = additionalContentExclusionPolicies, ManageScheduleEnabled = manageScheduleEnabled, SessionCapabilities = sessionCapabilities, SkipEmbeddingRetrieval = skipEmbeddingRetrieval, OrganizationCustomInstructions = organizationCustomInstructions, EnableFileHooks = enableFileHooks, EnableHostGitOperations = enableHostGitOperations, EnableSessionStore = enableSessionStore, EnableSkills = enableSkills, ContextTier = contextTier, SessionLimits = sessionLimits }; return await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.options.update", [request], cancellationToken); } } @@ -22443,10 +23031,10 @@ public async Task RecordContextChangeAsync(Se return await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.metadata.recordContextChange", [request], cancellationToken); } - /// Updates the session's recorded working directory. + /// Updates the session's working directory. For local sessions the target is validated first (an absolute path that exists on disk) and the permission primary directory is re-based; a rejected validation fails the call before any session state changes. /// Absolute path to set as the session's working directory. The runtime updates the session's recorded cwd so subsequent operations (shell tools, file lookups, telemetry) anchor to it. /// The to monitor for cancellation requests. The default is . - /// Update the session's working directory. Used by the host when the user explicitly changes cwd (e.g., the `/cd` slash command). The host is responsible for `process.chdir` and any related side-effects (file index, etc.); this method only updates the session's own recorded path. + /// Update the session's working directory. Used by the host when the user explicitly changes cwd (e.g., the `/cd` slash command). The host is responsible for any related side-effects (file index, etc.); it does NOT change the process working directory (a session's cwd is per-session, not process-global). For local sessions the runtime validates the target first (an absolute path that exists on disk) and re-bases the permission primary directory; a rejected validation fails the call before anything is mutated, persisted, or emitted. Location-scoped permission rules are then re-keyed to the new directory (best-effort). Remote sessions only record the path. public async Task SetWorkingDirectoryAsync(string workingDirectory, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(workingDirectory); @@ -22728,7 +23316,7 @@ public async Task TailAsync(CancellationToken cancellationTo } /// Registers consumer interest in an event type for runtime gating purposes. - /// The event type the consumer wants the runtime to treat as 'observed' for behavior-switching gating. Some runtime code paths inspect whether any consumer is interested in a specific event type and choose a different implementation accordingly (e.g. `mcp.oauth_required`: when interest is registered the runtime delegates OAuth token acquisition to the consumer; when no interest is registered OAuth-required servers become needs-auth). SDK clients that long-poll events do NOT automatically appear as listeners to these gating checks — they must explicitly call `registerInterest` for each event type they want the runtime to count as having a consumer. Multiple registrations for the same event type from the same or different consumers are tracked independently and must each be released. See: `mcp.oauth_required`, `sampling.requested`, `auto_mode_switch.requested`, `session_limits_exhausted.requested`, `user_input.requested`, `elicitation.requested`, `command.queued`, `exit_plan_mode.requested`. + /// The event type the consumer wants the runtime to treat as 'observed' for behavior-switching gating. Some runtime code paths inspect whether any consumer is interested in a specific event type and choose a different implementation accordingly (e.g. `mcp.oauth_required`: when interest is registered the runtime delegates interactive OAuth token acquisition to the consumer via `mcp.oauth_required` events; when no interest is registered the runtime still attempts non-interactive reconnect from cached or refreshable tokens, and only marks the server `needs-auth` if usable credentials are unavailable — it does not open a browser or start interactive OAuth without a consumer). SDK clients that long-poll events do NOT automatically appear as listeners to these gating checks — they must explicitly call `registerInterest` for each event type they want the runtime to count as having a consumer. Multiple registrations for the same event type from the same or different consumers are tracked independently and must each be released. See: `mcp.oauth_required`, `sampling.requested`, `auto_mode_switch.requested`, `session_limits_exhausted.requested`, `user_input.requested`, `elicitation.requested`, `command.queued`, `exit_plan_mode.requested`. /// The to monitor for cancellation requests. The default is . /// Opaque handle representing an event-type interest registration. public async Task RegisterInterestAsync(string eventType, CancellationToken cancellationToken = default) @@ -23206,6 +23794,8 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(GitHub.Copilot.AssistantReasoningDeltaData), TypeInfoPropertyName = "SessionEventsAssistantReasoningDeltaData")] [JsonSerializable(typeof(GitHub.Copilot.AssistantReasoningDeltaEvent), TypeInfoPropertyName = "SessionEventsAssistantReasoningDeltaEvent")] [JsonSerializable(typeof(GitHub.Copilot.AssistantReasoningEvent), TypeInfoPropertyName = "SessionEventsAssistantReasoningEvent")] +[JsonSerializable(typeof(GitHub.Copilot.AssistantServerToolProgressData), TypeInfoPropertyName = "SessionEventsAssistantServerToolProgressData")] +[JsonSerializable(typeof(GitHub.Copilot.AssistantServerToolProgressEvent), TypeInfoPropertyName = "SessionEventsAssistantServerToolProgressEvent")] [JsonSerializable(typeof(GitHub.Copilot.AssistantStreamingDeltaData), TypeInfoPropertyName = "SessionEventsAssistantStreamingDeltaData")] [JsonSerializable(typeof(GitHub.Copilot.AssistantStreamingDeltaEvent), TypeInfoPropertyName = "SessionEventsAssistantStreamingDeltaEvent")] [JsonSerializable(typeof(GitHub.Copilot.AssistantToolCallDeltaData), TypeInfoPropertyName = "SessionEventsAssistantToolCallDeltaData")] @@ -23243,6 +23833,7 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(GitHub.Copilot.AttachmentSelectionDetailsEnd), TypeInfoPropertyName = "SessionEventsAttachmentSelectionDetailsEnd")] [JsonSerializable(typeof(GitHub.Copilot.AttachmentSelectionDetailsStart), TypeInfoPropertyName = "SessionEventsAttachmentSelectionDetailsStart")] [JsonSerializable(typeof(GitHub.Copilot.AutoApprovalRecommendation), TypeInfoPropertyName = "SessionEventsAutoApprovalRecommendation")] +[JsonSerializable(typeof(GitHub.Copilot.AutoModeResolvedReasoningBucket), TypeInfoPropertyName = "SessionEventsAutoModeResolvedReasoningBucket")] [JsonSerializable(typeof(GitHub.Copilot.AutoModeSwitchCompletedData), TypeInfoPropertyName = "SessionEventsAutoModeSwitchCompletedData")] [JsonSerializable(typeof(GitHub.Copilot.AutoModeSwitchCompletedEvent), TypeInfoPropertyName = "SessionEventsAutoModeSwitchCompletedEvent")] [JsonSerializable(typeof(GitHub.Copilot.AutoModeSwitchRequestedData), TypeInfoPropertyName = "SessionEventsAutoModeSwitchRequestedData")] @@ -23305,6 +23896,7 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(GitHub.Copilot.GitHubRepoRef), TypeInfoPropertyName = "SessionEventsGitHubRepoRef")] [JsonSerializable(typeof(GitHub.Copilot.HandoffRepository), TypeInfoPropertyName = "SessionEventsHandoffRepository")] [JsonSerializable(typeof(GitHub.Copilot.HandoffSourceType), TypeInfoPropertyName = "SessionEventsHandoffSourceType")] +[JsonSerializable(typeof(GitHub.Copilot.HeaderEntry), TypeInfoPropertyName = "SessionEventsHeaderEntry")] [JsonSerializable(typeof(GitHub.Copilot.HookEndData), TypeInfoPropertyName = "SessionEventsHookEndData")] [JsonSerializable(typeof(GitHub.Copilot.HookEndError), TypeInfoPropertyName = "SessionEventsHookEndError")] [JsonSerializable(typeof(GitHub.Copilot.HookEndEvent), TypeInfoPropertyName = "SessionEventsHookEndEvent")] @@ -23312,6 +23904,7 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(GitHub.Copilot.HookProgressEvent), TypeInfoPropertyName = "SessionEventsHookProgressEvent")] [JsonSerializable(typeof(GitHub.Copilot.HookStartData), TypeInfoPropertyName = "SessionEventsHookStartData")] [JsonSerializable(typeof(GitHub.Copilot.HookStartEvent), TypeInfoPropertyName = "SessionEventsHookStartEvent")] +[JsonSerializable(typeof(GitHub.Copilot.ManagedSettingsResolvedSource), TypeInfoPropertyName = "SessionEventsManagedSettingsResolvedSource")] [JsonSerializable(typeof(GitHub.Copilot.McpAppToolCallCompleteData), TypeInfoPropertyName = "SessionEventsMcpAppToolCallCompleteData")] [JsonSerializable(typeof(GitHub.Copilot.McpAppToolCallCompleteError), TypeInfoPropertyName = "SessionEventsMcpAppToolCallCompleteError")] [JsonSerializable(typeof(GitHub.Copilot.McpAppToolCallCompleteEvent), TypeInfoPropertyName = "SessionEventsMcpAppToolCallCompleteEvent")] @@ -23326,15 +23919,19 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(GitHub.Copilot.McpOauthCompletedData), TypeInfoPropertyName = "SessionEventsMcpOauthCompletedData")] [JsonSerializable(typeof(GitHub.Copilot.McpOauthCompletedEvent), TypeInfoPropertyName = "SessionEventsMcpOauthCompletedEvent")] [JsonSerializable(typeof(GitHub.Copilot.McpOauthCompletionOutcome), TypeInfoPropertyName = "SessionEventsMcpOauthCompletionOutcome")] +[JsonSerializable(typeof(GitHub.Copilot.McpOauthHttpResponse), TypeInfoPropertyName = "SessionEventsMcpOauthHttpResponse")] [JsonSerializable(typeof(GitHub.Copilot.McpOauthRequestReason), TypeInfoPropertyName = "SessionEventsMcpOauthRequestReason")] [JsonSerializable(typeof(GitHub.Copilot.McpOauthRequiredData), TypeInfoPropertyName = "SessionEventsMcpOauthRequiredData")] [JsonSerializable(typeof(GitHub.Copilot.McpOauthRequiredEvent), TypeInfoPropertyName = "SessionEventsMcpOauthRequiredEvent")] [JsonSerializable(typeof(GitHub.Copilot.McpOauthRequiredStaticClientConfig), TypeInfoPropertyName = "SessionEventsMcpOauthRequiredStaticClientConfig")] [JsonSerializable(typeof(GitHub.Copilot.McpOauthWWWAuthenticateParams), TypeInfoPropertyName = "SessionEventsMcpOauthWWWAuthenticateParams")] +[JsonSerializable(typeof(GitHub.Copilot.McpPromptsListChangedEvent), TypeInfoPropertyName = "SessionEventsMcpPromptsListChangedEvent")] +[JsonSerializable(typeof(GitHub.Copilot.McpResourcesListChangedEvent), TypeInfoPropertyName = "SessionEventsMcpResourcesListChangedEvent")] [JsonSerializable(typeof(GitHub.Copilot.McpServerSource), TypeInfoPropertyName = "SessionEventsMcpServerSource")] [JsonSerializable(typeof(GitHub.Copilot.McpServerStatus), TypeInfoPropertyName = "SessionEventsMcpServerStatus")] [JsonSerializable(typeof(GitHub.Copilot.McpServerTransport), TypeInfoPropertyName = "SessionEventsMcpServerTransport")] [JsonSerializable(typeof(GitHub.Copilot.McpServersLoadedServer), TypeInfoPropertyName = "SessionEventsMcpServersLoadedServer")] +[JsonSerializable(typeof(GitHub.Copilot.McpToolsListChangedEvent), TypeInfoPropertyName = "SessionEventsMcpToolsListChangedEvent")] [JsonSerializable(typeof(GitHub.Copilot.ModelCallFailureBadRequestKind), TypeInfoPropertyName = "SessionEventsModelCallFailureBadRequestKind")] [JsonSerializable(typeof(GitHub.Copilot.ModelCallFailureData), TypeInfoPropertyName = "SessionEventsModelCallFailureData")] [JsonSerializable(typeof(GitHub.Copilot.ModelCallFailureEvent), TypeInfoPropertyName = "SessionEventsModelCallFailureEvent")] @@ -23688,6 +24285,17 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(McpRegisterExternalClientRequest))] [JsonSerializable(typeof(McpReloadWithConfigRequest))] [JsonSerializable(typeof(McpRemoveGitHubResult))] +[JsonSerializable(typeof(McpResource))] +[JsonSerializable(typeof(McpResourceAnnotations))] +[JsonSerializable(typeof(McpResourceContent))] +[JsonSerializable(typeof(McpResourceIcon))] +[JsonSerializable(typeof(McpResourceTemplate))] +[JsonSerializable(typeof(McpResourcesListRequest))] +[JsonSerializable(typeof(McpResourcesListResult))] +[JsonSerializable(typeof(McpResourcesListTemplatesRequest))] +[JsonSerializable(typeof(McpResourcesListTemplatesResult))] +[JsonSerializable(typeof(McpResourcesReadRequest))] +[JsonSerializable(typeof(McpResourcesReadResult))] [JsonSerializable(typeof(McpRestartServerRequest))] [JsonSerializable(typeof(McpSamplingExecutionResult))] [JsonSerializable(typeof(McpServer))] @@ -23699,6 +24307,7 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(McpStartServerRequest))] [JsonSerializable(typeof(McpStartServersResult))] [JsonSerializable(typeof(McpStopServerRequest))] +[JsonSerializable(typeof(McpToolUi))] [JsonSerializable(typeof(McpTools))] [JsonSerializable(typeof(McpUnregisterExternalClientRequest))] [JsonSerializable(typeof(MetadataContextAttributionResult))] @@ -23722,6 +24331,7 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(ModeSetRequest))] [JsonSerializable(typeof(Model))] [JsonSerializable(typeof(ModelBilling))] +[JsonSerializable(typeof(ModelBillingPromo))] [JsonSerializable(typeof(ModelBillingTokenPrices))] [JsonSerializable(typeof(ModelBillingTokenPricesLongContext))] [JsonSerializable(typeof(ModelCapabilities))] @@ -23879,6 +24489,9 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(SecretsAddFilterValuesRequest))] [JsonSerializable(typeof(SecretsAddFilterValuesResult))] [JsonSerializable(typeof(SendAttachmentsToMessageParams))] +[JsonSerializable(typeof(SendMessageItem))] +[JsonSerializable(typeof(SendMessagesRequest))] +[JsonSerializable(typeof(SendMessagesResult))] [JsonSerializable(typeof(SendRequest))] [JsonSerializable(typeof(SendResult))] [JsonSerializable(typeof(ServerAgentList))] @@ -23948,6 +24561,7 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(SessionModeGetRequest))] [JsonSerializable(typeof(SessionModelGetCurrentRequest))] [JsonSerializable(typeof(SessionModelList))] +[JsonSerializable(typeof(SessionModelPriceCategory))] [JsonSerializable(typeof(SessionNameGetRequest))] [JsonSerializable(typeof(SessionOpenResult))] [JsonSerializable(typeof(SessionPlanDeleteRequest))] diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index c3f827dec0..41b87d06ff 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -32,6 +32,7 @@ namespace GitHub.Copilot; [JsonDerivedType(typeof(AssistantMessageStartEvent), "assistant.message_start")] [JsonDerivedType(typeof(AssistantReasoningEvent), "assistant.reasoning")] [JsonDerivedType(typeof(AssistantReasoningDeltaEvent), "assistant.reasoning_delta")] +[JsonDerivedType(typeof(AssistantServerToolProgressEvent), "assistant.server_tool_progress")] [JsonDerivedType(typeof(AssistantStreamingDeltaEvent), "assistant.streaming_delta")] [JsonDerivedType(typeof(AssistantToolCallDeltaEvent), "assistant.tool_call_delta")] [JsonDerivedType(typeof(AssistantTurnEndEvent), "assistant.turn_end")] @@ -58,6 +59,9 @@ namespace GitHub.Copilot; [JsonDerivedType(typeof(McpHeadersRefreshRequiredEvent), "mcp.headers_refresh_required")] [JsonDerivedType(typeof(McpOauthCompletedEvent), "mcp.oauth_completed")] [JsonDerivedType(typeof(McpOauthRequiredEvent), "mcp.oauth_required")] +[JsonDerivedType(typeof(McpPromptsListChangedEvent), "mcp.prompts.list_changed")] +[JsonDerivedType(typeof(McpResourcesListChangedEvent), "mcp.resources.list_changed")] +[JsonDerivedType(typeof(McpToolsListChangedEvent), "mcp.tools.list_changed")] [JsonDerivedType(typeof(ModelCallFailureEvent), "model.call_failure")] [JsonDerivedType(typeof(PendingMessagesModifiedEvent), "pending_messages.modified")] [JsonDerivedType(typeof(PermissionCompletedEvent), "permission.completed")] @@ -66,6 +70,7 @@ namespace GitHub.Copilot; [JsonDerivedType(typeof(SamplingRequestedEvent), "sampling.requested")] [JsonDerivedType(typeof(SessionLimitsExhaustedCompletedEvent), "session_limits_exhausted.completed")] [JsonDerivedType(typeof(SessionLimitsExhaustedRequestedEvent), "session_limits_exhausted.requested")] +[JsonDerivedType(typeof(SessionAutoModeResolvedEvent), "session.auto_mode_resolved")] [JsonDerivedType(typeof(SessionAutopilotObjectiveChangedEvent), "session.autopilot_objective_changed")] [JsonDerivedType(typeof(SessionBackgroundTasksChangedEvent), "session.background_tasks_changed")] [JsonDerivedType(typeof(SessionBinaryAssetEvent), "session.binary_asset")] @@ -86,6 +91,7 @@ namespace GitHub.Copilot; [JsonDerivedType(typeof(SessionHandoffEvent), "session.handoff")] [JsonDerivedType(typeof(SessionIdleEvent), "session.idle")] [JsonDerivedType(typeof(SessionInfoEvent), "session.info")] +[JsonDerivedType(typeof(SessionManagedSettingsResolvedEvent), "session.managed_settings_resolved")] [JsonDerivedType(typeof(SessionMcpServerStatusChangedEvent), "session.mcp_server_status_changed")] [JsonDerivedType(typeof(SessionMcpServersLoadedEvent), "session.mcp_servers_loaded")] [JsonDerivedType(typeof(SessionModeChangedEvent), "session.mode_changed")] @@ -598,6 +604,19 @@ public sealed partial class AssistantIntentEvent : SessionEvent public required AssistantIntentData Data { get; set; } } +/// Live progress signal for a provider-hosted server tool (e.g. hosted web search) while it runs, before the finalized serverTools envelope lands on the terminal assistant.message. +/// Represents the assistant.server_tool_progress event. +public sealed partial class AssistantServerToolProgressEvent : SessionEvent +{ + /// + [JsonIgnore] + public override string Type => "assistant.server_tool_progress"; + + /// The assistant.server_tool_progress event payload. + [JsonPropertyName("data")] + public required AssistantServerToolProgressData Data { get; set; } +} + /// Assistant reasoning content for timeline display with complete thinking text. /// Represents the assistant.reasoning event. public sealed partial class AssistantReasoningEvent : SessionEvent @@ -1262,6 +1281,34 @@ public sealed partial class SessionLimitsExhaustedCompletedEvent : SessionEvent public required SessionLimitsExhaustedCompletedData Data { get; set; } } +/// Auto Intent resolution: the concrete model the session settled on for the first prompt of an auto-mode session, and why. Lets SDK clients render the chosen model and the full reason it was picked. The core selection fields (chosenModel/reasoningBucket/categoryScores) are stable; the routing-analytics fields (predictedLabel/confidence/candidateModels) mirror the upstream intent service and may evolve, hence the event's experimental stability. +/// Represents the session.auto_mode_resolved event. +[Experimental(Diagnostics.Experimental)] +public sealed partial class SessionAutoModeResolvedEvent : SessionEvent +{ + /// + [JsonIgnore] + public override string Type => "session.auto_mode_resolved"; + + /// The session.auto_mode_resolved event payload. + [JsonPropertyName("data")] + public required SessionAutoModeResolvedData Data { get; set; } +} + +/// Enterprise managed-settings resolution: the effective managed settings the session applied and where they came from, so SDK clients can show users what is enterprise-managed and by which authority. Fires whenever managed policy is (re)applied — at session start, on resume, and on account switch. This is an ephemeral live snapshot (delivered to subscribers but not persisted to the session event log), because at session start it resolves before `session.start` is emitted; for a session-independent pull, use the SDK `getManagedSettings()` API, which returns the identical payload. Managed settings have a single authoritative source, so the highest-authority present layer (server > device) wins wholesale; `bypassPermissionsDisabled` is deny-wins across layers. Marked experimental while the managed-settings surface stabilizes. +/// Represents the session.managed_settings_resolved event. +[Experimental(Diagnostics.Experimental)] +public sealed partial class SessionManagedSettingsResolvedEvent : SessionEvent +{ + /// + [JsonIgnore] + public override string Type => "session.managed_settings_resolved"; + + /// The session.managed_settings_resolved event payload. + [JsonPropertyName("data")] + public required SessionManagedSettingsResolvedData Data { get; set; } +} + /// SDK command registration change notification. /// Represents the commands.changed event. public sealed partial class CommandsChangedEvent : SessionEvent @@ -1392,6 +1439,45 @@ public sealed partial class SessionMcpServerStatusChangedEvent : SessionEvent public required SessionMcpServerStatusChangedData Data { get; set; } } +/// Payload identifying the MCP server associated with a list change. +/// Represents the mcp.tools.list_changed event. +public sealed partial class McpToolsListChangedEvent : SessionEvent +{ + /// + [JsonIgnore] + public override string Type => "mcp.tools.list_changed"; + + /// The mcp.tools.list_changed event payload. + [JsonPropertyName("data")] + public required McpToolsListChangedData Data { get; set; } +} + +/// Payload identifying the MCP server associated with a list change. +/// Represents the mcp.resources.list_changed event. +public sealed partial class McpResourcesListChangedEvent : SessionEvent +{ + /// + [JsonIgnore] + public override string Type => "mcp.resources.list_changed"; + + /// The mcp.resources.list_changed event payload. + [JsonPropertyName("data")] + public required McpResourcesListChangedData Data { get; set; } +} + +/// Payload identifying the MCP server associated with a list change. +/// Represents the mcp.prompts.list_changed event. +public sealed partial class McpPromptsListChangedEvent : SessionEvent +{ + /// + [JsonIgnore] + public override string Type => "mcp.prompts.list_changed"; + + /// The mcp.prompts.list_changed event payload. + [JsonPropertyName("data")] + public required McpPromptsListChangedData Data { get; set; } +} + /// Payload of `session.extensions_loaded` listing discovered extensions and their statuses. /// Represents the session.extensions_loaded event. public sealed partial class SessionExtensionsLoadedEvent : SessionEvent @@ -1405,7 +1491,7 @@ public sealed partial class SessionExtensionsLoadedEvent : SessionEvent public required SessionExtensionsLoadedData Data { get; set; } } -/// Payload of `session.canvas.opened` with canvas instance and provider IDs plus optional title, status, URL, and input. +/// Payload of `session.canvas.opened` with canvas instance and provider IDs plus optional icon, title, status, URL, and input. /// Represents the session.canvas.opened event. [Experimental(Diagnostics.Experimental)] public sealed partial class SessionCanvasOpenedEvent : SessionEvent @@ -2455,6 +2541,22 @@ public sealed partial class AssistantIntentData public required string Intent { get; set; } } +/// Live progress signal for a provider-hosted server tool (e.g. hosted web search) while it runs, before the finalized serverTools envelope lands on the terminal assistant.message. +public sealed partial class AssistantServerToolProgressData +{ + /// Kind of hosted server tool that is running. Only `web_search` is emitted today. + [JsonPropertyName("kind")] + public required string Kind { get; set; } + + /// Position of the hosted tool call in the response output. Stable across the call's lifecycle events (unlike the provider's per-event item id, which CAPI rotates), so the host keys the live in-progress row on it. + [JsonPropertyName("outputIndex")] + public required long OutputIndex { get; set; } + + /// Lifecycle status of the hosted call: `in_progress`, `searching`, or `completed`. + [JsonPropertyName("status")] + public required string Status { get; set; } +} + /// Assistant reasoning content for timeline display with complete thinking text. public sealed partial class AssistantReasoningData { @@ -2523,6 +2625,11 @@ public sealed partial class AssistantMessageData [JsonPropertyName("citations")] public Citations? Citations { get; set; } + /// Client-minted request id (x-request-id header) echoed by the server. Distinct from requestId (x-github-request-id) and serviceRequestId (x-copilot-service-request-id). + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("clientRequestId")] + public string? ClientRequestId { get; set; } + /// The assistant's text response content. [JsonPropertyName("content")] public required string Content { get; set; } @@ -2982,6 +3089,12 @@ public sealed partial class ToolExecutionCompleteData [JsonPropertyName("isUserRequested")] public bool? IsUserRequested { get; set; } + /// FIDES IFC label projected from tool ingress metadata (MCP `CallToolResult._meta` or synthesized built-in ingress labels). Persisted as `{ ifc: ... }` so the label survives session resume, including model-visible failure results. Experimental. + [Experimental(Diagnostics.Experimental)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("mcpMeta")] + public JsonElement? McpMeta { get; set; } + /// Model identifier that generated this tool call. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("model")] @@ -3496,6 +3609,11 @@ public sealed partial class SamplingCompletedData /// OAuth authentication request for an MCP server. public sealed partial class McpOauthRequiredData { + /// Raw HTTP response details from the OAuth auth challenge, as observed by the runtime. Header order and casing are transport-dependent, and duplicate header names may appear multiple times. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("httpResponse")] + public McpOauthHttpResponse? HttpResponse { get; set; } + /// Why the runtime is requesting host-provided OAuth credentials. [JsonPropertyName("reason")] public required McpOauthRequestReason Reason { get; set; } @@ -3748,6 +3866,74 @@ public sealed partial class SessionLimitsExhaustedCompletedData public required SessionLimitsExhaustedResponse Response { get; set; } } +/// Auto Intent resolution: the concrete model the session settled on for the first prompt of an auto-mode session, and why. Lets SDK clients render the chosen model and the full reason it was picked. The core selection fields (chosenModel/reasoningBucket/categoryScores) are stable; the routing-analytics fields (predictedLabel/confidence/candidateModels) mirror the upstream intent service and may evolve, hence the event's experimental stability. +[Experimental(Diagnostics.Experimental)] +public sealed partial class SessionAutoModeResolvedData +{ + /// Ordered candidate model list the router returned, when not a fallback. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("candidateModels")] + public string[]? CandidateModels { get; set; } + + /// Per-category classifier scores (0-1) behind the bucket: the granular HYDRA capability scores (reasoning, code_gen, debugging, tool_use), or the binary needs_reasoning/no_reasoning scores when HYDRA didn't run. Lets clients show a breakdown rather than just the bucket. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("categoryScores")] + public IDictionary? CategoryScores { get; set; } + + /// The concrete model the session will use after any intent refinement. + [JsonPropertyName("chosenModel")] + public required string ChosenModel { get; set; } + + /// Classifier confidence for the predicted label, when available. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("confidence")] + public double? Confidence { get; set; } + + /// The predicted classifier label (e.g. `needs_reasoning`), when available. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("predictedLabel")] + public string? PredictedLabel { get; set; } + + /// Coarse request-difficulty bucket, for explaining why a model was chosen ("picked X because this looks like high-reasoning work"). + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("reasoningBucket")] + public AutoModeResolvedReasoningBucket? ReasoningBucket { get; set; } +} + +/// Enterprise managed-settings resolution: the effective managed settings the session applied and where they came from, so SDK clients can show users what is enterprise-managed and by which authority. Fires whenever managed policy is (re)applied — at session start, on resume, and on account switch. This is an ephemeral live snapshot (delivered to subscribers but not persisted to the session event log), because at session start it resolves before `session.start` is emitted; for a session-independent pull, use the SDK `getManagedSettings()` API, which returns the identical payload. Managed settings have a single authoritative source, so the highest-authority present layer (server > device) wins wholesale; `bypassPermissionsDisabled` is deny-wins across layers. Marked experimental while the managed-settings surface stabilizes. +[Experimental(Diagnostics.Experimental)] +public sealed partial class SessionManagedSettingsResolvedData +{ + /// Whether enterprise policy disables bypass-permissions ("yolo") mode for this session. Deny-wins across layers, and forced on when `failClosed` is true. + [JsonPropertyName("bypassPermissionsDisabled")] + public required bool BypassPermissionsDisabled { get; set; } + + /// Whether the device (MDM/plist/registry/file) managed-settings layer was present. + [JsonPropertyName("deviceManaged")] + public required bool DeviceManaged { get; set; } + + /// Whether managed policy could not be determined (e.g. a failed server fetch) and the session fell back to the fail-closed restriction. When true, restrictions such as disabling bypass-permissions are enforced even though `settings` may be absent. + [JsonPropertyName("failClosed")] + public required bool FailClosed { get; set; } + + /// The setting keys under enterprise management in the effective managed settings (e.g. `model`, `enabledPlugins`, `permissions`). Empty when no managed settings are in force. + [JsonPropertyName("managedKeys")] + public required string[] ManagedKeys { get; set; } + + /// Whether the server (account/org) managed-settings layer was present. + [JsonPropertyName("serverManaged")] + public required bool ServerManaged { get; set; } + + /// The effective (resolved) managed settings values, so clients can render exactly what is enforced. Absent when no managed policy is in force. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("settings")] + public JsonElement? Settings { get; set; } + + /// Which channel supplied the effective managed settings (the winning layer), or `none` when no policy is in force. + [JsonPropertyName("source")] + public required ManagedSettingsResolvedSource Source { get; set; } +} + /// SDK command registration change notification. public sealed partial class CommandsChangedData { @@ -3879,6 +4065,30 @@ public sealed partial class SessionMcpServerStatusChangedData public required McpServerStatus Status { get; set; } } +/// Payload identifying the MCP server associated with a list change. +public sealed partial class McpToolsListChangedData +{ + /// Name of the MCP server whose list changed. + [JsonPropertyName("serverName")] + public required string ServerName { get; set; } +} + +/// Payload identifying the MCP server associated with a list change. +public sealed partial class McpResourcesListChangedData +{ + /// Name of the MCP server whose list changed. + [JsonPropertyName("serverName")] + public required string ServerName { get; set; } +} + +/// Payload identifying the MCP server associated with a list change. +public sealed partial class McpPromptsListChangedData +{ + /// Name of the MCP server whose list changed. + [JsonPropertyName("serverName")] + public required string ServerName { get; set; } +} + /// Payload of `session.extensions_loaded` listing discovered extensions and their statuses. public sealed partial class SessionExtensionsLoadedData { @@ -3887,7 +4097,7 @@ public sealed partial class SessionExtensionsLoadedData public required ExtensionsLoadedExtension[] Extensions { get; set; } } -/// Payload of `session.canvas.opened` with canvas instance and provider IDs plus optional title, status, URL, and input. +/// Payload of `session.canvas.opened` with canvas instance and provider IDs plus optional icon, title, status, URL, and input. [Experimental(Diagnostics.Experimental)] public sealed partial class SessionCanvasOpenedData { @@ -3904,6 +4114,11 @@ public sealed partial class SessionCanvasOpenedData [JsonPropertyName("extensionName")] public string? ExtensionName { get; set; } + /// Host-local PNG path for the canvas icon, when supplied. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("icon")] + public string? Icon { get; set; } + /// Input supplied when the instance was opened. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("input")] @@ -5283,7 +5498,7 @@ public sealed partial class ToolExecutionStartToolDescription /// MCP Apps metadata for UI resource association. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("_meta")] - public ToolExecutionStartToolDescriptionMeta? _meta { get; set; } + public ToolExecutionStartToolDescriptionMeta? Meta { get; set; } /// Tool description. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] @@ -5969,7 +6184,7 @@ public sealed partial class ToolExecutionCompleteUIResource /// Resource-level UI metadata (CSP, permissions, visual preferences). [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("_meta")] - public ToolExecutionCompleteUIResourceMeta? _meta { get; set; } + public ToolExecutionCompleteUIResourceMeta? Meta { get; set; } /// Base64-encoded HTML content. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] @@ -6020,6 +6235,12 @@ public sealed partial class ToolExecutionCompleteResult [JsonPropertyName("detailedContent")] public string? DetailedContent { get; set; } + /// FIDES IFC label projected from tool ingress metadata (MCP `CallToolResult._meta` or synthesized built-in ingress labels) — persisted as `{ ifc: ... }` (only the `ifc` key, not the whole `_meta`). Persisted so the FIDES IFC label survives session resume: the engine rehydrates accumulated taint by replaying these on load. Populated for ingress sources when FIDES IFC is on. Experimental. + [Experimental(Diagnostics.Experimental)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("mcpMeta")] + public JsonElement? McpMeta { get; set; } + /// Structured content (arbitrary JSON) returned verbatim by the MCP tool. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("structuredContent")] @@ -6063,7 +6284,7 @@ public sealed partial class ToolExecutionCompleteToolDescription /// MCP Apps metadata for UI resource association. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("_meta")] - public ToolExecutionCompleteToolDescriptionMeta? _meta { get; set; } + public ToolExecutionCompleteToolDescriptionMeta? Meta { get; set; } /// Tool description. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] @@ -7388,6 +7609,37 @@ public sealed partial class ElicitationRequestedSchema public required string Type { get; set; } } +/// Single HTTP header entry as a name/value pair. +/// Nested data type for HeaderEntry. +public sealed partial class HeaderEntry +{ + /// HTTP response header name as observed by the runtime. + [JsonPropertyName("name")] + public required string Name { get; set; } + + /// HTTP response header value as observed by the runtime. + [JsonPropertyName("value")] + public required string Value { get; set; } +} + +/// Raw HTTP response details from the OAuth auth challenge, as observed by the runtime. +/// Nested data type for McpOauthHttpResponse. +public sealed partial class McpOauthHttpResponse +{ + /// Complete UTF-8 response body for host-specific challenge handling, including an empty string for an empty body. Omitted when the complete body is not valid UTF-8; body read failures fail the HTTP operation rather than exposing a partial response. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("body")] + public string? Body { get; set; } + + /// HTTP response headers as observed by the runtime. Order and casing are transport-dependent, and duplicate header names may appear multiple times. + [JsonPropertyName("headers")] + public required HeaderEntry[] Headers { get; set; } + + /// HTTP status code returned with the auth challenge. + [JsonPropertyName("statusCode")] + public required int StatusCode { get; set; } +} + /// Static OAuth client configuration, if the server specifies one. /// Nested data type for McpOauthRequiredStaticClientConfig. public sealed partial class McpOauthRequiredStaticClientConfig @@ -7670,6 +7922,11 @@ public sealed partial class CanvasRegistryChangedCanvas [JsonPropertyName("extensionName")] public string? ExtensionName { get; set; } + /// Host-local PNG path for the canvas icon, when supplied. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("icon")] + public string? Icon { get; set; } + /// JSON Schema for canvas open input. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("inputSchema")] @@ -10484,6 +10741,134 @@ public override void Write(Utf8JsonWriter writer, SessionLimitsExhaustedResponse } } +/// Coarse request-difficulty bucket for UX explainability. +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct AutoModeResolvedReasoningBucket : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public AutoModeResolvedReasoningBucket(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// The request looks low-reasoning; a lighter model is appropriate. + public static AutoModeResolvedReasoningBucket Low { get; } = new("low"); + + /// The request needs a moderate amount of reasoning. + public static AutoModeResolvedReasoningBucket Medium { get; } = new("medium"); + + /// The request looks high-reasoning; a stronger model is appropriate. + public static AutoModeResolvedReasoningBucket High { get; } = new("high"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(AutoModeResolvedReasoningBucket left, AutoModeResolvedReasoningBucket right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(AutoModeResolvedReasoningBucket left, AutoModeResolvedReasoningBucket right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is AutoModeResolvedReasoningBucket other && Equals(other); + + /// + public bool Equals(AutoModeResolvedReasoningBucket other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override AutoModeResolvedReasoningBucket Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, AutoModeResolvedReasoningBucket value, JsonSerializerOptions options) + { + GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(AutoModeResolvedReasoningBucket)); + } + } +} + +/// Which channel supplied the effective enterprise managed settings (highest-authority present layer wins wholesale). +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct ManagedSettingsResolvedSource : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public ManagedSettingsResolvedSource(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Account/org policy self-fetched from the GitHub managed-settings endpoint (higher authority). + public static ManagedSettingsResolvedSource Server { get; } = new("server"); + + /// Device-level MDM policy discovered from plist/registry/file (lower authority). + public static ManagedSettingsResolvedSource Device { get; } = new("device"); + + /// No managed policy is in force (no layer contributed). + public static ManagedSettingsResolvedSource None { get; } = new("none"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(ManagedSettingsResolvedSource left, ManagedSettingsResolvedSource right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(ManagedSettingsResolvedSource left, ManagedSettingsResolvedSource right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is ManagedSettingsResolvedSource other && Equals(other); + + /// + public bool Equals(ManagedSettingsResolvedSource other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override ManagedSettingsResolvedSource Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, ManagedSettingsResolvedSource value, JsonSerializerOptions options) + { + GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(ManagedSettingsResolvedSource)); + } + } +} + /// Exit plan mode action. [JsonConverter(typeof(Converter))] [DebuggerDisplay("{Value,nq}")] @@ -10991,6 +11376,8 @@ public override void Write(Utf8JsonWriter writer, ExtensionsLoadedExtensionStatu [JsonSerializable(typeof(AssistantReasoningDeltaData))] [JsonSerializable(typeof(AssistantReasoningDeltaEvent))] [JsonSerializable(typeof(AssistantReasoningEvent))] +[JsonSerializable(typeof(AssistantServerToolProgressData))] +[JsonSerializable(typeof(AssistantServerToolProgressEvent))] [JsonSerializable(typeof(AssistantStreamingDeltaData))] [JsonSerializable(typeof(AssistantStreamingDeltaEvent))] [JsonSerializable(typeof(AssistantToolCallDeltaData))] @@ -11076,6 +11463,7 @@ public override void Write(Utf8JsonWriter writer, ExtensionsLoadedExtensionStatu [JsonSerializable(typeof(ExternalToolRequestedEvent))] [JsonSerializable(typeof(GitHubRepoRef))] [JsonSerializable(typeof(HandoffRepository))] +[JsonSerializable(typeof(HeaderEntry))] [JsonSerializable(typeof(HookEndData))] [JsonSerializable(typeof(HookEndError))] [JsonSerializable(typeof(HookEndEvent))] @@ -11094,11 +11482,18 @@ public override void Write(Utf8JsonWriter writer, ExtensionsLoadedExtensionStatu [JsonSerializable(typeof(McpHeadersRefreshRequiredEvent))] [JsonSerializable(typeof(McpOauthCompletedData))] [JsonSerializable(typeof(McpOauthCompletedEvent))] +[JsonSerializable(typeof(McpOauthHttpResponse))] [JsonSerializable(typeof(McpOauthRequiredData))] [JsonSerializable(typeof(McpOauthRequiredEvent))] [JsonSerializable(typeof(McpOauthRequiredStaticClientConfig))] [JsonSerializable(typeof(McpOauthWWWAuthenticateParams))] +[JsonSerializable(typeof(McpPromptsListChangedData))] +[JsonSerializable(typeof(McpPromptsListChangedEvent))] +[JsonSerializable(typeof(McpResourcesListChangedData))] +[JsonSerializable(typeof(McpResourcesListChangedEvent))] [JsonSerializable(typeof(McpServersLoadedServer))] +[JsonSerializable(typeof(McpToolsListChangedData))] +[JsonSerializable(typeof(McpToolsListChangedEvent))] [JsonSerializable(typeof(ModelCallFailureData))] [JsonSerializable(typeof(ModelCallFailureEvent))] [JsonSerializable(typeof(ModelCallFailureRequestFingerprint))] @@ -11152,6 +11547,8 @@ public override void Write(Utf8JsonWriter writer, ExtensionsLoadedExtensionStatu [JsonSerializable(typeof(SamplingCompletedEvent))] [JsonSerializable(typeof(SamplingRequestedData))] [JsonSerializable(typeof(SamplingRequestedEvent))] +[JsonSerializable(typeof(SessionAutoModeResolvedData))] +[JsonSerializable(typeof(SessionAutoModeResolvedEvent))] [JsonSerializable(typeof(SessionAutopilotObjectiveChangedData))] [JsonSerializable(typeof(SessionAutopilotObjectiveChangedEvent))] [JsonSerializable(typeof(SessionBackgroundTasksChangedData))] @@ -11199,6 +11596,8 @@ public override void Write(Utf8JsonWriter writer, ExtensionsLoadedExtensionStatu [JsonSerializable(typeof(SessionLimitsExhaustedRequestedData))] [JsonSerializable(typeof(SessionLimitsExhaustedRequestedEvent))] [JsonSerializable(typeof(SessionLimitsExhaustedResponse))] +[JsonSerializable(typeof(SessionManagedSettingsResolvedData))] +[JsonSerializable(typeof(SessionManagedSettingsResolvedEvent))] [JsonSerializable(typeof(SessionMcpServerStatusChangedData))] [JsonSerializable(typeof(SessionMcpServerStatusChangedEvent))] [JsonSerializable(typeof(SessionMcpServersLoadedData))] diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index ae010a97f3..04306b7f62 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -90,6 +90,13 @@ private sealed record EventSubscription(Type EventType, Action Han private readonly Channel _eventChannel = Channel.CreateUnbounded( new() { SingleReader = true }); + /// + /// Fixed name of the runtime's built-in tool-search tool. A client can + /// replace its behavior by registering a tool with this exact name and + /// OverridesBuiltInTool set to true. + /// + private const string ToolSearchToolName = "tool_search_tool"; + /// /// Gets the unique identifier for this session. /// @@ -841,6 +848,26 @@ private async Task ExecuteToolAndRespondAsync(string requestId, string toolName, Arguments = arguments }; + // The built-in tool-search tool receives a snapshot of the session's + // currently initialized tools so an override can filter the live + // catalog without issuing its own RPC. Fetch it only for that tool + // to avoid a round-trip on every tool call; a failed fetch leaves + // the snapshot null rather than failing the tool. + if (toolName == ToolSearchToolName) + { + try + { + var metadata = await Rpc.Tools.GetCurrentMetadataAsync(); + invocation.AvailableTools = metadata.Tools; + } + catch (Exception ex) when (ex is RemoteRpcException or IOException or ObjectDisposedException or JsonException) + { + // A failed metadata fetch is non-fatal: leave AvailableTools + // null so the tool still runs without the snapshot. + LogToolMetadataFetchFailed(ex, toolName); + } + } + var aiFunctionArgs = new AIFunctionArguments { Context = new Dictionary @@ -1091,6 +1118,7 @@ private void UpdateOpenCanvasesFromEvent(SessionEvent sessionEvent) InstanceId = data.InstanceId, Status = data.Status, Title = data.Title, + Icon = data.Icon, Url = data.Url, }); } @@ -1748,15 +1776,15 @@ public async Task AbortAsync(CancellationToken cancellationToken = default) /// Changes the model for this session. /// The new model takes effect for the next message. Conversation history is preserved. /// - /// Model ID to switch to (e.g., "gpt-4.1"). + /// Model ID to switch to (e.g., "gpt-5.4"). /// Reasoning effort level (e.g., "low", "medium", "high", "xhigh"). /// Per-property overrides for model capabilities, deep-merged over runtime defaults. /// Optional cancellation token. /// /// - /// await session.SetModelAsync("gpt-4.1"); + /// await session.SetModelAsync("gpt-5.4"); /// await session.SetModelAsync("claude-sonnet-4.6", "high"); - /// await session.SetModelAsync("gpt-4.1", new SetModelOptions { ContextTier = ContextTier.LongContext }); + /// await session.SetModelAsync("gpt-5.4", new SetModelOptions { ContextTier = ContextTier.LongContext }); /// /// public Task SetModelAsync(string model, string? reasoningEffort, ModelCapabilitiesOverride? modelCapabilities = null, CancellationToken cancellationToken = default) @@ -1775,7 +1803,7 @@ public Task SetModelAsync(string model, string? reasoningEffort, ModelCapabiliti /// Changes the model for this session. /// The new model takes effect for the next message. Conversation history is preserved. /// - /// Model ID to switch to (e.g., "gpt-4.1"). + /// Model ID to switch to (e.g., "gpt-5.4"). /// Settings for the new model. /// Optional cancellation token. public async Task SetModelAsync(string model, SetModelOptions options, CancellationToken cancellationToken = default) @@ -1907,6 +1935,9 @@ await InvokeRpcAsync( [LoggerMessage(Level = LogLevel.Error, Message = "Unhandled exception in session event handler")] private partial void LogEventHandlerError(Exception exception); + [LoggerMessage(Level = LogLevel.Debug, Message = "Failed to fetch tool metadata for {toolName}")] + private partial void LogToolMetadataFetchFailed(Exception exception, string toolName); + internal record SendMessageRequest { public string SessionId { get; init; } = string.Empty; diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 65295d3d24..f893baf5e5 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -697,6 +697,12 @@ public sealed class ToolResultObject [JsonPropertyName("toolTelemetry")] public IDictionary? ToolTelemetry { get; set; } + /// + /// Names of tools returned by a tool-search tool. + /// + [JsonPropertyName("toolReferences")] + public IList? ToolReferences { get; set; } + /// /// Converts the result of an invocation into a /// . Handles , @@ -808,6 +814,14 @@ public sealed class ToolInvocation /// Arguments passed to the tool by the language model. /// public JsonElement? Arguments { get; set; } + /// + /// Snapshot of the session's currently initialized tools. The SDK populates + /// this only when the invocation targets the built-in tool-search tool + /// (tool_search_tool), so a tool-search override can rank/filter the + /// live catalog — including MCP tools configured in settings — without + /// issuing its own RPC. null for every other tool invocation. + /// + public IList? AvailableTools { get; set; } } /// @@ -2721,6 +2735,30 @@ public sealed class LargeToolOutputConfig public string? OutputDirectory { get; set; } } +/// +/// Overrides the runtime's built-in tool-search behavior. +/// Defers tools to keep the model's active tool set small. +/// To override the tool-search tool's implementation, register a tool +/// named "tool_search_tool" with OverridesBuiltInTool set to +/// . +/// +public sealed class ToolSearchConfig +{ + /// + /// Enable or disable tool search. + /// + [JsonPropertyName("enabled")] + public bool? Enabled { get; set; } + + /// + /// The tool count above which MCP and external tools are deferred behind + /// tool search. When , the runtime default (30) + /// applies. + /// + [JsonPropertyName("deferThreshold")] + public int? DeferThreshold { get; set; } +} + /// /// Configuration for session memory. /// @@ -2829,6 +2867,7 @@ protected SessionConfigBase(SessionConfigBase? other) Hooks = other.Hooks; InfiniteSessions = other.InfiniteSessions; LargeOutput = other.LargeOutput; + ToolSearch = other.ToolSearch; Memory = other.Memory; McpServers = other.McpServers is not null ? (other.McpServers is Dictionary dict @@ -2861,12 +2900,14 @@ protected SessionConfigBase(SessionConfigBase? other) GitHubToken = other.GitHubToken; RemoteSession = other.RemoteSession; ExpAssignments = other.ExpAssignments; + EnableManagedSettings = other.EnableManagedSettings; #pragma warning disable GHCP001 Canvases = other.Canvases is not null ? [.. other.Canvases] : null; RequestCanvasRenderer = other.RequestCanvasRenderer; RequestExtensions = other.RequestExtensions; ExtensionSdkPath = other.ExtensionSdkPath; ExtensionInfo = other.ExtensionInfo; + CanvasProvider = other.CanvasProvider; CanvasHandler = other.CanvasHandler; #pragma warning restore GHCP001 SkillDirectories = other.SkillDirectories is not null ? [.. other.SkillDirectories] : null; @@ -3233,6 +3274,13 @@ protected SessionConfigBase(SessionConfigBase? other) /// public LargeToolOutputConfig? LargeOutput { get; set; } + /// + /// Overrides the runtime's built-in tool-search behavior. + /// Tool search defers tools to keep the model's active tool set small. When , + /// the runtime default applies. + /// + public ToolSearchConfig? ToolSearch { get; set; } + /// /// Configuration for session memory. When set, controls whether the /// session can read and write persistent memory. @@ -3285,6 +3333,16 @@ protected SessionConfigBase(SessionConfigBase? other) [EditorBrowsable(EditorBrowsableState.Never)] public JsonElement? ExpAssignments { get; set; } + /// + /// Opt-in: when true, the runtime self-fetches enterprise managed + /// settings (bypass-permissions policy) at session bootstrap using the + /// session's . Requires to + /// be set; if omitted, the runtime is expected to reject session creation + /// (fail-closed). When unset, behaves exactly as before. Serialized on the + /// wire as enableManagedSettings. + /// + public bool? EnableManagedSettings { get; set; } + #pragma warning disable GHCP001 /// /// Canvas declarations advertised by this connection. The runtime forwards @@ -3326,6 +3384,16 @@ protected SessionConfigBase(SessionConfigBase? other) [Experimental(Diagnostics.Experimental)] public ExtensionInfo? ExtensionInfo { get; set; } + /// + /// Stable identity for a host/SDK connection that supplies built-in + /// canvases. When set, the runtime uses + /// verbatim as the agent-facing canvas extension id, so canvases declared on + /// a control connection survive reconnect and CLI restart. Honored on + /// session create and resume. + /// + [Experimental(Diagnostics.Experimental)] + public CanvasProviderIdentity? CanvasProvider { get; set; } + /// /// Provider-side canvas lifecycle handler. The SDK routes inbound /// canvas.open / canvas.close / canvas.action.invoke @@ -4010,5 +4078,6 @@ public sealed class SystemMessageTransformRpcResponse [JsonSerializable(typeof(CanvasProviderOpenResult))] [JsonSerializable(typeof(CanvasHostContext))] [JsonSerializable(typeof(ExtensionInfo))] +[JsonSerializable(typeof(CanvasProviderIdentity))] #pragma warning restore GHCP001 internal partial class TypesJsonContext : JsonSerializerContext; diff --git a/dotnet/src/UnixMillisecondsDateTimeOffsetConverter.cs b/dotnet/src/UnixMillisecondsDateTimeOffsetConverter.cs index 4b8fcc3616..8e176fbafa 100644 --- a/dotnet/src/UnixMillisecondsDateTimeOffsetConverter.cs +++ b/dotnet/src/UnixMillisecondsDateTimeOffsetConverter.cs @@ -13,8 +13,14 @@ namespace GitHub.Copilot; public sealed class UnixMillisecondsDateTimeOffsetConverter : JsonConverter { /// - public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => - DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64()); + public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // The CLI may serialize the epoch-millisecond timestamp as a JSON integer + // or as a floating-point number (e.g. 1700000000000.0). GetInt64 throws on a + // fractional token, so fall back to reading a double and truncating. + long milliseconds = reader.TryGetInt64(out long value) ? value : (long)reader.GetDouble(); + return DateTimeOffset.FromUnixTimeMilliseconds(milliseconds); + } /// public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) => diff --git a/dotnet/src/build/GitHub.Copilot.SDK.targets b/dotnet/src/build/GitHub.Copilot.SDK.targets index 5a5e511811..5f7944b2c4 100644 --- a/dotnet/src/build/GitHub.Copilot.SDK.targets +++ b/dotnet/src/build/GitHub.Copilot.SDK.targets @@ -9,6 +9,7 @@ <_CopilotOs Condition="'$(RuntimeIdentifier)' != '' And $(RuntimeIdentifier.StartsWith('win'))">win <_CopilotOs Condition="'$(_CopilotOs)' == '' And '$(RuntimeIdentifier)' != '' And $(RuntimeIdentifier.StartsWith('osx'))">osx <_CopilotOs Condition="'$(_CopilotOs)' == '' And '$(RuntimeIdentifier)' != '' And $(RuntimeIdentifier.StartsWith('maccatalyst'))">osx + <_CopilotOs Condition="'$(_CopilotOs)' == '' And '$(RuntimeIdentifier)' != '' And $(RuntimeIdentifier.StartsWith('linux-musl'))">linux-musl <_CopilotOs Condition="'$(_CopilotOs)' == '' And '$(RuntimeIdentifier)' != ''">linux @@ -22,7 +23,7 @@ - + @@ -31,6 +32,8 @@ <_CopilotPlatform Condition="'$(_CopilotRid)' == 'win-arm64'">win32-arm64 <_CopilotPlatform Condition="'$(_CopilotRid)' == 'linux-x64'">linux-x64 <_CopilotPlatform Condition="'$(_CopilotRid)' == 'linux-arm64'">linux-arm64 + <_CopilotPlatform Condition="'$(_CopilotRid)' == 'linux-musl-x64'">linuxmusl-x64 + <_CopilotPlatform Condition="'$(_CopilotRid)' == 'linux-musl-arm64'">linuxmusl-arm64 <_CopilotPlatform Condition="'$(_CopilotRid)' == 'osx-x64'">darwin-x64 <_CopilotPlatform Condition="'$(_CopilotRid)' == 'osx-arm64'">darwin-arm64 <_CopilotBinary Condition="$(_CopilotRid.StartsWith('win-'))">copilot.exe diff --git a/dotnet/test/E2E/CopilotRequestE2EProvider.cs b/dotnet/test/E2E/CopilotRequestE2EProvider.cs index bade5c6e5d..89826b4f84 100644 --- a/dotnet/test/E2E/CopilotRequestE2EProvider.cs +++ b/dotnet/test/E2E/CopilotRequestE2EProvider.cs @@ -56,7 +56,13 @@ protected override async Task SendRequestAsync(HttpRequestM #else : await request.Content.ReadAsStringAsync().ConfigureAwait(false); #endif - _records.Enqueue(new InterceptedRequest(url, ctx.SessionId, bodyText)); + _records.Enqueue(new InterceptedRequest( + url, + ctx.SessionId, + ctx.AgentId, + ctx.ParentAgentId, + ctx.InteractionType, + bodyText)); return IsInferenceUrl(url) ? BuildInferenceResponse(url, bodyText) @@ -188,4 +194,10 @@ internal static HttpResponseMessage BuildNonInferenceResponse(string url) } /// A single request the callback intercepted. -internal sealed record InterceptedRequest(string Url, string? SessionId, string Body); +internal sealed record InterceptedRequest( + string Url, + string? SessionId, + string? AgentId, + string? ParentAgentId, + string? InteractionType, + string Body); diff --git a/dotnet/test/E2E/CopilotRequestSessionIdE2ETests.cs b/dotnet/test/E2E/CopilotRequestSessionIdE2ETests.cs index e09c72c46e..08dd075643 100644 --- a/dotnet/test/E2E/CopilotRequestSessionIdE2ETests.cs +++ b/dotnet/test/E2E/CopilotRequestSessionIdE2ETests.cs @@ -53,7 +53,11 @@ public async Task Threads_The_Session_Id_Into_A_Capi_Session_Inference_Request() var inference = provider.InferenceRequests; Assert.NotEmpty(inference); - Assert.All(inference, r => Assert.Equal(capiSessionId, r.SessionId)); + Assert.All(inference, r => + { + Assert.Equal(capiSessionId, r.SessionId); + AssertAgentMetadata(r); + }); // Validate the final assistant response arrived (guards against truncated captures) Assert.Contains("OK from the synthetic", content); @@ -96,9 +100,19 @@ public async Task Threads_The_Session_Id_Into_A_Byok_Session_Inference_Request() var inference = provider.InferenceRequests; Assert.NotEmpty(inference); - Assert.All(inference, r => Assert.Equal(byokSessionId, r.SessionId)); + Assert.All(inference, r => + { + Assert.Equal(byokSessionId, r.SessionId); + AssertAgentMetadata(r); + }); // Validate the final assistant response arrived (guards against truncated captures) Assert.Contains("OK from the synthetic", content); } + + private static void AssertAgentMetadata(InterceptedRequest request) + { + Assert.False(string.IsNullOrEmpty(request.AgentId)); + Assert.False(string.IsNullOrEmpty(request.InteractionType)); + } } diff --git a/dotnet/test/E2E/SubagentHooksE2ETests.cs b/dotnet/test/E2E/SubagentHooksE2ETests.cs index 4723c19b78..a718c56c1a 100644 --- a/dotnet/test/E2E/SubagentHooksE2ETests.cs +++ b/dotnet/test/E2E/SubagentHooksE2ETests.cs @@ -3,12 +3,15 @@ *--------------------------------------------------------------------------------------------*/ using System.Collections.Concurrent; +using System.Net.Http; using GitHub.Copilot.Test.Harness; using Xunit; using Xunit.Abstractions; namespace GitHub.Copilot.Test.E2E; +#pragma warning disable GHCP001 // The LLM inference surface is intentionally experimental. + public class SubagentHooksE2ETests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "subagent_hooks", output) { @@ -16,11 +19,16 @@ public class SubagentHooksE2ETests(E2ETestFixture fixture, ITestOutputHelper out public async Task Should_Invoke_PreToolUse_And_PostToolUse_Hooks_For_Sub_Agent_Tool_Calls() { var hookLog = new ConcurrentBag<(string Kind, string ToolName, string SessionId)>(); + var requestHandler = new RecordingForwardingRequestHandler(); // Create a client with the session-based subagents feature flag var env = new Dictionary(Ctx.GetEnvironment()); env["COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS"] = "true"; - var client = Ctx.CreateClient(environment: env); + var client = Ctx.CreateClient(new CopilotClientOptions + { + Connection = RuntimeConnection.ForStdio(), + RequestHandler = requestHandler + }, environment: env); var session = await client.CreateSessionAsync(new SessionConfig { @@ -69,5 +77,42 @@ await session.SendAndWaitAsync( // input.SessionId distinguishes parent from sub-agent Assert.NotEqual(viewPre[0].SessionId, taskPre[0].SessionId); + AssertSubagentRequestMetadata(requestHandler.InferenceRequests); + } + + private static void AssertSubagentRequestMetadata(IReadOnlyCollection records) + { + Assert.NotEmpty(records); + var subagentRequest = records.FirstOrDefault(r => !string.IsNullOrEmpty(r.ParentAgentId)); + Assert.NotNull(subagentRequest); + Assert.False(string.IsNullOrEmpty(subagentRequest.AgentId), + "Sub-agent inference request should carry an agent id"); + Assert.False(string.IsNullOrEmpty(subagentRequest.InteractionType), + "Sub-agent inference request should carry an interaction type"); + Assert.NotEqual(subagentRequest.ParentAgentId, subagentRequest.AgentId); + } + + private sealed class RecordingForwardingRequestHandler : CopilotRequestHandler + { + private readonly ConcurrentBag _records = []; + + public IReadOnlyCollection InferenceRequests => + [.. _records.Where(r => RecordingRequestHandler.IsInferenceUrl(r.Url))]; + + protected override Task SendRequestAsync(HttpRequestMessage request, CopilotRequestContext ctx) + { + _records.Add(new RequestRecord( + request.RequestUri!.ToString(), + ctx.AgentId, + ctx.ParentAgentId, + ctx.InteractionType)); + return base.SendRequestAsync(request, ctx); + } } + + private sealed record RequestRecord( + string Url, + string? AgentId, + string? ParentAgentId, + string? InteractionType); } diff --git a/dotnet/test/Harness/E2ETestContext.cs b/dotnet/test/Harness/E2ETestContext.cs index f4df1749f0..4c9b892ff2 100644 --- a/dotnet/test/Harness/E2ETestContext.cs +++ b/dotnet/test/Harness/E2ETestContext.cs @@ -244,9 +244,17 @@ public CopilotClient CreateClient( { options ??= new CopilotClientOptions(); - options.WorkingDirectory ??= WorkDir; options.Logger ??= Logger; + // Resolve the working directory the worker should run in. Child-process and + // URI transports take it as a per-client option; the in-process transport + // rejects a per-client WorkingDirectory (the native host spawns the worker + // without a cwd parameter), so — mirroring the Node/Rust harnesses — we point + // THIS process's cwd at the desired directory before the worker spawns and + // clear the per-client option. InProcessEnvIsolationAttribute.After restores + // the cwd after the test. + var desiredWorkingDirectory = options.WorkingDirectory ?? WorkDir; + // Tests must supply environment via the 'environment' parameter, which the // harness routes to the right place per transport (the connection for // child-process transports, the host process for in-process). Setting @@ -300,12 +308,24 @@ public CopilotClient CreateClient( { InProcessEnvIsolation.Apply(name, value); } + + // A per-client WorkingDirectory is rejected in-process; instead point this + // process's cwd at the desired directory so the worker inherits it at spawn + // (restored after the test by InProcessEnvIsolationAttribute). + options.WorkingDirectory = null; + InProcessEnvIsolation.SetWorkingDirectory(desiredWorkingDirectory); } else if (options.Connection is ChildProcessRuntimeConnection child) { // Child-process transport: hand the environment to the spawned child // via the connection, where per-client environment is coherent. child.Environment = env; + options.WorkingDirectory = desiredWorkingDirectory; + } + else + { + // URI / existing-runtime transport: per-client WorkingDirectory applies normally. + options.WorkingDirectory = desiredWorkingDirectory; } // Auto-inject auth token unless connecting to an existing runtime via URI. diff --git a/dotnet/test/Harness/InProcessEnvIsolation.cs b/dotnet/test/Harness/InProcessEnvIsolation.cs index 14b065204f..af06001eda 100644 --- a/dotnet/test/Harness/InProcessEnvIsolation.cs +++ b/dotnet/test/Harness/InProcessEnvIsolation.cs @@ -22,6 +22,11 @@ internal static class InProcessEnvIsolation // Captured at load, before any fixture/test mutates env. private static readonly Dictionary s_ambient = CaptureEnvironment(); + // The process working directory captured at load, restored after each test so an + // in-process test that repoints the cwd (the FFI worker inherits it at spawn) + // can't leak that change into the next test. + private static readonly string s_ambientCwd = Directory.GetCurrentDirectory(); + // Runs at assembly load so the ambient env is snapshotted before the shared // fixture mirrors per-test env onto the process. Justifies suppressing CA2255. #pragma warning disable CA2255 // ModuleInitializer discouraged in libraries; intentional in this test harness. @@ -56,8 +61,21 @@ public static void NeutralizeAmbientCredentials() } } + // Points the process working directory at the given path so the in-process FFI + // worker inherits it at spawn (the native host has no per-client cwd parameter). + // RestoreAmbient() returns the process to its load-time cwd after the test. + public static void SetWorkingDirectory(string path) => + Directory.SetCurrentDirectory(path); + public static void RestoreAmbient() { + // Unconditionally repoint the process cwd at its load-time value. We must + // not read Directory.GetCurrentDirectory() first: an in-process test can + // chdir into a temp work dir that the harness then deletes, so getcwd() + // would throw FileNotFoundException. SetCurrentDirectory to an absolute + // path succeeds regardless of whether the old cwd still exists. + Directory.SetCurrentDirectory(s_ambientCwd); + foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) { var name = (string)entry.Key; diff --git a/dotnet/test/Unit/CanvasTests.cs b/dotnet/test/Unit/CanvasTests.cs index 612814d1f3..4993d88f6f 100644 --- a/dotnet/test/Unit/CanvasTests.cs +++ b/dotnet/test/Unit/CanvasTests.cs @@ -166,6 +166,7 @@ public void SessionCanvasOpenedEvent_UpdatesOpenCanvasSnapshots() ExtensionName = "Counter Provider", InstanceId = "counter-1", Title = "Counter", + Icon = "beaker", Status = "ready", Url = "https://example.test/counter", Input = JsonDocument.Parse("""{"seed":1}""").RootElement.Clone(), @@ -200,6 +201,7 @@ public void SessionCanvasOpenedEvent_UpdatesOpenCanvasSnapshots() ExtensionName = "Counter Provider", InstanceId = "counter-1", Title = "Counter Updated", + Icon = "beaker-filled", Status = "reconnected", Url = "https://example.test/counter-updated", Input = JsonDocument.Parse("""{"seed":2}""").RootElement.Clone(), @@ -212,6 +214,7 @@ public void SessionCanvasOpenedEvent_UpdatesOpenCanvasSnapshots() { Assert.Equal("counter-1", canvas.InstanceId); Assert.Equal("Counter Updated", canvas.Title); + Assert.Equal("beaker-filled", canvas.Icon); Assert.Equal("reconnected", canvas.Status); Assert.Equal("https://example.test/counter-updated", canvas.Url); Assert.Equal(2, canvas.Input!.Value.GetProperty("seed").GetInt32()); @@ -313,6 +316,28 @@ public void ExtensionInfo_Serializes_SourceAndName() Assert.Equal("demo", doc.RootElement.GetProperty("name").GetString()); } + [Fact] + public void CanvasProviderIdentity_Serializes_IdAndName() + { + var options = GetSerializerOptions(); + var identity = new CanvasProviderIdentity { Id = "app:builtin:window-1", Name = "Built-in" }; + var json = JsonSerializer.Serialize(identity, options); + using var doc = JsonDocument.Parse(json); + Assert.Equal("app:builtin:window-1", doc.RootElement.GetProperty("id").GetString()); + Assert.Equal("Built-in", doc.RootElement.GetProperty("name").GetString()); + } + + [Fact] + public void CanvasProviderIdentity_OmitsNullName() + { + var options = GetSerializerOptions(); + var identity = new CanvasProviderIdentity { Id = "app:builtin:window-1" }; + var json = JsonSerializer.Serialize(identity, options); + using var doc = JsonDocument.Parse(json); + Assert.Equal("app:builtin:window-1", doc.RootElement.GetProperty("id").GetString()); + Assert.False(doc.RootElement.TryGetProperty("name", out _)); + } + [Fact] public async Task CanvasHandlerBase_DefaultOnClose_Completes() { @@ -348,6 +373,7 @@ public void SessionConfig_Clone_CopiesCanvasFields() RequestCanvasRenderer = true, RequestExtensions = true, ExtensionInfo = new ExtensionInfo { Source = "github-app", Name = "demo" }, + CanvasProvider = new CanvasProviderIdentity { Id = "app:builtin:window-1", Name = "Built-in" }, CanvasHandler = handler }; @@ -360,6 +386,8 @@ public void SessionConfig_Clone_CopiesCanvasFields() Assert.True(clone.RequestExtensions); Assert.NotNull(clone.ExtensionInfo); Assert.Equal("github-app", clone.ExtensionInfo!.Source); + Assert.NotNull(clone.CanvasProvider); + Assert.Equal("app:builtin:window-1", clone.CanvasProvider!.Id); Assert.Same(handler, clone.CanvasHandler); // Mutating the clone's list does not affect the original. @@ -376,6 +404,7 @@ public void ResumeSessionConfig_Clone_CopiesCanvasFields() Canvases = new[] { new CanvasDeclaration { Id = "c1", DisplayName = "C", Description = "d" } }, RequestCanvasRenderer = true, ExtensionInfo = new ExtensionInfo { Source = "s", Name = "n" }, + CanvasProvider = new CanvasProviderIdentity { Id = "app:builtin:window-2" }, CanvasHandler = handler }; @@ -385,6 +414,8 @@ public void ResumeSessionConfig_Clone_CopiesCanvasFields() Assert.Single(clone.Canvases!); Assert.True(clone.RequestCanvasRenderer); Assert.NotNull(clone.ExtensionInfo); + Assert.NotNull(clone.CanvasProvider); + Assert.Equal("app:builtin:window-2", clone.CanvasProvider!.Id); Assert.Same(handler, clone.CanvasHandler); } diff --git a/dotnet/test/Unit/CopilotToolTests.cs b/dotnet/test/Unit/CopilotToolTests.cs index 9c9e2a93ba..c3f5861492 100644 --- a/dotnet/test/Unit/CopilotToolTests.cs +++ b/dotnet/test/Unit/CopilotToolTests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.AI; using System.ComponentModel; using System.Text.Json; +using System.Text.Json.Nodes; using Xunit; namespace GitHub.Copilot.Test.Unit; @@ -43,6 +44,34 @@ public void DefineTool_Omits_Copilot_Metadata_When_Flags_Are_False() Assert.False(function.AdditionalProperties.ContainsKey("defer")); } + [Fact] + public void DefineTool_Sets_Metadata_In_Additional_Properties() + { + var metadata = new Dictionary + { + ["github.com/copilot:safeForTelemetry"] = new JsonObject + { + ["name"] = true, + ["inputsNames"] = false + } + }; + + var function = CopilotTool.DefineTool( + ReturnsOk, + new CopilotToolOptions { Metadata = metadata }); + + Assert.True(function.AdditionalProperties.TryGetValue("metadata", out var value)); + Assert.Same(metadata, value); + } + + [Fact] + public void DefineTool_Omits_Metadata_When_Unset() + { + var function = CopilotTool.DefineTool(ReturnsOk); + + Assert.False(function.AdditionalProperties.ContainsKey("metadata")); + } + [Fact] public void DefineTool_Accepts_Lambda_Handlers_Without_Casts() { diff --git a/dotnet/test/Unit/ForwardCompatibilityTests.cs b/dotnet/test/Unit/ForwardCompatibilityTests.cs index 09133dfb52..b52a7713f7 100644 --- a/dotnet/test/Unit/ForwardCompatibilityTests.cs +++ b/dotnet/test/Unit/ForwardCompatibilityTests.cs @@ -168,6 +168,24 @@ public void FromJson_UnknownEventType_WithUnknownEnumInData_DoesNotThrow() Assert.Equal("unknown", result.Type); } + [Fact] + public void FromJson_InternalEventType_ReturnsBaseSessionEvent() + { + var json = """ + { + "id": "12345678-1234-1234-1234-123456789abc", + "timestamp": "2026-06-15T10:30:00Z", + "type": "session.memory_changed", + "data": {} + } + """; + + var result = SessionEvent.FromJson(json); + + Assert.IsType(result); + Assert.Equal("unknown", result.Type); + } + [Fact] public void FromJson_KnownEventType_WithUnknownEnumInData_PreservesValue() { diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index f6fa31d88c..ae7e885138 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -856,6 +856,48 @@ public void HooksInvokeResponse_SerializesNullOutput_AsEmptyOrNoOutputProperty() // else: property omitted, which is fine (runtime treats undefined output as no-op) } + [Fact] + public void ToolResultObject_SerializesToolReferences_WithSdkOptions() + { + var options = GetSerializerOptions(); + var original = new ToolResultObject + { + TextResultForLlm = "found 2 tools", + ResultType = "success", + ToolReferences = ["get_weather", "check_status"], + }; + + var json = JsonSerializer.Serialize(original, options); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + Assert.Equal("found 2 tools", root.GetProperty("textResultForLlm").GetString()); + var refs = root.GetProperty("toolReferences"); + Assert.Equal(JsonValueKind.Array, refs.ValueKind); + Assert.Equal(2, refs.GetArrayLength()); + Assert.Equal("get_weather", refs[0].GetString()); + Assert.Equal("check_status", refs[1].GetString()); + + var deserialized = JsonSerializer.Deserialize(json, options); + Assert.NotNull(deserialized); + string[] expectedReferences = ["get_weather", "check_status"]; + Assert.Equal(expectedReferences, deserialized!.ToolReferences); + } + + [Fact] + public void ToolResultObject_OmitsToolReferences_WhenNull_WithSdkOptions() + { + var options = GetSerializerOptions(); + var original = new ToolResultObject + { + TextResultForLlm = "ok", + ResultType = "success", + }; + + var json = JsonSerializer.Serialize(original, options); + using var document = JsonDocument.Parse(json); + Assert.False(document.RootElement.TryGetProperty("toolReferences", out _)); + } + private static JsonSerializerOptions GetSerializerOptions() { var prop = typeof(CopilotClient) diff --git a/go/README.md b/go/README.md index 5787e5a53e..44ba01d66a 100644 --- a/go/README.md +++ b/go/README.md @@ -101,6 +101,49 @@ Follow these steps to embed the CLI: That's it! When your application calls `copilot.NewClient` without a `Connection` field (or with an empty `StdioConnection{}`) and no `COPILOT_CLI_PATH` environment variable, the SDK will automatically install the embedded CLI to a cache directory and use it for all operations. +The bundler prepares the native runtime library required by the [in-process transport](#in-process-transport-experimental). It is included in the application only when building with the `copilot_inprocess` build tag. + +## In-process transport (Experimental) + +> **Experimental:** the in-process API may change in a future release. + +By default the SDK starts the runtime as a child process and talks JSON-RPC over stdio or TCP. The **in-process** transport instead loads a native runtime library directly into your process. + +Build your application with the `copilot_inprocess` build tag: + +```sh +go build -tags copilot_inprocess +``` + +```go +client := copilot.NewClient(&copilot.ClientOptions{ + Connection: copilot.InProcessConnection{}, +}) +if err := client.Start(context.Background()); err != nil { + log.Fatal(err) +} +defer client.Stop() +``` + +Resolution and requirements: + +- The application must be built with the `copilot_inprocess` build tag. +- Set `COPILOT_SDK_DEFAULT_CONNECTION=inprocess` to select the in-process + transport when `ClientOptions.Connection` is nil. An explicit connection + always takes precedence. +- Set `COPILOT_CLI_PATH` only when using an externally provisioned compatible runtime package; otherwise the bundled runtime is used. No `PATH` lookup is performed. +- Embedded runtime versions are isolated in separate cache directories. Start fails loudly if the native runtime is unavailable. +- Linux in-process bundles include both glibc and musl runtime packages and select the matching package automatically at startup. +- Only one native runtime version may be loaded per process. + +The in-process transport rejects options that cannot be honored by a runtime hosted in your shared process (each panics at `NewClient`): + +- `Env` — the host process has a single environment block. Set variables on the host process environment instead. +- `WorkingDirectory` — the runtime shares the host process's working directory. Change the process working directory before creating the client. +- `Telemetry` — per-client telemetry is lowered to native-runtime environment variables. Use a child-process transport for per-client telemetry. + +Implemented with pure-Go FFI (via [purego](https://github.com/ebitengine/purego)), so `CGO_ENABLED=0` and cross-compilation are preserved; no C toolchain is required. + ## API Reference ### Client @@ -142,11 +185,14 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec **ClientOptions:** - `Connection` (RuntimeConnection): How the SDK connects to the runtime. Construct via one of: - - `StdioConnection{Path, Args}` — spawn a runtime over stdio (the default if `Connection` is nil) - - `TCPConnection{Port, ConnectionToken, Path, Args}` — spawn a runtime that listens on TCP + - `StdioConnection{Path, Args, Env}` — spawn a runtime over stdio (the default if `Connection` is nil) + - `TCPConnection{Port, ConnectionToken, Path, Args, Env}` — spawn a runtime that listens on TCP - `URIConnection{URL, ConnectionToken}` — connect to an already-running runtime (no process spawned) + - `InProcessConnection{}` — **Experimental.** Host the runtime in-process via the native FFI library instead of spawning a child process. See [In-process transport](#in-process-transport-experimental) below. When `Path` is empty for stdio/tcp, the SDK uses the bundled CLI (or `COPILOT_CLI_PATH` env var). + + `StdioConnection` and `TCPConnection` accept an optional connection-level `Env`. Set environment variables via **either** the client-level `Env` option or the connection's `Env`, not both (setting both panics); prefer the connection-level `Env`. - `WorkingDirectory` (string): Working directory for the runtime process - `BaseDirectory` (string): Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned runtime. When empty, the runtime defaults to `~/.copilot`. Ignored with `URIConnection`. This does **not** affect where the Go SDK extracts the embedded CLI binary; use `embeddedcli.Config.Dir` for the extraction/cache location. - `LogLevel` (string): Log level. When empty (default), the runtime uses its own default level (the SDK does not pass `--log-level`). diff --git a/go/canvas.go b/go/canvas.go index 375f7c6eef..e31598bb1e 100644 --- a/go/canvas.go +++ b/go/canvas.go @@ -42,6 +42,24 @@ type ExtensionInfo struct { Name string `json:"name"` } +// CanvasProviderIdentity is the stable identity for a host/SDK connection +// that supplies built-in canvases. +// +// When set on session create or resume, the runtime uses ID verbatim as the +// agent-facing canvas extension id, so host-provided canvases survive +// reconnect and CLI restart. +// +// Experimental: CanvasProviderIdentity is part of an experimental +// wire-protocol surface and may change or be removed in future SDK or CLI +// releases. +type CanvasProviderIdentity struct { + // ID is an opaque, stable provider id used verbatim as the canvas + // extension id. + ID string `json:"id"` + // Name is an optional display name surfaced as the canvas extension name. + Name *string `json:"name,omitempty"` +} + // CanvasError is a structured error returned from canvas handlers. // // Wire envelope: diff --git a/go/client.go b/go/client.go index 5b8dabf8f2..f243268aa4 100644 --- a/go/client.go +++ b/go/client.go @@ -93,6 +93,37 @@ func validateSessionFSConfig(config *SessionFSConfig) error { return nil } +// validateEnvironmentOptions enforces the transport-specific rules for +// per-client environment, working directory, and telemetry. It panics (fails +// loud) on a misconfiguration, matching the other SDKs. +// +// The in-process transport loads the native runtime into this process, whose +// single environment block and process-global working directory cannot carry +// per-client values, and whose telemetry lowers to shared process-global env +// vars — so options that depend on them are rejected there. Child-process +// transports each own their OS process, so per-connection env is allowed, but +// setting it in both the client-level option and the connection is rejected. +func validateEnvironmentOptions(connection RuntimeConnection, opts *ClientOptions) { + if _, ok := connection.(InProcessConnection); ok { + if opts.Env != nil { + panic("Env is not supported with InProcessConnection: the in-process transport loads the native runtime into the shared host process, whose single environment block cannot carry per-client values. Set the variables on the host process environment instead.") + } + if opts.WorkingDirectory != "" { + panic("WorkingDirectory is not supported with InProcessConnection: the native runtime shares the host process working directory. Use a child-process transport, or set the process working directory before creating the client.") + } + if opts.Telemetry != nil { + panic("Telemetry is not supported with InProcessConnection: telemetry configuration is lowered to environment variables read by native runtime code running in the shared host process, so per-client telemetry cannot be honored in-process. Configure telemetry via the host process environment, or use a child-process transport.") + } + return + } + + if cp, ok := connection.(childProcessConnection); ok { + if cp.connEnv() != nil && opts.Env != nil { + panic("Set environment variables via either the client-level Env option or the connection's Env, not both. Prefer the connection-level Env for child-process transports.") + } + } +} + // Client manages the connection to the Copilot CLI server and provides session management. // // The Client can either spawn a CLI server process or connect to an existing server. @@ -124,6 +155,8 @@ type Client struct { isExternalServer bool conn net.Conn // stores net.Conn for external TCP connections useStdio bool // resolved value from options + useInProcess bool // true for InProcessConnection (FFI transport) + ffiHost inProcessHost // resolved process options for the spawned runtime (zero values for URIConnection) cliPath string cliArgs []string @@ -193,10 +226,15 @@ func NewClient(options *ClientOptions) *Client { opts = *options } - // Resolve the connection. nil defaults to an empty StdioConnection. + // Resolve the connection. An explicit connection always wins; otherwise + // honor the same process/environment override as the other SDKs. connection := opts.Connection if connection == nil { - connection = StdioConnection{} + env := opts.Env + if env == nil { + env = os.Environ() + } + connection = resolveDefaultConnection(env) } switch conn := connection.(type) { case StdioConnection: @@ -223,32 +261,52 @@ func NewClient(options *ClientOptions) *Client { client.isExternalServer = true client.useStdio = false client.tcpConnectionToken = conn.ConnectionToken + case InProcessConnection: + client.useStdio = false + client.useInProcess = true default: panic(fmt.Sprintf("unknown RuntimeConnection type: %T", connection)) } + // Validate transport-specific option constraints (fail loud). The in-process + // transport loads the runtime into this process, whose single environment + // block, process-global working directory, and shared telemetry state cannot + // carry per-client values. Child-process transports may set env via either + // the client-level option or the connection, but not both. + validateEnvironmentOptions(connection, &opts) + // Validate auth options when connecting to an external runtime. if client.isExternalServer && (opts.GitHubToken != "" || opts.UseLoggedInUser != nil) { panic("GitHubToken and UseLoggedInUser cannot be used with URIConnection (external runtime manages its own auth)") } + // For child-process transports, a connection-level env takes precedence over + // the client-level env (setting both was rejected above). Resolve it before + // defaulting so an explicit empty connection env stays authoritative. + if cp, ok := connection.(childProcessConnection); ok { + if env := cp.connEnv(); env != nil { + opts.Env = env + } + } + // Default Env to current environment if not set if opts.Env == nil { opts.Env = os.Environ() } - // Check effective environment for CLI path (only if not explicitly set via options) - if client.cliPath == "" { + // Check the effective environment for a child-process runtime override. + if client.cliPath == "" && !client.useInProcess { if cliPath := getEnvValue(opts.Env, "COPILOT_CLI_PATH"); cliPath != "" { client.cliPath = cliPath } } // Resolve the effective connection token: explicit value if set; else if the SDK - // spawns its own runtime in TCP mode, generate a UUID; otherwise empty. + // spawns its own runtime in TCP mode, generate a UUID; otherwise empty. The + // in-process transport uses no socket, so it needs no connection token. if client.tcpConnectionToken != "" { client.effectiveConnectionToken = client.tcpConnectionToken - } else if !client.useStdio && !client.isExternalServer { + } else if !client.useStdio && !client.isExternalServer && !client.useInProcess { client.effectiveConnectionToken = uuid.NewString() } @@ -266,6 +324,27 @@ func NewClient(options *ClientOptions) *Client { return client } +const defaultConnectionEnvVar = "COPILOT_SDK_DEFAULT_CONNECTION" + +// resolveDefaultConnection selects the transport when no explicit connection +// was supplied. The override is primarily used by hosts and the E2E transport +// matrix; explicit connection options always take precedence. +func resolveDefaultConnection(env []string) RuntimeConnection { + value := getEnvValue(env, defaultConnectionEnvVar) + switch { + case value == "", strings.EqualFold(value, "stdio"): + return StdioConnection{} + case strings.EqualFold(value, "inprocess"): + return InProcessConnection{} + default: + panic(fmt.Sprintf( + "invalid %s value %q: expected \"inprocess\", \"stdio\", or unset", + defaultConnectionEnvVar, + value, + )) + } +} + // getEnvValue looks up a key in an environment slice ([]string of "KEY=VALUE"). // Returns the value if found, or empty string otherwise. func getEnvValue(env []string, key string) string { @@ -451,7 +530,7 @@ func (c *Client) Stop() error { c.startStopMux.Lock() defer c.startStopMux.Unlock() - if c.process != nil && !c.isExternalServer && c.RPC != nil { + if (c.process != nil || c.ffiHost != nil) && !c.isExternalServer && c.RPC != nil { rpcClient := c.RPC runtimeShutdownStart := time.Now() shutdownDone := make(chan error, 1) @@ -486,6 +565,13 @@ func (c *Client) Stop() error { } c.process = nil + // Tear down the in-process FFI host (closes the connection and shuts down the + // native runtime). No child process to reap in this mode. + if c.ffiHost != nil { + c.ffiHost.Dispose() + c.ffiHost = nil + } + // Close external TCP connection if exists if c.isExternalServer && c.conn != nil { if err := c.conn.Close(); err != nil { @@ -567,6 +653,12 @@ func (c *Client) ForceStop() { } c.process = nil + // Dispose the in-process FFI host (if any) without waiting on graceful shutdown. + if c.ffiHost != nil { + c.ffiHost.Dispose() + c.ffiHost = nil + } + // Close external TCP connection if exists if c.isExternalServer && c.conn != nil { _ = c.conn.Close() // Ignore errors @@ -722,16 +814,20 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.DisabledSkills = config.DisabledSkills req.InfiniteSessions = config.InfiniteSessions req.LargeOutput = config.LargeOutput + req.ToolSearch = config.ToolSearch req.Memory = config.Memory req.GitHubToken = config.GitHubToken req.RemoteSession = config.RemoteSession req.Cloud = config.Cloud req.Canvases = config.Canvases + req.ExtensionInfo = config.ExtensionInfo + req.CanvasProvider = config.CanvasProvider req.RequestCanvasRenderer = config.RequestCanvasRenderer req.RequestExtensions = config.RequestExtensions req.ExtensionSDKPath = config.ExtensionSDKPath req.ExtensionInfo = config.ExtensionInfo req.ExpAssignments = config.ExpAssignments + req.EnableManagedSettings = config.EnableManagedSettings if len(config.Commands) > 0 { cmds := make([]wireCommand, 0, len(config.Commands)) @@ -1085,16 +1181,20 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.DisabledSkills = config.DisabledSkills req.InfiniteSessions = config.InfiniteSessions req.LargeOutput = config.LargeOutput + req.ToolSearch = config.ToolSearch req.Memory = config.Memory req.GitHubToken = config.GitHubToken req.RemoteSession = config.RemoteSession req.Canvases = config.Canvases req.OpenCanvases = config.OpenCanvases + req.ExtensionInfo = config.ExtensionInfo + req.CanvasProvider = config.CanvasProvider req.RequestCanvasRenderer = config.RequestCanvasRenderer req.RequestExtensions = config.RequestExtensions req.ExtensionSDKPath = config.ExtensionSDKPath req.ExtensionInfo = config.ExtensionInfo req.ExpAssignments = config.ExpAssignments + req.EnableManagedSettings = config.EnableManagedSettings if config.OnPermissionRequest != nil { req.RequestPermission = Bool(true) } @@ -1745,6 +1845,10 @@ const stderrBufferSize = 64 * 1024 // This spawns the CLI server as a subprocess using the configured transport // mode (stdio or TCP). func (c *Client) startCLIServer(ctx context.Context) error { + if c.useInProcess { + return c.startInProcess(ctx) + } + cliPath := c.cliPath if cliPath == "" { // If no CLI path is provided, attempt to use the embedded CLI if available @@ -1954,7 +2058,122 @@ func (c *Client) startCLIServer(ctx context.Context) error { } } +// startInProcess loads the native runtime library and wires the JSON-RPC client +// to its FFI byte streams. +func (c *Client) startInProcess(ctx context.Context) error { + if !inProcessAvailable { + return errors.New("in-process transport unavailable: rebuild with -tags copilot_inprocess on a supported platform") + } + + runtimePath := c.cliPath + if runtimePath == "" { + // The in-process transport does not resolve a bare command name from PATH + // (unlike the child-process transport). + if p := getEnvValue(c.options.Env, "COPILOT_CLI_PATH"); p != "" { + runtimePath = p + } + } + if runtimePath == "" { + runtimePath = embeddedcli.Path() + } + if runtimePath == "" { + return errors.New("in-process runtime unavailable: set COPILOT_CLI_PATH to a compatible runtime package or build with the bundled embedded runtime") + } + + config := c.inProcessHostConfig() + + host, err := createInProcessHost(runtimePath, config) + if err != nil { + return err + } + // Own the host before the blocking handshake so a cancelled or failed start + // leaves it disposable by Stop/ForceStop rather than leaking (host.Start runs + // on its own goroutine and cannot be interrupted once the native call begins). + c.ffiHost = host + + errCh := make(chan error, 1) + go func() { errCh <- host.Start() }() + select { + case err := <-errCh: + if err != nil { + host.Dispose() + c.ffiHost = nil + return err + } + case <-ctx.Done(): + c.ffiHost = nil + go func() { + <-errCh + host.Dispose() + }() + return ctx.Err() + } + + c.client = jsonrpc2.NewClient(host.Writer(), host.Reader()) + c.client.SetOnClose(func() { + // Run in a goroutine to avoid deadlocking with Stop/ForceStop, which hold + // startStopMux while waiting for readLoop to finish. + go func() { + c.startStopMux.Lock() + defer c.startStopMux.Unlock() + c.state = stateDisconnected + }() + }) + c.RPC = rpc.NewServerRPC(c.client) + c.internalRPC = rpc.NewInternalServerRPC(c.client) + c.setupNotificationHandler() + c.client.Start() + return nil +} + +func (c *Client) inProcessHostConfig() inProcessHostConfig { + args := make([]string, 0, 8) + if c.options.LogLevel != "" { + args = append(args, "--log-level", c.options.LogLevel) + } + if c.options.GitHubToken != "" { + args = append(args, "--auth-token-env", "COPILOT_SDK_AUTH_TOKEN") + } + useLoggedInUser := true + if c.options.UseLoggedInUser != nil { + useLoggedInUser = *c.options.UseLoggedInUser + } else if c.options.GitHubToken != "" { + useLoggedInUser = false + } + if !useLoggedInUser { + args = append(args, "--no-auto-login") + } + if c.options.SessionIdleTimeoutSeconds > 0 { + args = append(args, "--session-idle-timeout", strconv.Itoa(c.options.SessionIdleTimeoutSeconds)) + } + if c.options.EnableRemoteSessions { + args = append(args, "--remote") + } + + environment := make(map[string]string) + if c.options.GitHubToken != "" { + environment["COPILOT_SDK_AUTH_TOKEN"] = c.options.GitHubToken + } + if c.options.BaseDirectory != "" { + environment["COPILOT_HOME"] = c.options.BaseDirectory + } + if c.options.Mode == ModeEmpty { + environment["COPILOT_DISABLE_KEYTAR"] = "1" + } + + return inProcessHostConfig{ + Environment: environment, + Args: args, + } +} + func (c *Client) killProcess() error { + // Tear down the in-process FFI host on error paths that reuse killProcess to + // abort a start (there is no OS process to kill in that mode). + if c.ffiHost != nil { + c.ffiHost.Dispose() + c.ffiHost = nil + } if p := c.osProcess.Swap(nil); p != nil { if err := p.Kill(); err != nil { return fmt.Errorf("failed to kill CLI process: %w", err) @@ -2016,8 +2235,8 @@ func (c *Client) monitorProcess() { // connectToServer establishes a connection to the server. func (c *Client) connectToServer(ctx context.Context) error { - if c.useStdio { - // Already connected via stdio in startCLIServer + if c.useStdio || c.useInProcess { + // Already connected: stdio in startCLIServer, FFI streams in startInProcess. return nil } @@ -2071,7 +2290,6 @@ func (c *Client) setupNotificationHandler() { c.client.SetRequestHandler("userInput.request", jsonrpc2.RequestHandlerFor(c.handleUserInputRequest)) c.client.SetRequestHandler("exitPlanMode.request", jsonrpc2.RequestHandlerFor(c.handleExitPlanModeRequest)) c.client.SetRequestHandler("autoModeSwitch.request", jsonrpc2.RequestHandlerFor(c.handleAutoModeSwitchRequest)) - c.client.SetRequestHandler("hooks.invoke", jsonrpc2.RequestHandlerFor(c.handleHooksInvoke)) c.client.SetRequestHandler("systemMessage.transform", jsonrpc2.RequestHandlerFor(c.handleSystemMessageTransform)) rpc.RegisterClientSessionAPIHandlers(c.client, func(sessionID string) *rpc.ClientSessionAPIHandlers { c.sessionsMux.Lock() @@ -2082,21 +2300,25 @@ func (c *Client) setupNotificationHandler() { } return session.clientSessionAPIs }) - if c.options.RequestHandler != nil || c.options.OnGitHubTelemetry != nil { - handlers := &rpc.ClientGlobalAPIHandlers{} - if c.options.RequestHandler != nil { - handlers.LlmInference = newCopilotRequestAdapter(c.options.RequestHandler, func() *rpc.ServerLlmInferenceAPI { - if c.RPC == nil { - return nil - } - return c.RPC.LlmInference - }) - } - if c.options.OnGitHubTelemetry != nil { - handlers.GitHubTelemetry = &gitHubTelemetryAdapter{callback: c.options.OnGitHubTelemetry} - } - rpc.RegisterClientGlobalAPIHandlers(c.client, handlers) + // hooks.invoke is a client-global RPC method: one connection-level handler + // receives every hook callback and routes to the owning session via the + // payload's sessionId. Always register the global handlers so the generated + // hooks.invoke handler is wired to our dispatcher. + handlers := &rpc.ClientGlobalAPIHandlers{ + Hooks: &hooksAdapter{client: c}, + } + if c.options.RequestHandler != nil { + handlers.LlmInference = newCopilotRequestAdapter(c.options.RequestHandler, func() *rpc.ServerLlmInferenceAPI { + if c.RPC == nil { + return nil + } + return c.RPC.LlmInference + }) + } + if c.options.OnGitHubTelemetry != nil { + handlers.GitHubTelemetry = &gitHubTelemetryAdapter{callback: c.options.OnGitHubTelemetry} } + rpc.RegisterClientGlobalAPIHandlers(c.client, handlers) } // gitHubTelemetryAdapter adapts the OnGitHubTelemetry option to the generated @@ -2204,7 +2426,8 @@ func (c *Client) handleAutoModeSwitchRequest(req autoModeSwitchRequest) (*autoMo return &autoModeSwitchResponse{Response: response}, nil } -// handleHooksInvoke handles a hooks invocation from the CLI server. +// handleHooksInvoke routes a hook callback to its owning session, keyed by the +// payload's sessionId. func (c *Client) handleHooksInvoke(req hooksInvokeRequest) (map[string]any, *jsonrpc2.Error) { if req.SessionID == "" || req.Type == "" { return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid hooks invoke payload"} @@ -2229,6 +2452,34 @@ func (c *Client) handleHooksInvoke(req hooksInvokeRequest) (map[string]any, *jso return result, nil } +// hooksAdapter implements the generated rpc.HooksHandler, delegating to the +// client's per-session hook dispatcher. +type hooksAdapter struct { + client *Client +} + +func (a *hooksAdapter) Invoke(request *rpc.HookInvokeRequest) (*rpc.HookInvokeResponse, error) { + rawInput, err := json.Marshal(request.Input) + if err != nil { + return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("invalid hooks invoke payload: %v", err)} + } + + result, rpcErr := a.client.handleHooksInvoke(hooksInvokeRequest{ + SessionID: request.SessionID, + Type: string(request.HookType), + Input: rawInput, + }) + if rpcErr != nil { + return nil, rpcErr + } + + response := &rpc.HookInvokeResponse{} + if result != nil { + response.Output = result["output"] + } + return response, nil +} + // handleSystemMessageTransform handles a system message transform request from the CLI server. func (c *Client) handleSystemMessageTransform(req systemMessageTransformRequest) (systemMessageTransformResponse, *jsonrpc2.Error) { if req.SessionID == "" { diff --git a/go/client_test.go b/go/client_test.go index bd48baacd7..b24628d836 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -251,6 +251,82 @@ func TestClient_ForwardsCapiOptionsToSessionRequests(t *testing.T) { assertCapiEnableWebSocketResponses(t, <-resumeParams) } +func TestClient_ForwardsCanvasProviderToSessionRequests(t *testing.T) { + rpcClient, server, _ := newRuntimeShutdownRpcPair(t) + t.Cleanup(server.Stop) + client := &Client{ + client: rpcClient, + RPC: rpc.NewServerRPC(rpcClient), + sessions: make(map[string]*Session), + } + + createParams := make(chan json.RawMessage, 1) + server.SetRequestHandler("session.create", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + createParams <- append(json.RawMessage(nil), params...) + sessionID := sessionIDFromParams(t, params) + return []byte(`{"sessionId":"` + sessionID + `","workspacePath":"/workspace"}`), nil + }) + + _, err := client.CreateSession(t.Context(), &SessionConfig{ + ExtensionInfo: &ExtensionInfo{Source: "github-app", Name: "counter-provider"}, + CanvasProvider: &CanvasProviderIdentity{ID: "app:builtin:window-1", Name: String("Built-in")}, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + assertCanvasProviderForwarded(t, <-createParams, "app:builtin:window-1", "Built-in", "counter-provider") + + resumeParams := make(chan json.RawMessage, 1) + server.SetRequestHandler("session.resume", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + resumeParams <- append(json.RawMessage(nil), params...) + return []byte(`{"sessionId":"resumed-canvas","workspacePath":"/workspace"}`), nil + }) + + _, err = client.ResumeSessionWithOptions(t.Context(), "resumed-canvas", &ResumeSessionConfig{ + CanvasProvider: &CanvasProviderIdentity{ID: "app:builtin:window-1"}, + }) + if err != nil { + t.Fatalf("ResumeSessionWithOptions failed: %v", err) + } + assertCanvasProviderForwarded(t, <-resumeParams, "app:builtin:window-1", "", "") +} + +// assertCanvasProviderForwarded checks the outbound params carry canvasProvider +// with the expected id. A non-empty wantName asserts the name is present; an +// empty wantName asserts the name key is omitted from the wire. A non-empty +// wantExtensionName asserts extensionInfo.name is forwarded alongside it. +func assertCanvasProviderForwarded(t *testing.T, params json.RawMessage, wantID, wantName, wantExtensionName string) { + t.Helper() + + var decoded map[string]any + if err := json.Unmarshal(params, &decoded); err != nil { + t.Fatalf("failed to unmarshal request params: %v", err) + } + provider, ok := decoded["canvasProvider"].(map[string]any) + if !ok { + t.Fatalf("expected canvasProvider object in request params, got %T", decoded["canvasProvider"]) + } + if provider["id"] != wantID { + t.Fatalf("expected canvasProvider.id=%q, got %v", wantID, provider["id"]) + } + if wantName == "" { + if _, present := provider["name"]; present { + t.Fatalf("expected canvasProvider.name to be omitted, got %v", provider["name"]) + } + } else if provider["name"] != wantName { + t.Fatalf("expected canvasProvider.name=%q, got %v", wantName, provider["name"]) + } + if wantExtensionName != "" { + info, ok := decoded["extensionInfo"].(map[string]any) + if !ok { + t.Fatalf("expected extensionInfo object in request params, got %T", decoded["extensionInfo"]) + } + if info["name"] != wantExtensionName { + t.Fatalf("expected extensionInfo.name=%q, got %v", wantExtensionName, info["name"]) + } + } +} + func TestClient_ForwardsNewSessionOptionsToSessionRequests(t *testing.T) { rpcClient, server, _ := newRuntimeShutdownRpcPair(t) t.Cleanup(server.Stop) @@ -549,6 +625,198 @@ func TestClient_EnvOptions(t *testing.T) { }) } +func TestClient_InProcessConnection(t *testing.T) { + t.Run("requires build tag", func(t *testing.T) { + if inProcessAvailable { + t.Skip("in-process transport is enabled") + } + + client := NewClient(&ClientOptions{Connection: InProcessConnection{}}) + err := client.Start(context.Background()) + if err == nil || !strings.Contains(err.Error(), "-tags copilot_inprocess") { + t.Fatalf("Expected build-tag error, got %v", err) + } + }) + + t.Run("uses in-process transport", func(t *testing.T) { + client := NewClient(&ClientOptions{Connection: InProcessConnection{}}) + if !client.useInProcess { + t.Error("Expected useInProcess=true for InProcessConnection") + } + if client.useStdio { + t.Error("Expected useStdio=false for InProcessConnection") + } + if client.isExternalServer { + t.Error("Expected isExternalServer=false for InProcessConnection") + } + if client.cliPath != "" { + t.Errorf("Expected in-process cliPath to stay empty at construction, got %q", client.cliPath) + } + }) + + t.Run("does not resolve COPILOT_CLI_PATH into cliPath at construction", func(t *testing.T) { + t.Setenv("COPILOT_CLI_PATH", "/from/env/copilot") + client := NewClient(&ClientOptions{Connection: InProcessConnection{}}) + if client.cliPath != "" { + t.Errorf("Expected in-process cliPath to stay empty at construction, got %q", client.cliPath) + } + }) + + t.Run("panics when Env is set", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("Expected panic when Env is set with InProcessConnection") + } + }() + NewClient(&ClientOptions{ + Connection: InProcessConnection{}, + Env: []string{"FOO=bar"}, + }) + }) + + t.Run("panics when WorkingDirectory is set", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("Expected panic when WorkingDirectory is set with InProcessConnection") + } + }() + NewClient(&ClientOptions{ + Connection: InProcessConnection{}, + WorkingDirectory: "/tmp/work", + }) + }) + + t.Run("panics when Telemetry is set", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("Expected panic when Telemetry is set with InProcessConnection") + } + }() + NewClient(&ClientOptions{ + Connection: InProcessConnection{}, + Telemetry: &TelemetryConfig{ExporterType: "file"}, + }) + }) + + t.Run("forwards typed runtime options", func(t *testing.T) { + client := NewClient(&ClientOptions{ + Connection: InProcessConnection{}, + GitHubToken: "test-token", + UseLoggedInUser: Bool(false), + BaseDirectory: "/copilot-home", + LogLevel: "debug", + SessionIdleTimeoutSeconds: 30, + EnableRemoteSessions: true, + Mode: ModeEmpty, + }) + + config := client.inProcessHostConfig() + expectedArgs := []string{ + "--log-level", "debug", + "--auth-token-env", "COPILOT_SDK_AUTH_TOKEN", + "--no-auto-login", + "--session-idle-timeout", "30", + "--remote", + } + if !reflect.DeepEqual(config.Args, expectedArgs) { + t.Fatalf("Expected managed arguments %v, got %v", expectedArgs, config.Args) + } + expectedEnvironment := map[string]string{ + "COPILOT_SDK_AUTH_TOKEN": "test-token", + "COPILOT_HOME": "/copilot-home", + "COPILOT_DISABLE_KEYTAR": "1", + } + if !reflect.DeepEqual(config.Environment, expectedEnvironment) { + t.Fatalf("Expected managed environment %v, got %v", expectedEnvironment, config.Environment) + } + }) +} + +func TestClient_DefaultConnection(t *testing.T) { + t.Run("defaults to stdio when override is unset", func(t *testing.T) { + t.Setenv(defaultConnectionEnvVar, "") + + client := NewClient(nil) + + if !client.useStdio || client.useInProcess { + t.Fatalf("Expected stdio default, got useStdio=%v useInProcess=%v", client.useStdio, client.useInProcess) + } + }) + + t.Run("selects in-process case-insensitively", func(t *testing.T) { + t.Setenv(defaultConnectionEnvVar, "InPrOcEsS") + + client := NewClient(nil) + + if !client.useInProcess || client.useStdio { + t.Fatalf("Expected in-process default, got useStdio=%v useInProcess=%v", client.useStdio, client.useInProcess) + } + }) + + t.Run("accepts explicit stdio override", func(t *testing.T) { + t.Setenv(defaultConnectionEnvVar, "STDIO") + + client := NewClient(nil) + + if !client.useStdio || client.useInProcess { + t.Fatalf("Expected stdio default, got useStdio=%v useInProcess=%v", client.useStdio, client.useInProcess) + } + }) + + t.Run("explicit connection takes precedence", func(t *testing.T) { + t.Setenv(defaultConnectionEnvVar, "inprocess") + + client := NewClient(&ClientOptions{Connection: TCPConnection{Port: 1234}}) + + if client.useInProcess || client.useStdio || client.port != 1234 { + t.Fatalf("Expected explicit TCP connection to win, got useStdio=%v useInProcess=%v port=%d", client.useStdio, client.useInProcess, client.port) + } + }) + + t.Run("panics for invalid override", func(t *testing.T) { + t.Setenv(defaultConnectionEnvVar, "tcp") + + defer func() { + if r := recover(); r == nil { + t.Fatal("Expected invalid default connection override to panic") + } + }() + NewClient(nil) + }) +} + +func TestClient_ConnectionLevelEnv(t *testing.T) { + t.Run("rejects env set on both client and connection", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("Expected panic when env is set on both client and connection") + } + }() + NewClient(&ClientOptions{ + Connection: StdioConnection{Env: []string{"A=1"}}, + Env: []string{"B=2"}, + }) + }) + + t.Run("stdio connection env is used when client env is unset", func(t *testing.T) { + client := NewClient(&ClientOptions{ + Connection: StdioConnection{Env: []string{"ONLY=conn"}}, + }) + if len(client.options.Env) != 1 || client.options.Env[0] != "ONLY=conn" { + t.Errorf("Expected connection-level Env to be used, got %v", client.options.Env) + } + }) + + t.Run("tcp connection env is used when client env is unset", func(t *testing.T) { + client := NewClient(&ClientOptions{ + Connection: TCPConnection{Port: 9000, Env: []string{"ONLY=conn"}}, + }) + if len(client.options.Env) != 1 || client.options.Env[0] != "ONLY=conn" { + t.Errorf("Expected connection-level Env to be used, got %v", client.options.Env) + } + }) +} + func TestClient_SessionIdleTimeoutSeconds(t *testing.T) { t.Run("should store SessionIdleTimeoutSeconds option", func(t *testing.T) { client := NewClient(&ClientOptions{ @@ -1160,6 +1428,53 @@ func TestToolDefer(t *testing.T) { }) } +func TestToolMetadata(t *testing.T) { + t.Run("Metadata is serialized in tool definition", func(t *testing.T) { + tool := Tool{ + Name: "my_tool", + Description: "A custom tool", + Metadata: map[string]any{ + "github.com/copilot:safeForTelemetry": map[string]any{"name": true, "inputsNames": false}, + }, + Handler: func(_ ToolInvocation) (ToolResult, error) { return ToolResult{}, nil }, + } + data, err := json.Marshal(tool) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + meta, ok := m["metadata"].(map[string]any) + if !ok { + t.Fatalf("expected metadata object, got %v", m) + } + if _, ok := meta["github.com/copilot:safeForTelemetry"]; !ok { + t.Errorf("expected namespaced key preserved, got %v", meta) + } + }) + + t.Run("Metadata omitted when unset", func(t *testing.T) { + tool := Tool{ + Name: "custom_tool", + Description: "A custom tool", + Handler: func(_ ToolInvocation) (ToolResult, error) { return ToolResult{}, nil }, + } + data, err := json.Marshal(tool) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if _, ok := m["metadata"]; ok { + t.Errorf("expected metadata to be omitted, got %v", m) + } + }) +} + func TestClient_CreateSession_AllowsMissingPermissionHandler(t *testing.T) { t.Run("accepts nil config before connection validation", func(t *testing.T) { client := NewClient(&ClientOptions{Connection: StdioConnection{Path: "/__nonexistent_copilot_binary__"}}) diff --git a/go/cmd/bundler/main.go b/go/cmd/bundler/main.go index 1e5f5ecd8b..e63d1fde66 100644 --- a/go/cmd/bundler/main.go +++ b/go/cmd/bundler/main.go @@ -91,14 +91,47 @@ func main() { fmt.Printf("Building bundle for %s (CLI version %s)\n", *platform, version) - binaryPath, sha256Hash, err := buildBundle(info, version, outputPath) + binaryPath, sha256Hash, runtimeArtifactPath, runtimeHash, err := buildBundle(info, version, outputPath, goos) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } + var muslBinaryPath, muslRuntimeArtifactPath string + var muslBinaryHash, muslRuntimeHash []byte + if goos == "linux" { + muslInfo := platformInfo{ + npmPlatform: strings.Replace(info.npmPlatform, "linux-", "linuxmusl-", 1), + binaryName: info.binaryName, + } + muslOutputPath := filepath.Join(*output, defaultOutputFileName(version, "linuxmusl", goarch, info.binaryName)) + muslBinaryPath, muslBinaryHash, muslRuntimeArtifactPath, muslRuntimeHash, err = buildBundle( + muslInfo, + version, + muslOutputPath, + goos, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + } + // Generate the Go file with embed directive - if err := generateGoFile(goos, goarch, binaryPath, version, sha256Hash, "main"); err != nil { + if err := generateGoFile( + goos, + goarch, + binaryPath, + version, + sha256Hash, + runtimeArtifactPath, + runtimeHash, + muslBinaryPath, + muslBinaryHash, + muslRuntimeArtifactPath, + muslRuntimeHash, + "main", + ); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } @@ -253,12 +286,16 @@ func isHex(s string) bool { return true } -// buildBundle downloads the CLI binary and writes it to outputPath. -func buildBundle(info platformInfo, cliVersion, outputPath string) (string, []byte, error) { +// buildBundle downloads the CLI binary (and, when the CLI package ships it, the +// native in-process runtime library) and writes them to outputPath's directory. +// It returns the CLI bundle path and hash, plus the runtime-library artifact path +// and hash (both empty when the package does not ship the runtime library). +func buildBundle(info platformInfo, cliVersion, outputPath, goos string) (string, []byte, string, []byte, error) { outputDir := filepath.Dir(outputPath) if outputDir == "" { outputDir = "." } + runtimeArtifactPath := filepath.Join(outputDir, runtimeLibArtifactName(cliVersion, info.npmPlatform, goos)) // Check if output already exists if _, err := os.Stat(outputPath); err == nil { @@ -266,68 +303,254 @@ func buildBundle(info platformInfo, cliVersion, outputPath string) (string, []by fmt.Printf("Output %s already exists, skipping download\n", outputPath) sha256Hash, err := sha256FileFromCompressed(outputPath) if err != nil { - return "", nil, fmt.Errorf("failed to hash existing output: %w", err) + return "", nil, "", nil, fmt.Errorf("failed to hash existing output: %w", err) } if err := downloadCLILicense(cliVersion, outputPath); err != nil { - return "", nil, fmt.Errorf("failed to download CLI license: %w", err) + return "", nil, "", nil, fmt.Errorf("failed to download CLI license: %w", err) + } + // Reuse an existing runtime-library artifact if present. + if _, err := os.Stat(runtimeArtifactPath); err == nil { + runtimeHash, err := sha256FileFromCompressed(runtimeArtifactPath) + if err != nil { + return "", nil, "", nil, fmt.Errorf("failed to hash existing runtime library: %w", err) + } + return outputPath, sha256Hash, runtimeArtifactPath, runtimeHash, nil } - return outputPath, sha256Hash, nil + return outputPath, sha256Hash, "", nil, nil } // Create temp directory for download tempDir, err := os.MkdirTemp("", "copilot-bundler-*") if err != nil { - return "", nil, fmt.Errorf("failed to create temp dir: %w", err) + return "", nil, "", nil, fmt.Errorf("failed to create temp dir: %w", err) } defer os.RemoveAll(tempDir) // Download the binary - binaryPath, err := downloadCLIBinary(info.npmPlatform, info.binaryName, cliVersion, tempDir) + binaryPath, tarballPath, err := downloadCLIBinary(info.npmPlatform, info.binaryName, cliVersion, tempDir) if err != nil { - return "", nil, fmt.Errorf("failed to download CLI binary: %w", err) + return "", nil, "", nil, fmt.Errorf("failed to download CLI binary: %w", err) } // Create output directory if needed if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { - return "", nil, fmt.Errorf("failed to create output directory: %w", err) + return "", nil, "", nil, fmt.Errorf("failed to create output directory: %w", err) } } sha256Hash, err := sha256File(binaryPath) if err != nil { - return "", nil, fmt.Errorf("failed to hash output binary: %w", err) + return "", nil, "", nil, fmt.Errorf("failed to hash output binary: %w", err) } if err := compressZstdFile(binaryPath, outputPath); err != nil { - return "", nil, fmt.Errorf("failed to write output binary: %w", err) + return "", nil, "", nil, fmt.Errorf("failed to write output binary: %w", err) } if err := downloadCLILicense(cliVersion, outputPath); err != nil { - return "", nil, fmt.Errorf("failed to download CLI license: %w", err) + return "", nil, "", nil, fmt.Errorf("failed to download CLI license: %w", err) } + + // Extract the native in-process runtime library from the same tarball, if the + // package ships it (older CLI versions do not). Missing is not an error — the + // generated file simply omits the runtime embed for that platform. + rawLibPath := filepath.Join(tempDir, "runtime.node") + found, err := extractOptionalFileFromTarball(tarballPath, tempDir, + "package/prebuilds/"+info.npmPlatform+"/runtime.node", "runtime.node") + if err != nil { + return "", nil, "", nil, fmt.Errorf("failed to extract runtime library: %w", err) + } + var runtimeHash []byte + returnedRuntimeArtifact := "" + if found { + runtimeHash, err = sha256File(rawLibPath) + if err != nil { + return "", nil, "", nil, fmt.Errorf("failed to hash runtime library: %w", err) + } + if err := compressZstdFile(rawLibPath, runtimeArtifactPath); err != nil { + return "", nil, "", nil, fmt.Errorf("failed to write runtime library: %w", err) + } + returnedRuntimeArtifact = runtimeArtifactPath + fmt.Printf("Successfully created %s\n", runtimeArtifactPath) + } else { + fmt.Printf("Package %s does not ship a runtime library; in-process transport unavailable for this platform bundle\n", info.npmPlatform) + } + fmt.Printf("Successfully created %s\n", outputPath) - return outputPath, sha256Hash, nil + return outputPath, sha256Hash, returnedRuntimeArtifact, runtimeHash, nil } -// generateGoFile creates a Go source file that embeds the binary and metadata. -func generateGoFile(goos, goarch, binaryPath, cliVersion string, sha256Hash []byte, pkgName string) error { - // Generate Go file path: zcopilot_linux_amd64.go (without version) +// runtimeLibArtifactName builds the compressed runtime-library artifact filename. +func runtimeLibArtifactName(version, npmPlatform, goos string) string { + return fmt.Sprintf("zcopilotruntime_%s_%s.%s.zst", version, npmPlatform, runtimeLibExt(goos)) +} + +// runtimeLibExt returns the shared-library extension for the target OS. +func runtimeLibExt(goos string) string { + switch goos { + case "windows": + return "dll" + case "darwin": + return "dylib" + default: + return "so" + } +} + +// generateGoFile creates separate source files for normal and in-process builds. +// Both embed the CLI, while only the copilot_inprocess-tagged file embeds the +// native runtime library. +func generateGoFile( + goos, + goarch, + binaryPath, + cliVersion string, + sha256Hash []byte, + runtimeArtifactPath string, + runtimeHash []byte, + muslBinaryPath string, + muslBinaryHash []byte, + muslRuntimeArtifactPath string, + muslRuntimeHash []byte, + pkgName string, +) error { binaryName := filepath.Base(binaryPath) licenseName := licenseFileName(binaryName) - goFileName := fmt.Sprintf("zcopilot_%s_%s.go", goos, goarch) - goFilePath := filepath.Join(filepath.Dir(binaryPath), goFileName) hashBase64 := "" if len(sha256Hash) > 0 { hashBase64 = base64.StdEncoding.EncodeToString(sha256Hash) } - content := fmt.Sprintf(`// Code generated by copilot-sdk bundler; DO NOT EDIT. + outputDir := filepath.Dir(binaryPath) + defaultPath := filepath.Join(outputDir, fmt.Sprintf("zcopilot_%s_%s.go", goos, goarch)) + defaultContent := generatedGoFileContent( + "!copilot_inprocess", + pkgName, + binaryName, + licenseName, + cliVersion, + hashBase64, + "", + nil, + "", + nil, + "", + nil, + ) + if err := os.WriteFile(defaultPath, []byte(defaultContent), 0644); err != nil { + return err + } + + inProcessPath := filepath.Join(outputDir, fmt.Sprintf("zcopilot_inprocess_%s_%s.go", goos, goarch)) + inProcessContent := generatedGoFileContent( + "copilot_inprocess", + pkgName, + binaryName, + licenseName, + cliVersion, + hashBase64, + runtimeArtifactPath, + runtimeHash, + muslBinaryPath, + muslBinaryHash, + muslRuntimeArtifactPath, + muslRuntimeHash, + ) + if err := os.WriteFile(inProcessPath, []byte(inProcessContent), 0644); err != nil { + return err + } + + fmt.Printf("Generated %s\n", defaultPath) + fmt.Printf("Generated %s\n", inProcessPath) + return nil +} + +func generatedGoFileContent( + buildConstraint, + pkgName, + binaryName, + licenseName, + cliVersion, + hashBase64, + runtimeArtifactPath string, + runtimeHash []byte, + muslBinaryPath string, + muslBinaryHash []byte, + muslRuntimeArtifactPath string, + muslRuntimeHash []byte, +) string { + runtimeEmbed := "" + runtimeConfig := "" + runtimeReader := "" + if runtimeArtifactPath != "" { + runtimeArtifactName := filepath.Base(runtimeArtifactPath) + runtimeHashBase64 := base64.StdEncoding.EncodeToString(runtimeHash) + runtimeEmbed = fmt.Sprintf(` +//go:embed %s +var localEmbeddedCopilotRuntimeLib []byte +`, runtimeArtifactName) + runtimeConfig = fmt.Sprintf(` + RuntimeLib: runtimeLibReader(), + RuntimeLibHash: mustDecodeBase64(%q),`, runtimeHashBase64) + runtimeReader = ` +func runtimeLibReader() io.Reader { + r, err := zstd.NewReader(bytes.NewReader(localEmbeddedCopilotRuntimeLib)) + if err != nil { + panic("failed to create zstd reader: " + err.Error()) + } + return r +} +` + } + + muslEmbed := "" + muslConfig := "" + muslReaders := "" + if muslBinaryPath != "" && muslRuntimeArtifactPath != "" { + muslBinaryName := filepath.Base(muslBinaryPath) + muslBinaryHashBase64 := base64.StdEncoding.EncodeToString(muslBinaryHash) + muslRuntimeName := filepath.Base(muslRuntimeArtifactPath) + muslRuntimeHashBase64 := base64.StdEncoding.EncodeToString(muslRuntimeHash) + muslEmbed = fmt.Sprintf(` +//go:embed %s +var localEmbeddedCopilotCLILinuxMusl []byte + +//go:embed %s +var localEmbeddedCopilotRuntimeLibLinuxMusl []byte +`, muslBinaryName, muslRuntimeName) + muslConfig = fmt.Sprintf(` + LinuxMuslCli: linuxMuslCLIReader(), + LinuxMuslCliHash: mustDecodeBase64(%q), + LinuxMuslRuntimeLib: linuxMuslRuntimeLibReader(), + LinuxMuslRuntimeLibHash: mustDecodeBase64(%q),`, muslBinaryHashBase64, muslRuntimeHashBase64) + muslReaders = ` +func linuxMuslCLIReader() io.Reader { + r, err := zstd.NewReader(bytes.NewReader(localEmbeddedCopilotCLILinuxMusl)) + if err != nil { + panic("failed to create zstd reader: " + err.Error()) + } + return r +} + +func linuxMuslRuntimeLibReader() io.Reader { + r, err := zstd.NewReader(bytes.NewReader(localEmbeddedCopilotRuntimeLibLinuxMusl)) + if err != nil { + panic("failed to create zstd reader: " + err.Error()) + } + return r +} +` + } + + return fmt.Sprintf(`//go:build %s + +// Code generated by copilot-sdk bundler; DO NOT EDIT. package %s import ( "bytes" - "io" "encoding/base64" _ "embed" + "io" "github.com/github/copilot-sdk/go/embeddedcli" "github.com/klauspost/compress/zstd" @@ -338,14 +561,15 @@ var localEmbeddedCopilotCLI []byte //go:embed %s var localEmbeddedCopilotCLILicense []byte - +%s +%s func init() { embeddedcli.Setup(embeddedcli.Config{ Cli: cliReader(), License: localEmbeddedCopilotCLILicense, Version: %q, - CliHash: mustDecodeBase64(%q), + CliHash: mustDecodeBase64(%q),%s%s }) } @@ -356,7 +580,8 @@ func cliReader() io.Reader { } return r } - +%s +%s func mustDecodeBase64(s string) []byte { b, err := base64.StdEncoding.DecodeString(s) if err != nil { @@ -364,73 +589,68 @@ func mustDecodeBase64(s string) []byte { } return b } -`, pkgName, binaryName, licenseName, cliVersion, hashBase64) - - if err := os.WriteFile(goFilePath, []byte(content), 0644); err != nil { - return err - } - - fmt.Printf("Generated %s\n", goFilePath) - return nil +`, buildConstraint, pkgName, binaryName, licenseName, runtimeEmbed, muslEmbed, cliVersion, hashBase64, runtimeConfig, muslConfig, runtimeReader, muslReaders) } -// downloadCLIBinary downloads the npm tarball and extracts the CLI binary. -func downloadCLIBinary(npmPlatform, binaryName, cliVersion, destDir string) (string, error) { +// downloadCLIBinary downloads the npm tarball and extracts the CLI binary. It +// returns the extracted binary path and the downloaded tarball path (retained so +// callers can extract additional files, such as the runtime library). +func downloadCLIBinary(npmPlatform, binaryName, cliVersion, destDir string) (string, string, error) { tarballURL := fmt.Sprintf(tarballURLFmt, npmPlatform, npmPlatform, cliVersion) fmt.Printf("Downloading from %s...\n", tarballURL) resp, err := http.Get(tarballURL) if err != nil { - return "", fmt.Errorf("failed to download: %w", err) + return "", "", fmt.Errorf("failed to download: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("failed to download: %s", resp.Status) + return "", "", fmt.Errorf("failed to download: %s", resp.Status) } // Save tarball to temp file tarballPath := filepath.Join(destDir, fmt.Sprintf("copilot-%s-%s.tgz", npmPlatform, cliVersion)) tarballFile, err := os.Create(tarballPath) if err != nil { - return "", fmt.Errorf("failed to create tarball file: %w", err) + return "", "", fmt.Errorf("failed to create tarball file: %w", err) } if _, err := io.Copy(tarballFile, resp.Body); err != nil { tarballFile.Close() - return "", fmt.Errorf("failed to save tarball: %w", err) + return "", "", fmt.Errorf("failed to save tarball: %w", err) } if err := tarballFile.Close(); err != nil { - return "", fmt.Errorf("failed to close tarball file: %w", err) + return "", "", fmt.Errorf("failed to close tarball file: %w", err) } // Extract only the CLI binary to avoid unpacking the full package tree. binaryPath := filepath.Join(destDir, binaryName) if err := extractFileFromTarball(tarballPath, destDir, "package/"+binaryName, binaryName); err != nil { - return "", fmt.Errorf("failed to extract binary: %w", err) + return "", "", fmt.Errorf("failed to extract binary: %w", err) } // Verify binary exists if _, err := os.Stat(binaryPath); err != nil { - return "", fmt.Errorf("binary not found after extraction: %w", err) + return "", "", fmt.Errorf("binary not found after extraction: %w", err) } // Make executable on Unix if !strings.HasSuffix(binaryName, ".exe") { if err := os.Chmod(binaryPath, 0755); err != nil { - return "", fmt.Errorf("failed to chmod binary: %w", err) + return "", "", fmt.Errorf("failed to chmod binary: %w", err) } } stat, err := os.Stat(binaryPath) if err != nil { - return "", fmt.Errorf("failed to stat binary: %w", err) + return "", "", fmt.Errorf("failed to stat binary: %w", err) } sizeMB := float64(stat.Size()) / 1024 / 1024 fmt.Printf("Downloaded %s (%.1f MB)\n", binaryName, sizeMB) - return binaryPath, nil + return binaryPath, tarballPath, nil } // downloadCLILicense downloads the @github/copilot package and writes its license next to outputPath. @@ -561,6 +781,21 @@ func extractFileFromTarball(tarballPath, destDir, targetPath, outputName string) return fmt.Errorf("file %q not found in tarball", targetPath) } +// extractOptionalFileFromTarball extracts a single file from a .tgz into destDir +// like extractFileFromTarball, but returns (false, nil) instead of an error when +// the file is absent. Used for the runtime library, which older CLI packages do +// not ship. +func extractOptionalFileFromTarball(tarballPath, destDir, targetPath, outputName string) (bool, error) { + err := extractFileFromTarball(tarballPath, destDir, targetPath, outputName) + if err == nil { + return true, nil + } + if strings.Contains(err.Error(), "not found in tarball") { + return false, nil + } + return false, err +} + // compressZstdFile compresses src into dst using zstd. func compressZstdFile(src, dst string) error { srcFile, err := os.Open(src) diff --git a/go/cmd/bundler/main_test.go b/go/cmd/bundler/main_test.go new file mode 100644 index 0000000000..badc791359 --- /dev/null +++ b/go/cmd/bundler/main_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGenerateGoFileGatesRuntimeEmbed(t *testing.T) { + dir := t.TempDir() + binaryPath := filepath.Join(dir, "copilot.zst") + runtimePath := filepath.Join(dir, "runtime.node.zst") + muslBinaryPath := filepath.Join(dir, "copilot-musl.zst") + muslRuntimePath := filepath.Join(dir, "runtime-musl.node.zst") + for _, path := range []string{ + binaryPath, + licensePathForOutput(binaryPath), + runtimePath, + muslBinaryPath, + muslRuntimePath, + } { + if err := os.WriteFile(path, []byte("test"), 0644); err != nil { + t.Fatal(err) + } + } + + hash := make([]byte, 32) + if err := generateGoFile( + "linux", + "amd64", + binaryPath, + "1.2.3", + hash, + runtimePath, + hash, + muslBinaryPath, + hash, + muslRuntimePath, + hash, + "main", + ); err != nil { + t.Fatal(err) + } + + defaultSource, err := os.ReadFile(filepath.Join(dir, "zcopilot_linux_amd64.go")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(defaultSource), "//go:build !copilot_inprocess") { + t.Fatal("default embed file does not exclude copilot_inprocess builds") + } + if strings.Contains(string(defaultSource), "localEmbeddedCopilotRuntimeLib") { + t.Fatal("default embed file includes the native runtime") + } + if _, err := parser.ParseFile(token.NewFileSet(), "zcopilot_linux_amd64.go", defaultSource, parser.AllErrors); err != nil { + t.Fatalf("default generated source is invalid: %v", err) + } + + inProcessSource, err := os.ReadFile(filepath.Join(dir, "zcopilot_inprocess_linux_amd64.go")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(inProcessSource), "//go:build copilot_inprocess") { + t.Fatal("in-process embed file does not require the copilot_inprocess tag") + } + if !strings.Contains(string(inProcessSource), "localEmbeddedCopilotRuntimeLib") { + t.Fatal("in-process embed file does not include the native runtime") + } + if !strings.Contains(string(inProcessSource), "localEmbeddedCopilotCLILinuxMusl") { + t.Fatal("in-process embed file does not include the Linux musl CLI") + } + if !strings.Contains(string(inProcessSource), "localEmbeddedCopilotRuntimeLibLinuxMusl") { + t.Fatal("in-process embed file does not include the Linux musl runtime") + } + if _, err := parser.ParseFile(token.NewFileSet(), "zcopilot_inprocess_linux_amd64.go", inProcessSource, parser.AllErrors); err != nil { + t.Fatalf("in-process generated source is invalid: %v", err) + } +} diff --git a/go/copilot_request_handler.go b/go/copilot_request_handler.go index c1ac52bc2e..ba8bb9b919 100644 --- a/go/copilot_request_handler.go +++ b/go/copilot_request_handler.go @@ -50,8 +50,11 @@ var sharedHTTPTransport = func() http.RoundTripper { // CopilotRequestContext is the per-request context handed to every // [CopilotRequestHandler] seam. type CopilotRequestContext struct { - RequestID string - SessionID string + RequestID string + SessionID string + AgentID string + ParentAgentID string + InteractionType string // Transport is "http" (covering plain HTTP and SSE) or "websocket". Transport string Method string @@ -144,12 +147,12 @@ type CopilotWebSocketHandler interface { // copilotContextKey is used to attach [CopilotRequestContext] to an // [http.Request] so custom [http.RoundTripper] implementations can access -// metadata (e.g. SessionID) without additional parameters. +// metadata (e.g. SessionID and AgentID) without additional parameters. type copilotContextKey struct{} // RequestContextFrom returns the [CopilotRequestContext] attached to an // http.Request by the adapter, or nil if not present. Call this from a custom -// [http.RoundTripper] to access metadata such as SessionID. +// [http.RoundTripper] to access metadata such as SessionID and AgentID. func RequestContextFrom(r *http.Request) *CopilotRequestContext { v, _ := r.Context().Value(copilotContextKey{}).(*CopilotRequestContext) return v @@ -194,7 +197,7 @@ func buildHTTPRequest(rctx *CopilotRequestContext) (*http.Request, error) { return nil, err } // Attach rctx so custom RoundTripper implementations can read metadata - // (e.g. SessionID) via [RequestContextFrom]. + // (e.g. SessionID and AgentID) via [RequestContextFrom]. httpReq = httpReq.WithContext(context.WithValue(httpReq.Context(), copilotContextKey{}, rctx)) for name, values := range rctx.Headers { if isForbiddenRequestHeader(name) { @@ -615,14 +618,17 @@ func (a *copilotRequestAdapter) HttpRequestStart(params *rpc.LlmInferenceHTTPReq } rctx := &CopilotRequestContext{ - RequestID: params.RequestID, - SessionID: sessionID, - Method: params.Method, - URL: params.URL, - Headers: headers, - Transport: transport, - body: bodyCh, - Context: ctx, + RequestID: params.RequestID, + SessionID: sessionID, + AgentID: stringOrEmpty(params.AgentID), + ParentAgentID: stringOrEmpty(params.ParentAgentID), + InteractionType: stringOrEmpty(params.InteractionType), + Method: params.Method, + URL: params.URL, + Headers: headers, + Transport: transport, + body: bodyCh, + Context: ctx, } sink := &responseSink{requestID: params.RequestID, adapter: a, exchange: exchange} go a.runHandler(rctx, sink, exchange) @@ -706,6 +712,13 @@ func (a *copilotRequestAdapter) removePending(requestID string) { a.mu.Unlock() } +func stringOrEmpty(value *string) string { + if value == nil { + return "" + } + return *value +} + func decodeChunkData(data string, binary bool) ([]byte, error) { if binary { return base64.StdEncoding.DecodeString(data) diff --git a/go/definetool.go b/go/definetool.go index bc223dc10d..a63aeab9f8 100644 --- a/go/definetool.go +++ b/go/definetool.go @@ -207,7 +207,7 @@ func generateSchemaForType(t reflect.Type) map[string]any { } // Handle pointer types - if t.Kind() == reflect.Ptr { + if t.Kind() == reflect.Pointer { t = t.Elem() } diff --git a/go/embeddedcli/installer.go b/go/embeddedcli/installer.go index 6edddf281a..9702b3aec6 100644 --- a/go/embeddedcli/installer.go +++ b/go/embeddedcli/installer.go @@ -5,9 +5,10 @@ import "github.com/github/copilot-sdk/go/internal/embeddedcli" // Config defines the inputs used to install and locate the embedded Copilot CLI. // // Cli and CliHash are required. If Dir is empty, the CLI is installed into the -// system cache directory. Version is used to suffix the installed binary name to -// allow multiple versions to coexist. License, when provided, is written next -// to the installed binary. +// system cache directory. When Version is set, the CLI and runtime library are +// installed into a version-specific child directory so multiple versions can +// coexist. Linux musl alternatives, when provided, are selected automatically. +// License, when provided, is written next to the installed binary. type Config = embeddedcli.Config // Setup sets the embedded GitHub Copilot CLI install configuration. diff --git a/go/go.mod b/go/go.mod index 586a5d3360..ba0f4feb73 100644 --- a/go/go.mod +++ b/go/go.mod @@ -9,6 +9,7 @@ require ( require ( github.com/coder/websocket v1.8.15 + github.com/ebitengine/purego v0.10.1 github.com/google/uuid v1.6.0 go.opentelemetry.io/otel v1.35.0 go.opentelemetry.io/otel/trace v1.35.0 diff --git a/go/go.sum b/go/go.sum index e7ac53d5a4..cab5b6aabe 100644 --- a/go/go.sum +++ b/go/go.sum @@ -2,6 +2,8 @@ github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNU github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= diff --git a/go/inprocess.go b/go/inprocess.go new file mode 100644 index 0000000000..c74410e42b --- /dev/null +++ b/go/inprocess.go @@ -0,0 +1,15 @@ +package copilot + +import "io" + +type inProcessHost interface { + Start() error + Writer() io.WriteCloser + Reader() io.ReadCloser + Dispose() +} + +type inProcessHostConfig struct { + Environment map[string]string + Args []string +} diff --git a/go/inprocess_disabled.go b/go/inprocess_disabled.go new file mode 100644 index 0000000000..b86ed5ca36 --- /dev/null +++ b/go/inprocess_disabled.go @@ -0,0 +1,11 @@ +//go:build !copilot_inprocess || (!darwin && !linux && !windows) + +package copilot + +import "errors" + +const inProcessAvailable = false + +func createInProcessHost(string, inProcessHostConfig) (inProcessHost, error) { + return nil, errors.New("in-process transport unavailable") +} diff --git a/go/inprocess_enabled.go b/go/inprocess_enabled.go new file mode 100644 index 0000000000..c20013d8ab --- /dev/null +++ b/go/inprocess_enabled.go @@ -0,0 +1,11 @@ +//go:build copilot_inprocess && (darwin || linux || windows) + +package copilot + +import "github.com/github/copilot-sdk/go/internal/ffihost" + +const inProcessAvailable = true + +func createInProcessHost(runtimePath string, config inProcessHostConfig) (inProcessHost, error) { + return ffihost.Create(runtimePath, config.Environment, config.Args) +} diff --git a/go/internal/e2e/byok_bearer_token_provider_e2e_test.go b/go/internal/e2e/byok_bearer_token_provider_e2e_test.go index 2f298596d1..33e32b1322 100644 --- a/go/internal/e2e/byok_bearer_token_provider_e2e_test.go +++ b/go/internal/e2e/byok_bearer_token_provider_e2e_test.go @@ -111,6 +111,7 @@ func (rt *byokCapturingRoundTripper) reset() { // 3. per-provider dispatch routes each provider's turn to its own callback, and // the resulting token reaches that provider's endpoint. func TestBYOKBearerTokenProvider(t *testing.T) { + testharness.SkipIfInProcess(t, "an LLM inference provider is process-global in-process") ctx := testharness.NewTestContext(t) rt := &byokCapturingRoundTripper{} handler := &copilot.CopilotRequestHandler{Transport: rt} diff --git a/go/internal/e2e/copilot_request_cancel_error_e2e_test.go b/go/internal/e2e/copilot_request_cancel_error_e2e_test.go index c48a617021..46091d5ac7 100644 --- a/go/internal/e2e/copilot_request_cancel_error_e2e_test.go +++ b/go/internal/e2e/copilot_request_cancel_error_e2e_test.go @@ -54,6 +54,7 @@ func (tr *throwingTransport) RoundTrip(req *http.Request) (*http.Response, error } func TestCopilotRequestError(t *testing.T) { + testharness.SkipIfInProcess(t, "an LLM inference provider is process-global in-process") ctx := testharness.NewTestContext(t) transport := &throwingTransport{} handler := &copilot.CopilotRequestHandler{Transport: transport} @@ -132,6 +133,7 @@ func waitFor(t *testing.T, predicate func() bool, timeout time.Duration) { } func TestCopilotRequestCancel(t *testing.T) { + testharness.SkipIfInProcess(t, "an LLM inference provider is process-global in-process") ctx := testharness.NewTestContext(t) transport := newCancellingTransport() handler := &copilot.CopilotRequestHandler{Transport: transport} diff --git a/go/internal/e2e/copilot_request_handler_e2e_test.go b/go/internal/e2e/copilot_request_handler_e2e_test.go index 052a1bdebc..a0cfcb63ea 100644 --- a/go/internal/e2e/copilot_request_handler_e2e_test.go +++ b/go/internal/e2e/copilot_request_handler_e2e_test.go @@ -119,6 +119,7 @@ func (rt *rewritingRoundTripper) RoundTrip(req *http.Request) (*http.Response, e } func TestCopilotRequestHandler(t *testing.T) { + testharness.SkipIfInProcess(t, "an LLM inference provider is process-global in-process") ctx := testharness.NewTestContext(t) counters := &handlerCounters{} httpURL, wsURL := startFakeUpstreams(t, counters) diff --git a/go/internal/e2e/copilot_request_session_id_e2e_test.go b/go/internal/e2e/copilot_request_session_id_e2e_test.go index e88b91c971..f7673bd457 100644 --- a/go/internal/e2e/copilot_request_session_id_e2e_test.go +++ b/go/internal/e2e/copilot_request_session_id_e2e_test.go @@ -16,9 +16,12 @@ import ( ) type interceptedRequest struct { - url string - sessionID string - body string + url string + sessionID string + agentID string + parentAgentID string + interactionType string + body string } // recordingTransport intercepts every model-layer request, records its URL and @@ -32,8 +35,14 @@ type recordingTransport struct { func (rt *recordingTransport) RoundTrip(req *http.Request) (*http.Response, error) { rctx := copilot.RequestContextFrom(req) sessionID := "" + agentID := "" + parentAgentID := "" + interactionType := "" if rctx != nil { sessionID = rctx.SessionID + agentID = rctx.AgentID + parentAgentID = rctx.ParentAgentID + interactionType = rctx.InteractionType } bodyBytes := []byte(nil) if req.Body != nil { @@ -42,7 +51,14 @@ func (rt *recordingTransport) RoundTrip(req *http.Request) (*http.Response, erro bodyText := string(bodyBytes) rt.mu.Lock() - rt.records = append(rt.records, interceptedRequest{url: req.URL.String(), sessionID: sessionID, body: bodyText}) + rt.records = append(rt.records, interceptedRequest{ + url: req.URL.String(), + sessionID: sessionID, + agentID: agentID, + parentAgentID: parentAgentID, + interactionType: interactionType, + body: bodyText, + }) rt.mu.Unlock() if isInferenceURL(req.URL.String()) { @@ -63,7 +79,18 @@ func (rt *recordingTransport) inferenceRecords() []interceptedRequest { return out } +func assertAgentMetadata(t *testing.T, r interceptedRequest) { + t.Helper() + if r.agentID == "" { + t.Fatal("inference request must carry an agent id") + } + if r.interactionType == "" { + t.Fatal("inference request must carry an interaction type") + } +} + func TestCopilotRequestSessionID(t *testing.T) { + testharness.SkipIfInProcess(t, "an LLM inference provider is process-global in-process") ctx := testharness.NewTestContext(t) transport := &recordingTransport{} handler := &copilot.CopilotRequestHandler{Transport: transport} @@ -99,6 +126,7 @@ func TestCopilotRequestSessionID(t *testing.T) { if r.sessionID != capiSessionID { t.Fatalf("CAPI inference request must carry session id %q, got %q", capiSessionID, r.sessionID) } + assertAgentMetadata(t, r) } // Validate the final assistant response arrived (guards against truncated captures) @@ -140,6 +168,7 @@ func TestCopilotRequestSessionID(t *testing.T) { if r.sessionID != byokSessionID { t.Fatalf("BYOK inference request must carry session id %q, got %q", byokSessionID, r.sessionID) } + assertAgentMetadata(t, r) } if byokSessionID == capiSessionID { diff --git a/go/internal/e2e/event_fidelity_e2e_test.go b/go/internal/e2e/event_fidelity_e2e_test.go index c48a4908a1..e7cc4bfb37 100644 --- a/go/internal/e2e/event_fidelity_e2e_test.go +++ b/go/internal/e2e/event_fidelity_e2e_test.go @@ -168,6 +168,7 @@ func TestEventFidelityE2E(t *testing.T) { if answer == nil { t.Fatal("Expected SendAndWait to return an assistant message") + return } if ad, ok := answer.Data.(*copilot.AssistantMessageData); !ok || !strings.Contains(ad.Content, "18") { t.Errorf("Expected answer to contain '18', got %v", answer.Data) diff --git a/go/internal/e2e/inprocess_ffi_e2e_test.go b/go/internal/e2e/inprocess_ffi_e2e_test.go new file mode 100644 index 0000000000..6923384a28 --- /dev/null +++ b/go/internal/e2e/inprocess_ffi_e2e_test.go @@ -0,0 +1,62 @@ +package e2e + +import ( + "testing" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" +) + +// TestInProcessFfiE2E is a smoke test for the in-process (FFI) transport. It +// starts a client that loads the native runtime cdylib next to the resolved CLI +// entrypoint, lets the native host spawn the worker, performs a purely local +// "ping" round-trip through the runtime, and stops cleanly. No auth or replay +// proxy is involved, so it needs no snapshot. +// +// Mirrors python/e2e/test_inprocess_ffi_e2e.py and +// nodejs/test/e2e/inprocess_ffi.e2e.test.ts. +func TestInProcessFfiE2E(t *testing.T) { + // Loading the native runtime cdylib (libnode) into this test process installs + // foreign signal handlers. On macOS the Go runtime then aborts when it reaps + // its own os/exec children (see ffihost signal re-arming). The in-process + // matrix cell already loads libnode for the whole suite and re-arms those + // handlers; the default (child-process) cell must never load it, so restrict + // this dedicated FFI smoke test to the in-process cell. + if !testharness.IsInProcessTransport() { + t.Skip("in-process FFI smoke test runs only under the inprocess transport cell") + } + + cliPath := testharness.CLIPath() + if cliPath == "" { + t.Fatal("CLI not found. Run 'npm install' in the nodejs directory first.") + } + t.Setenv("COPILOT_CLI_PATH", cliPath) + + t.Run("should start and connect over in-process FFI", func(t *testing.T) { + client := copilot.NewClient(&copilot.ClientOptions{ + Connection: copilot.InProcessConnection{}, + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client over in-process FFI: %v", err) + } + + pong, err := client.Ping(t.Context(), "ffi message") + if err != nil { + t.Fatalf("Failed to ping: %v", err) + } + + if pong.Message != "pong: ffi message" { + t.Errorf("Expected pong.message to be 'pong: ffi message', got %q", pong.Message) + } + + if pong.Timestamp.IsZero() { + t.Errorf("Expected non-zero pong.timestamp, got %s", pong.Timestamp) + } + + if err := client.Stop(); err != nil { + t.Errorf("Expected no errors on stop, got %v", err) + } + }) +} diff --git a/go/internal/e2e/mcp_and_agents_e2e_test.go b/go/internal/e2e/mcp_and_agents_e2e_test.go index e4bd34e268..71a152ecaf 100644 --- a/go/internal/e2e/mcp_and_agents_e2e_test.go +++ b/go/internal/e2e/mcp_and_agents_e2e_test.go @@ -115,10 +115,7 @@ func TestMCPServersE2E(t *testing.T) { t.Run("should pass literal env values to MCP server subprocess", func(t *testing.T) { ctx.ConfigureForTest(t) - mcpServerPath, err := filepath.Abs("../../../test/harness/test-mcp-server.mjs") - if err != nil { - t.Fatalf("Failed to resolve test-mcp-server path: %v", err) - } + mcpServerPath := testharness.RepoPath("test", "harness", "test-mcp-server.mjs") mcpServerDir := filepath.Dir(mcpServerPath) mcpServers := map[string]copilot.MCPServerConfig{ diff --git a/go/internal/e2e/mcp_oauth_e2e_test.go b/go/internal/e2e/mcp_oauth_e2e_test.go index 7959043063..1e08b839c8 100644 --- a/go/internal/e2e/mcp_oauth_e2e_test.go +++ b/go/internal/e2e/mcp_oauth_e2e_test.go @@ -6,7 +6,6 @@ import ( "net/http" "os" "os/exec" - "path/filepath" "slices" "strings" "sync" @@ -306,17 +305,14 @@ type oauthMCPRequest struct { func startOAuthMCPServer(t *testing.T) string { t.Helper() - serverPath, err := filepath.Abs("../../../test/harness/test-mcp-oauth-server.mjs") - if err != nil { - t.Fatalf("Failed to resolve OAuth MCP server path: %v", err) - } + serverPath := testharness.RepoPath("test", "harness", "test-mcp-oauth-server.mjs") cmd := exec.Command("node", serverPath) cmd.Env = append(os.Environ(), "EXPECTED_TOKEN="+expectedMCPOAuthToken) stdout, err := cmd.StdoutPipe() if err != nil { t.Fatalf("Failed to pipe OAuth MCP server stdout: %v", err) } - var stderr strings.Builder + var stderr syncBuffer cmd.Stderr = &stderr if err := cmd.Start(); err != nil { t.Fatalf("Failed to start OAuth MCP server: %v", err) @@ -362,6 +358,26 @@ func stringValue(value *string) string { return *value } +// syncBuffer is a minimal io.Writer whose contents can be read concurrently. +// os/exec writes to cmd.Stderr on a separate goroutine, so reading a plain +// strings.Builder while the process is running is a data race (caught by -race). +type syncBuffer struct { + mu sync.Mutex + buf strings.Builder +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + func fetchOAuthMCPRequests(t *testing.T, baseURL string) []oauthMCPRequest { t.Helper() diff --git a/go/internal/e2e/mcp_server_helpers_test.go b/go/internal/e2e/mcp_server_helpers_test.go index e7269b1e26..68e72b18bc 100644 --- a/go/internal/e2e/mcp_server_helpers_test.go +++ b/go/internal/e2e/mcp_server_helpers_test.go @@ -8,16 +8,14 @@ import ( "time" copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" "github.com/github/copilot-sdk/go/rpc" ) func testMCPServers(t *testing.T, serverNames ...string) map[string]copilot.MCPServerConfig { t.Helper() - mcpServerPath, err := filepath.Abs("../../../test/harness/test-mcp-server.mjs") - if err != nil { - t.Fatalf("Failed to resolve test-mcp-server path: %v", err) - } + mcpServerPath := testharness.RepoPath("test", "harness", "test-mcp-server.mjs") mcpServerDir := filepath.Dir(mcpServerPath) mcpServers := make(map[string]copilot.MCPServerConfig, len(serverNames)) diff --git a/go/internal/e2e/pre_mcp_tool_call_hook_e2e_test.go b/go/internal/e2e/pre_mcp_tool_call_hook_e2e_test.go index 1847270922..111cfb86a4 100644 --- a/go/internal/e2e/pre_mcp_tool_call_hook_e2e_test.go +++ b/go/internal/e2e/pre_mcp_tool_call_hook_e2e_test.go @@ -15,7 +15,7 @@ func TestPreMCPToolCallHookE2E(t *testing.T) { client := ctx.NewClient() t.Cleanup(func() { client.ForceStop() }) - testHarnessDir, _ := filepath.Abs("../../../test/harness") + testHarnessDir := testharness.RepoPath("test", "harness") metaEchoServer := filepath.Join(testHarnessDir, "test-mcp-meta-echo-server.mjs") metaEchoConfig := func() map[string]copilot.MCPServerConfig { diff --git a/go/internal/e2e/rpc_server_e2e_test.go b/go/internal/e2e/rpc_server_e2e_test.go index 2510eb1d9c..6ea9ad6851 100644 --- a/go/internal/e2e/rpc_server_e2e_test.go +++ b/go/internal/e2e/rpc_server_e2e_test.go @@ -604,6 +604,7 @@ func TestRPCServerE2E(t *testing.T) { projectSkillPath := findSkillDiscoveryPath(skillPaths.Paths, ctx.WorkDir) if projectSkillPath == nil { t.Fatalf("Expected skill discovery paths to include %q", ctx.WorkDir) + return } if strings.TrimSpace(projectSkillPath.Path) == "" { t.Fatal("Expected non-empty skill discovery path") @@ -632,6 +633,7 @@ func TestRPCServerE2E(t *testing.T) { projectAgentPath := findAgentDiscoveryPath(agentPaths.Paths, ctx.WorkDir) if projectAgentPath == nil { t.Fatalf("Expected agent discovery paths to include %q", ctx.WorkDir) + return } if strings.TrimSpace(projectAgentPath.Path) == "" { t.Fatal("Expected non-empty agent discovery path") diff --git a/go/internal/e2e/rpc_server_misc_e2e_test.go b/go/internal/e2e/rpc_server_misc_e2e_test.go index 38b569798c..37ec57e1ba 100644 --- a/go/internal/e2e/rpc_server_misc_e2e_test.go +++ b/go/internal/e2e/rpc_server_misc_e2e_test.go @@ -159,6 +159,7 @@ func TestRpcServerMisc(t *testing.T) { } if users == nil { t.Fatal("Expected non-nil users result") + return } for _, user := range *users { userInfo, ok := user.AuthInfo.(*rpc.UserAuthInfo) diff --git a/go/internal/e2e/rpc_server_plugins_e2e_test.go b/go/internal/e2e/rpc_server_plugins_e2e_test.go index 9dbd17a672..a9d1d243cc 100644 --- a/go/internal/e2e/rpc_server_plugins_e2e_test.go +++ b/go/internal/e2e/rpc_server_plugins_e2e_test.go @@ -58,6 +58,7 @@ func TestRpcServerPlugins(t *testing.T) { listed := findPortedInstalledPlugin(afterInstall.Plugins, portedPluginName, portedMarketplaceName) if listed == nil { t.Fatalf("Expected installed plugin %q in marketplace %q", portedPluginName, portedMarketplaceName) + return } if !listed.Enabled { t.Fatal("Expected listed marketplace plugin to be enabled") @@ -229,6 +230,7 @@ func TestRpcServerPlugins(t *testing.T) { mine := findPortedMarketplace(list.Marketplaces, portedMarketplaceName) if mine == nil { t.Fatalf("Expected marketplace %q in list %+v", portedMarketplaceName, list.Marketplaces) + return } if mine.IsDefault != nil && *mine.IsDefault { t.Fatal("Expected local marketplace not to be marked default") @@ -311,7 +313,10 @@ func newStartedPortedClient(t *testing.T, ctx *testharness.TestContext, opts ... func newStartedIsolatedPortedClient(t *testing.T, ctx *testharness.TestContext) *copilot.Client { t.Helper() - home := t.TempDir() + home, err := os.MkdirTemp(ctx.WorkDir, "plugin-home-") + if err != nil { + t.Fatalf("Failed to create isolated plugin home: %v", err) + } return newStartedPortedClient(t, ctx, func(opts *copilot.ClientOptions) { opts.Env = append(opts.Env, "COPILOT_HOME="+home, diff --git a/go/internal/e2e/rpc_ui_ephemeral_query_e2e_test.go b/go/internal/e2e/rpc_ui_ephemeral_query_e2e_test.go index c6e4033609..2669faea9a 100644 --- a/go/internal/e2e/rpc_ui_ephemeral_query_e2e_test.go +++ b/go/internal/e2e/rpc_ui_ephemeral_query_e2e_test.go @@ -26,6 +26,7 @@ func TestRpcUiEphemeralQuery(t *testing.T) { } if result == nil { t.Fatal("Expected non-nil ephemeral query result") + return } if strings.TrimSpace(result.Answer) == "" { t.Fatal("Expected non-empty ephemeral query answer") diff --git a/go/internal/e2e/rpc_workspace_checkpoints_e2e_test.go b/go/internal/e2e/rpc_workspace_checkpoints_e2e_test.go index 87c3c8da83..849e3a5fa6 100644 --- a/go/internal/e2e/rpc_workspace_checkpoints_e2e_test.go +++ b/go/internal/e2e/rpc_workspace_checkpoints_e2e_test.go @@ -33,6 +33,11 @@ func TestRPCWorkspaceCheckpointsE2E(t *testing.T) { }) t.Run("should return nil or empty content for unknown checkpoint", func(t *testing.T) { + // In-process, session.workspaces.readCheckpoint is answered by the native + // runtime, which decodes the checkpoint number as a u32 and rejects the + // large sentinel this test uses. Covered by the default (stdio) transport. + // Mirrors Rust's should_return_null_or_empty_content_for_unknown_checkpoint. + testharness.SkipIfInProcess(t, "readCheckpoint decodes the id as u32 in-process") session := createWorkspaceRPCSession(t, client) defer session.Disconnect() diff --git a/go/internal/e2e/session_config_e2e_test.go b/go/internal/e2e/session_config_e2e_test.go index 6dc1c59ad4..672a905dc0 100644 --- a/go/internal/e2e/session_config_e2e_test.go +++ b/go/internal/e2e/session_config_e2e_test.go @@ -404,6 +404,7 @@ func TestSessionConfigNewOptionsE2E(t *testing.T) { } func TestSessionConfigNewOptionsCopilotRequestE2E(t *testing.T) { + testharness.SkipIfInProcess(t, "an LLM inference provider is process-global in-process") t.Run("should enable citations for Anthropic file attachments on create", func(t *testing.T) { ctx := testharness.NewTestContext(t) transport := &recordingTransport{} diff --git a/go/internal/e2e/subagent_hooks_e2e_test.go b/go/internal/e2e/subagent_hooks_e2e_test.go index c632b1e606..0e2fde9f86 100644 --- a/go/internal/e2e/subagent_hooks_e2e_test.go +++ b/go/internal/e2e/subagent_hooks_e2e_test.go @@ -1,6 +1,7 @@ package e2e import ( + "net/http" "os" "path/filepath" "sync" @@ -10,10 +11,78 @@ import ( "github.com/github/copilot-sdk/go/internal/e2e/testharness" ) +type subagentRequestRecord struct { + agentID string + parentAgentID string + interactionType string +} + +type recordingForwardingTransport struct { + inner http.RoundTripper + mu sync.Mutex + records []subagentRequestRecord +} + +func newRecordingForwardingTransport() *recordingForwardingTransport { + inner := http.DefaultTransport.(*http.Transport).Clone() + inner.DisableCompression = true + return &recordingForwardingTransport{inner: inner} +} + +func (rt *recordingForwardingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if isInferenceURL(req.URL.String()) { + rctx := copilot.RequestContextFrom(req) + record := subagentRequestRecord{} + if rctx != nil { + record.agentID = rctx.AgentID + record.parentAgentID = rctx.ParentAgentID + record.interactionType = rctx.InteractionType + } + rt.mu.Lock() + rt.records = append(rt.records, record) + rt.mu.Unlock() + } + return rt.inner.RoundTrip(req) +} + +func (rt *recordingForwardingTransport) inferenceRecords() []subagentRequestRecord { + rt.mu.Lock() + defer rt.mu.Unlock() + out := make([]subagentRequestRecord, len(rt.records)) + copy(out, rt.records) + return out +} + +func assertSubagentRequestMetadata(t *testing.T, records []subagentRequestRecord) { + t.Helper() + if len(records) == 0 { + t.Fatal("request handler should observe inference requests") + } + for _, r := range records { + if r.parentAgentID == "" { + continue + } + if r.agentID == "" { + t.Fatal("sub-agent inference request should carry an agent id") + } + if r.interactionType == "" { + t.Fatal("sub-agent inference request should carry an interaction type") + } + if r.parentAgentID == r.agentID { + t.Fatal("sub-agent inference request should have distinct parent and child agent ids") + } + return + } + t.Fatal("sub-agent inference request should carry a parent agent id") +} + func TestSubagentHooksE2E(t *testing.T) { + testharness.SkipIfInProcess(t, "an LLM inference provider is process-global in-process") ctx := testharness.NewTestContext(t) + transport := newRecordingForwardingTransport() client := ctx.NewClient(func(o *copilot.ClientOptions) { o.Env = append(o.Env, "COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS=true") + o.RequestHandler = &copilot.CopilotRequestHandler{Transport: transport} }) t.Cleanup(func() { client.ForceStop() }) @@ -100,5 +169,6 @@ func TestSubagentHooksE2E(t *testing.T) { if viewPre[0].sessionID == taskPre.sessionID { t.Error("Sub-agent tool hooks should have a different sessionId than parent tool hooks") } + assertSubagentRequestMetadata(t, transport.inferenceRecords()) }) } diff --git a/go/internal/e2e/system_message_sections_e2e_test.go b/go/internal/e2e/system_message_sections_e2e_test.go index 61493c8122..c1eb313a25 100644 --- a/go/internal/e2e/system_message_sections_e2e_test.go +++ b/go/internal/e2e/system_message_sections_e2e_test.go @@ -43,6 +43,7 @@ func TestSystemMessageSectionsE2E(t *testing.T) { } if response == nil { t.Fatal("Expected a response from the assistant") + return } ad, ok := response.Data.(*copilot.AssistantMessageData) @@ -82,6 +83,7 @@ func TestSystemMessageSectionsE2E(t *testing.T) { } if response == nil { t.Fatal("Expected a response from the assistant") + return } ad, ok := response.Data.(*copilot.AssistantMessageData) diff --git a/go/internal/e2e/telemetry_e2e_test.go b/go/internal/e2e/telemetry_e2e_test.go index eff384d020..4567817fd9 100644 --- a/go/internal/e2e/telemetry_e2e_test.go +++ b/go/internal/e2e/telemetry_e2e_test.go @@ -14,6 +14,7 @@ import ( // Mirrors dotnet/test/TelemetryExportTests.cs (snapshot category "telemetry"). func TestTelemetryE2E(t *testing.T) { + testharness.SkipIfInProcess(t, "telemetry configuration is not honored in-process") t.Run("should export file telemetry for sdk interactions", func(t *testing.T) { ctx := testharness.NewTestContext(t) ctx.ConfigureForTest(t) diff --git a/go/internal/e2e/testharness/context.go b/go/internal/e2e/testharness/context.go index 6b6463749f..b73153dfff 100644 --- a/go/internal/e2e/testharness/context.go +++ b/go/internal/e2e/testharness/context.go @@ -33,13 +33,11 @@ func CLIPath() string { // 1.0.64-1 the @github/copilot package is a thin loader; the runnable // index.js ships in the installed platform package // (e.g. @github/copilot-linux-x64). - base, err := filepath.Abs("../../../nodejs/node_modules/@github") - if err == nil { - matches, _ := filepath.Glob(filepath.Join(base, "copilot-*", "index.js")) - if len(matches) > 0 { - cliPath = matches[0] - return - } + base := RepoPath("nodejs", "node_modules", "@github") + matches, _ := filepath.Glob(filepath.Join(base, "copilot-*", "index.js")) + if len(matches) > 0 { + cliPath = matches[0] + return } }) return cliPath @@ -53,6 +51,67 @@ type TestContext struct { ProxyURL string proxy *CapiProxy + + // In-process transport state. When the inprocess CI matrix cell is active the + // worker inherits this process's ambient env and cwd (per-client env/working + // directory are rejected in-process), so the isolated test env/cwd are mirrored + // onto the real process and restored on Close. + inProcess bool + restoreEnv []envRestore + restoreCwd string +} + +// envRestore captures a single environment variable's prior value so the +// in-process ambient mirror can be undone during teardown. +type envRestore struct { + key string + prev string + had bool +} + +// isInProcessTransport reports whether the in-process (FFI) transport is selected +// for E2E tests via COPILOT_SDK_DEFAULT_CONNECTION=inprocess. Mirrors the +// Node/Python/.NET harnesses. +func isInProcessTransport() bool { + return strings.EqualFold(os.Getenv("COPILOT_SDK_DEFAULT_CONNECTION"), "inprocess") +} + +// init neutralizes any ambient HMAC signing key as early as package load when the +// in-process transport is selected. Host-side auth resolution ranks the HMAC key +// above the GitHub token, so an ambient COPILOT_HMAC_KEY (CI injects one as a +// job-level credential) would be picked over the token the replay snapshots +// expect, producing request signatures that miss the recorded exchanges. Because +// the runtime is hosted in this process, the key must be removed before the native +// library is loaded and captures it — a later, per-client override is too late and +// setting it to an empty value is still treated as a signing key. Out-of-process +// children resolve auth in their own process where the token already outranks the +// HMAC key, so this is scoped to the in-process cell. Mirrors the analogous +// module-load neutralization in the Node/Python/.NET harnesses. +// See https://github.com/github/copilot-sdk/issues/1934. +func init() { + if isInProcessTransport() { + os.Unsetenv("COPILOT_HMAC_KEY") + os.Unsetenv("CAPI_HMAC_KEY") + } +} + +// IsInProcessTransport reports whether E2E tests run under the in-process (FFI) +// transport. Tests that configure options unsupported in-process (e.g. per-client +// telemetry) should skip when this returns true. +func IsInProcessTransport() bool { + return isInProcessTransport() +} + +// SkipIfInProcess skips the test when E2E tests run under the in-process (FFI) +// transport, for behavior the shared in-process runtime cannot support (e.g. a +// process-global LLM inference provider, or per-client telemetry). The reason is +// surfaced in the test log so the skip is explicit rather than a silent transport +// downgrade. Such tests still run over stdio in the default matrix cell. +func SkipIfInProcess(t *testing.T, reason string) { + t.Helper() + if isInProcessTransport() { + t.Skipf("unsupported over the in-process (FFI) transport: %s", reason) + } } // NewTestContext creates a new test context with isolated directories and a replaying proxy. @@ -106,11 +165,12 @@ func NewTestContext(t *testing.T) *TestContext { } ctx := &TestContext{ - CLIPath: cliPath, - HomeDir: homeDir, - WorkDir: workDir, - ProxyURL: proxyURL, - proxy: proxy, + CLIPath: cliPath, + HomeDir: homeDir, + WorkDir: workDir, + ProxyURL: proxyURL, + proxy: proxy, + inProcess: isInProcessTransport(), } t.Cleanup(func() { @@ -146,7 +206,14 @@ func (c *TestContext) ConfigureForTest(t *testing.T) { t.Fatalf("Expected test name with subtest, got: %s", testName) } sanitizedName := strings.ToLower(regexp.MustCompile(`[^a-zA-Z0-9]`).ReplaceAllString(parts[1], "_")) - snapshotPath := filepath.Join("..", "..", "..", "test", "snapshots", testFile, sanitizedName+".yaml") + // Anchor the snapshot path to the caller's source directory rather than the + // process working directory: the in-process transport chdir's into the test's + // isolated work dir (the worker inherits the process cwd), so a cwd-relative + // path would resolve against the wrong root for every subtest after the first. + // All e2e test files live in go/internal/e2e, so the repo root is three levels + // up from the caller's directory. + repoRoot := filepath.Join(filepath.Dir(callerFile), "..", "..", "..") + snapshotPath := filepath.Join(repoRoot, "test", "snapshots", testFile, sanitizedName+".yaml") absSnapshotPath, err := filepath.Abs(snapshotPath) if err != nil { @@ -172,6 +239,7 @@ func (c *TestContext) ConfigureWithoutSnapshot(t *testing.T) { // Close cleans up the test context resources. func (c *TestContext) Close(testFailed bool) { + c.restoreInProcessEnvironment() if c.proxy != nil { c.proxy.StopWithOptions(testFailed) } @@ -183,6 +251,62 @@ func (c *TestContext) Close(testFailed bool) { } } +// applyInProcessEnvironment mirrors the isolated test environment onto the real +// process for in-process hosting: the worker inherits this process's env and cwd +// at spawn, so per-test redirects must live on os.Environ and the process cwd. +// Auth flows via GH_TOKEN/GITHUB_TOKEN (the FFI argv omits the stdio auth-token +// wiring); the ambient HMAC signing key is removed process-wide at package load +// (see init) so host-side auth matches the replay snapshots. mergedEnv is the +// effective per-client env (harness defaults plus any per-test additions); workDir +// is the effective working directory. Values are restored in Close. Safe to call +// more than once (restores unwind in reverse). +func (c *TestContext) applyInProcessEnvironment(mergedEnv []string, workDir string) { + inprocessEnv := map[string]string{} + for _, kv := range mergedEnv { + if key, value, ok := strings.Cut(kv, "="); ok { + inprocessEnv[key] = value + } + } + // Auth flows via GH_TOKEN/GITHUB_TOKEN for the in-process host, overriding any + // inherited values. The HMAC key is neutralized process-wide at package load. + inprocessEnv["GH_TOKEN"] = defaultGitHubToken + inprocessEnv["GITHUB_TOKEN"] = defaultGitHubToken + inprocessEnv["COPILOT_CLI_PATH"] = c.CLIPath + delete(inprocessEnv, "COPILOT_HMAC_KEY") + delete(inprocessEnv, "CAPI_HMAC_KEY") + + for key, value := range inprocessEnv { + prev, had := os.LookupEnv(key) + c.restoreEnv = append(c.restoreEnv, envRestore{key: key, prev: prev, had: had}) + os.Setenv(key, value) + } + if workDir != "" { + if c.restoreCwd == "" { + if cwd, err := os.Getwd(); err == nil { + c.restoreCwd = cwd + } + } + os.Chdir(workDir) + } +} + +// restoreInProcessEnvironment undoes applyInProcessEnvironment during teardown. +func (c *TestContext) restoreInProcessEnvironment() { + for i := len(c.restoreEnv) - 1; i >= 0; i-- { + r := c.restoreEnv[i] + if r.had { + os.Setenv(r.key, r.prev) + } else { + os.Unsetenv(r.key) + } + } + c.restoreEnv = nil + if c.restoreCwd != "" { + os.Chdir(c.restoreCwd) + c.restoreCwd = "" + } +} + // GetExchanges retrieves the captured HTTP exchanges from the proxy. func (c *TestContext) GetExchanges() ([]ParsedHttpExchange, error) { return c.proxy.GetExchanges() @@ -261,9 +385,39 @@ func (c *TestContext) NewClient(opts ...func(*copilot.ClientOptions)) *copilot.C options.GitHubToken = defaultGitHubToken } + // Under the inprocess matrix cell, host the default stdio connection in-process. + // The worker inherits this process's ambient env/cwd (per-client env and working + // directory are rejected in-process), so mirror the effective (merged) env and + // cwd onto the real process and drop those options. Tests that pin a specific + // transport (TCP/URI/custom stdio) or configure per-client telemetry are left on + // their transport, mirroring the Node/.NET harnesses. + if c.inProcess && c.shouldUseInProcess(options) { + c.applyInProcessEnvironment(options.Env, options.WorkingDirectory) + options.Connection = copilot.InProcessConnection{} + options.Env = nil + options.WorkingDirectory = "" + } + return copilot.NewClient(options) } +// shouldUseInProcess reports whether a client built from options should be hosted +// in-process for the inprocess matrix cell. Only the harness default stdio +// connection is swapped; a test that pins a custom stdio path/args/env or a +// TCP/URI connection is exercising behavior that must stay on its own transport. +// +// Options the in-process runtime cannot support (per-client telemetry, an LLM +// inference provider) are NOT silently downgraded here — the affected tests skip +// explicitly via testharness.SkipIfInProcess so the limitation is visible rather +// than masked by a quiet transport swap. +func (c *TestContext) shouldUseInProcess(options *copilot.ClientOptions) bool { + s, ok := options.Connection.(copilot.StdioConnection) + if !ok { + return false + } + return s.Path == c.CLIPath && len(s.Args) == 0 && s.Env == nil +} + func fileExists(path string) bool { _, err := os.Stat(path) return err == nil diff --git a/go/internal/e2e/testharness/helper.go b/go/internal/e2e/testharness/helper.go index ca94d03adf..af08b2dbcc 100644 --- a/go/internal/e2e/testharness/helper.go +++ b/go/internal/e2e/testharness/helper.go @@ -3,11 +3,32 @@ package testharness import ( "context" "errors" + "path/filepath" + "runtime" "time" copilot "github.com/github/copilot-sdk/go" ) +// RepoPath resolves a path relative to the repository root, anchored to this +// source file's directory rather than the process working directory. The +// in-process (FFI) transport os.Chdir's the whole test process into a per-test +// temp workdir (the shared runtime host inherits the process cwd), so any +// cwd-relative resolution (e.g. filepath.Abs("../../../test/...")) would break +// for every test after the first in-process one. This helper stays correct +// regardless of the current working directory. +func RepoPath(elem ...string) string { + _, callerFile, _, ok := runtime.Caller(0) + if !ok { + // Fall back to a cwd-relative join; only correct before any chdir. + return filepath.Join(append([]string{"..", "..", ".."}, elem...)...) + } + // This file lives at go/internal/e2e/testharness/, so the repo root is four + // levels up from its directory. + repoRoot := filepath.Join(filepath.Dir(callerFile), "..", "..", "..", "..") + return filepath.Join(append([]string{repoRoot}, elem...)...) +} + // GetFinalAssistantMessage waits for and returns the final assistant message from a session turn. // If alreadyIdle is true, skip waiting for session.idle (useful for resumed sessions where the // idle event was ephemeral and not persisted in the event history). diff --git a/go/internal/e2e/testharness/proxy.go b/go/internal/e2e/testharness/proxy.go index 8933183c87..ec16124bc6 100644 --- a/go/internal/e2e/testharness/proxy.go +++ b/go/internal/e2e/testharness/proxy.go @@ -38,11 +38,14 @@ func (p *CapiProxy) Start() (string, error) { return p.proxyURL, nil } - // The harness server is in the shared test directory - serverPath := "../../../test/harness/server.ts" + // The harness server is in the shared test directory. Anchor the path to + // the repo root (not the process cwd), because the in-process (FFI) + // transport os.Chdir's into a per-test temp workdir, which would otherwise + // break the cwd-relative resolution. + serverPath := RepoPath("test", "harness", "server.ts") p.cmd = exec.Command("npx", "tsx", serverPath) - p.cmd.Dir = "." // Will be resolved relative to test execution + p.cmd.Dir = RepoPath("test", "harness") stdout, err := p.cmd.StdoutPipe() if err != nil { diff --git a/go/internal/e2e/tools_e2e_test.go b/go/internal/e2e/tools_e2e_test.go index 1600eb1630..062d377917 100644 --- a/go/internal/e2e/tools_e2e_test.go +++ b/go/internal/e2e/tools_e2e_test.go @@ -140,6 +140,7 @@ func TestToolsE2E(t *testing.T) { if answer == nil { t.Fatalf("Expected non-nil assistant message") + return } ad, ok := answer.Data.(*copilot.AssistantMessageData) if !ok { diff --git a/go/internal/embeddedcli/embeddedcli.go b/go/internal/embeddedcli/embeddedcli.go index 0866a3f811..cd0be21895 100644 --- a/go/internal/embeddedcli/embeddedcli.go +++ b/go/internal/embeddedcli/embeddedcli.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -18,15 +19,30 @@ import ( // Config defines the inputs used to install and locate the embedded Copilot CLI. // // Cli and CliHash are required. If Dir is empty, the CLI is installed into the -// system cache directory. Version is used to suffix the installed binary name to -// allow multiple versions to coexist. License, when provided, is written next -// to the installed binary. +// system cache directory. When Version is set, the CLI is installed into a +// version-specific child directory so multiple versions can coexist. License, +// when provided, is written next to the installed binary. +// +// RuntimeLib and RuntimeLibHash are optional: when set, the native in-process +// runtime library (cdylib) is installed next to the CLI binary so the in-process +// (FFI) transport can load it. They are omitted for CLI packages that do not +// ship the native runtime. type Config struct { Cli io.Reader CliHash []byte License []byte + RuntimeLib io.Reader + RuntimeLibHash []byte + + // LinuxMuslCli and LinuxMuslRuntimeLib are optional alternatives selected + // automatically when the application runs on a musl-based Linux system. + LinuxMuslCli io.Reader + LinuxMuslCliHash []byte + LinuxMuslRuntimeLib io.Reader + LinuxMuslRuntimeLibHash []byte + Dir string Version string } @@ -38,6 +54,12 @@ func Setup(cfg Config) { if len(cfg.CliHash) != sha256.Size { panic(fmt.Sprintf("CliHash must be a SHA-256 hash (%d bytes), got %d bytes", sha256.Size, len(cfg.CliHash))) } + if cfg.LinuxMuslCli != nil && len(cfg.LinuxMuslCliHash) != sha256.Size { + panic(fmt.Sprintf("LinuxMuslCliHash must be a SHA-256 hash (%d bytes), got %d bytes", sha256.Size, len(cfg.LinuxMuslCliHash))) + } + if cfg.LinuxMuslRuntimeLib != nil && len(cfg.LinuxMuslRuntimeLibHash) != sha256.Size { + panic(fmt.Sprintf("LinuxMuslRuntimeLibHash must be a SHA-256 hash (%d bytes), got %d bytes", sha256.Size, len(cfg.LinuxMuslRuntimeLibHash))) + } setupMu.Lock() defer setupMu.Unlock() if setupDone { @@ -61,14 +83,28 @@ var Path = sync.OnceValue(func() string { return path }) +// RuntimeLibPath returns the on-disk path to the installed native in-process +// runtime library (cdylib), or "" when no runtime library was bundled or the +// CLI could not be installed. It ensures the embedded CLI is installed first. +func RuntimeLibPath() string { + Path() + setupMu.Lock() + defer setupMu.Unlock() + return runtimeLibPath +} + var ( config Config setupMu sync.Mutex setupDone bool pathInitialized bool + runtimeLibPath string + linuxMuslBundle bool ) func install() (path string) { + selectLinuxMuslBundle() + verbose := os.Getenv("COPILOT_CLI_INSTALL_VERBOSE") == "1" logError := func(msg string, err error) { if verbose { @@ -103,18 +139,41 @@ func install() (path string) { return path } -func installAt(installDir string) (string, error) { - if err := os.MkdirAll(installDir, 0755); err != nil { - return "", fmt.Errorf("creating install directory: %w", err) +func selectLinuxMuslBundle() { + if runtime.GOOS != "linux" || config.LinuxMuslCli == nil || !isMusl() { + return } + config = linuxMuslConfig(config) + linuxMuslBundle = true +} + +func linuxMuslConfig(cfg Config) Config { + cfg.Cli = cfg.LinuxMuslCli + cfg.CliHash = cfg.LinuxMuslCliHash + cfg.RuntimeLib = cfg.LinuxMuslRuntimeLib + cfg.RuntimeLibHash = cfg.LinuxMuslRuntimeLibHash + return cfg +} + +func isMusl() bool { + out, _ := exec.Command("ldd", "--version").CombinedOutput() + return strings.Contains(strings.ToLower(string(out)), "musl") +} + +func installAt(installDir string) (string, error) { version := sanitizeVersion(config.Version) - lockName := ".copilot-cli.lock" if version != "" { - lockName = fmt.Sprintf(".copilot-cli-%s.lock", version) + installDir = filepath.Join(installDir, version) + } + if linuxMuslBundle { + installDir = filepath.Join(installDir, "linuxmusl") + } + if err := os.MkdirAll(installDir, 0755); err != nil { + return "", fmt.Errorf("creating install directory: %w", err) } // Best effort to prevent concurrent installs. - if release, _ := flock.Acquire(filepath.Join(installDir, lockName)); release != nil { + if release, _ := flock.Acquire(filepath.Join(installDir, ".copilot-cli.lock")); release != nil { defer release() } @@ -122,7 +181,7 @@ func installAt(installDir string) (string, error) { if runtime.GOOS == "windows" { binaryName += ".exe" } - finalPath := versionedBinaryPath(installDir, binaryName, version) + finalPath := filepath.Join(installDir, binaryName) if _, err := os.Stat(finalPath); err == nil { existingHash, err := hashFile(finalPath) @@ -132,6 +191,13 @@ func installAt(installDir string) (string, error) { if !bytes.Equal(existingHash, config.CliHash) { return "", fmt.Errorf("existing binary hash mismatch") } + if config.RuntimeLib != nil { + libPath, err := installRuntimeLib(installDir) + if err != nil { + return "", err + } + runtimeLibPath = libPath + } return finalPath, nil } @@ -155,17 +221,81 @@ func installAt(installDir string) (string, error) { return "", fmt.Errorf("writing license file: %w", err) } } + + // Install the native in-process runtime library (if bundled) next to the CLI. + // Fail closed on any hash mismatch; never place unverified native code. + if config.RuntimeLib != nil { + libPath, err := installRuntimeLib(installDir) + if err != nil { + return "", err + } + runtimeLibPath = libPath + } + return finalPath, nil } -// versionedBinaryPath builds the unpacked binary filename with an optional version suffix. -func versionedBinaryPath(dir, binaryName, version string) string { - if version == "" { - return filepath.Join(dir, binaryName) +// installRuntimeLib writes the embedded runtime cdylib into installDir under its +// natural platform file name, verifying its SHA-256. It is idempotent: an +// existing file with a matching hash is reused; a mismatch is a hard error. +func installRuntimeLib(installDir string) (string, error) { + if len(config.RuntimeLibHash) != sha256.Size { + return "", fmt.Errorf("RuntimeLibHash must be a SHA-256 hash (%d bytes), got %d bytes", sha256.Size, len(config.RuntimeLibHash)) + } + libPath := filepath.Join(installDir, naturalRuntimeLibName()) + + if _, err := os.Stat(libPath); err == nil { + existingHash, err := hashFile(libPath) + if err != nil { + return "", fmt.Errorf("hashing existing runtime library: %w", err) + } + if !bytes.Equal(existingHash, config.RuntimeLibHash) { + return "", fmt.Errorf("existing runtime library hash mismatch") + } + return libPath, nil + } + + // Write to a temp file in the same directory, verify, then atomically rename. + tmp, err := os.CreateTemp(installDir, ".copilot-runtime-*.tmp") + if err != nil { + return "", fmt.Errorf("creating temp runtime library: %w", err) + } + tmpPath := tmp.Name() + h := sha256.New() + _, err = io.Copy(io.MultiWriter(tmp, h), config.RuntimeLib) + if err1 := tmp.Close(); err1 != nil && err == nil { + err = err1 + } + if closer, ok := config.RuntimeLib.(io.Closer); ok { + closer.Close() + } + if err != nil { + os.Remove(tmpPath) + return "", fmt.Errorf("writing runtime library: %w", err) + } + if !bytes.Equal(h.Sum(nil), config.RuntimeLibHash) { + os.Remove(tmpPath) + return "", fmt.Errorf("runtime library hash mismatch") + } + if err := os.Rename(tmpPath, libPath); err != nil { + os.Remove(tmpPath) + return "", fmt.Errorf("installing runtime library: %w", err) + } + return libPath, nil +} + +// naturalRuntimeLibName is the flat platform file name for the runtime cdylib, +// matching ffihost.NaturalLibraryName (kept in sync; embeddedcli stays +// dependency-free for use by generated embed files). +func naturalRuntimeLibName() string { + switch runtime.GOOS { + case "windows": + return "copilot_runtime.dll" + case "darwin": + return "libcopilot_runtime.dylib" + default: + return "libcopilot_runtime.so" } - base := strings.TrimSuffix(binaryName, filepath.Ext(binaryName)) - ext := filepath.Ext(binaryName) - return filepath.Join(dir, fmt.Sprintf("%s_%s%s", base, version, ext)) } // sanitizeVersion makes a version string safe for filenames. @@ -188,7 +318,11 @@ func sanitizeVersion(version string) string { b.WriteRune('_') } } - return b.String() + sanitized := b.String() + if sanitized == "." || sanitized == ".." { + return strings.Repeat("_", len(sanitized)) + } + return sanitized } // hashFile returns the SHA-256 hash of a file on disk. diff --git a/go/internal/embeddedcli/embeddedcli_test.go b/go/internal/embeddedcli/embeddedcli_test.go index 0453f7293d..b0394e0f69 100644 --- a/go/internal/embeddedcli/embeddedcli_test.go +++ b/go/internal/embeddedcli/embeddedcli_test.go @@ -16,6 +16,8 @@ func resetGlobals() { config = Config{} setupDone = false pathInitialized = false + runtimeLibPath = "" + linuxMuslBundle = false } func mustPanic(t *testing.T, fn func()) { @@ -36,6 +38,31 @@ func binaryNameForOS() string { return name } +func TestLinuxMuslConfigSelectsAlternativeArtifacts(t *testing.T) { + glibcCLI := strings.NewReader("glibc-cli") + glibcRuntime := strings.NewReader("glibc-runtime") + muslCLI := strings.NewReader("musl-cli") + muslRuntime := strings.NewReader("musl-runtime") + muslCLIHash := bytes.Repeat([]byte{1}, sha256.Size) + muslRuntimeHash := bytes.Repeat([]byte{2}, sha256.Size) + + selected := linuxMuslConfig(Config{ + Cli: glibcCLI, + RuntimeLib: glibcRuntime, + LinuxMuslCli: muslCLI, + LinuxMuslCliHash: muslCLIHash, + LinuxMuslRuntimeLib: muslRuntime, + LinuxMuslRuntimeLibHash: muslRuntimeHash, + }) + + if selected.Cli != muslCLI || selected.RuntimeLib != muslRuntime { + t.Fatal("Expected Linux musl artifacts to replace the glibc artifacts") + } + if !bytes.Equal(selected.CliHash, muslCLIHash) || !bytes.Equal(selected.RuntimeLibHash, muslRuntimeHash) { + t.Fatal("Expected Linux musl hashes to replace the glibc hashes") + } +} + func TestSetupPanicsOnNilCli(t *testing.T) { resetGlobals() mustPanic(t, func() { Setup(Config{}) }) @@ -65,7 +92,7 @@ func TestInstallAtWritesBinaryAndLicense(t *testing.T) { path := Path() - expectedPath := versionedBinaryPath(tempDir, binaryNameForOS(), "1.2.3") + expectedPath := filepath.Join(tempDir, "1.2.3", binaryNameForOS()) if path != expectedPath { t.Fatalf("unexpected path: got %q want %q", path, expectedPath) } @@ -99,7 +126,7 @@ func TestInstallAtWritesBinaryAndLicense(t *testing.T) { func TestInstallAtExistingBinaryHashMismatch(t *testing.T) { resetGlobals() tempDir := t.TempDir() - binaryPath := versionedBinaryPath(tempDir, binaryNameForOS(), "") + binaryPath := filepath.Join(tempDir, binaryNameForOS()) if err := os.MkdirAll(filepath.Dir(binaryPath), 0755); err != nil { t.Fatalf("mkdir: %v", err) } @@ -120,17 +147,102 @@ func TestInstallAtExistingBinaryHashMismatch(t *testing.T) { } func TestSanitizeVersion(t *testing.T) { - got := sanitizeVersion("v1.2.3+build/abc") - want := "v1.2.3_build_abc" - if got != want { - t.Fatalf("sanitizeVersion() = %q want %q", got, want) + tests := map[string]string{ + "v1.2.3+build/abc": "v1.2.3_build_abc", + ".": "_", + "..": "__", + } + for input, want := range tests { + if got := sanitizeVersion(input); got != want { + t.Errorf("sanitizeVersion(%q) = %q want %q", input, got, want) + } } } -func TestVersionedBinaryPath(t *testing.T) { - got := versionedBinaryPath("/tmp", "copilot.exe", "1.0.0") - want := filepath.Join("/tmp", "copilot_1.0.0.exe") - if got != want { - t.Fatalf("versionedBinaryPath() = %q want %q", got, want) +func TestInstallAtAllowsMultipleRuntimeVersions(t *testing.T) { + resetGlobals() + tempDir := t.TempDir() + + installVersion := func(version string, cliContent, runtimeContent []byte) (string, string) { + t.Helper() + cliHash := sha256.Sum256(cliContent) + runtimeHash := sha256.Sum256(runtimeContent) + config = Config{ + Cli: bytes.NewReader(cliContent), + CliHash: cliHash[:], + RuntimeLib: bytes.NewReader(runtimeContent), + RuntimeLibHash: runtimeHash[:], + Version: version, + } + + cliPath, err := installAt(tempDir) + if err != nil { + t.Fatalf("install version %s: %v", version, err) + } + return cliPath, runtimeLibPath + } + + cli1, runtime1 := installVersion("1.0.0", []byte("cli-one"), []byte("runtime-one")) + cli2, runtime2 := installVersion("2.0.0", []byte("cli-two"), []byte("runtime-two")) + + if cli1 == cli2 { + t.Fatalf("Expected versioned CLI paths to differ, got %q", cli1) + } + if runtime1 == runtime2 { + t.Fatalf("Expected versioned runtime paths to differ, got %q", runtime1) + } + if got, want := filepath.Base(cli1), binaryNameForOS(); got != want { + t.Fatalf("First CLI filename = %q, want %q", got, want) + } + if got, want := filepath.Base(runtime1), naturalRuntimeLibName(); got != want { + t.Fatalf("First runtime filename = %q, want %q", got, want) + } + if got, want := filepath.Base(filepath.Dir(cli1)), "1.0.0"; got != want { + t.Fatalf("First CLI version directory = %q, want %q", got, want) + } + if filepath.Dir(cli1) != filepath.Dir(runtime1) { + t.Fatalf("CLI and runtime were installed in different directories: %q and %q", cli1, runtime1) + } + if got, err := os.ReadFile(runtime1); err != nil || string(got) != "runtime-one" { + t.Fatalf("Unexpected first runtime: content=%q err=%v", got, err) + } + if got, err := os.ReadFile(runtime2); err != nil || string(got) != "runtime-two" { + t.Fatalf("Unexpected second runtime: content=%q err=%v", got, err) + } +} + +func TestInstallAtExistingBinaryInstallsMissingRuntime(t *testing.T) { + resetGlobals() + tempDir := t.TempDir() + versionDir := filepath.Join(tempDir, "1.2.3") + if err := os.MkdirAll(versionDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + cliContent := []byte("cli") + cliPath := filepath.Join(versionDir, binaryNameForOS()) + if err := os.WriteFile(cliPath, cliContent, 0755); err != nil { + t.Fatalf("write CLI: %v", err) + } + cliHash := sha256.Sum256(cliContent) + runtimeContent := []byte("runtime") + runtimeHash := sha256.Sum256(runtimeContent) + config = Config{ + Cli: bytes.NewReader(cliContent), + CliHash: cliHash[:], + RuntimeLib: bytes.NewReader(runtimeContent), + RuntimeLibHash: runtimeHash[:], + Version: "1.2.3", + } + + gotCLIPath, err := installAt(tempDir) + if err != nil { + t.Fatalf("installAt(): %v", err) + } + if gotCLIPath != cliPath { + t.Fatalf("installAt() = %q, want %q", gotCLIPath, cliPath) + } + if got, err := os.ReadFile(filepath.Join(versionDir, naturalRuntimeLibName())); err != nil || string(got) != "runtime" { + t.Fatalf("Unexpected runtime: content=%q err=%v", got, err) } } diff --git a/go/internal/ffihost/buffer.go b/go/internal/ffihost/buffer.go new file mode 100644 index 0000000000..2185ce833d --- /dev/null +++ b/go/internal/ffihost/buffer.go @@ -0,0 +1,67 @@ +//go:build copilot_inprocess && (darwin || linux || windows) + +package ffihost + +import ( + "io" + "sync" +) + +// receiveBuffer is a thread-safe byte buffer that feeds blocking Read from a +// producer thread. The native outbound callback (invoked on a foreign runtime +// thread) appends frames via feed without ever blocking; the JSON-RPC reader +// goroutine drains them via Read, which blocks until data or EOF. +// +// It implements io.ReadCloser so it can be handed to jsonrpc2.NewClient as the +// server → client stream. +type receiveBuffer struct { + mu sync.Mutex + cond *sync.Cond + buf []byte + closed bool +} + +func newReceiveBuffer() *receiveBuffer { + rb := &receiveBuffer{} + rb.cond = sync.NewCond(&rb.mu) + return rb +} + +func (rb *receiveBuffer) feed(data []byte) { + rb.mu.Lock() + defer rb.mu.Unlock() + if rb.closed { + return + } + rb.buf = append(rb.buf, data...) + rb.cond.Broadcast() +} + +// Read blocks until at least one byte is available or the buffer is closed. +// It returns io.EOF only once the buffer is closed and fully drained. +func (rb *receiveBuffer) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + rb.mu.Lock() + defer rb.mu.Unlock() + for len(rb.buf) == 0 && !rb.closed { + rb.cond.Wait() + } + if len(rb.buf) == 0 { + return 0, io.EOF + } + n := copy(p, rb.buf) + rb.buf = rb.buf[n:] + return n, nil +} + +// Close marks the buffer closed; subsequent Reads drain remaining bytes then +// return io.EOF. Idempotent. +func (rb *receiveBuffer) Close() error { + rb.mu.Lock() + defer rb.mu.Unlock() + rb.closed = true + rb.cond.Broadcast() + return nil +} diff --git a/go/internal/ffihost/ffihost.go b/go/internal/ffihost/ffihost.go new file mode 100644 index 0000000000..30cd831281 --- /dev/null +++ b/go/internal/ffihost/ffihost.go @@ -0,0 +1,384 @@ +//go:build copilot_inprocess && (darwin || linux || windows) + +// Package ffihost hosts the Copilot runtime in-process by loading its native +// library and driving JSON-RPC over the runtime's C ABI. +// +// It pumps opaque LSP Content-Length-framed JSON-RPC bytes across the boundary: +// +// - client → server frames go to copilot_runtime_connection_write +// - server → client frames arrive on a native callback that feeds a +// thread-safe receive buffer read by the JSON-RPC client +// +// The existing internal/jsonrpc2 client handles framing unchanged — this is a +// transport swap, not a new protocol. Host exposes an io.WriteCloser (client → +// server) and io.ReadCloser (server → client) that plug straight into +// jsonrpc2.NewClient. +// +// The C ABI (shared with the .NET, Node.js, Python, and Rust SDKs): +// +// uint32 copilot_runtime_host_start(uint8 *argv, size_t argv_len, +// uint8 *env, size_t env_len); +// bool copilot_runtime_host_shutdown(uint32 server_id); +// uint32 copilot_runtime_connection_open(uint32 server_id, outbound cb, +// void *user_data, +// uint8 *a, size_t a_len, +// uint8 *b, size_t b_len, +// uint8 *c, size_t c_len); +// bool copilot_runtime_connection_write(uint32 conn_id, uint8 *bytes, size_t len); +// bool copilot_runtime_connection_close(uint32 conn_id); +// // outbound callback: +// void outbound(void *user_data, uint8 *bytes, size_t len); +// +// The native binding uses github.com/ebitengine/purego so the library is loaded +// at runtime with CGO disabled, preserving the SDK's pure-Go build and +// cross-compilation. +package ffihost + +import ( + "encoding/json" + "fmt" + "io" + "runtime" + "strings" + "sync" + "sync/atomic" + "unsafe" + + "github.com/ebitengine/purego" +) + +const symbolPrefix = "copilot_runtime_" + +// ffiLibrary binds the copilot_runtime_* C ABI exports of a loaded cdylib. +type ffiLibrary struct { + handle uintptr + hostStart func(argv unsafe.Pointer, argvLen uintptr, env unsafe.Pointer, envLen uintptr) uint32 + hostShutdown func(serverID uint32) bool + connectionOpen func(serverID uint32, cb uintptr, userData uintptr, a unsafe.Pointer, aLen uintptr, b unsafe.Pointer, bLen uintptr, c unsafe.Pointer, cLen uintptr) uint32 + connectionWrite func(connID uint32, bytes unsafe.Pointer, length uintptr) bool + connectionClose func(connID uint32) bool +} + +// The cdylib may only be loaded once per process; a second load of a different +// path is unsupported (matches the .NET/Node/Python/Rust hosts). Guard it here. +var ( + loadMu sync.Mutex + loadedLibrary *ffiLibrary + loadedLibraryPath string +) + +var ( + outboundCallbackOnce sync.Once + outboundCallbackHandle uintptr + outboundTargets sync.Map + nextOutboundToken atomic.Uint64 +) + +func sharedOutboundCallback() uintptr { + outboundCallbackOnce.Do(func() { + outboundCallbackHandle = purego.NewCallback(routeOutbound) + }) + return outboundCallbackHandle +} + +func routeOutbound(userData uintptr, bytesPtr uintptr, bytesLen uintptr) uintptr { + target, ok := outboundTargets.Load(userData) + if !ok { + return 0 + } + return target.(*Host).onOutbound(bytesPtr, bytesLen) +} + +func loadLibrary(libraryPath string) (lib *ffiLibrary, err error) { + loadMu.Lock() + defer loadMu.Unlock() + + if loadedLibrary != nil { + if loadedLibraryPath != libraryPath { + return nil, fmt.Errorf( + "an in-process FFI runtime library is already loaded from %q; loading a different library from %q in the same process is not supported", + loadedLibraryPath, libraryPath) + } + return loadedLibrary, nil + } + + handle, err := openLibrary(libraryPath) + if err != nil { + return nil, fmt.Errorf("loading FFI runtime library %q: %w", libraryPath, err) + } + + // RegisterLibFunc panics if a symbol is missing; convert that to an error so + // callers get a clean failure instead of a crash. + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("binding FFI runtime library %q: %v", libraryPath, r) + lib = nil + } + }() + + bound := &ffiLibrary{handle: handle} + purego.RegisterLibFunc(&bound.hostStart, handle, symbolPrefix+"host_start") + purego.RegisterLibFunc(&bound.hostShutdown, handle, symbolPrefix+"host_shutdown") + purego.RegisterLibFunc(&bound.connectionOpen, handle, symbolPrefix+"connection_open") + purego.RegisterLibFunc(&bound.connectionWrite, handle, symbolPrefix+"connection_write") + purego.RegisterLibFunc(&bound.connectionClose, handle, symbolPrefix+"connection_close") + + loadedLibrary = bound + loadedLibraryPath = libraryPath + return bound, nil +} + +// Host hosts the Copilot runtime in-process via its native C ABI. +// +// Construct with Create, call Start to open the FFI connection, wire +// Writer/Reader into jsonrpc2.NewClient, and call Dispose to tear everything +// down. +type Host struct { + libraryPath string + cliEntrypoint string + environment map[string]string + args []string + lib *ffiLibrary + + // lifecycleMu serializes native start/write/shutdown operations. hostStart + // cannot be interrupted, so Dispose waits for it before closing native IDs. + lifecycleMu sync.Mutex + // mu serializes disposal with native callbacks so the receive buffer cannot + // be fed after it is closed. + mu sync.Mutex + serverID uint32 + connectionID uint32 + disposed bool + // activeCallbacks counts outbound native callbacks currently executing. + activeCallbacks int + + recv *receiveBuffer + + callbackToken uintptr +} + +// Create resolves the native library and prepares the host. environment and +// args contain SDK-managed runtime options. +func Create(cliEntrypoint string, environment map[string]string, args []string) (*Host, error) { + libraryPath, err := ResolveLibraryPath(cliEntrypoint) + if err != nil { + return nil, err + } + lib, err := loadLibrary(libraryPath) + if err != nil { + return nil, err + } + return &Host{ + libraryPath: libraryPath, + cliEntrypoint: cliEntrypoint, + environment: environment, + args: append([]string(nil), args...), + lib: lib, + recv: newReceiveBuffer(), + }, nil +} + +// Start opens the FFI connection. Native startup may block, so callers should +// run it off any latency-sensitive goroutine. +func (h *Host) Start() error { + h.lifecycleMu.Lock() + defer h.lifecycleMu.Unlock() + + h.mu.Lock() + if h.disposed { + h.mu.Unlock() + return fmt.Errorf("the in-process runtime host is disposed") + } + h.mu.Unlock() + + argv := h.buildArgv() + env := h.buildEnv() + + var argvPtr, envPtr unsafe.Pointer + if len(argv) > 0 { + argvPtr = unsafe.Pointer(&argv[0]) + } + if len(env) > 0 { + envPtr = unsafe.Pointer(&env[0]) + } + + h.serverID = h.lib.hostStart(argvPtr, uintptr(len(argv)), envPtr, uintptr(len(env))) + // Keep the JSON buffers alive across the (synchronous) native call. + runtime.KeepAlive(argv) + runtime.KeepAlive(env) + if h.serverID == 0 { + return fmt.Errorf("copilot_runtime_host_start failed (library %q, entrypoint %q)", h.libraryPath, h.cliEntrypoint) + } + + // host_start spawned the worker child via libuv's uv_spawn, which installs a + // SIGCHLD handler without SA_ONSTACK on its first call. The Go runtime aborts + // ("non-Go code set up signal handler without SA_ONSTACK flag") when it later + // reaps one of its own os/exec children (e.g. a test-spawned MCP server) and + // the delivered SIGCHLD lands on a non-signal stack. Re-add SA_ONSTACK to that + // foreign handler now that it exists (implemented on darwin+linux; a no-op on + // other platforms, and before the first spawn there is nothing to fix — hence + // here rather than at library load). + rearmForeignSignalHandlers(h.lib.handle) + + callbackHandle := sharedOutboundCallback() + callbackToken := uintptr(nextOutboundToken.Add(1)) + outboundTargets.Store(callbackToken, h) + h.callbackToken = callbackToken + h.connectionID = h.lib.connectionOpen(h.serverID, callbackHandle, callbackToken, nil, 0, nil, 0, nil, 0) + if h.connectionID == 0 { + outboundTargets.Delete(callbackToken) + h.callbackToken = 0 + h.lib.hostShutdown(h.serverID) + rearmForeignSignalHandlers(h.lib.handle) + h.serverID = 0 + return fmt.Errorf("copilot_runtime_connection_open failed") + } + return nil +} + +// Writer returns the client → server frame sink (plug into jsonrpc2 as stdin). +func (h *Host) Writer() io.WriteCloser { return hostWriter{h} } + +// Reader returns the server → client frame source (plug into jsonrpc2 as stdout). +func (h *Host) Reader() io.ReadCloser { return h.recv } + +func (h *Host) buildArgv() []byte { + // A `.js` entrypoint (dev) is launched via node; the packaged single-file CLI + // embeds its own Node and is invoked directly. `--no-auto-update` pins the + // worker to the runtime package matching the loaded cdylib (avoids ABI skew). + var argv []string + if strings.HasSuffix(strings.ToLower(h.cliEntrypoint), ".js") { + argv = []string{"node", h.cliEntrypoint, "--embedded-host", "--no-auto-update"} + } else { + argv = []string{h.cliEntrypoint, "--embedded-host", "--no-auto-update"} + } + argv = append(argv, h.args...) + b, _ := json.Marshal(argv) + return b +} + +func (h *Host) buildEnv() []byte { + if len(h.environment) == 0 { + return nil + } + b, _ := json.Marshal(h.environment) + return b +} + +// onOutbound is the native server → client callback, invoked on a foreign +// runtime thread. The native pointer is only valid for this call, so the bytes +// are copied out before returning. Nothing may panic across the FFI boundary. +func (h *Host) onOutbound(bytesPtr uintptr, bytesLen uintptr) uintptr { + h.mu.Lock() + if h.disposed { + h.mu.Unlock() + return 0 + } + h.activeCallbacks++ + h.mu.Unlock() + + defer func() { + h.mu.Lock() + h.activeCallbacks-- + h.mu.Unlock() + // Never let a panic unwind into native code. + _ = recover() + }() + + if bytesPtr != 0 && bytesLen > 0 { + // The native runtime delivers the outbound frame as a raw buffer address + // (uintptr) plus length. Materialize a slice over it just long enough to + // copy the bytes into Go-owned memory before returning to native code. + //nolint:govet // FFI callback receives the buffer address as an integer; converting it to a pointer to copy out is the intended, checked-length use. + src := unsafe.Slice((*byte)(unsafe.Pointer(bytesPtr)), int(bytesLen)) + buf := make([]byte, len(src)) + copy(buf, src) + h.recv.feed(buf) + } + return 0 +} + +func (h *Host) writeFrame(frame []byte) (int, error) { + h.lifecycleMu.Lock() + defer h.lifecycleMu.Unlock() + + h.mu.Lock() + disposed := h.disposed + h.mu.Unlock() + connID := h.connectionID + if disposed || connID == 0 { + return 0, fmt.Errorf("the in-process runtime connection is closed") + } + if len(frame) == 0 { + return 0, nil + } + ok := h.lib.connectionWrite(connID, unsafe.Pointer(&frame[0]), uintptr(len(frame))) + runtime.KeepAlive(frame) + if !ok { + return 0, fmt.Errorf("failed to write a frame to the in-process runtime connection") + } + return len(frame), nil +} + +// Dispose closes the FFI connection, shuts down the native host, and releases +// resources. It is idempotent and waits for any in-flight outbound callback to +// finish before closing the receive buffer. +func (h *Host) Dispose() { + h.lifecycleMu.Lock() + defer h.lifecycleMu.Unlock() + + h.mu.Lock() + if h.disposed { + h.mu.Unlock() + return + } + // Publish disposed under the same lock onOutbound uses to check it, so no new + // callback can pass the check and increment activeCallbacks after the drain + // loop below observes zero. + h.disposed = true + connID := h.connectionID + serverID := h.serverID + callbackToken := h.callbackToken + h.connectionID = 0 + h.serverID = 0 + h.callbackToken = 0 + h.mu.Unlock() + + if callbackToken != 0 { + outboundTargets.Delete(callbackToken) + } + + // Stop accepting new callbacks and wait for in-flight ones to drain before + // closing the receive buffer they feed. + for { + h.mu.Lock() + if h.activeCallbacks == 0 { + h.mu.Unlock() + break + } + h.mu.Unlock() + runtime.Gosched() + } + + if connID != 0 { + h.lib.connectionClose(connID) + } + if serverID != 0 { + h.lib.hostShutdown(serverID) + // libuv may restore a previously saved SIGCHLD action while tearing down + // its final child watcher, so repair the process-wide handler again after + // shutdown before Go reaps another os/exec child. + rearmForeignSignalHandlers(h.lib.handle) + } + h.recv.Close() +} + +// hostWriter adapts Host into the io.WriteCloser jsonrpc2 writes request frames to. +type hostWriter struct{ h *Host } + +func (w hostWriter) Write(p []byte) (int, error) { return w.h.writeFrame(p) } + +func (w hostWriter) Close() error { + w.h.Dispose() + return nil +} diff --git a/go/internal/ffihost/ffihost_test.go b/go/internal/ffihost/ffihost_test.go new file mode 100644 index 0000000000..bc588fa6a9 --- /dev/null +++ b/go/internal/ffihost/ffihost_test.go @@ -0,0 +1,98 @@ +//go:build copilot_inprocess && (darwin || linux || windows) + +package ffihost + +import ( + "encoding/json" + "sync/atomic" + "testing" + "time" + "unsafe" +) + +func TestDisposeUnregistersOutboundTarget(t *testing.T) { + token := uintptr(nextOutboundToken.Add(1)) + host := &Host{ + recv: newReceiveBuffer(), + callbackToken: token, + } + outboundTargets.Store(token, host) + + host.Dispose() + + if _, ok := outboundTargets.Load(token); ok { + t.Fatal("Expected disposed host to be removed from outbound callback registry") + } +} + +func TestBuildArgvAppendsManagedOptions(t *testing.T) { + host := &Host{ + cliEntrypoint: "copilot", + args: []string{"--log-level", "debug", "--remote"}, + } + + var argv []string + if err := json.Unmarshal(host.buildArgv(), &argv); err != nil { + t.Fatal(err) + } + + expected := []string{"copilot", "--embedded-host", "--no-auto-update", "--log-level", "debug", "--remote"} + if len(argv) != len(expected) { + t.Fatalf("Expected %d arguments, got %d: %v", len(expected), len(argv), argv) + } + for i := range expected { + if argv[i] != expected[i] { + t.Fatalf("Expected argument %d to be %q, got %q", i, expected[i], argv[i]) + } + } +} + +func TestDisposeWaitsForStartBeforeShuttingDown(t *testing.T) { + started := make(chan struct{}) + releaseStart := make(chan struct{}) + startDone := make(chan error, 1) + disposeDone := make(chan struct{}) + var shutdownID atomic.Uint32 + + host := &Host{ + lib: &ffiLibrary{ + hostStart: func(_ unsafe.Pointer, _ uintptr, _ unsafe.Pointer, _ uintptr) uint32 { + close(started) + <-releaseStart + return 41 + }, + hostShutdown: func(serverID uint32) bool { + shutdownID.Store(serverID) + return true + }, + connectionOpen: func(_ uint32, _ uintptr, _ uintptr, _ unsafe.Pointer, _ uintptr, _ unsafe.Pointer, _ uintptr, _ unsafe.Pointer, _ uintptr) uint32 { + return 42 + }, + connectionClose: func(_ uint32) bool { return true }, + }, + recv: newReceiveBuffer(), + } + + go func() { startDone <- host.Start() }() + <-started + go func() { + host.Dispose() + close(disposeDone) + }() + + select { + case <-disposeDone: + t.Fatal("Dispose returned before native startup completed") + case <-time.After(20 * time.Millisecond): + } + + close(releaseStart) + if err := <-startDone; err != nil { + t.Fatal(err) + } + <-disposeDone + + if got := shutdownID.Load(); got != 41 { + t.Fatalf("Expected shutdown of server 41, got %d", got) + } +} diff --git a/go/internal/ffihost/loader_other.go b/go/internal/ffihost/loader_other.go new file mode 100644 index 0000000000..0b7bcc40b9 --- /dev/null +++ b/go/internal/ffihost/loader_other.go @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT + +//go:build copilot_inprocess && (darwin || linux) + +package ffihost + +import "github.com/ebitengine/purego" + +// openLibrary loads the shared library at path and returns an opaque handle. +// RTLD_NOW surfaces any load problem here (eager binding) rather than at first +// call, matching the .NET/Python hosts; RTLD_LOCAL keeps the runtime's symbols +// private to this handle. +func openLibrary(path string) (uintptr, error) { + return purego.Dlopen(path, purego.RTLD_NOW|purego.RTLD_LOCAL) +} diff --git a/go/internal/ffihost/loader_windows.go b/go/internal/ffihost/loader_windows.go new file mode 100644 index 0000000000..4ac2789f98 --- /dev/null +++ b/go/internal/ffihost/loader_windows.go @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT + +//go:build copilot_inprocess && windows + +package ffihost + +import "syscall" + +// openLibrary loads the DLL at path and returns its module handle. purego's +// RegisterLibFunc resolves exports from this handle via GetProcAddress, so the +// standard-library loader is sufficient and keeps CGO disabled. +func openLibrary(path string) (uintptr, error) { + handle, err := syscall.LoadLibrary(path) + if err != nil { + return 0, err + } + return uintptr(handle), nil +} diff --git a/go/internal/ffihost/resolve.go b/go/internal/ffihost/resolve.go new file mode 100644 index 0000000000..c8d4052322 --- /dev/null +++ b/go/internal/ffihost/resolve.go @@ -0,0 +1,117 @@ +package ffihost + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" +) + +// NaturalLibraryName is the natural platform shared-library file name for the +// runtime cdylib — the `.node` file renamed to what a Rust cdylib would be +// called on this OS. The library is loaded by absolute path, so the on-disk name +// is ours to choose; this matches the flat name the bundler installs next to the +// CLI binary and the name the other SDKs use. +func NaturalLibraryName() string { + switch runtime.GOOS { + case "windows": + return "copilot_runtime.dll" + case "darwin": + return "libcopilot_runtime.dylib" + default: + return "libcopilot_runtime.so" + } +} + +// PrebuildsFolder returns the napi-rs `-` folder name the +// runtime package ships under prebuilds/ (e.g. linux-x64, darwin-arm64, +// win32-x64, including the musl variant on Alpine). Returns "" for unsupported +// platforms. +func PrebuildsFolder() string { + var platform string + switch runtime.GOOS { + case "linux": + if isMusl() { + platform = "linuxmusl" + } else { + platform = "linux" + } + case "darwin": + platform = "darwin" + case "windows": + platform = "win32" + default: + return "" + } + + var arch string + switch runtime.GOARCH { + case "amd64": + arch = "x64" + case "arm64": + arch = "arm64" + default: + return "" + } + return platform + "-" + arch +} + +// ResolveLibraryPath resolves the native runtime library next to the given CLI +// entrypoint. It checks, in order: +// +// 1. The natural platform library name next to the CLI (bundled/flat layout). +// 2. prebuilds//runtime.node next to the CLI (dev/package layout). +// +// It returns an error when neither exists. +func ResolveLibraryPath(cliEntrypoint string) (string, error) { + abs, err := filepath.Abs(cliEntrypoint) + if err != nil { + abs = cliEntrypoint + } + dir := filepath.Dir(abs) + + flat := filepath.Join(dir, NaturalLibraryName()) + if fileExists(flat) { + return flat, nil + } + + if folder := PrebuildsFolder(); folder != "" { + prebuilt := filepath.Join(dir, "prebuilds", folder, "runtime.node") + if fileExists(prebuilt) { + return prebuilt, nil + } + } + + return "", fmt.Errorf( + "in-process FFI runtime library not found next to %q (looked for %q and prebuilds/%s/runtime.node); "+ + "use a runtime package that ships the native library", + abs, NaturalLibraryName(), PrebuildsFolder()) +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +var ( + muslOnce sync.Once + muslResult bool +) + +// isMusl reports whether the current Linux system uses musl libc (e.g. Alpine), +// which ships the runtime under the linuxmusl- prebuilds folder. +func isMusl() bool { + muslOnce.Do(func() { + if runtime.GOOS != "linux" { + return + } + // `ldd --version` prints "musl libc" on musl systems and errors/glibc text + // elsewhere; a best-effort check is enough to pick the prebuilds folder. + out, _ := exec.Command("ldd", "--version").CombinedOutput() + muslResult = strings.Contains(strings.ToLower(string(out)), "musl") + }) + return muslResult +} diff --git a/go/internal/ffihost/resolve_test.go b/go/internal/ffihost/resolve_test.go new file mode 100644 index 0000000000..df3a668dfe --- /dev/null +++ b/go/internal/ffihost/resolve_test.go @@ -0,0 +1,54 @@ +package ffihost + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveLibraryPathUsesNaturalLibraryNextToCLI(t *testing.T) { + dir := t.TempDir() + cliPath := filepath.Join(dir, "copilot") + libraryPath := filepath.Join(dir, NaturalLibraryName()) + + for _, path := range []string{cliPath, libraryPath} { + if err := os.WriteFile(path, []byte("test"), 0600); err != nil { + t.Fatalf("WriteFile(%q): %v", path, err) + } + } + + got, err := ResolveLibraryPath(cliPath) + if err != nil { + t.Fatalf("ResolveLibraryPath() error: %v", err) + } + if got != libraryPath { + t.Fatalf("ResolveLibraryPath() = %q, want %q", got, libraryPath) + } +} + +func TestResolveLibraryPathFallsBackToPrebuilds(t *testing.T) { + folder := PrebuildsFolder() + if folder == "" { + t.Skip("unsupported platform") + } + + dir := t.TempDir() + cliPath := filepath.Join(dir, "copilot") + libraryPath := filepath.Join(dir, "prebuilds", folder, "runtime.node") + if err := os.MkdirAll(filepath.Dir(libraryPath), 0755); err != nil { + t.Fatalf("MkdirAll(): %v", err) + } + for _, path := range []string{cliPath, libraryPath} { + if err := os.WriteFile(path, []byte("test"), 0600); err != nil { + t.Fatalf("WriteFile(%q): %v", path, err) + } + } + + got, err := ResolveLibraryPath(cliPath) + if err != nil { + t.Fatalf("ResolveLibraryPath() error: %v", err) + } + if got != libraryPath { + t.Fatalf("ResolveLibraryPath() = %q, want %q", got, libraryPath) + } +} diff --git a/go/internal/ffihost/sigonstack_darwin.go b/go/internal/ffihost/sigonstack_darwin.go new file mode 100644 index 0000000000..2e7d2a99b7 --- /dev/null +++ b/go/internal/ffihost/sigonstack_darwin.go @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT + +//go:build copilot_inprocess && darwin + +package ffihost + +import ( + "encoding/binary" + "unsafe" + + "github.com/ebitengine/purego" +) + +// Darwin `struct sigaction` layout (16 bytes, little-endian on amd64/arm64): +// +// offset 0: union __sigaction_u sa_handler/sa_sigaction (8 bytes, pointer) +// offset 8: sigset_t sa_mask (4 bytes, uint32) +// offset 12: int sa_flags (4 bytes) +const ( + darwinSigactionSize = 16 + darwinFlagsOffset = 12 + saOnStack = 0x0001 // SA_ONSTACK on Darwin + sigDfl = 0 // SIG_DFL + sigIgn = 1 // SIG_IGN + maxSignal = 31 // NSIG-1 on Darwin +) + +// rearmForeignSignalHandlers re-adds the SA_ONSTACK flag to any signal handler +// installed by the native runtime (libnode/libuv, loaded via dlopen) that +// omitted it. The Go runtime aborts with "non-Go code set up signal handler +// without SA_ONSTACK flag" when such a signal (notably SIGCHLD, signal 20 on +// Darwin) is delivered while a Go-managed child process is reaped. libuv +// installs a SIGCHLD handler without SA_ONSTACK, which poisons every subsequent +// os/exec child reaped by Go in the same process (enforced by the Go runtime on +// both macOS and Linux; the Linux variant lives in sigonstack_linux.go). +// +// We preserve each foreign handler and merely OR in SA_ONSTACK, so libuv's child +// watching keeps working while the Go runtime stays happy. Handlers left at +// SIG_DFL/SIG_IGN and Go's own handlers (which already carry SA_ONSTACK) are +// untouched. Best-effort: any failure is silently ignored, since the worst case +// is the pre-existing crash. +func rearmForeignSignalHandlers(_ uintptr) { + handle, err := purego.Dlopen("/usr/lib/libSystem.B.dylib", purego.RTLD_NOW|purego.RTLD_GLOBAL) + if err != nil || handle == 0 { + return + } + + var sigaction func(sig int32, act, oact unsafe.Pointer) int32 + if !bindSigaction(handle, &sigaction) { + return + } + + for sig := int32(1); sig <= maxSignal; sig++ { + var cur [darwinSigactionSize]byte + if sigaction(sig, nil, unsafe.Pointer(&cur[0])) != 0 { + continue + } + handler := binary.LittleEndian.Uint64(cur[0:8]) + if handler == sigDfl || handler == sigIgn { + continue + } + flags := binary.LittleEndian.Uint32(cur[darwinFlagsOffset : darwinFlagsOffset+4]) + if flags&saOnStack != 0 { + continue + } + binary.LittleEndian.PutUint32(cur[darwinFlagsOffset:darwinFlagsOffset+4], flags|saOnStack) + sigaction(sig, unsafe.Pointer(&cur[0]), nil) + } +} + +// bindSigaction resolves libc's sigaction into fn, converting the panic +// RegisterLibFunc raises on a missing symbol into a false return. +func bindSigaction(handle uintptr, fn *func(sig int32, act, oact unsafe.Pointer) int32) (ok bool) { + defer func() { + if recover() != nil { + ok = false + } + }() + purego.RegisterLibFunc(fn, handle, "sigaction") + return true +} diff --git a/go/internal/ffihost/sigonstack_linux.go b/go/internal/ffihost/sigonstack_linux.go new file mode 100644 index 0000000000..668cf67055 --- /dev/null +++ b/go/internal/ffihost/sigonstack_linux.go @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT + +//go:build copilot_inprocess && linux + +package ffihost + +import ( + "syscall" + "unsafe" +) + +const ( + linuxSaOnStack = 0x08000000 + linuxSigDfl = 0 + linuxSigIgn = 1 + linuxMaxSignal = 31 +) + +// linuxSigaction matches the kernel rt_sigaction ABI used by the Go runtime on +// Linux amd64 and arm64. +type linuxSigaction struct { + handler uintptr + flags uint64 + restorer uintptr + mask uint64 +} + +// rearmForeignSignalHandlers re-adds the SA_ONSTACK flag to any signal handler +// installed by the native runtime (libnode/libuv, loaded via dlopen) that +// omitted it. The Go runtime aborts with "non-Go code set up signal handler +// without SA_ONSTACK flag" when such a signal (notably SIGCHLD, signal 17 on +// Linux) is delivered while a Go-managed child process is reaped. libuv installs +// a SIGCHLD handler without SA_ONSTACK, which poisons every subsequent os/exec +// child reaped by Go in the same process. +// +// We preserve each foreign handler and merely OR in SA_ONSTACK, so libuv's child +// watching keeps working while the Go runtime stays happy. Handlers left at +// SIG_DFL/SIG_IGN and Go's own handlers (which already carry SA_ONSTACK) are +// untouched. Best-effort: any failure is silently ignored, since the worst case +// is the pre-existing crash. +func rearmForeignSignalHandlers(_ uintptr) { + for sig := 1; sig <= linuxMaxSignal; sig++ { + var action linuxSigaction + if !linuxGetSigaction(sig, &action) { + continue + } + if action.handler == linuxSigDfl || action.handler == linuxSigIgn { + continue + } + if action.flags&linuxSaOnStack != 0 { + continue + } + action.flags |= linuxSaOnStack + linuxSetSigaction(sig, &action) + } +} + +func linuxGetSigaction(signal int, action *linuxSigaction) bool { + _, _, errno := syscall.RawSyscall6( + syscall.SYS_RT_SIGACTION, + uintptr(signal), + 0, + uintptr(unsafe.Pointer(action)), + unsafe.Sizeof(action.mask), + 0, + 0, + ) + return errno == 0 +} + +func linuxSetSigaction(signal int, action *linuxSigaction) bool { + _, _, errno := syscall.RawSyscall6( + syscall.SYS_RT_SIGACTION, + uintptr(signal), + uintptr(unsafe.Pointer(action)), + 0, + unsafe.Sizeof(action.mask), + 0, + 0, + ) + return errno == 0 +} diff --git a/go/internal/ffihost/sigonstack_linux_test.go b/go/internal/ffihost/sigonstack_linux_test.go new file mode 100644 index 0000000000..3a382385f2 --- /dev/null +++ b/go/internal/ffihost/sigonstack_linux_test.go @@ -0,0 +1,38 @@ +//go:build copilot_inprocess && linux + +package ffihost + +import ( + "os" + "os/signal" + "syscall" + "testing" +) + +func TestRearmForeignSignalHandlersAddsOnStack(t *testing.T) { + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGUSR1) + defer signal.Stop(signals) + + var original linuxSigaction + if !linuxGetSigaction(int(syscall.SIGUSR1), &original) { + t.Fatal("failed to read SIGUSR1 action") + } + defer linuxSetSigaction(int(syscall.SIGUSR1), &original) + + withoutOnStack := original + withoutOnStack.flags &^= linuxSaOnStack + if !linuxSetSigaction(int(syscall.SIGUSR1), &withoutOnStack) { + t.Fatal("failed to clear SA_ONSTACK") + } + + rearmForeignSignalHandlers(0) + + var rearmed linuxSigaction + if !linuxGetSigaction(int(syscall.SIGUSR1), &rearmed) { + t.Fatal("failed to read rearmed SIGUSR1 action") + } + if rearmed.flags&linuxSaOnStack == 0 { + t.Fatal("SA_ONSTACK was not restored") + } +} diff --git a/go/internal/ffihost/sigonstack_other.go b/go/internal/ffihost/sigonstack_other.go new file mode 100644 index 0000000000..9da78255f6 --- /dev/null +++ b/go/internal/ffihost/sigonstack_other.go @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT + +//go:build copilot_inprocess && windows + +package ffihost + +// rearmForeignSignalHandlers is a no-op on platforms other than darwin and +// linux. Only those Unix platforms deliver the SA_ONSTACK-less SIGCHLD handler +// (installed by libuv) that the Go runtime rejects; Windows is unaffected. +func rearmForeignSignalHandlers(_ uintptr) {} diff --git a/go/rpc/zrpc.go b/go/rpc/zrpc.go index 1a1ec84ddb..30ae6ff271 100644 --- a/go/rpc/zrpc.go +++ b/go/rpc/zrpc.go @@ -1784,6 +1784,8 @@ type DiscoveredCanvas struct { ExtensionID string `json:"extensionId"` // Owning extension display name, when available ExtensionName *string `json:"extensionName,omitempty"` + // Host-local PNG path for the canvas icon, when supplied + Icon *string `json:"icon,omitempty"` // JSON Schema for canvas open input InputSchema any `json:"inputSchema,omitempty"` } @@ -2461,6 +2463,26 @@ type HistoryTruncateResult struct { EventsRemoved int64 `json:"eventsRemoved"` } +// Runtime-owned wire payload for a server-to-client hook callback invocation. +// Experimental: HookInvokeRequest is part of an experimental API and may change or be +// removed. +// Internal: HookInvokeRequest is an internal SDK API and is not part of the public surface. +type HookInvokeRequest struct { + // Internal: HookType is part of the SDK's internal API surface and is not intended for + // external use. + HookType HookType `json:"hookType"` + Input any `json:"input"` + SessionID string `json:"sessionId"` +} + +// Optional output returned by an SDK callback hook. +// Experimental: HookInvokeResponse is part of an experimental API and may change or be +// removed. +// Internal: HookInvokeResponse is an internal SDK API and is not part of the public surface. +type HookInvokeResponse struct { + Output any `json:"output,omitempty"` +} + // Installed plugin record from global state, with marketplace, version, install time, // enabled state, cache path, and source. // Experimental: InstalledPlugin is part of an experimental API and may change or be removed. @@ -3595,15 +3617,172 @@ type MCPRemoveGitHubResult struct { Removed bool `json:"removed"` } -// Server name and opaque configuration for an individual MCP server restart. +// An MCP resource descriptor (spec `Resource`): URI, name, and optional title, description, +// MIME type, size, icons, annotations, and metadata. Server-provided fields outside the +// standard descriptor shape are exposed under `additionalProperties`. +// Experimental: MCPResource is part of an experimental API and may change or be removed. +type MCPResource struct { + // Server-provided non-standard descriptor fields preserved from the MCP response + AdditionalProperties map[string]any `json:"additionalProperties,omitzero"` + // Model/client annotations associated with this resource + Annotations *MCPResourceAnnotations `json:"annotations,omitempty"` + // Optional description of what this resource represents + Description *string `json:"description,omitempty"` + // Icons associated with this resource + Icons []MCPResourceIcon `json:"icons,omitzero"` + // Resource-level metadata + Meta map[string]any `json:"_meta,omitzero"` + // MIME type of the resource, if known + MIMEType *string `json:"mimeType,omitempty"` + // The programmatic name of the resource + Name string `json:"name"` + // Resource size in bytes, when known + Size *int64 `json:"size,omitempty"` + // Optional human-readable display title + Title *string `json:"title,omitempty"` + // The resource URI (e.g. ui://... or file:///...) + URI string `json:"uri"` +} + +// Standard MCP resource annotations plus preserved non-standard annotation fields. +// Experimental: MCPResourceAnnotations is part of an experimental API and may change or be +// removed. +type MCPResourceAnnotations struct { + // Server-provided non-standard annotation fields preserved from the MCP response + AdditionalProperties map[string]any `json:"additionalProperties,omitzero"` + // Intended audience roles for this resource + Audience []string `json:"audience,omitzero"` + // Last-modified timestamp hint + LastModified *string `json:"lastModified,omitempty"` + // Priority hint for model/client use + Priority *float64 `json:"priority,omitempty"` +} + +// MCP resource content with URI, optional MIME type, text or base64 blob, and resource +// metadata. +// Experimental: MCPResourceContent is part of an experimental API and may change or be +// removed. +type MCPResourceContent struct { + // Base64-encoded binary content + Blob *string `json:"blob,omitempty"` + // Resource-level metadata (CSP, permissions, etc.) + Meta map[string]any `json:"_meta,omitzero"` + // MIME type of the content + MIMEType *string `json:"mimeType,omitempty"` + // Text content (e.g. HTML) + Text *string `json:"text,omitempty"` + // The resource URI + URI string `json:"uri"` +} + +// A resource icon descriptor plus preserved non-standard icon fields. +// Experimental: MCPResourceIcon is part of an experimental API and may change or be removed. +type MCPResourceIcon struct { + // Server-provided non-standard icon fields preserved from the MCP response + AdditionalProperties map[string]any `json:"additionalProperties,omitzero"` + // Icon MIME type, when known + MIMEType *string `json:"mimeType,omitempty"` + // Icon sizes hint + Sizes *string `json:"sizes,omitempty"` + // Icon URI + Src string `json:"src"` + // Theme hint for this icon + Theme *string `json:"theme,omitempty"` +} + +// MCP server whose resources to enumerate. +// Experimental: MCPResourcesListRequest is part of an experimental API and may change or be +// removed. +type MCPResourcesListRequest struct { + // Opaque MCP pagination cursor from a prior `nextCursor` value + Cursor *string `json:"cursor,omitempty"` + // Name of the MCP server whose resources to enumerate + ServerName string `json:"serverName"` +} + +// One page of resources advertised by the named MCP server. +// Experimental: MCPResourcesListResult is part of an experimental API and may change or be +// removed. +type MCPResourcesListResult struct { + // Opaque cursor for the next page, if the server has more resources + NextCursor *string `json:"nextCursor,omitempty"` + // Resources advertised by the server (proxied MCP `resources/list`) + Resources []MCPResource `json:"resources"` +} + +// MCP server whose resource templates to enumerate. +// Experimental: MCPResourcesListTemplatesRequest is part of an experimental API and may +// change or be removed. +type MCPResourcesListTemplatesRequest struct { + // Opaque MCP pagination cursor from a prior `nextCursor` value + Cursor *string `json:"cursor,omitempty"` + // Name of the MCP server whose resource templates to enumerate + ServerName string `json:"serverName"` +} + +// One page of resource templates advertised by the named MCP server. +// Experimental: MCPResourcesListTemplatesResult is part of an experimental API and may +// change or be removed. +type MCPResourcesListTemplatesResult struct { + // Opaque cursor for the next page, if the server has more resource templates + NextCursor *string `json:"nextCursor,omitempty"` + // Resource templates advertised by the server (proxied MCP `resources/templates/list`) + ResourceTemplates []MCPResourceTemplate `json:"resourceTemplates"` +} + +// MCP server and resource URI to fetch. +// Experimental: MCPResourcesReadRequest is part of an experimental API and may change or be +// removed. +type MCPResourcesReadRequest struct { + // Name of the MCP server hosting the resource + ServerName string `json:"serverName"` + // Resource URI + URI string `json:"uri"` +} + +// Resource contents returned by the MCP server. +// Experimental: MCPResourcesReadResult is part of an experimental API and may change or be +// removed. +type MCPResourcesReadResult struct { + // Resource contents returned by the server + Contents []MCPResourceContent `json:"contents"` +} + +// An MCP resource template descriptor (spec `ResourceTemplate`): an RFC 6570 URI template, +// name, and optional title, description, MIME type, icons, annotations, and metadata. +// Server-provided fields outside the standard descriptor shape are exposed under +// `additionalProperties`. +// Experimental: MCPResourceTemplate is part of an experimental API and may change or be +// removed. +type MCPResourceTemplate struct { + // Server-provided non-standard descriptor fields preserved from the MCP response + AdditionalProperties map[string]any `json:"additionalProperties,omitzero"` + // Model/client annotations associated with this template + Annotations *MCPResourceAnnotations `json:"annotations,omitempty"` + // Optional description of what this template is for + Description *string `json:"description,omitempty"` + // Icons associated with resources matching this template + Icons []MCPResourceIcon `json:"icons,omitzero"` + // Resource-template-level metadata + Meta map[string]any `json:"_meta,omitzero"` + // MIME type for resources matching this template, if uniform + MIMEType *string `json:"mimeType,omitempty"` + // The programmatic name of the resource template + Name string `json:"name"` + // Optional human-readable display title + Title *string `json:"title,omitempty"` + // An RFC 6570 URI template for constructing resource URIs + URITemplate string `json:"uriTemplate"` +} + +// Server name and optional replacement configuration for an individual MCP server restart. +// Omit `config` for a config-free restart-by-name of an already-configured server. // Experimental: MCPRestartServerRequest is part of an experimental API and may change or be // removed. type MCPRestartServerRequest struct { - // Opaque server configuration (MCPServerConfig). Marked internal: an in-process runtime - // shape supplied only by in-process CLI callers. - // Internal: Config is part of the SDK's internal API surface and is not intended for - // external use. - Config any `json:"config"` + // Replacement MCP server configuration (stdio process or remote HTTP/SSE). Omit to restart + // the server with its already-registered configuration (config-free restart-by-name). + Config MCPServerConfig `json:"config,omitempty"` // Name of the MCP server to restart ServerName string `json:"serverName"` } @@ -3791,15 +3970,12 @@ type MCPSetEnvValueModeResult struct { Mode MCPSetEnvValueModeDetails `json:"mode"` } -// Server name and opaque configuration for an individual MCP server start. +// Server name and configuration for an individual MCP server start. // Experimental: MCPStartServerRequest is part of an experimental API and may change or be // removed. type MCPStartServerRequest struct { - // Opaque server configuration (MCPServerConfig). Marked internal: an in-process runtime - // shape supplied only by in-process CLI callers. - // Internal: Config is part of the SDK's internal API surface and is not intended for - // external use. - Config any `json:"config"` + // MCP server configuration (stdio process or remote HTTP/SSE) + Config MCPServerConfig `json:"config"` // Name of the MCP server to start ServerName string `json:"serverName"` } @@ -3822,13 +3998,27 @@ type MCPStopServerRequest struct { ServerName string `json:"serverName"` } -// MCP tool metadata with tool name and optional description. +// MCP tool metadata with tool name, optional description, and normalized MCP Apps discovery +// metadata. // Experimental: MCPTools is part of an experimental API and may change or be removed. type MCPTools struct { // Tool description, when provided. Description *string `json:"description,omitempty"` // Tool name. Name string `json:"name"` + // Normalized MCP Apps discovery metadata. An empty object indicates that a valid `_meta.ui` + // block was present without recognized fields. + UI *MCPToolUI `json:"ui,omitempty"` +} + +// Normalized MCP Apps discovery metadata from a tool's `_meta.ui` block. +// Experimental: MCPToolUI is part of an experimental API and may change or be removed. +type MCPToolUI struct { + // URI of the tool's MCP App resource, typically a `ui://` resource identifier. Use + // `session.mcp.resources.read` to fetch its HTML and resource metadata. + ResourceURI *string `json:"resourceUri,omitempty"` + // Tool visibility advertised by the server. When absent, MCP Apps defaults apply. + Visibility []MCPToolUIVisibility `json:"visibility,omitzero"` } // Server name identifying the external client to remove. @@ -3952,7 +4142,10 @@ type MetadataRecordContextChangeRequest struct { type MetadataRecordContextChangeResult struct { } -// Absolute path to set as the session's new working directory. +// Absolute path to set as the session's new working directory. For local sessions the path +// must be absolute and exist on disk: it is validated before any session state changes, and +// a failing validation rejects the call with nothing mutated, persisted, or emitted. Remote +// sessions record the path as-is. // Experimental: MetadataSetWorkingDirectoryRequest is part of an experimental API and may // change or be removed. type MetadataSetWorkingDirectoryRequest struct { @@ -3963,9 +4156,13 @@ type MetadataSetWorkingDirectoryRequest struct { } // Update the session's working directory. Used by the host when the user explicitly changes -// cwd (e.g., the `/cd` slash command). The host is responsible for `process.chdir` and any -// related side-effects (file index, etc.); this method only updates the session's own -// recorded path. +// cwd (e.g., the `/cd` slash command). The host is responsible for any related side-effects +// (file index, etc.); it does NOT change the process working directory (a session's cwd is +// per-session, not process-global). For local sessions the runtime validates the target +// first (an absolute path that exists on disk) and re-bases the permission primary +// directory; a rejected validation fails the call before anything is mutated, persisted, or +// emitted. Location-scoped permission rules are then re-keyed to the new directory +// (best-effort). Remote sessions only record the path. // Experimental: MetadataSetWorkingDirectoryResult is part of an experimental API and may // change or be removed. type MetadataSetWorkingDirectoryResult struct { @@ -4035,10 +4232,30 @@ type ModelBilling struct { DiscountPercent *int32 `json:"discountPercent,omitempty"` // Billing cost multiplier relative to the base rate Multiplier *float64 `json:"multiplier,omitempty"` + // Active server-driven promotion for this model, if any. Present when the model is being + // promoted with a time-boxed discount. + Promo *ModelBillingPromo `json:"promo,omitempty"` // Token-level pricing information for this model TokenPrices *ModelBillingTokenPrices `json:"tokenPrices,omitempty"` } +// Active server-driven promotion for a model, including its discount and expiry. +// Experimental: ModelBillingPromo is part of an experimental API and may change or be +// removed. +type ModelBillingPromo struct { + // Percentage discount (0-100) applied while the promotion is active. May be fractional. + DiscountPercent *float64 `json:"discountPercent,omitempty"` + // UTC ISO 8601 timestamp marking when the promotion ends. Always present: the API only + // surfaces a promo whose expiry parses and is in the future. Consumers should treat a past + // value as expired. + EndsAt string `json:"endsAt"` + // Stable identifier for the promotion campaign. + ID *string `json:"id,omitempty"` + // Human-readable promotion message. Does not include the expiry timestamp; consumers may + // format endsAt and append it. + Message *string `json:"message,omitempty"` +} + // Token-level pricing information for this model // Experimental: ModelBillingTokenPrices is part of an experimental API and may change or be // removed. @@ -4357,6 +4574,8 @@ type OpenCanvasInstance struct { ExtensionID string `json:"extensionId"` // Owning extension display name, when available ExtensionName *string `json:"extensionName,omitempty"` + // Host-local PNG path for the canvas icon, when supplied + Icon *string `json:"icon,omitempty"` // Input supplied when the instance was opened Input any `json:"input,omitempty"` // Stable caller-supplied canvas instance identifier @@ -5691,7 +5910,7 @@ type PluginsInstallRequest struct { WorkingDirectory *string `json:"workingDirectory,omitempty"` } -// Marketplace source to register. +// Marketplace source and optional working directory for relative-path resolution. // Experimental: PluginsMarketplacesAddRequest is part of an experimental API and may change // or be removed. type PluginsMarketplacesAddRequest struct { @@ -5700,6 +5919,9 @@ type PluginsMarketplacesAddRequest struct { // (user@host:path), or a local path. The marketplace's own name (from its manifest) is used // as the registration key. Source string `json:"source"` + // Working directory used to resolve relative local paths in `source`. Defaults to the + // server's current working directory. + WorkingDirectory *string `json:"workingDirectory,omitempty"` } // Name of the marketplace whose plugin catalog to fetch. @@ -6428,16 +6650,18 @@ type RegisterEventInterestParams struct { // The event type the consumer wants the runtime to treat as 'observed' for // behavior-switching gating. Some runtime code paths inspect whether any consumer is // interested in a specific event type and choose a different implementation accordingly - // (e.g. `mcp.oauth_required`: when interest is registered the runtime delegates OAuth token - // acquisition to the consumer; when no interest is registered OAuth-required servers become - // needs-auth). SDK clients that long-poll events do NOT automatically appear as listeners - // to these gating checks — they must explicitly call `registerInterest` for each event type - // they want the runtime to count as having a consumer. Multiple registrations for the same - // event type from the same or different consumers are tracked independently and must each - // be released. See: `mcp.oauth_required`, `sampling.requested`, - // `auto_mode_switch.requested`, `session_limits_exhausted.requested`, - // `user_input.requested`, `elicitation.requested`, `command.queued`, - // `exit_plan_mode.requested`. + // (e.g. `mcp.oauth_required`: when interest is registered the runtime delegates interactive + // OAuth token acquisition to the consumer via `mcp.oauth_required` events; when no interest + // is registered the runtime still attempts non-interactive reconnect from cached or + // refreshable tokens, and only marks the server `needs-auth` if usable credentials are + // unavailable — it does not open a browser or start interactive OAuth without a consumer). + // SDK clients that long-poll events do NOT automatically appear as listeners to these + // gating checks — they must explicitly call `registerInterest` for each event type they + // want the runtime to count as having a consumer. Multiple registrations for the same event + // type from the same or different consumers are tracked independently and must each be + // released. See: `mcp.oauth_required`, `sampling.requested`, `auto_mode_switch.requested`, + // `session_limits_exhausted.requested`, `user_input.requested`, `elicitation.requested`, + // `command.queued`, `exit_plan_mode.requested`. EventType string `json:"eventType"` } @@ -6871,6 +7095,73 @@ type SendAttachmentsToMessageParams struct { InstanceID *string `json:"instanceId,omitempty"` } +// A single user message to append to the session as part of a `session.sendMessages` turn +// Experimental: SendMessageItem is part of an experimental API and may change or be removed. +type SendMessageItem struct { + // Optional attachments (files, directories, selections, blobs, GitHub references) to + // include with this message + Attachments []Attachment `json:"attachments,omitzero"` + // If false, this message will not trigger a Premium Request Unit charge. User messages + // default to billable. + // Internal: Billable is part of the SDK's internal API surface and is not intended for + // external use. + Billable *bool `json:"billable,omitempty"` + // If provided, this is shown in the timeline instead of `prompt` + DisplayPrompt *string `json:"displayPrompt,omitempty"` + // The user message text + Prompt string `json:"prompt"` + // If set, the request will fail if the named tool is not available when this message is + // among the user messages at the start of the current exchange + RequiredTool *string `json:"requiredTool,omitempty"` + // Optional provenance tag copied to the resulting user.message event. Must match one of + // three forms: the literal `system`, `command-` for messages originating from a + // command (e.g. slash command, Mission Control command), or `schedule-` for + // messages originating from a scheduled job. + // Internal: Source is part of the SDK's internal API surface and is not intended for + // external use. + Source *string `json:"source,omitempty"` +} + +// Parameters for sending zero or more user messages to the session in a single turn. +// Remote-backed (Mission Control) sessions do not support this method and will return an +// error. +// Experimental: SendMessagesRequest is part of an experimental API and may change or be +// removed. +type SendMessagesRequest struct { + // The UI mode the agent was in when these messages were sent. Defaults to the session's + // current mode. + AgentMode *SendAgentMode `json:"agentMode,omitempty"` + // The user messages to append to the conversation, in order. May be empty, in which case a + // single turn runs over the existing history with no new user message. + Messages []SendMessageItem `json:"messages"` + // How to deliver the messages. `enqueue` (default) appends to the message queue. + // `immediate` interjects during an in-progress turn. + Mode *SendMode `json:"mode,omitempty"` + // If true, adds the messages to the front of the queue instead of the end + Prepend *bool `json:"prepend,omitempty"` + // Custom HTTP headers to include in outbound model requests for this turn. Merged with + // session-level provider headers; per-turn headers augment and overwrite session-level + // headers with the same key. + RequestHeaders map[string]string `json:"requestHeaders,omitzero"` + // W3C Trace Context traceparent header for distributed tracing of this agent turn + Traceparent *string `json:"traceparent,omitempty"` + // W3C Trace Context tracestate header for distributed tracing + Tracestate *string `json:"tracestate,omitempty"` + // If true, await completion of the agentic loop for this turn before returning. Defaults to + // false (fire-and-forget). When true, the result still contains the same `messageIds`; the + // caller can rely on the agent having processed the messages before the call resolves. + Wait *bool `json:"wait,omitempty"` +} + +// Result of sending zero or more user messages +// Experimental: SendMessagesResult is part of an experimental API and may change or be +// removed. +type SendMessagesResult struct { + // Unique identifiers assigned to the messages, one per provided message in order. Empty + // when no messages were provided. + MessageIDs []string `json:"messageIds"` +} + // Parameters for sending a user message to the session // Experimental: SendRequest is part of an experimental API and may change or be removed. type SendRequest struct { @@ -7701,10 +7992,21 @@ type SessionModelList struct { // (CAPI) models and any registry BYOK models; a BYOK model appears under its // provider-qualified selection id (`provider/id`). List []any `json:"list"` + // Cost categories for the full CAPI catalog, including picker-disabled models that Auto may + // select. Metadata only; entries absent from `list` are not manually selectable. + ModelPriceCategories []SessionModelPriceCategory `json:"modelPriceCategories,omitzero"` // Per-quota snapshots returned alongside the model list, keyed by quota type. QuotaSnapshots map[string]any `json:"quotaSnapshots,omitzero"` } +// Cost-category metadata for a CAPI model. +// Experimental: SessionModelPriceCategory is part of an experimental API and may change or +// be removed. +type SessionModelPriceCategory struct { + ID string `json:"id"` + PriceCategory ModelPickerPriceCategory `json:"priceCategory"` +} + // Experimental: SessionModeSetResult is part of an experimental API and may change or be // removed. type SessionModeSetResult struct { @@ -7790,6 +8092,10 @@ type SessionOpenOptions struct { ExpAssignments any `json:"expAssignments,omitempty"` // Feature-flag values resolved by the host. FeatureFlags map[string]bool `json:"featureFlags,omitzero"` + // Built-in subagent names to include in this session. When specified, only these built-ins + // are available, subject to runtime availability and exclusions. Custom agents with the + // same name remain available. + IncludedBuiltinAgents []string `json:"includedBuiltinAgents,omitzero"` // Installed plugins visible to the session. InstalledPlugins []InstalledPlugin `json:"installedPlugins,omitzero"` // Stable integration identifier for analytics. @@ -8725,6 +9031,10 @@ type SessionUpdateOptionsParams struct { ExcludedTools []string `json:"excludedTools,omitzero"` // Map of feature-flag IDs to their boolean enabled state. FeatureFlags map[string]bool `json:"featureFlags,omitzero"` + // Built-in subagent names to include in this session. When specified, only these built-ins + // are available, subject to runtime availability and exclusions. Custom agents with the + // same name remain available. Set to null to remove the allowlist restriction. + IncludedBuiltinAgents []string `json:"includedBuiltinAgents,omitzero"` // Full set of installed plugins for the session. Replaces the existing list; the runtime // invalidates the skills cache only when the list materially changes. InstalledPlugins []SessionInstalledPlugin `json:"installedPlugins,omitzero"` @@ -8796,6 +9106,8 @@ type SessionUpdateOptionsParams struct { // Experimental: SessionUpdateOptionsResult is part of an experimental API and may change or // be removed. type SessionUpdateOptionsResult struct { + // Number of hooks loaded from installed plugins, returned when installedPlugins is updated + PluginHookCount *int64 `json:"pluginHookCount,omitempty"` // Whether the operation succeeded Success bool `json:"success"` } @@ -9113,7 +9425,7 @@ func (r RawSlashCommandInvocationResultData) Kind() SlashCommandInvocationResult } // Slash-command invocation result that submits an agent prompt, with display prompt, -// optional mode, and settings-change flag. +// optional mode, optional user-facing notice, and settings-change flag. // Experimental: SlashCommandAgentPromptResult is part of an experimental API and may change // or be removed. type SlashCommandAgentPromptResult struct { @@ -9121,6 +9433,8 @@ type SlashCommandAgentPromptResult struct { DisplayPrompt string `json:"displayPrompt"` // Optional target session mode for the agent prompt Mode *SessionMode `json:"mode,omitempty"` + // Optional user-facing notice to show before the prompt is submitted + Notice *string `json:"notice,omitempty"` // Prompt to submit to the agent Prompt string `json:"prompt"` // True when the invocation mutated user runtime settings; consumers caching settings should @@ -11089,6 +11403,45 @@ const ( HMACAuthInfoHostHTTPSGitHubCom HMACAuthInfoHost = "https://github.com" ) +// Hook event name dispatched through the SDK callback transport. +// Experimental: HookType is part of an experimental API and may change or be removed. +type HookType string + +const ( + // Runs when the agent stops. + HookTypeAgentStop HookType = "agentStop" + // Runs when the agent encounters an error. + HookTypeErrorOccurred HookType = "errorOccurred" + // Runs when the agent emits a notification. + HookTypeNotification HookType = "notification" + // Runs when the agent requests permission. + HookTypePermissionRequest HookType = "permissionRequest" + // Runs after an agent result is produced. + HookTypePostResult HookType = "postResult" + // Runs after a tool completes successfully. + HookTypePostToolUse HookType = "postToolUse" + // Runs after a tool fails. + HookTypePostToolUseFailure HookType = "postToolUseFailure" + // Runs before conversation context is compacted. + HookTypePreCompact HookType = "preCompact" + // Runs before an MCP tool is invoked. + HookTypePreMCPToolCall HookType = "preMcpToolCall" + // Runs before a pull request description is generated. + HookTypePrePRDescription HookType = "prePRDescription" + // Runs before a tool is invoked. + HookTypePreToolUse HookType = "preToolUse" + // Runs when a session ends. + HookTypeSessionEnd HookType = "sessionEnd" + // Runs when a session starts. + HookTypeSessionStart HookType = "sessionStart" + // Runs when a subagent starts. + HookTypeSubagentStart HookType = "subagentStart" + // Runs when a subagent stops. + HookTypeSubagentStop HookType = "subagentStop" + // Runs after the user submits a prompt. + HookTypeUserPromptSubmitted HookType = "userPromptSubmitted" +) + // Constant value. Always "github". type InstalledPluginSourceGitHubSource string @@ -11436,6 +11789,18 @@ const ( MCPSetEnvValueModeDetailsIndirect MCPSetEnvValueModeDetails = "indirect" ) +// Consumer allowed to call an MCP tool. +// Experimental: MCPToolUIVisibility is part of an experimental API and may change or be +// removed. +type MCPToolUIVisibility string + +const ( + // An MCP App view may call the tool. + MCPToolUIVisibilityApp MCPToolUIVisibility = "app" + // The model may call the tool. + MCPToolUIVisibilityModel MCPToolUIVisibility = "model" +) + // The current agent mode for this session (e.g., 'interactive', 'plan', 'autopilot') // Experimental: MetadataSnapshotCurrentMode is part of an experimental API and may change // or be removed. @@ -12820,6 +13185,29 @@ func (a *ServerAgentsAPI) GetDiscoveryPaths(ctx context.Context, params *AgentsG return &result, nil } +// Experimental: ServerCommandsAPI contains experimental APIs that may change or be removed. +type ServerCommandsAPI serverAPI + +// Lists the well-known built-in slash commands that work as the first message in a new +// session (e.g. /plan, /env), without requiring an active session. Commands that depend on +// session state, authentication, or a synced session are omitted. +// +// RPC method: commands.list. +// +// Returns: Slash commands available in the session, after applying any include/exclude +// filters. +func (a *ServerCommandsAPI) List(ctx context.Context) (*CommandList, error) { + raw, err := a.client.Request(ctx, "commands.list", nil) + if err != nil { + return nil, err + } + var result CommandList + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + // Experimental: ServerInstructionsAPI contains experimental APIs that may change or be // removed. type ServerInstructionsAPI serverAPI @@ -13237,7 +13625,8 @@ type ServerPluginsMarketplacesAPI serverAPI // // RPC method: plugins.marketplaces.add. // -// Parameters: Marketplace source to register. +// Parameters: Marketplace source and optional working directory for relative-path +// resolution. // // Returns: Result of registering a new marketplace. func (a *ServerPluginsMarketplacesAPI) Add(ctx context.Context, params *PluginsMarketplacesAddRequest) (*MarketplaceAddResult, error) { @@ -14056,6 +14445,7 @@ type ServerRPC struct { Account *ServerAccountAPI AgentRegistry *ServerAgentRegistryAPI Agents *ServerAgentsAPI + Commands *ServerCommandsAPI Instructions *ServerInstructionsAPI LlmInference *ServerLlmInferenceAPI MCP *ServerMCPAPI @@ -14097,6 +14487,7 @@ func NewServerRPC(client *jsonrpc2.Client) *ServerRPC { r.Account = (*ServerAccountAPI)(&r.common) r.AgentRegistry = (*ServerAgentRegistryAPI)(&r.common) r.Agents = (*ServerAgentsAPI)(&r.common) + r.Commands = (*ServerCommandsAPI)(&r.common) r.Instructions = (*ServerInstructionsAPI)(&r.common) r.LlmInference = (*ServerLlmInferenceAPI)(&r.common) r.MCP = (*ServerMCPAPI)(&r.common) @@ -15371,7 +15762,9 @@ func (a *MCPAPI) List(ctx context.Context) (*MCPServerList, error) { return &result, nil } -// ListTools lists the tools exposed by a connected MCP server on this session's host. +// ListTools lists the tools exposed by a connected MCP server on this session's host. This +// performs a live `tools/list` request. Tool UI metadata is returned independently of +// whether MCP Apps rendering is enabled for the session. // // RPC method: session.mcp.listTools. // @@ -15430,6 +15823,35 @@ func (a *MCPAPI) RemoveGitHub(ctx context.Context) (*MCPRemoveGitHubResult, erro return &result, nil } +// RestartServer restarts an individual MCP server on the live session (stops then starts). +// Omit `config` for a config-free restart-by-name of an already-configured server; supply +// `config` to restart with a replacement configuration. Session-scoped and ephemeral: does +// NOT modify persistent user configuration (`mcp.config.*`). +// +// RPC method: session.mcp.restartServer. +// +// Parameters: Server name and optional replacement configuration for an individual MCP +// server restart. Omit `config` for a config-free restart-by-name of an already-configured +// server. +func (a *MCPAPI) RestartServer(ctx context.Context, params *MCPRestartServerRequest) (*SessionMCPRestartServerResult, error) { + req := map[string]any{"sessionId": a.sessionID} + if params != nil { + if params.Config != nil { + req["config"] = params.Config + } + req["serverName"] = params.ServerName + } + raw, err := a.client.Request(ctx, "session.mcp.restartServer", req) + if err != nil { + return nil, err + } + var result SessionMCPRestartServerResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + // SetEnvValueMode sets how environment-variable values supplied to MCP servers are resolved // (direct or indirect). // @@ -15455,6 +15877,33 @@ func (a *MCPAPI) SetEnvValueMode(ctx context.Context, params *MCPSetEnvValueMode return &result, nil } +// StartServer starts an individual MCP server on the live session from a caller-supplied +// config. Session-scoped and ephemeral: the server is added to this session's running set +// only and is reaped when the session ends. Does NOT modify persistent user configuration +// (`mcp.config.*`), so it does not affect future sessions. The server surfaces through +// `session.mcp.list` and the `session.mcp_servers_loaded` / +// `session.mcp_server_status_changed` events like any other server. +// +// RPC method: session.mcp.startServer. +// +// Parameters: Server name and configuration for an individual MCP server start. +func (a *MCPAPI) StartServer(ctx context.Context, params *MCPStartServerRequest) (*SessionMCPStartServerResult, error) { + req := map[string]any{"sessionId": a.sessionID} + if params != nil { + req["config"] = params.Config + req["serverName"] = params.ServerName + } + raw, err := a.client.Request(ctx, "session.mcp.startServer", req) + if err != nil { + return nil, err + } + var result SessionMCPStartServerResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + // StopServer stops an individual MCP server on the session's host. // // RPC method: session.mcp.stopServer. @@ -15744,6 +16193,93 @@ func (s *MCPAPI) Oauth() *MCPOauthAPI { return (*MCPOauthAPI)(s) } +// Experimental: MCPResourcesAPI contains experimental APIs that may change or be removed. +type MCPResourcesAPI sessionAPI + +// List enumerate one page of resources a connected MCP server exposes (proxies MCP +// `resources/list`). Pass `cursor` to continue from a prior result's `nextCursor`. +// +// RPC method: session.mcp.resources.list. +// +// Parameters: MCP server whose resources to enumerate. +// +// Returns: One page of resources advertised by the named MCP server. +func (a *MCPResourcesAPI) List(ctx context.Context, params *MCPResourcesListRequest) (*MCPResourcesListResult, error) { + req := map[string]any{"sessionId": a.sessionID} + if params != nil { + if params.Cursor != nil { + req["cursor"] = *params.Cursor + } + req["serverName"] = params.ServerName + } + raw, err := a.client.Request(ctx, "session.mcp.resources.list", req) + if err != nil { + return nil, err + } + var result MCPResourcesListResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +// ListTemplates enumerate one page of resource templates a connected MCP server exposes +// (proxies MCP `resources/templates/list`). Pass `cursor` to continue from a prior result's +// `nextCursor`. +// +// RPC method: session.mcp.resources.listTemplates. +// +// Parameters: MCP server whose resource templates to enumerate. +// +// Returns: One page of resource templates advertised by the named MCP server. +func (a *MCPResourcesAPI) ListTemplates(ctx context.Context, params *MCPResourcesListTemplatesRequest) (*MCPResourcesListTemplatesResult, error) { + req := map[string]any{"sessionId": a.sessionID} + if params != nil { + if params.Cursor != nil { + req["cursor"] = *params.Cursor + } + req["serverName"] = params.ServerName + } + raw, err := a.client.Request(ctx, "session.mcp.resources.listTemplates", req) + if err != nil { + return nil, err + } + var result MCPResourcesListTemplatesResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +// Read fetch an MCP resource from a connected server by URI (proxies MCP `resources/read`). +// +// RPC method: session.mcp.resources.read. +// +// Parameters: MCP server and resource URI to fetch. +// +// Returns: Resource contents returned by the MCP server. +func (a *MCPResourcesAPI) Read(ctx context.Context, params *MCPResourcesReadRequest) (*MCPResourcesReadResult, error) { + req := map[string]any{"sessionId": a.sessionID} + if params != nil { + req["serverName"] = params.ServerName + req["uri"] = params.URI + } + raw, err := a.client.Request(ctx, "session.mcp.resources.read", req) + if err != nil { + return nil, err + } + var result MCPResourcesReadResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +// Experimental: Resources returns experimental APIs that may change or be removed. +func (s *MCPAPI) Resources() *MCPResourcesAPI { + return (*MCPResourcesAPI)(s) +} + // Experimental: MetadataAPI contains experimental APIs that may change or be removed. type MetadataAPI sessionAPI @@ -15920,16 +16456,26 @@ func (a *MetadataAPI) RecordContextChange(ctx context.Context, params *MetadataR return &result, nil } -// SetWorkingDirectory updates the session's recorded working directory. +// SetWorkingDirectory updates the session's working directory. For local sessions the +// target is validated first (an absolute path that exists on disk) and the permission +// primary directory is re-based; a rejected validation fails the call before any session +// state changes. // // RPC method: session.metadata.setWorkingDirectory. // -// Parameters: Absolute path to set as the session's new working directory. +// Parameters: Absolute path to set as the session's new working directory. For local +// sessions the path must be absolute and exist on disk: it is validated before any session +// state changes, and a failing validation rejects the call with nothing mutated, persisted, +// or emitted. Remote sessions record the path as-is. // // Returns: Update the session's working directory. Used by the host when the user -// explicitly changes cwd (e.g., the `/cd` slash command). The host is responsible for -// `process.chdir` and any related side-effects (file index, etc.); this method only updates -// the session's own recorded path. +// explicitly changes cwd (e.g., the `/cd` slash command). The host is responsible for any +// related side-effects (file index, etc.); it does NOT change the process working directory +// (a session's cwd is per-session, not process-global). For local sessions the runtime +// validates the target first (an absolute path that exists on disk) and re-bases the +// permission primary directory; a rejected validation fails the call before anything is +// mutated, persisted, or emitted. Location-scoped permission rules are then re-keyed to the +// new directory (best-effort). Remote sessions only record the path. func (a *MetadataAPI) SetWorkingDirectory(ctx context.Context, params *MetadataSetWorkingDirectoryRequest) (*MetadataSetWorkingDirectoryResult, error) { req := map[string]any{"sessionId": a.sessionID} if params != nil { @@ -16287,6 +16833,9 @@ func (a *OptionsAPI) Update(ctx context.Context, params *SessionUpdateOptionsPar if params.FeatureFlags != nil { req["featureFlags"] = params.FeatureFlags } + if params.IncludedBuiltinAgents != nil { + req["includedBuiltinAgents"] = params.IncludedBuiltinAgents + } if params.InstalledPlugins != nil { req["installedPlugins"] = params.InstalledPlugins } @@ -18657,6 +19206,58 @@ func (a *SessionRPC) Send(ctx context.Context, params *SendRequest) (*SendResult return &result, nil } +// SendMessages sends zero or more user messages to the session in a single turn and returns +// their message IDs. All provided messages are appended to the conversation in order, then +// exactly one agent turn runs over the resulting history. When the list is empty, one turn +// runs over the existing history with no new user message. Remote-backed (Mission Control) +// sessions do not support this method and will return an error. +// +// RPC method: session.sendMessages. +// +// Parameters: Parameters for sending zero or more user messages to the session in a single +// turn. Remote-backed (Mission Control) sessions do not support this method and will return +// an error. +// +// Returns: Result of sending zero or more user messages +// Experimental: SendMessages is an experimental API and may change or be removed in future +// versions. +func (a *SessionRPC) SendMessages(ctx context.Context, params *SendMessagesRequest) (*SendMessagesResult, error) { + req := map[string]any{"sessionId": a.common.sessionID} + if params != nil { + if params.AgentMode != nil { + req["agentMode"] = *params.AgentMode + } + req["messages"] = params.Messages + if params.Mode != nil { + req["mode"] = *params.Mode + } + if params.Prepend != nil { + req["prepend"] = *params.Prepend + } + if params.RequestHeaders != nil { + req["requestHeaders"] = params.RequestHeaders + } + if params.Traceparent != nil { + req["traceparent"] = *params.Traceparent + } + if params.Tracestate != nil { + req["tracestate"] = *params.Tracestate + } + if params.Wait != nil { + req["wait"] = *params.Wait + } + } + raw, err := a.common.client.Request(ctx, "session.sendMessages", req) + if err != nil { + return nil, err + } + var result SendMessagesResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + // Shutdown shuts down the session and persists its final state. Awaits any deferred // sessionEnd hooks before resolving so user-supplied hook scripts complete before the // runtime tears down. @@ -18836,54 +19437,6 @@ func (a *InternalMCPAPI) ReloadWithConfig(ctx context.Context, params *MCPReload return &result, nil } -// RestartServer restarts an individual MCP server on the session's host (stops then starts). -// -// RPC method: session.mcp.restartServer. -// -// Parameters: Server name and opaque configuration for an individual MCP server restart. -// Internal: RestartServer is part of the SDK's internal handshake/plumbing; external -// callers should not use it. -func (a *InternalMCPAPI) RestartServer(ctx context.Context, params *MCPRestartServerRequest) (*SessionMCPRestartServerResult, error) { - req := map[string]any{"sessionId": a.sessionID} - if params != nil { - req["config"] = params.Config - req["serverName"] = params.ServerName - } - raw, err := a.client.Request(ctx, "session.mcp.restartServer", req) - if err != nil { - return nil, err - } - var result SessionMCPRestartServerResult - if err := json.Unmarshal(raw, &result); err != nil { - return nil, err - } - return &result, nil -} - -// StartServer starts an individual MCP server on the session's host. -// -// RPC method: session.mcp.startServer. -// -// Parameters: Server name and opaque configuration for an individual MCP server start. -// Internal: StartServer is part of the SDK's internal handshake/plumbing; external callers -// should not use it. -func (a *InternalMCPAPI) StartServer(ctx context.Context, params *MCPStartServerRequest) (*SessionMCPStartServerResult, error) { - req := map[string]any{"sessionId": a.sessionID} - if params != nil { - req["config"] = params.Config - req["serverName"] = params.ServerName - } - raw, err := a.client.Request(ctx, "session.mcp.startServer", req) - if err != nil { - return nil, err - } - var result SessionMCPStartServerResult - if err := json.Unmarshal(raw, &result); err != nil { - return nil, err - } - return &result, nil -} - // UnregisterExternalClient unregisters a previously registered external MCP client by // server name. Marked internal as the paired companion of `registerExternalClient`: only // in-process callers that registered a client this way can meaningfully unregister it. @@ -19499,6 +20052,20 @@ type GitHubTelemetryHandler interface { Event(request *GitHubTelemetryNotification) error } +// Experimental: HooksHandler contains experimental APIs that may change or be removed. +type HooksHandler interface { + // Invoke dispatches one SDK callback hook from the runtime to the connection that + // registered it. Internal transport plumbing: clients opt in through session initialization + // and the Rust hook processor owns ordering, policy, timeout, and callback routing. + // + // RPC method: hooks.invoke. + // + // Parameters: Runtime-owned wire payload for a server-to-client hook callback invocation. + // + // Returns: Optional output returned by an SDK callback hook. + Invoke(request *HookInvokeRequest) (*HookInvokeResponse, error) +} + // Experimental: LlmInferenceHandler contains experimental APIs that may change or be // removed. type LlmInferenceHandler interface { @@ -19536,6 +20103,7 @@ type LlmInferenceHandler interface { // key; a single set of handlers serves the entire connection. type ClientGlobalAPIHandlers struct { GitHubTelemetry GitHubTelemetryHandler + Hooks HooksHandler LlmInference LlmInferenceHandler } @@ -19566,6 +20134,24 @@ func RegisterClientGlobalAPIHandlers(client *jsonrpc2.Client, handlers *ClientGl } return nil, nil }) + client.SetRequestHandler("hooks.invoke", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + var request HookInvokeRequest + if err := json.Unmarshal(params, &request); err != nil { + return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("Invalid params: %v", err)} + } + if handlers == nil || handlers.Hooks == nil { + return nil, &jsonrpc2.Error{Code: -32603, Message: "No hooks client-global handler registered"} + } + result, err := handlers.Hooks.Invoke(&request) + if err != nil { + return nil, clientGlobalHandlerError(err) + } + raw, err := json.Marshal(result) + if err != nil { + return nil, &jsonrpc2.Error{Code: -32603, Message: fmt.Sprintf("Failed to marshal response: %v", err)} + } + return raw, nil + }) client.SetRequestHandler("llmInference.httpRequestChunk", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { var request LlmInferenceHTTPRequestChunkRequest if err := json.Unmarshal(params, &request); err != nil { diff --git a/go/rpc/zrpc_encoding.go b/go/rpc/zrpc_encoding.go index 89bd609a65..81e693120b 100644 --- a/go/rpc/zrpc_encoding.go +++ b/go/rpc/zrpc_encoding.go @@ -1561,6 +1561,46 @@ func (r *MCPOauthHandlePendingRequest) UnmarshalJSON(data []byte) error { return nil } +func (r *MCPRestartServerRequest) UnmarshalJSON(data []byte) error { + type rawMCPRestartServerRequest struct { + Config json.RawMessage `json:"config,omitempty"` + ServerName string `json:"serverName"` + } + var raw rawMCPRestartServerRequest + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.Config != nil { + value, err := unmarshalMCPServerConfig(raw.Config) + if err != nil { + return err + } + r.Config = value + } + r.ServerName = raw.ServerName + return nil +} + +func (r *MCPStartServerRequest) UnmarshalJSON(data []byte) error { + type rawMCPStartServerRequest struct { + Config json.RawMessage `json:"config"` + ServerName string `json:"serverName"` + } + var raw rawMCPStartServerRequest + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.Config != nil { + value, err := unmarshalMCPServerConfig(raw.Config) + if err != nil { + return err + } + r.Config = value + } + r.ServerName = raw.ServerName + return nil +} + func unmarshalPermissionDecision(data []byte) (PermissionDecision, error) { if string(data) == "null" { return nil, nil @@ -3136,6 +3176,37 @@ func (r *SendAttachmentsToMessageParams) UnmarshalJSON(data []byte) error { return nil } +func (r *SendMessageItem) UnmarshalJSON(data []byte) error { + type rawSendMessageItem struct { + Attachments []json.RawMessage `json:"attachments,omitzero"` + Billable *bool `json:"billable,omitempty"` + DisplayPrompt *string `json:"displayPrompt,omitempty"` + Prompt string `json:"prompt"` + RequiredTool *string `json:"requiredTool,omitempty"` + Source *string `json:"source,omitempty"` + } + var raw rawSendMessageItem + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw.Attachments != nil { + r.Attachments = make([]Attachment, 0, len(raw.Attachments)) + for _, rawItem := range raw.Attachments { + value, err := unmarshalAttachment(rawItem) + if err != nil { + return err + } + r.Attachments = append(r.Attachments, value) + } + } + r.Billable = raw.Billable + r.DisplayPrompt = raw.DisplayPrompt + r.Prompt = raw.Prompt + r.RequiredTool = raw.RequiredTool + r.Source = raw.Source + return nil +} + func (r *SendRequest) UnmarshalJSON(data []byte) error { type rawSendRequest struct { AgentMode *SendAgentMode `json:"agentMode,omitempty"` @@ -3339,6 +3410,7 @@ func (r *SessionOpenOptions) UnmarshalJSON(data []byte) error { ExcludedTools []string `json:"excludedTools,omitzero"` ExpAssignments any `json:"expAssignments,omitempty"` FeatureFlags map[string]bool `json:"featureFlags,omitzero"` + IncludedBuiltinAgents []string `json:"includedBuiltinAgents,omitzero"` InstalledPlugins []InstalledPlugin `json:"installedPlugins,omitzero"` IntegrationID *string `json:"integrationId,omitempty"` IsExperimentalMode *bool `json:"isExperimentalMode,omitempty"` @@ -3410,6 +3482,7 @@ func (r *SessionOpenOptions) UnmarshalJSON(data []byte) error { r.ExcludedTools = raw.ExcludedTools r.ExpAssignments = raw.ExpAssignments r.FeatureFlags = raw.FeatureFlags + r.IncludedBuiltinAgents = raw.IncludedBuiltinAgents r.InstalledPlugins = raw.InstalledPlugins r.IntegrationID = raw.IntegrationID r.IsExperimentalMode = raw.IsExperimentalMode diff --git a/go/rpc/zsession_encoding.go b/go/rpc/zsession_encoding.go index 1102e9bdae..2bd212d884 100644 --- a/go/rpc/zsession_encoding.go +++ b/go/rpc/zsession_encoding.go @@ -83,6 +83,12 @@ func (e *SessionEvent) UnmarshalJSON(data []byte) error { return err } e.Data = &d + case SessionEventTypeAssistantServerToolProgress: + var d AssistantServerToolProgressData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d case SessionEventTypeAssistantStreamingDelta: var d AssistantStreamingDeltaData if err := json.Unmarshal(raw.Data, &d); err != nil { @@ -239,6 +245,24 @@ func (e *SessionEvent) UnmarshalJSON(data []byte) error { return err } e.Data = &d + case SessionEventTypeMCPPromptsListChanged: + var d MCPPromptsListChangedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeMCPResourcesListChanged: + var d MCPResourcesListChangedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d + case SessionEventTypeMCPToolsListChanged: + var d MCPToolsListChangedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d case SessionEventTypeModelCallFailure: var d ModelCallFailureData if err := json.Unmarshal(raw.Data, &d); err != nil { @@ -275,6 +299,12 @@ func (e *SessionEvent) UnmarshalJSON(data []byte) error { return err } e.Data = &d + case SessionEventTypeSessionAutoModeResolved: + var d SessionAutoModeResolvedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d case SessionEventTypeSessionAutopilotObjectiveChanged: var d SessionAutopilotObjectiveChangedData if err := json.Unmarshal(raw.Data, &d); err != nil { @@ -407,6 +437,12 @@ func (e *SessionEvent) UnmarshalJSON(data []byte) error { return err } e.Data = &d + case SessionEventTypeSessionManagedSettingsResolved: + var d SessionManagedSettingsResolvedData + if err := json.Unmarshal(raw.Data, &d); err != nil { + return err + } + e.Data = &d case SessionEventTypeSessionMCPServersLoaded: var d SessionMCPServersLoadedData if err := json.Unmarshal(raw.Data, &d); err != nil { @@ -1224,6 +1260,7 @@ func (r *ToolExecutionCompleteResult) UnmarshalJSON(data []byte) error { Content string `json:"content"` Contents []json.RawMessage `json:"contents,omitzero"` DetailedContent *string `json:"detailedContent,omitempty"` + MCPMeta any `json:"mcpMeta,omitempty"` StructuredContent any `json:"structuredContent,omitempty"` UIResource *ToolExecutionCompleteUIResource `json:"uiResource,omitempty"` } @@ -1254,6 +1291,7 @@ func (r *ToolExecutionCompleteResult) UnmarshalJSON(data []byte) error { } } r.DetailedContent = raw.DetailedContent + r.MCPMeta = raw.MCPMeta r.StructuredContent = raw.StructuredContent r.UIResource = raw.UIResource return nil diff --git a/go/rpc/zsession_events.go b/go/rpc/zsession_events.go index 758b90b7d8..ec211132df 100644 --- a/go/rpc/zsession_events.go +++ b/go/rpc/zsession_events.go @@ -53,46 +53,53 @@ func (r RawSessionEventData) Type() SessionEventType { type SessionEventType string const ( - SessionEventTypeAbort SessionEventType = "abort" - SessionEventTypeAssistantIdle SessionEventType = "assistant.idle" - SessionEventTypeAssistantIntent SessionEventType = "assistant.intent" - SessionEventTypeAssistantMessage SessionEventType = "assistant.message" - SessionEventTypeAssistantMessageDelta SessionEventType = "assistant.message_delta" - SessionEventTypeAssistantMessageStart SessionEventType = "assistant.message_start" - SessionEventTypeAssistantReasoning SessionEventType = "assistant.reasoning" - SessionEventTypeAssistantReasoningDelta SessionEventType = "assistant.reasoning_delta" - SessionEventTypeAssistantStreamingDelta SessionEventType = "assistant.streaming_delta" - SessionEventTypeAssistantToolCallDelta SessionEventType = "assistant.tool_call_delta" - SessionEventTypeAssistantTurnEnd SessionEventType = "assistant.turn_end" - SessionEventTypeAssistantTurnStart SessionEventType = "assistant.turn_start" - SessionEventTypeAssistantUsage SessionEventType = "assistant.usage" - SessionEventTypeAutoModeSwitchCompleted SessionEventType = "auto_mode_switch.completed" - SessionEventTypeAutoModeSwitchRequested SessionEventType = "auto_mode_switch.requested" - SessionEventTypeCapabilitiesChanged SessionEventType = "capabilities.changed" - SessionEventTypeCommandCompleted SessionEventType = "command.completed" - SessionEventTypeCommandExecute SessionEventType = "command.execute" - SessionEventTypeCommandQueued SessionEventType = "command.queued" - SessionEventTypeCommandsChanged SessionEventType = "commands.changed" - SessionEventTypeElicitationCompleted SessionEventType = "elicitation.completed" - SessionEventTypeElicitationRequested SessionEventType = "elicitation.requested" - SessionEventTypeExitPlanModeCompleted SessionEventType = "exit_plan_mode.completed" - SessionEventTypeExitPlanModeRequested SessionEventType = "exit_plan_mode.requested" - SessionEventTypeExternalToolCompleted SessionEventType = "external_tool.completed" - SessionEventTypeExternalToolRequested SessionEventType = "external_tool.requested" - SessionEventTypeHookEnd SessionEventType = "hook.end" - SessionEventTypeHookProgress SessionEventType = "hook.progress" - SessionEventTypeHookStart SessionEventType = "hook.start" - SessionEventTypeMCPAppToolCallComplete SessionEventType = "mcp_app.tool_call_complete" - SessionEventTypeMCPHeadersRefreshCompleted SessionEventType = "mcp.headers_refresh_completed" - SessionEventTypeMCPHeadersRefreshRequired SessionEventType = "mcp.headers_refresh_required" - SessionEventTypeMCPOauthCompleted SessionEventType = "mcp.oauth_completed" - SessionEventTypeMCPOauthRequired SessionEventType = "mcp.oauth_required" - SessionEventTypeModelCallFailure SessionEventType = "model.call_failure" - SessionEventTypePendingMessagesModified SessionEventType = "pending_messages.modified" - SessionEventTypePermissionCompleted SessionEventType = "permission.completed" - SessionEventTypePermissionRequested SessionEventType = "permission.requested" - SessionEventTypeSamplingCompleted SessionEventType = "sampling.completed" - SessionEventTypeSamplingRequested SessionEventType = "sampling.requested" + SessionEventTypeAbort SessionEventType = "abort" + SessionEventTypeAssistantIdle SessionEventType = "assistant.idle" + SessionEventTypeAssistantIntent SessionEventType = "assistant.intent" + SessionEventTypeAssistantMessage SessionEventType = "assistant.message" + SessionEventTypeAssistantMessageDelta SessionEventType = "assistant.message_delta" + SessionEventTypeAssistantMessageStart SessionEventType = "assistant.message_start" + SessionEventTypeAssistantReasoning SessionEventType = "assistant.reasoning" + SessionEventTypeAssistantReasoningDelta SessionEventType = "assistant.reasoning_delta" + SessionEventTypeAssistantServerToolProgress SessionEventType = "assistant.server_tool_progress" + SessionEventTypeAssistantStreamingDelta SessionEventType = "assistant.streaming_delta" + SessionEventTypeAssistantToolCallDelta SessionEventType = "assistant.tool_call_delta" + SessionEventTypeAssistantTurnEnd SessionEventType = "assistant.turn_end" + SessionEventTypeAssistantTurnStart SessionEventType = "assistant.turn_start" + SessionEventTypeAssistantUsage SessionEventType = "assistant.usage" + SessionEventTypeAutoModeSwitchCompleted SessionEventType = "auto_mode_switch.completed" + SessionEventTypeAutoModeSwitchRequested SessionEventType = "auto_mode_switch.requested" + SessionEventTypeCapabilitiesChanged SessionEventType = "capabilities.changed" + SessionEventTypeCommandCompleted SessionEventType = "command.completed" + SessionEventTypeCommandExecute SessionEventType = "command.execute" + SessionEventTypeCommandQueued SessionEventType = "command.queued" + SessionEventTypeCommandsChanged SessionEventType = "commands.changed" + SessionEventTypeElicitationCompleted SessionEventType = "elicitation.completed" + SessionEventTypeElicitationRequested SessionEventType = "elicitation.requested" + SessionEventTypeExitPlanModeCompleted SessionEventType = "exit_plan_mode.completed" + SessionEventTypeExitPlanModeRequested SessionEventType = "exit_plan_mode.requested" + SessionEventTypeExternalToolCompleted SessionEventType = "external_tool.completed" + SessionEventTypeExternalToolRequested SessionEventType = "external_tool.requested" + SessionEventTypeHookEnd SessionEventType = "hook.end" + SessionEventTypeHookProgress SessionEventType = "hook.progress" + SessionEventTypeHookStart SessionEventType = "hook.start" + SessionEventTypeMCPAppToolCallComplete SessionEventType = "mcp_app.tool_call_complete" + SessionEventTypeMCPHeadersRefreshCompleted SessionEventType = "mcp.headers_refresh_completed" + SessionEventTypeMCPHeadersRefreshRequired SessionEventType = "mcp.headers_refresh_required" + SessionEventTypeMCPOauthCompleted SessionEventType = "mcp.oauth_completed" + SessionEventTypeMCPOauthRequired SessionEventType = "mcp.oauth_required" + SessionEventTypeMCPPromptsListChanged SessionEventType = "mcp.prompts.list_changed" + SessionEventTypeMCPResourcesListChanged SessionEventType = "mcp.resources.list_changed" + SessionEventTypeMCPToolsListChanged SessionEventType = "mcp.tools.list_changed" + SessionEventTypeModelCallFailure SessionEventType = "model.call_failure" + SessionEventTypePendingMessagesModified SessionEventType = "pending_messages.modified" + SessionEventTypePermissionCompleted SessionEventType = "permission.completed" + SessionEventTypePermissionRequested SessionEventType = "permission.requested" + SessionEventTypeSamplingCompleted SessionEventType = "sampling.completed" + SessionEventTypeSamplingRequested SessionEventType = "sampling.requested" + // Experimental: SessionEventTypeSessionAutoModeResolved identifies an experimental event + // that may change or be removed. + SessionEventTypeSessionAutoModeResolved SessionEventType = "session.auto_mode_resolved" SessionEventTypeSessionAutopilotObjectiveChanged SessionEventType = "session.autopilot_objective_changed" SessionEventTypeSessionBackgroundTasksChanged SessionEventType = "session.background_tasks_changed" // Experimental: SessionEventTypeSessionBinaryAsset identifies an experimental event that @@ -129,47 +136,50 @@ const ( SessionEventTypeSessionInfo SessionEventType = "session.info" SessionEventTypeSessionLimitsExhaustedCompleted SessionEventType = "session_limits_exhausted.completed" SessionEventTypeSessionLimitsExhaustedRequested SessionEventType = "session_limits_exhausted.requested" - SessionEventTypeSessionMCPServersLoaded SessionEventType = "session.mcp_servers_loaded" - SessionEventTypeSessionMCPServerStatusChanged SessionEventType = "session.mcp_server_status_changed" - SessionEventTypeSessionModeChanged SessionEventType = "session.mode_changed" - SessionEventTypeSessionModelChange SessionEventType = "session.model_change" - SessionEventTypeSessionPermissionsChanged SessionEventType = "session.permissions_changed" - SessionEventTypeSessionPlanChanged SessionEventType = "session.plan_changed" - SessionEventTypeSessionRemoteSteerableChanged SessionEventType = "session.remote_steerable_changed" - SessionEventTypeSessionResume SessionEventType = "session.resume" - SessionEventTypeSessionScheduleCancelled SessionEventType = "session.schedule_cancelled" - SessionEventTypeSessionScheduleCreated SessionEventType = "session.schedule_created" - SessionEventTypeSessionScheduleRearmed SessionEventType = "session.schedule_rearmed" - SessionEventTypeSessionSessionLimitsChanged SessionEventType = "session.session_limits_changed" - SessionEventTypeSessionShutdown SessionEventType = "session.shutdown" - SessionEventTypeSessionSkillsLoaded SessionEventType = "session.skills_loaded" - SessionEventTypeSessionSnapshotRewind SessionEventType = "session.snapshot_rewind" - SessionEventTypeSessionStart SessionEventType = "session.start" - SessionEventTypeSessionTaskComplete SessionEventType = "session.task_complete" - SessionEventTypeSessionTitleChanged SessionEventType = "session.title_changed" - SessionEventTypeSessionTodosChanged SessionEventType = "session.todos_changed" - SessionEventTypeSessionToolsUpdated SessionEventType = "session.tools_updated" - SessionEventTypeSessionTruncation SessionEventType = "session.truncation" - SessionEventTypeSessionUsageCheckpoint SessionEventType = "session.usage_checkpoint" - SessionEventTypeSessionUsageInfo SessionEventType = "session.usage_info" - SessionEventTypeSessionWarning SessionEventType = "session.warning" - SessionEventTypeSessionWorkspaceFileChanged SessionEventType = "session.workspace_file_changed" - SessionEventTypeSkillInvoked SessionEventType = "skill.invoked" - SessionEventTypeSubagentCompleted SessionEventType = "subagent.completed" - SessionEventTypeSubagentDeselected SessionEventType = "subagent.deselected" - SessionEventTypeSubagentFailed SessionEventType = "subagent.failed" - SessionEventTypeSubagentSelected SessionEventType = "subagent.selected" - SessionEventTypeSubagentStarted SessionEventType = "subagent.started" - SessionEventTypeSystemMessage SessionEventType = "system.message" - SessionEventTypeSystemNotification SessionEventType = "system.notification" - SessionEventTypeToolExecutionComplete SessionEventType = "tool.execution_complete" - SessionEventTypeToolExecutionPartialResult SessionEventType = "tool.execution_partial_result" - SessionEventTypeToolExecutionProgress SessionEventType = "tool.execution_progress" - SessionEventTypeToolExecutionStart SessionEventType = "tool.execution_start" - SessionEventTypeToolUserRequested SessionEventType = "tool.user_requested" - SessionEventTypeUserInputCompleted SessionEventType = "user_input.completed" - SessionEventTypeUserInputRequested SessionEventType = "user_input.requested" - SessionEventTypeUserMessage SessionEventType = "user.message" + // Experimental: SessionEventTypeSessionManagedSettingsResolved identifies an experimental + // event that may change or be removed. + SessionEventTypeSessionManagedSettingsResolved SessionEventType = "session.managed_settings_resolved" + SessionEventTypeSessionMCPServersLoaded SessionEventType = "session.mcp_servers_loaded" + SessionEventTypeSessionMCPServerStatusChanged SessionEventType = "session.mcp_server_status_changed" + SessionEventTypeSessionModeChanged SessionEventType = "session.mode_changed" + SessionEventTypeSessionModelChange SessionEventType = "session.model_change" + SessionEventTypeSessionPermissionsChanged SessionEventType = "session.permissions_changed" + SessionEventTypeSessionPlanChanged SessionEventType = "session.plan_changed" + SessionEventTypeSessionRemoteSteerableChanged SessionEventType = "session.remote_steerable_changed" + SessionEventTypeSessionResume SessionEventType = "session.resume" + SessionEventTypeSessionScheduleCancelled SessionEventType = "session.schedule_cancelled" + SessionEventTypeSessionScheduleCreated SessionEventType = "session.schedule_created" + SessionEventTypeSessionScheduleRearmed SessionEventType = "session.schedule_rearmed" + SessionEventTypeSessionSessionLimitsChanged SessionEventType = "session.session_limits_changed" + SessionEventTypeSessionShutdown SessionEventType = "session.shutdown" + SessionEventTypeSessionSkillsLoaded SessionEventType = "session.skills_loaded" + SessionEventTypeSessionSnapshotRewind SessionEventType = "session.snapshot_rewind" + SessionEventTypeSessionStart SessionEventType = "session.start" + SessionEventTypeSessionTaskComplete SessionEventType = "session.task_complete" + SessionEventTypeSessionTitleChanged SessionEventType = "session.title_changed" + SessionEventTypeSessionTodosChanged SessionEventType = "session.todos_changed" + SessionEventTypeSessionToolsUpdated SessionEventType = "session.tools_updated" + SessionEventTypeSessionTruncation SessionEventType = "session.truncation" + SessionEventTypeSessionUsageCheckpoint SessionEventType = "session.usage_checkpoint" + SessionEventTypeSessionUsageInfo SessionEventType = "session.usage_info" + SessionEventTypeSessionWarning SessionEventType = "session.warning" + SessionEventTypeSessionWorkspaceFileChanged SessionEventType = "session.workspace_file_changed" + SessionEventTypeSkillInvoked SessionEventType = "skill.invoked" + SessionEventTypeSubagentCompleted SessionEventType = "subagent.completed" + SessionEventTypeSubagentDeselected SessionEventType = "subagent.deselected" + SessionEventTypeSubagentFailed SessionEventType = "subagent.failed" + SessionEventTypeSubagentSelected SessionEventType = "subagent.selected" + SessionEventTypeSubagentStarted SessionEventType = "subagent.started" + SessionEventTypeSystemMessage SessionEventType = "system.message" + SessionEventTypeSystemNotification SessionEventType = "system.notification" + SessionEventTypeToolExecutionComplete SessionEventType = "tool.execution_complete" + SessionEventTypeToolExecutionPartialResult SessionEventType = "tool.execution_partial_result" + SessionEventTypeToolExecutionProgress SessionEventType = "tool.execution_progress" + SessionEventTypeToolExecutionStart SessionEventType = "tool.execution_start" + SessionEventTypeToolUserRequested SessionEventType = "tool.user_requested" + SessionEventTypeUserInputCompleted SessionEventType = "user_input.completed" + SessionEventTypeUserInputRequested SessionEventType = "user_input.requested" + SessionEventTypeUserMessage SessionEventType = "user.message" ) // Agent intent description for current activity or plan @@ -210,6 +220,8 @@ type AssistantMessageData struct { // Provider-agnostic citations linking spans of this message's content to the sources that support them. Experimental; only populated when citation emission is enabled. // Experimental: Citations is part of an experimental API and may change or be removed. Citations *Citations `json:"citations,omitempty"` + // Client-minted request id (x-request-id header) echoed by the server. Distinct from requestId (x-github-request-id) and serviceRequestId (x-copilot-service-request-id). + ClientRequestID *string `json:"clientRequestId,omitempty"` // The assistant's text response content Content string `json:"content"` // Encrypted reasoning content from OpenAI models. Session-bound and stripped on resume. @@ -248,6 +260,28 @@ type AssistantMessageData struct { func (*AssistantMessageData) sessionEventData() {} func (*AssistantMessageData) Type() SessionEventType { return SessionEventTypeAssistantMessage } +// Auto Intent resolution: the concrete model the session settled on for the first prompt of an auto-mode session, and why. Lets SDK clients render the chosen model and the full reason it was picked. The core selection fields (chosenModel/reasoningBucket/categoryScores) are stable; the routing-analytics fields (predictedLabel/confidence/candidateModels) mirror the upstream intent service and may evolve, hence the event's experimental stability. +// Experimental: SessionAutoModeResolvedData is part of an experimental API and may change or be removed. +type SessionAutoModeResolvedData struct { + // Ordered candidate model list the router returned, when not a fallback + CandidateModels []string `json:"candidateModels,omitzero"` + // Per-category classifier scores (0-1) behind the bucket: the granular HYDRA capability scores (reasoning, code_gen, debugging, tool_use), or the binary needs_reasoning/no_reasoning scores when HYDRA didn't run. Lets clients show a breakdown rather than just the bucket. + CategoryScores map[string]float64 `json:"categoryScores,omitzero"` + // The concrete model the session will use after any intent refinement + ChosenModel string `json:"chosenModel"` + // Classifier confidence for the predicted label, when available + Confidence *float64 `json:"confidence,omitempty"` + // The predicted classifier label (e.g. `needs_reasoning`), when available + PredictedLabel *string `json:"predictedLabel,omitempty"` + // Coarse request-difficulty bucket, for explaining why a model was chosen ("picked X because this looks like high-reasoning work") + ReasoningBucket *AutoModeResolvedReasoningBucket `json:"reasoningBucket,omitempty"` +} + +func (*SessionAutoModeResolvedData) sessionEventData() {} +func (*SessionAutoModeResolvedData) Type() SessionEventType { + return SessionEventTypeSessionAutoModeResolved +} + // Auto mode switch completion notification type AutoModeSwitchCompletedData struct { // Request ID of the resolved request; clients should dismiss any UI for this request @@ -555,6 +589,30 @@ func (*PendingMessagesModifiedData) Type() SessionEventType { return SessionEventTypePendingMessagesModified } +// Enterprise managed-settings resolution: the effective managed settings the session applied and where they came from, so SDK clients can show users what is enterprise-managed and by which authority. Fires whenever managed policy is (re)applied — at session start, on resume, and on account switch. This is an ephemeral live snapshot (delivered to subscribers but not persisted to the session event log), because at session start it resolves before `session.start` is emitted; for a session-independent pull, use the SDK `getManagedSettings()` API, which returns the identical payload. Managed settings have a single authoritative source, so the highest-authority present layer (server > device) wins wholesale; `bypassPermissionsDisabled` is deny-wins across layers. Marked experimental while the managed-settings surface stabilizes. +// Experimental: SessionManagedSettingsResolvedData is part of an experimental API and may change or be removed. +type SessionManagedSettingsResolvedData struct { + // Whether enterprise policy disables bypass-permissions ("yolo") mode for this session. Deny-wins across layers, and forced on when `failClosed` is true. + BypassPermissionsDisabled bool `json:"bypassPermissionsDisabled"` + // Whether the device (MDM/plist/registry/file) managed-settings layer was present + DeviceManaged bool `json:"deviceManaged"` + // Whether managed policy could not be determined (e.g. a failed server fetch) and the session fell back to the fail-closed restriction. When true, restrictions such as disabling bypass-permissions are enforced even though `settings` may be absent. + FailClosed bool `json:"failClosed"` + // The setting keys under enterprise management in the effective managed settings (e.g. `model`, `enabledPlugins`, `permissions`). Empty when no managed settings are in force. + ManagedKeys []string `json:"managedKeys"` + // Whether the server (account/org) managed-settings layer was present + ServerManaged bool `json:"serverManaged"` + // The effective (resolved) managed settings values, so clients can render exactly what is enforced. Absent when no managed policy is in force. + Settings any `json:"settings,omitempty"` + // Which channel supplied the effective managed settings (the winning layer), or `none` when no policy is in force + Source ManagedSettingsResolvedSource `json:"source"` +} + +func (*SessionManagedSettingsResolvedData) sessionEventData() {} +func (*SessionManagedSettingsResolvedData) Type() SessionEventType { + return SessionEventTypeSessionManagedSettingsResolved +} + // Ephemeral progress update from a running hook process type HookProgressData struct { // Human-readable progress message from the hook process @@ -760,6 +818,21 @@ type AssistantUsageData struct { func (*AssistantUsageData) sessionEventData() {} func (*AssistantUsageData) Type() SessionEventType { return SessionEventTypeAssistantUsage } +// Live progress signal for a provider-hosted server tool (e.g. hosted web search) while it runs, before the finalized serverTools envelope lands on the terminal assistant.message +type AssistantServerToolProgressData struct { + // Kind of hosted server tool that is running. Only `web_search` is emitted today. + Kind string `json:"kind"` + // Position of the hosted tool call in the response output. Stable across the call's lifecycle events (unlike the provider's per-event item id, which CAPI rotates), so the host keys the live in-progress row on it. + OutputIndex int64 `json:"outputIndex"` + // Lifecycle status of the hosted call: `in_progress`, `searching`, or `completed`. + Status string `json:"status"` +} + +func (*AssistantServerToolProgressData) sessionEventData() {} +func (*AssistantServerToolProgressData) Type() SessionEventType { + return SessionEventTypeAssistantServerToolProgress +} + // MCP App view called a tool on a connected MCP server (SEP-1865) type MCPAppToolCallCompleteData struct { // Arguments passed to the tool by the app view, if any @@ -849,6 +922,8 @@ func (*SessionRemoteSteerableChangedData) Type() SessionEventType { // OAuth authentication request for an MCP server type MCPOauthRequiredData struct { + // Raw HTTP response details from the OAuth auth challenge, as observed by the runtime. Header order and casing are transport-dependent, and duplicate header names may appear multiple times. + HTTPResponse *MCPOauthHTTPResponse `json:"httpResponse,omitempty"` // Why the runtime is requesting host-provided OAuth credentials. Reason MCPOauthRequestReason `json:"reason"` // Unique identifier for this OAuth request; used to respond via session.mcp.oauth.handlePendingRequest @@ -896,6 +971,37 @@ type AssistantIdleData struct { func (*AssistantIdleData) sessionEventData() {} func (*AssistantIdleData) Type() SessionEventType { return SessionEventTypeAssistantIdle } +// Payload identifying the MCP server associated with a list change. +type MCPPromptsListChangedData struct { + // Name of the MCP server whose list changed + ServerName string `json:"serverName"` +} + +func (*MCPPromptsListChangedData) sessionEventData() {} +func (*MCPPromptsListChangedData) Type() SessionEventType { + return SessionEventTypeMCPPromptsListChanged +} + +// Payload identifying the MCP server associated with a list change. +type MCPResourcesListChangedData struct { + // Name of the MCP server whose list changed + ServerName string `json:"serverName"` +} + +func (*MCPResourcesListChangedData) sessionEventData() {} +func (*MCPResourcesListChangedData) Type() SessionEventType { + return SessionEventTypeMCPResourcesListChanged +} + +// Payload identifying the MCP server associated with a list change. +type MCPToolsListChangedData struct { + // Name of the MCP server whose list changed + ServerName string `json:"serverName"` +} + +func (*MCPToolsListChangedData) sessionEventData() {} +func (*MCPToolsListChangedData) Type() SessionEventType { return SessionEventTypeMCPToolsListChanged } + // Payload indicating the session is idle with no background agents or attached shell commands in flight type SessionIdleData struct { // True when the preceding agentic loop was cancelled via abort signal @@ -919,7 +1025,7 @@ type SessionCanvasClosedData struct { func (*SessionCanvasClosedData) sessionEventData() {} func (*SessionCanvasClosedData) Type() SessionEventType { return SessionEventTypeSessionCanvasClosed } -// Payload of `session.canvas.opened` with canvas instance and provider IDs plus optional title, status, URL, and input. +// Payload of `session.canvas.opened` with canvas instance and provider IDs plus optional icon, title, status, URL, and input. // Experimental: SessionCanvasOpenedData is part of an experimental API and may change or be removed. type SessionCanvasOpenedData struct { // Provider-local canvas identifier @@ -928,6 +1034,8 @@ type SessionCanvasOpenedData struct { ExtensionID string `json:"extensionId"` // Owning extension display name, when available ExtensionName *string `json:"extensionName,omitempty"` + // Host-local PNG path for the canvas icon, when supplied + Icon *string `json:"icon,omitempty"` // Input supplied when the instance was opened Input any `json:"input,omitempty"` // Stable caller-supplied canvas instance identifier @@ -1701,6 +1809,9 @@ type ToolExecutionCompleteData struct { InteractionID *string `json:"interactionId,omitempty"` // Whether this tool call was explicitly requested by the user rather than the assistant IsUserRequested *bool `json:"isUserRequested,omitempty"` + // FIDES IFC label projected from tool ingress metadata (MCP `CallToolResult._meta` or synthesized built-in ingress labels). Persisted as `{ ifc: ... }` so the label survives session resume, including model-visible failure results. Experimental. + // Experimental: MCPMeta is part of an experimental API and may change or be removed. + MCPMeta any `json:"mcpMeta,omitempty"` // Model identifier that generated this tool call Model *string `json:"model,omitempty"` // Tool call ID of the parent tool invocation when this event originates from a sub-agent @@ -2017,6 +2128,8 @@ type CanvasRegistryChangedCanvas struct { ExtensionID string `json:"extensionId"` // Owning extension display name, when available ExtensionName *string `json:"extensionName,omitempty"` + // Host-local PNG path for the canvas icon, when supplied + Icon *string `json:"icon,omitempty"` // JSON Schema for canvas open input InputSchema any `json:"inputSchema,omitempty"` } @@ -2262,6 +2375,14 @@ type HandoffRepository struct { Owner string `json:"owner"` } +// Single HTTP header entry as a name/value pair. +type HeaderEntry struct { + // HTTP response header name as observed by the runtime. + Name string `json:"name"` + // HTTP response header value as observed by the runtime. + Value string `json:"value"` +} + // Error details when the hook failed type HookEndError struct { // Human-readable error message @@ -2292,6 +2413,16 @@ type MCPAppToolCallCompleteToolMetaUI struct { Visibility []string `json:"visibility,omitzero"` } +// Raw HTTP response details from the OAuth auth challenge, as observed by the runtime. +type MCPOauthHTTPResponse struct { + // Complete UTF-8 response body for host-specific challenge handling, including an empty string for an empty body. Omitted when the complete body is not valid UTF-8; body read failures fail the HTTP operation rather than exposing a partial response. + Body *string `json:"body,omitempty"` + // HTTP response headers as observed by the runtime. Order and casing are transport-dependent, and duplicate header names may appear multiple times. + Headers []HeaderEntry `json:"headers"` + // HTTP status code returned with the auth challenge. + StatusCode int32 `json:"statusCode"` +} + // Static OAuth client configuration, if the server specifies one type MCPOauthRequiredStaticClientConfig struct { // OAuth client ID for the server @@ -3408,6 +3539,9 @@ type ToolExecutionCompleteResult struct { Contents []ToolExecutionCompleteContent `json:"contents,omitzero"` // Full detailed tool result for UI/timeline display, preserving complete content such as diffs. Falls back to content when absent. DetailedContent *string `json:"detailedContent,omitempty"` + // FIDES IFC label projected from tool ingress metadata (MCP `CallToolResult._meta` or synthesized built-in ingress labels) — persisted as `{ ifc: ... }` (only the `ifc` key, not the whole `_meta`). Persisted so the FIDES IFC label survives session resume: the engine rehydrates accumulated taint by replaying these on load. Populated for ingress sources when FIDES IFC is on. Experimental. + // Experimental: MCPMeta is part of an experimental API and may change or be removed. + MCPMeta any `json:"mcpMeta,omitempty"` // Structured content (arbitrary JSON) returned verbatim by the MCP tool StructuredContent any `json:"structuredContent,omitempty"` // MCP Apps UI resource content for rendering in a sandboxed iframe @@ -3595,6 +3729,18 @@ const ( AutoApprovalRecommendationRequireApproval AutoApprovalRecommendation = "requireApproval" ) +// Coarse request-difficulty bucket for UX explainability +type AutoModeResolvedReasoningBucket string + +const ( + // The request looks high-reasoning; a stronger model is appropriate. + AutoModeResolvedReasoningBucketHigh AutoModeResolvedReasoningBucket = "high" + // The request looks low-reasoning; a lighter model is appropriate. + AutoModeResolvedReasoningBucketLow AutoModeResolvedReasoningBucket = "low" + // The request needs a moderate amount of reasoning. + AutoModeResolvedReasoningBucketMedium AutoModeResolvedReasoningBucket = "medium" +) + // The user's auto-mode-switch choice type AutoModeSwitchResponse string @@ -3757,6 +3903,18 @@ const ( HandoffSourceTypeRemote HandoffSourceType = "remote" ) +// Which channel supplied the effective enterprise managed settings (highest-authority present layer wins wholesale) +type ManagedSettingsResolvedSource string + +const ( + // Device-level MDM policy discovered from plist/registry/file (lower authority). + ManagedSettingsResolvedSourceDevice ManagedSettingsResolvedSource = "device" + // No managed policy is in force (no layer contributed). + ManagedSettingsResolvedSourceNone ManagedSettingsResolvedSource = "none" + // Account/org policy self-fetched from the GitHub managed-settings endpoint (higher authority). + ManagedSettingsResolvedSourceServer ManagedSettingsResolvedSource = "server" +) + // How the pending MCP headers refresh request resolved. type MCPHeadersRefreshCompletedOutcome string diff --git a/go/session.go b/go/session.go index 808876761e..c4e742e906 100644 --- a/go/session.go +++ b/go/session.go @@ -13,6 +13,11 @@ import ( "github.com/github/copilot-sdk/go/rpc" ) +// toolSearchToolName is the fixed name of the runtime's built-in tool-search +// tool. A client can replace its behavior by registering a [Tool] with this +// exact name and OverridesBuiltInTool set to true. +const toolSearchToolName = "tool_search_tool" + type sessionHandler struct { id uint64 fn SessionEventHandler @@ -163,6 +168,7 @@ func (s *Session) updateOpenCanvasesFromEvent(event SessionEvent) { InstanceID: data.InstanceID, Status: data.Status, Title: data.Title, + Icon: data.Icon, URL: data.URL, }) case *SessionCanvasClosedData: @@ -1513,6 +1519,17 @@ func (s *Session) executeToolAndRespond(requestID, toolName, toolCallID string, TraceContext: ctx, } + // The built-in tool-search tool receives a snapshot of the session's + // currently initialized tools so an override can filter the live catalog + // without issuing its own RPC. Fetch it only for that tool to avoid a + // round-trip on every tool call; a failed fetch leaves the snapshot nil + // rather than failing the tool. + if toolName == toolSearchToolName { + if metadata, mErr := s.RPC.Tools.GetCurrentMetadata(ctx); mErr == nil && metadata != nil { + invocation.AvailableTools = metadata.Tools + } + } + result, err := handler(invocation) if err != nil { errMsg := err.Error() @@ -1542,6 +1559,7 @@ func (s *Session) executeToolAndRespond(requestID, toolName, toolCallID string, TextResultForLlm: textResultForLLM, ToolTelemetry: result.ToolTelemetry, ResultType: &effectiveResultType, + ToolReferences: result.ToolReferences, } if result.Error != "" { rpcResult.Error = &result.Error @@ -1735,7 +1753,7 @@ type SetModelOptions struct { // // Example: // -// if err := session.SetModel(context.Background(), "gpt-4.1", nil); err != nil { +// if err := session.SetModel(context.Background(), "gpt-5.4", nil); err != nil { // log.Printf("Failed to set model: %v", err) // } // if err := session.SetModel(context.Background(), "claude-sonnet-4.6", &SetModelOptions{ReasoningEffort: new("high")}); err != nil { diff --git a/go/session_event_serialization_test.go b/go/session_event_serialization_test.go index 5f8855336c..bd47fdfbe2 100644 --- a/go/session_event_serialization_test.go +++ b/go/session_event_serialization_test.go @@ -145,6 +145,26 @@ func TestSessionEventAgentIDRoundTripsUnknownEvent(t *testing.T) { } } +func TestInternalSessionEventUsesRawFallback(t *testing.T) { + var event SessionEvent + if err := json.Unmarshal([]byte(`{ + "id": "00000000-0000-0000-0000-000000000003", + "timestamp": "2026-01-01T00:00:00Z", + "parentId": null, + "type": "session.memory_changed", + "data": {} + }`), &event); err != nil { + t.Fatalf("failed to unmarshal internal session event: %v", err) + } + + if _, ok := event.Data.(*RawSessionEventData); !ok { + t.Fatalf("expected internal event to use raw session event data, got %T", event.Data) + } + if event.Type() != "session.memory_changed" { + t.Fatalf("expected internal event type to be preserved, got %q", event.Type()) + } +} + func TestRawSessionEventDataWithNilRawMarshalsAsNull(t *testing.T) { event := SessionEvent{ Data: &RawSessionEventData{EventType: "future.event"}, diff --git a/go/session_test.go b/go/session_test.go index 277ea29e3e..d34c34233b 100644 --- a/go/session_test.go +++ b/go/session_test.go @@ -793,6 +793,7 @@ func TestSession_Capabilities(t *testing.T) { CanvasID: "counter", InstanceID: "counter-1", Title: ptr("Counter"), + Icon: ptr("beaker"), Status: ptr("ready"), URL: ptr("https://example.test/counter"), Input: map[string]any{"seed": float64(1)}, @@ -822,6 +823,7 @@ func TestSession_Capabilities(t *testing.T) { CanvasID: "counter", InstanceID: "counter-1", Title: ptr("Counter Updated"), + Icon: ptr("beaker-filled"), Status: ptr("reconnected"), URL: ptr("https://example.test/counter-updated"), Input: map[string]any{"seed": float64(2)}, @@ -838,6 +840,9 @@ func TestSession_Capabilities(t *testing.T) { if open[0].Title == nil || *open[0].Title != "Counter Updated" { t.Fatalf("expected updated title, got %+v", open[0].Title) } + if open[0].Icon == nil || *open[0].Icon != "beaker-filled" { + t.Fatalf("expected updated icon, got %+v", open[0].Icon) + } if open[0].Status == nil || *open[0].Status != "reconnected" { t.Fatalf("expected updated status, got %+v", open[0].Status) } diff --git a/go/types.go b/go/types.go index 1e424514eb..d487d6d8ab 100644 --- a/go/types.go +++ b/go/types.go @@ -20,14 +20,23 @@ const ( // RuntimeConnection describes how a [Client] connects to the Copilot runtime. // -// Construct one with a [StdioConnection], [TCPConnection], or [URIConnection] -// literal and pass it via [ClientOptions.Connection]. When [ClientOptions.Connection] -// is nil, the default is an empty [StdioConnection] (the SDK spawns the bundled -// runtime and communicates over stdin/stdout). +// Construct one with a [StdioConnection], [TCPConnection], [URIConnection], or +// [InProcessConnection] literal and pass it via [ClientOptions.Connection]. When +// [ClientOptions.Connection] is nil, COPILOT_SDK_DEFAULT_CONNECTION may select +// "inprocess" or "stdio"; when unset, the default is an empty [StdioConnection]. type RuntimeConnection interface { runtimeConnection() } +// childProcessConnection is implemented by the connection types that spawn a +// runtime child process ([StdioConnection] and [TCPConnection]). It exposes the +// per-connection environment so the client can resolve and validate it uniformly +// regardless of the specific child-process transport. +type childProcessConnection interface { + RuntimeConnection + connEnv() []string +} + // StdioConnection spawns a runtime child process and communicates over its // stdin/stdout pipes. This is the default when no connection is configured. type StdioConnection struct { @@ -35,10 +44,17 @@ type StdioConnection struct { Path string // Args are extra command-line arguments inserted before SDK-managed args. Args []string + // Env are the environment variables for the runtime process, each of the + // form "KEY=VALUE". When set, these take precedence over + // [ClientOptions.Env]; setting both is rejected. When nil, the client-level + // env (or the current process environment) is used. + Env []string } func (StdioConnection) runtimeConnection() {} +func (c StdioConnection) connEnv() []string { return c.Env } + // TCPConnection spawns a runtime child process that listens on a TCP socket // and connects to it. type TCPConnection struct { @@ -54,10 +70,17 @@ type TCPConnection struct { Path string // Args are extra command-line arguments inserted before SDK-managed args. Args []string + // Env are the environment variables for the runtime process, each of the + // form "KEY=VALUE". When set, these take precedence over + // [ClientOptions.Env]; setting both is rejected. When nil, the client-level + // env (or the current process environment) is used. + Env []string } func (TCPConnection) runtimeConnection() {} +func (c TCPConnection) connEnv() []string { return c.Env } + // URIConnection connects to an already-running runtime at the given URL. // The SDK does not spawn a process in this mode. type URIConnection struct { @@ -71,11 +94,29 @@ type URIConnection struct { func (URIConnection) runtimeConnection() {} +// InProcessConnection hosts the Copilot runtime in-process by loading its native +// runtime library (a Rust cdylib) and driving JSON-RPC over the library's C ABI, +// instead of spawning a runtime child process. +// +// Because the runtime is loaded into the calling process, per-client +// environment, working directory, and telemetry cannot be represented and are +// rejected by [NewClient] (see [ClientOptions]). Set those via the host process +// environment instead, or use a child-process transport ([StdioConnection] / +// [TCPConnection]). +// +// Experimental: the in-process transport is experimental and its API and +// behavior may change in a future release. Build the application with the +// copilot_inprocess build tag to enable this transport. +type InProcessConnection struct { +} + +func (InProcessConnection) runtimeConnection() {} + // ClientOptions configures the [Client]. type ClientOptions struct { // Connection describes how to connect to the Copilot runtime. When nil, - // defaults to an empty [StdioConnection] (spawn the bundled runtime over - // stdio). + // COPILOT_SDK_DEFAULT_CONNECTION may select "inprocess" or "stdio"; + // when unset, defaults to an empty [StdioConnection]. Connection RuntimeConnection // WorkingDirectory is the working directory for the runtime process. // If empty, inherits the current process's working directory. @@ -95,6 +136,12 @@ type ClientOptions struct { // Env are the environment variables for the runtime process (default: // inherits from current process). Each entry is of the form "KEY=VALUE". // If Env contains duplicate keys, only the last value for each key is used. + // + // For child-process transports ([StdioConnection] / [TCPConnection]) the + // per-connection Env, when set, takes precedence over this field; setting + // both is rejected. Env is not supported with [InProcessConnection] (the + // runtime shares this process's single environment block) and is rejected + // by [NewClient]. Env []string // GitHubToken is the GitHub token to use for authentication. // When provided, the token is passed to the runtime via environment @@ -948,6 +995,18 @@ type LargeToolOutputConfig struct { OutputDirectory string `json:"outputDir,omitempty"` } +// ToolSearchConfig allows to configure tool search behavior. +// Tool search defers tools to keep the model's active tool set small. +// To override the tool-search tool's implementation, register a +// [Tool] named "tool_search_tool" with OverridesBuiltInTool set to true. +type ToolSearchConfig struct { + // Controls whether tool search is enabled. + Enabled *bool `json:"enabled,omitempty"` + // DeferThreshold is the tool count above which MCP and external tools are + // deferred behind tool search. When nil, the runtime default (30) applies. + DeferThreshold *int `json:"deferThreshold,omitempty"` +} + // SessionFSCapabilities declares optional provider capabilities. type SessionFSCapabilities struct { // Sqlite indicates whether the provider supports SQLite query/exists operations. @@ -1153,6 +1212,10 @@ type SessionConfig struct { // output exceeding the configured size, the output is written to a temp file // and a reference is returned to the model instead of the full payload. LargeOutput *LargeToolOutputConfig + // ToolSearch overrides the runtime's built-in tool-search behavior, which + // defers rarely used tools behind a searchable index. When nil, the runtime + // default applies. + ToolSearch *ToolSearchConfig // Memory configures the memory feature for the session. When omitted, the // runtime default applies. Memory *MemoryConfiguration @@ -1235,6 +1298,9 @@ type SessionConfig struct { CanvasHandler CanvasHandler `json:"-"` // ExtensionInfo identifies the stable extension providing this session's canvases. ExtensionInfo *ExtensionInfo + // CanvasProvider is the stable identity for a host/SDK connection that + // supplies built-in canvases, so they survive reconnect and CLI restart. + CanvasProvider *CanvasProviderIdentity // ExpAssignments injects ExP assignment ("flight") data for this session, // in the same JSON shape the Copilot CLI fetches from the experimentation // service (CopilotExpAssignmentResponse). When supplied, the runtime feeds @@ -1247,6 +1313,12 @@ type SessionConfig struct { // intended for trusted out-of-process integrators, and is not intended for // general external use. ExpAssignments any + // EnableManagedSettings, when set to true, opts the runtime into + // self-fetching enterprise managed settings (bypass-permissions policy) at + // session bootstrap using the session's GitHubToken. Requires GitHubToken to + // be set; if omitted, the runtime is expected to reject session creation + // (fail-closed). Unset behaves exactly as before. + EnableManagedSettings *bool } // ToolDefer controls whether a tool may be deferred (loaded lazily via tool @@ -1269,6 +1341,11 @@ type Tool struct { // Defer controls whether the tool may be deferred (loaded lazily via tool // search) rather than always pre-loaded. When empty, the runtime decides. Defer ToolDefer `json:"defer,omitempty"` + // Metadata is opaque, host-defined metadata associated with the tool + // definition. Keys are namespaced and not part of the stable public API; + // values are not interpreted and may be recognized to inform host-specific + // behavior. Unknown keys are preserved and round-tripped untouched. + Metadata map[string]any `json:"metadata,omitempty"` // Handler is optional. When nil, the SDK exposes the tool declaration but does // not automatically invoke it. Handler ToolHandler `json:"-"` @@ -1281,6 +1358,14 @@ type ToolInvocation struct { ToolName string Arguments any + // AvailableTools is a snapshot of the session's currently initialized + // tools. The SDK populates it only when this invocation targets the + // built-in tool-search tool ("tool_search_tool"), so a tool-search + // override can rank/filter the live catalog -- including MCP tools + // configured in settings -- without issuing its own RPC. It is nil for + // every other tool invocation. + AvailableTools []rpc.CurrentToolMetadata + // TraceContext carries the W3C Trace Context propagated from the CLI's // execute_tool span. Pass this to OpenTelemetry-aware code so that // child spans created inside the handler are parented to the CLI span. @@ -1300,6 +1385,8 @@ type ToolResult struct { Error string `json:"error,omitempty"` SessionLog string `json:"sessionLog,omitempty"` ToolTelemetry map[string]any `json:"toolTelemetry,omitempty"` + // ToolReferences lists names of tools returned by a tool-search tool. + ToolReferences []string `json:"toolReferences,omitempty"` } // CommandContext provides context about a slash-command invocation. @@ -1596,6 +1683,10 @@ type ResumeSessionConfig struct { // output exceeding the configured size, the output is written to a temp file // and a reference is returned to the model instead of the full payload. LargeOutput *LargeToolOutputConfig + // ToolSearch overrides the runtime's built-in tool-search behavior, which + // defers rarely used tools behind a searchable index. When nil, the runtime + // default applies. + ToolSearch *ToolSearchConfig // Memory configures the memory feature for the session. When omitted, the // runtime default applies. Memory *MemoryConfiguration @@ -1660,6 +1751,9 @@ type ResumeSessionConfig struct { CanvasHandler CanvasHandler `json:"-"` // ExtensionInfo identifies the stable extension providing this session's canvases. ExtensionInfo *ExtensionInfo + // CanvasProvider is the stable identity for a host/SDK connection that + // supplies built-in canvases. See SessionConfig.CanvasProvider. + CanvasProvider *CanvasProviderIdentity // ExpAssignments injects ExP assignment ("flight") data on resume. See // SessionConfig.ExpAssignments. Re-supply on resume so the runtime // re-applies the assignments after a CLI process restart. @@ -1668,6 +1762,10 @@ type ResumeSessionConfig struct { // intended for trusted out-of-process integrators, and is not intended for // general external use. ExpAssignments any + // EnableManagedSettings injects the same opt-in flag on resume. See + // SessionConfig.EnableManagedSettings. Re-supply on resume so the runtime + // re-applies the managed-settings self-fetch after a CLI process restart. + EnableManagedSettings *bool } // ProviderTokenArgs carries the context passed to a [BearerTokenProvider] callback @@ -2109,6 +2207,7 @@ type createSessionRequest struct { DisabledSkills []string `json:"disabledSkills,omitempty"` InfiniteSessions *InfiniteSessionConfig `json:"infiniteSessions,omitempty"` LargeOutput *LargeToolOutputConfig `json:"largeOutput,omitempty"` + ToolSearch *ToolSearchConfig `json:"toolSearch,omitempty"` Memory *MemoryConfiguration `json:"memory,omitempty"` Commands []wireCommand `json:"commands,omitempty"` RequestElicitation *bool `json:"requestElicitation,omitempty"` @@ -2121,7 +2220,9 @@ type createSessionRequest struct { RequestExtensions *bool `json:"requestExtensions,omitempty"` ExtensionSDKPath *string `json:"extensionSdkPath,omitempty"` ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"` + CanvasProvider *CanvasProviderIdentity `json:"canvasProvider,omitempty"` ExpAssignments any `json:"expAssignments,omitempty"` + EnableManagedSettings *bool `json:"enableManagedSettings,omitempty"` Traceparent string `json:"traceparent,omitempty"` Tracestate string `json:"tracestate,omitempty"` } @@ -2198,6 +2299,7 @@ type resumeSessionRequest struct { DisabledSkills []string `json:"disabledSkills,omitempty"` InfiniteSessions *InfiniteSessionConfig `json:"infiniteSessions,omitempty"` LargeOutput *LargeToolOutputConfig `json:"largeOutput,omitempty"` + ToolSearch *ToolSearchConfig `json:"toolSearch,omitempty"` Memory *MemoryConfiguration `json:"memory,omitempty"` Commands []wireCommand `json:"commands,omitempty"` RequestElicitation *bool `json:"requestElicitation,omitempty"` @@ -2210,7 +2312,9 @@ type resumeSessionRequest struct { RequestExtensions *bool `json:"requestExtensions,omitempty"` ExtensionSDKPath *string `json:"extensionSdkPath,omitempty"` ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"` + CanvasProvider *CanvasProviderIdentity `json:"canvasProvider,omitempty"` ExpAssignments any `json:"expAssignments,omitempty"` + EnableManagedSettings *bool `json:"enableManagedSettings,omitempty"` Traceparent string `json:"traceparent,omitempty"` Tracestate string `json:"tracestate,omitempty"` } diff --git a/go/types_test.go b/go/types_test.go index f4132fa3d6..b0349de292 100644 --- a/go/types_test.go +++ b/go/types_test.go @@ -203,6 +203,57 @@ func TestCustomAgentConfig_JSONOmitsNilTools(t *testing.T) { } } +func TestToolResult_JSONIncludesToolReferences(t *testing.T) { + result := ToolResult{ + TextResultForLLM: "found 2 tools", + ResultType: "success", + ToolReferences: []string{"get_weather", "check_status"}, + } + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("failed to marshal ToolResult: %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("failed to unmarshal ToolResult: %v", err) + } + + rawRefs, present := decoded["toolReferences"] + if !present { + t.Fatal("expected toolReferences to be present") + } + refs, ok := rawRefs.([]any) + if !ok { + t.Fatalf("expected toolReferences array, got %T", rawRefs) + } + if len(refs) != 2 || refs[0] != "get_weather" || refs[1] != "check_status" { + t.Errorf("unexpected toolReferences: %v", refs) + } +} + +func TestToolResult_JSONOmitsNilToolReferences(t *testing.T) { + result := ToolResult{ + TextResultForLLM: "ok", + ResultType: "success", + } + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("failed to marshal ToolResult: %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("failed to unmarshal ToolResult: %v", err) + } + + if _, present := decoded["toolReferences"]; present { + t.Errorf("expected toolReferences to be omitted for nil slice, got %v", decoded["toolReferences"]) + } +} + func TestCustomAgentConfig_JSONOmitsModelWhenEmpty(t *testing.T) { cfg := CustomAgentConfig{ Name: "no-model-agent", diff --git a/go/zsession_events.go b/go/zsession_events.go index 24e726f7ad..b3dd66ef77 100644 --- a/go/zsession_events.go +++ b/go/zsession_events.go @@ -19,6 +19,7 @@ type ( AssistantMessageToolRequestType = rpc.AssistantMessageToolRequestType AssistantReasoningData = rpc.AssistantReasoningData AssistantReasoningDeltaData = rpc.AssistantReasoningDeltaData + AssistantServerToolProgressData = rpc.AssistantServerToolProgressData AssistantStreamingDeltaData = rpc.AssistantStreamingDeltaData AssistantToolCallDeltaData = rpc.AssistantToolCallDeltaData AssistantTurnEndData = rpc.AssistantTurnEndData @@ -52,6 +53,7 @@ type ( AttachmentSelectionDetailsStart = rpc.AttachmentSelectionDetailsStart AttachmentType = rpc.AttachmentType AutoApprovalRecommendation = rpc.AutoApprovalRecommendation + AutoModeResolvedReasoningBucket = rpc.AutoModeResolvedReasoningBucket AutoModeSwitchCompletedData = rpc.AutoModeSwitchCompletedData AutoModeSwitchRequestedData = rpc.AutoModeSwitchRequestedData AutoModeSwitchResponse = rpc.AutoModeSwitchResponse @@ -103,10 +105,12 @@ type ( GitHubRepoRef = rpc.GitHubRepoRef HandoffRepository = rpc.HandoffRepository HandoffSourceType = rpc.HandoffSourceType + HeaderEntry = rpc.HeaderEntry HookEndData = rpc.HookEndData HookEndError = rpc.HookEndError HookProgressData = rpc.HookProgressData HookStartData = rpc.HookStartData + ManagedSettingsResolvedSource = rpc.ManagedSettingsResolvedSource MCPAppToolCallCompleteData = rpc.MCPAppToolCallCompleteData MCPAppToolCallCompleteError = rpc.MCPAppToolCallCompleteError MCPAppToolCallCompleteToolMeta = rpc.MCPAppToolCallCompleteToolMeta @@ -117,15 +121,19 @@ type ( MCPHeadersRefreshRequiredReason = rpc.MCPHeadersRefreshRequiredReason MCPOauthCompletedData = rpc.MCPOauthCompletedData MCPOauthCompletionOutcome = rpc.MCPOauthCompletionOutcome + MCPOauthHTTPResponse = rpc.MCPOauthHTTPResponse MCPOauthRequestReason = rpc.MCPOauthRequestReason MCPOauthRequiredData = rpc.MCPOauthRequiredData MCPOauthRequiredStaticClientConfig = rpc.MCPOauthRequiredStaticClientConfig MCPOauthRequiredStaticClientConfigGrantType = rpc.MCPOauthRequiredStaticClientConfigGrantType MCPOauthWwwAuthenticateParams = rpc.MCPOauthWwwAuthenticateParams + MCPPromptsListChangedData = rpc.MCPPromptsListChangedData + MCPResourcesListChangedData = rpc.MCPResourcesListChangedData MCPServersLoadedServer = rpc.MCPServersLoadedServer MCPServerSource = rpc.MCPServerSource MCPServerStatus = rpc.MCPServerStatus MCPServerTransport = rpc.MCPServerTransport + MCPToolsListChangedData = rpc.MCPToolsListChangedData ModelCallFailureBadRequestKind = rpc.ModelCallFailureBadRequestKind ModelCallFailureData = rpc.ModelCallFailureData ModelCallFailureRequestFingerprint = rpc.ModelCallFailureRequestFingerprint @@ -198,6 +206,7 @@ type ( ReasoningSummary = rpc.ReasoningSummary SamplingCompletedData = rpc.SamplingCompletedData SamplingRequestedData = rpc.SamplingRequestedData + SessionAutoModeResolvedData = rpc.SessionAutoModeResolvedData SessionAutopilotObjectiveChangedData = rpc.SessionAutopilotObjectiveChangedData SessionBackgroundTasksChangedData = rpc.SessionBackgroundTasksChangedData SessionBinaryAssetData = rpc.SessionBinaryAssetData @@ -226,6 +235,7 @@ type ( SessionLimitsExhaustedRequestedData = rpc.SessionLimitsExhaustedRequestedData SessionLimitsExhaustedResponse = rpc.SessionLimitsExhaustedResponse SessionLimitsExhaustedResponseAction = rpc.SessionLimitsExhaustedResponseAction + SessionManagedSettingsResolvedData = rpc.SessionManagedSettingsResolvedData SessionMCPServersLoadedData = rpc.SessionMCPServersLoadedData SessionMCPServerStatusChangedData = rpc.SessionMCPServerStatusChangedData SessionMode = rpc.SessionMode @@ -372,6 +382,9 @@ const ( AutoApprovalRecommendationError = rpc.AutoApprovalRecommendationError AutoApprovalRecommendationExcluded = rpc.AutoApprovalRecommendationExcluded AutoApprovalRecommendationRequireApproval = rpc.AutoApprovalRecommendationRequireApproval + AutoModeResolvedReasoningBucketHigh = rpc.AutoModeResolvedReasoningBucketHigh + AutoModeResolvedReasoningBucketLow = rpc.AutoModeResolvedReasoningBucketLow + AutoModeResolvedReasoningBucketMedium = rpc.AutoModeResolvedReasoningBucketMedium AutoModeSwitchResponseNo = rpc.AutoModeSwitchResponseNo AutoModeSwitchResponseYes = rpc.AutoModeSwitchResponseYes AutoModeSwitchResponseYesAlways = rpc.AutoModeSwitchResponseYesAlways @@ -414,6 +427,9 @@ const ( ExtensionsLoadedExtensionStatusStarting = rpc.ExtensionsLoadedExtensionStatusStarting HandoffSourceTypeLocal = rpc.HandoffSourceTypeLocal HandoffSourceTypeRemote = rpc.HandoffSourceTypeRemote + ManagedSettingsResolvedSourceDevice = rpc.ManagedSettingsResolvedSourceDevice + ManagedSettingsResolvedSourceNone = rpc.ManagedSettingsResolvedSourceNone + ManagedSettingsResolvedSourceServer = rpc.ManagedSettingsResolvedSourceServer MCPHeadersRefreshCompletedOutcomeHeaders = rpc.MCPHeadersRefreshCompletedOutcomeHeaders MCPHeadersRefreshCompletedOutcomeNone = rpc.MCPHeadersRefreshCompletedOutcomeNone MCPHeadersRefreshCompletedOutcomeTimeout = rpc.MCPHeadersRefreshCompletedOutcomeTimeout @@ -508,6 +524,7 @@ const ( SessionEventTypeAssistantMessageStart = rpc.SessionEventTypeAssistantMessageStart SessionEventTypeAssistantReasoning = rpc.SessionEventTypeAssistantReasoning SessionEventTypeAssistantReasoningDelta = rpc.SessionEventTypeAssistantReasoningDelta + SessionEventTypeAssistantServerToolProgress = rpc.SessionEventTypeAssistantServerToolProgress SessionEventTypeAssistantStreamingDelta = rpc.SessionEventTypeAssistantStreamingDelta SessionEventTypeAssistantToolCallDelta = rpc.SessionEventTypeAssistantToolCallDelta SessionEventTypeAssistantTurnEnd = rpc.SessionEventTypeAssistantTurnEnd @@ -534,12 +551,16 @@ const ( SessionEventTypeMCPHeadersRefreshRequired = rpc.SessionEventTypeMCPHeadersRefreshRequired SessionEventTypeMCPOauthCompleted = rpc.SessionEventTypeMCPOauthCompleted SessionEventTypeMCPOauthRequired = rpc.SessionEventTypeMCPOauthRequired + SessionEventTypeMCPPromptsListChanged = rpc.SessionEventTypeMCPPromptsListChanged + SessionEventTypeMCPResourcesListChanged = rpc.SessionEventTypeMCPResourcesListChanged + SessionEventTypeMCPToolsListChanged = rpc.SessionEventTypeMCPToolsListChanged SessionEventTypeModelCallFailure = rpc.SessionEventTypeModelCallFailure SessionEventTypePendingMessagesModified = rpc.SessionEventTypePendingMessagesModified SessionEventTypePermissionCompleted = rpc.SessionEventTypePermissionCompleted SessionEventTypePermissionRequested = rpc.SessionEventTypePermissionRequested SessionEventTypeSamplingCompleted = rpc.SessionEventTypeSamplingCompleted SessionEventTypeSamplingRequested = rpc.SessionEventTypeSamplingRequested + SessionEventTypeSessionAutoModeResolved = rpc.SessionEventTypeSessionAutoModeResolved SessionEventTypeSessionAutopilotObjectiveChanged = rpc.SessionEventTypeSessionAutopilotObjectiveChanged SessionEventTypeSessionBackgroundTasksChanged = rpc.SessionEventTypeSessionBackgroundTasksChanged SessionEventTypeSessionBinaryAsset = rpc.SessionEventTypeSessionBinaryAsset @@ -562,6 +583,7 @@ const ( SessionEventTypeSessionInfo = rpc.SessionEventTypeSessionInfo SessionEventTypeSessionLimitsExhaustedCompleted = rpc.SessionEventTypeSessionLimitsExhaustedCompleted SessionEventTypeSessionLimitsExhaustedRequested = rpc.SessionEventTypeSessionLimitsExhaustedRequested + SessionEventTypeSessionManagedSettingsResolved = rpc.SessionEventTypeSessionManagedSettingsResolved SessionEventTypeSessionMCPServersLoaded = rpc.SessionEventTypeSessionMCPServersLoaded SessionEventTypeSessionMCPServerStatusChanged = rpc.SessionEventTypeSessionMCPServerStatusChanged SessionEventTypeSessionModeChanged = rpc.SessionEventTypeSessionModeChanged diff --git a/java/README.md b/java/README.md index 334db459f5..72b3f0ea6e 100644 --- a/java/README.md +++ b/java/README.md @@ -39,7 +39,7 @@ Replace `${copilot.sdk.version}` with the latest release from Maven Central. ### Gradle ```groovy -implementation 'com.github:copilot-sdk-java:1.0.6-preview.1-01' +implementation 'com.github:copilot-sdk-java:1.0.7-preview.3-01' ``` #### Snapshot Builds @@ -58,7 +58,7 @@ Snapshot builds of the next development version are published to Maven Central S com.github copilot-sdk-java - 1.0.7-SNAPSHOT + 1.0.8-preview.3-SNAPSHOT ``` @@ -67,7 +67,7 @@ Snapshot builds of the next development version are published to Maven Central S Replace `${copilot.sdk.version}` with the latest release from Maven Central. ```groovy -implementation 'com.github:copilot-sdk-java:1.0.6-preview.1-01-SNAPSHOT' +implementation 'com.github:copilot-sdk-java:1.0.7-preview.3-01-SNAPSHOT' ``` ## Quick Start diff --git a/java/docs/adr/adr-005-tool-definition.md b/java/docs/adr/adr-005-tool-definition.md index e1b4dcc764..dc1eb36143 100644 --- a/java/docs/adr/adr-005-tool-definition.md +++ b/java/docs/adr/adr-005-tool-definition.md @@ -181,14 +181,45 @@ final class MyTools$$CopilotToolMeta { Phase phase = invocation.getArgumentsAs(Phase.class); return CompletableFuture.completedFuture( instance.setCurrentPhase(phase)); - }, null, null, null) + }, null, null, null, null) ); } } ``` +The trailing constructor arguments are `overridesBuiltInTool`, `skipPermission`, `defer`, and `metadata` — all `null` here because none were set on the annotation. + At runtime, `ToolDefinition.fromObject(myTools)` loads the generated `$$CopilotToolMeta` class — zero reflection, zero dependency on `-parameters`. +### Host-defined metadata + +`@CopilotTool` also accepts an opaque `metadata` bag via nested annotations. Because annotation members can't express arbitrary maps, the representation is deliberately shallow: each entry maps a namespaced key to a boolean, a string, or a one-level map of named boolean flags. + +```java +@CopilotTool( + value = "Reports phase", + metadata = { + @CopilotTool.MetadataEntry( + key = "github.com/copilot:safeForTelemetry", + value = @CopilotTool.MetadataValue(flags = { + @CopilotTool.MetadataFlag(name = "name", value = true), + @CopilotTool.MetadataFlag(name = "inputsNames", value = false) + })) + }) +public String reportPhase(@CopilotToolParam("Phase") String phase) { + return phase; +} +``` + +The processor emits this as the `metadata` constructor argument: + +```java +Map.of("github.com/copilot:safeForTelemetry", + Map.of("name", true, "inputsNames", false)) +``` + +For richer values (numbers, arrays, deeper nesting), use the programmatic `ToolDefinition.createWithMetadata(...)` / `ToolDefinition.metadata(...)` API instead. + ### Compile-time validation Because the processor has full access to the source AST, it can emit compile errors for: diff --git a/java/docs/adr/adr-007-native-bundling-strategy.md b/java/docs/adr/adr-007-native-bundling-strategy.md index d2a76d161f..1322e199ef 100644 --- a/java/docs/adr/adr-007-native-bundling-strategy.md +++ b/java/docs/adr/adr-007-native-bundling-strategy.md @@ -1,4 +1,4 @@ -# ADR-007: DRAFT: Native runtime bundling strategy — per-platform classifier JARs +# ADR-007: Native runtime bundling strategy — per-platform classifier JARs ## Context and Problem Statement @@ -133,7 +133,7 @@ The SDK ships a minimal placeholder that detects the current platform at runtime ## Decision Outcome -**Chosen: Option 2 — per-platform classifier JARs.** +**Chosen: Option 2 — per-platform classifier JARs and Option 1 - monolithic jar. Use `maven-assembly-plugin` to allow the creation of the monolithic jar.** ### Rationale @@ -149,6 +149,187 @@ The SDK ships a minimal placeholder that detects the current platform at runtime 6. **Option 3 remains composable.** A download-on-demand fallback can be layered on top of Option 2 for users who prefer it without changing the primary distribution model. The coordination artifact can attempt classpath lookup first, then fall back to a cached download if no matching classifier JAR is present. +7. See section [How can we do Option 2 and Option 1](#how-can-we-do-option-2-and-option-1) for more details. + +## Binding technology: JNA over Panama FFM + +A secondary decision within the scope of this ADR is *how* the coordination artifact calls the C ABI entry points once the correct `runtime.node` binary has been loaded. Two candidates were considered: [JNA](#references) and the [Foreign Function & Memory API](#references) (FFM, the product of [Project Panama](#references), final since Java 22 via [JEP 454](#references)). + +**Chosen: JNA.** FFM was considered and deliberately deferred, for the following reasons: + +1. **Java baseline.** The SDK supports Java 17, where FFM does not exist (it finalized in Java 22). A JNA-based binding is therefore required regardless; adopting FFM today would mean maintaining two parallel binding implementations, not replacing one with the other. + +2. **Consumer-side configuration burden.** FFM downcalls and upcalls are restricted operations under the JDK's integrity-by-default direction ([JEP 472](#references)). An FFM-based SDK would require every consumer to grant native access explicitly — `--enable-native-access=` (or `ALL-UNNAMED` for classpath applications) on the launcher, or an `Enable-Native-Access` manifest attribute. JNA requires no consumer-side configuration today. For an SDK, this flag becomes every downstream application's problem and a predictable source of support issues. (JNA is on the same enforcement trajectory eventually, as it uses JNI internally; this consideration buys time, not immunity.) + +3. **No realizable performance benefit.** FFM's principal advantage over JNA is the elimination of per-call reflective marshalling overhead. The C ABI surface here is a fixed set of ~12 entry points carrying JSON-RPC strings; JSON serialization/deserialization cost dominates the call path, and call frequency is bounded by agent-interaction rates rather than tight loops. The latency difference between JNA and FFM is expected to be unmeasurable in end-to-end SDK usage. This calculus would change only if the transport moved to a high-frequency or shared-memory framing model. + +4. **Upcall lifetime complexity.** The transport is bidirectional: the runtime delivers JSON-RPC responses and server-initiated requests back into Java from native threads. JNA's `Callback` mechanism handles foreign-thread attachment with well-established semantics. FFM upcall stubs require explicit `Arena` lifetime management, where a stub whose arena is closed while the Rust side still holds the function pointer results in a JVM crash. This shifts lifetime reasoning that JNA encapsulates onto the binding layer. + +5. **GraalVM native-image maturity.** JNA's behavior under GraalVM native-image is well established with mature reachability metadata. FFM support in native-image (particularly for upcalls) is newer and varies by GraalVM release. Plausible SDK consumers (e.g., Quarkus/Micronaut-based CLI tools) compile to native images, so this is a compatibility surface the SDK should not destabilize without verification. + +6. **FFM's safety advantages do not apply to this ABI shape.** FFM's `MemorySegment` bounds and lifetime checking pays off when Java code performs structural manipulation of native memory. This surface passes strings through a fixed transport; there is little structural memory work to make safe. + +### Preserving the FFM migration path + +FFM is regarded as the likely eventual binding technology: the JEP 472 endgame applies enforcement pressure to JNA as well, and a ~12-function stable C ABI makes a future migration inexpensive. To keep that path open at low cost: + +- The binding layer is abstracted behind a small internal interface (native load + downcall + upcall registration), so that an FFM implementation can be introduced later — for example, as a multi-release JAR selecting FFM on Java 22+ — without changes to the transport or API layers. +- The decision should be revisited when (a) the SDK's minimum Java baseline moves past 17, or (b) JDK releases begin enforcing `--illegal-native-access=deny` by default, whichever comes first. + +## How can we do Option 2 and Option 1 + +## How it works: classpath resource convention + platform detection + +### 1. Each classifier JAR uses a well-known resource path + +Each per-platform JAR (`copilot-sdk-java-runtime:VERSION:darwin-arm64`, etc.) places its binary under a deterministic path inside the JAR: + +``` +native/darwin-arm64/runtime.node +native/darwin-arm64/platform.properties +``` + +When `maven-assembly-plugin` creates the uber-jar, it unpacks all dependencies and merges them. The resulting uber-jar contains: + +``` +com/github/copilot/sdk/... (Java classes) +native/linux-x64/runtime.node +native/linux-arm64/runtime.node +native/linuxmusl-x64/runtime.node +native/linuxmusl-arm64/runtime.node +native/darwin-x64/runtime.node +native/darwin-arm64/runtime.node +native/win32-x64/runtime.node +native/win32-arm64/runtime.node +``` + +### 2. The coordination artifact selects at runtime via `getResourceAsStream` + +```java +public class NativeRuntimeLoader { + + public Path loadRuntime() { + String classifier = detectPlatformClassifier(); + String resourcePath = "native/" + classifier + "/runtime.node"; + + try (InputStream in = getClass().getClassLoader() + .getResourceAsStream(resourcePath)) { + if (in == null) { + throw new UnsupportedOperationException( + "No native runtime for platform: " + classifier); + } + Path cached = getCachePath(classifier); + if (!Files.exists(cached)) { + Files.createDirectories(cached.getParent()); + Files.copy(in, cached); + // Make executable on Unix + cached.toFile().setExecutable(true); + } + return cached; + } + } + + private String detectPlatformClassifier() { + String os = normalizeOs(System.getProperty("os.name")); + String arch = normalizeArch(System.getProperty("os.arch")); + String libc = "linux".equals(os) ? detectLinuxLibc() : ""; + + // Produces: "linux-x64", "linuxmusl-arm64", "darwin-arm64", "win32-x64", etc. + return (libc.isEmpty() ? os : os + libc) + "-" + arch; + } + + private String detectLinuxLibc() { + // Read ELF PT_INTERP from /proc/self/exe + // If interpreter contains "/ld-musl-" → "musl" + // Otherwise → "" (glibc is the default/unmarked case for "linux-") + // ... + } + + private Path getCachePath(String classifier) { + String version = getClass().getPackage().getImplementationVersion(); + return Path.of(System.getProperty("user.home"), + ".copilot", "runtime-cache", version, classifier, "runtime.node"); + } +} +``` + +### 3. JNA loads from the extracted path + +Once extracted to a known filesystem path, JNA loads it directly: + +```java +NativeLibrary lib = NativeLibrary.getInstance(extractedPath.toString()); +// Or via a mapped interface: +CopilotRuntime runtime = Native.load(extractedPath.toString(), CopilotRuntime.class); +``` + +### Key insight: the same code works in both modes + +The beauty is that `getResourceAsStream("native/darwin-arm64/runtime.node")` works identically whether: + +- The native lives in a **separate classifier JAR** on the classpath (normal dev dependency), OR +- It's been **merged into an uber-jar** by `maven-assembly-plugin` + +The classloader doesn't care which JAR file the resource came from — it searches the entire classpath. This means **zero code changes** between the two consumption models. + +--- + +## Assembly plugin configuration (consumer-side) + +A consumer building a portable uber-jar would configure: + +```xml + + maven-assembly-plugin + + + jar-with-dependencies + + + +``` + +With all classifier JARs declared as dependencies: + +```xml + + + com.github + copilot-sdk-java + ${copilot.version} + + + + com.github + copilot-sdk-java-runtime + ${copilot.version} + linux-x64 + + + com.github + copilot-sdk-java-runtime + ${copilot.version} + darwin-arm64 + + + +``` + +--- + +## Why this works cleanly + +| Concern | How it's handled | +|---------|-----------------| +| No resource path collisions | Each platform has its own subdirectory (`native//`) | +| Extraction only happens once | Cached to `~/.copilot/runtime-cache///` | +| Works without uber-jar too | Same `getResourceAsStream` call — classloader finds it in the separate JAR | +| Subset selection | Consumer declares only the classifiers they need; missing platforms get a clear error at runtime | +| JNA loading | `NativeLibrary.getInstance(path)` loads from an absolute filesystem path after extraction — no JNA platform-detection magic needed | + +The pattern is identical to how DJL's `LibUtils.loadLibrary()` works — detect platform, construct resource path, extract if needed, load via absolute path. + + ## Consequences - A new Maven module (`copilot-sdk-java-runtime` or similar) is introduced to hold the per-platform native JARs. The existing `copilot-sdk-java` coordination artifact depends on it. @@ -156,7 +337,7 @@ The SDK ships a minimal placeholder that detects the current platform at runtime 1. Detects OS, architecture, and Linux libc variant deterministically as described above. 2. Locates the matching `runtime.node` binary on the classpath (via `getResourceAsStream` from the classifier JAR). 3. Extracts the binary to a temporary or cached location (e.g., `~/.copilot/runtime-cache/`) if not already present. - 4. Loads it via [JNA](#references) using the C ABI entry points. + 4. Loads it via [JNA](#references) using the C ABI entry points, per the [binding technology decision](#binding-technology-jna-over-panama-ffm) above. The JNA-specific code is confined behind an internal binding interface to preserve a future FFM migration path. - The release pipeline for `github/copilot-agent-runtime` must produce the per-platform `runtime.node` binaries as inputs to the Java SDK publish workflow. The per-platform `pkg-tarballs-` artifacts from the `publish-cli.yml` workflow are the authoritative source. - Each release of `copilot-sdk-java` publishes 6 (or 8) classifier JARs to Maven Central alongside the coordination JAR. - The version of the bundled `runtime.node` is recorded in the coordination JAR's manifest and queryable at runtime, enabling diagnostics and mismatch detection. @@ -183,6 +364,10 @@ The SDK ships a minimal placeholder that detects the current platform at runtime | **glibc** (GNU C Library) | The standard C runtime library on most mainstream Linux distributions (Debian, Ubuntu, RHEL, Fedora, SLES). Binaries linked against glibc require the same version or newer to be present at runtime. The `runtime.node` glibc build requires glibc ≥ 2.28. | https://www.gnu.org/software/libc/ | | **musl libc** | An alternative C standard library optimised for static linking and used as the default libc on Alpine Linux. Not binary-compatible with glibc; a separate `runtime.node` build is required. | https://musl.libc.org/ | | **MSVC CRT** (Microsoft Visual C++ Runtime) | The C runtime library shipped with Visual Studio. When compiled with `+crt-static` (as `runtime.node` is on Windows), it is statically linked into the binary and the end-user does not need to install the Visual C++ Redistributable. | https://learn.microsoft.com/en-us/cpp/c-runtime-library/c-run-time-library-reference | +| **Project Panama** | The OpenJDK project that produced the Foreign Function & Memory API as the modern, supported replacement for JNI-based native interop. | https://openjdk.org/projects/panama/ | +| **FFM** (Foreign Function & Memory API) | The `java.lang.foreign` API for calling native functions and managing native memory from Java, finalized in Java 22. Considered and deferred as the binding technology for this SDK; see [Binding technology](#binding-technology-jna-over-panama-ffm). | https://docs.oracle.com/en/java/javase/22/core/foreign-function-and-memory-api.html | +| **JEP 454** | The JDK Enhancement Proposal that finalized the FFM API in Java 22. | https://openjdk.org/jeps/454 | +| **JEP 472** | "Prepare to Restrict the Use of JNI" — part of the JDK's integrity-by-default direction under which native access (via JNI or FFM) requires explicit consumer opt-in (`--enable-native-access`). Drives both the FFM configuration-burden concern and the expectation that JNA itself will eventually require the same opt-in. | https://openjdk.org/jeps/472 | | **DJL** (Deep Java Library) | Amazon's open-source Java framework for ML inference, used here as a reference for the per-platform classifier JAR distribution pattern. Its PyTorch native artifacts (`pytorch-native-cpu-*-.jar`) are the direct model for the proposed `copilot-sdk-java-runtime:VERSION:` artifacts. | https://djl.ai/ | | **os-maven-plugin** | A Maven extension that detects the current OS and architecture and exposes them as properties (e.g., `${os.detected.classifier}`) so that `` values can be resolved at build time rather than hardcoded. | https://github.com/trustin/os-maven-plugin | | **ONNX Runtime** | Microsoft's cross-platform ML inference runtime, used in this ADR as the size comparable for a monolithic all-platform JAR (~130 MB, Option 1). | https://onnxruntime.ai/ | diff --git a/java/jbang-example.java b/java/jbang-example.java index 9bb83457ad..abc1e0b02e 100644 --- a/java/jbang-example.java +++ b/java/jbang-example.java @@ -1,5 +1,5 @@ ///usr/bin/env jbang "$0" "$@" ; exit $? -//DEPS com.github:copilot-sdk-java:1.0.6-preview.1-01 +//DEPS com.github:copilot-sdk-java:1.0.7-preview.3-01 import com.github.copilot.CopilotClient; import com.github.copilot.generated.AssistantMessageEvent; import com.github.copilot.generated.SessionUsageInfoEvent; diff --git a/java/pom.xml b/java/pom.xml index 30be279b0e..59e768a052 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ com.github copilot-sdk-java - 1.0.7-SNAPSHOT + 1.0.8-preview.3-SNAPSHOT jar GitHub Copilot SDK :: Java @@ -86,7 +86,7 @@ DO NOT EDIT MANUALLY. Updated by the update-copilot-dependency workflow. --> - ^1.0.69 + ^1.0.71 diff --git a/java/scripts/codegen/java.ts b/java/scripts/codegen/java.ts index 7c2a5cebea..1e5ff1d543 100644 --- a/java/scripts/codegen/java.ts +++ b/java/scripts/codegen/java.ts @@ -21,6 +21,12 @@ const REPO_ROOT = path.resolve(__dirname, "../.."); /** Event types to exclude from generation (internal/legacy types) */ const EXCLUDED_EVENT_TYPES = new Set(["session.import_legacy"]); +function isSchemaInternal(schema: JSONSchema7 | null | undefined): boolean { + return typeof schema === "object" && + schema !== null && + (schema as Record).visibility === "internal"; +} + const AUTO_GENERATED_HEADER = `// AUTO-GENERATED FILE - DO NOT EDIT`; const GENERATED_FROM_SESSION_EVENTS = `// Generated from: session-events.schema.json`; const GENERATED_FROM_API = `// Generated from: api.schema.json`; @@ -725,7 +731,7 @@ function extractEventVariants(schema: JSONSchema7): EventVariant[] { deprecated: (variant as unknown as Record).deprecated === true, }; }) - .filter((v) => !EXCLUDED_EVENT_TYPES.has(v.typeName)); + .filter((v) => !EXCLUDED_EVENT_TYPES.has(v.typeName) && !isSchemaInternal(v.dataSchema)); } async function generateSessionEvents(schemaPath: string): Promise { diff --git a/java/scripts/codegen/package-lock.json b/java/scripts/codegen/package-lock.json index bb3df20f6b..35cb42191d 100644 --- a/java/scripts/codegen/package-lock.json +++ b/java/scripts/codegen/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "copilot-sdk-java-codegen", "dependencies": { - "@github/copilot": "^1.0.69", + "@github/copilot": "^1.0.71", "json-schema": "^0.4.0", "tsx": "^4.22.4" } @@ -428,9 +428,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.69.tgz", - "integrity": "sha512-sri6PKtRQu7mfok3eV505fmYo6M6PGZpB1vd+QbMEm2rEZaKE2+wJGCAtkpQNKfAWBvvoW/C94IaeFyyulUvqQ==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.71.tgz", + "integrity": "sha512-F3axBi+sXSLYDJbxCBW36bM6MYKNC2rlyAf3Ivo/MjiHKKJW7j5AmaR1IRYS9Gt8r9mxOwWFM1cJFA+CuLaR8g==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -439,20 +439,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.69", - "@github/copilot-darwin-x64": "1.0.69", - "@github/copilot-linux-arm64": "1.0.69", - "@github/copilot-linux-x64": "1.0.69", - "@github/copilot-linuxmusl-arm64": "1.0.69", - "@github/copilot-linuxmusl-x64": "1.0.69", - "@github/copilot-win32-arm64": "1.0.69", - "@github/copilot-win32-x64": "1.0.69" + "@github/copilot-darwin-arm64": "1.0.71", + "@github/copilot-darwin-x64": "1.0.71", + "@github/copilot-linux-arm64": "1.0.71", + "@github/copilot-linux-x64": "1.0.71", + "@github/copilot-linuxmusl-arm64": "1.0.71", + "@github/copilot-linuxmusl-x64": "1.0.71", + "@github/copilot-win32-arm64": "1.0.71", + "@github/copilot-win32-x64": "1.0.71" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.69.tgz", - "integrity": "sha512-LpaVS2o21BbYTFUlkqdwUJXqE0l1Lp50Cb/EHru3z+5n5x7zNUU5IlPxdMg5gHMVw78clnMn2zSJbe5hYFyDDQ==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.71.tgz", + "integrity": "sha512-mEWzyqbqRAWgyU7i2uuSRoVPx/TwaFQX0nZmw0bc30aJ0BnO7cy2kYQyCHw8ykmf/tfxT0xauZ6k0BOFmWizzQ==", "cpu": [ "arm64" ], @@ -466,9 +466,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.69.tgz", - "integrity": "sha512-QL5bsmnYqliBVIEE8RY91e6yAr39c2TNvJc/5IzoaKMlQ9MCrD9xakBJwd7LOS0SiOwRE4fqn8pUA4L1+UU5fg==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.71.tgz", + "integrity": "sha512-Md9yEg406OBVBx3w4PeEj62TubulVLBcHleqmCoOoUmPgUxPZotUbrqz3rtbzADbXfrrD7JWvVsbd2UiNL194w==", "cpu": [ "x64" ], @@ -482,9 +482,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.69.tgz", - "integrity": "sha512-SOibp0I6APLoY2KkccbNOISpbhE3lp41s9fM3eANqfdoHQLsgBopfk4ruBP8k/DzjQA6t4MrbV8v6dLixAqv3w==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.71.tgz", + "integrity": "sha512-ykLJYOqBj3jRB5IJCDugLClAqbr7DmtTbUjlNY7+Jdq/n6i+d7xUQGclf1IWL5gnxbGQVAf+zkToD+sRM389Kg==", "cpu": [ "arm64" ], @@ -498,9 +498,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.69.tgz", - "integrity": "sha512-URT7UaR3l1VAAtEpbKjybup2vxHTMSfDTVlu0jinu1O2nWxFOeQ9N4BQgRdcCB7wDu3N8Yz47o3fV1Gob6Z1Yw==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.71.tgz", + "integrity": "sha512-pC0FNHG+BBwZd6yZlM85kkAGN+uJhM6o+THi76N2GnnSxmw7+remb1mvYxdgRVbdCm+LBUIbCKRWJLuMwrfb6A==", "cpu": [ "x64" ], @@ -514,9 +514,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.69.tgz", - "integrity": "sha512-Y6DY9RkyWV6l+S0cNz4d+AkUa38goNV5StQH3j8OoVrndK3+RgQeUjG1XpJ//PzXEXi4vQ0cZuc9XzFj/TY7tw==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.71.tgz", + "integrity": "sha512-hBmDljFTjacxqZTasCEy43H8EIzuXB/hHEBBCMFjhB9J00nIxsO6Dh0woTifKpx7knTYZdpTjjca3D0pAoZlUA==", "cpu": [ "arm64" ], @@ -530,9 +530,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.69.tgz", - "integrity": "sha512-h0aNgUWRu70Tgp4/7Vdqa/4XKJi96wP32R1PoKeXkz1QNxb6i/IQxhoCjjS+xFon9e2kefKdF8pDrDtUJjsiBQ==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.71.tgz", + "integrity": "sha512-CfTXU8pa5dxRz22xQzoi3TiG1PJo9+WR8PRDiPSdkIBSyPJ1NvX87DJmfXjTgeAfR+wkjt/p0keDCaBBVhNmUA==", "cpu": [ "x64" ], @@ -546,9 +546,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.69.tgz", - "integrity": "sha512-7DMoC1uwt01yZf48xq8MgKdnHacXabfsGsOizbgmrlZMfvQQofrF55x1Efu3BKsEKFGRl4fCLjJHJYCz/nWsHg==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.71.tgz", + "integrity": "sha512-+HI1DokixXhHUahj06Fw67ZAigBuXKC58BFma4UJOGrQsDgwOSbqeTQHCw6vuymzjKlg3sactfsCUTaefkjscQ==", "cpu": [ "arm64" ], @@ -562,9 +562,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.69.tgz", - "integrity": "sha512-5uD1/k6oVzqRhiS9jLrySGa20HjItqUJsaMe+bV6eYT7zQPF/74xihEjxMm3/bJcATW2SesiYQxyNzTcKm3qRA==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.71.tgz", + "integrity": "sha512-02kXOBd9CwBbCaztuf71WYWn+uGapCuiaasomN4tcMH3HBVZ4gi3J0ZUoRcgcS80xh81uQyeBHbnUKzb/RE/9A==", "cpu": [ "x64" ], diff --git a/java/scripts/codegen/package.json b/java/scripts/codegen/package.json index a8e592d969..20f742fdce 100644 --- a/java/scripts/codegen/package.json +++ b/java/scripts/codegen/package.json @@ -7,7 +7,7 @@ "generate:java": "tsx java.ts" }, "dependencies": { - "@github/copilot": "^1.0.69", + "@github/copilot": "^1.0.71", "json-schema": "^0.4.0", "tsx": "^4.22.4" } diff --git a/java/src/generated/java/com/github/copilot/generated/AssistantMessageEvent.java b/java/src/generated/java/com/github/copilot/generated/AssistantMessageEvent.java index 4fce0133e9..fdbdc0afd7 100644 --- a/java/src/generated/java/com/github/copilot/generated/AssistantMessageEvent.java +++ b/java/src/generated/java/com/github/copilot/generated/AssistantMessageEvent.java @@ -59,6 +59,8 @@ public record AssistantMessageEventData( @JsonProperty("interactionId") String interactionId, /** GitHub request tracing ID (x-github-request-id header) for correlating with server-side logs */ @JsonProperty("requestId") String requestId, + /** Client-minted request id (x-request-id header) echoed by the server. Distinct from requestId (x-github-request-id) and serviceRequestId (x-copilot-service-request-id). */ + @JsonProperty("clientRequestId") String clientRequestId, /** Copilot service request ID (x-copilot-service-request-id header) for CAPI log correlation */ @JsonProperty("serviceRequestId") String serviceRequestId, /** Provider's completion / response identifier; shared across all chunks of a single API call. Used to group multi-chunk assistant utterances. */ diff --git a/java/src/generated/java/com/github/copilot/generated/AssistantServerToolProgressEvent.java b/java/src/generated/java/com/github/copilot/generated/AssistantServerToolProgressEvent.java new file mode 100644 index 0000000000..462a573b3e --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/AssistantServerToolProgressEvent.java @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: session-events.schema.json + +package com.github.copilot.generated; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.annotation.processing.Generated; + +/** + * Session event "assistant.server_tool_progress". Live progress signal for a provider-hosted server tool (e.g. hosted web search) while it runs, before the finalized serverTools envelope lands on the terminal assistant.message + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public final class AssistantServerToolProgressEvent extends SessionEvent { + + @Override + public String getType() { return "assistant.server_tool_progress"; } + + @JsonProperty("data") + private AssistantServerToolProgressEventData data; + + public AssistantServerToolProgressEventData getData() { return data; } + public void setData(AssistantServerToolProgressEventData data) { this.data = data; } + + /** Data payload for {@link AssistantServerToolProgressEvent}. */ + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public record AssistantServerToolProgressEventData( + /** Position of the hosted tool call in the response output. Stable across the call's lifecycle events (unlike the provider's per-event item id, which CAPI rotates), so the host keys the live in-progress row on it. */ + @JsonProperty("outputIndex") Long outputIndex, + /** Kind of hosted server tool that is running. Only `web_search` is emitted today. */ + @JsonProperty("kind") String kind, + /** Lifecycle status of the hosted call: `in_progress`, `searching`, or `completed`. */ + @JsonProperty("status") String status + ) { + } +} diff --git a/java/src/generated/java/com/github/copilot/generated/AutoModeResolvedReasoningBucket.java b/java/src/generated/java/com/github/copilot/generated/AutoModeResolvedReasoningBucket.java new file mode 100644 index 0000000000..3034b0bf16 --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/AutoModeResolvedReasoningBucket.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: session-events.schema.json + +package com.github.copilot.generated; + +import javax.annotation.processing.Generated; + +/** + * Coarse request-difficulty bucket for UX explainability + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public enum AutoModeResolvedReasoningBucket { + /** The {@code low} variant. */ + LOW("low"), + /** The {@code medium} variant. */ + MEDIUM("medium"), + /** The {@code high} variant. */ + HIGH("high"); + + private final String value; + AutoModeResolvedReasoningBucket(String value) { this.value = value; } + @com.fasterxml.jackson.annotation.JsonValue + public String getValue() { return value; } + @com.fasterxml.jackson.annotation.JsonCreator + public static AutoModeResolvedReasoningBucket fromValue(String value) { + for (AutoModeResolvedReasoningBucket v : values()) { + if (v.value.equals(value)) return v; + } + throw new IllegalArgumentException("Unknown AutoModeResolvedReasoningBucket value: " + value); + } +} diff --git a/java/src/generated/java/com/github/copilot/generated/CanvasRegistryChangedCanvas.java b/java/src/generated/java/com/github/copilot/generated/CanvasRegistryChangedCanvas.java index dbafc5d626..12518491e8 100644 --- a/java/src/generated/java/com/github/copilot/generated/CanvasRegistryChangedCanvas.java +++ b/java/src/generated/java/com/github/copilot/generated/CanvasRegistryChangedCanvas.java @@ -32,6 +32,8 @@ public record CanvasRegistryChangedCanvas( @JsonProperty("displayName") String displayName, /** Short, single-sentence description shown to the agent in canvas catalogs. */ @JsonProperty("description") String description, + /** Host-local PNG path for the canvas icon, when supplied */ + @JsonProperty("icon") String icon, /** JSON Schema for canvas open input */ @JsonProperty("inputSchema") Object inputSchema, /** Actions the agent or host may invoke */ diff --git a/java/src/generated/java/com/github/copilot/generated/HeaderEntry.java b/java/src/generated/java/com/github/copilot/generated/HeaderEntry.java new file mode 100644 index 0000000000..14828d32e3 --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/HeaderEntry.java @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: session-events.schema.json + +package com.github.copilot.generated; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.annotation.processing.Generated; + +/** + * Single HTTP header entry as a name/value pair. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record HeaderEntry( + /** HTTP response header name as observed by the runtime. */ + @JsonProperty("name") String name, + /** HTTP response header value as observed by the runtime. */ + @JsonProperty("value") String value +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/ManagedSettingsResolvedSource.java b/java/src/generated/java/com/github/copilot/generated/ManagedSettingsResolvedSource.java new file mode 100644 index 0000000000..1ffa100635 --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/ManagedSettingsResolvedSource.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: session-events.schema.json + +package com.github.copilot.generated; + +import javax.annotation.processing.Generated; + +/** + * Which channel supplied the effective enterprise managed settings (highest-authority present layer wins wholesale) + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public enum ManagedSettingsResolvedSource { + /** The {@code server} variant. */ + SERVER("server"), + /** The {@code device} variant. */ + DEVICE("device"), + /** The {@code none} variant. */ + NONE("none"); + + private final String value; + ManagedSettingsResolvedSource(String value) { this.value = value; } + @com.fasterxml.jackson.annotation.JsonValue + public String getValue() { return value; } + @com.fasterxml.jackson.annotation.JsonCreator + public static ManagedSettingsResolvedSource fromValue(String value) { + for (ManagedSettingsResolvedSource v : values()) { + if (v.value.equals(value)) return v; + } + throw new IllegalArgumentException("Unknown ManagedSettingsResolvedSource value: " + value); + } +} diff --git a/java/src/generated/java/com/github/copilot/generated/McpOauthHttpResponse.java b/java/src/generated/java/com/github/copilot/generated/McpOauthHttpResponse.java new file mode 100644 index 0000000000..bed8e0ac62 --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/McpOauthHttpResponse.java @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: session-events.schema.json + +package com.github.copilot.generated; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import javax.annotation.processing.Generated; + +/** + * Raw HTTP response details from the OAuth auth challenge, as observed by the runtime. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record McpOauthHttpResponse( + /** HTTP status code returned with the auth challenge. */ + @JsonProperty("statusCode") Long statusCode, + /** HTTP response headers as observed by the runtime. Order and casing are transport-dependent, and duplicate header names may appear multiple times. */ + @JsonProperty("headers") List headers, + /** Complete UTF-8 response body for host-specific challenge handling, including an empty string for an empty body. Omitted when the complete body is not valid UTF-8; body read failures fail the HTTP operation rather than exposing a partial response. */ + @JsonProperty("body") String body +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/McpOauthRequiredEvent.java b/java/src/generated/java/com/github/copilot/generated/McpOauthRequiredEvent.java index 67413d382f..f21f84cd4b 100644 --- a/java/src/generated/java/com/github/copilot/generated/McpOauthRequiredEvent.java +++ b/java/src/generated/java/com/github/copilot/generated/McpOauthRequiredEvent.java @@ -44,6 +44,8 @@ public record McpOauthRequiredEventData( @JsonProperty("staticClientConfig") McpOauthRequiredStaticClientConfig staticClientConfig, /** OAuth WWW-Authenticate parameters parsed from the auth challenge, if available */ @JsonProperty("wwwAuthenticateParams") McpOauthWWWAuthenticateParams wwwAuthenticateParams, + /** Raw HTTP response details from the OAuth auth challenge, as observed by the runtime. Header order and casing are transport-dependent, and duplicate header names may appear multiple times. */ + @JsonProperty("httpResponse") McpOauthHttpResponse httpResponse, /** Raw OAuth protected-resource metadata document fetched for the MCP server, if available */ @JsonProperty("resourceMetadata") String resourceMetadata, /** Why the runtime is requesting host-provided OAuth credentials. */ diff --git a/java/src/generated/java/com/github/copilot/generated/McpPromptsListChangedEvent.java b/java/src/generated/java/com/github/copilot/generated/McpPromptsListChangedEvent.java new file mode 100644 index 0000000000..805328d3c1 --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/McpPromptsListChangedEvent.java @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: session-events.schema.json + +package com.github.copilot.generated; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.annotation.processing.Generated; + +/** + * Session event "mcp.prompts.list_changed". Payload identifying the MCP server associated with a list change. + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public final class McpPromptsListChangedEvent extends SessionEvent { + + @Override + public String getType() { return "mcp.prompts.list_changed"; } + + @JsonProperty("data") + private McpPromptsListChangedEventData data; + + public McpPromptsListChangedEventData getData() { return data; } + public void setData(McpPromptsListChangedEventData data) { this.data = data; } + + /** Data payload for {@link McpPromptsListChangedEvent}. */ + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public record McpPromptsListChangedEventData( + /** Name of the MCP server whose list changed */ + @JsonProperty("serverName") String serverName + ) { + } +} diff --git a/java/src/generated/java/com/github/copilot/generated/McpResourcesListChangedEvent.java b/java/src/generated/java/com/github/copilot/generated/McpResourcesListChangedEvent.java new file mode 100644 index 0000000000..f1a613b6fa --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/McpResourcesListChangedEvent.java @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: session-events.schema.json + +package com.github.copilot.generated; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.annotation.processing.Generated; + +/** + * Session event "mcp.resources.list_changed". Payload identifying the MCP server associated with a list change. + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public final class McpResourcesListChangedEvent extends SessionEvent { + + @Override + public String getType() { return "mcp.resources.list_changed"; } + + @JsonProperty("data") + private McpResourcesListChangedEventData data; + + public McpResourcesListChangedEventData getData() { return data; } + public void setData(McpResourcesListChangedEventData data) { this.data = data; } + + /** Data payload for {@link McpResourcesListChangedEvent}. */ + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public record McpResourcesListChangedEventData( + /** Name of the MCP server whose list changed */ + @JsonProperty("serverName") String serverName + ) { + } +} diff --git a/java/src/generated/java/com/github/copilot/generated/McpToolsListChangedEvent.java b/java/src/generated/java/com/github/copilot/generated/McpToolsListChangedEvent.java new file mode 100644 index 0000000000..4255b8544f --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/McpToolsListChangedEvent.java @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: session-events.schema.json + +package com.github.copilot.generated; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.annotation.processing.Generated; + +/** + * Session event "mcp.tools.list_changed". Payload identifying the MCP server associated with a list change. + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public final class McpToolsListChangedEvent extends SessionEvent { + + @Override + public String getType() { return "mcp.tools.list_changed"; } + + @JsonProperty("data") + private McpToolsListChangedEventData data; + + public McpToolsListChangedEventData getData() { return data; } + public void setData(McpToolsListChangedEventData data) { this.data = data; } + + /** Data payload for {@link McpToolsListChangedEvent}. */ + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public record McpToolsListChangedEventData( + /** Name of the MCP server whose list changed */ + @JsonProperty("serverName") String serverName + ) { + } +} diff --git a/java/src/generated/java/com/github/copilot/generated/SessionAutoModeResolvedEvent.java b/java/src/generated/java/com/github/copilot/generated/SessionAutoModeResolvedEvent.java new file mode 100644 index 0000000000..f79be388ec --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/SessionAutoModeResolvedEvent.java @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: session-events.schema.json + +package com.github.copilot.generated; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Map; +import javax.annotation.processing.Generated; + +/** + * Session event "session.auto_mode_resolved". Auto Intent resolution: the concrete model the session settled on for the first prompt of an auto-mode session, and why. Lets SDK clients render the chosen model and the full reason it was picked. The core selection fields (chosenModel/reasoningBucket/categoryScores) are stable; the routing-analytics fields (predictedLabel/confidence/candidateModels) mirror the upstream intent service and may evolve, hence the event's experimental stability. + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public final class SessionAutoModeResolvedEvent extends SessionEvent { + + @Override + public String getType() { return "session.auto_mode_resolved"; } + + @JsonProperty("data") + private SessionAutoModeResolvedEventData data; + + public SessionAutoModeResolvedEventData getData() { return data; } + public void setData(SessionAutoModeResolvedEventData data) { this.data = data; } + + /** Data payload for {@link SessionAutoModeResolvedEvent}. */ + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public record SessionAutoModeResolvedEventData( + /** The concrete model the session will use after any intent refinement */ + @JsonProperty("chosenModel") String chosenModel, + /** Coarse request-difficulty bucket, for explaining why a model was chosen ("picked X because this looks like high-reasoning work") */ + @JsonProperty("reasoningBucket") AutoModeResolvedReasoningBucket reasoningBucket, + /** Per-category classifier scores (0-1) behind the bucket: the granular HYDRA capability scores (reasoning, code_gen, debugging, tool_use), or the binary needs_reasoning/no_reasoning scores when HYDRA didn't run. Lets clients show a breakdown rather than just the bucket. */ + @JsonProperty("categoryScores") Map categoryScores, + /** The predicted classifier label (e.g. `needs_reasoning`), when available */ + @JsonProperty("predictedLabel") String predictedLabel, + /** Classifier confidence for the predicted label, when available */ + @JsonProperty("confidence") Double confidence, + /** Ordered candidate model list the router returned, when not a fallback */ + @JsonProperty("candidateModels") List candidateModels + ) { + } +} diff --git a/java/src/generated/java/com/github/copilot/generated/SessionCanvasOpenedEvent.java b/java/src/generated/java/com/github/copilot/generated/SessionCanvasOpenedEvent.java index 975737b2f8..018e6a234d 100644 --- a/java/src/generated/java/com/github/copilot/generated/SessionCanvasOpenedEvent.java +++ b/java/src/generated/java/com/github/copilot/generated/SessionCanvasOpenedEvent.java @@ -13,7 +13,7 @@ import javax.annotation.processing.Generated; /** - * Session event "session.canvas.opened". Payload of `session.canvas.opened` with canvas instance and provider IDs plus optional title, status, URL, and input. + * Session event "session.canvas.opened". Payload of `session.canvas.opened` with canvas instance and provider IDs plus optional icon, title, status, URL, and input. * @since 1.0.0 */ @JsonIgnoreProperties(ignoreUnknown = true) @@ -42,6 +42,8 @@ public record SessionCanvasOpenedEventData( @JsonProperty("extensionName") String extensionName, /** Provider-local canvas identifier */ @JsonProperty("canvasId") String canvasId, + /** Host-local PNG path for the canvas icon, when supplied */ + @JsonProperty("icon") String icon, /** Rendered title */ @JsonProperty("title") String title, /** Provider-supplied status text */ diff --git a/java/src/generated/java/com/github/copilot/generated/SessionEvent.java b/java/src/generated/java/com/github/copilot/generated/SessionEvent.java index d4aed5cd4d..d15da0c41c 100644 --- a/java/src/generated/java/com/github/copilot/generated/SessionEvent.java +++ b/java/src/generated/java/com/github/copilot/generated/SessionEvent.java @@ -58,6 +58,7 @@ @JsonSubTypes.Type(value = PendingMessagesModifiedEvent.class, name = "pending_messages.modified"), @JsonSubTypes.Type(value = AssistantTurnStartEvent.class, name = "assistant.turn_start"), @JsonSubTypes.Type(value = AssistantIntentEvent.class, name = "assistant.intent"), + @JsonSubTypes.Type(value = AssistantServerToolProgressEvent.class, name = "assistant.server_tool_progress"), @JsonSubTypes.Type(value = AssistantReasoningEvent.class, name = "assistant.reasoning"), @JsonSubTypes.Type(value = AssistantReasoningDeltaEvent.class, name = "assistant.reasoning_delta"), @JsonSubTypes.Type(value = AssistantToolCallDeltaEvent.class, name = "assistant.tool_call_delta"), @@ -109,6 +110,8 @@ @JsonSubTypes.Type(value = AutoModeSwitchCompletedEvent.class, name = "auto_mode_switch.completed"), @JsonSubTypes.Type(value = SessionLimitsExhaustedRequestedEvent.class, name = "session_limits_exhausted.requested"), @JsonSubTypes.Type(value = SessionLimitsExhaustedCompletedEvent.class, name = "session_limits_exhausted.completed"), + @JsonSubTypes.Type(value = SessionAutoModeResolvedEvent.class, name = "session.auto_mode_resolved"), + @JsonSubTypes.Type(value = SessionManagedSettingsResolvedEvent.class, name = "session.managed_settings_resolved"), @JsonSubTypes.Type(value = CommandsChangedEvent.class, name = "commands.changed"), @JsonSubTypes.Type(value = CapabilitiesChangedEvent.class, name = "capabilities.changed"), @JsonSubTypes.Type(value = ExitPlanModeRequestedEvent.class, name = "exit_plan_mode.requested"), @@ -119,6 +122,9 @@ @JsonSubTypes.Type(value = SessionCustomAgentsUpdatedEvent.class, name = "session.custom_agents_updated"), @JsonSubTypes.Type(value = SessionMcpServersLoadedEvent.class, name = "session.mcp_servers_loaded"), @JsonSubTypes.Type(value = SessionMcpServerStatusChangedEvent.class, name = "session.mcp_server_status_changed"), + @JsonSubTypes.Type(value = McpToolsListChangedEvent.class, name = "mcp.tools.list_changed"), + @JsonSubTypes.Type(value = McpResourcesListChangedEvent.class, name = "mcp.resources.list_changed"), + @JsonSubTypes.Type(value = McpPromptsListChangedEvent.class, name = "mcp.prompts.list_changed"), @JsonSubTypes.Type(value = SessionExtensionsLoadedEvent.class, name = "session.extensions_loaded"), @JsonSubTypes.Type(value = SessionCanvasOpenedEvent.class, name = "session.canvas.opened"), @JsonSubTypes.Type(value = SessionCanvasRegistryChangedEvent.class, name = "session.canvas.registry_changed"), @@ -164,6 +170,7 @@ public abstract sealed class SessionEvent permits PendingMessagesModifiedEvent, AssistantTurnStartEvent, AssistantIntentEvent, + AssistantServerToolProgressEvent, AssistantReasoningEvent, AssistantReasoningDeltaEvent, AssistantToolCallDeltaEvent, @@ -215,6 +222,8 @@ public abstract sealed class SessionEvent permits AutoModeSwitchCompletedEvent, SessionLimitsExhaustedRequestedEvent, SessionLimitsExhaustedCompletedEvent, + SessionAutoModeResolvedEvent, + SessionManagedSettingsResolvedEvent, CommandsChangedEvent, CapabilitiesChangedEvent, ExitPlanModeRequestedEvent, @@ -225,6 +234,9 @@ public abstract sealed class SessionEvent permits SessionCustomAgentsUpdatedEvent, SessionMcpServersLoadedEvent, SessionMcpServerStatusChangedEvent, + McpToolsListChangedEvent, + McpResourcesListChangedEvent, + McpPromptsListChangedEvent, SessionExtensionsLoadedEvent, SessionCanvasOpenedEvent, SessionCanvasRegistryChangedEvent, diff --git a/java/src/generated/java/com/github/copilot/generated/SessionManagedSettingsResolvedEvent.java b/java/src/generated/java/com/github/copilot/generated/SessionManagedSettingsResolvedEvent.java new file mode 100644 index 0000000000..7cc495269f --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/SessionManagedSettingsResolvedEvent.java @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: session-events.schema.json + +package com.github.copilot.generated; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import javax.annotation.processing.Generated; + +/** + * Session event "session.managed_settings_resolved". Enterprise managed-settings resolution: the effective managed settings the session applied and where they came from, so SDK clients can show users what is enterprise-managed and by which authority. Fires whenever managed policy is (re)applied — at session start, on resume, and on account switch. This is an ephemeral live snapshot (delivered to subscribers but not persisted to the session event log), because at session start it resolves before `session.start` is emitted; for a session-independent pull, use the SDK `getManagedSettings()` API, which returns the identical payload. Managed settings have a single authoritative source, so the highest-authority present layer (server > device) wins wholesale; `bypassPermissionsDisabled` is deny-wins across layers. Marked experimental while the managed-settings surface stabilizes. + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public final class SessionManagedSettingsResolvedEvent extends SessionEvent { + + @Override + public String getType() { return "session.managed_settings_resolved"; } + + @JsonProperty("data") + private SessionManagedSettingsResolvedEventData data; + + public SessionManagedSettingsResolvedEventData getData() { return data; } + public void setData(SessionManagedSettingsResolvedEventData data) { this.data = data; } + + /** Data payload for {@link SessionManagedSettingsResolvedEvent}. */ + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public record SessionManagedSettingsResolvedEventData( + /** Which channel supplied the effective managed settings (the winning layer), or `none` when no policy is in force */ + @JsonProperty("source") ManagedSettingsResolvedSource source, + /** Whether the server (account/org) managed-settings layer was present */ + @JsonProperty("serverManaged") Boolean serverManaged, + /** Whether the device (MDM/plist/registry/file) managed-settings layer was present */ + @JsonProperty("deviceManaged") Boolean deviceManaged, + /** Whether managed policy could not be determined (e.g. a failed server fetch) and the session fell back to the fail-closed restriction. When true, restrictions such as disabling bypass-permissions are enforced even though `settings` may be absent. */ + @JsonProperty("failClosed") Boolean failClosed, + /** Whether enterprise policy disables bypass-permissions ("yolo") mode for this session. Deny-wins across layers, and forced on when `failClosed` is true. */ + @JsonProperty("bypassPermissionsDisabled") Boolean bypassPermissionsDisabled, + /** The setting keys under enterprise management in the effective managed settings (e.g. `model`, `enabledPlugins`, `permissions`). Empty when no managed settings are in force. */ + @JsonProperty("managedKeys") List managedKeys, + /** The effective (resolved) managed settings values, so clients can render exactly what is enforced. Absent when no managed policy is in force. */ + @JsonProperty("settings") Object settings + ) { + } +} diff --git a/java/src/generated/java/com/github/copilot/generated/ToolExecutionCompleteEvent.java b/java/src/generated/java/com/github/copilot/generated/ToolExecutionCompleteEvent.java index b4e82d2685..99d138b1e6 100644 --- a/java/src/generated/java/com/github/copilot/generated/ToolExecutionCompleteEvent.java +++ b/java/src/generated/java/com/github/copilot/generated/ToolExecutionCompleteEvent.java @@ -41,6 +41,8 @@ public record ToolExecutionCompleteEventData( @JsonProperty("success") Boolean success, /** Model identifier that generated this tool call */ @JsonProperty("model") String model, + /** FIDES IFC label projected from tool ingress metadata (MCP `CallToolResult._meta` or synthesized built-in ingress labels). Persisted as `{ ifc: ... }` so the label survives session resume, including model-visible failure results. Experimental. */ + @JsonProperty("mcpMeta") Object mcpMeta, /** CAPI interaction ID for correlating this tool execution with upstream telemetry */ @JsonProperty("interactionId") String interactionId, /** Whether this tool call was explicitly requested by the user rather than the assistant */ diff --git a/java/src/generated/java/com/github/copilot/generated/ToolExecutionCompleteResult.java b/java/src/generated/java/com/github/copilot/generated/ToolExecutionCompleteResult.java index 7459d5517f..f7f08d93c6 100644 --- a/java/src/generated/java/com/github/copilot/generated/ToolExecutionCompleteResult.java +++ b/java/src/generated/java/com/github/copilot/generated/ToolExecutionCompleteResult.java @@ -35,6 +35,8 @@ public record ToolExecutionCompleteResult( /** Structured content (arbitrary JSON) returned verbatim by the MCP tool */ @JsonProperty("structuredContent") Object structuredContent, /** Provider-neutral source material this tool makes available to the model as citable content. Persisted so it survives session resume. Experimental. */ - @JsonProperty("citableSources") List citableSources + @JsonProperty("citableSources") List citableSources, + /** FIDES IFC label projected from tool ingress metadata (MCP `CallToolResult._meta` or synthesized built-in ingress labels) — persisted as `{ ifc: ... }` (only the `ifc` key, not the whole `_meta`). Persisted so the FIDES IFC label survives session resume: the engine rehydrates accumulated taint by replaying these on load. Populated for ingress sources when FIDES IFC is on. Experimental. */ + @JsonProperty("mcpMeta") Object mcpMeta ) { } diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/CommandsListResult.java b/java/src/generated/java/com/github/copilot/generated/rpc/CommandsListResult.java new file mode 100644 index 0000000000..9873fa7ff9 --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/CommandsListResult.java @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.CopilotExperimental; +import java.util.List; +import javax.annotation.processing.Generated; + +/** + * Slash commands available in the session, after applying any include/exclude filters. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ +@CopilotExperimental +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record CommandsListResult( + /** Commands available in this session */ + @JsonProperty("commands") List commands +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/DiscoveredCanvas.java b/java/src/generated/java/com/github/copilot/generated/rpc/DiscoveredCanvas.java index e6e02745c8..0b0c518040 100644 --- a/java/src/generated/java/com/github/copilot/generated/rpc/DiscoveredCanvas.java +++ b/java/src/generated/java/com/github/copilot/generated/rpc/DiscoveredCanvas.java @@ -26,6 +26,8 @@ public record DiscoveredCanvas( @JsonProperty("displayName") String displayName, /** Short, single-sentence description shown to the agent in canvas catalogs. */ @JsonProperty("description") String description, + /** Host-local PNG path for the canvas icon, when supplied */ + @JsonProperty("icon") String icon, /** JSON Schema for canvas open input */ @JsonProperty("inputSchema") Object inputSchema, /** Actions the agent or host may invoke on an open instance */ diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/HookInvokeRequest.java b/java/src/generated/java/com/github/copilot/generated/rpc/HookInvokeRequest.java new file mode 100644 index 0000000000..9ed02d28b0 --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/HookInvokeRequest.java @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.annotation.processing.Generated; + +/** + * Runtime-owned wire payload for a server-to-client hook callback invocation. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record HookInvokeRequest( + @JsonProperty("sessionId") String sessionId, + @JsonProperty("hookType") HookType hookType, + @JsonProperty("input") Object input +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/HookType.java b/java/src/generated/java/com/github/copilot/generated/rpc/HookType.java new file mode 100644 index 0000000000..006f8a7c1f --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/HookType.java @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import javax.annotation.processing.Generated; + +/** + * Hook event name dispatched through the SDK callback transport. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public enum HookType { + /** The {@code preToolUse} variant. */ + PRETOOLUSE("preToolUse"), + /** The {@code preMcpToolCall} variant. */ + PREMCPTOOLCALL("preMcpToolCall"), + /** The {@code postToolUse} variant. */ + POSTTOOLUSE("postToolUse"), + /** The {@code postToolUseFailure} variant. */ + POSTTOOLUSEFAILURE("postToolUseFailure"), + /** The {@code userPromptSubmitted} variant. */ + USERPROMPTSUBMITTED("userPromptSubmitted"), + /** The {@code sessionStart} variant. */ + SESSIONSTART("sessionStart"), + /** The {@code sessionEnd} variant. */ + SESSIONEND("sessionEnd"), + /** The {@code postResult} variant. */ + POSTRESULT("postResult"), + /** The {@code prePRDescription} variant. */ + PREPRDESCRIPTION("prePRDescription"), + /** The {@code errorOccurred} variant. */ + ERROROCCURRED("errorOccurred"), + /** The {@code agentStop} variant. */ + AGENTSTOP("agentStop"), + /** The {@code subagentStart} variant. */ + SUBAGENTSTART("subagentStart"), + /** The {@code subagentStop} variant. */ + SUBAGENTSTOP("subagentStop"), + /** The {@code preCompact} variant. */ + PRECOMPACT("preCompact"), + /** The {@code permissionRequest} variant. */ + PERMISSIONREQUEST("permissionRequest"), + /** The {@code notification} variant. */ + NOTIFICATION("notification"); + + private final String value; + HookType(String value) { this.value = value; } + @com.fasterxml.jackson.annotation.JsonValue + public String getValue() { return value; } + @com.fasterxml.jackson.annotation.JsonCreator + public static HookType fromValue(String value) { + for (HookType v : values()) { + if (v.value.equals(value)) return v; + } + throw new IllegalArgumentException("Unknown HookType value: " + value); + } +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/HooksInvokeResult.java b/java/src/generated/java/com/github/copilot/generated/rpc/HooksInvokeResult.java new file mode 100644 index 0000000000..a111b7af4e --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/HooksInvokeResult.java @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.CopilotExperimental; +import javax.annotation.processing.Generated; + +/** + * Optional output returned by an SDK callback hook. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ +@CopilotExperimental +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record HooksInvokeResult( + @JsonProperty("output") Object output +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/McpResource.java b/java/src/generated/java/com/github/copilot/generated/rpc/McpResource.java new file mode 100644 index 0000000000..92302772ce --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/McpResource.java @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Map; +import javax.annotation.processing.Generated; + +/** + * An MCP resource descriptor (spec `Resource`): URI, name, and optional title, description, MIME type, size, icons, annotations, and metadata. Server-provided fields outside the standard descriptor shape are exposed under `additionalProperties`. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record McpResource( + /** The resource URI (e.g. ui://... or file:///...) */ + @JsonProperty("uri") String uri, + /** The programmatic name of the resource */ + @JsonProperty("name") String name, + /** Optional human-readable display title */ + @JsonProperty("title") String title, + /** Optional description of what this resource represents */ + @JsonProperty("description") String description, + /** MIME type of the resource, if known */ + @JsonProperty("mimeType") String mimeType, + /** Resource size in bytes, when known */ + @JsonProperty("size") Long size, + /** Icons associated with this resource */ + @JsonProperty("icons") List icons, + /** Model/client annotations associated with this resource */ + @JsonProperty("annotations") McpResourceAnnotations annotations, + /** Resource-level metadata */ + @JsonProperty("_meta") Map meta, + /** Server-provided non-standard descriptor fields preserved from the MCP response */ + @JsonProperty("additionalProperties") Map additionalProperties +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/McpResourceAnnotations.java b/java/src/generated/java/com/github/copilot/generated/rpc/McpResourceAnnotations.java new file mode 100644 index 0000000000..6cae65957a --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/McpResourceAnnotations.java @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Map; +import javax.annotation.processing.Generated; + +/** + * Standard MCP resource annotations plus preserved non-standard annotation fields. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record McpResourceAnnotations( + /** Intended audience roles for this resource */ + @JsonProperty("audience") List audience, + /** Priority hint for model/client use */ + @JsonProperty("priority") Double priority, + /** Last-modified timestamp hint */ + @JsonProperty("lastModified") String lastModified, + /** Server-provided non-standard annotation fields preserved from the MCP response */ + @JsonProperty("additionalProperties") Map additionalProperties +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/McpResourceContent.java b/java/src/generated/java/com/github/copilot/generated/rpc/McpResourceContent.java new file mode 100644 index 0000000000..4967286f15 --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/McpResourceContent.java @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; +import javax.annotation.processing.Generated; + +/** + * MCP resource content with URI, optional MIME type, text or base64 blob, and resource metadata. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record McpResourceContent( + /** The resource URI */ + @JsonProperty("uri") String uri, + /** MIME type of the content */ + @JsonProperty("mimeType") String mimeType, + /** Text content (e.g. HTML) */ + @JsonProperty("text") String text, + /** Base64-encoded binary content */ + @JsonProperty("blob") String blob, + /** Resource-level metadata (CSP, permissions, etc.) */ + @JsonProperty("_meta") Map meta +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/McpResourceIcon.java b/java/src/generated/java/com/github/copilot/generated/rpc/McpResourceIcon.java new file mode 100644 index 0000000000..f5a8c68d34 --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/McpResourceIcon.java @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; +import javax.annotation.processing.Generated; + +/** + * A resource icon descriptor plus preserved non-standard icon fields. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record McpResourceIcon( + /** Icon URI */ + @JsonProperty("src") String src, + /** Icon MIME type, when known */ + @JsonProperty("mimeType") String mimeType, + /** Icon sizes hint */ + @JsonProperty("sizes") String sizes, + /** Theme hint for this icon */ + @JsonProperty("theme") String theme, + /** Server-provided non-standard icon fields preserved from the MCP response */ + @JsonProperty("additionalProperties") Map additionalProperties +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/McpResourceTemplate.java b/java/src/generated/java/com/github/copilot/generated/rpc/McpResourceTemplate.java new file mode 100644 index 0000000000..14ffca372d --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/McpResourceTemplate.java @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Map; +import javax.annotation.processing.Generated; + +/** + * An MCP resource template descriptor (spec `ResourceTemplate`): an RFC 6570 URI template, name, and optional title, description, MIME type, icons, annotations, and metadata. Server-provided fields outside the standard descriptor shape are exposed under `additionalProperties`. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record McpResourceTemplate( + /** An RFC 6570 URI template for constructing resource URIs */ + @JsonProperty("uriTemplate") String uriTemplate, + /** The programmatic name of the resource template */ + @JsonProperty("name") String name, + /** Optional human-readable display title */ + @JsonProperty("title") String title, + /** Optional description of what this template is for */ + @JsonProperty("description") String description, + /** MIME type for resources matching this template, if uniform */ + @JsonProperty("mimeType") String mimeType, + /** Icons associated with resources matching this template */ + @JsonProperty("icons") List icons, + /** Model/client annotations associated with this template */ + @JsonProperty("annotations") McpResourceAnnotations annotations, + /** Resource-template-level metadata */ + @JsonProperty("_meta") Map meta, + /** Server-provided non-standard descriptor fields preserved from the MCP response */ + @JsonProperty("additionalProperties") Map additionalProperties +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/McpToolUi.java b/java/src/generated/java/com/github/copilot/generated/rpc/McpToolUi.java new file mode 100644 index 0000000000..2f4436ca42 --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/McpToolUi.java @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import javax.annotation.processing.Generated; + +/** + * Normalized MCP Apps discovery metadata from a tool's `_meta.ui` block. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record McpToolUi( + /** URI of the tool's MCP App resource, typically a `ui://` resource identifier. Use `session.mcp.resources.read` to fetch its HTML and resource metadata. */ + @JsonProperty("resourceUri") String resourceUri, + /** Tool visibility advertised by the server. When absent, MCP Apps defaults apply. */ + @JsonProperty("visibility") List visibility +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/McpToolUiVisibility.java b/java/src/generated/java/com/github/copilot/generated/rpc/McpToolUiVisibility.java new file mode 100644 index 0000000000..9e73f0c90c --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/McpToolUiVisibility.java @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import javax.annotation.processing.Generated; + +/** + * Consumer allowed to call an MCP tool. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public enum McpToolUiVisibility { + /** The {@code model} variant. */ + MODEL("model"), + /** The {@code app} variant. */ + APP("app"); + + private final String value; + McpToolUiVisibility(String value) { this.value = value; } + @com.fasterxml.jackson.annotation.JsonValue + public String getValue() { return value; } + @com.fasterxml.jackson.annotation.JsonCreator + public static McpToolUiVisibility fromValue(String value) { + for (McpToolUiVisibility v : values()) { + if (v.value.equals(value)) return v; + } + throw new IllegalArgumentException("Unknown McpToolUiVisibility value: " + value); + } +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/McpTools.java b/java/src/generated/java/com/github/copilot/generated/rpc/McpTools.java index 04d6881266..37782f6d31 100644 --- a/java/src/generated/java/com/github/copilot/generated/rpc/McpTools.java +++ b/java/src/generated/java/com/github/copilot/generated/rpc/McpTools.java @@ -13,7 +13,7 @@ import javax.annotation.processing.Generated; /** - * MCP tool metadata with tool name and optional description. + * MCP tool metadata with tool name, optional description, and normalized MCP Apps discovery metadata. * * @since 1.0.0 */ @@ -24,6 +24,8 @@ public record McpTools( /** Tool name. */ @JsonProperty("name") String name, /** Tool description, when provided. */ - @JsonProperty("description") String description + @JsonProperty("description") String description, + /** Normalized MCP Apps discovery metadata. An empty object indicates that a valid `_meta.ui` block was present without recognized fields. */ + @JsonProperty("ui") McpToolUi ui ) { } diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/ModelBilling.java b/java/src/generated/java/com/github/copilot/generated/rpc/ModelBilling.java index 53ed64f4bf..f8298dd624 100644 --- a/java/src/generated/java/com/github/copilot/generated/rpc/ModelBilling.java +++ b/java/src/generated/java/com/github/copilot/generated/rpc/ModelBilling.java @@ -26,6 +26,8 @@ public record ModelBilling( /** Token-level pricing information for this model */ @JsonProperty("tokenPrices") ModelBillingTokenPrices tokenPrices, /** Whole-number percentage discount (0-100) applied to usage billed through this model. Populated for the synthetic `auto` model, where requests routed by auto-mode are billed at a reduced rate; absent for concrete models. */ - @JsonProperty("discountPercent") Long discountPercent + @JsonProperty("discountPercent") Long discountPercent, + /** Active server-driven promotion for this model, if any. Present when the model is being promoted with a time-boxed discount. */ + @JsonProperty("promo") ModelBillingPromo promo ) { } diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/ModelBillingPromo.java b/java/src/generated/java/com/github/copilot/generated/rpc/ModelBillingPromo.java new file mode 100644 index 0000000000..731e298355 --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/ModelBillingPromo.java @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.annotation.processing.Generated; + +/** + * Active server-driven promotion for a model, including its discount and expiry. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record ModelBillingPromo( + /** Stable identifier for the promotion campaign. */ + @JsonProperty("id") String id, + /** Percentage discount (0-100) applied while the promotion is active. May be fractional. */ + @JsonProperty("discountPercent") Double discountPercent, + /** UTC ISO 8601 timestamp marking when the promotion ends. Always present: the API only surfaces a promo whose expiry parses and is in the future. Consumers should treat a past value as expired. */ + @JsonProperty("endsAt") String endsAt, + /** Human-readable promotion message. Does not include the expiry timestamp; consumers may format endsAt and append it. */ + @JsonProperty("message") String message +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/OpenCanvasInstance.java b/java/src/generated/java/com/github/copilot/generated/rpc/OpenCanvasInstance.java index 9495d21611..f38ba82c4f 100644 --- a/java/src/generated/java/com/github/copilot/generated/rpc/OpenCanvasInstance.java +++ b/java/src/generated/java/com/github/copilot/generated/rpc/OpenCanvasInstance.java @@ -29,6 +29,8 @@ public record OpenCanvasInstance( @JsonProperty("extensionName") String extensionName, /** Provider-local canvas identifier */ @JsonProperty("canvasId") String canvasId, + /** Host-local PNG path for the canvas icon, when supplied */ + @JsonProperty("icon") String icon, /** Rendered title */ @JsonProperty("title") String title, /** Provider-supplied status text */ diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/PluginsMarketplacesAddParams.java b/java/src/generated/java/com/github/copilot/generated/rpc/PluginsMarketplacesAddParams.java index f33ade9a02..f4d00d7c16 100644 --- a/java/src/generated/java/com/github/copilot/generated/rpc/PluginsMarketplacesAddParams.java +++ b/java/src/generated/java/com/github/copilot/generated/rpc/PluginsMarketplacesAddParams.java @@ -14,7 +14,7 @@ import javax.annotation.processing.Generated; /** - * Marketplace source to register. + * Marketplace source and optional working directory for relative-path resolution. * * @apiNote This method is experimental and may change in a future version. * @since 1.0.0 @@ -25,6 +25,8 @@ @JsonIgnoreProperties(ignoreUnknown = true) public record PluginsMarketplacesAddParams( /** Marketplace source. Accepts the same forms as the CLI: "owner/repo" or "owner/repo#ref" (GitHub), an http/https/ssh URL (optionally with #ref), a git scp-style URL (user@host:path), or a local path. The marketplace's own name (from its manifest) is used as the registration key. */ - @JsonProperty("source") String source + @JsonProperty("source") String source, + /** Working directory used to resolve relative local paths in `source`. Defaults to the server's current working directory. */ + @JsonProperty("workingDirectory") String workingDirectory ) { } diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/SendMessageItem.java b/java/src/generated/java/com/github/copilot/generated/rpc/SendMessageItem.java new file mode 100644 index 0000000000..bdb37cd9fd --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/SendMessageItem.java @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import javax.annotation.processing.Generated; + +/** + * A single user message to append to the session as part of a `session.sendMessages` turn + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record SendMessageItem( + /** The user message text */ + @JsonProperty("prompt") String prompt, + /** If provided, this is shown in the timeline instead of `prompt` */ + @JsonProperty("displayPrompt") String displayPrompt, + /** Optional attachments (files, directories, selections, blobs, GitHub references) to include with this message */ + @JsonProperty("attachments") List attachments, + /** If false, this message will not trigger a Premium Request Unit charge. User messages default to billable. */ + @JsonProperty("billable") Boolean billable, + /** If set, the request will fail if the named tool is not available when this message is among the user messages at the start of the current exchange */ + @JsonProperty("requiredTool") String requiredTool, + /** Optional provenance tag copied to the resulting user.message event. Must match one of three forms: the literal `system`, `command-` for messages originating from a command (e.g. slash command, Mission Control command), or `schedule-` for messages originating from a scheduled job. */ + @JsonProperty("source") String source +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/ServerCommandsApi.java b/java/src/generated/java/com/github/copilot/generated/rpc/ServerCommandsApi.java new file mode 100644 index 0000000000..efa5317ea4 --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/ServerCommandsApi.java @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.github.copilot.CopilotExperimental; +import java.util.concurrent.CompletableFuture; +import javax.annotation.processing.Generated; + +/** + * API methods for the {@code commands} namespace. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public final class ServerCommandsApi { + + private final RpcCaller caller; + + /** @param caller the RPC transport function */ + ServerCommandsApi(RpcCaller caller) { + this.caller = caller; + } + + /** + * Slash commands available in the session, after applying any include/exclude filters. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ + @CopilotExperimental + public CompletableFuture list() { + return caller.invoke("commands.list", java.util.Map.of(), CommandsListResult.class); + } + +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/ServerPluginsMarketplacesApi.java b/java/src/generated/java/com/github/copilot/generated/rpc/ServerPluginsMarketplacesApi.java index 3d8ced5848..47b239a0c0 100644 --- a/java/src/generated/java/com/github/copilot/generated/rpc/ServerPluginsMarketplacesApi.java +++ b/java/src/generated/java/com/github/copilot/generated/rpc/ServerPluginsMarketplacesApi.java @@ -38,7 +38,7 @@ public CompletableFuture list() { } /** - * Marketplace source to register. + * Marketplace source and optional working directory for relative-path resolution. * * @apiNote This method is experimental and may change in a future version. * @since 1.0.0 diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/ServerRpc.java b/java/src/generated/java/com/github/copilot/generated/rpc/ServerRpc.java index 21907b7ab3..033fe8bf3e 100644 --- a/java/src/generated/java/com/github/copilot/generated/rpc/ServerRpc.java +++ b/java/src/generated/java/com/github/copilot/generated/rpc/ServerRpc.java @@ -43,6 +43,8 @@ public final class ServerRpc { public final ServerAgentsApi agents; /** API methods for the {@code instructions} namespace. */ public final ServerInstructionsApi instructions; + /** API methods for the {@code commands} namespace. */ + public final ServerCommandsApi commands; /** API methods for the {@code user} namespace. */ public final ServerUserApi user; /** API methods for the {@code runtime} namespace. */ @@ -72,6 +74,7 @@ public ServerRpc(RpcCaller caller) { this.skills = new ServerSkillsApi(caller); this.agents = new ServerAgentsApi(caller); this.instructions = new ServerInstructionsApi(caller); + this.commands = new ServerCommandsApi(caller); this.user = new ServerUserApi(caller); this.runtime = new ServerRuntimeApi(caller); this.sessionFs = new ServerSessionFsApi(caller); diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/SessionCanvasOpenResult.java b/java/src/generated/java/com/github/copilot/generated/rpc/SessionCanvasOpenResult.java index 1d4e0bdf53..7678d1d6a8 100644 --- a/java/src/generated/java/com/github/copilot/generated/rpc/SessionCanvasOpenResult.java +++ b/java/src/generated/java/com/github/copilot/generated/rpc/SessionCanvasOpenResult.java @@ -32,6 +32,8 @@ public record SessionCanvasOpenResult( @JsonProperty("extensionName") String extensionName, /** Provider-local canvas identifier */ @JsonProperty("canvasId") String canvasId, + /** Host-local PNG path for the canvas icon, when supplied */ + @JsonProperty("icon") String icon, /** Rendered title */ @JsonProperty("title") String title, /** Provider-supplied status text */ diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/SessionEventLogRegisterInterestParams.java b/java/src/generated/java/com/github/copilot/generated/rpc/SessionEventLogRegisterInterestParams.java index 6188858e01..567156cc5e 100644 --- a/java/src/generated/java/com/github/copilot/generated/rpc/SessionEventLogRegisterInterestParams.java +++ b/java/src/generated/java/com/github/copilot/generated/rpc/SessionEventLogRegisterInterestParams.java @@ -26,7 +26,7 @@ public record SessionEventLogRegisterInterestParams( /** Target session identifier */ @JsonProperty("sessionId") String sessionId, - /** The event type the consumer wants the runtime to treat as 'observed' for behavior-switching gating. Some runtime code paths inspect whether any consumer is interested in a specific event type and choose a different implementation accordingly (e.g. `mcp.oauth_required`: when interest is registered the runtime delegates OAuth token acquisition to the consumer; when no interest is registered OAuth-required servers become needs-auth). SDK clients that long-poll events do NOT automatically appear as listeners to these gating checks — they must explicitly call `registerInterest` for each event type they want the runtime to count as having a consumer. Multiple registrations for the same event type from the same or different consumers are tracked independently and must each be released. See: `mcp.oauth_required`, `sampling.requested`, `auto_mode_switch.requested`, `session_limits_exhausted.requested`, `user_input.requested`, `elicitation.requested`, `command.queued`, `exit_plan_mode.requested`. */ + /** The event type the consumer wants the runtime to treat as 'observed' for behavior-switching gating. Some runtime code paths inspect whether any consumer is interested in a specific event type and choose a different implementation accordingly (e.g. `mcp.oauth_required`: when interest is registered the runtime delegates interactive OAuth token acquisition to the consumer via `mcp.oauth_required` events; when no interest is registered the runtime still attempts non-interactive reconnect from cached or refreshable tokens, and only marks the server `needs-auth` if usable credentials are unavailable — it does not open a browser or start interactive OAuth without a consumer). SDK clients that long-poll events do NOT automatically appear as listeners to these gating checks — they must explicitly call `registerInterest` for each event type they want the runtime to count as having a consumer. Multiple registrations for the same event type from the same or different consumers are tracked independently and must each be released. See: `mcp.oauth_required`, `sampling.requested`, `auto_mode_switch.requested`, `session_limits_exhausted.requested`, `user_input.requested`, `elicitation.requested`, `command.queued`, `exit_plan_mode.requested`. */ @JsonProperty("eventType") String eventType ) { } diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpApi.java b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpApi.java index f4603c249f..172f6e5075 100644 --- a/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpApi.java +++ b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpApi.java @@ -30,6 +30,8 @@ public final class SessionMcpApi { public final SessionMcpHeadersApi headers; /** API methods for the {@code mcp.apps} sub-namespace. */ public final SessionMcpAppsApi apps; + /** API methods for the {@code mcp.resources} sub-namespace. */ + public final SessionMcpResourcesApi resources; /** @param caller the RPC transport function */ SessionMcpApi(RpcCaller caller, String sessionId) { @@ -38,6 +40,7 @@ public final class SessionMcpApi { this.oauth = new SessionMcpOauthApi(caller, sessionId); this.headers = new SessionMcpHeadersApi(caller, sessionId); this.apps = new SessionMcpAppsApi(caller, sessionId); + this.resources = new SessionMcpResourcesApi(caller, sessionId); } /** @@ -202,7 +205,7 @@ public CompletableFuture configureGitHub(Sessio } /** - * Server name and opaque configuration for an individual MCP server start. + * Server name and configuration for an individual MCP server start. *

* Note: the {@code sessionId} field in the params record is overridden * by the session-scoped wrapper; any value provided is ignored. @@ -218,7 +221,7 @@ public CompletableFuture startServer(SessionMcpStartServerParams params) { } /** - * Server name and opaque configuration for an individual MCP server restart. + * Server name and optional replacement configuration for an individual MCP server restart. Omit `config` for a config-free restart-by-name of an already-configured server. *

* Note: the {@code sessionId} field in the params record is overridden * by the session-scoped wrapper; any value provided is ignored. diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpResourcesApi.java b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpResourcesApi.java new file mode 100644 index 0000000000..c1a30e135d --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpResourcesApi.java @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.github.copilot.CopilotExperimental; +import java.util.concurrent.CompletableFuture; +import javax.annotation.processing.Generated; + +/** + * API methods for the {@code mcp.resources} namespace. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +public final class SessionMcpResourcesApi { + + private static final com.fasterxml.jackson.databind.ObjectMapper MAPPER = RpcMapper.INSTANCE; + + private final RpcCaller caller; + private final String sessionId; + + /** @param caller the RPC transport function */ + SessionMcpResourcesApi(RpcCaller caller, String sessionId) { + this.caller = caller; + this.sessionId = sessionId; + } + + /** + * MCP server and resource URI to fetch. + *

+ * Note: the {@code sessionId} field in the params record is overridden + * by the session-scoped wrapper; any value provided is ignored. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ + @CopilotExperimental + public CompletableFuture read(SessionMcpResourcesReadParams params) { + com.fasterxml.jackson.databind.node.ObjectNode _p = MAPPER.valueToTree(params); + _p.put("sessionId", this.sessionId); + return caller.invoke("session.mcp.resources.read", _p, SessionMcpResourcesReadResult.class); + } + + /** + * MCP server whose resources to enumerate. + *

+ * Note: the {@code sessionId} field in the params record is overridden + * by the session-scoped wrapper; any value provided is ignored. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ + @CopilotExperimental + public CompletableFuture list(SessionMcpResourcesListParams params) { + com.fasterxml.jackson.databind.node.ObjectNode _p = MAPPER.valueToTree(params); + _p.put("sessionId", this.sessionId); + return caller.invoke("session.mcp.resources.list", _p, SessionMcpResourcesListResult.class); + } + + /** + * MCP server whose resource templates to enumerate. + *

+ * Note: the {@code sessionId} field in the params record is overridden + * by the session-scoped wrapper; any value provided is ignored. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ + @CopilotExperimental + public CompletableFuture listTemplates(SessionMcpResourcesListTemplatesParams params) { + com.fasterxml.jackson.databind.node.ObjectNode _p = MAPPER.valueToTree(params); + _p.put("sessionId", this.sessionId); + return caller.invoke("session.mcp.resources.listTemplates", _p, SessionMcpResourcesListTemplatesResult.class); + } + +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpResourcesListParams.java b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpResourcesListParams.java new file mode 100644 index 0000000000..bd8a9de64b --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpResourcesListParams.java @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.CopilotExperimental; +import javax.annotation.processing.Generated; + +/** + * MCP server whose resources to enumerate. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ +@CopilotExperimental +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record SessionMcpResourcesListParams( + /** Target session identifier */ + @JsonProperty("sessionId") String sessionId, + /** Name of the MCP server whose resources to enumerate */ + @JsonProperty("serverName") String serverName, + /** Opaque MCP pagination cursor from a prior `nextCursor` value */ + @JsonProperty("cursor") String cursor +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpResourcesListResult.java b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpResourcesListResult.java new file mode 100644 index 0000000000..b7e1042cfc --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpResourcesListResult.java @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.CopilotExperimental; +import java.util.List; +import javax.annotation.processing.Generated; + +/** + * One page of resources advertised by the named MCP server. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ +@CopilotExperimental +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record SessionMcpResourcesListResult( + /** Resources advertised by the server (proxied MCP `resources/list`) */ + @JsonProperty("resources") List resources, + /** Opaque cursor for the next page, if the server has more resources */ + @JsonProperty("nextCursor") String nextCursor +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpResourcesListTemplatesParams.java b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpResourcesListTemplatesParams.java new file mode 100644 index 0000000000..a58252c766 --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpResourcesListTemplatesParams.java @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.CopilotExperimental; +import javax.annotation.processing.Generated; + +/** + * MCP server whose resource templates to enumerate. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ +@CopilotExperimental +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record SessionMcpResourcesListTemplatesParams( + /** Target session identifier */ + @JsonProperty("sessionId") String sessionId, + /** Name of the MCP server whose resource templates to enumerate */ + @JsonProperty("serverName") String serverName, + /** Opaque MCP pagination cursor from a prior `nextCursor` value */ + @JsonProperty("cursor") String cursor +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpResourcesListTemplatesResult.java b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpResourcesListTemplatesResult.java new file mode 100644 index 0000000000..9cb3ff0569 --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpResourcesListTemplatesResult.java @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.CopilotExperimental; +import java.util.List; +import javax.annotation.processing.Generated; + +/** + * One page of resource templates advertised by the named MCP server. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ +@CopilotExperimental +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record SessionMcpResourcesListTemplatesResult( + /** Resource templates advertised by the server (proxied MCP `resources/templates/list`) */ + @JsonProperty("resourceTemplates") List resourceTemplates, + /** Opaque cursor for the next page, if the server has more resource templates */ + @JsonProperty("nextCursor") String nextCursor +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpResourcesReadParams.java b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpResourcesReadParams.java new file mode 100644 index 0000000000..5c7b9d803b --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpResourcesReadParams.java @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.CopilotExperimental; +import javax.annotation.processing.Generated; + +/** + * MCP server and resource URI to fetch. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ +@CopilotExperimental +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record SessionMcpResourcesReadParams( + /** Target session identifier */ + @JsonProperty("sessionId") String sessionId, + /** Name of the MCP server hosting the resource */ + @JsonProperty("serverName") String serverName, + /** Resource URI */ + @JsonProperty("uri") String uri +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpResourcesReadResult.java b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpResourcesReadResult.java new file mode 100644 index 0000000000..7e85574ee5 --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpResourcesReadResult.java @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.CopilotExperimental; +import java.util.List; +import javax.annotation.processing.Generated; + +/** + * Resource contents returned by the MCP server. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ +@CopilotExperimental +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record SessionMcpResourcesReadResult( + /** Resource contents returned by the server */ + @JsonProperty("contents") List contents +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpRestartServerParams.java b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpRestartServerParams.java index 12035ae070..b517802562 100644 --- a/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpRestartServerParams.java +++ b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpRestartServerParams.java @@ -14,7 +14,7 @@ import javax.annotation.processing.Generated; /** - * Server name and opaque configuration for an individual MCP server restart. + * Server name and optional replacement configuration for an individual MCP server restart. Omit `config` for a config-free restart-by-name of an already-configured server. * * @apiNote This method is experimental and may change in a future version. * @since 1.0.0 @@ -28,7 +28,7 @@ public record SessionMcpRestartServerParams( @JsonProperty("sessionId") String sessionId, /** Name of the MCP server to restart */ @JsonProperty("serverName") String serverName, - /** Opaque server configuration (MCPServerConfig). Marked internal: an in-process runtime shape supplied only by in-process CLI callers. */ + /** Replacement MCP server configuration (stdio process or remote HTTP/SSE). Omit to restart the server with its already-registered configuration (config-free restart-by-name). */ @JsonProperty("config") Object config ) { } diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpStartServerParams.java b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpStartServerParams.java index 6e866d64d7..e4c14e30e4 100644 --- a/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpStartServerParams.java +++ b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMcpStartServerParams.java @@ -14,7 +14,7 @@ import javax.annotation.processing.Generated; /** - * Server name and opaque configuration for an individual MCP server start. + * Server name and configuration for an individual MCP server start. * * @apiNote This method is experimental and may change in a future version. * @since 1.0.0 @@ -28,7 +28,7 @@ public record SessionMcpStartServerParams( @JsonProperty("sessionId") String sessionId, /** Name of the MCP server to start */ @JsonProperty("serverName") String serverName, - /** Opaque server configuration (MCPServerConfig). Marked internal: an in-process runtime shape supplied only by in-process CLI callers. */ + /** MCP server configuration (stdio process or remote HTTP/SSE) */ @JsonProperty("config") Object config ) { } diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/SessionMetadataApi.java b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMetadataApi.java index 209fe8cac4..0b15df5d43 100644 --- a/java/src/generated/java/com/github/copilot/generated/rpc/SessionMetadataApi.java +++ b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMetadataApi.java @@ -123,7 +123,7 @@ public CompletableFuture recordContextChange(SessionMetadataRecordContextC } /** - * Absolute path to set as the session's new working directory. + * Absolute path to set as the session's new working directory. For local sessions the path must be absolute and exist on disk: it is validated before any session state changes, and a failing validation rejects the call with nothing mutated, persisted, or emitted. Remote sessions record the path as-is. *

* Note: the {@code sessionId} field in the params record is overridden * by the session-scoped wrapper; any value provided is ignored. diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/SessionMetadataSetWorkingDirectoryParams.java b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMetadataSetWorkingDirectoryParams.java index 99ab15b9ba..968cf5d8b6 100644 --- a/java/src/generated/java/com/github/copilot/generated/rpc/SessionMetadataSetWorkingDirectoryParams.java +++ b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMetadataSetWorkingDirectoryParams.java @@ -14,7 +14,7 @@ import javax.annotation.processing.Generated; /** - * Absolute path to set as the session's new working directory. + * Absolute path to set as the session's new working directory. For local sessions the path must be absolute and exist on disk: it is validated before any session state changes, and a failing validation rejects the call with nothing mutated, persisted, or emitted. Remote sessions record the path as-is. * * @apiNote This method is experimental and may change in a future version. * @since 1.0.0 diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/SessionMetadataSetWorkingDirectoryResult.java b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMetadataSetWorkingDirectoryResult.java index 477ba62bea..b0dff14ed1 100644 --- a/java/src/generated/java/com/github/copilot/generated/rpc/SessionMetadataSetWorkingDirectoryResult.java +++ b/java/src/generated/java/com/github/copilot/generated/rpc/SessionMetadataSetWorkingDirectoryResult.java @@ -14,7 +14,7 @@ import javax.annotation.processing.Generated; /** - * Update the session's working directory. Used by the host when the user explicitly changes cwd (e.g., the `/cd` slash command). The host is responsible for `process.chdir` and any related side-effects (file index, etc.); this method only updates the session's own recorded path. + * Update the session's working directory. Used by the host when the user explicitly changes cwd (e.g., the `/cd` slash command). The host is responsible for any related side-effects (file index, etc.); it does NOT change the process working directory (a session's cwd is per-session, not process-global). For local sessions the runtime validates the target first (an absolute path that exists on disk) and re-bases the permission primary directory; a rejected validation fails the call before anything is mutated, persisted, or emitted. Location-scoped permission rules are then re-keyed to the new directory (best-effort). Remote sessions only record the path. * * @apiNote This method is experimental and may change in a future version. * @since 1.0.0 diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/SessionModelListResult.java b/java/src/generated/java/com/github/copilot/generated/rpc/SessionModelListResult.java index f3fd591f62..8951499ef4 100644 --- a/java/src/generated/java/com/github/copilot/generated/rpc/SessionModelListResult.java +++ b/java/src/generated/java/com/github/copilot/generated/rpc/SessionModelListResult.java @@ -28,6 +28,8 @@ public record SessionModelListResult( /** Available models, ordered with the most preferred default first. Includes both Copilot (CAPI) models and any registry BYOK models; a BYOK model appears under its provider-qualified selection id (`provider/id`). */ @JsonProperty("list") List list, + /** Cost categories for the full CAPI catalog, including picker-disabled models that Auto may select. Metadata only; entries absent from `list` are not manually selectable. */ + @JsonProperty("modelPriceCategories") List modelPriceCategories, /** Per-quota snapshots returned alongside the model list, keyed by quota type. */ @JsonProperty("quotaSnapshots") Map quotaSnapshots ) { diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/SessionModelPriceCategory.java b/java/src/generated/java/com/github/copilot/generated/rpc/SessionModelPriceCategory.java new file mode 100644 index 0000000000..295f6a01f0 --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/SessionModelPriceCategory.java @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.annotation.processing.Generated; + +/** + * Cost-category metadata for a CAPI model. + * + * @since 1.0.0 + */ +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record SessionModelPriceCategory( + @JsonProperty("id") String id, + @JsonProperty("priceCategory") ModelPickerPriceCategory priceCategory +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/SessionOptionsUpdateParams.java b/java/src/generated/java/com/github/copilot/generated/rpc/SessionOptionsUpdateParams.java index ba012106a4..fb8c25b3da 100644 --- a/java/src/generated/java/com/github/copilot/generated/rpc/SessionOptionsUpdateParams.java +++ b/java/src/generated/java/com/github/copilot/generated/rpc/SessionOptionsUpdateParams.java @@ -58,6 +58,8 @@ public record SessionOptionsUpdateParams( @JsonProperty("availableTools") List availableTools, /** Denylist of tool names for this session. */ @JsonProperty("excludedTools") List excludedTools, + /** Built-in subagent names to include in this session. When specified, only these built-ins are available, subject to runtime availability and exclusions. Custom agents with the same name remain available. Set to null to remove the allowlist restriction. */ + @JsonProperty("includedBuiltinAgents") List includedBuiltinAgents, /** Built-in subagent names to exclude from this session. Excluded built-ins are hidden from agent discovery and cannot be dispatched unless a custom agent with the same name is available. */ @JsonProperty("excludedBuiltinAgents") List excludedBuiltinAgents, /** Controls how availableTools (allowlist) and excludedTools (denylist) combine when both are set. */ diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/SessionOptionsUpdateResult.java b/java/src/generated/java/com/github/copilot/generated/rpc/SessionOptionsUpdateResult.java index a13e0d0d5e..3d7d274610 100644 --- a/java/src/generated/java/com/github/copilot/generated/rpc/SessionOptionsUpdateResult.java +++ b/java/src/generated/java/com/github/copilot/generated/rpc/SessionOptionsUpdateResult.java @@ -25,6 +25,8 @@ @JsonIgnoreProperties(ignoreUnknown = true) public record SessionOptionsUpdateResult( /** Whether the operation succeeded */ - @JsonProperty("success") Boolean success + @JsonProperty("success") Boolean success, + /** Number of hooks loaded from installed plugins, returned when installedPlugins is updated */ + @JsonProperty("pluginHookCount") Long pluginHookCount ) { } diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/SessionRpc.java b/java/src/generated/java/com/github/copilot/generated/rpc/SessionRpc.java index 1007c0db7b..c150b75679 100644 --- a/java/src/generated/java/com/github/copilot/generated/rpc/SessionRpc.java +++ b/java/src/generated/java/com/github/copilot/generated/rpc/SessionRpc.java @@ -173,6 +173,22 @@ public CompletableFuture send(SessionSendParams params) { return caller.invoke("session.send", _p, SessionSendResult.class); } + /** + * Parameters for sending zero or more user messages to the session in a single turn. Remote-backed (Mission Control) sessions do not support this method and will return an error. + *

+ * Note: the {@code sessionId} field in the params record is overridden + * by the session-scoped wrapper; any value provided is ignored. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ + @CopilotExperimental + public CompletableFuture sendMessages(SessionSendMessagesParams params) { + com.fasterxml.jackson.databind.node.ObjectNode _p = MAPPER.valueToTree(params); + _p.put("sessionId", this.sessionId); + return caller.invoke("session.sendMessages", _p, SessionSendMessagesResult.class); + } + /** * Parameters for aborting the current turn *

diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/SessionSendMessagesParams.java b/java/src/generated/java/com/github/copilot/generated/rpc/SessionSendMessagesParams.java new file mode 100644 index 0000000000..2d66b4fe16 --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/SessionSendMessagesParams.java @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.CopilotExperimental; +import java.util.List; +import java.util.Map; +import javax.annotation.processing.Generated; + +/** + * Parameters for sending zero or more user messages to the session in a single turn. Remote-backed (Mission Control) sessions do not support this method and will return an error. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ +@CopilotExperimental +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record SessionSendMessagesParams( + /** Target session identifier */ + @JsonProperty("sessionId") String sessionId, + /** The user messages to append to the conversation, in order. May be empty, in which case a single turn runs over the existing history with no new user message. */ + @JsonProperty("messages") List messages, + /** How to deliver the messages. `enqueue` (default) appends to the message queue. `immediate` interjects during an in-progress turn. */ + @JsonProperty("mode") SendMode mode, + /** If true, adds the messages to the front of the queue instead of the end */ + @JsonProperty("prepend") Boolean prepend, + /** The UI mode the agent was in when these messages were sent. Defaults to the session's current mode. */ + @JsonProperty("agentMode") SendAgentMode agentMode, + /** Custom HTTP headers to include in outbound model requests for this turn. Merged with session-level provider headers; per-turn headers augment and overwrite session-level headers with the same key. */ + @JsonProperty("requestHeaders") Map requestHeaders, + /** W3C Trace Context traceparent header for distributed tracing of this agent turn */ + @JsonProperty("traceparent") String traceparent, + /** W3C Trace Context tracestate header for distributed tracing */ + @JsonProperty("tracestate") String tracestate, + /** If true, await completion of the agentic loop for this turn before returning. Defaults to false (fire-and-forget). When true, the result still contains the same `messageIds`; the caller can rely on the agent having processed the messages before the call resolves. */ + @JsonProperty("wait") Boolean wait_ +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/SessionSendMessagesResult.java b/java/src/generated/java/com/github/copilot/generated/rpc/SessionSendMessagesResult.java new file mode 100644 index 0000000000..aeb556ba0f --- /dev/null +++ b/java/src/generated/java/com/github/copilot/generated/rpc/SessionSendMessagesResult.java @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.CopilotExperimental; +import java.util.List; +import javax.annotation.processing.Generated; + +/** + * Result of sending zero or more user messages + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ +@CopilotExperimental +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record SessionSendMessagesResult( + /** Unique identifiers assigned to the messages, one per provided message in order. Empty when no messages were provided. */ + @JsonProperty("messageIds") List messageIds +) { +} diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/SlashCommandAgentPromptResult.java b/java/src/generated/java/com/github/copilot/generated/rpc/SlashCommandAgentPromptResult.java index a034458c98..4c454a55a9 100644 --- a/java/src/generated/java/com/github/copilot/generated/rpc/SlashCommandAgentPromptResult.java +++ b/java/src/generated/java/com/github/copilot/generated/rpc/SlashCommandAgentPromptResult.java @@ -13,7 +13,7 @@ import javax.annotation.processing.Generated; /** - * Slash-command invocation result that submits an agent prompt, with display prompt, optional mode, and settings-change flag. + * Slash-command invocation result that submits an agent prompt, with display prompt, optional mode, optional user-facing notice, and settings-change flag. * * @since 1.0.0 */ @@ -40,6 +40,10 @@ public final class SlashCommandAgentPromptResult extends SlashCommandInvocationR @JsonProperty("mode") private SessionMode mode; + /** Optional user-facing notice to show before the prompt is submitted */ + @JsonProperty("notice") + private String notice; + /** True when the invocation mutated user runtime settings; consumers caching settings should refresh */ @JsonProperty("runtimeSettingsChanged") private Boolean runtimeSettingsChanged; @@ -53,6 +57,9 @@ public final class SlashCommandAgentPromptResult extends SlashCommandInvocationR public SessionMode getMode() { return mode; } public void setMode(SessionMode mode) { this.mode = mode; } + public String getNotice() { return notice; } + public void setNotice(String notice) { this.notice = notice; } + public Boolean getRuntimeSettingsChanged() { return runtimeSettingsChanged; } public void setRuntimeSettingsChanged(Boolean runtimeSettingsChanged) { this.runtimeSettingsChanged = runtimeSettingsChanged; } } diff --git a/java/src/main/java/com/github/copilot/CopilotClient.java b/java/src/main/java/com/github/copilot/CopilotClient.java index 01294fdaac..7244c8c0a5 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -937,6 +937,7 @@ CompletableFuture updateSessionOptionsForMode(CopilotSession session, Bool null, // workingDirectory null, // availableTools null, // excludedTools + null, // includedBuiltinAgents null, // excludedBuiltinAgents null, // toolFilterPrecedence null, // enableScriptSafety diff --git a/java/src/main/java/com/github/copilot/CopilotRequestContext.java b/java/src/main/java/com/github/copilot/CopilotRequestContext.java index 0948a24c28..610fb56c6d 100644 --- a/java/src/main/java/com/github/copilot/CopilotRequestContext.java +++ b/java/src/main/java/com/github/copilot/CopilotRequestContext.java @@ -22,6 +22,12 @@ public final class CopilotRequestContext { private final String requestId; @Nullable private final String sessionId; + @Nullable + private final String agentId; + @Nullable + private final String parentAgentId; + @Nullable + private final String interactionType; private final CopilotRequestTransport transport; private final String url; private final Map> headers; @@ -29,20 +35,25 @@ public final class CopilotRequestContext { private LlmWebSocketResponseBridge webSocketResponse; - CopilotRequestContext(String requestId, @Nullable String sessionId, CopilotRequestTransport transport, String url, - Map> headers, CompletableFuture cancellation) { + CopilotRequestContext(String requestId, @Nullable String sessionId, @Nullable String agentId, + @Nullable String parentAgentId, @Nullable String interactionType, CopilotRequestTransport transport, + String url, Map> headers, CompletableFuture cancellation) { this.requestId = requestId; this.sessionId = sessionId; + this.agentId = agentId; + this.parentAgentId = parentAgentId; + this.interactionType = interactionType; this.transport = transport; this.url = url; this.headers = headers; this.cancellation = cancellation; } - private CopilotRequestContext(String requestId, @Nullable String sessionId, CopilotRequestTransport transport, + private CopilotRequestContext(String requestId, @Nullable String sessionId, @Nullable String agentId, + @Nullable String parentAgentId, @Nullable String interactionType, CopilotRequestTransport transport, String url, Map> headers, CompletableFuture cancellation, LlmWebSocketResponseBridge webSocketResponse) { - this(requestId, sessionId, transport, url, headers, cancellation); + this(requestId, sessionId, agentId, parentAgentId, interactionType, transport, url, headers, cancellation); this.webSocketResponse = webSocketResponse; } @@ -68,6 +79,39 @@ public String sessionId() { return sessionId; } + /** + * Gets the stable per-agent-instance id for the agent trajectory that issued + * this request, or {@code null} when no agent is in scope. + * + * @return the agent id, or {@code null} + */ + @Nullable + public String agentId() { + return agentId; + } + + /** + * Gets the id of the parent agent when this request was issued by a subagent, + * or {@code null} for root-agent and non-agent requests. + * + * @return the parent agent id, or {@code null} + */ + @Nullable + public String parentAgentId() { + return parentAgentId; + } + + /** + * Gets the runtime classification for the interaction that produced this + * request, or {@code null} when the runtime did not classify it. + * + * @return the interaction type, or {@code null} + */ + @Nullable + public String interactionType() { + return interactionType; + } + /** * Gets the transport the runtime would otherwise use. * @@ -103,8 +147,8 @@ public Map> headers() { * @return the copied context */ public CopilotRequestContext withUrl(String url) { - return new CopilotRequestContext(requestId, sessionId, transport, url, headers, cancellation, - webSocketResponse); + return new CopilotRequestContext(requestId, sessionId, agentId, parentAgentId, interactionType, transport, url, + headers, cancellation, webSocketResponse); } /** @@ -115,8 +159,8 @@ public CopilotRequestContext withUrl(String url) { * @return the copied context */ public CopilotRequestContext withHeaders(Map> headers) { - return new CopilotRequestContext(requestId, sessionId, transport, url, headers, cancellation, - webSocketResponse); + return new CopilotRequestContext(requestId, sessionId, agentId, parentAgentId, interactionType, transport, url, + headers, cancellation, webSocketResponse); } /** diff --git a/java/src/main/java/com/github/copilot/CopilotSession.java b/java/src/main/java/com/github/copilot/CopilotSession.java index 3fe0de9889..4826b3309f 100644 --- a/java/src/main/java/com/github/copilot/CopilotSession.java +++ b/java/src/main/java/com/github/copilot/CopilotSession.java @@ -158,6 +158,13 @@ public final class CopilotSession implements AutoCloseable { private static final Logger LOG = Logger.getLogger(CopilotSession.class.getName()); private static final ObjectMapper MAPPER = JsonRpcClient.getObjectMapper(); + /** + * Fixed name of the runtime's built-in tool-search tool. A client can replace + * its behavior by registering a tool with this exact name and + * {@code overridesBuiltInTool} set to {@code true}. + */ + private static final String TOOL_SEARCH_TOOL_NAME = "tool_search_tool"; + /** * 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 @@ -898,6 +905,32 @@ private void handleBroadcastEventAsync(SessionEvent event) { } } + /** + * Populates the invocation's available-tools snapshot when it targets the + * built-in tool-search tool, so an override can filter the live catalog without + * issuing its own RPC. The snapshot is fetched only for that tool to avoid a + * round-trip on every ordinary tool call; a failed fetch leaves the snapshot + * {@code null} rather than failing the tool. Shared by both server-to-client + * tool dispatch paths ({@link RpcHandlerDispatcher} and + * {@link #executeToolAndRespondAsync}). + * + * @param toolName + * the name of the tool being invoked + * @param invocation + * the invocation to populate in place + */ + void populateToolSearchMetadata(String toolName, com.github.copilot.rpc.ToolInvocation invocation) { + if (!TOOL_SEARCH_TOOL_NAME.equals(toolName)) { + return; + } + try { + var metadata = getRpc().tools.getCurrentMetadata().join(); + invocation.setAvailableTools(metadata.tools()); + } catch (Exception e) { + LOG.log(Level.FINE, "Failed to fetch tool metadata for tool search", e); + } + } + /** * Executes a tool handler and sends the result back via * {@code session.tools.handlePendingToolCall}. @@ -912,6 +945,8 @@ private void executeToolAndRespondAsync(String requestId, String toolName, Strin var invocation = new com.github.copilot.rpc.ToolInvocation().setSessionId(sessionId) .setToolCallId(toolCallId).setToolName(toolName).setArguments(argumentsNode); + populateToolSearchMetadata(toolName, invocation); + tool.handler().invoke(invocation).thenAccept(result -> { try { ToolResultObject toolResult; @@ -1564,7 +1599,7 @@ private void updateOpenCanvasesFromEvent(SessionEvent event) { return; } upsertOpenCanvas(new OpenCanvasInstance(data.instanceId(), data.extensionId(), data.extensionName(), - data.canvasId(), data.title(), data.status(), data.url(), data.input())); + data.canvasId(), data.icon(), data.title(), data.status(), data.url(), data.input())); } } @@ -1910,12 +1945,12 @@ public CompletableFuture abort() { * preserved. * *

{@code
-     * session.setModel("gpt-4.1").get();
+     * session.setModel("gpt-5.4").get();
      * session.setModel("claude-sonnet-4.6", "high").get();
      * }
* * @param model - * the model ID to switch to (e.g., {@code "gpt-4.1"}) + * the model ID to switch to (e.g., {@code "gpt-5.4"}) * @param reasoningEffort * reasoning effort level (e.g., {@code "low"}, {@code "medium"}, * {@code "high"}, {@code "xhigh"}); {@code null} to use default @@ -1945,7 +1980,7 @@ public CompletableFuture setModel(String model, String reasoningEffort) { * } * * @param model - * the model ID to switch to (e.g., {@code "gpt-4.1"}) + * the model ID to switch to (e.g., {@code "gpt-5.4"}) * @param reasoningEffort * reasoning effort level (e.g., {@code "low"}, {@code "medium"}, * {@code "high"}, {@code "xhigh"}); {@code null} to use default @@ -1970,7 +2005,7 @@ public CompletableFuture setModel(String model, String reasoningEffort, * preserved. * * @param model - * the model ID to switch to (e.g., {@code "gpt-4.1"}) + * the model ID to switch to (e.g., {@code "gpt-5.4"}) * @param reasoningEffort * reasoning effort level; {@code null} to use default * @param reasoningSummary @@ -2018,11 +2053,11 @@ public CompletableFuture setModel(String model, String reasoningEffort, St * preserved. * *
{@code
-     * session.setModel("gpt-4.1").get();
+     * session.setModel("gpt-5.4").get();
      * }
* * @param model - * the model ID to switch to (e.g., {@code "gpt-4.1"}) + * the model ID to switch to (e.g., {@code "gpt-5.4"}) * @return a future that completes when the model switch is acknowledged * @throws IllegalStateException * if this session has been terminated diff --git a/java/src/main/java/com/github/copilot/LlmInferenceAdapter.java b/java/src/main/java/com/github/copilot/LlmInferenceAdapter.java index 9087df6c1f..3e741a56bc 100644 --- a/java/src/main/java/com/github/copilot/LlmInferenceAdapter.java +++ b/java/src/main/java/com/github/copilot/LlmInferenceAdapter.java @@ -65,6 +65,9 @@ private LlmInferenceExchange getOrCreateExchange(String requestId) { private void handleRequestStart(JsonRpcClient rpc, String rpcId, JsonNode params) { String requestId = params.get("requestId").asText(); String sessionId = textOrNull(params, "sessionId"); + String agentId = textOrNull(params, "agentId"); + String parentAgentId = textOrNull(params, "parentAgentId"); + String interactionType = textOrNull(params, "interactionType"); String method = textOrNull(params, "method"); String url = textOrNull(params, "url"); CopilotRequestTransport transport = CopilotRequestTransport.fromWire(textOrNull(params, "transport")); @@ -74,8 +77,8 @@ private void handleRequestStart(JsonRpcClient rpc, String rpcId, JsonNode params // body — rather than dropping those frames. LlmInferenceExchange exchange = getOrCreateExchange(requestId); exchange.setMethod(method); - exchange.setContext( - new CopilotRequestContext(requestId, sessionId, transport, url, headers, exchange.cancellation())); + exchange.setContext(new CopilotRequestContext(requestId, sessionId, agentId, parentAgentId, interactionType, + transport, url, headers, exchange.cancellation())); // Return from httpRequestStart immediately (after registering state) so the // runtime's RPC reply is not gated on the consumer's I/O. The actual handler diff --git a/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java b/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java index 9a42a8e22d..d2dff958dc 100644 --- a/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java +++ b/java/src/main/java/com/github/copilot/RpcHandlerDispatcher.java @@ -162,6 +162,8 @@ private void handleToolCall(JsonRpcClient rpc, String requestId, JsonNode params var invocation = new ToolInvocation().setSessionId(sessionId).setToolCallId(toolCallId) .setToolName(toolName).setArguments(arguments); + session.populateToolSearchMetadata(toolName, invocation); + tool.handler().invoke(invocation).thenAccept(result -> { try { ToolResultObject toolResult; diff --git a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java index f88bf2a85e..57d9a46a21 100644 --- a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java +++ b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java @@ -145,6 +145,7 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess request.setInstructionDirectories(config.getInstructionDirectories()); request.setPluginDirectories(config.getPluginDirectories()); request.setLargeOutput(config.getLargeOutput()); + request.setToolSearch(config.getToolSearch()); request.setMemory(config.getMemory()); request.setDisabledSkills(config.getDisabledSkills()); request.setConfigDirectory(config.getConfigDirectory()); @@ -185,6 +186,7 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess request.setRemoteSession(config.getRemoteSession()); request.setCloud(config.getCloud()); request.setExpAssignments(config.getExpAssignments()); + config.getEnableManagedSettings().ifPresent(request::setEnableManagedSettings); return request; } @@ -280,6 +282,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo request.setInstructionDirectories(config.getInstructionDirectories()); request.setPluginDirectories(config.getPluginDirectories()); request.setLargeOutput(config.getLargeOutput()); + request.setToolSearch(config.getToolSearch()); request.setMemory(config.getMemory()); request.setDisabledSkills(config.getDisabledSkills()); request.setInfiniteSessions(config.getInfiniteSessions()); @@ -306,6 +309,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo request.setGitHubToken(config.getGitHubToken()); request.setRemoteSession(config.getRemoteSession()); request.setExpAssignments(config.getExpAssignments()); + config.getEnableManagedSettings().ifPresent(request::setEnableManagedSettings); return request; } diff --git a/java/src/main/java/com/github/copilot/package-info.java b/java/src/main/java/com/github/copilot/package-info.java index 71025f07af..0e0b2cf824 100644 --- a/java/src/main/java/com/github/copilot/package-info.java +++ b/java/src/main/java/com/github/copilot/package-info.java @@ -31,7 +31,7 @@ * try (var client = new CopilotClient()) { * client.start().get(); * - * var session = client.createSession(new SessionConfig().setModel("gpt-4.1")).get(); + * var session = client.createSession(new SessionConfig().setModel("gpt-5.4")).get(); * * session.on(AssistantMessageEvent.class, msg -> { * System.out.println(msg.getData().content()); diff --git a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java index 2510b0bd6e..f2ce097a13 100644 --- a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -138,6 +138,9 @@ public final class CreateSessionRequest { @JsonProperty("largeOutput") private LargeToolOutputConfig largeOutput; + @JsonProperty("toolSearch") + private ToolSearchConfig toolSearch; + @JsonProperty("memory") private MemoryConfiguration memory; @@ -212,6 +215,10 @@ public final class CreateSessionRequest { @JsonProperty("expAssignments") private JsonNode expAssignments; + @JsonProperty("enableManagedSettings") + @JsonInclude(JsonInclude.Include.NON_NULL) + private Boolean enableManagedSettings; + /** Gets the model name. @return the model */ public String getModel() { return model; @@ -620,6 +627,16 @@ public void setLargeOutput(LargeToolOutputConfig largeOutput) { this.largeOutput = largeOutput; } + /** Gets tool-search config. @return the tool-search config */ + public ToolSearchConfig getToolSearch() { + return toolSearch; + } + + /** Sets tool-search config. @param toolSearch the tool-search config */ + public void setToolSearch(ToolSearchConfig toolSearch) { + this.toolSearch = toolSearch; + } + /** Gets memory config. @return the memory config */ public MemoryConfiguration getMemory() { return memory; @@ -966,4 +983,27 @@ public JsonNode getExpAssignments() { public void setExpAssignments(JsonNode expAssignments) { this.expAssignments = expAssignments; } + + /** + * Gets the self-fetch managed settings flag. @return the flag, or {@code null} + * if not set + */ + public Boolean getEnableManagedSettings() { + return enableManagedSettings; + } + + /** + * Sets the self-fetch managed settings flag. @param enableManagedSettings the + * flag + */ + public void setEnableManagedSettings(boolean enableManagedSettings) { + this.enableManagedSettings = enableManagedSettings; + } + + /** + * Clears the enableManagedSettings setting, reverting to the default behavior. + */ + public void clearEnableManagedSettings() { + this.enableManagedSettings = null; + } } diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java index 83b365a410..6f3c315658 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -90,6 +90,7 @@ public class ResumeSessionConfig { private List instructionDirectories; private List pluginDirectories; private LargeToolOutputConfig largeOutput; + private ToolSearchConfig toolSearch; private MemoryConfiguration memory; private List disabledSkills; private InfiniteSessionConfig infiniteSessions; @@ -102,6 +103,7 @@ public class ResumeSessionConfig { private String gitHubToken; private String remoteSession; private JsonNode expAssignments; + private Boolean enableManagedSettings; /** * Gets the AI model to use. @@ -1446,6 +1448,27 @@ public ResumeSessionConfig setLargeOutput(LargeToolOutputConfig largeOutput) { return this; } + /** + * Gets the tool-search configuration. + * + * @return the tool-search config, or {@code null} for the runtime default + */ + public ToolSearchConfig getToolSearch() { + return toolSearch; + } + + /** + * Sets the tool-search configuration. + * + * @param toolSearch + * the tool-search config + * @return this config for method chaining + */ + public ResumeSessionConfig setToolSearch(ToolSearchConfig toolSearch) { + this.toolSearch = toolSearch; + return this; + } + /** * Gets the configuration for session memory. * @@ -1745,6 +1768,36 @@ public ResumeSessionConfig setExpAssignments(JsonNode expAssignments) { return this; } + /** + * Gets whether the runtime self-fetches enterprise managed settings at session + * bootstrap on resume. + * + * @return an {@link java.util.Optional} containing {@code true} to opt into + * self-fetching managed settings, or {@link java.util.Optional#empty()} + * to use the default behavior + */ + @JsonIgnore + public Optional getEnableManagedSettings() { + return Optional.ofNullable(enableManagedSettings); + } + + /** + * Opts the runtime into self-fetching enterprise managed settings on resume. + *

+ * See {@link SessionConfig#setEnableManagedSettings(boolean)} for details. + * Re-supply on resume so the runtime re-applies the managed-settings self-fetch + * after a CLI process restart. Serialized on the wire as + * {@code enableManagedSettings}. + * + * @param enableManagedSettings + * {@code true} to opt into self-fetching managed settings + * @return this config for method chaining + */ + public ResumeSessionConfig setEnableManagedSettings(boolean enableManagedSettings) { + this.enableManagedSettings = enableManagedSettings; + return this; + } + /** * Creates a shallow clone of this {@code ResumeSessionConfig} instance. *

@@ -1806,6 +1859,7 @@ public ResumeSessionConfig clone() { : null; copy.pluginDirectories = this.pluginDirectories != null ? new ArrayList<>(this.pluginDirectories) : null; copy.largeOutput = this.largeOutput; + copy.toolSearch = this.toolSearch; copy.memory = this.memory; copy.disabledSkills = this.disabledSkills != null ? new ArrayList<>(this.disabledSkills) : null; copy.infiniteSessions = this.infiniteSessions; @@ -1819,6 +1873,7 @@ public ResumeSessionConfig clone() { copy.gitHubToken = this.gitHubToken; copy.remoteSession = this.remoteSession; copy.expAssignments = this.expAssignments; + copy.enableManagedSettings = this.enableManagedSettings; return copy; } } diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java index 4917f1d8cc..ab848118e6 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -178,6 +178,9 @@ public final class ResumeSessionRequest { @JsonProperty("largeOutput") private LargeToolOutputConfig largeOutput; + @JsonProperty("toolSearch") + private ToolSearchConfig toolSearch; + @JsonProperty("memory") private MemoryConfiguration memory; @@ -214,6 +217,10 @@ public final class ResumeSessionRequest { @JsonProperty("expAssignments") private JsonNode expAssignments; + @JsonProperty("enableManagedSettings") + @JsonInclude(JsonInclude.Include.NON_NULL) + private Boolean enableManagedSettings; + /** Gets the session ID. @return the session ID */ public String getSessionId() { return sessionId; @@ -836,6 +843,16 @@ public void setLargeOutput(LargeToolOutputConfig largeOutput) { this.largeOutput = largeOutput; } + /** Gets tool-search config. @return the tool-search config */ + public ToolSearchConfig getToolSearch() { + return toolSearch; + } + + /** Sets tool-search config. @param toolSearch the tool-search config */ + public void setToolSearch(ToolSearchConfig toolSearch) { + this.toolSearch = toolSearch; + } + /** Gets memory config. @return the memory config */ public MemoryConfiguration getMemory() { return memory; @@ -981,4 +998,27 @@ public JsonNode getExpAssignments() { public void setExpAssignments(JsonNode expAssignments) { this.expAssignments = expAssignments; } + + /** + * Gets the self-fetch managed settings flag. @return the flag, or {@code null} + * if not set + */ + public Boolean getEnableManagedSettings() { + return enableManagedSettings; + } + + /** + * Sets the self-fetch managed settings flag. @param enableManagedSettings the + * flag + */ + public void setEnableManagedSettings(boolean enableManagedSettings) { + this.enableManagedSettings = enableManagedSettings; + } + + /** + * Clears the enableManagedSettings setting, reverting to the default behavior. + */ + public void clearEnableManagedSettings() { + this.enableManagedSettings = null; + } } diff --git a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java index cc27386c0b..e611f66c89 100644 --- a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -80,6 +80,7 @@ public class SessionConfig { private List instructionDirectories; private List pluginDirectories; private LargeToolOutputConfig largeOutput; + private ToolSearchConfig toolSearch; private MemoryConfiguration memory; private List disabledSkills; private String configDirectory; @@ -103,6 +104,7 @@ public class SessionConfig { private String remoteSession; private CloudSessionOptions cloud; private JsonNode expAssignments; + private Boolean enableManagedSettings; /** * Gets the custom session ID. @@ -1129,6 +1131,28 @@ public SessionConfig setLargeOutput(LargeToolOutputConfig largeOutput) { return this; } + /** + * Gets the tool-search override configuration. + * + * @return the tool-search config, or {@code null} for the runtime default + */ + public ToolSearchConfig getToolSearch() { + return toolSearch; + } + + /** + * Sets the tool-search override configuration. When {@code null}, the runtime + * default tool-search behavior applies. + * + * @param toolSearch + * the tool-search config + * @return this config instance for method chaining + */ + public SessionConfig setToolSearch(ToolSearchConfig toolSearch) { + this.toolSearch = toolSearch; + return this; + } + /** * Gets the configuration for session memory. * @@ -1876,6 +1900,38 @@ public SessionConfig setExpAssignments(JsonNode expAssignments) { return this; } + /** + * Gets whether the runtime self-fetches enterprise managed settings at session + * bootstrap. + * + * @return an {@link java.util.Optional} containing {@code true} to opt into + * self-fetching managed settings, or {@link java.util.Optional#empty()} + * to use the default behavior + */ + @JsonIgnore + public Optional getEnableManagedSettings() { + return Optional.ofNullable(enableManagedSettings); + } + + /** + * Opts the runtime into self-fetching enterprise managed settings + * (bypass-permissions policy) at session bootstrap. + *

+ * When {@code true}, the runtime self-fetches enterprise managed settings using + * the session's {@link #getGitHubToken() gitHubToken}. Requires + * {@code gitHubToken} to be set; if omitted, the runtime is expected to reject + * session creation (fail-closed). When unset, behaves exactly as before. + * Serialized on the wire as {@code enableManagedSettings}. + * + * @param enableManagedSettings + * {@code true} to opt into self-fetching managed settings + * @return this config instance for method chaining + */ + public SessionConfig setEnableManagedSettings(boolean enableManagedSettings) { + this.enableManagedSettings = enableManagedSettings; + return this; + } + /** * Creates a shallow clone of this {@code SessionConfig} instance. *

@@ -1931,6 +1987,7 @@ public SessionConfig clone() { : null; copy.pluginDirectories = this.pluginDirectories != null ? new ArrayList<>(this.pluginDirectories) : null; copy.largeOutput = this.largeOutput; + copy.toolSearch = this.toolSearch; copy.memory = this.memory; copy.disabledSkills = this.disabledSkills != null ? new ArrayList<>(this.disabledSkills) : null; copy.configDirectory = this.configDirectory; @@ -1955,6 +2012,7 @@ public SessionConfig clone() { copy.remoteSession = this.remoteSession; copy.cloud = this.cloud; copy.expAssignments = this.expAssignments; + copy.enableManagedSettings = this.enableManagedSettings; return copy; } } diff --git a/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java b/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java index 8a336c749a..ccf0ef5309 100644 --- a/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java +++ b/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java @@ -75,6 +75,9 @@ * controls whether the tool may be deferred (loaded lazily via tool * search) rather than always pre-loaded; {@code null} lets the * runtime decide + * @param metadata + * opaque, host-defined metadata; keys are namespaced and not part of + * the stable public API; {@code null} when unset * @see SessionConfig#setTools(java.util.List) * @see ToolHandler * @since 1.0.0 @@ -83,7 +86,36 @@ public record ToolDefinition(@JsonProperty("name") String name, @JsonProperty("description") String description, @JsonProperty("parameters") Object parameters, @JsonIgnore ToolHandler handler, @JsonProperty("overridesBuiltInTool") Boolean overridesBuiltInTool, - @JsonProperty("skipPermission") Boolean skipPermission, @JsonProperty("defer") ToolDefer defer) { + @JsonProperty("skipPermission") Boolean skipPermission, @JsonProperty("defer") ToolDefer defer, + @JsonProperty("metadata") Map metadata) { + + /** + * Creates a tool definition without a {@code metadata} bag. + *

+ * Convenience overload equivalent to the canonical constructor with + * {@code metadata} set to {@code null}. + * + * @param name + * the unique name of the tool + * @param description + * a description of what the tool does + * @param parameters + * the JSON Schema for the tool's parameters + * @param handler + * the handler function to execute when invoked + * @param overridesBuiltInTool + * whether this tool overrides a built-in tool; {@code null} for the + * default + * @param skipPermission + * whether the tool may run without a permission check; {@code null} + * for the default + * @param defer + * the deferral mode; {@code null} lets the runtime decide + */ + public ToolDefinition(String name, String description, Object parameters, ToolHandler handler, + Boolean overridesBuiltInTool, Boolean skipPermission, ToolDefer defer) { + this(name, description, parameters, handler, overridesBuiltInTool, skipPermission, defer, null); + } /** * Creates a tool definition with a JSON schema for parameters. @@ -103,7 +135,7 @@ 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, null, null, null); + return new ToolDefinition(name, description, schema, handler, null, null, null, null); } /** @@ -127,7 +159,7 @@ public static ToolDefinition create(String name, String description, Map schema, ToolHandler handler) { - return new ToolDefinition(name, description, schema, handler, true, null, null); + return new ToolDefinition(name, description, schema, handler, true, null, null, null); } /** @@ -150,7 +182,7 @@ public static ToolDefinition createOverride(String name, String description, Map */ public static ToolDefinition createSkipPermission(String name, String description, Map schema, ToolHandler handler) { - return new ToolDefinition(name, description, schema, handler, null, true, null); + return new ToolDefinition(name, description, schema, handler, null, true, null, null); } /** @@ -176,7 +208,32 @@ public static ToolDefinition createSkipPermission(String name, String descriptio */ public static ToolDefinition createWithDefer(String name, String description, Map schema, ToolHandler handler, ToolDefer defer) { - return new ToolDefinition(name, description, schema, handler, null, null, defer); + return new ToolDefinition(name, description, schema, handler, null, null, defer, null); + } + + /** + * Creates a tool definition with opaque, host-defined metadata. + *

+ * Use this factory method to attach namespaced metadata to the tool. The keys + * are not part of the stable public API; specific keys may be recognized to + * inform host-specific behavior. + * + * @param name + * the unique name of the tool + * @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 + * @param metadata + * the opaque metadata map + * @return a new tool definition with the metadata set + * @since 1.0.7 + */ + public static ToolDefinition createWithMetadata(String name, String description, Map schema, + ToolHandler handler, Map metadata) { + return new ToolDefinition(name, description, schema, handler, null, null, null, metadata); } /** @@ -247,7 +304,7 @@ public static List fromClass(Class clazz) { */ @CopilotExperimental public ToolDefinition overridesBuiltInTool(boolean value) { - return new ToolDefinition(name, description, parameters, handler, value, skipPermission, defer); + return new ToolDefinition(name, description, parameters, handler, value, skipPermission, defer, metadata); } /** @@ -261,7 +318,7 @@ public ToolDefinition overridesBuiltInTool(boolean value) { */ @CopilotExperimental public ToolDefinition skipPermission(boolean value) { - return new ToolDefinition(name, description, parameters, handler, overridesBuiltInTool, value, defer); + return new ToolDefinition(name, description, parameters, handler, overridesBuiltInTool, value, defer, metadata); } /** @@ -275,7 +332,23 @@ public ToolDefinition skipPermission(boolean value) { */ @CopilotExperimental public ToolDefinition defer(ToolDefer value) { - return new ToolDefinition(name, description, parameters, handler, overridesBuiltInTool, skipPermission, value); + return new ToolDefinition(name, description, parameters, handler, overridesBuiltInTool, skipPermission, value, + metadata); + } + + /** + * Returns a copy with the opaque {@code metadata} bag set. + * + * @param value + * the opaque, host-defined metadata; keys are namespaced and not + * part of the stable public API + * @return a new {@code ToolDefinition} with the metadata applied + * @since 1.0.7 + */ + @CopilotExperimental + public ToolDefinition metadata(Map value) { + return new ToolDefinition(name, description, parameters, handler, overridesBuiltInTool, skipPermission, defer, + value); } // ------------------------------------------------------------------ @@ -319,7 +392,7 @@ public static ToolDefinition from(String name, String description, Supplier< R result = handler.get(); return CompletableFuture.completedFuture(formatResult(result, mapper)); }; - return new ToolDefinition(name, description, schema, toolHandler, null, null, null); + return new ToolDefinition(name, description, schema, toolHandler, null, null, null, null); } /** @@ -361,7 +434,7 @@ public static ToolDefinition from(String name, String description, Param R result = handler.apply(arg1); return CompletableFuture.completedFuture(formatResult(result, mapper)); }; - return new ToolDefinition(name, description, schema, toolHandler, null, null, null); + return new ToolDefinition(name, description, schema, toolHandler, null, null, null, null); } /** @@ -409,7 +482,7 @@ public static ToolDefinition from(String name, String description, P R result = handler.apply(arg1, arg2); return CompletableFuture.completedFuture(formatResult(result, mapper)); }; - return new ToolDefinition(name, description, schema, toolHandler, null, null, null); + return new ToolDefinition(name, description, schema, toolHandler, null, null, null, null); } // ------------------------------------------------------------------ @@ -458,7 +531,7 @@ public static ToolDefinition fromAsync(String name, String description, } return future.thenApply(result -> formatResult(result, mapper)); }; - return new ToolDefinition(name, description, schema, toolHandler, null, null, null); + return new ToolDefinition(name, description, schema, toolHandler, null, null, null, null); } /** @@ -506,7 +579,7 @@ public static ToolDefinition fromAsync(String name, String description, } return future.thenApply(result -> formatResult(result, mapper)); }; - return new ToolDefinition(name, description, schema, toolHandler, null, null, null); + return new ToolDefinition(name, description, schema, toolHandler, null, null, null, null); } /** @@ -551,7 +624,7 @@ public static ToolDefinition fromAsync(String name, String descripti } return future.thenApply(result -> formatResult(result, mapper)); }; - return new ToolDefinition(name, description, schema, toolHandler, null, null, null); + return new ToolDefinition(name, description, schema, toolHandler, null, null, null, null); } // ------------------------------------------------------------------ @@ -594,7 +667,7 @@ public static ToolDefinition fromWithToolInvocation(String name, String desc R result = handler.apply(invocation); return CompletableFuture.completedFuture(formatResult(result, mapper)); }; - return new ToolDefinition(name, description, schema, toolHandler, null, null, null); + return new ToolDefinition(name, description, schema, toolHandler, null, null, null, null); } /** @@ -640,7 +713,7 @@ public static ToolDefinition fromWithToolInvocation(String name, String R result = handler.apply(arg1, invocation); return CompletableFuture.completedFuture(formatResult(result, mapper)); }; - return new ToolDefinition(name, description, schema, toolHandler, null, null, null); + return new ToolDefinition(name, description, schema, toolHandler, null, null, null, null); } // ------------------------------------------------------------------ @@ -689,7 +762,7 @@ public static ToolDefinition fromAsyncWithToolInvocation(String name, String } return future.thenApply(result -> formatResult(result, mapper)); }; - return new ToolDefinition(name, description, schema, toolHandler, null, null, null); + return new ToolDefinition(name, description, schema, toolHandler, null, null, null, null); } /** @@ -741,7 +814,7 @@ public static ToolDefinition fromAsyncWithToolInvocation(String name, St } return future.thenApply(result -> formatResult(result, mapper)); }; - return new ToolDefinition(name, description, schema, toolHandler, null, null, null); + return new ToolDefinition(name, description, schema, toolHandler, null, null, null, null); } // ------------------------------------------------------------------ diff --git a/java/src/main/java/com/github/copilot/rpc/ToolInvocation.java b/java/src/main/java/com/github/copilot/rpc/ToolInvocation.java index 048fede64a..efe24fd6ae 100644 --- a/java/src/main/java/com/github/copilot/rpc/ToolInvocation.java +++ b/java/src/main/java/com/github/copilot/rpc/ToolInvocation.java @@ -4,6 +4,7 @@ package com.github.copilot.rpc; +import java.util.List; import java.util.Map; import com.fasterxml.jackson.annotation.JsonInclude; @@ -11,6 +12,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.generated.rpc.CurrentToolMetadata; /** * Represents a tool invocation request from the AI assistant. @@ -40,6 +42,7 @@ public final class ToolInvocation { private String toolCallId; private String toolName; private JsonNode argumentsNode; + private List availableTools; /** * Gets the session ID where the tool was invoked. @@ -174,4 +177,35 @@ public ToolInvocation setArguments(JsonNode arguments) { this.argumentsNode = arguments; return this; } + + /** + * Gets a snapshot of the session's currently initialized tools. + *

+ * The SDK populates this only when the invocation targets the built-in + * tool-search tool ({@code "tool_search_tool"}), so a tool-search override can + * rank or filter the live catalog — including MCP tools configured in settings + * — without issuing its own RPC. It is {@code null} for every other tool + * invocation. + * + * @return the available tools snapshot, or {@code null} if not applicable + * @since 1.0.7 + */ + public List getAvailableTools() { + return availableTools; + } + + /** + * Sets the available tools snapshot. + *

+ * Note: This method is intended for internal SDK use. Users + * typically do not need to call this method directly. + * + * @param availableTools + * the available tools snapshot + * @return this invocation for method chaining + */ + public ToolInvocation setAvailableTools(List availableTools) { + this.availableTools = availableTools; + return this; + } } diff --git a/java/src/main/java/com/github/copilot/rpc/ToolResultObject.java b/java/src/main/java/com/github/copilot/rpc/ToolResultObject.java index e55ff9ab6e..2e101acbd7 100644 --- a/java/src/main/java/com/github/copilot/rpc/ToolResultObject.java +++ b/java/src/main/java/com/github/copilot/rpc/ToolResultObject.java @@ -31,7 +31,7 @@ *

Example: Custom Result

* *
{@code
- * return new ToolResultObject("success", "Result text", null, null, null, null);
+ * return new ToolResultObject("success", "Result text", null, null, null, null, null);
  * }
* * @param resultType @@ -46,6 +46,8 @@ * the session log text * @param toolTelemetry * the tool telemetry data + * @param toolReferences + * names of tools returned by a tool-search tool * @see ToolHandler * @see ToolBinaryResult * @since 1.0.0 @@ -55,7 +57,33 @@ public record ToolResultObject(@JsonProperty("resultType") String resultType, @JsonProperty("textResultForLlm") String textResultForLlm, @JsonProperty("binaryResultsForLlm") List binaryResultsForLlm, @JsonProperty("error") String error, @JsonProperty("sessionLog") String sessionLog, - @JsonProperty("toolTelemetry") Map toolTelemetry) { + @JsonProperty("toolTelemetry") Map toolTelemetry, + @JsonProperty("toolReferences") List toolReferences) { + + /** + * Creates a result without tool references. + *

+ * Provided for source and binary compatibility with callers written or compiled + * before the {@code toolReferences} component was added. Delegates to the + * canonical constructor with {@code toolReferences} set to {@code null}. + * + * @param resultType + * the result type ("success" or "error"), defaults to "success" + * @param textResultForLlm + * the text result to be sent to the LLM + * @param binaryResultsForLlm + * the list of binary results to be sent to the LLM + * @param error + * the error message, or {@code null} if successful + * @param sessionLog + * the session log text + * @param toolTelemetry + * the tool telemetry data + */ + public ToolResultObject(String resultType, String textResultForLlm, List binaryResultsForLlm, + String error, String sessionLog, Map toolTelemetry) { + this(resultType, textResultForLlm, binaryResultsForLlm, error, sessionLog, toolTelemetry, null); + } /** * Creates a success result with the given text. @@ -65,7 +93,7 @@ public record ToolResultObject(@JsonProperty("resultType") String resultType, * @return a success result */ public static ToolResultObject success(String textResultForLlm) { - return new ToolResultObject("success", textResultForLlm, null, null, null, null); + return new ToolResultObject("success", textResultForLlm, null, null, null, null, null); } /** @@ -76,7 +104,7 @@ public static ToolResultObject success(String textResultForLlm) { * @return an error result */ public static ToolResultObject error(String error) { - return new ToolResultObject("error", null, null, error, null, null); + return new ToolResultObject("error", null, null, error, null, null, null); } /** @@ -89,7 +117,7 @@ public static ToolResultObject error(String error) { * @return an error result */ public static ToolResultObject error(String textResultForLlm, String error) { - return new ToolResultObject("error", textResultForLlm, null, error, null, null); + return new ToolResultObject("error", textResultForLlm, null, error, null, null, null); } /** @@ -106,6 +134,6 @@ public static ToolResultObject error(String textResultForLlm, String error) { * @return a failure result */ public static ToolResultObject failure(String textResultForLlm, String error) { - return new ToolResultObject("failure", textResultForLlm, null, error, null, null); + return new ToolResultObject("failure", textResultForLlm, null, error, null, null, null); } } diff --git a/java/src/main/java/com/github/copilot/rpc/ToolSearchConfig.java b/java/src/main/java/com/github/copilot/rpc/ToolSearchConfig.java new file mode 100644 index 0000000000..dd27ddf868 --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/ToolSearchConfig.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +package com.github.copilot.rpc; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Overrides the runtime's built-in tool-search behavior. + *

+ * Tool search defers tools to keep the model's active tool set small. To + * override the tool-search tool's implementation, register a tool named + * {@code "tool_search_tool"} with {@code overridesBuiltInTool} set to + * {@code true}. + * + * @since 1.3.0 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ToolSearchConfig { + + @JsonProperty("enabled") + private Boolean enabled; + + @JsonProperty("deferThreshold") + private Integer deferThreshold; + + /** + * Gets whether tool search is enabled. + * + * @return {@code true} if enabled, {@code false} if disabled, or {@code null} + * for the runtime default + */ + public Boolean getEnabled() { + return enabled; + } + + /** + * Toggle that enables or disables tool search. + * + * @param enabled + * {@code true} to enable, {@code false} to disable + * @return this config for method chaining + */ + public ToolSearchConfig setEnabled(Boolean enabled) { + this.enabled = enabled; + return this; + } + + /** + * Gets the tool count above which MCP and external tools are deferred behind + * tool search. + * + * @return the defer threshold, or {@code null} for the runtime default (30) + */ + public Integer getDeferThreshold() { + return deferThreshold; + } + + /** + * Sets the tool count above which MCP and external tools are deferred behind + * tool search. Defaults to the runtime default (30) when unset. + * + * @param deferThreshold + * the threshold value + * @return this config for method chaining + */ + public ToolSearchConfig setDeferThreshold(Integer deferThreshold) { + this.deferThreshold = deferThreshold; + return this; + } +} diff --git a/java/src/main/java/com/github/copilot/rpc/package-info.java b/java/src/main/java/com/github/copilot/rpc/package-info.java index edc7dedcfc..83772cd048 100644 --- a/java/src/main/java/com/github/copilot/rpc/package-info.java +++ b/java/src/main/java/com/github/copilot/rpc/package-info.java @@ -80,7 +80,7 @@ *

Usage Example

* *
{@code
- * var config = new SessionConfig().setModel("gpt-4.1").setStreaming(true)
+ * var config = new SessionConfig().setModel("gpt-5.4").setStreaming(true)
  * 		.setSystemMessage(new SystemMessageConfig().setMode(SystemMessageMode.APPEND)
  * 				.setContent("Be concise in your responses."))
  * 		.setTools(List.of(ToolDefinition.create("my_tool", "Description", schema, handler)));
diff --git a/java/src/main/java/com/github/copilot/tool/CopilotTool.java b/java/src/main/java/com/github/copilot/tool/CopilotTool.java
index 0bad327b99..db9e3ca62d 100644
--- a/java/src/main/java/com/github/copilot/tool/CopilotTool.java
+++ b/java/src/main/java/com/github/copilot/tool/CopilotTool.java
@@ -50,4 +50,87 @@
 
     /** Defer configuration for this tool. */
     ToolDefer defer() default ToolDefer.NONE;
+
+    /**
+     * Opaque, host-defined metadata for this tool. Keys are namespaced and not part
+     * of the stable public API; specific keys may be recognized to inform
+     * host-specific behavior.
+     *
+     * 

+ * Because annotation members cannot express arbitrary maps, this uses a + * deliberately shallow representation: each {@link MetadataEntry} maps a string + * key to a single {@link MetadataValue} that is either a boolean, a string, or + * a one-level map of named boolean {@link MetadataFlag flags}. Numbers, arrays, + * and deeper nesting are not supported here; use the programmatic + * {@code ToolDefinition.createWithMetadata(...)} / + * {@code ToolDefinition.metadata(...)} API for richer values. + * + *

+ * Example emitted shape: + * + *

+     * Map.of("github.com/copilot:safeForTelemetry", Map.of("name", true, "inputsNames", false))
+     * 
+ */ + MetadataEntry[] metadata() default {}; + + /** + * A single metadata key/value pair. Used only as a member value of + * {@link CopilotTool#metadata()}. + */ + @Documented + @Retention(RetentionPolicy.RUNTIME) + @Target({}) + @interface MetadataEntry { + + /** The namespaced metadata key. */ + String key(); + + /** The value associated with {@link #key()}. */ + MetadataValue value(); + } + + /** + * A metadata value. Exactly one representation is intended per value: a map of + * named boolean {@link #flags()} (when non-empty), otherwise a {@link #str()} + * (when non-empty), otherwise a {@link #bool()}. + */ + @Documented + @Retention(RetentionPolicy.RUNTIME) + @Target({}) + @interface MetadataValue { + + /** + * Scalar boolean value. Used when {@link #flags()} and {@link #str()} are + * unset. + */ + boolean bool() default false; + + /** + * Scalar string value. Used when {@link #flags()} is empty and this is + * non-empty. + */ + String str() default ""; + + /** + * Object-like value: a one-level map of named boolean flags. Takes precedence + * when non-empty. + */ + MetadataFlag[] flags() default {}; + } + + /** + * A single named boolean flag within a {@link MetadataValue#flags()} map. + */ + @Documented + @Retention(RetentionPolicy.RUNTIME) + @Target({}) + @interface MetadataFlag { + + /** The flag name (map key). */ + String name(); + + /** The flag value. */ + boolean value(); + } } diff --git a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java index 9bf4e99c03..03af4a7cd9 100644 --- a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java +++ b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java @@ -276,10 +276,48 @@ private void writeToolDefinition(PrintWriter out, ExecutableElement method) { out.println(" },"); out.println(" " + overridesArg + ","); out.println(" " + skipPermArg + ","); - out.println(" " + deferArg); + out.println(" " + deferArg + ","); + out.println(" " + metadataSource(annotation)); out.print(" )"); } + /** + * Converts the {@code @CopilotTool(metadata = ...)} entries into a Java source + * literal. Returns {@code "null"} when no metadata is present, otherwise a + * {@code Map.of(...)} expression. + */ + private String metadataSource(CopilotTool annotation) { + CopilotTool.MetadataEntry[] entries = annotation.metadata(); + if (entries.length == 0) { + return "null"; + } + List parts = new ArrayList<>(); + for (CopilotTool.MetadataEntry entry : entries) { + parts.add("\"" + escapeJava(entry.key()) + "\", " + metadataValueSource(entry.value())); + } + return "Map.of(" + String.join(", ", parts) + ")"; + } + + /** + * Converts a single {@link CopilotTool.MetadataValue} into a Java source + * literal. A non-empty {@code flags} map takes precedence, then a non-empty + * {@code str}, otherwise the {@code bool} scalar. + */ + private String metadataValueSource(CopilotTool.MetadataValue value) { + CopilotTool.MetadataFlag[] flags = value.flags(); + if (flags.length > 0) { + List flagParts = new ArrayList<>(); + for (CopilotTool.MetadataFlag flag : flags) { + flagParts.add("\"" + escapeJava(flag.name()) + "\", " + flag.value()); + } + return "Map.of(" + String.join(", ", flagParts) + ")"; + } + if (!value.str().isEmpty()) { + return "\"" + escapeJava(value.str()) + "\""; + } + return String.valueOf(value.bool()); + } + private String generateSchemaWithParamMetadata(List parameters) { List schemaParameters = getSchemaParameters(parameters); diff --git a/java/src/test/java/com/github/copilot/CopilotRequestSessionIdE2ETest.java b/java/src/test/java/com/github/copilot/CopilotRequestSessionIdE2ETest.java index daf524945e..3025c64c39 100644 --- a/java/src/test/java/com/github/copilot/CopilotRequestSessionIdE2ETest.java +++ b/java/src/test/java/com/github/copilot/CopilotRequestSessionIdE2ETest.java @@ -10,6 +10,7 @@ import static com.github.copilot.CopilotRequestTestSupport.setupCapiAuth; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -68,6 +69,7 @@ void threadsSessionIdForCapiAndByok() throws Exception { assertFalse(capiInference.isEmpty(), "Expected at least one intercepted inference request"); for (InterceptedRequest r : capiInference) { assertEquals(capiSessionId, r.sessionId(), "CAPI inference request must carry the session id"); + assertAgentMetadata(r); } assertTrue(assistantText(capiResult).contains("OK from the synthetic"), "Expected synthetic content in CAPI assistant reply, got " + assistantText(capiResult)); @@ -91,10 +93,18 @@ void threadsSessionIdForCapiAndByok() throws Exception { assertTrue(byokInference.size() > before, "Expected at least one intercepted BYOK inference request"); for (InterceptedRequest r : byokInference.subList(before, byokInference.size())) { assertEquals(byokSessionId, r.sessionId(), "BYOK inference request must carry the session id"); + assertAgentMetadata(r); } assertNotEquals(capiSessionId, byokSessionId, "Expected per-session ids to differ between turns"); assertTrue(assistantText(byokResult).contains("OK from the synthetic"), "Expected synthetic content in BYOK assistant reply, got " + assistantText(byokResult)); } } + + private static void assertAgentMetadata(InterceptedRequest request) { + assertNotNull(request.agentId(), "Inference request must carry an agent id"); + assertFalse(request.agentId().isEmpty(), "Inference request must carry an agent id"); + assertNotNull(request.interactionType(), "Inference request must carry an interaction type"); + assertFalse(request.interactionType().isEmpty(), "Inference request must carry an interaction type"); + } } diff --git a/java/src/test/java/com/github/copilot/CopilotRequestTestSupport.java b/java/src/test/java/com/github/copilot/CopilotRequestTestSupport.java index 7a2e7f2f0f..ecbf92068f 100644 --- a/java/src/test/java/com/github/copilot/CopilotRequestTestSupport.java +++ b/java/src/test/java/com/github/copilot/CopilotRequestTestSupport.java @@ -473,7 +473,8 @@ static String assistantText(AssistantMessageEvent event) { } /** A single request the handler intercepted. */ - record InterceptedRequest(String url, String sessionId, String body) { + record InterceptedRequest(String url, String sessionId, String agentId, String parentAgentId, + String interactionType, String body) { } /** @@ -509,7 +510,8 @@ protected HttpResponse sendRequest(HttpRequest request, CopilotRequ throws Exception { String url = request.uri().toString(); String body = requestBodyText(request); - records.add(new InterceptedRequest(url, ctx.sessionId(), body)); + records.add(new InterceptedRequest(url, ctx.sessionId(), ctx.agentId(), ctx.parentAgentId(), + ctx.interactionType(), body)); if (isInferenceUrl(url)) { return buildInferenceResponse(url, body, text); } diff --git a/java/src/test/java/com/github/copilot/ForwardCompatibilityTest.java b/java/src/test/java/com/github/copilot/ForwardCompatibilityTest.java index 40166307e3..9163ae1357 100644 --- a/java/src/test/java/com/github/copilot/ForwardCompatibilityTest.java +++ b/java/src/test/java/com/github/copilot/ForwardCompatibilityTest.java @@ -56,6 +56,22 @@ void parse_unknownEventType_returnsUnknownSessionEvent() throws Exception { assertEquals("future.feature_from_server", result.getType()); } + @Test + void parse_internalEventType_returnsUnknownSessionEvent() throws Exception { + String json = """ + { + "id": "12345678-1234-1234-1234-123456789abc", + "timestamp": "2026-06-15T10:30:00Z", + "type": "session.memory_changed", + "data": {} + } + """; + SessionEvent result = MAPPER.readValue(json, SessionEvent.class); + + assertInstanceOf(UnknownSessionEvent.class, result); + assertEquals("session.memory_changed", result.getType()); + } + @Test void parse_unknownEventType_preservesOriginalType() throws Exception { String json = """ diff --git a/java/src/test/java/com/github/copilot/SessionCanvasSnapshotTest.java b/java/src/test/java/com/github/copilot/SessionCanvasSnapshotTest.java index 00db1d01c3..f50138b3b4 100644 --- a/java/src/test/java/com/github/copilot/SessionCanvasSnapshotTest.java +++ b/java/src/test/java/com/github/copilot/SessionCanvasSnapshotTest.java @@ -118,7 +118,7 @@ void getOpenCanvasesReturnsImmutableCopy() { var canvases = session.getOpenCanvases(); assertThrows(UnsupportedOperationException.class, - () -> canvases.add(new OpenCanvasInstance("x", "ext", null, "c", null, null, null, null))); + () -> canvases.add(new OpenCanvasInstance("x", "ext", null, "c", null, null, null, null, null))); // The returned list is a point-in-time snapshot, not a live view: a // subsequent event must not change the previously-returned list. @@ -133,9 +133,9 @@ void getOpenCanvasesReturnsImmutableCopy() { @Test void setOpenCanvasesSeedsAndFiltersNulls() { var seed = new java.util.ArrayList(); - seed.add(new OpenCanvasInstance("inst-1", "ext", null, "canvas-a", null, null, null, null)); + seed.add(new OpenCanvasInstance("inst-1", "ext", null, "canvas-a", null, null, null, null, null)); seed.add(null); - seed.add(new OpenCanvasInstance("inst-2", "ext", null, "canvas-b", null, null, null, null)); + seed.add(new OpenCanvasInstance("inst-2", "ext", null, "canvas-b", null, null, null, null, null)); session.setOpenCanvases(seed); @@ -195,8 +195,8 @@ void resumeSessionResponseDeserializesOpenCanvases() throws Exception { private static SessionCanvasOpenedEvent openedEvent(String instanceId, String canvasId) { var event = new SessionCanvasOpenedEvent(); - event.setData(new SessionCanvasOpenedEventData(instanceId, "ext-id", "Ext Name", canvasId, "Title", "ok", null, - null)); + event.setData(new SessionCanvasOpenedEventData(instanceId, "ext-id", "Ext Name", canvasId, null, "Title", "ok", + null, null)); return event; } diff --git a/java/src/test/java/com/github/copilot/SessionEventHandlingTest.java b/java/src/test/java/com/github/copilot/SessionEventHandlingTest.java index f075d57d5e..1cf3ceff1f 100644 --- a/java/src/test/java/com/github/copilot/SessionEventHandlingTest.java +++ b/java/src/test/java/com/github/copilot/SessionEventHandlingTest.java @@ -865,7 +865,7 @@ private SessionStartEvent createSessionStartEvent(String sessionId) { private AssistantMessageEvent createAssistantMessageEvent(String content) { var event = new AssistantMessageEvent(); var data = new AssistantMessageEvent.AssistantMessageEventData(null, null, content, null, null, null, null, - null, null, null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null, null, null); event.setData(data); return event; } diff --git a/java/src/test/java/com/github/copilot/SubagentHooksE2ETest.java b/java/src/test/java/com/github/copilot/SubagentHooksE2ETest.java new file mode 100644 index 0000000000..c2ad45ff24 --- /dev/null +++ b/java/src/test/java/com/github/copilot/SubagentHooksE2ETest.java @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.InputStream; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import com.github.copilot.rpc.CopilotClientOptions; +import com.github.copilot.rpc.MessageOptions; +import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.PostToolUseHookOutput; +import com.github.copilot.rpc.PreToolUseHookOutput; +import com.github.copilot.rpc.SessionConfig; +import com.github.copilot.rpc.SessionHooks; + +public class SubagentHooksE2ETest { + + private static final String SNAPSHOT_NAME = "should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls"; + + @Test + void shouldInvokePreToolUseAndPostToolUseHooksForSubAgentToolCalls() throws Exception { + try (E2ETestContext ctx = E2ETestContext.create()) { + ctx.configureForTest("subagent_hooks", SNAPSHOT_NAME); + + ConcurrentLinkedQueue hookLog = new ConcurrentLinkedQueue<>(); + RecordingForwardingRequestHandler requestHandler = new RecordingForwardingRequestHandler(); + HashMap env = new HashMap<>(ctx.getEnvironment()); + env.put("COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS", "true"); + + try (CopilotClient client = ctx + .createClient(new CopilotClientOptions().setEnvironment(env).setRequestHandler(requestHandler))) { + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> { + hookLog.add(new HookEntry("pre", input.getToolName(), input.getSessionId())); + return CompletableFuture.completedFuture(PreToolUseHookOutput.allow()); + }).setOnPostToolUse((input, invocation) -> { + hookLog.add(new HookEntry("post", input.getToolName(), input.getSessionId())); + return CompletableFuture.completedFuture((PostToolUseHookOutput) null); + }))) + .get(); + try { + Files.writeString(ctx.getWorkDir().resolve("subagent-test.txt"), "Hello from subagent test!"); + session.sendAndWait(new MessageOptions() + .setPrompt("Use the task tool to spawn an explore agent that reads the file " + + "subagent-test.txt in the current directory and reports its contents. " + + "You must use the task tool.")) + .get(120, TimeUnit.SECONDS); + + HookEntry taskPre = hookLog.stream() + .filter(h -> h.kind().equals("pre") && h.toolName().equals("task")).findFirst() + .orElse(null); + assertNotNull(taskPre, "preToolUse should fire for the parent's 'task' tool call"); + + List viewPre = hookLog.stream() + .filter(h -> h.kind().equals("pre") && h.toolName().equals("view")).toList(); + List viewPost = hookLog.stream() + .filter(h -> h.kind().equals("post") && h.toolName().equals("view")).toList(); + assertFalse(viewPre.isEmpty(), "preToolUse should fire for the sub-agent's 'view' tool call"); + assertFalse(viewPost.isEmpty(), "postToolUse should fire for the sub-agent's 'view' tool call"); + assertNotEquals(taskPre.sessionId(), viewPre.get(0).sessionId(), + "Sub-agent tool hooks should have a different sessionId than parent tool hooks"); + assertSubagentRequestMetadata(requestHandler.inferenceRequests()); + } finally { + session.close(); + } + } + } + } + + private static void assertSubagentRequestMetadata(List records) { + assertFalse(records.isEmpty(), "request handler should observe inference requests"); + RequestRecord subagentRequest = records.stream() + .filter(r -> r.parentAgentId() != null && !r.parentAgentId().isEmpty()).findFirst().orElse(null); + assertNotNull(subagentRequest, "sub-agent inference request should carry a parentAgentId"); + assertFalse(subagentRequest.agentId() == null || subagentRequest.agentId().isEmpty(), + "sub-agent inference request should carry an agentId"); + assertFalse(subagentRequest.interactionType() == null || subagentRequest.interactionType().isEmpty(), + "sub-agent inference request should carry an interactionType"); + assertNotEquals(subagentRequest.parentAgentId(), subagentRequest.agentId()); + } + + private static boolean isInferenceUrl(String url) { + String u = url.toLowerCase(); + return u.endsWith("/chat/completions") || u.endsWith("/responses") || u.endsWith("/v1/messages") + || u.endsWith("/messages"); + } + + private record HookEntry(String kind, String toolName, String sessionId) { + } + + private record RequestRecord(String url, String agentId, String parentAgentId, String interactionType) { + } + + private static final class RecordingForwardingRequestHandler extends CopilotRequestHandler { + private final ConcurrentLinkedQueue records = new ConcurrentLinkedQueue<>(); + + List inferenceRequests() { + return records.stream().filter(r -> isInferenceUrl(r.url())).toList(); + } + + @Override + protected HttpResponse sendRequest(HttpRequest request, CopilotRequestContext ctx) + throws Exception { + records.add(new RequestRecord(request.uri().toString(), ctx.agentId(), ctx.parentAgentId(), + ctx.interactionType())); + return super.sendRequest(request, ctx); + } + } +} diff --git a/java/src/test/java/com/github/copilot/ToolDefinitionTest.java b/java/src/test/java/com/github/copilot/ToolDefinitionTest.java index 614e6ab4fa..66c9f9ec86 100644 --- a/java/src/test/java/com/github/copilot/ToolDefinitionTest.java +++ b/java/src/test/java/com/github/copilot/ToolDefinitionTest.java @@ -59,4 +59,73 @@ void testDeferNeverIsSerialized() throws Exception { assertEquals("never", json.get("defer").asText()); } + + @Test + void testMetadataIsSerialized() throws Exception { + Map metadata = Map.of("github.com/copilot:safeForTelemetry", + Map.of("name", true, "inputsNames", false)); + ToolDefinition tool = ToolDefinition.createWithMetadata("my_tool", "A tool", schema(), + invocation -> CompletableFuture.completedFuture("ok"), metadata); + + ObjectNode json = (ObjectNode) MAPPER.readTree(MAPPER.writeValueAsString(tool)); + + assertTrue(json.has("metadata")); + assertTrue(json.get("metadata").has("github.com/copilot:safeForTelemetry")); + } + + @Test + void testMetadataOmittedWhenNull() throws Exception { + ToolDefinition tool = ToolDefinition.create("my_tool", "A tool", schema(), + invocation -> CompletableFuture.completedFuture("ok")); + + ObjectNode json = (ObjectNode) MAPPER.readTree(MAPPER.writeValueAsString(tool)); + + assertFalse(json.has("metadata")); + } + + @Test + void testSevenArgConstructorLeavesMetadataNull() throws Exception { + ToolDefinition tool = new ToolDefinition("my_tool", "A tool", schema(), + invocation -> CompletableFuture.completedFuture("ok"), null, null, null); + + assertNull(tool.metadata()); + + ObjectNode json = (ObjectNode) MAPPER.readTree(MAPPER.writeValueAsString(tool)); + + assertFalse(json.has("metadata")); + } + + @Test + void testMetadataCopyMethodSerializes() throws Exception { + Map metadata = Map.of("github.com/copilot:safeForTelemetry", + Map.of("name", true, "inputsNames", false)); + ToolDefinition tool = ToolDefinition + .create("my_tool", "A tool", schema(), invocation -> CompletableFuture.completedFuture("ok")) + .metadata(metadata); + + assertEquals(metadata, tool.metadata()); + + ObjectNode json = (ObjectNode) MAPPER.readTree(MAPPER.writeValueAsString(tool)); + + assertTrue(json.get("metadata").has("github.com/copilot:safeForTelemetry")); + } + + @Test + void testChainingFlagsPreservesMetadata() throws Exception { + Map metadata = Map.of("github.com/copilot:safeForTelemetry", Map.of("name", true)); + + ToolDefinition metadataFirst = ToolDefinition + .create("my_tool", "A tool", schema(), invocation -> CompletableFuture.completedFuture("ok")) + .metadata(metadata).overridesBuiltInTool(true).skipPermission(true).defer(ToolDefer.NEVER); + + ToolDefinition flagsFirst = ToolDefinition + .create("my_tool", "A tool", schema(), invocation -> CompletableFuture.completedFuture("ok")) + .overridesBuiltInTool(true).skipPermission(true).defer(ToolDefer.NEVER).metadata(metadata); + + assertEquals(metadata, metadataFirst.metadata()); + assertEquals(metadata, flagsFirst.metadata()); + assertEquals(Boolean.TRUE, flagsFirst.overridesBuiltInTool()); + assertEquals(Boolean.TRUE, flagsFirst.skipPermission()); + assertEquals(ToolDefer.NEVER, flagsFirst.defer()); + } } diff --git a/java/src/test/java/com/github/copilot/ToolResultObjectSerializationTest.java b/java/src/test/java/com/github/copilot/ToolResultObjectSerializationTest.java new file mode 100644 index 0000000000..3f08cae943 --- /dev/null +++ b/java/src/test/java/com/github/copilot/ToolResultObjectSerializationTest.java @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.junit.jupiter.api.Test; + +import com.github.copilot.rpc.ToolResultObject; + +/** + * Verifies JSON (de)serialization of the {@code toolReferences} field on + * {@link ToolResultObject}, including that it is omitted when {@code null} (via + * {@code @JsonInclude(NON_NULL)}) and preserved by the backward-compatible + * six-argument constructor. + */ +class ToolResultObjectSerializationTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + void serializesToolReferences() { + var result = new ToolResultObject("success", "found 2 tools", null, null, null, null, + List.of("get_weather", "check_status")); + + JsonNode node = MAPPER.valueToTree(result); + + assertEquals("found 2 tools", node.get("textResultForLlm").asText()); + JsonNode refs = node.get("toolReferences"); + assertNotNull(refs); + assertTrue(refs.isArray()); + assertEquals(2, refs.size()); + assertEquals("get_weather", refs.get(0).asText()); + assertEquals("check_status", refs.get(1).asText()); + } + + @Test + void omitsToolReferencesWhenNull() { + JsonNode node = MAPPER.valueToTree(ToolResultObject.success("ok")); + + assertFalse(node.has("toolReferences")); + } + + @Test + void sixArgConstructorLeavesToolReferencesNull() { + var result = new ToolResultObject("success", "ok", null, null, null, null); + + assertNull(result.toolReferences()); + assertFalse(MAPPER.valueToTree(result).has("toolReferences")); + } + + @Test + void deserializesToolReferences() throws Exception { + String json = "{\"resultType\":\"success\",\"textResultForLlm\":\"x\"," + + "\"toolReferences\":[\"alpha\",\"beta\"]}"; + + ToolResultObject result = MAPPER.readValue(json, ToolResultObject.class); + + assertEquals(List.of("alpha", "beta"), result.toolReferences()); + } +} diff --git a/java/src/test/java/com/github/copilot/ToolResultsTest.java b/java/src/test/java/com/github/copilot/ToolResultsTest.java index 54216d9216..8278fdf28f 100644 --- a/java/src/test/java/com/github/copilot/ToolResultsTest.java +++ b/java/src/test/java/com/github/copilot/ToolResultsTest.java @@ -68,7 +68,7 @@ void testShouldHandleToolResultWithRejectedResultType() throws Exception { toolHandlerCalled[0] = true; return CompletableFuture.completedFuture(new ToolResultObject("rejected", "Deployment rejected: policy violation - production deployments require approval", null, - null, null, null)); + null, null, null, null)); }); try (CopilotClient client = ctx.createClient()) { @@ -116,7 +116,7 @@ void testShouldHandleToolResultWithDeniedResultType() throws Exception { (invocation) -> { toolHandlerCalled[0] = true; return CompletableFuture.completedFuture(new ToolResultObject("denied", - "Access denied: insufficient permissions to read secrets", null, null, null, null)); + "Access denied: insufficient permissions to read secrets", null, null, null, null, null)); }); try (CopilotClient client = ctx.createClient()) { diff --git a/java/src/test/java/com/github/copilot/e2e/ErgonomicTestTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/e2e/ErgonomicTestTools$$CopilotToolMeta.java index 3e6291984f..56b8b281e6 100644 --- a/java/src/test/java/com/github/copilot/e2e/ErgonomicTestTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/e2e/ErgonomicTestTools$$CopilotToolMeta.java @@ -32,7 +32,7 @@ public List definitions(ErgonomicTestTools instance, ObjectMappe Map args = invocation.getArguments(); String phase = (String) args.get("phase"); return CompletableFuture.completedFuture(instance.setCurrentPhase(phase)); - }, null, null, null), + }, null, null, null, null), new ToolDefinition( "search_items", "Search for items by keyword", Map .of("type", "object", "properties", @@ -44,11 +44,11 @@ public List definitions(ErgonomicTestTools instance, ObjectMappe Map args = invocation.getArguments(); String keyword = (String) args.get("keyword"); return CompletableFuture.completedFuture(instance.searchItems(keyword)); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("get_status", "Returns the current status", Map.of("type", "object", "properties", Map.of(), "required", List.of()), invocation -> { return CompletableFuture.completedFuture(instance.getStatus()); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("combine_values", "Combines two values into a single string", Map.of( "type", "object", "properties", Map .ofEntries( @@ -63,6 +63,6 @@ public List definitions(ErgonomicTestTools instance, ObjectMappe String value1 = (String) args.get("value1"); String value2 = (String) args.get("value2"); return CompletableFuture.completedFuture(instance.combineValues(value1, value2)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/generated/rpc/GeneratedRpcRecordsCoverageTest.java b/java/src/test/java/com/github/copilot/generated/rpc/GeneratedRpcRecordsCoverageTest.java index 0a7a4f2541..2b6b0164d5 100644 --- a/java/src/test/java/com/github/copilot/generated/rpc/GeneratedRpcRecordsCoverageTest.java +++ b/java/src/test/java/com/github/copilot/generated/rpc/GeneratedRpcRecordsCoverageTest.java @@ -804,7 +804,8 @@ void modelsListResult_nested() { var limits = new ModelCapabilitiesLimits(100000L, 8192L, 128000L, null); var capabilities = new ModelCapabilities(supports, limits); var policy = new ModelPolicy(ModelPolicyState.ENABLED, null); - var billing = new ModelBilling(1.0, null, null); + var promo = new ModelBillingPromo("summer-2026", 25.0, "2026-08-01T00:00:00Z", "Summer discount"); + var billing = new ModelBilling(1.0, null, null, promo); var modelItem = new Model("gpt-5", "GPT-5", capabilities, policy, billing, null, null, null, null); var result = new ModelsListResult(List.of(modelItem)); @@ -816,6 +817,10 @@ void modelsListResult_nested() { assertEquals(100000L, result.models().get(0).capabilities().limits().maxPromptTokens()); assertEquals(ModelPolicyState.ENABLED, result.models().get(0).policy().state()); assertEquals(Double.valueOf(1.0), result.models().get(0).billing().multiplier()); + assertEquals("summer-2026", result.models().get(0).billing().promo().id()); + assertEquals(Double.valueOf(25.0), result.models().get(0).billing().promo().discountPercent()); + assertEquals("2026-08-01T00:00:00Z", result.models().get(0).billing().promo().endsAt()); + assertEquals("Summer discount", result.models().get(0).billing().promo().message()); } @Test diff --git a/java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java b/java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java index 37ea9f6a50..afa3d42511 100644 --- a/java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java +++ b/java/src/test/java/com/github/copilot/rpc/ToolDefinitionFromObjectTest.java @@ -92,6 +92,21 @@ void fromObject_handlerInvocation() throws Exception { assertEquals("Hello, Alice!", result); } + @Test + void fromObject_toolMetadata() { + var tools = ToolDefinition.fromObject(new SimpleTools()); + + var withMetadata = findTool(tools, "greet_user"); + assertNotNull(withMetadata); + assertNotNull(withMetadata.metadata()); + assertEquals(Map.of("github.com/copilot:safeForTelemetry", Map.of("name", true, "inputsNames", false)), + withMetadata.metadata()); + + var withoutMetadata = findTool(tools, "add_numbers"); + assertNotNull(withoutMetadata); + assertNull(withoutMetadata.metadata()); + } + // ── Test 2: Handler return type patterns ──────────────────────────────────── @Test diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/ArgCoercionTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/ArgCoercionTools$$CopilotToolMeta.java index 882c0555f7..5cc5ee87a5 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/ArgCoercionTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/ArgCoercionTools$$CopilotToolMeta.java @@ -45,6 +45,6 @@ public List definitions(ArgCoercionTools instance, ObjectMapper boolean flag = (Boolean) args.get("flag"); ArgCoercionTools.Color color = ArgCoercionTools.Color.valueOf((String) args.get("color")); return CompletableFuture.completedFuture(instance.mixedArgs(text, count, flag, color)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/DateTimeTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/DateTimeTools$$CopilotToolMeta.java index 7001336504..0c2b1f07e7 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/DateTimeTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/DateTimeTools$$CopilotToolMeta.java @@ -33,6 +33,6 @@ public List definitions(DateTimeTools instance, ObjectMapper map Map args = invocation.getArguments(); LocalDateTime when = mapper.convertValue(args.get("when"), LocalDateTime.class); return CompletableFuture.completedFuture(instance.scheduleEvent(when)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/DefaultValueTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/DefaultValueTools$$CopilotToolMeta.java index ee5369b849..6cef2e03a0 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/DefaultValueTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/DefaultValueTools$$CopilotToolMeta.java @@ -40,6 +40,6 @@ public List definitions(DefaultValueTools instance, ObjectMapper Object countRaw = args.containsKey("count") ? args.get("count") : 42; int count = ((Number) countRaw).intValue(); return CompletableFuture.completedFuture(instance.withDefault(label, count)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools$$CopilotToolMeta.java index cc7150e57c..e7c78608a0 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/InvocationAwareTools$$CopilotToolMeta.java @@ -23,7 +23,7 @@ public List definitions(InvocationAwareTools instance, ObjectMap Map args = invocation.getArguments(); String phase = (String) args.get("phase"); return CompletableFuture.completedFuture(instance.reportProgress(phase, invocation)); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("report_progress_async", "Reports progress asynchronously with invocation context", Map.of("type", "object", "properties", Map.ofEntries( @@ -33,7 +33,7 @@ public List definitions(InvocationAwareTools instance, ObjectMap Map args = invocation.getArguments(); String phase = (String) args.get("phase"); return instance.reportProgressAsync(phase, invocation).thenApply(r -> (Object) r); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("report_progress_first", "Reports progress with invocation first", Map.of("type", "object", "properties", Map.ofEntries( @@ -43,11 +43,11 @@ public List definitions(InvocationAwareTools instance, ObjectMap Map args = invocation.getArguments(); String phase = (String) args.get("phase"); return CompletableFuture.completedFuture(instance.reportProgressFirst(invocation, phase)); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("only_context", "Reports context with invocation only", Map.of("type", "object", "properties", Map.of(), "required", List.of()), invocation -> CompletableFuture.completedFuture(instance.onlyContext(invocation)), null, null, - null), + null, null), new ToolDefinition("report_progress_middle", "Reports progress with invocation in the middle", Map.of( "type", "object", "properties", Map.ofEntries(Map.entry("phase", Map.of("type", "string", "description", "Current phase")), @@ -58,7 +58,7 @@ public List definitions(InvocationAwareTools instance, ObjectMap int limit = ((Number) args.get("limit")).intValue(); return CompletableFuture .completedFuture(instance.reportProgressMiddle(phase, invocation, limit)); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("report_progress_with_record", "Reports progress with record args and invocation", Map.of("type", "object", "properties", Map.ofEntries(Map.entry("query", Map.of("type", "string")), @@ -69,6 +69,6 @@ public List definitions(InvocationAwareTools instance, ObjectMap RecordInvocationArgs.class); return CompletableFuture .completedFuture(instance.reportProgressWithRecord(args, invocation)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/MultiReturnTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/MultiReturnTools$$CopilotToolMeta.java index e1ac5c38dd..571db8e7cb 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/MultiReturnTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/MultiReturnTools$$CopilotToolMeta.java @@ -16,14 +16,14 @@ public List definitions(MultiReturnTools instance, ObjectMapper return List.of(new ToolDefinition("string_method", "Returns a string", Map.of("type", "object", "properties", Map.of(), "required", List.of()), invocation -> { return CompletableFuture.completedFuture(instance.stringMethod()); - }, null, null, null), new ToolDefinition("void_method", "Void method", + }, null, null, null, null), new ToolDefinition("void_method", "Void method", Map.of("type", "object", "properties", Map.of(), "required", List.of()), invocation -> { instance.voidMethod(); return CompletableFuture.completedFuture("Success"); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("async_method", "Async method", Map.of("type", "object", "properties", Map.of(), "required", List.of()), invocation -> { return instance.asyncMethod().thenApply(r -> (Object) r); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/OptionalParamTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/OptionalParamTools$$CopilotToolMeta.java index df6c39fd66..75fde6bb3c 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/OptionalParamTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/OptionalParamTools$$CopilotToolMeta.java @@ -39,7 +39,7 @@ public List definitions(OptionalParamTools instance, ObjectMappe Object titleRaw = args.get("title"); Optional title = titleRaw != null ? Optional.of((String) titleRaw) : Optional.empty(); return CompletableFuture.completedFuture(instance.greetWithTitle(name, title)); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("multiply", "Multiply with optional factor", Map.of("type", "object", "properties", Map.ofEntries( @@ -58,7 +58,7 @@ public List definitions(OptionalParamTools instance, ObjectMappe ? OptionalInt.of(((Number) factorRaw).intValue()) : OptionalInt.empty(); return CompletableFuture.completedFuture(instance.multiply(base, factor)); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("scale", "Scale with optional ratio", Map.of("type", "object", "properties", Map.ofEntries( @@ -77,7 +77,7 @@ public List definitions(OptionalParamTools instance, ObjectMappe ? OptionalDouble.of(((Number) ratioRaw).doubleValue()) : OptionalDouble.empty(); return CompletableFuture.completedFuture(instance.scale(value, ratio)); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("offset", "Offset with optional delta", Map.of("type", "object", "properties", Map.ofEntries( @@ -96,6 +96,6 @@ public List definitions(OptionalParamTools instance, ObjectMappe ? OptionalLong.of(((Number) deltaRaw).longValue()) : OptionalLong.empty(); return CompletableFuture.completedFuture(instance.offset(base, delta)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/OverrideTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/OverrideTools$$CopilotToolMeta.java index 127dc922b4..2d37204f82 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/OverrideTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/OverrideTools$$CopilotToolMeta.java @@ -34,6 +34,6 @@ public List definitions(OverrideTools instance, ObjectMapper map Map args = invocation.getArguments(); String pattern = (String) args.get("pattern"); return CompletableFuture.completedFuture(instance.customGrep(pattern)); - }, Boolean.TRUE, null, null)); + }, Boolean.TRUE, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools$$CopilotToolMeta.java index 0b52bd1ef2..ac38d0cce2 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools$$CopilotToolMeta.java @@ -30,7 +30,9 @@ public List definitions(SimpleTools instance, ObjectMapper mappe Map args = invocation.getArguments(); String name = (String) args.get("name"); return CompletableFuture.completedFuture(instance.greetUser(name)); - }, null, null, null), + }, null, null, null, + Map.of("github.com/copilot:safeForTelemetry", + Map.of("name", true, "inputsNames", false))), new ToolDefinition("add_numbers", "Adds two numbers together", Map.of("type", "object", "properties", Map.ofEntries( @@ -46,6 +48,6 @@ public List definitions(SimpleTools instance, ObjectMapper mappe int a = ((Number) args.get("a")).intValue(); int b = ((Number) args.get("b")).intValue(); return CompletableFuture.completedFuture(instance.addNumbers(a, b)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools.java b/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools.java index 5bc3d841e5..814b3883c3 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools.java @@ -12,7 +12,10 @@ */ public class SimpleTools { - @CopilotTool("Greets a user by name") + @CopilotTool(value = "Greets a user by name", metadata = { + @CopilotTool.MetadataEntry(key = "github.com/copilot:safeForTelemetry", value = @CopilotTool.MetadataValue(flags = { + @CopilotTool.MetadataFlag(name = "name", value = true), + @CopilotTool.MetadataFlag(name = "inputsNames", value = false)}))}) public String greetUser(@CopilotToolParam(value = "The user's name", required = true) String name) { return "Hello, " + name + "!"; } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/StaticInvocationTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/StaticInvocationTools$$CopilotToolMeta.java index c2fd7d7f6c..2535d671e8 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/StaticInvocationTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/StaticInvocationTools$$CopilotToolMeta.java @@ -24,6 +24,6 @@ public List definitions(StaticInvocationTools instance, ObjectMa Map args = invocation.getArguments(); String phase = (String) args.get("phase"); return CompletableFuture.completedFuture(StaticInvocationTools.reportStatic(phase, invocation)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/StaticTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/StaticTools$$CopilotToolMeta.java index 842547b68d..a0c6e66855 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/StaticTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/StaticTools$$CopilotToolMeta.java @@ -32,6 +32,6 @@ public List definitions(StaticTools instance, ObjectMapper mappe // Mimics what the processor now generates for static methods: // QualifiedClassName.method(...) instead of instance.method(...) return CompletableFuture.completedFuture(StaticTools.greet(name)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java index c2bda9f9ad..574d3acaa0 100644 --- a/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java +++ b/java/src/test/java/com/github/copilot/tool/CopilotToolProcessorTest.java @@ -212,6 +212,88 @@ public String doSomething(@CopilotToolParam("Input") String input) { "Expected completedFuture wrapping for String return, got:\n" + generated); } + @Test + void generatesMetadata_withNestedFlags() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.tool.CopilotToolParam; + public class MetaTools { + @CopilotTool(value = "Reports phase", metadata = { + @CopilotTool.MetadataEntry( + key = "github.com/copilot:safeForTelemetry", + value = @CopilotTool.MetadataValue(flags = { + @CopilotTool.MetadataFlag(name = "name", value = true), + @CopilotTool.MetadataFlag(name = "inputsNames", value = false) + })) + }) + public String reportPhase(@CopilotToolParam("Phase") String phase) { + return phase; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.MetaTools", source))); + assertNoErrors(result); + String generated = result.getGeneratedSource("test.MetaTools$$CopilotToolMeta"); + assertTrue(generated.contains("Map.of(\"github.com/copilot:safeForTelemetry\""), + "Expected typed metadata map, got:\n" + generated); + assertTrue(generated.contains("Map.of(\"name\", true, \"inputsNames\", false)"), + "Expected nested flag map, got:\n" + generated); + } + + @Test + void generatesNullMetadata_whenAbsent() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.tool.CopilotToolParam; + public class PlainTools { + @CopilotTool("Plain tool") + public String doSomething(@CopilotToolParam("Input") String input) { + return input; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.PlainTools", source))); + assertNoErrors(result); + String generated = result.getGeneratedSource("test.PlainTools$$CopilotToolMeta"); + assertFalse(generated.contains("Map.of("), + "Expected no metadata map for a tool without metadata, got:\n" + generated); + assertTrue(generated.contains(" null\n )"), + "Expected metadata constructor argument to be null when metadata is absent, got:\n" + generated); + } + + @Test + void generatesMetadata_alongsideOtherFlags() { + String source = """ + package test; + import com.github.copilot.tool.CopilotTool; + import com.github.copilot.rpc.ToolDefer; + import com.github.copilot.tool.CopilotToolParam; + public class ComboTools { + @CopilotTool(value = "Combo", name = "combo", overridesBuiltInTool = true, + skipPermission = true, defer = ToolDefer.NEVER, + metadata = { + @CopilotTool.MetadataEntry(key = "k", + value = @CopilotTool.MetadataValue(bool = true)) + }) + public String doSomething(@CopilotToolParam("Input") String input) { + return input; + } + } + """; + + CompilationResult result = compileWithProcessor(List.of(inMemorySource("test.ComboTools", source))); + assertNoErrors(result); + String generated = result.getGeneratedSource("test.ComboTools$$CopilotToolMeta"); + assertTrue(generated.contains("Boolean.TRUE"), "Expected overrides/skip flags, got:\n" + generated); + assertTrue(generated.contains("ToolDefer.NEVER"), "Expected defer, got:\n" + generated); + assertTrue(generated.contains("Map.of(\"k\", true)"), + "Expected scalar bool metadata, got:\n" + generated); + } + @Test void generatesCorrectCode_forVoidReturnType() { String source = """ diff --git a/nodejs/README.md b/nodejs/README.md index e9d83c6df9..52b3d28053 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -84,9 +84,11 @@ new CopilotClient(options?: CopilotClientOptions) **Options:** - `connection?: RuntimeConnection` - How to connect to the Copilot runtime. Construct via the factory functions on `RuntimeConnection`: - - `RuntimeConnection.forStdio({ path?, args? })` (default) — spawn the runtime and communicate over its stdin/stdout. - - `RuntimeConnection.forTcp({ port?, connectionToken?, path?, args? })` — spawn the runtime as a TCP server. + - `RuntimeConnection.forStdio({ path?, args?, env? })` (default) — spawn the runtime and communicate over its stdin/stdout. + - `RuntimeConnection.forTcp({ port?, connectionToken?, path?, args?, env? })` — spawn the runtime as a TCP server. - `RuntimeConnection.forUri(url, { connectionToken? })` — connect to an already-running runtime (mutually exclusive with `gitHubToken`/`useLoggedInUser`). There is no top-level `cliUrl` shortcut; use this factory for URL-based connections. + - `RuntimeConnection.forInProcess()` — host the runtime in-process over its native C ABI (FFI). **Experimental.** Because the runtime shares this process, `env`, `telemetry`, and `workingDirectory` are rejected with this transport; set them on the host process instead. + - The child-process transports (`forStdio`/`forTcp`) also accept a per-connection `env`. Set it there or via the top-level `env` option — not both (setting both throws). - `mode?: "empty" | "copilot-cli"` - Defaulting strategy. Use `"empty"` for multi-user server mode; defaults to `"copilot-cli"`. - `workingDirectory?: string` - Working directory for the runtime process (default: current process cwd). - `baseDirectory?: string` - Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned runtime. When not set, the runtime defaults to `~/.copilot`. Ignored when connecting via `RuntimeConnection.forUri`. diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 2275d9f59e..799053c0fa 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0-dev", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.69", + "@github/copilot": "^1.0.71", + "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -699,9 +700,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.69.tgz", - "integrity": "sha512-sri6PKtRQu7mfok3eV505fmYo6M6PGZpB1vd+QbMEm2rEZaKE2+wJGCAtkpQNKfAWBvvoW/C94IaeFyyulUvqQ==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.71.tgz", + "integrity": "sha512-F3axBi+sXSLYDJbxCBW36bM6MYKNC2rlyAf3Ivo/MjiHKKJW7j5AmaR1IRYS9Gt8r9mxOwWFM1cJFA+CuLaR8g==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -710,20 +711,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.69", - "@github/copilot-darwin-x64": "1.0.69", - "@github/copilot-linux-arm64": "1.0.69", - "@github/copilot-linux-x64": "1.0.69", - "@github/copilot-linuxmusl-arm64": "1.0.69", - "@github/copilot-linuxmusl-x64": "1.0.69", - "@github/copilot-win32-arm64": "1.0.69", - "@github/copilot-win32-x64": "1.0.69" + "@github/copilot-darwin-arm64": "1.0.71", + "@github/copilot-darwin-x64": "1.0.71", + "@github/copilot-linux-arm64": "1.0.71", + "@github/copilot-linux-x64": "1.0.71", + "@github/copilot-linuxmusl-arm64": "1.0.71", + "@github/copilot-linuxmusl-x64": "1.0.71", + "@github/copilot-win32-arm64": "1.0.71", + "@github/copilot-win32-x64": "1.0.71" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.69.tgz", - "integrity": "sha512-LpaVS2o21BbYTFUlkqdwUJXqE0l1Lp50Cb/EHru3z+5n5x7zNUU5IlPxdMg5gHMVw78clnMn2zSJbe5hYFyDDQ==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.71.tgz", + "integrity": "sha512-mEWzyqbqRAWgyU7i2uuSRoVPx/TwaFQX0nZmw0bc30aJ0BnO7cy2kYQyCHw8ykmf/tfxT0xauZ6k0BOFmWizzQ==", "cpu": [ "arm64" ], @@ -737,9 +738,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.69.tgz", - "integrity": "sha512-QL5bsmnYqliBVIEE8RY91e6yAr39c2TNvJc/5IzoaKMlQ9MCrD9xakBJwd7LOS0SiOwRE4fqn8pUA4L1+UU5fg==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.71.tgz", + "integrity": "sha512-Md9yEg406OBVBx3w4PeEj62TubulVLBcHleqmCoOoUmPgUxPZotUbrqz3rtbzADbXfrrD7JWvVsbd2UiNL194w==", "cpu": [ "x64" ], @@ -753,9 +754,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.69.tgz", - "integrity": "sha512-SOibp0I6APLoY2KkccbNOISpbhE3lp41s9fM3eANqfdoHQLsgBopfk4ruBP8k/DzjQA6t4MrbV8v6dLixAqv3w==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.71.tgz", + "integrity": "sha512-ykLJYOqBj3jRB5IJCDugLClAqbr7DmtTbUjlNY7+Jdq/n6i+d7xUQGclf1IWL5gnxbGQVAf+zkToD+sRM389Kg==", "cpu": [ "arm64" ], @@ -769,9 +770,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.69.tgz", - "integrity": "sha512-URT7UaR3l1VAAtEpbKjybup2vxHTMSfDTVlu0jinu1O2nWxFOeQ9N4BQgRdcCB7wDu3N8Yz47o3fV1Gob6Z1Yw==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.71.tgz", + "integrity": "sha512-pC0FNHG+BBwZd6yZlM85kkAGN+uJhM6o+THi76N2GnnSxmw7+remb1mvYxdgRVbdCm+LBUIbCKRWJLuMwrfb6A==", "cpu": [ "x64" ], @@ -785,9 +786,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.69.tgz", - "integrity": "sha512-Y6DY9RkyWV6l+S0cNz4d+AkUa38goNV5StQH3j8OoVrndK3+RgQeUjG1XpJ//PzXEXi4vQ0cZuc9XzFj/TY7tw==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.71.tgz", + "integrity": "sha512-hBmDljFTjacxqZTasCEy43H8EIzuXB/hHEBBCMFjhB9J00nIxsO6Dh0woTifKpx7knTYZdpTjjca3D0pAoZlUA==", "cpu": [ "arm64" ], @@ -801,9 +802,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.69.tgz", - "integrity": "sha512-h0aNgUWRu70Tgp4/7Vdqa/4XKJi96wP32R1PoKeXkz1QNxb6i/IQxhoCjjS+xFon9e2kefKdF8pDrDtUJjsiBQ==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.71.tgz", + "integrity": "sha512-CfTXU8pa5dxRz22xQzoi3TiG1PJo9+WR8PRDiPSdkIBSyPJ1NvX87DJmfXjTgeAfR+wkjt/p0keDCaBBVhNmUA==", "cpu": [ "x64" ], @@ -817,9 +818,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.69.tgz", - "integrity": "sha512-7DMoC1uwt01yZf48xq8MgKdnHacXabfsGsOizbgmrlZMfvQQofrF55x1Efu3BKsEKFGRl4fCLjJHJYCz/nWsHg==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.71.tgz", + "integrity": "sha512-+HI1DokixXhHUahj06Fw67ZAigBuXKC58BFma4UJOGrQsDgwOSbqeTQHCw6vuymzjKlg3sactfsCUTaefkjscQ==", "cpu": [ "arm64" ], @@ -833,9 +834,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.69.tgz", - "integrity": "sha512-5uD1/k6oVzqRhiS9jLrySGa20HjItqUJsaMe+bV6eYT7zQPF/74xihEjxMm3/bJcATW2SesiYQxyNzTcKm3qRA==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.71.tgz", + "integrity": "sha512-02kXOBd9CwBbCaztuf71WYWn+uGapCuiaasomN4tcMH3HBVZ4gi3J0ZUoRcgcS80xh81uQyeBHbnUKzb/RE/9A==", "cpu": [ "x64" ], @@ -921,6 +922,230 @@ "dev": true, "license": "MIT" }, + "node_modules/@koromix/koffi-darwin-arm64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.1.0.tgz", + "integrity": "sha512-VEt5r3fXTfbejr83PnuOP0H7s9Zmazcs+lofu96DOcRkistlMsn59wYyWiKpyAjs9PCgm0Ykh62ChZ3CGMmIOg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-x64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.1.0.tgz", + "integrity": "sha512-n/tVRB9xIzdXT5H3zZt8ueThgWTSDL+yU7PWnU8wbZPBSawP/otx3swQyd6nMOqj1bmHgSHopiKSBXRS9pllmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-arm64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.1.0.tgz", + "integrity": "sha512-vazoPYIhOAlXZksVIqDRMIID4VeUZKx8F3dR90hOobT2ATyOkqNS5dv5UCV7Q7DSq22lQTrdbvENBAhROzCp0w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-ia32": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.1.0.tgz", + "integrity": "sha512-Vm7Uc97ru6RTSVmae2zCZZQeaizqVZ8WoU4+gG4H03Qe+WOj7kbKt/MxT7VBzdbPYIU5ZJeG/ZED1YlZyab6eQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-x64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.1.0.tgz", + "integrity": "sha512-N+VuVWjoiYPy1Go5mRadZ3B6RM5Qz+eCLhj2LXrMlefbUJ+O4gg7teCUGvPGfBEHDgmSN4yYUrfQmdJC10vOYw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.1.0.tgz", + "integrity": "sha512-Wx5iOkeALe2ympLdiYwRpIg5qUkyQIv8N2foZ9rRker0uE7ZtXew2RRkbEgMir4b0yDYR1zyXd6B62GUzLtZ/g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ia32": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.1.0.tgz", + "integrity": "sha512-1DjYm1QehXU0dgn0uE+FGYOb3Of7GiTMqLS+ZI2gbl1b+h76sz4LRBvDVrQyAmSMVVU8/7696S21YgE/iBhBVg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-loong64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.1.0.tgz", + "integrity": "sha512-NOa0LdyltdESz3oeTqUH6MErHVoJOHoeXIsEp6xIMTUh4eKXEtlDQeoK6EYqo0DnBt83Xud95qLvi4Aw12pG4Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-riscv64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.1.0.tgz", + "integrity": "sha512-Ye6kiXZCGxGtAIXSly6XuOP5tJZNYOZ2eVg33k1MilKrzimAy9Mpw4d6e9+Sfsc1jesgeNYs1sb5iaI8HS3ncA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-x64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.1.0.tgz", + "integrity": "sha512-3yQTOkQrMna4VX+yeyfYImBjLlGrItMpsWyfaW1uSiz/A6GRydqdwYH7DWnp4Z+RSGYZpsewkf7byMc8pOOQKA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-ia32": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.1.0.tgz", + "integrity": "sha512-/cDoFHb9yx4+yoT3GUpnKnfi3W2drG+/Ewo0TTZaQHb4PsxnYYyT6V8+t4cL5XXbQcTTcOsZxpmBRrn0NBa3dA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-x64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.1.0.tgz", + "integrity": "sha512-CoQdqgnKvWgTXXZlUst8cBRQEov7QsxlTN2WAsu9wez01Xe6gEcH/zYePANualzzCbnaELfe5P0rA80QkoDuPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-ia32": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.1.0.tgz", + "integrity": "sha512-WjrA+DEkpy0xEHu48+NSOboHhTnzkIfsFuq3d/WrSs+T9WflWRng3jC7mdJxmR4eHb6i6BqjW3k/U0mNUTjFPA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-x64": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.1.0.tgz", + "integrity": "sha512-tnK5+IkzQBauQAQSzuyjso8OOIQRlaTZS39xIWpfqVYDLVDIuLDQk/WwHcOrR5yxlDrZq9ygiebBTOfcJFia7w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -2592,6 +2817,32 @@ "json-buffer": "3.0.1" } }, + "node_modules/koffi": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.1.0.tgz", + "integrity": "sha512-0mCvdjTJBXioiaKNz0vajAEdWtfM5qyhVXSq+wQrrU3odzNvl/J7Cqna79QpNo9mfoKpQgGsyFFDRtDACCwGrQ==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + }, + "optionalDependencies": { + "@koromix/koffi-darwin-arm64": "3.1.0", + "@koromix/koffi-darwin-x64": "3.1.0", + "@koromix/koffi-freebsd-arm64": "3.1.0", + "@koromix/koffi-freebsd-ia32": "3.1.0", + "@koromix/koffi-freebsd-x64": "3.1.0", + "@koromix/koffi-linux-arm64": "3.1.0", + "@koromix/koffi-linux-ia32": "3.1.0", + "@koromix/koffi-linux-loong64": "3.1.0", + "@koromix/koffi-linux-riscv64": "3.1.0", + "@koromix/koffi-linux-x64": "3.1.0", + "@koromix/koffi-openbsd-ia32": "3.1.0", + "@koromix/koffi-openbsd-x64": "3.1.0", + "@koromix/koffi-win32-ia32": "3.1.0", + "@koromix/koffi-win32-x64": "3.1.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/nodejs/package.json b/nodejs/package.json index fc96f6ad59..4f588640a8 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -56,7 +56,8 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.69", + "@github/copilot": "^1.0.71", + "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/samples/package-lock.json b/nodejs/samples/package-lock.json index 9d57bb9fe7..89c74c1535 100644 --- a/nodejs/samples/package-lock.json +++ b/nodejs/samples/package-lock.json @@ -18,7 +18,8 @@ "version": "0.0.0-dev", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.69", + "@github/copilot": "^1.0.71", + "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 9f430600ca..61f4a99416 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -40,6 +40,7 @@ import type { } from "./generated/rpc.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession } from "./session.js"; +import type { FfiRuntimeHost } from "./ffiRuntimeHost.js"; import { createSessionFsAdapter, type SessionFsProvider } from "./sessionFsProvider.js"; import { createCopilotRequestAdapter } from "./copilotRequestHandler.js"; import type { CopilotRequestHandler } from "./copilotRequestHandler.js"; @@ -58,6 +59,7 @@ import type { BearerTokenProvider, GetStatusResponse, InternalRuntimeConnection, + RuntimeConnection, LargeToolOutputConfig, MCPServerConfig, ModelInfo, @@ -473,6 +475,7 @@ class TeardownResilientStreamMessageWriter extends StreamMessageWriter { export class CopilotClient { private cliStartTimeout: ReturnType | null = null; private cliProcess: ChildProcess | null = null; + private ffiHost: FfiRuntimeHost | null = null; private connection: MessageConnection | null = null; private messageWriter: TeardownResilientStreamMessageWriter | null = null; private socket: Socket | null = null; @@ -563,6 +566,32 @@ export class CopilotClient { } } + /** + * Environment variable that overrides the transport when the caller does not set + * {@link CopilotClientOptions.connection}. Accepts `"inprocess"` or `"stdio"` + * (case-insensitive); unset preserves the default stdio transport. Any other value + * is an error. + */ + private static readonly DEFAULT_CONNECTION_ENV_VAR = "COPILOT_SDK_DEFAULT_CONNECTION"; + + /** + * Resolves the default {@link RuntimeConnection} for the no-connection case, + * honoring {@link CopilotClient.DEFAULT_CONNECTION_ENV_VAR}. + */ + private static resolveDefaultConnection(): RuntimeConnection { + const value = process.env[CopilotClient.DEFAULT_CONNECTION_ENV_VAR]; + if (!value || value.toLowerCase() === "stdio") { + return { kind: "stdio" }; + } + if (value.toLowerCase() === "inprocess") { + return { kind: "inprocess" }; + } + throw new Error( + `Invalid ${CopilotClient.DEFAULT_CONNECTION_ENV_VAR} value '${value}'. ` + + `Expected 'inprocess', 'stdio', or unset.` + ); + } + /** * Creates a new CopilotClient instance. * @@ -594,8 +623,10 @@ export class CopilotClient { // Resolve the connection mode. `_internalConnection` is set by // `joinSession()` to opt into the parent-process stdio path; consumers // should always go through the public `connection` field. - const conn: InternalRuntimeConnection = options._internalConnection ?? - options.connection ?? { kind: "stdio" }; + const conn: InternalRuntimeConnection = + options._internalConnection ?? + options.connection ?? + CopilotClient.resolveDefaultConnection(); if ( conn.kind === "uri" && @@ -605,6 +636,40 @@ export class CopilotClient { "gitHubToken and useLoggedInUser cannot be used with RuntimeConnection.forUri (external server manages its own auth)" ); } + if (conn.kind === "inprocess" && options.workingDirectory !== undefined) { + throw new Error( + "workingDirectory is not supported with RuntimeConnection.forInProcess(): the in-process " + + "transport hosts the runtime in this process, so honoring it would require mutating the " + + "shared process-global cwd. Change the host process's working directory before " + + "constructing the client instead." + ); + } + if (conn.kind === "inprocess" && options.env !== undefined) { + throw new Error( + "env is not supported with RuntimeConnection.forInProcess(): the in-process transport loads " + + "the native runtime into the shared host process, whose single environment block cannot " + + "carry per-client values. Set the variables on the host process environment instead." + ); + } + if (conn.kind === "inprocess" && options.telemetry !== undefined) { + throw new Error( + "telemetry is not supported with RuntimeConnection.forInProcess(): telemetry configuration " + + "is lowered to environment variables read by native runtime code running in the shared " + + "host process, so per-client telemetry cannot be honored in-process. Configure telemetry " + + "via the host process environment, or use a child-process transport." + ); + } + if ( + (conn.kind === "stdio" || conn.kind === "tcp") && + conn.env !== undefined && + options.env !== undefined + ) { + throw new Error( + "Set environment variables via either the client-level env option or the connection's env " + + "(RuntimeConnection.forStdio/forTcp), not both. Prefer the connection-level env for " + + "child-process transports." + ); + } if (conn.kind === "tcp" && conn.connectionToken !== undefined) { if (typeof conn.connectionToken !== "string" || conn.connectionToken.length === 0) { throw new Error("connectionToken must be a non-empty string"); @@ -642,7 +707,13 @@ export class CopilotClient { this.onGitHubTelemetry = options.onGitHubTelemetry; this.setupClientGlobalHandlers(); - const effectiveEnv = options.env ?? process.env; + // Connection-level env (child-process transports only) takes precedence + // over the client-level env, which falls back to the ambient process env. + // The constructor guard above rejects setting both, so at most one of the + // first two is defined. Mirrors .NET/Python precedence. + const connEnv: Record | undefined = + conn.kind === "stdio" || conn.kind === "tcp" ? conn.env : undefined; + const effectiveEnv = connEnv ?? options.env ?? process.env; this.resolvedEnv = effectiveEnv; this.resolvedCliPath = conn.kind === "stdio" || conn.kind === "tcp" @@ -759,6 +830,11 @@ export class CopilotClient { private setupClientGlobalHandlers(): void { const handlers: import("./generated/rpc.js").ClientGlobalApiHandlers = {}; + // `hooks.invoke` is a client-global RPC method whose payload carries a + // `sessionId`; route each invocation to the matching session's dispatcher. + handlers.hooks = { + invoke: async (params) => await this.handleHooksInvoke(params), + }; if (this.requestHandler) { handlers.llmInference = createCopilotRequestAdapter(this.requestHandler, () => { if (!this.connection) { @@ -810,7 +886,9 @@ export class CopilotClient { try { // Only start CLI server process if not connecting to external server - if (!this.isExternalServer) { + if (this.connectionConfig.kind === "inprocess") { + await this.startInProcessFfi(); + } else if (!this.isExternalServer) { await this.startCLIServer(); } @@ -873,6 +951,20 @@ export class CopilotClient { // Disconnect all active sessions with retry logic const activeSessions = [...this.sessions.values()]; + // TEMPORARY: over the in-process (FFI) transport the runtime shares this + // process, so a turn still running when the runtime disposes the session + // can leave that session's SQLite session.db handle open — it isn't + // reclaimed by terminating a child process, so the file stays locked + // (Windows) and the session-state directory can't be removed. Abort any + // in-flight turn first so it cancels and releases the handle. Best-effort + // and idempotent: a session with no active turn is a no-op. Scoped to + // in-process only: stdio/tcp runtimes run in a child process that we kill + // on shutdown (which frees the handle), and for external servers we don't + // own the runtime and aborting would cancel pending work other clients + // may still resume. Remove once the runtime cleans up fully on shutdown. + if (this.connectionConfig.kind === "inprocess") { + await Promise.allSettled(activeSessions.map((session) => session.abort())); + } for (const session of activeSessions) { const sessionId = session.sessionId; let lastError: Error | null = null; @@ -910,7 +1002,7 @@ export class CopilotClient { // Ask SDK-owned runtimes to flush and clean up before we tear down // their transport/process. External runtimes may be shared, so only // close our connection to them. - if (this.connection && this.cliProcess && !this.isExternalServer) { + if (this.connection && (this.cliProcess || this.ffiHost) && !this.isExternalServer) { const runtimeShutdownStart = Date.now(); const shutdownPromise = this.rpc.runtime.shutdown(); void shutdownPromise.catch(() => undefined); @@ -1011,6 +1103,21 @@ export class CopilotClient { ); } } + // Tear down the in-process FFI host (closes the native connection and + // shuts down the native runtime host) for SDK-owned in-process runtimes. + if (this.ffiHost) { + const host = this.ffiHost; + this.ffiHost = null; + try { + host.dispose(); + } catch (error) { + errors.push( + new Error( + `Failed to dispose in-process runtime host: ${error instanceof Error ? error.message : String(error)}` + ) + ); + } + } if (this.cliStartTimeout) { clearTimeout(this.cliStartTimeout); this.cliStartTimeout = null; @@ -1113,6 +1220,16 @@ export class CopilotClient { this.cliProcess = null; } + // Tear down the in-process FFI host (if any). + if (this.ffiHost) { + try { + this.ffiHost.dispose(); + } catch { + // Ignore errors during force stop + } + this.ffiHost = null; + } + if (this.cliStartTimeout) { clearTimeout(this.cliStartTimeout); this.cliStartTimeout = null; @@ -1409,12 +1526,15 @@ export class CopilotClient { overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, defer: tool.defer, + metadata: tool.metadata, })), + toolSearch: config.toolSearch, canvases: config.canvases?.map((canvas) => canvas.declaration), requestCanvasRenderer: config.requestCanvasRenderer, requestExtensions: config.requestExtensions, extensionSdkPath: config.extensionSdkPath, extensionInfo: config.extensionInfo, + canvasProvider: config.canvasProvider, commands: config.commands?.map((cmd) => ({ name: cmd.name, description: cmd.description, @@ -1472,6 +1592,7 @@ export class CopilotClient { remoteSession: config.remoteSession, cloud: config.cloud, expAssignments: config.expAssignments, + enableManagedSettings: config.enableManagedSettings, }); const { @@ -1625,12 +1746,15 @@ export class CopilotClient { overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, defer: tool.defer, + metadata: tool.metadata, })), + toolSearch: config.toolSearch, canvases: config.canvases?.map((canvas) => canvas.declaration), requestCanvasRenderer: config.requestCanvasRenderer, requestExtensions: config.requestExtensions, extensionSdkPath: config.extensionSdkPath, extensionInfo: config.extensionInfo, + canvasProvider: config.canvasProvider, commands: config.commands?.map((cmd) => ({ name: cmd.name, description: cmd.description, @@ -1683,6 +1807,7 @@ export class CopilotClient { remoteSession: config.remoteSession, openCanvases: config.openCanvases, expAssignments: config.expAssignments, + enableManagedSettings: config.enableManagedSettings, }); const { workspacePath, capabilities, openCanvases } = response as { @@ -2195,6 +2320,47 @@ export class CopilotClient { }; } + /** + * Builds the environment for the spawned runtime child process (stdio/TCP): applies + * the auth token, connection token, `COPILOT_HOME`, keychain setting, and telemetry + * variables on top of the effective env. Not used by the in-process (FFI) transport, + * whose worker inherits the host process's ambient environment + * (see {@link CopilotClient.startInProcessFfi}). + */ + private buildRuntimeEnv(): Record { + const env: Record = { ...this.resolvedEnv }; + delete env.NODE_DEBUG; + + if (this.options.gitHubToken) { + env.COPILOT_SDK_AUTH_TOKEN = this.options.gitHubToken; + } + if (this.effectiveConnectionToken) { + env.COPILOT_CONNECTION_TOKEN = this.effectiveConnectionToken; + } + if (this.options.baseDirectory) { + env.COPILOT_HOME = this.options.baseDirectory; + } + // In empty mode, disable the system keychain. Keytar reads from a + // process-wide store that's shared across sessions, which is unsafe + // for multi-tenant hosts. The runtime falls back to file-based + // credential storage scoped to COPILOT_HOME. + if (this.options.mode === "empty") { + env.COPILOT_DISABLE_KEYTAR = "1"; + } + if (this.options.telemetry) { + const t = this.options.telemetry; + env.COPILOT_OTEL_ENABLED = "true"; + if (t.otlpEndpoint !== undefined) env.OTEL_EXPORTER_OTLP_ENDPOINT = t.otlpEndpoint; + if (t.otlpProtocol !== undefined) env.OTEL_EXPORTER_OTLP_PROTOCOL = t.otlpProtocol; + if (t.filePath !== undefined) env.COPILOT_OTEL_FILE_EXPORTER_PATH = t.filePath; + if (t.exporterType !== undefined) env.COPILOT_OTEL_EXPORTER_TYPE = t.exporterType; + if (t.sourceName !== undefined) env.COPILOT_OTEL_SOURCE_NAME = t.sourceName; + if (t.captureContent !== undefined) + env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = String(t.captureContent); + } + return env; + } + /** * Start the CLI server process */ @@ -2241,30 +2407,9 @@ export class CopilotClient { args.push("--remote"); } - // Suppress debug/trace output that might pollute stdout - const envWithoutNodeDebug = { ...this.resolvedEnv }; - delete envWithoutNodeDebug.NODE_DEBUG; - - // Set auth token in environment if provided - if (this.options.gitHubToken) { - envWithoutNodeDebug.COPILOT_SDK_AUTH_TOKEN = this.options.gitHubToken; - } - - if (this.effectiveConnectionToken) { - envWithoutNodeDebug.COPILOT_CONNECTION_TOKEN = this.effectiveConnectionToken; - } - - if (this.options.baseDirectory) { - envWithoutNodeDebug.COPILOT_HOME = this.options.baseDirectory; - } - - // In empty mode, disable the system keychain. Keytar reads from a - // process-wide store that's shared across sessions, which is unsafe - // for multi-tenant hosts. The runtime falls back to file-based - // credential storage scoped to COPILOT_HOME. - if (this.options.mode === "empty") { - envWithoutNodeDebug.COPILOT_DISABLE_KEYTAR = "1"; - } + // Suppress debug/trace output that might pollute stdout, and apply the + // shared runtime env (auth token, connection token, COPILOT_HOME, telemetry). + const envWithoutNodeDebug = this.buildRuntimeEnv(); if (!this.resolvedCliPath) { throw new Error( @@ -2276,26 +2421,6 @@ export class CopilotClient { ); } - // Set OpenTelemetry environment variables if telemetry is configured - if (this.options.telemetry) { - const t = this.options.telemetry; - envWithoutNodeDebug.COPILOT_OTEL_ENABLED = "true"; - if (t.otlpEndpoint !== undefined) - envWithoutNodeDebug.OTEL_EXPORTER_OTLP_ENDPOINT = t.otlpEndpoint; - if (t.otlpProtocol !== undefined) - envWithoutNodeDebug.OTEL_EXPORTER_OTLP_PROTOCOL = t.otlpProtocol; - if (t.filePath !== undefined) - envWithoutNodeDebug.COPILOT_OTEL_FILE_EXPORTER_PATH = t.filePath; - if (t.exporterType !== undefined) - envWithoutNodeDebug.COPILOT_OTEL_EXPORTER_TYPE = t.exporterType; - if (t.sourceName !== undefined) - envWithoutNodeDebug.COPILOT_OTEL_SOURCE_NAME = t.sourceName; - if (t.captureContent !== undefined) - envWithoutNodeDebug.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = String( - t.captureContent - ); - } - // Verify CLI exists before attempting to spawn if (!existsSync(this.resolvedCliPath)) { throw new Error( @@ -2432,12 +2557,120 @@ export class CopilotClient { return this.connectToParentProcessViaStdio(); case "stdio": return this.connectToChildProcessViaStdio(); + case "inprocess": + return this.connectViaFfi(); case "tcp": case "uri": return this.connectViaTcp(); } } + /** Starts the in-process FFI runtime with SDK-managed typed options. */ + private async startInProcessFfi(): Promise { + const entrypoint = this.resolveCliPathForFfi(); + // Load the FFI host lazily so the native `koffi` addon (and its + // platform-specific `koffi.node`) is only loaded on the in-process path; + // out-of-process (stdio/tcp) consumers never touch the native dependency. + // The transpiled output is per-file (not bundled), so this resolves the + // sibling module at runtime in both the ESM and CJS builds. + const { FfiRuntimeHost } = await import("./ffiRuntimeHost.js"); + const environment: Record = {}; + if (this.options.gitHubToken) { + environment.COPILOT_SDK_AUTH_TOKEN = this.options.gitHubToken; + } + if (this.options.baseDirectory) { + environment.COPILOT_HOME = this.options.baseDirectory; + } + if (this.options.mode === "empty") { + environment.COPILOT_DISABLE_KEYTAR = "1"; + } + + const args: string[] = []; + if (this.options.logLevel) { + args.push("--log-level", this.options.logLevel); + } + if (this.options.gitHubToken) { + args.push("--auth-token-env", "COPILOT_SDK_AUTH_TOKEN"); + } + if (!this.options.useLoggedInUser) { + args.push("--no-auto-login"); + } + if (this.options.sessionIdleTimeoutSeconds > 0) { + args.push("--session-idle-timeout", this.options.sessionIdleTimeoutSeconds.toString()); + } + if (this.options.enableRemoteSessions) { + args.push("--remote"); + } + + const host = FfiRuntimeHost.create( + entrypoint, + CopilotClient.getNapiPrebuildsFolder(entrypoint), + environment, + args + ); + this.ffiHost = host; + await host.start(); + } + + /** + * Connect to the in-process FFI runtime host over its receive/send streams, + * reusing the same `vscode-jsonrpc` framing as the stdio transport. + */ + private async connectViaFfi(): Promise { + if (!this.ffiHost) { + throw new Error("In-process FFI runtime host not started"); + } + this.messageWriter = new TeardownResilientStreamMessageWriter(this.ffiHost.sendStream); + this.connection = createMessageConnection( + new StreamMessageReader(this.ffiHost.receiveStream), + this.messageWriter + ); + + this.attachConnectionHandlers(); + this.connection.listen(); + } + + /** + * Resolves the CLI entrypoint used for in-process FFI hosting: `COPILOT_CLI_PATH` + * when set, otherwise the bundled platform-package entrypoint. + */ + private resolveCliPathForFfi(): string { + return this.resolvedEnv.COPILOT_CLI_PATH ?? getBundledCliPath(); + } + + /** + * Returns the napi prebuilds folder name for the current host — the + * `-` convention (e.g. `win32-x64`, `darwin-arm64`, + * `linux-x64`, `linuxmusl-x64`) under which the runtime ships + * `prebuilds//runtime.node`. + */ + private static getNapiPrebuildsFolder(entrypoint: string): string { + const arch = process.arch; + if (arch !== "x64" && arch !== "arm64") { + throw new Error(`Unsupported architecture '${arch}' for in-process FFI hosting.`); + } + let platform: string = process.platform; + if (platform === "linux" && CopilotClient.isMusl(entrypoint)) { + platform = "linuxmusl"; + } + return `${platform}-${arch}`; + } + + private static isMusl(entrypoint: string): boolean { + if (entrypoint.includes(`copilot-linuxmusl-${process.arch}`)) { + return true; + } + if (entrypoint.includes(`copilot-linux-${process.arch}`)) { + return false; + } + const report = process.report?.getReport(); + const header = + report && "header" in report + ? (report.header as { glibcVersionRuntime?: string }) + : undefined; + return header !== undefined && header.glibcVersionRuntime === undefined; + } + /** * Connect to child via stdio pipes */ @@ -2568,15 +2801,6 @@ export class CopilotClient { await this.handleAutoModeSwitchRequest(params) ); - this.connection.onRequest( - "hooks.invoke", - async (params: { - sessionId: string; - hookType: string; - input: unknown; - }): Promise<{ output?: unknown }> => await this.handleHooksInvoke(params) - ); - this.connection.onRequest( "systemMessage.transform", async (params: { @@ -2620,8 +2844,9 @@ export class CopilotClient { } const session = this.sessions.get((notification as { sessionId: string }).sessionId); + const event = (notification as { event: SessionEvent }).event; if (session) { - session._dispatchEvent((notification as { event: SessionEvent }).event); + session._dispatchEvent(event); } } diff --git a/nodejs/src/copilotRequestHandler.ts b/nodejs/src/copilotRequestHandler.ts index a949cecfd9..ccfe6591c6 100644 --- a/nodejs/src/copilotRequestHandler.ts +++ b/nodejs/src/copilotRequestHandler.ts @@ -33,6 +33,9 @@ type InternalContext = CopilotRequestContext & { [kBridge]: CopilotWebSocketResp export interface CopilotRequestContext { readonly requestId: string; readonly sessionId?: string; + readonly agentId?: string; + readonly parentAgentId?: string; + readonly interactionType?: string; readonly transport: "http" | "websocket"; url: string; headers: LlmInferenceHeaders; @@ -249,6 +252,9 @@ export class CopilotRequestHandler { const ctx: InternalContext = { requestId: exchange.requestId, sessionId: exchange.sessionId, + agentId: exchange.agentId, + parentAgentId: exchange.parentAgentId, + interactionType: exchange.interactionType, transport: exchange.transport, url: exchange.url, headers: exchange.headers, @@ -456,6 +462,9 @@ interface BodyQueueItem { class CopilotRequestExchange { readonly requestId: string; sessionId?: string; + agentId?: string; + parentAgentId?: string; + interactionType?: string; method = "GET"; url = ""; headers: LlmInferenceHeaders = {}; @@ -478,6 +487,9 @@ class CopilotRequestExchange { /** Fill in the request context once the matching start frame arrives. */ setContext(params: LlmInferenceHttpRequestStartRequest): void { this.sessionId = params.sessionId; + this.agentId = params.agentId; + this.parentAgentId = params.parentAgentId; + this.interactionType = params.interactionType; this.method = params.method; this.url = params.url; this.headers = params.headers; diff --git a/nodejs/src/ffiRuntimeHost.ts b/nodejs/src/ffiRuntimeHost.ts new file mode 100644 index 0000000000..a92aa1589a --- /dev/null +++ b/nodejs/src/ffiRuntimeHost.ts @@ -0,0 +1,341 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * Hosts the Copilot runtime in-process by loading the native `runtime.node` cdylib + * and speaking JSON-RPC over its C ABI (FFI) instead of spawning a CLI child process + * and communicating over stdio/TCP. + * + * The native `host_start` export spawns the CLI worker itself + * (`node --embedded-host` for a `.js` entrypoint, or ` + * --embedded-host` for a packaged binary), so the SDK never launches the worker + * directly. LSP `Content-Length:`-framed JSON-RPC bytes are pumped across the ABI: + * writes go to `connection_write`; inbound frames arrive on a native callback that + * feeds {@link FfiRuntimeHost.receiveStream}. The existing `vscode-jsonrpc` + * `StreamMessageReader`/`StreamMessageWriter` handle framing unchanged — this is a + * transport swap, not a new protocol. + */ + +import { existsSync } from "node:fs"; +import koffi from "koffi"; +import { dirname, join, resolve } from "node:path"; +import { PassThrough, Writable } from "node:stream"; + +const SYMBOL_PREFIX = "copilot_runtime_"; + +// A long, referenced no-op timer keeps the Node event loop alive while the in-process +// connection is open (see start()); the exact interval is irrelevant. +const KEEP_ALIVE_INTERVAL_MS = 1 << 30; + +type KoffiFunction = ReturnType["func"]>; +type KoffiType = ReturnType; +type KoffiRegisteredCallback = ReturnType; + +interface FfiLibrary { + hostStart: KoffiFunction; + hostShutdown: KoffiFunction; + connectionOpen: KoffiFunction; + connectionWrite: KoffiFunction; + connectionClose: KoffiFunction; + outboundCallbackType: KoffiType; +} + +let loadedLibraryPath: string | undefined; +let loadedLibrary: FfiLibrary | undefined; + +/** + * Loads the cdylib once per process and binds the C ABI exports. Loading a + * different library path in the same process is unsupported. + */ +function loadLibrary(libraryPath: string): FfiLibrary { + if (loadedLibrary) { + if (loadedLibraryPath !== libraryPath) { + throw new Error( + `An in-process FFI runtime library is already loaded from '${loadedLibraryPath}'; ` + + `loading a different library from '${libraryPath}' in the same process is not supported.` + ); + } + return loadedLibrary; + } + + const lib = koffi.load(libraryPath); + const outboundCallbackType = koffi.pointer( + koffi.proto( + `void ${SYMBOL_PREFIX}outbound(void *userData, uint8 *bytesPtr, size_t bytesLen)` + ) + ); + + loadedLibrary = { + hostStart: lib.func(`${SYMBOL_PREFIX}host_start`, "uint32", [ + "uint8*", + "size_t", + "uint8*", + "size_t", + ]), + hostShutdown: lib.func(`${SYMBOL_PREFIX}host_shutdown`, "bool", ["uint32"]), + connectionOpen: lib.func(`${SYMBOL_PREFIX}connection_open`, "uint32", [ + "uint32", + outboundCallbackType, + "void*", + "uint8*", + "size_t", + "uint8*", + "size_t", + "uint8*", + "size_t", + ]), + connectionWrite: lib.func(`${SYMBOL_PREFIX}connection_write`, "bool", [ + "uint32", + "uint8*", + "size_t", + ]), + connectionClose: lib.func(`${SYMBOL_PREFIX}connection_close`, "bool", ["uint32"]), + outboundCallbackType, + }; + loadedLibraryPath = libraryPath; + return loadedLibrary; +} + +function buildArgvJson(cliEntrypoint: string, args: readonly string[]): Buffer { + // A `.js` entrypoint is launched via node; the packaged single-file CLI binary + // embeds its own Node and is invoked directly. `--no-auto-update` pins the worker + // to the bundled pkg matching the loaded cdylib, instead of drifting to a newer + // version installed under the user's `~/.copilot/pkg` (which would cause ABI skew). + const argv = cliEntrypoint.toLowerCase().endsWith(".js") + ? ["node", cliEntrypoint, "--embedded-host", "--no-auto-update"] + : [cliEntrypoint, "--embedded-host", "--no-auto-update"]; + argv.push(...args); + return Buffer.from(JSON.stringify(argv), "utf8"); +} + +function buildEnvJson(environment?: Record): Buffer | null { + if (!environment) { + return null; + } + const obj: Record = {}; + for (const [key, value] of Object.entries(environment)) { + if (value !== undefined) { + obj[key] = value; + } + } + if (Object.keys(obj).length === 0) { + return null; + } + return Buffer.from(JSON.stringify(obj), "utf8"); +} + +export class FfiRuntimeHost { + private readonly lib: FfiLibrary; + private serverId = 0; + private connectionId = 0; + private disposed = false; + private outboundCallback: KoffiRegisteredCallback | undefined; + private keepAliveTimer: ReturnType | undefined; + + /** The stream JSON-RPC reads server→client frames from. */ + readonly receiveStream: PassThrough; + /** The stream JSON-RPC writes client→server frames to. */ + readonly sendStream: Writable; + + private constructor( + private readonly libraryPath: string, + private readonly cliEntrypoint: string, + private readonly environment: Record | undefined, + private readonly args: readonly string[] + ) { + this.lib = loadLibrary(libraryPath); + this.receiveStream = new PassThrough(); + this.sendStream = new Writable({ + // connection_write enqueues the frame into the runtime's inbound channel and + // returns immediately, so a synchronous FFI call is sufficient here. + write: (chunk: Buffer, _encoding, callback) => { + try { + this.writeFrame(chunk); + callback(); + } catch (error) { + callback(error as Error); + } + }, + }); + } + + /** + * Resolves the cdylib next to the given CLI entrypoint and prepares the FFI host. + * The cdylib is resolved as `prebuilds//runtime.node` relative to + * the entrypoint directory (the napi-rs `-` layout, e.g. + * `linux-x64`). Throws if it cannot be found. + */ + static create( + cliEntrypoint: string, + prebuildsFolder: string, + environment: Record | undefined, + args: readonly string[] + ): FfiRuntimeHost { + const fullEntrypoint = resolve(cliEntrypoint); + const distDir = dirname(fullEntrypoint); + const libraryPath = join(distDir, "prebuilds", prebuildsFolder, "runtime.node"); + if (!existsSync(libraryPath)) { + throw new Error(`FFI runtime library not found. Looked for '${libraryPath}'.`); + } + return new FfiRuntimeHost(libraryPath, fullEntrypoint, environment, args); + } + + /** + * Starts the in-process runtime: spawns the CLI worker via the native host, + * waits for readiness, and opens the FFI JSON-RPC connection. + */ + async start(): Promise { + const argvJson = buildArgvJson(this.cliEntrypoint, this.args); + const envJson = buildEnvJson(this.environment); + + // The native host spawns the CLI worker itself and has no cwd parameter, so the + // worker inherits this process's cwd. A custom working directory is intentionally + // unsupported for the in-process transport (rejected by the client constructor) + // rather than mutating the shared process-global cwd here. + + // host_start blocks until the worker connects back and signals readiness + // (up to ~30s); run it as an async FFI call so the Node event loop isn't blocked. + this.serverId = await new Promise((resolvePromise, rejectPromise) => { + this.lib.hostStart.async( + argvJson, + argvJson.length, + envJson, + envJson ? envJson.length : 0, + (error: Error | null, result: number) => { + if (error) { + rejectPromise(error); + } else { + resolvePromise(result); + } + } + ); + }); + if (!this.serverId) { + throw new Error( + `copilot_runtime_host_start failed (library '${this.libraryPath}', entrypoint '${this.cliEntrypoint}').` + ); + } + + this.outboundCallback = koffi.register( + (_userData: unknown, bytesPtr: unknown, bytesLen: number | bigint) => + this.feedInbound(bytesPtr, bytesLen), + this.lib.outboundCallbackType + ); + + this.connectionId = this.lib.connectionOpen( + this.serverId, + this.outboundCallback, + null, + null, + 0, + null, + 0, + null, + 0 + ); + if (!this.connectionId) { + this.unregisterCallback(); + this.lib.hostShutdown(this.serverId); + this.serverId = 0; + throw new Error("copilot_runtime_connection_open failed."); + } + + // The in-process transport has no socket/pipe handle to keep the Node event loop + // alive while the SDK is idle awaiting a server→client frame. koffi delivers the + // outbound callback on the loop but does not reference it, so hold one referenced + // timer for the lifetime of the connection. + this.keepAliveTimer = setInterval(() => {}, KEEP_ALIVE_INTERVAL_MS); + } + + private writeFrame(frame: Buffer): void { + if (this.disposed || !this.connectionId) { + throw new Error("The in-process runtime connection is closed."); + } + const ok = this.lib.connectionWrite(this.connectionId, frame, frame.length); + if (!ok) { + throw new Error("Failed to write a frame to the in-process runtime connection."); + } + } + + /** + * Native outbound (server→client) callback. koffi delivers it on the JS event loop + * via a threadsafe function, so the frame is decoded and written straight to + * {@link receiveStream}. The native pointer is only valid for this call, so the + * bytes are copied out before returning. + */ + private feedInbound(bytesPtr: unknown, bytesLen: number | bigint): void { + // An exception thrown across the native→JS (Node-API) boundary cannot propagate + // and would surface only as a DEP0168 "uncaught Node-API callback exception" + // warning, so catch and log it here instead of letting it escape. + try { + // A native outbound callback can still be delivered on the event loop after + // dispose() has ended receiveStream; writing then would throw + // ERR_STREAM_WRITE_AFTER_END. Drop late frames instead — the connection is + // gone and nothing is reading them. + if (this.disposed || this.receiveStream.writableEnded) { + return; + } + const length = Number(bytesLen); + if (!bytesPtr || length <= 0) { + return; + } + const bytes = koffi.decode( + bytesPtr, + koffi.array("uint8", length, "Typed") + ) as Uint8Array; + this.receiveStream.write(Buffer.from(bytes)); + } catch (error) { + console.error( + `In-process FFI inbound callback failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}` + ); + } + } + + private unregisterCallback(): void { + if (this.outboundCallback === undefined) { + return; + } + const callback = this.outboundCallback; + this.outboundCallback = undefined; + try { + koffi.unregister(callback); + } catch { + // Ignore teardown failures. + } + } + + /** Closes the FFI connection, shuts down the native host, and releases resources. */ + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + + if (this.keepAliveTimer !== undefined) { + clearInterval(this.keepAliveTimer); + this.keepAliveTimer = undefined; + } + + try { + if (this.connectionId) { + this.lib.connectionClose(this.connectionId); + this.connectionId = 0; + } + } catch { + // Ignore teardown failures. + } + + try { + if (this.serverId) { + this.lib.hostShutdown(this.serverId); + this.serverId = 0; + } + } catch { + // Ignore teardown failures. + } + + this.receiveStream.end(); + this.unregisterCallback(); + } +} diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 4814ea191f..255a769d55 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -521,6 +521,47 @@ export type FilterMapping = [k: string]: ContentFilterMode; } | ContentFilterMode; +/** + * Hook event name dispatched through the SDK callback transport. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "HookType". + */ +/** @experimental */ +/** @internal */ +export type HookType = + /** Runs before a tool is invoked. */ + | "preToolUse" + /** Runs before an MCP tool is invoked. */ + | "preMcpToolCall" + /** Runs after a tool completes successfully. */ + | "postToolUse" + /** Runs after a tool fails. */ + | "postToolUseFailure" + /** Runs after the user submits a prompt. */ + | "userPromptSubmitted" + /** Runs when a session starts. */ + | "sessionStart" + /** Runs when a session ends. */ + | "sessionEnd" + /** Runs after an agent result is produced. */ + | "postResult" + /** Runs before a pull request description is generated. */ + | "prePRDescription" + /** Runs when the agent encounters an error. */ + | "errorOccurred" + /** Runs when the agent stops. */ + | "agentStop" + /** Runs when a subagent starts. */ + | "subagentStart" + /** Runs when a subagent stops. */ + | "subagentStop" + /** Runs before conversation context is compacted. */ + | "preCompact" + /** Runs when the agent requests permission. */ + | "permissionRequest" + /** Runs when the agent emits a notification. */ + | "notification"; /** * Source for direct repo installs (when marketplace is empty) * @@ -817,6 +858,18 @@ export type McpHeadersHandlePendingHeadersRefreshRequest = | { kind: "none"; }; +/** + * Consumer allowed to call an MCP tool. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpToolUiVisibility". + */ +/** @experimental */ +export type McpToolUiVisibility = + /** The model may call the tool. */ + | "model" + /** An MCP App view may call the tool. */ + | "app"; /** * Host response to the pending OAuth request. * @@ -3370,6 +3423,10 @@ export interface DiscoveredCanvas { * Short, single-sentence description shown to the agent in canvas catalogs. */ description: string; + /** + * Host-local PNG path for the canvas icon, when supplied + */ + icon?: string; inputSchema?: CanvasJsonSchema; /** * Actions the agent or host may invoke on an open instance @@ -3425,6 +3482,10 @@ export interface OpenCanvasInstance { * Provider-local canvas identifier */ canvasId: string; + /** + * Host-local PNG path for the canvas icon, when supplied + */ + icon?: string; /** * Rendered title */ @@ -5122,6 +5183,30 @@ export interface HistoryTruncateResult { */ eventsRemoved: number; } +/** + * Runtime-owned wire payload for a server-to-client hook callback invocation. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "HookInvokeRequest". + */ +/** @experimental */ +/** @internal */ +export interface HookInvokeRequest { + sessionId: string; + hookType: HookType; + input: unknown; +} +/** + * Optional output returned by an SDK callback hook. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "HookInvokeResponse". + */ +/** @experimental */ +/** @internal */ +export interface HookInvokeResponse { + output?: unknown; +} /** * Installed plugin record from global state, with marketplace, version, install time, enabled state, cache path, and source. * @@ -6620,7 +6705,7 @@ export interface McpListToolsResult { tools: McpTools[]; } /** - * MCP tool metadata with tool name and optional description. + * MCP tool metadata with tool name, optional description, and normalized MCP Apps discovery metadata. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "McpTools". @@ -6635,6 +6720,24 @@ export interface McpTools { * Tool description, when provided. */ description?: string; + ui?: McpToolUi; +} +/** + * Normalized MCP Apps discovery metadata from a tool's `_meta.ui` block. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpToolUi". + */ +/** @experimental */ +export interface McpToolUi { + /** + * URI of the tool's MCP App resource, typically a `ui://` resource identifier. Use `session.mcp.resources.read` to fetch its HTML and resource metadata. + */ + resourceUri?: string; + /** + * Tool visibility advertised by the server. When absent, MCP Apps defaults apply. + */ + visibility?: McpToolUiVisibility[]; } /** * Pending MCP OAuth request ID and host-provided token or cancellation response. @@ -6784,27 +6887,302 @@ export interface McpRemoveGitHubResult { removed: boolean; } /** - * Server name and opaque configuration for an individual MCP server restart. + * An MCP resource descriptor (spec `Resource`): URI, name, and optional title, description, MIME type, size, icons, annotations, and metadata. Server-provided fields outside the standard descriptor shape are exposed under `additionalProperties`. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "McpRestartServerRequest". + * via the `definition` "McpResource". */ /** @experimental */ -/** @internal */ -export interface McpRestartServerRequest { +export interface McpResource { /** - * Name of the MCP server to restart + * The resource URI (e.g. ui://... or file:///...) + */ + uri: string; + /** + * The programmatic name of the resource + */ + name: string; + /** + * Optional human-readable display title + */ + title?: string; + /** + * Optional description of what this resource represents + */ + description?: string; + /** + * MIME type of the resource, if known + */ + mimeType?: string; + /** + * Resource size in bytes, when known + */ + size?: number; + /** + * Icons associated with this resource + */ + icons?: McpResourceIcon[]; + annotations?: McpResourceAnnotations; + /** + * Resource-level metadata + */ + _meta?: { + [k: string]: unknown | undefined; + }; + /** + * Server-provided non-standard descriptor fields preserved from the MCP response + */ + additionalProperties?: { + [k: string]: unknown | undefined; + }; +} +/** + * A resource icon descriptor plus preserved non-standard icon fields. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpResourceIcon". + */ +/** @experimental */ +export interface McpResourceIcon { + /** + * Icon URI + */ + src: string; + /** + * Icon MIME type, when known + */ + mimeType?: string; + /** + * Icon sizes hint + */ + sizes?: string; + /** + * Theme hint for this icon + */ + theme?: string; + /** + * Server-provided non-standard icon fields preserved from the MCP response + */ + additionalProperties?: { + [k: string]: unknown | undefined; + }; +} +/** + * Standard MCP resource annotations plus preserved non-standard annotation fields. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpResourceAnnotations". + */ +/** @experimental */ +export interface McpResourceAnnotations { + /** + * Intended audience roles for this resource + */ + audience?: string[]; + /** + * Priority hint for model/client use + */ + priority?: number; + /** + * Last-modified timestamp hint + */ + lastModified?: string; + /** + * Server-provided non-standard annotation fields preserved from the MCP response + */ + additionalProperties?: { + [k: string]: unknown | undefined; + }; +} +/** + * MCP resource content with URI, optional MIME type, text or base64 blob, and resource metadata. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpResourceContent". + */ +/** @experimental */ +export interface McpResourceContent { + /** + * The resource URI + */ + uri: string; + /** + * MIME type of the content + */ + mimeType?: string; + /** + * Text content (e.g. HTML) + */ + text?: string; + /** + * Base64-encoded binary content + */ + blob?: string; + /** + * Resource-level metadata (CSP, permissions, etc.) + */ + _meta?: { + [k: string]: unknown | undefined; + }; +} +/** + * MCP server whose resources to enumerate. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpResourcesListRequest". + */ +/** @experimental */ +export interface McpResourcesListRequest { + /** + * Name of the MCP server whose resources to enumerate */ serverName: string; /** - * Opaque server configuration (MCPServerConfig). Marked internal: an in-process runtime shape supplied only by in-process CLI callers. - * - * @internal + * Opaque MCP pagination cursor from a prior `nextCursor` value */ - config: { + cursor?: string; +} +/** + * One page of resources advertised by the named MCP server. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpResourcesListResult". + */ +/** @experimental */ +export interface McpResourcesListResult { + /** + * Resources advertised by the server (proxied MCP `resources/list`) + */ + resources: McpResource[]; + /** + * Opaque cursor for the next page, if the server has more resources + */ + nextCursor?: string; +} +/** + * MCP server whose resource templates to enumerate. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpResourcesListTemplatesRequest". + */ +/** @experimental */ +export interface McpResourcesListTemplatesRequest { + /** + * Name of the MCP server whose resource templates to enumerate + */ + serverName: string; + /** + * Opaque MCP pagination cursor from a prior `nextCursor` value + */ + cursor?: string; +} +/** + * One page of resource templates advertised by the named MCP server. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpResourcesListTemplatesResult". + */ +/** @experimental */ +export interface McpResourcesListTemplatesResult { + /** + * Resource templates advertised by the server (proxied MCP `resources/templates/list`) + */ + resourceTemplates: McpResourceTemplate[]; + /** + * Opaque cursor for the next page, if the server has more resource templates + */ + nextCursor?: string; +} +/** + * An MCP resource template descriptor (spec `ResourceTemplate`): an RFC 6570 URI template, name, and optional title, description, MIME type, icons, annotations, and metadata. Server-provided fields outside the standard descriptor shape are exposed under `additionalProperties`. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpResourceTemplate". + */ +/** @experimental */ +export interface McpResourceTemplate { + /** + * An RFC 6570 URI template for constructing resource URIs + */ + uriTemplate: string; + /** + * The programmatic name of the resource template + */ + name: string; + /** + * Optional human-readable display title + */ + title?: string; + /** + * Optional description of what this template is for + */ + description?: string; + /** + * MIME type for resources matching this template, if uniform + */ + mimeType?: string; + /** + * Icons associated with resources matching this template + */ + icons?: McpResourceIcon[]; + annotations?: McpResourceAnnotations; + /** + * Resource-template-level metadata + */ + _meta?: { + [k: string]: unknown | undefined; + }; + /** + * Server-provided non-standard descriptor fields preserved from the MCP response + */ + additionalProperties?: { [k: string]: unknown | undefined; }; } +/** + * MCP server and resource URI to fetch. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpResourcesReadRequest". + */ +/** @experimental */ +export interface McpResourcesReadRequest { + /** + * Name of the MCP server hosting the resource + */ + serverName: string; + /** + * Resource URI + */ + uri: string; +} +/** + * Resource contents returned by the MCP server. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpResourcesReadResult". + */ +/** @experimental */ +export interface McpResourcesReadResult { + /** + * Resource contents returned by the server + */ + contents: McpResourceContent[]; +} +/** + * Server name and optional replacement configuration for an individual MCP server restart. Omit `config` for a config-free restart-by-name of an already-configured server. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "McpRestartServerRequest". + */ +/** @experimental */ +export interface McpRestartServerRequest { + /** + * Name of the MCP server to restart + */ + serverName: string; + config?: McpServerConfig; +} /** * Outcome of an MCP sampling execution: success result, failure error, or cancellation. * @@ -6882,26 +7260,18 @@ export interface McpSetEnvValueModeResult { mode: McpSetEnvValueModeDetails; } /** - * Server name and opaque configuration for an individual MCP server start. + * Server name and configuration for an individual MCP server start. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "McpStartServerRequest". */ /** @experimental */ -/** @internal */ export interface McpStartServerRequest { /** * Name of the MCP server to start */ serverName: string; - /** - * Opaque server configuration (MCPServerConfig). Marked internal: an in-process runtime shape supplied only by in-process CLI callers. - * - * @internal - */ - config: { - [k: string]: unknown | undefined; - }; + config: McpServerConfig; } /** * MCP server startup filtering result. @@ -7141,7 +7511,7 @@ export interface SessionWorkingDirectoryContext { /** @experimental */ export interface MetadataRecordContextChangeResult {} /** - * Absolute path to set as the session's new working directory. + * Absolute path to set as the session's new working directory. For local sessions the path must be absolute and exist on disk: it is validated before any session state changes, and a failing validation rejects the call with nothing mutated, persisted, or emitted. Remote sessions record the path as-is. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "MetadataSetWorkingDirectoryRequest". @@ -7154,7 +7524,7 @@ export interface MetadataSetWorkingDirectoryRequest { workingDirectory: string; } /** - * Update the session's working directory. Used by the host when the user explicitly changes cwd (e.g., the `/cd` slash command). The host is responsible for `process.chdir` and any related side-effects (file index, etc.); this method only updates the session's own recorded path. + * Update the session's working directory. Used by the host when the user explicitly changes cwd (e.g., the `/cd` slash command). The host is responsible for any related side-effects (file index, etc.); it does NOT change the process working directory (a session's cwd is per-session, not process-global). For local sessions the runtime validates the target first (an absolute path that exists on disk) and re-bases the permission primary directory; a rejected validation fails the call before anything is mutated, persisted, or emitted. Location-scoped permission rules are then re-keyed to the new directory (best-effort). Remote sessions only record the path. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "MetadataSetWorkingDirectoryResult". @@ -7339,6 +7709,7 @@ export interface ModelBilling { * Whole-number percentage discount (0-100) applied to usage billed through this model. Populated for the synthetic `auto` model, where requests routed by auto-mode are billed at a reduced rate; absent for concrete models. */ discountPercent?: number; + promo?: ModelBillingPromo; } /** * Token-level pricing information for this model @@ -7423,6 +7794,31 @@ export interface ModelBillingTokenPricesLongContext { */ maxPromptTokens?: number; } +/** + * Active server-driven promotion for a model, including its discount and expiry. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ModelBillingPromo". + */ +/** @experimental */ +export interface ModelBillingPromo { + /** + * Stable identifier for the promotion campaign. + */ + id?: string; + /** + * Percentage discount (0-100) applied while the promotion is active. May be fractional. + */ + discountPercent?: number; + /** + * UTC ISO 8601 timestamp marking when the promotion ends. Always present: the API only surfaces a promo whose expiry parses and is in the future. Consumers should treat a past value as expired. + */ + endsAt: string; + /** + * Human-readable promotion message. Does not include the expiry timestamp; consumers may format endsAt and append it. + */ + message?: string; +} /** * Optional capability overrides (vision, tool_calls, reasoning, etc.). * @@ -9303,7 +9699,7 @@ export interface PluginsInstallRequest { workingDirectory?: string; } /** - * Marketplace source to register. + * Marketplace source and optional working directory for relative-path resolution. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "PluginsMarketplacesAddRequest". @@ -9314,6 +9710,10 @@ export interface PluginsMarketplacesAddRequest { * Marketplace source. Accepts the same forms as the CLI: "owner/repo" or "owner/repo#ref" (GitHub), an http/https/ssh URL (optionally with #ref), a git scp-style URL (user@host:path), or a local path. The marketplace's own name (from its manifest) is used as the registration key. */ source: string; + /** + * Working directory used to resolve relative local paths in `source`. Defaults to the server's current working directory. + */ + workingDirectory?: string; } /** * Name of the marketplace whose plugin catalog to fetch. @@ -10217,7 +10617,7 @@ export interface QueueRemoveMostRecentResult { /** @experimental */ export interface RegisterEventInterestParams { /** - * The event type the consumer wants the runtime to treat as 'observed' for behavior-switching gating. Some runtime code paths inspect whether any consumer is interested in a specific event type and choose a different implementation accordingly (e.g. `mcp.oauth_required`: when interest is registered the runtime delegates OAuth token acquisition to the consumer; when no interest is registered OAuth-required servers become needs-auth). SDK clients that long-poll events do NOT automatically appear as listeners to these gating checks — they must explicitly call `registerInterest` for each event type they want the runtime to count as having a consumer. Multiple registrations for the same event type from the same or different consumers are tracked independently and must each be released. See: `mcp.oauth_required`, `sampling.requested`, `auto_mode_switch.requested`, `session_limits_exhausted.requested`, `user_input.requested`, `elicitation.requested`, `command.queued`, `exit_plan_mode.requested`. + * The event type the consumer wants the runtime to treat as 'observed' for behavior-switching gating. Some runtime code paths inspect whether any consumer is interested in a specific event type and choose a different implementation accordingly (e.g. `mcp.oauth_required`: when interest is registered the runtime delegates interactive OAuth token acquisition to the consumer via `mcp.oauth_required` events; when no interest is registered the runtime still attempts non-interactive reconnect from cached or refreshable tokens, and only marks the server `needs-auth` if usable credentials are unavailable — it does not open a browser or start interactive OAuth without a consumer). SDK clients that long-poll events do NOT automatically appear as listeners to these gating checks — they must explicitly call `registerInterest` for each event type they want the runtime to count as having a consumer. Multiple registrations for the same event type from the same or different consumers are tracked independently and must each be released. See: `mcp.oauth_required`, `sampling.requested`, `auto_mode_switch.requested`, `session_limits_exhausted.requested`, `user_input.requested`, `elicitation.requested`, `command.queued`, `exit_plan_mode.requested`. */ eventType: string; } @@ -10881,6 +11281,93 @@ export interface SendAttachmentsToMessageParams { */ attachments: PushAttachment[]; } +/** + * A single user message to append to the session as part of a `session.sendMessages` turn + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "SendMessageItem". + */ +/** @experimental */ +export interface SendMessageItem { + /** + * The user message text + */ + prompt: string; + /** + * If provided, this is shown in the timeline instead of `prompt` + */ + displayPrompt?: string; + /** + * Optional attachments (files, directories, selections, blobs, GitHub references) to include with this message + */ + attachments?: Attachment[]; + /** + * If false, this message will not trigger a Premium Request Unit charge. User messages default to billable. + * + * @internal + */ + billable?: boolean; + /** + * If set, the request will fail if the named tool is not available when this message is among the user messages at the start of the current exchange + */ + requiredTool?: string; + /** + * Optional provenance tag copied to the resulting user.message event. Must match one of three forms: the literal `system`, `command-` for messages originating from a command (e.g. slash command, Mission Control command), or `schedule-` for messages originating from a scheduled job. + * + * @internal + */ + source?: string; +} +/** + * Parameters for sending zero or more user messages to the session in a single turn. Remote-backed (Mission Control) sessions do not support this method and will return an error. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "SendMessagesRequest". + */ +/** @experimental */ +export interface SendMessagesRequest { + /** + * The user messages to append to the conversation, in order. May be empty, in which case a single turn runs over the existing history with no new user message. + */ + messages: SendMessageItem[]; + mode?: SendMode; + /** + * If true, adds the messages to the front of the queue instead of the end + */ + prepend?: boolean; + agentMode?: SendAgentMode; + /** + * Custom HTTP headers to include in outbound model requests for this turn. Merged with session-level provider headers; per-turn headers augment and overwrite session-level headers with the same key. + */ + requestHeaders?: { + [k: string]: string | undefined; + }; + /** + * W3C Trace Context traceparent header for distributed tracing of this agent turn + */ + traceparent?: string; + /** + * W3C Trace Context tracestate header for distributed tracing + */ + tracestate?: string; + /** + * If true, await completion of the agentic loop for this turn before returning. Defaults to false (fire-and-forget). When true, the result still contains the same `messageIds`; the caller can rely on the agent having processed the messages before the call resolves. + */ + wait?: boolean; +} +/** + * Result of sending zero or more user messages + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "SendMessagesResult". + */ +/** @experimental */ +export interface SendMessagesResult { + /** + * Unique identifiers assigned to the messages, one per provided message in order. Empty when no messages were provided. + */ + messageIds: string[]; +} /** * Parameters for sending a user message to the session * @@ -11744,6 +12231,10 @@ export interface SessionModelList { * Available models, ordered with the most preferred default first. Includes both Copilot (CAPI) models and any registry BYOK models; a BYOK model appears under its provider-qualified selection id (`provider/id`). */ list: unknown[]; + /** + * Cost categories for the full CAPI catalog, including picker-disabled models that Auto may select. Metadata only; entries absent from `list` are not manually selectable. + */ + modelPriceCategories?: SessionModelPriceCategory[]; /** * Per-quota snapshots returned alongside the model list, keyed by quota type. */ @@ -11751,6 +12242,17 @@ export interface SessionModelList { [k: string]: unknown | undefined; }; } +/** + * Cost-category metadata for a CAPI model. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "SessionModelPriceCategory". + */ +/** @experimental */ +export interface SessionModelPriceCategory { + id: string; + priceCategory: ModelPickerPriceCategory; +} /** * Session construction options. * @@ -11863,6 +12365,10 @@ export interface SessionOpenOptions { * Denylist of tool names. */ excludedTools?: string[]; + /** + * Built-in subagent names to include in this session. When specified, only these built-ins are available, subject to runtime availability and exclusions. Custom agents with the same name remain available. + */ + includedBuiltinAgents?: string[]; /** * Built-in subagent names to exclude from this session. Excluded built-ins are hidden from agent discovery and cannot be dispatched unless a custom agent with the same name is available. */ @@ -12963,6 +13469,10 @@ export interface SessionUpdateOptionsParams { * Denylist of tool names for this session. */ excludedTools?: string[]; + /** + * Built-in subagent names to include in this session. When specified, only these built-ins are available, subject to runtime availability and exclusions. Custom agents with the same name remain available. Set to null to remove the allowlist restriction. + */ + includedBuiltinAgents?: string[] | null; /** * Built-in subagent names to exclude from this session. Excluded built-ins are hidden from agent discovery and cannot be dispatched unless a custom agent with the same name is available. */ @@ -13122,6 +13632,10 @@ export interface SessionUpdateOptionsResult { * Whether the operation succeeded */ success: boolean; + /** + * Number of hooks loaded from installed plugins, returned when installedPlugins is updated + */ + pluginHookCount?: number; } /** * User-requested shell execution cancellation handle. @@ -13451,7 +13965,7 @@ export interface SkillsLoadDiagnostics { errors: string[]; } /** - * Slash-command invocation result that submits an agent prompt, with display prompt, optional mode, and settings-change flag. + * Slash-command invocation result that submits an agent prompt, with display prompt, optional mode, optional user-facing notice, and settings-change flag. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SlashCommandAgentPromptResult". @@ -13471,6 +13985,10 @@ export interface SlashCommandAgentPromptResult { */ displayPrompt: string; mode?: SessionMode; + /** + * Optional user-facing notice to show before the prompt is submitted + */ + notice?: string; /** * True when the invocation mutated user runtime settings; consumers caching settings should refresh */ @@ -15541,7 +16059,7 @@ export function createServerRpc(connection: MessageConnection) { /** * Registers a new marketplace from a source (owner/repo, URL, or local path). * - * @param params Marketplace source to register. + * @param params Marketplace source and optional working directory for relative-path resolution. * * @returns Result of registering a new marketplace. */ @@ -15650,6 +16168,16 @@ export function createServerRpc(connection: MessageConnection) { connection.sendRequest("instructions.getDiscoveryPaths", params), }, /** @experimental */ + commands: { + /** + * Lists the well-known built-in slash commands that work as the first message in a new session (e.g. /plan, /env), without requiring an active session. Commands that depend on session state, authentication, or a synced session are omitted. + * + * @returns Slash commands available in the session, after applying any include/exclude filters. + */ + list: async (): Promise => + connection.sendRequest("commands.list", {}), + }, + /** @experimental */ user: { /** @experimental */ settings: { @@ -16033,6 +16561,17 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin */ send: async (params: SendRequest): Promise => connection.sendRequest("session.send", { sessionId, ...params }), + /** + * Sends zero or more user messages to the session in a single turn and returns their message IDs. All provided messages are appended to the conversation in order, then exactly one agent turn runs over the resulting history. When the list is empty, one turn runs over the existing history with no new user message. Remote-backed (Mission Control) sessions do not support this method and will return an error. + * + * @param params Parameters for sending zero or more user messages to the session in a single turn. Remote-backed (Mission Control) sessions do not support this method and will return an error. + * + * @returns Result of sending zero or more user messages + * + * @experimental + */ + sendMessages: async (params: SendMessagesRequest): Promise => + connection.sendRequest("session.sendMessages", { sessionId, ...params }), /** * Aborts the current agent turn. * @@ -16536,7 +17075,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin list: async (): Promise => connection.sendRequest("session.mcp.list", { sessionId }), /** - * Lists the tools exposed by a connected MCP server on this session's host. + * Lists the tools exposed by a connected MCP server on this session's host. This performs a live `tools/list` request. Tool UI metadata is returned independently of whether MCP Apps rendering is enabled for the session. * * @param params Server name whose tool list should be returned. * @@ -16597,6 +17136,20 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin */ removeGitHub: async (): Promise => connection.sendRequest("session.mcp.removeGitHub", { sessionId }), + /** + * Starts an individual MCP server on the live session from a caller-supplied config. Session-scoped and ephemeral: the server is added to this session's running set only and is reaped when the session ends. Does NOT modify persistent user configuration (`mcp.config.*`), so it does not affect future sessions. The server surfaces through `session.mcp.list` and the `session.mcp_servers_loaded` / `session.mcp_server_status_changed` events like any other server. + * + * @param params Server name and configuration for an individual MCP server start. + */ + startServer: async (params: McpStartServerRequest): Promise => + connection.sendRequest("session.mcp.startServer", { sessionId, ...params }), + /** + * Restarts an individual MCP server on the live session (stops then starts). Omit `config` for a config-free restart-by-name of an already-configured server; supply `config` to restart with a replacement configuration. Session-scoped and ephemeral: does NOT modify persistent user configuration (`mcp.config.*`). + * + * @param params Server name and optional replacement configuration for an individual MCP server restart. Omit `config` for a config-free restart-by-name of an already-configured server. + */ + restartServer: async (params: McpRestartServerRequest): Promise => + connection.sendRequest("session.mcp.restartServer", { sessionId, ...params }), /** * Stops an individual MCP server on the session's host. * @@ -16699,6 +17252,36 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin diagnose: async (params: McpAppsDiagnoseRequest): Promise => connection.sendRequest("session.mcp.apps.diagnose", { sessionId, ...params }), }, + /** @experimental */ + resources: { + /** + * Fetch an MCP resource from a connected server by URI (proxies MCP `resources/read`). + * + * @param params MCP server and resource URI to fetch. + * + * @returns Resource contents returned by the MCP server. + */ + read: async (params: McpResourcesReadRequest): Promise => + connection.sendRequest("session.mcp.resources.read", { sessionId, ...params }), + /** + * Enumerate one page of resources a connected MCP server exposes (proxies MCP `resources/list`). Pass `cursor` to continue from a prior result's `nextCursor`. + * + * @param params MCP server whose resources to enumerate. + * + * @returns One page of resources advertised by the named MCP server. + */ + list: async (params: McpResourcesListRequest): Promise => + connection.sendRequest("session.mcp.resources.list", { sessionId, ...params }), + /** + * Enumerate one page of resource templates a connected MCP server exposes (proxies MCP `resources/templates/list`). Pass `cursor` to continue from a prior result's `nextCursor`. + * + * @param params MCP server whose resource templates to enumerate. + * + * @returns One page of resource templates advertised by the named MCP server. + */ + listTemplates: async (params: McpResourcesListTemplatesRequest): Promise => + connection.sendRequest("session.mcp.resources.listTemplates", { sessionId, ...params }), + }, }, /** @experimental */ plugins: { @@ -17261,11 +17844,11 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin recordContextChange: async (params: MetadataRecordContextChangeRequest): Promise => connection.sendRequest("session.metadata.recordContextChange", { sessionId, ...params }), /** - * Updates the session's recorded working directory. + * Updates the session's working directory. For local sessions the target is validated first (an absolute path that exists on disk) and the permission primary directory is re-based; a rejected validation fails the call before any session state changes. * - * @param params Absolute path to set as the session's new working directory. + * @param params Absolute path to set as the session's new working directory. For local sessions the path must be absolute and exist on disk: it is validated before any session state changes, and a failing validation rejects the call with nothing mutated, persisted, or emitted. Remote sessions record the path as-is. * - * @returns Update the session's working directory. Used by the host when the user explicitly changes cwd (e.g., the `/cd` slash command). The host is responsible for `process.chdir` and any related side-effects (file index, etc.); this method only updates the session's own recorded path. + * @returns Update the session's working directory. Used by the host when the user explicitly changes cwd (e.g., the `/cd` slash command). The host is responsible for any related side-effects (file index, etc.); it does NOT change the process working directory (a session's cwd is per-session, not process-global). For local sessions the runtime validates the target first (an absolute path that exists on disk) and re-bases the permission primary directory; a rejected validation fails the call before anything is mutated, persisted, or emitted. Location-scoped permission rules are then re-keyed to the new directory (best-effort). Remote sessions only record the path. */ setWorkingDirectory: async (params: MetadataSetWorkingDirectoryRequest): Promise => connection.sendRequest("session.metadata.setWorkingDirectory", { sessionId, ...params }), @@ -17523,20 +18106,6 @@ export function createInternalSessionRpc(connection: MessageConnection, sessionI */ configureGitHub: async (params: McpConfigureGitHubRequest): Promise => connection.sendRequest("session.mcp.configureGitHub", { sessionId, ...params }), - /** - * Starts an individual MCP server on the session's host. - * - * @param params Server name and opaque configuration for an individual MCP server start. - */ - startServer: async (params: McpStartServerRequest): Promise => - connection.sendRequest("session.mcp.startServer", { sessionId, ...params }), - /** - * Restarts an individual MCP server on the session's host (stops then starts). - * - * @param params Server name and opaque configuration for an individual MCP server restart. - */ - restartServer: async (params: McpRestartServerRequest): Promise => - connection.sendRequest("session.mcp.restartServer", { sessionId, ...params }), /** * Registers a pre-connected external MCP client (e.g. IDE) on the session's host. The caller retains lifecycle ownership of the client and transport. Marked internal because the `client` and `transport` arguments are in-process MCP SDK instances that cannot be serialized across the JSON-RPC boundary; once the CLI moves on top of the SDK, external clients will be expressed as transport configs the runtime can construct itself. * @@ -17814,6 +18383,19 @@ export function registerClientSessionApiHandlers( }); } +/** Handler for `hooks` client global API methods. */ +/** @experimental */ +export interface HooksHandler { + /** + * Dispatches one SDK callback hook from the runtime to the connection that registered it. Internal transport plumbing: clients opt in through session initialization and the Rust hook processor owns ordering, policy, timeout, and callback routing. + * + * @param params Runtime-owned wire payload for a server-to-client hook callback invocation. + * + * @returns Optional output returned by an SDK callback hook. + */ + invoke(params: HookInvokeRequest): Promise; +} + /** Handler for `llmInference` client global API methods. */ /** @experimental */ export interface LlmInferenceHandler { @@ -17848,6 +18430,7 @@ export interface GitHubTelemetryHandler { /** All client global API handler groups. */ export interface ClientGlobalApiHandlers { + hooks?: HooksHandler; llmInference?: LlmInferenceHandler; gitHubTelemetry?: GitHubTelemetryHandler; } @@ -17863,6 +18446,11 @@ export function registerClientGlobalApiHandlers( connection: MessageConnection, handlers: ClientGlobalApiHandlers, ): void { + connection.onRequest("hooks.invoke", async (params: HookInvokeRequest) => { + const handler = handlers.hooks; + if (!handler) throw new Error("No hooks client-global handler registered"); + return handler.invoke(params); + }); connection.onRequest("llmInference.httpRequestStart", async (params: LlmInferenceHttpRequestStartRequest) => { const handler = handlers.llmInference; if (!handler) throw new Error("No llmInference client-global handler registered"); diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index c62044006c..7581545a8e 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -40,6 +40,7 @@ export type SessionEvent = | PendingMessagesModifiedEvent | AssistantTurnStartEvent | AssistantIntentEvent + | AssistantServerToolProgressEvent | AssistantReasoningEvent | AssistantReasoningDeltaEvent | AssistantToolCallDeltaEvent @@ -91,6 +92,8 @@ export type SessionEvent = | AutoModeSwitchCompletedEvent | SessionLimitsExhaustedRequestedEvent | SessionLimitsExhaustedCompletedEvent + | AutoModeResolvedEvent + | ManagedSettingsResolvedEvent | CommandsChangedEvent | CapabilitiesChangedEvent | ExitPlanModeRequestedEvent @@ -101,6 +104,9 @@ export type SessionEvent = | CustomAgentsUpdatedEvent | McpServersLoadedEvent | McpServerStatusChangedEvent + | McpToolsListChangedEvent + | McpResourcesListChangedEvent + | McpPromptsListChangedEvent | ExtensionsLoadedEvent | CanvasOpenedEvent | CanvasRegistryChangedEvent @@ -631,6 +637,26 @@ export type SessionLimitsExhaustedResponseAction = | "unset" /** Leave the limit unchanged and cancel the blocked model request. */ | "cancel"; +/** + * Coarse request-difficulty bucket for UX explainability + */ +export type AutoModeResolvedReasoningBucket = + /** The request looks low-reasoning; a lighter model is appropriate. */ + | "low" + /** The request needs a moderate amount of reasoning. */ + | "medium" + /** The request looks high-reasoning; a stronger model is appropriate. */ + | "high"; +/** + * Which channel supplied the effective enterprise managed settings (highest-authority present layer wins wholesale) + */ +export type ManagedSettingsResolvedSource = + /** Account/org policy self-fetched from the GitHub managed-settings endpoint (higher authority). */ + | "server" + /** Device-level MDM policy discovered from plist/registry/file (lower authority). */ + | "device" + /** No managed policy is in force (no layer contributed). */ + | "none"; /** * Exit plan mode action */ @@ -3136,6 +3162,53 @@ export interface AssistantIntentData { */ intent: string; } +/** + * Session event "assistant.server_tool_progress". Live progress signal for a provider-hosted server tool (e.g. hosted web search) while it runs, before the finalized serverTools envelope lands on the terminal assistant.message + */ +export interface AssistantServerToolProgressEvent { + /** + * Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. + */ + agentId?: string; + data: AssistantServerToolProgressData; + /** + * Always true for events that are transient and not persisted to the session event log on disk. + */ + ephemeral: true; + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * Type discriminator. Always "assistant.server_tool_progress". + */ + type: "assistant.server_tool_progress"; +} +/** + * Live progress signal for a provider-hosted server tool (e.g. hosted web search) while it runs, before the finalized serverTools envelope lands on the terminal assistant.message + */ +export interface AssistantServerToolProgressData { + /** + * Kind of hosted server tool that is running. Only `web_search` is emitted today. + */ + kind: string; + /** + * Position of the hosted tool call in the response output. Stable across the call's lifecycle events (unlike the provider's per-event item id, which CAPI rotates), so the host keys the live in-progress row on it. + */ + outputIndex: number; + /** + * Lifecycle status of the hosted call: `in_progress`, `searching`, or `completed`. + */ + status: string; +} /** * Session event "assistant.reasoning". Assistant reasoning content for timeline display with complete thinking text */ @@ -3353,6 +3426,10 @@ export interface AssistantMessageData { * @experimental */ citations?: Citations; + /** + * Client-minted request id (x-request-id header) echoed by the server. Distinct from requestId (x-github-request-id) and serviceRequestId (x-copilot-service-request-id). + */ + clientRequestId?: string; /** * The assistant's text response content */ @@ -4451,6 +4528,14 @@ export interface ToolExecutionCompleteData { * Whether this tool call was explicitly requested by the user rather than the assistant */ isUserRequested?: boolean; + /** + * FIDES IFC label projected from tool ingress metadata (MCP `CallToolResult._meta` or synthesized built-in ingress labels). Persisted as `{ ifc: ... }` so the label survives session resume, including model-visible failure results. Experimental. + * + * @experimental + */ + mcpMeta?: { + [k: string]: unknown | undefined; + }; /** * Model identifier that generated this tool call */ @@ -4526,6 +4611,14 @@ export interface ToolExecutionCompleteResult { * Full detailed tool result for UI/timeline display, preserving complete content such as diffs. Falls back to content when absent. */ detailedContent?: string; + /** + * FIDES IFC label projected from tool ingress metadata (MCP `CallToolResult._meta` or synthesized built-in ingress labels) — persisted as `{ ifc: ... }` (only the `ifc` key, not the whole `_meta`). Persisted so the FIDES IFC label survives session resume: the engine rehydrates accumulated taint by replaying these on load. Populated for ingress sources when FIDES IFC is on. Experimental. + * + * @experimental + */ + mcpMeta?: { + [k: string]: unknown | undefined; + }; /** * Structured content (arbitrary JSON) returned verbatim by the MCP tool */ @@ -7113,6 +7206,7 @@ export interface McpOauthRequiredEvent { * OAuth authentication request for an MCP server */ export interface McpOauthRequiredData { + httpResponse?: McpOauthHttpResponse; reason: McpOauthRequestReason; /** * Unique identifier for this OAuth request; used to respond via session.mcp.oauth.handlePendingRequest @@ -7133,6 +7227,36 @@ export interface McpOauthRequiredData { staticClientConfig?: McpOauthRequiredStaticClientConfig; wwwAuthenticateParams?: McpOauthWWWAuthenticateParams; } +/** + * Raw HTTP response details from the OAuth auth challenge, as observed by the runtime. + */ +export interface McpOauthHttpResponse { + /** + * Complete UTF-8 response body for host-specific challenge handling, including an empty string for an empty body. Omitted when the complete body is not valid UTF-8; body read failures fail the HTTP operation rather than exposing a partial response. + */ + body?: string; + /** + * HTTP response headers as observed by the runtime. Order and casing are transport-dependent, and duplicate header names may appear multiple times. + */ + headers: HeaderEntry[]; + /** + * HTTP status code returned with the auth challenge. + */ + statusCode: number; +} +/** + * Single HTTP header entry as a name/value pair. + */ +export interface HeaderEntry { + /** + * HTTP response header name as observed by the runtime. + */ + name: string; + /** + * HTTP response header value as observed by the runtime. + */ + value: string; +} /** * Static OAuth client configuration, if the server specifies one */ @@ -7789,6 +7913,130 @@ export interface SessionLimitsExhaustedResponse { */ maxAiCredits?: number; } +/** + * Session event "session.auto_mode_resolved". Auto Intent resolution: the concrete model the session settled on for the first prompt of an auto-mode session, and why. Lets SDK clients render the chosen model and the full reason it was picked. The core selection fields (chosenModel/reasoningBucket/categoryScores) are stable; the routing-analytics fields (predictedLabel/confidence/candidateModels) mirror the upstream intent service and may evolve, hence the event's experimental stability. + */ +/** @experimental */ +export interface AutoModeResolvedEvent { + /** + * Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. + */ + agentId?: string; + data: AutoModeResolvedData; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ + ephemeral?: boolean; + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * Type discriminator. Always "session.auto_mode_resolved". + */ + type: "session.auto_mode_resolved"; +} +/** + * Auto Intent resolution: the concrete model the session settled on for the first prompt of an auto-mode session, and why. Lets SDK clients render the chosen model and the full reason it was picked. The core selection fields (chosenModel/reasoningBucket/categoryScores) are stable; the routing-analytics fields (predictedLabel/confidence/candidateModels) mirror the upstream intent service and may evolve, hence the event's experimental stability. + */ +/** @experimental */ +export interface AutoModeResolvedData { + /** + * Ordered candidate model list the router returned, when not a fallback + */ + candidateModels?: string[]; + /** + * Per-category classifier scores (0-1) behind the bucket: the granular HYDRA capability scores (reasoning, code_gen, debugging, tool_use), or the binary needs_reasoning/no_reasoning scores when HYDRA didn't run. Lets clients show a breakdown rather than just the bucket. + */ + categoryScores?: { + [k: string]: number | undefined; + }; + /** + * The concrete model the session will use after any intent refinement + */ + chosenModel: string; + /** + * Classifier confidence for the predicted label, when available + */ + confidence?: number; + /** + * The predicted classifier label (e.g. `needs_reasoning`), when available + */ + predictedLabel?: string; + reasoningBucket?: AutoModeResolvedReasoningBucket; +} +/** + * Session event "session.managed_settings_resolved". Enterprise managed-settings resolution: the effective managed settings the session applied and where they came from, so SDK clients can show users what is enterprise-managed and by which authority. Fires whenever managed policy is (re)applied — at session start, on resume, and on account switch. This is an ephemeral live snapshot (delivered to subscribers but not persisted to the session event log), because at session start it resolves before `session.start` is emitted; for a session-independent pull, use the SDK `getManagedSettings()` API, which returns the identical payload. Managed settings have a single authoritative source, so the highest-authority present layer (server > device) wins wholesale; `bypassPermissionsDisabled` is deny-wins across layers. Marked experimental while the managed-settings surface stabilizes. + */ +/** @experimental */ +export interface ManagedSettingsResolvedEvent { + /** + * Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. + */ + agentId?: string; + data: ManagedSettingsResolvedData; + /** + * Always true for events that are transient and not persisted to the session event log on disk. + */ + ephemeral: true; + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * Type discriminator. Always "session.managed_settings_resolved". + */ + type: "session.managed_settings_resolved"; +} +/** + * Enterprise managed-settings resolution: the effective managed settings the session applied and where they came from, so SDK clients can show users what is enterprise-managed and by which authority. Fires whenever managed policy is (re)applied — at session start, on resume, and on account switch. This is an ephemeral live snapshot (delivered to subscribers but not persisted to the session event log), because at session start it resolves before `session.start` is emitted; for a session-independent pull, use the SDK `getManagedSettings()` API, which returns the identical payload. Managed settings have a single authoritative source, so the highest-authority present layer (server > device) wins wholesale; `bypassPermissionsDisabled` is deny-wins across layers. Marked experimental while the managed-settings surface stabilizes. + */ +/** @experimental */ +export interface ManagedSettingsResolvedData { + /** + * Whether enterprise policy disables bypass-permissions ("yolo") mode for this session. Deny-wins across layers, and forced on when `failClosed` is true. + */ + bypassPermissionsDisabled: boolean; + /** + * Whether the device (MDM/plist/registry/file) managed-settings layer was present + */ + deviceManaged: boolean; + /** + * Whether managed policy could not be determined (e.g. a failed server fetch) and the session fell back to the fail-closed restriction. When true, restrictions such as disabling bypass-permissions are enforced even though `settings` may be absent. + */ + failClosed: boolean; + /** + * The setting keys under enterprise management in the effective managed settings (e.g. `model`, `enabledPlugins`, `permissions`). Empty when no managed settings are in force. + */ + managedKeys: string[]; + /** + * Whether the server (account/org) managed-settings layer was present + */ + serverManaged: boolean; + /** + * The effective (resolved) managed settings values, so clients can render exactly what is enforced. Absent when no managed policy is in force. + */ + settings?: { + [k: string]: unknown | undefined; + }; + source: ManagedSettingsResolvedSource; +} /** * Session event "commands.changed". SDK command registration change notification */ @@ -8331,6 +8579,105 @@ export interface McpServerStatusChangedData { serverName: string; status: McpServerStatus; } +/** + * Session event "mcp.tools.list_changed". Payload identifying the MCP server associated with a list change. + */ +export interface McpToolsListChangedEvent { + /** + * Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. + */ + agentId?: string; + data: McpListChangedData; + /** + * Always true for events that are transient and not persisted to the session event log on disk. + */ + ephemeral: true; + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * Type discriminator. Always "mcp.tools.list_changed". + */ + type: "mcp.tools.list_changed"; +} +/** + * Payload identifying the MCP server associated with a list change. + */ +export interface McpListChangedData { + /** + * Name of the MCP server whose list changed + */ + serverName: string; +} +/** + * Session event "mcp.resources.list_changed". Payload identifying the MCP server associated with a list change. + */ +export interface McpResourcesListChangedEvent { + /** + * Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. + */ + agentId?: string; + data: McpListChangedData; + /** + * Always true for events that are transient and not persisted to the session event log on disk. + */ + ephemeral: true; + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * Type discriminator. Always "mcp.resources.list_changed". + */ + type: "mcp.resources.list_changed"; +} +/** + * Session event "mcp.prompts.list_changed". Payload identifying the MCP server associated with a list change. + */ +export interface McpPromptsListChangedEvent { + /** + * Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. + */ + agentId?: string; + data: McpListChangedData; + /** + * Always true for events that are transient and not persisted to the session event log on disk. + */ + ephemeral: true; + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * Type discriminator. Always "mcp.prompts.list_changed". + */ + type: "mcp.prompts.list_changed"; +} /** * Session event "session.extensions_loaded". Payload of `session.extensions_loaded` listing discovered extensions and their statuses. */ @@ -8386,7 +8733,7 @@ export interface ExtensionsLoadedExtension { status: ExtensionsLoadedExtensionStatus; } /** - * Session event "session.canvas.opened". Payload of `session.canvas.opened` with canvas instance and provider IDs plus optional title, status, URL, and input. + * Session event "session.canvas.opened". Payload of `session.canvas.opened` with canvas instance and provider IDs plus optional icon, title, status, URL, and input. */ /** @experimental */ export interface CanvasOpenedEvent { @@ -8417,7 +8764,7 @@ export interface CanvasOpenedEvent { type: "session.canvas.opened"; } /** - * Payload of `session.canvas.opened` with canvas instance and provider IDs plus optional title, status, URL, and input. + * Payload of `session.canvas.opened` with canvas instance and provider IDs plus optional icon, title, status, URL, and input. */ /** @experimental */ export interface CanvasOpenedData { @@ -8433,6 +8780,10 @@ export interface CanvasOpenedData { * Owning extension display name, when available */ extensionName?: string; + /** + * Host-local PNG path for the canvas icon, when supplied + */ + icon?: string; /** * Input supplied when the instance was opened */ @@ -8526,6 +8877,10 @@ export interface CanvasRegistryChangedCanvas { * Owning extension display name, when available */ extensionName?: string; + /** + * Host-local PNG path for the canvas icon, when supplied + */ + icon?: string; /** * JSON Schema for canvas open input */ diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index e05b33c158..9d3bdcd7f0 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -51,6 +51,7 @@ export type { CommandContext, CommandDefinition, CommandHandler, + CanvasProviderIdentity, CloudSessionOptions, CloudSessionRepository, AutoModeSwitchHandler, @@ -59,8 +60,10 @@ export type { CopilotClientMode, CopilotClientOptions, StdioRuntimeConnection, + InProcessRuntimeConnection, TcpRuntimeConnection, UriRuntimeConnection, + ChildProcessRuntimeConnection, CustomAgentConfig, ElicitationFieldValue, ElicitationHandler, @@ -147,8 +150,10 @@ export type { Tool, ToolHandler, ToolInvocation, + CurrentToolMetadata, ToolTelemetry, ToolResultObject, + ToolSearchConfig, TypedSessionEventHandler, TypedSessionLifecycleHandler, ZodSchema, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 8d8fc6714f..1f71209de8 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -13,6 +13,7 @@ import { createSessionRpc } from "./generated/rpc.js"; import type { ClientSessionApiHandlers, CanvasActionInvokeResult, + CurrentToolMetadata, McpOauthPendingRequestResponse, } from "./generated/rpc.js"; import { type Canvas, CanvasError } from "./canvas.js"; @@ -96,6 +97,8 @@ function isOpenCanvasInstance(value: unknown): value is OpenCanvasInstance { /** Assistant message event - the final response from the assistant. */ export type AssistantMessageEvent = Extract; +const TOOL_SEARCH_TOOL_NAME = "tool_search_tool"; + /** * Represents a single conversation session with the Copilot CLI. * @@ -121,6 +124,12 @@ export type AssistantMessageEvent = Extract = new Set(); private typedEventHandlers: Map void>> = @@ -606,11 +615,26 @@ export class CopilotSession { tracestate?: string ): Promise { try { + // The built-in tool-search tool receives a snapshot of the session's + // currently initialized tools so an override can filter the live + // catalog without issuing its own RPC. Fetch it only for that tool + // to avoid a round-trip on every tool call; a failed fetch simply + // leaves the snapshot undefined rather than failing the tool. + let availableTools: CurrentToolMetadata[] | undefined; + if (toolName === TOOL_SEARCH_TOOL_NAME) { + try { + const metadata = await this.rpc.tools.getCurrentMetadata(); + availableTools = metadata.tools ?? undefined; + } catch { + availableTools = undefined; + } + } const rawResult = await handler(args, { sessionId: this.sessionId, toolCallId, toolName, arguments: args, + availableTools, traceparent, tracestate, }); @@ -1348,7 +1372,7 @@ export class CopilotSession { * * @example * ```typescript - * await session.setModel("gpt-4.1"); + * await session.setModel("gpt-5.4"); * await session.setModel("claude-sonnet-4.6", { reasoningEffort: "high" }); * ``` */ diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 97182b5f1c..7fecef2e6c 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -21,9 +21,11 @@ import type { ModelBillingTokenPrices, OpenCanvasInstance, RemoteSessionMode, + CurrentToolMetadata, } from "./generated/rpc.js"; import type { ToolSet } from "./toolSet.js"; export type { RemoteSessionMode } from "./generated/rpc.js"; +export type { CurrentToolMetadata } from "./generated/rpc.js"; export type { GitHubTelemetryNotification, GitHubTelemetryEvent, @@ -96,25 +98,60 @@ export interface TelemetryConfig { */ export type RuntimeConnection = | StdioRuntimeConnection + | InProcessRuntimeConnection | TcpRuntimeConnection | UriRuntimeConnection; /** - * Spawns a runtime child process and communicates over its stdin/stdout. - * This is the default if no {@link CopilotClientOptions.connection} is set. + * Shared shape for the transports that spawn a runtime **child process** + * ({@link StdioRuntimeConnection} and {@link TcpRuntimeConnection}). */ -export interface StdioRuntimeConnection { - readonly kind: "stdio"; +export interface ChildProcessRuntimeConnection { /** Path to the runtime executable. When omitted, the bundled runtime is used. */ readonly path?: string; /** Extra command-line arguments to pass to the runtime process. */ readonly args?: readonly string[]; + /** + * Environment variables for the spawned runtime child process, replacing the + * inherited environment. Cannot be combined with + * {@link CopilotClientOptions.env}; setting both throws when the client is + * constructed. When omitted, the client-level env (or `process.env`) is used. + */ + readonly env?: Record; +} + +/** + * Spawns a runtime child process and communicates over its stdin/stdout. + * This is the default if no {@link CopilotClientOptions.connection} is set. + */ +export interface StdioRuntimeConnection extends ChildProcessRuntimeConnection { + readonly kind: "stdio"; +} + +/** + * Hosts the runtime in-process by loading the native runtime library and speaking + * JSON-RPC over its C ABI (FFI), instead of spawning a runtime child process. The + * native host spawns the CLI worker itself. Construct via + * {@link RuntimeConnection.forInProcess}. + * + * @experimental The in-process (FFI) transport is experimental and its behavior may + * change. Per-client options that are lowered to environment variables — including + * {@link CopilotClientOptions.env}, {@link CopilotClientOptions.telemetry}, + * {@link CopilotClientOptions.gitHubToken}, and + * {@link CopilotClientOptions.baseDirectory} — are **not** honored with this + * transport, because the native runtime loads into the shared host process and its + * worker inherits that process's ambient environment. To configure the in-process + * runtime, set the corresponding environment variables on the host process before + * constructing the client. See https://github.com/github/copilot-sdk/issues/1934. + */ +export interface InProcessRuntimeConnection { + readonly kind: "inprocess"; } /** * Spawns a runtime child process that listens on a TCP socket and connects to it. */ -export interface TcpRuntimeConnection { +export interface TcpRuntimeConnection extends ChildProcessRuntimeConnection { readonly kind: "tcp"; /** * TCP port to listen on. `0` (the default) auto-allocates a free port. @@ -127,10 +164,6 @@ export interface TcpRuntimeConnection { * loopback listener is safe by default. */ readonly connectionToken?: string; - /** Path to the runtime executable. When omitted, the bundled runtime is used. */ - readonly path?: string; - /** Extra command-line arguments to pass to the runtime process. */ - readonly args?: readonly string[]; } /** @@ -154,8 +187,10 @@ export const RuntimeConnection = { * Spawn a runtime child process and communicate over its stdin/stdout. * This is the default if no {@link CopilotClientOptions.connection} is set. */ - forStdio(opts: { path?: string; args?: readonly string[] } = {}): StdioRuntimeConnection { - return { kind: "stdio", path: opts.path, args: opts.args }; + forStdio( + opts: { path?: string; args?: readonly string[]; env?: Record } = {} + ): StdioRuntimeConnection { + return { kind: "stdio", path: opts.path, args: opts.args, env: opts.env }; }, /** * Spawn a runtime child process that listens on a TCP socket and connect to it. @@ -166,6 +201,7 @@ export const RuntimeConnection = { connectionToken?: string; path?: string; args?: readonly string[]; + env?: Record; } = {} ): TcpRuntimeConnection { return { @@ -174,6 +210,7 @@ export const RuntimeConnection = { connectionToken: opts.connectionToken, path: opts.path, args: opts.args, + env: opts.env, }; }, /** @@ -183,6 +220,18 @@ export const RuntimeConnection = { forUri(url: string, opts: { connectionToken?: string } = {}): UriRuntimeConnection { return { kind: "uri", url, connectionToken: opts.connectionToken }; }, + /** + * Host the runtime in-process over the native runtime library's C ABI (FFI). + * + * @experimental Per-client options lowered to environment variables (`env`, + * `telemetry`, `gitHubToken`, `baseDirectory`) are **not** honored in-process; + * the worker inherits the host process's ambient environment. Set the + * corresponding environment variables on the host process instead. See + * https://github.com/github/copilot-sdk/issues/1934. + */ + forInProcess(): InProcessRuntimeConnection { + return { kind: "inprocess" }; + }, } as const; /** @@ -403,6 +452,10 @@ export type ToolResultObject = { error?: string; sessionLog?: string; toolTelemetry?: ToolTelemetry; + /** + * Names of tools returned by a tool-search tool. + */ + toolReferences?: string[]; }; export type ToolResult = string | ToolResultObject; @@ -527,6 +580,14 @@ export interface ToolInvocation { toolCallId: string; toolName: string; arguments: unknown; + /** + * Snapshot of the session's currently initialized tools. Populated by the + * SDK only when this invocation targets the built-in tool-search tool + * (`tool_search_tool`), so a tool-search override can rank/filter the live + * catalog — including MCP tools configured in settings — without issuing its + * own RPC. `undefined` for every other tool invocation. + */ + availableTools?: CurrentToolMetadata[]; /** W3C Trace Context traceparent from the CLI's execute_tool span. */ traceparent?: string; /** W3C Trace Context tracestate from the CLI's execute_tool span. */ @@ -579,6 +640,14 @@ export interface Tool { * Optional; defaults to `"auto"`. */ defer?: "auto" | "never"; + /** + * Opaque, host-defined metadata associated with the tool definition. + * + * Keys are namespaced and are not part of the stable public API. Values are + * not interpreted and may be recognized to inform host-specific behavior. + * Unknown keys are preserved and round-tripped untouched. + */ + metadata?: Record; } /** @@ -594,11 +663,41 @@ export function defineTool( overridesBuiltInTool?: boolean; skipPermission?: boolean; defer?: "auto" | "never"; + metadata?: Record; } ): Tool { return { name, ...config }; } +/** + * SDK-supplied override for the runtime's built-in tool-search behavior. + * + * Tool search lets the model discover tools on demand instead of loading every + * tool definition up front. When the total tool count exceeds the deferral + * threshold, MCP and external tools are marked as deferred and surfaced through + * the built-in `tool_search_tool`. + * + * To override the tool-search tool's model-facing definition and/or its + * execution, register a {@link Tool} named `tool_search_tool` with + * `overridesBuiltInTool: true`. To customize the in-prompt tool-search + * guidance, use the `tool_instructions` section of {@link SystemMessageConfig} + * in `"customize"` mode. + */ +export interface ToolSearchConfig { + /** + * Toggle to enable/disable tool search. When disabled, all tools are pre-loaded + * and the model's active tool set is not deferred. + */ + enabled?: boolean; + + /** + * Overrides the total tool count at which MCP and external tools are + * automatically deferred behind tool search. Defaults to the built-in + * threshold (30) when omitted. + */ + deferThreshold?: number; +} + // ============================================================================ // Commands // ============================================================================ @@ -1714,6 +1813,23 @@ export interface ExtensionInfo { name: string; } +/** + * Stable identity for a host/SDK connection that supplies built-in canvases. + * + * When set on session create or resume, the runtime uses {@link id} verbatim + * as the agent-facing canvas extension id, so canvases declared on a control + * connection survive stdio reconnect and CLI process restart instead of being + * re-keyed to a per-connection id. The id is opaque to the runtime; a + * per-window-stable value such as `app:builtin:` is recommended. An + * id beginning with `connection:` is reserved and ignored by the runtime. + */ +export interface CanvasProviderIdentity { + /** Opaque, stable provider id used verbatim as the canvas extension id. */ + id: string; + /** Optional display name surfaced as the canvas extension name. */ + name?: string; +} + /** * Provider-scoped options for the Copilot API (CAPI). * @@ -1858,6 +1974,14 @@ export interface SessionConfigBase { */ extensionInfo?: ExtensionInfo; + /** + * Stable identity for a host/SDK connection that supplies built-in + * canvases. When set, the runtime uses `id` verbatim as the agent-facing + * canvas extension id, so canvases declared on a control connection survive + * reconnect and CLI restart. Honored on session create and resume. + */ + canvasProvider?: CanvasProviderIdentity; + /** * Slash commands registered for this session. * When the CLI has a TUI, each command appears as `/name` for the user to invoke. @@ -1871,6 +1995,15 @@ export interface SessionConfigBase { */ systemMessage?: SystemMessageConfig; + /** + * Override for the runtime's built-in tool-search behavior. + * + * To also override the tool-search tool's implementation, register a + * {@link Tool} named `tool_search_tool` with `overridesBuiltInTool: true` in + * {@link SessionConfigBase.tools}. + */ + toolSearch?: ToolSearchConfig; + /** * List of tool names to allow. When specified, only these tools will be available. * @@ -2189,6 +2322,14 @@ export interface SessionConfigBase { */ gitHubToken?: string; + /** + * Opt-in: when true, the runtime self-fetches enterprise managed settings + * (bypass-permissions policy) at session bootstrap using the session's + * `gitHubToken`. Requires {@link SessionConfigBase.gitHubToken} to be set; + * if omitted, the runtime is expected to reject session creation (fail-closed). + */ + enableManagedSettings?: boolean; + /** * When true, skips embedding-based retrieval for this session. * Use in multitenant deployments to prevent cross-session information leakage diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 96c32a5951..2585542b4b 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { EventEmitter } from "node:events"; -import { describe, expect, it, onTestFinished, vi } from "vitest"; import { PassThrough } from "stream"; +import { describe, expect, it, onTestFinished, vi } from "vitest"; import { approveAll, CopilotClient, @@ -15,6 +15,10 @@ import { defaultJoinSessionPermissionHandler } from "../src/types.js"; // This file is for unit tests. Where relevant, prefer to add e2e tests in e2e/*.test.ts instead +async function stopClient(client: CopilotClient): Promise { + await client.stop(); +} + describe("CopilotClient", () => { it("disposes the stdio connection when child stdin emits an error", async () => { const client = new CopilotClient(); @@ -128,7 +132,7 @@ describe("CopilotClient", () => { it("registers interest in MCP OAuth required events after create when an auth handler is configured", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -156,7 +160,7 @@ describe("CopilotClient", () => { it("does not register MCP OAuth interest without an auth handler", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -183,7 +187,7 @@ describe("CopilotClient", () => { it("registers MCP OAuth interest after cloud create only when an auth handler is configured", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); let cloudCreateCount = 0; const spy = vi @@ -224,7 +228,7 @@ describe("CopilotClient", () => { it("registers MCP OAuth interest after resuming only when an auth handler is configured", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -278,7 +282,7 @@ describe("CopilotClient", () => { it("forwards canvas declarations and request flags in session.create", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const canvas = createCanvas({ id: "counter", @@ -301,6 +305,7 @@ describe("CopilotClient", () => { requestCanvasRenderer: true, requestExtensions: true, extensionInfo: { source: "github-app", name: "counter-provider" }, + canvasProvider: { id: "app:builtin:window-1", name: "Built-in" }, }); const payload = spy.mock.calls.find(([method]) => method === "session.create")![1] as any; @@ -318,12 +323,16 @@ describe("CopilotClient", () => { source: "github-app", name: "counter-provider", }); + expect(payload.canvasProvider).toEqual({ + id: "app:builtin:window-1", + name: "Built-in", + }); }); it("forwards canvas declarations in session.resume", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const canvas = createCanvas({ @@ -345,6 +354,7 @@ describe("CopilotClient", () => { requestCanvasRenderer: true, requestExtensions: true, extensionInfo: { source: "github-app", name: "counter-provider" }, + canvasProvider: { id: "app:builtin:window-1" }, }); const payload = spy.mock.calls.find(([method]) => method === "session.resume")![1] as any; @@ -355,13 +365,14 @@ describe("CopilotClient", () => { source: "github-app", name: "counter-provider", }); + expect(payload.canvasProvider).toEqual({ id: "app:builtin:window-1" }); expect(payload.openCanvasInstances).toBeUndefined(); }); it("forwards reasoningSummary in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -393,7 +404,7 @@ describe("CopilotClient", () => { it("forwards contextTier in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -422,11 +433,76 @@ describe("CopilotClient", () => { expect(resumePayload.contextTier).toBe("default"); }); - it("forwards new session options in session.create and session.resume", async () => { + it("forwards tool metadata verbatim in session.create and session.resume", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + + const metadata = { + "github.com/copilot:safeForTelemetry": { name: true, inputsNames: false }, + }; + const tool = { + name: "my_tool", + description: "a tool", + parameters: { type: "object", properties: {} }, + metadata, + }; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + tools: [tool], + }); + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + tools: [tool], + }); + + const createPayload = spy.mock.calls.find( + ([method]) => method === "session.create" + )![1] as any; + const resumePayload = spy.mock.calls.find( + ([method]) => method === "session.resume" + )![1] as any; + expect(createPayload.tools[0].metadata).toEqual(metadata); + expect(resumePayload.tools[0].metadata).toEqual(metadata); + }); + + it("omits tool metadata from session.create when unset", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + + await client.createSession({ + onPermissionRequest: approveAll, + tools: [{ name: "my_tool", description: "a tool" }], + }); + + const createPayload = spy.mock.calls.find( + ([method]) => method === "session.create" + )![1] as any; + expect(createPayload.tools[0].metadata).toBeUndefined(); + }); + + it("forwards new session options in session.create and session.resume", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + const spy = vi .spyOn((client as any).connection!, "sendRequest") .mockImplementation(async (method: string, params: any) => { @@ -465,7 +541,7 @@ describe("CopilotClient", () => { it("opts into GitHub telemetry forwarding when onGitHubTelemetry is provided", async () => { const client = new CopilotClient({ onGitHubTelemetry: () => {} }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -490,7 +566,7 @@ describe("CopilotClient", () => { it("opts into GitHub telemetry forwarding on the connect handshake when a handler is provided", async () => { const client = new CopilotClient({ onGitHubTelemetry: () => {} }); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const sendRequest = vi.fn(async (method: string) => { if (method === "connect") return { ok: true, protocolVersion: 3, version: "test" }; @@ -507,7 +583,7 @@ describe("CopilotClient", () => { it("does not opt into GitHub telemetry forwarding on the connect handshake without a handler", async () => { const client = new CopilotClient(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const sendRequest = vi.fn(async (method: string) => { if (method === "connect") return { ok: true, protocolVersion: 3, version: "test" }; @@ -525,7 +601,7 @@ describe("CopilotClient", () => { it("does not opt into GitHub telemetry forwarding without a handler", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -604,7 +680,7 @@ describe("CopilotClient", () => { it("registers no gitHubTelemetry handler when onGitHubTelemetry is omitted", () => { const client = new CopilotClient(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const handlers = (client as any).clientGlobalHandlers; expect(handlers.gitHubTelemetry).toBeUndefined(); @@ -613,7 +689,7 @@ describe("CopilotClient", () => { it("forwards gitHubTelemetry events to the onGitHubTelemetry handler", () => { const received: GitHubTelemetryNotification[] = []; const client = new CopilotClient({ onGitHubTelemetry: (n) => received.push(n) }); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const handlers = (client as any).clientGlobalHandlers; expect(handlers.gitHubTelemetry).toBeDefined(); @@ -630,7 +706,7 @@ describe("CopilotClient", () => { it("forwards expAssignments in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -667,7 +743,7 @@ describe("CopilotClient", () => { it("omits expAssignments from session.create and session.resume when unset", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -693,7 +769,7 @@ describe("CopilotClient", () => { it("forwards capi options in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -725,7 +801,7 @@ describe("CopilotClient", () => { it("forwards pluginDirectories and largeOutput in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -966,7 +1042,7 @@ describe("CopilotClient", () => { it("forwards clientName in session.create request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ clientName: "my-app", onPermissionRequest: approveAll }); @@ -980,7 +1056,7 @@ describe("CopilotClient", () => { it("forwards cloud options in session.create request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -1005,7 +1081,7 @@ describe("CopilotClient", () => { it("forwards clientName in session.resume request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); // Mock sendRequest to capture the call without hitting the runtime @@ -1030,7 +1106,7 @@ describe("CopilotClient", () => { it("forwards enableSessionTelemetry in session.create request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ @@ -1047,7 +1123,7 @@ describe("CopilotClient", () => { it("forwards enableSessionTelemetry in session.resume request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -1071,7 +1147,7 @@ describe("CopilotClient", () => { it("forwards enableOnDemandInstructionDiscovery in session.create request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ @@ -1088,7 +1164,7 @@ describe("CopilotClient", () => { it("forwards enableOnDemandInstructionDiscovery in session.resume request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -1115,7 +1191,7 @@ describe("CopilotClient", () => { it("defaults includeSubAgentStreamingEvents to true in session.create when not specified", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ onPermissionRequest: approveAll }); @@ -1127,7 +1203,7 @@ describe("CopilotClient", () => { it("forwards explicit false for includeSubAgentStreamingEvents in session.create", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ @@ -1142,7 +1218,7 @@ describe("CopilotClient", () => { it("defaults includeSubAgentStreamingEvents to true in session.resume when not specified", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -1161,7 +1237,7 @@ describe("CopilotClient", () => { it("forwards explicit false for includeSubAgentStreamingEvents in session.resume", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -1183,7 +1259,7 @@ describe("CopilotClient", () => { it("defaults mcpOAuthTokenStorage to 'in-memory' in session.create when mode is empty", async () => { const client = new CopilotClient({ mode: "empty", baseDirectory: "/tmp/copilot-test" }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -1201,7 +1277,7 @@ describe("CopilotClient", () => { it("does not send mcpOAuthTokenStorage in session.create when mode is copilot-cli", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ onPermissionRequest: approveAll }); @@ -1213,7 +1289,7 @@ describe("CopilotClient", () => { it("forwards explicit 'persistent' for mcpOAuthTokenStorage in session.create", async () => { const client = new CopilotClient({ mode: "empty", baseDirectory: "/tmp/copilot-test" }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -1235,7 +1311,7 @@ describe("CopilotClient", () => { it("defaults mcpOAuthTokenStorage to 'in-memory' in session.resume when mode is empty", async () => { const client = new CopilotClient({ mode: "empty", baseDirectory: "/tmp/copilot-test" }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -1255,7 +1331,7 @@ describe("CopilotClient", () => { it("forwards explicit 'persistent' for mcpOAuthTokenStorage in session.resume", async () => { const client = new CopilotClient({ mode: "empty", baseDirectory: "/tmp/copilot-test" }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -1279,7 +1355,7 @@ describe("CopilotClient", () => { it("defaults memory to { enabled: false } in session.create when mode is empty", async () => { const client = new CopilotClient({ mode: "empty", baseDirectory: "/tmp/copilot-test" }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -1297,7 +1373,7 @@ describe("CopilotClient", () => { it("does not send memory in session.create when mode is copilot-cli", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ onPermissionRequest: approveAll }); @@ -1309,7 +1385,7 @@ describe("CopilotClient", () => { it("forwards explicit memory config in session.create even in empty mode", async () => { const client = new CopilotClient({ mode: "empty", baseDirectory: "/tmp/copilot-test" }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -1331,7 +1407,7 @@ describe("CopilotClient", () => { it("defaults memory to { enabled: false } in session.resume when mode is empty", async () => { const client = new CopilotClient({ mode: "empty", baseDirectory: "/tmp/copilot-test" }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -1351,7 +1427,7 @@ describe("CopilotClient", () => { it("does not send memory in session.resume when mode is copilot-cli", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -1370,7 +1446,7 @@ describe("CopilotClient", () => { it("forwards continuePendingWork in session.resume request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -1392,7 +1468,7 @@ describe("CopilotClient", () => { it("omits continuePendingWork from session.resume payload when not specified", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -1411,7 +1487,7 @@ describe("CopilotClient", () => { it("forwards memory configuration in session.create request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -1434,7 +1510,7 @@ describe("CopilotClient", () => { it("forwards memory configuration in session.resume request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -1456,7 +1532,7 @@ describe("CopilotClient", () => { it("omits memory from session.create payload when not specified", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -1477,7 +1553,7 @@ describe("CopilotClient", () => { it("forwards provider headers in session.create request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi .spyOn((client as any).connection!, "sendRequest") @@ -1518,7 +1594,7 @@ describe("CopilotClient", () => { it("forwards provider headers in session.resume request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -1559,7 +1635,7 @@ describe("CopilotClient", () => { it("forwards defaultAgent in session.create request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ @@ -1578,7 +1654,7 @@ describe("CopilotClient", () => { it("forwards defaultAgent in session.resume request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi.spyOn((client as any).connection!, "sendRequest"); @@ -1598,7 +1674,7 @@ describe("CopilotClient", () => { it("forwards instructionDirectories in session.create request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const instructionDirectories = ["C:\\extra-instructions", "C:\\more-instructions"]; const spy = vi.spyOn((client as any).connection!, "sendRequest"); @@ -1616,7 +1692,7 @@ describe("CopilotClient", () => { it("forwards instructionDirectories in session.resume request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const instructionDirectories = ["C:\\resume-instructions"]; @@ -1644,7 +1720,7 @@ describe("CopilotClient", () => { it("does not request permissions on session.resume when using the default joinSession handler", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -1671,7 +1747,7 @@ describe("CopilotClient", () => { it("requests permissions on session.resume when using an explicit handler", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -1698,7 +1774,7 @@ describe("CopilotClient", () => { it("forwards mode callback request flags in session.resume request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -1728,7 +1804,7 @@ describe("CopilotClient", () => { it("sends session.model.switchTo RPC with correct params", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); @@ -1754,7 +1830,7 @@ describe("CopilotClient", () => { it("sends reasoning options with session.model.switchTo when provided", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); @@ -1996,13 +2072,84 @@ describe("CopilotClient", () => { /gitHubToken and useLoggedInUser cannot be used with RuntimeConnection.forUri/ ); }); + + it("should throw error when env is used with forInProcess", () => { + expect(() => { + new CopilotClient({ + connection: RuntimeConnection.forInProcess(), + env: { FOO: "bar" }, + logLevel: "error", + }); + }).toThrow(/env is not supported with RuntimeConnection.forInProcess/); + }); + + it("should throw error when telemetry is used with forInProcess", () => { + expect(() => { + new CopilotClient({ + connection: RuntimeConnection.forInProcess(), + telemetry: { otlpEndpoint: "http://localhost:4318" }, + logLevel: "error", + }); + }).toThrow(/telemetry is not supported with RuntimeConnection.forInProcess/); + }); + + it("should throw error when workingDirectory is used with forInProcess", () => { + expect(() => { + new CopilotClient({ + connection: RuntimeConnection.forInProcess(), + workingDirectory: "/tmp", + logLevel: "error", + }); + }).toThrow(/workingDirectory is not supported with RuntimeConnection.forInProcess/); + }); + + it("should throw error when env is set on both the client and a stdio connection", () => { + expect(() => { + new CopilotClient({ + connection: RuntimeConnection.forStdio({ env: { FOO: "conn" } }), + env: { FOO: "client" }, + logLevel: "error", + }); + }).toThrow( + /Set environment variables via either the client-level env option or the connection/ + ); + }); + + it("should throw error when env is set on both the client and a tcp connection", () => { + expect(() => { + new CopilotClient({ + connection: RuntimeConnection.forTcp({ env: { FOO: "conn" } }), + env: { FOO: "client" }, + logLevel: "error", + }); + }).toThrow( + /Set environment variables via either the client-level env option or the connection/ + ); + }); + + it("should use the connection-level env for child-process transports", () => { + const client = new CopilotClient({ + connection: RuntimeConnection.forStdio({ env: { FOO: "from-conn" } }), + logLevel: "error", + }); + expect((client as any).resolvedEnv).toEqual({ FOO: "from-conn" }); + }); + + it("should allow env on the client alone with a child-process transport", () => { + const client = new CopilotClient({ + connection: RuntimeConnection.forStdio(), + env: { FOO: "from-client" }, + logLevel: "error", + }); + expect((client as any).resolvedEnv).toEqual({ FOO: "from-client" }); + }); }); describe("overridesBuiltInTool in tool definitions", () => { it("sends overridesBuiltInTool in tool definition on session.create", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ @@ -2026,7 +2173,7 @@ describe("CopilotClient", () => { it("sends overridesBuiltInTool in tool definition on session.resume", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); // Mock sendRequest to capture the call without hitting the runtime @@ -2060,7 +2207,7 @@ describe("CopilotClient", () => { it("sends defer in tool definition on session.create", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ @@ -2084,7 +2231,7 @@ describe("CopilotClient", () => { it("sends defer in tool definition on session.resume", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -2117,7 +2264,7 @@ describe("CopilotClient", () => { it("forwards agent in session.create request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ @@ -2139,7 +2286,7 @@ describe("CopilotClient", () => { it("forwards custom agent model in session.create request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ @@ -2162,7 +2309,7 @@ describe("CopilotClient", () => { it("forwards agent in session.resume request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -2220,7 +2367,7 @@ describe("CopilotClient", () => { const handler = vi.fn().mockReturnValue(customModels); const client = new CopilotClient({ onListModels: handler }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const models = await client.listModels(); expect(handler).toHaveBeenCalledTimes(1); @@ -2243,7 +2390,7 @@ describe("CopilotClient", () => { const handler = vi.fn().mockReturnValue(customModels); const client = new CopilotClient({ onListModels: handler }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); await client.listModels(); await client.listModels(); @@ -2265,7 +2412,7 @@ describe("CopilotClient", () => { const handler = vi.fn().mockResolvedValue(customModels); const client = new CopilotClient({ onListModels: handler }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const models = await client.listModels(); expect(models).toEqual(customModels); @@ -2293,22 +2440,29 @@ describe("CopilotClient", () => { }); describe("unexpected disconnection", () => { - it("transitions to disconnected when child process is killed", async () => { - const client = new CopilotClient(); - await client.start(); - onTestFinished(() => client.forceStop()); - - expect((client as any).state).toBe("connected"); - - // Kill the child process to simulate unexpected termination - const proc = (client as any).cliProcess as import("node:child_process").ChildProcess; - proc.kill(); - - // Wait for the connection.onClose handler to fire - await vi.waitFor(() => { - expect((client as any).state).toBe("disconnected"); - }); - }); + // No child process exists over the in-process (FFI) transport, so this + // child-process-kill scenario does not apply there. Covered by the default + // (stdio) cell. + it.skipIf((process.env.COPILOT_SDK_DEFAULT_CONNECTION ?? "").toLowerCase() === "inprocess")( + "transitions to disconnected when child process is killed", + async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + + expect((client as any).state).toBe("connected"); + + // Kill the child process to simulate unexpected termination + const proc = (client as any) + .cliProcess as import("node:child_process").ChildProcess; + proc.kill(); + + // Wait for the connection.onClose handler to fire + await vi.waitFor(() => { + expect((client as any).state).toBe("disconnected"); + }); + } + ); }); describe("onGetTraceContext", () => { @@ -2320,7 +2474,7 @@ describe("CopilotClient", () => { const provider = vi.fn().mockReturnValue(traceContext); const client = new CopilotClient({ onGetTraceContext: provider }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ onPermissionRequest: approveAll }); @@ -2342,7 +2496,7 @@ describe("CopilotClient", () => { const provider = vi.fn().mockReturnValue(traceContext); const client = new CopilotClient({ onGetTraceContext: provider }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -2368,7 +2522,7 @@ describe("CopilotClient", () => { const provider = vi.fn().mockReturnValue(traceContext); const client = new CopilotClient({ onGetTraceContext: provider }); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -2390,7 +2544,7 @@ describe("CopilotClient", () => { it("forwards requestHeaders in session.send request", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -2417,7 +2571,7 @@ describe("CopilotClient", () => { it("does not include trace context when no callback is provided", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ onPermissionRequest: approveAll }); @@ -2432,7 +2586,7 @@ describe("CopilotClient", () => { it("forwards commands in session.create RPC", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const spy = vi.spyOn((client as any).connection!, "sendRequest"); await client.createSession({ @@ -2453,7 +2607,7 @@ describe("CopilotClient", () => { it("forwards commands in session.resume RPC", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); const spy = vi @@ -2475,7 +2629,7 @@ describe("CopilotClient", () => { it("routes command.execute event to the correct handler", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const handler = vi.fn(); const session = await client.createSession({ @@ -2529,7 +2683,7 @@ describe("CopilotClient", () => { it("sends error when command handler throws", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll, @@ -2577,7 +2731,7 @@ describe("CopilotClient", () => { it("sends error for unknown command", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll, @@ -2623,7 +2777,7 @@ describe("CopilotClient", () => { it("reads capabilities from session.create response", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); // Intercept session.create to inject capabilities const origSendRequest = (client as any).connection!.sendRequest.bind( @@ -2649,7 +2803,7 @@ describe("CopilotClient", () => { it("defaults capabilities when not injected", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); // CLI returns actual capabilities (elicitation false in headless mode) @@ -2659,7 +2813,7 @@ describe("CopilotClient", () => { it("elicitation throws when capability is missing", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll }); @@ -2678,7 +2832,7 @@ describe("CopilotClient", () => { it("sends requestElicitation flag when onElicitationRequest is provided", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const rpcSpy = vi.spyOn((client as any).connection!, "sendRequest"); @@ -2704,7 +2858,7 @@ describe("CopilotClient", () => { it("does not send requestElicitation when no handler provided", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const rpcSpy = vi.spyOn((client as any).connection!, "sendRequest"); @@ -2726,7 +2880,7 @@ describe("CopilotClient", () => { it("sends mode callback request flags based on handler presence", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const rpcSpy = vi.spyOn((client as any).connection!, "sendRequest"); @@ -2761,7 +2915,7 @@ describe("CopilotClient", () => { it("dispatches mode callback requests to registered handlers", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll, @@ -2809,7 +2963,7 @@ describe("CopilotClient", () => { it("sends cancel when elicitation handler throws", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const session = await client.createSession({ onPermissionRequest: approveAll, @@ -2869,7 +3023,7 @@ describe("CopilotClient", () => { it("dispatches postToolUseFailure to onPostToolUseFailure handler", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const received: { input: any; invocation: any }[] = []; const session = await client.createSession({ @@ -2910,7 +3064,7 @@ describe("CopilotClient", () => { it("does not fall back to onPostToolUse for postToolUseFailure events", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const postUseCalls: string[] = []; const session = await client.createSession({ @@ -2939,7 +3093,7 @@ describe("CopilotClient", () => { it("dispatches postToolUse and postToolUseFailure to their respective handlers", async () => { const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const postCalls: string[] = []; const failureCalls: string[] = []; @@ -2979,7 +3133,7 @@ describe("CopilotClient", () => { it("routes hooks.invoke JSON-RPC requests to the SessionHooks handler", async () => { // Validates the full JSON-RPC entry point used by the CLI: - // CopilotClient.handleHooksInvoke({sessionId, hookType, input}) + // clientGlobalHandlers.hooks.invoke({sessionId, hookType, input}) // → CopilotSession._handleHooksInvoke(hookType, input) // → SessionHooks.onPostToolUseFailure(normalizedInput, {sessionId}) // @@ -2989,7 +3143,7 @@ describe("CopilotClient", () => { // The SDK maps that to public `{..., timestamp: Date, workingDirectory}`. const client = new CopilotClient(); await client.start(); - onTestFinished(() => client.forceStop()); + onTestFinished(() => stopClient(client)); const received: { input: any; invocation: any }[] = []; const session = await client.createSession({ @@ -3010,7 +3164,7 @@ describe("CopilotClient", () => { cwd: "/tmp", }; - const response = await (client as any).handleHooksInvoke({ + const response = await (client as any).clientGlobalHandlers.hooks.invoke({ sessionId: session.sessionId, hookType: "postToolUseFailure", input: failureInput, diff --git a/nodejs/test/e2e/client.e2e.test.ts b/nodejs/test/e2e/client.e2e.test.ts index 33b7a0636b..89489f78e6 100644 --- a/nodejs/test/e2e/client.e2e.test.ts +++ b/nodejs/test/e2e/client.e2e.test.ts @@ -1,11 +1,12 @@ import { ChildProcess } from "child_process"; import { describe, expect, it, onTestFinished } from "vitest"; -import { CopilotClient, approveAll, RuntimeConnection } from "../../src/index.js"; +import { approveAll, CopilotClient, RuntimeConnection } from "../../src/index.js"; +import { isInProcessTransport } from "./harness/sdkTestContext.js"; -function onTestFinishedForceStop(client: CopilotClient) { +function onTestFinishedStop(client: CopilotClient) { onTestFinished(async () => { try { - await client.forceStop(); + await client.stop(); } catch { // Ignore cleanup errors - process may already be stopped } @@ -18,7 +19,7 @@ describe("Client", () => { { transport: "tcp", connection: () => RuntimeConnection.forTcp() }, ])("allows createSession without onPermissionRequest ($transport)", async ({ connection }) => { const client = new CopilotClient({ connection: connection() }); - onTestFinishedForceStop(client); + onTestFinishedStop(client); await using session = await client.createSession({}); expect(session.sessionId).toMatch(/^[a-f0-9-]+$/); @@ -30,7 +31,7 @@ describe("Client", () => { const client = new CopilotClient({ connection: RuntimeConnection.forTcp({ connectionToken }), }); - onTestFinishedForceStop(client); + onTestFinishedStop(client); await using originalSession = await client.createSession({}); @@ -42,7 +43,7 @@ describe("Client", () => { const resumeClient = new CopilotClient({ connection: RuntimeConnection.forUri(`localhost:${port}`, { connectionToken }), }); - onTestFinishedForceStop(resumeClient); + onTestFinishedStop(resumeClient); await using resumedSession = await resumeClient.resumeSession( originalSession.sessionId, @@ -53,7 +54,7 @@ describe("Client", () => { it("should start and connect to server using stdio", async () => { const client = new CopilotClient(); - onTestFinishedForceStop(client); + onTestFinishedStop(client); await client.start(); @@ -66,7 +67,7 @@ describe("Client", () => { it("should start and connect to server using tcp", async () => { const client = new CopilotClient({ connection: RuntimeConnection.forTcp() }); - onTestFinishedForceStop(client); + onTestFinishedStop(client); await client.start(); @@ -106,9 +107,14 @@ describe("Client", () => { 60_000 ); - it("should forceStop without cleanup", async () => { + // Skipping on in-proc: + // - It breaks the macOS E2E run (failure: EPIPE) + // - It's not clear that anyone should use forceStop in the in-proc case - there's no child process + // to terminate, so we can't be sure to leave a clean state + // - If you want to get to a clean state within your process, that's what "stop" (not "forceStop") is for + it.skipIf(isInProcessTransport)("should forceStop without cleanup", async () => { const client = new CopilotClient({}); - onTestFinishedForceStop(client); + onTestFinishedStop(client); await client.createSession({ onPermissionRequest: approveAll }); await client.forceStop(); @@ -116,7 +122,7 @@ describe("Client", () => { it("should get status with version and protocol info", async () => { const client = new CopilotClient(); - onTestFinishedForceStop(client); + onTestFinishedStop(client); await client.start(); @@ -132,7 +138,7 @@ describe("Client", () => { it("should get auth status", async () => { const client = new CopilotClient(); - onTestFinishedForceStop(client); + onTestFinishedStop(client); await client.start(); @@ -148,7 +154,7 @@ describe("Client", () => { it("should list models when authenticated", async () => { const client = new CopilotClient(); - onTestFinishedForceStop(client); + onTestFinishedStop(client); await client.start(); @@ -177,7 +183,7 @@ describe("Client", () => { const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ args: ["--nonexistent-flag-for-testing"] }), }); - onTestFinishedForceStop(client); + onTestFinishedStop(client); let initialError: Error | undefined; try { diff --git a/nodejs/test/e2e/client_api.e2e.test.ts b/nodejs/test/e2e/client_api.e2e.test.ts index 4adaad6ec3..46c23cee69 100644 --- a/nodejs/test/e2e/client_api.e2e.test.ts +++ b/nodejs/test/e2e/client_api.e2e.test.ts @@ -44,6 +44,7 @@ describe("Client session management", async () => { await waitFor(async () => (await client.listSessions()).some((s) => s.sessionId === sessionId) ); + await session.abort(); await session.disconnect(); await client.deleteSession(sessionId); diff --git a/nodejs/test/e2e/client_options.e2e.test.ts b/nodejs/test/e2e/client_options.e2e.test.ts index 2910b6fb9c..e207f275ab 100644 --- a/nodejs/test/e2e/client_options.e2e.test.ts +++ b/nodejs/test/e2e/client_options.e2e.test.ts @@ -182,7 +182,7 @@ describe("Client options", async () => { }); onTestFinished(async () => { try { - await client.forceStop(); + await client.stop(); } catch { // Ignore cleanup errors } @@ -206,7 +206,7 @@ describe("Client options", async () => { }); onTestFinished(async () => { try { - await client.forceStop(); + await client.stop(); } catch { // Ignore cleanup errors } @@ -237,7 +237,7 @@ describe("Client options", async () => { }); onTestFinished(async () => { try { - await client.forceStop(); + await client.stop(); } catch { // Ignore cleanup errors } @@ -291,7 +291,7 @@ describe("Client options", async () => { }); onTestFinished(async () => { try { - await client.forceStop(); + await client.stop(); } catch { // Ignore cleanup errors } @@ -374,7 +374,7 @@ describe("Client options", async () => { }); onTestFinished(async () => { try { - await client.forceStop(); + await client.stop(); } catch { // Ignore cleanup errors } @@ -523,7 +523,7 @@ describe("Client options", async () => { }); onTestFinished(async () => { try { - await client.forceStop(); + await client.stop(); } catch { // Ignore cleanup errors } @@ -590,7 +590,7 @@ describe("Client options", async () => { }); onTestFinished(async () => { try { - await client.forceStop(); + await client.stop(); } catch { // Ignore cleanup errors } diff --git a/nodejs/test/e2e/copilot_request_cancel_error.e2e.test.ts b/nodejs/test/e2e/copilot_request_cancel_error.e2e.test.ts index 5a9cad5a59..69bacd4f6e 100644 --- a/nodejs/test/e2e/copilot_request_cancel_error.e2e.test.ts +++ b/nodejs/test/e2e/copilot_request_cancel_error.e2e.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { approveAll, CopilotRequestHandler, type CopilotRequestContext } from "../../src/index.js"; -import { createSdkTestContext } from "./harness/sdkTestContext.js"; +import { createSdkTestContext, isInProcessTransport } from "./harness/sdkTestContext.js"; /** * Cancellation and error coverage for {@link CopilotRequestHandler}. These two @@ -162,23 +162,33 @@ describe("CopilotRequestHandler observes runtime cancellation", async () => { copilotClientOptions: { requestHandler: handler }, }); - it("fires ctx.signal when the consumer aborts an in-flight inference request", async () => { - await client.start(); - const session = await client.createSession({ onPermissionRequest: approveAll }); - try { - await session.send("Say OK."); - await waitFor(() => handler.inferenceEntered, 60_000); - await session.abort(); - await waitFor(() => handler.sawAbort, 30_000); - } finally { - await session.disconnect(); - } + // The runtime enforces a single, process-wide LLM inference provider: a second + // client.start() with a requestHandler rejects llmInference.setProvider with + // "Another client is already the LLM inference provider." The sibling error test + // above already registers a provider and holds it for this file's lifetime, and + // inproc runs share one runtime host, so this scenario can only run on the default + // (stdio) cell, where each client owns its own runtime process. + it.skipIf(isInProcessTransport)( + "fires ctx.signal when the consumer aborts an in-flight inference request", + async () => { + await client.start(); + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + await session.send("Say OK."); + await waitFor(() => handler.inferenceEntered, 60_000); + await session.abort(); + await waitFor(() => handler.sawAbort, 30_000); + } finally { + await session.disconnect(); + } - expect(handler.inferenceEntered, "expected the inference callback to be entered").toBe( - true - ); - expect(handler.sawAbort, "expected the callback to observe runtime cancellation").toBe( - true - ); - }, 90_000); + expect(handler.inferenceEntered, "expected the inference callback to be entered").toBe( + true + ); + expect(handler.sawAbort, "expected the callback to observe runtime cancellation").toBe( + true + ); + }, + 90_000 + ); }); diff --git a/nodejs/test/e2e/copilot_request_session_id.e2e.test.ts b/nodejs/test/e2e/copilot_request_session_id.e2e.test.ts index 3f01475aae..bd070c20ca 100644 --- a/nodejs/test/e2e/copilot_request_session_id.e2e.test.ts +++ b/nodejs/test/e2e/copilot_request_session_id.e2e.test.ts @@ -11,6 +11,9 @@ const SYNTHETIC_TEXT = "OK from the synthetic stream."; interface InterceptedRequest { url: string; sessionId?: string; + agentId?: string; + parentAgentId?: string; + interactionType?: string; } function isInferenceUrl(url: string): boolean { @@ -43,7 +46,13 @@ class RecordingRequestHandler extends CopilotRequestHandler { ctx: CopilotRequestContext ): Promise { const url = request.url; - this.records.push({ url, sessionId: ctx.sessionId }); + this.records.push({ + url, + sessionId: ctx.sessionId, + agentId: ctx.agentId, + parentAgentId: ctx.parentAgentId, + interactionType: ctx.interactionType, + }); const bodyText = request.body ? await request.text() : ""; return isInferenceUrl(url) ? buildInferenceResponse(url, bodyText) @@ -105,6 +114,11 @@ function buildNonInferenceResponse(url: string): Response { return json("{}"); } +function expectAgentMetadata(r: InterceptedRequest): void { + expect(r.agentId).toBeTruthy(); + expect(r.interactionType).toBeTruthy(); +} + const RESPONSES_STREAM_EVENTS: string[] = [ `event: response.created\ndata: ${JSON.stringify({ type: "response.created", @@ -273,6 +287,7 @@ describe("CopilotRequestHandler threads the runtime session id (CAPI + BYOK)", a expect(r.sessionId, "CAPI inference request must carry the runtime session id").toBe( session.sessionId ); + expectAgentMetadata(r); } // Validate the final assistant response arrived (guards against truncated captures) @@ -313,6 +328,7 @@ describe("CopilotRequestHandler threads the runtime session id (CAPI + BYOK)", a expect(r.sessionId, "BYOK inference request must carry the runtime session id").toBe( byokSessionId ); + expectAgentMetadata(r); } // Session ids are per-session, so the two turns must differ — proves diff --git a/nodejs/test/e2e/harness/sdkTestContext.ts b/nodejs/test/e2e/harness/sdkTestContext.ts index a59f62126d..bf62db4826 100644 --- a/nodejs/test/e2e/harness/sdkTestContext.ts +++ b/nodejs/test/e2e/harness/sdkTestContext.ts @@ -16,6 +16,29 @@ import { formatError, retry } from "./sdkTestHelper"; export const isCI = process.env.GITHUB_ACTIONS === "true"; export const DEFAULT_GITHUB_TOKEN = "fake-token-for-e2e-tests"; +/** + * True when the E2E suite is running over the in-process (FFI) transport + * (COPILOT_SDK_DEFAULT_CONNECTION=inprocess). Use with `it.skipIf` / `describe.skipIf` + * to skip tests for features that are not supported over the in-process transport (the + * runtime loads into the shared host process), so the in-process CI cell stays green. + * Such features are covered by the default (stdio) cell. + */ +export const isInProcessTransport = + (process.env.COPILOT_SDK_DEFAULT_CONNECTION ?? "").toLowerCase() === "inprocess"; + +// The in-process (FFI) transport resolves auth host-side, in this test process, and +// ranks HMAC above the GitHub token — so an ambient COPILOT_HMAC_KEY (CI sets one as a +// job-level credential) would be picked over the SDK/Bearer token the replay snapshots +// expect, yielding 401s. Host-side auth can capture the key as early as client +// construction (before any per-test beforeEach runs), so neutralize it at module load — +// the analogue of .NET's InProcessEnvIsolation `[ModuleInitializer]`. Only applied for +// the in-process transport; stdio/tcp children resolve auth in their own process where +// the token already outranks HMAC. See https://github.com/github/copilot-sdk/issues/1934. +if ((process.env.COPILOT_SDK_DEFAULT_CONNECTION ?? "").toLowerCase() === "inprocess") { + delete process.env.COPILOT_HMAC_KEY; + delete process.env.CAPI_HMAC_KEY; +} + const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const SNAPSHOTS_DIR = resolve(__dirname, "../../../../test/snapshots"); @@ -69,8 +92,14 @@ export async function createSdkTestContext({ COPILOT_HOME: copilotHomeDir, COPILOT_SDK_AUTH_TOKEN: "", GH_CONFIG_DIR: homeDir, - GH_TOKEN: "", - GITHUB_TOKEN: "", + // Use the proxy-recognized token rather than blanking these. Tests that spin up + // their own client without passing `gitHubToken` (e.g. the stdio/tcp + // "works without onPermissionRequest" cases) rely on GH_TOKEN/GITHUB_TOKEN to + // authenticate against the replay proxy. Blanking them only worked on CI, where an + // ambient COPILOT_HMAC_KEY secret supplies the credential instead; locally there is + // no HMAC key, so the child CLI had nothing to authenticate with and got a 401. + GH_TOKEN: authTokenToUse, + GITHUB_TOKEN: authTokenToUse, // TODO: I'm not convinced the SDK should default to using whatever config you happen to have in your homedir. // The SDK config should be independent of the regular CLI app. Likewise it shouldn't mix sessions from the @@ -102,11 +131,17 @@ export async function createSdkTestContext({ } else { connection = userConn; } + } else if (useStdio === false) { + connection = RuntimeConnection.forTcp({ path: cliPath }); + } else if ( + useStdio === undefined && + (process.env.COPILOT_SDK_DEFAULT_CONNECTION ?? "").toLowerCase() === "inprocess" + ) { + // The in-process FFI transport resolves the CLI entrypoint itself + // (COPILOT_CLI_PATH or the bundled platform package), so no path is passed. + connection = RuntimeConnection.forInProcess(); } else { - connection = - useStdio === false - ? RuntimeConnection.forTcp({ path: cliPath }) - : RuntimeConnection.forStdio({ path: cliPath }); + connection = RuntimeConnection.forStdio({ path: cliPath }); } const { @@ -114,20 +149,92 @@ export async function createSdkTestContext({ env: userEnv, ...remainingClientOptions } = copilotClientOptions ?? {}; - const copilotClient = new CopilotClient({ - workingDirectory: workDir, - env: { ...env, ...userEnv }, - logLevel: logLevel || "error", - connection, - gitHubToken: authTokenToUse, - ...remainingClientOptions, - }); - const harness = { homeDir, workDir, openAiEndpoint, copilotClient, env }; + const mergedEnv = { ...env, ...userEnv }; + + // The in-process (FFI) transport loads the runtime into this test host process, + // and its worker inherits this process's ambient environment rather than a + // per-client env block (see https://github.com/github/copilot-sdk/issues/1934). + // So the per-test redirects, isolated home, and credentials must be mirrored onto + // the real process environment. Node's `process.env` writes reach native `getenv`, + // so host-side runtime reads (auth resolution, GitHub API redirect) observe them. + // Auth flows via GH_TOKEN/GITHUB_TOKEN here (the FFI argv omits the stdio + // `--auth-token-env COPILOT_SDK_AUTH_TOKEN` wiring), and HMAC is disabled so + // host-side auth resolution picks the SDK/Bearer token the replay snapshots expect. + const isInProcess = connection.kind === "inprocess"; + const inProcessEnv: Record = isInProcess + ? { + ...(mergedEnv as Record), + GH_TOKEN: authTokenToUse, + GITHUB_TOKEN: authTokenToUse, + COPILOT_HMAC_KEY: "", + CAPI_HMAC_KEY: "", + } + : {}; + + // Builds a CopilotClient wired for the active transport, so tests that need a + // secondary client (e.g. resuming a session from a fresh client) don't have to + // reimplement the in-process env/cwd handling. Callers may override the connection + // (e.g. pin stdio for telemetry, which the in-process transport cannot carry + // per-client); env is attached to child-process transports and mirrored onto the + // process for in-process (see beforeEach below), never passed per-client for the + // in-process transport where it would be rejected. + function createClient(overrides: Partial = {}): CopilotClient { + const { + connection: overrideConnection, + env: _ignoredEnv, + workingDirectory: overrideWorkingDirectory, + ...rest + } = overrides; + + let effectiveConnection = overrideConnection ?? connection; + // Fill in the bundled CLI path for child-process connections that omit it + // (e.g. a bare RuntimeConnection.forStdio() used to pin telemetry to stdio). + if (effectiveConnection.kind === "stdio" && effectiveConnection.path === undefined) { + effectiveConnection = RuntimeConnection.forStdio({ + ...effectiveConnection, + path: cliPath, + }); + } else if (effectiveConnection.kind === "tcp" && effectiveConnection.path === undefined) { + effectiveConnection = RuntimeConnection.forTcp({ + ...effectiveConnection, + path: cliPath, + }); + } + const effectiveInProcess = effectiveConnection.kind === "inprocess"; + + return new CopilotClient({ + // The in-process transport rejects a per-client workingDirectory (it would have to + // mutate the shared host process cwd). Instead the harness changes this process's + // cwd to workDir around the in-process worker's startup (see beforeEach below), so + // the worker still spawns with workDir as its cwd. Out-of-process clients get it + // as a normal per-client option. + workingDirectory: + overrideWorkingDirectory ?? (effectiveInProcess ? undefined : workDir), + // In-process hosting mirrors the environment onto the real process (per test, in + // beforeEach below), so the worker inherits it; passing a per-client env here + // would have no effect (and is rejected by the in-process transport). + env: effectiveInProcess ? undefined : mergedEnv, + logLevel: logLevel || "error", + connection: effectiveConnection, + gitHubToken: authTokenToUse, + ...rest, + }); + } + + const copilotClient = createClient(remainingClientOptions); + + const harness = { homeDir, workDir, openAiEndpoint, copilotClient, env, createClient }; // Track if any test fails to avoid writing corrupted snapshots let anyTestFailed = false; + // Holds the process.env entries the current test overwrote, so afterEach restores them. + let restoreProcessEnv: Array<[string, string | undefined]> = []; + + // Holds the process cwd before an in-process test changed it, so afterEach restores it. + let restoreCwd: string | undefined; + // Wire up to Vitest lifecycle beforeEach(async (testContext) => { // Must be inside beforeEach - vitest requires test context @@ -135,6 +242,25 @@ export async function createSdkTestContext({ anyTestFailed = true; }); + // Mirror this context's environment onto the real process for in-process + // hosting, right before the test runs (see the comment above the client). The + // client auto-starts on first use inside the test body, so the worker spawns + // under these values. + restoreProcessEnv = []; + for (const [key, value] of Object.entries(inProcessEnv)) { + restoreProcessEnv.push([key, process.env[key]]); + process.env[key] = value; + } + + // The in-process worker inherits this process's cwd at spawn (the client auto-starts + // on first use inside the test body). Point cwd at workDir here so the worker spawns + // with the same working directory the out-of-process transport passes explicitly; + // afterEach restores it. + if (isInProcess) { + restoreCwd = process.cwd(); + process.chdir(workDir); + } + await openAiEndpoint.updateConfig({ filePath: getTrafficCapturePath(testContext), workDir, @@ -146,6 +272,20 @@ export async function createSdkTestContext({ }); afterEach(async () => { + // Undo this test's process.env mirror so it can't leak into the next test/suite. + for (const [key, previous] of restoreProcessEnv.reverse()) { + if (previous === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous; + } + } + restoreProcessEnv = []; + // Restore the cwd an in-process test changed for worker startup. + if (restoreCwd !== undefined) { + process.chdir(restoreCwd); + restoreCwd = undefined; + } // Empty directories but leave them in place for next test await rimraf([join(homeDir, "*"), join(workDir, "*")], { glob: true }); }); @@ -153,7 +293,15 @@ export async function createSdkTestContext({ afterAll(async () => { await copilotClient.stop(); await openAiEndpoint.stop(anyTestFailed); - await rmDir("remove e2e test copilotHomeDir", copilotHomeDir); + // On Windows, this Vitest worker can retain the in-process runtime's session.db + // lock until the worker exits. Retrying from its afterAll hook cannot succeed: + // the hook waits for the lock, while the lock cannot clear until the hook returns + // and lets the worker exit. + await rmDir( + "remove e2e test copilotHomeDir", + copilotHomeDir, + isInProcess && process.platform === "win32" ? 1 : 30 + ); await rmDir("remove e2e test homeDir", homeDir); await rmDir("remove e2e test workDir", workDir); }); @@ -180,14 +328,14 @@ function getTrafficCapturePath(testContext: TestContext): string { return join(SNAPSHOTS_DIR, testFileName, `${taskNameAsFilename}.yaml`); } -async function rmDir(message: string, path: string): Promise { +async function rmDir(message: string, path: string, maxTries = 30): Promise { // Use longer retries to tolerate Windows holding SQLite session-store.db // open briefly after the CLI subprocess exits. If the temp dir still can't // be removed (e.g. CLI background writer racing with cleanup), warn and // continue rather than failing the whole test run — the OS / CI runner // will reclaim the temp dir on shutdown. try { - await retry(message, () => rm(path, { recursive: true, force: true }), 30, 1000); + await retry(message, () => rm(path, { recursive: true, force: true }), maxTries, 1000); } catch (error) { console.warn( `WARN: ${message} failed; leaving temp dir for OS cleanup: ${formatError(error)}` diff --git a/nodejs/test/e2e/inprocess_ffi.e2e.test.ts b/nodejs/test/e2e/inprocess_ffi.e2e.test.ts new file mode 100644 index 0000000000..af879ea77b --- /dev/null +++ b/nodejs/test/e2e/inprocess_ffi.e2e.test.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; +import { CopilotClient, RuntimeConnection } from "../../src/index.js"; + +describe("In-process FFI transport", () => { + // Smoke test that the in-process FFI transport starts and completes a round-trip. + // Resolution of the in-process transport from COPILOT_SDK_DEFAULT_CONNECTION is + // exercised by the full E2E suite running under the `inprocess` CI matrix cell, + // not a dedicated test. + it("should start and connect over in-process FFI", async () => { + // In-process FFI hosting resolves the CLI entrypoint (COPILOT_CLI_PATH or the + // bundled platform package) and its sibling native runtime library itself. If + // neither is available, start() throws and the test fails hard. + const client = new CopilotClient({ connection: RuntimeConnection.forInProcess() }); + await client.start(); + + const pong = await client.ping("ffi message"); + expect(pong.message).toBe("pong: ffi message"); + expect(Date.parse(pong.timestamp)).not.toBeNaN(); + + expect(await client.stop()).toHaveLength(0); // No errors on stop + }); +}); diff --git a/nodejs/test/e2e/multi-client.e2e.test.ts b/nodejs/test/e2e/multi-client.e2e.test.ts index a63b1b0ebf..a44ceec3c3 100644 --- a/nodejs/test/e2e/multi-client.e2e.test.ts +++ b/nodejs/test/e2e/multi-client.e2e.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it, afterAll } from "vitest"; import { z } from "zod"; import { CopilotClient, defineTool, approveAll, RuntimeConnection } from "../../src/index.js"; import type { SessionEvent } from "../../src/index.js"; -import { createSdkTestContext } from "./harness/sdkTestContext"; +import { createSdkTestContext, isInProcessTransport } from "./harness/sdkTestContext"; describe("Multi-client broadcast", async () => { // Use TCP mode so a second client can connect to the same CLI process @@ -304,71 +304,75 @@ describe("Multi-client broadcast", async () => { } ); - it("disconnecting client removes its tools", { timeout: 90_000 }, async () => { - const toolA = defineTool("stable_tool", { - description: "A tool that persists across disconnects", - parameters: z.object({ input: z.string() }), - handler: ({ input }) => `STABLE_${input}`, - }); + it.skipIf(isInProcessTransport)( + "disconnecting client removes its tools", + { timeout: 90_000 }, + async () => { + const toolA = defineTool("stable_tool", { + description: "A tool that persists across disconnects", + parameters: z.object({ input: z.string() }), + handler: ({ input }) => `STABLE_${input}`, + }); - const toolB = defineTool("ephemeral_tool", { - description: "A tool that will disappear when its client disconnects", - parameters: z.object({ input: z.string() }), - handler: ({ input }) => `EPHEMERAL_${input}`, - }); + const toolB = defineTool("ephemeral_tool", { + description: "A tool that will disappear when its client disconnects", + parameters: z.object({ input: z.string() }), + handler: ({ input }) => `EPHEMERAL_${input}`, + }); - // Client 1 creates a session with stable_tool - const session1 = await client1.createSession({ - onPermissionRequest: approveAll, - tools: [toolA], - }); + // Client 1 creates a session with stable_tool + const session1 = await client1.createSession({ + onPermissionRequest: approveAll, + tools: [toolA], + }); - // Client 2 resumes with ephemeral_tool - await client2.resumeSession(session1.sessionId, { - onPermissionRequest: approveAll, - tools: [toolB], - }); + // Client 2 resumes with ephemeral_tool + await client2.resumeSession(session1.sessionId, { + onPermissionRequest: approveAll, + tools: [toolB], + }); - // Verify both tools work before disconnect (sequential to avoid nondeterministic tool_call ordering) - const stableResponse = await session1.sendAndWait({ - prompt: "Use the stable_tool with input 'test1' and tell me the result.", - }); - expect(stableResponse?.data.content).toContain("STABLE_test1"); + // Verify both tools work before disconnect (sequential to avoid nondeterministic tool_call ordering) + const stableResponse = await session1.sendAndWait({ + prompt: "Use the stable_tool with input 'test1' and tell me the result.", + }); + expect(stableResponse?.data.content).toContain("STABLE_test1"); - const ephemeralResponse = await session1.sendAndWait({ - prompt: "Use the ephemeral_tool with input 'test2' and tell me the result.", - }); - expect(ephemeralResponse?.data.content).toContain("EPHEMERAL_test2"); - - // Disconnect client 2 without destroying the shared session. - // Suppress "Connection is disposed" rejections that occur when the server - // broadcasts events (e.g. tool_changed_notice) to the now-dead connection. - const suppressDisposed = (reason: unknown) => { - if (reason instanceof Error && reason.message.includes("Connection is disposed")) { - return; - } - throw reason; - }; - process.on("unhandledRejection", suppressDisposed); - await client2.forceStop(); - - // Give the server time to process the connection close and remove tools - await new Promise((resolve) => setTimeout(resolve, 500)); - process.removeListener("unhandledRejection", suppressDisposed); - - // Recreate client2 for cleanup in afterAll (but don't rejoin the session) - client2 = new CopilotClient({ - connection: RuntimeConnection.forUri(`localhost:${runtimePort}`, { - connectionToken: tcpConnectionToken, - }), - }); + const ephemeralResponse = await session1.sendAndWait({ + prompt: "Use the ephemeral_tool with input 'test2' and tell me the result.", + }); + expect(ephemeralResponse?.data.content).toContain("EPHEMERAL_test2"); + + // Disconnect client 2 without destroying the shared session. + // Suppress "Connection is disposed" rejections that occur when the server + // broadcasts events (e.g. tool_changed_notice) to the now-dead connection. + const suppressDisposed = (reason: unknown) => { + if (reason instanceof Error && reason.message.includes("Connection is disposed")) { + return; + } + throw reason; + }; + process.on("unhandledRejection", suppressDisposed); + await client2.forceStop(); + + // Give the server time to process the connection close and remove tools + await new Promise((resolve) => setTimeout(resolve, 500)); + process.removeListener("unhandledRejection", suppressDisposed); + + // Recreate client2 for cleanup in afterAll (but don't rejoin the session) + client2 = new CopilotClient({ + connection: RuntimeConnection.forUri(`localhost:${runtimePort}`, { + connectionToken: tcpConnectionToken, + }), + }); - // Now only stable_tool should be available - const afterResponse = await session1.sendAndWait({ - prompt: "Use the stable_tool with input 'still_here'. Also try using ephemeral_tool if it is available.", - }); - expect(afterResponse?.data.content).toContain("STABLE_still_here"); - // ephemeral_tool should NOT have produced a result - expect(afterResponse?.data.content).not.toContain("EPHEMERAL_"); - }); + // Now only stable_tool should be available + const afterResponse = await session1.sendAndWait({ + prompt: "Use the stable_tool with input 'still_here'. Also try using ephemeral_tool if it is available.", + }); + expect(afterResponse?.data.content).toContain("STABLE_still_here"); + // ephemeral_tool should NOT have produced a result + expect(afterResponse?.data.content).not.toContain("EPHEMERAL_"); + } + ); }); diff --git a/nodejs/test/e2e/rpc.e2e.test.ts b/nodejs/test/e2e/rpc.e2e.test.ts index 0442ab9267..f90547da9b 100644 --- a/nodejs/test/e2e/rpc.e2e.test.ts +++ b/nodejs/test/e2e/rpc.e2e.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it, onTestFinished } from "vitest"; import { CopilotClient, approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; -function onTestFinishedForceStop(client: CopilotClient) { +function onTestFinishedStop(client: CopilotClient) { onTestFinished(async () => { try { - await client.forceStop(); + await client.stop(); } catch { // Ignore cleanup errors - process may already be stopped } @@ -15,7 +15,7 @@ function onTestFinishedForceStop(client: CopilotClient) { describe("RPC", () => { it("should call rpc.ping with typed params and result", async () => { const client = new CopilotClient(); - onTestFinishedForceStop(client); + onTestFinishedStop(client); await client.start(); @@ -28,7 +28,7 @@ describe("RPC", () => { it("should call rpc.models.list with typed result", async () => { const client = new CopilotClient(); - onTestFinishedForceStop(client); + onTestFinishedStop(client); await client.start(); @@ -48,7 +48,7 @@ describe("RPC", () => { // account.getQuota is defined in schema but not yet implemented in CLI it.skip("should call rpc.account.getQuota when authenticated", async () => { const client = new CopilotClient(); - onTestFinishedForceStop(client); + onTestFinishedStop(client); await client.start(); diff --git a/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts b/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts index cdd64017c1..4025dc444c 100644 --- a/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts +++ b/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts @@ -74,7 +74,7 @@ describe("Session MCP and skills RPC", async () => { }); onTestFinished(async () => { try { - await mcpAppsClient.forceStop(); + await mcpAppsClient.stop(); } catch { // Ignore cleanup errors } diff --git a/nodejs/test/e2e/rpc_mcp_config.e2e.test.ts b/nodejs/test/e2e/rpc_mcp_config.e2e.test.ts index 581567cb3e..95694a8c63 100644 --- a/nodejs/test/e2e/rpc_mcp_config.e2e.test.ts +++ b/nodejs/test/e2e/rpc_mcp_config.e2e.test.ts @@ -9,7 +9,7 @@ function startEphemeralClient(): CopilotClient { const client = new CopilotClient(); onTestFinished(async () => { try { - await client.forceStop(); + await client.stop(); } catch { // Ignore cleanup errors } diff --git a/nodejs/test/e2e/rpc_server.e2e.test.ts b/nodejs/test/e2e/rpc_server.e2e.test.ts index 8913146b12..5075ae68d9 100644 --- a/nodejs/test/e2e/rpc_server.e2e.test.ts +++ b/nodejs/test/e2e/rpc_server.e2e.test.ts @@ -39,7 +39,7 @@ describe("Server-scoped RPC", async () => { }); onTestFinished(async () => { try { - await extraClient.forceStop(); + await extraClient.stop(); } catch { // Ignore cleanup errors } diff --git a/nodejs/test/e2e/rpc_server_misc.e2e.test.ts b/nodejs/test/e2e/rpc_server_misc.e2e.test.ts index c38fccca17..4f12e507a5 100644 --- a/nodejs/test/e2e/rpc_server_misc.e2e.test.ts +++ b/nodejs/test/e2e/rpc_server_misc.e2e.test.ts @@ -65,7 +65,7 @@ describe("Miscellaneous server-scoped RPC", async () => { async function disposeIsolated(isolatedClient: CopilotClient, home: string): Promise { try { - await isolatedClient.forceStop(); + await isolatedClient.stop(); } catch { // Best-effort cleanup. } @@ -74,7 +74,7 @@ describe("Miscellaneous server-scoped RPC", async () => { async function forceStop(target: CopilotClient): Promise { try { - await target.forceStop(); + await target.stop(); } catch { // Runtime may already be gone. } diff --git a/nodejs/test/e2e/rpc_server_plugins.e2e.test.ts b/nodejs/test/e2e/rpc_server_plugins.e2e.test.ts index 4bc3c63c96..20575a9114 100644 --- a/nodejs/test/e2e/rpc_server_plugins.e2e.test.ts +++ b/nodejs/test/e2e/rpc_server_plugins.e2e.test.ts @@ -59,7 +59,7 @@ describe("Server-scoped plugin RPC", async () => { fixtureDir?: string ): Promise { try { - await client.forceStop(); + await client.stop(); } catch { // Best-effort cleanup. } diff --git a/nodejs/test/e2e/rpc_server_remote_control.e2e.test.ts b/nodejs/test/e2e/rpc_server_remote_control.e2e.test.ts index 2e6c6cf05b..3094d32577 100644 --- a/nodejs/test/e2e/rpc_server_remote_control.e2e.test.ts +++ b/nodejs/test/e2e/rpc_server_remote_control.e2e.test.ts @@ -23,7 +23,7 @@ describe("Server-scoped remote-control RPC", async () => { async function forceStop(client: CopilotClient): Promise { try { - await client.forceStop(); + await client.stop(); } catch { // Runtime may already be gone. } diff --git a/nodejs/test/e2e/rpc_session_state_extras.e2e.test.ts b/nodejs/test/e2e/rpc_session_state_extras.e2e.test.ts index 6e84a6f669..54f40fe12c 100644 --- a/nodejs/test/e2e/rpc_session_state_extras.e2e.test.ts +++ b/nodejs/test/e2e/rpc_session_state_extras.e2e.test.ts @@ -84,7 +84,7 @@ describe("Session-scoped state extras RPC", async () => { } finally { await disconnect(session); try { - await authClient.forceStop(); + await authClient.stop(); } catch { // Best-effort cleanup. } diff --git a/nodejs/test/e2e/rpc_workspace_checkpoints.e2e.test.ts b/nodejs/test/e2e/rpc_workspace_checkpoints.e2e.test.ts index d7f478e1f1..78a820f67a 100644 --- a/nodejs/test/e2e/rpc_workspace_checkpoints.e2e.test.ts +++ b/nodejs/test/e2e/rpc_workspace_checkpoints.e2e.test.ts @@ -23,9 +23,9 @@ describe("Session workspace checkpoint RPC", async () => { it("should return null or empty content for unknown checkpoint", async () => { const session = await client.createSession({ onPermissionRequest: approveAll }); try { - const result = await session.rpc.workspaces.readCheckpoint({ - number: Number.MAX_SAFE_INTEGER, - }); + // A high but 32-bit-safe checkpoint number that will never exist in a fresh + // session, so the read reports the checkpoint as missing. + const result = await session.rpc.workspaces.readCheckpoint({ number: 4294967294 }); expect(result.content ?? "").toBe(""); } finally { await session.disconnect(); diff --git a/nodejs/test/e2e/session.e2e.test.ts b/nodejs/test/e2e/session.e2e.test.ts index c29f0790d0..2cb88917e3 100644 --- a/nodejs/test/e2e/session.e2e.test.ts +++ b/nodejs/test/e2e/session.e2e.test.ts @@ -5,15 +5,16 @@ import { CopilotClient, approveAll, defineTool, RuntimeConnection } from "../../ import { createSdkTestContext, DEFAULT_GITHUB_TOKEN, isCI } from "./harness/sdkTestContext.js"; import { getFinalAssistantMessage, getNextEventOfType, retry } from "./harness/sdkTestHelper.js"; -describe("Sessions", async () => { - const { - copilotClient: client, - openAiEndpoint, - homeDir, - workDir, - env, - } = await createSdkTestContext(); - +const { + copilotClient: client, + openAiEndpoint, + homeDir, + workDir, + env, + createClient, +} = await createSdkTestContext(); + +describe("Sessions", () => { async function waitForExchanges(minimumCount = 1) { await retry( `capture ${minimumCount} chat completion request(s)`, @@ -39,15 +40,14 @@ describe("Sessions", async () => { }); onTestFinished(async () => { try { - await standaloneClient.forceStop(); + await standaloneClient.stop(); } catch { // ignore } }); - const session = await standaloneClient.createSession({}); + await using session = await standaloneClient.createSession({}); expect(session.sessionId).toMatch(/^[a-f0-9-]+$/); - await session.disconnect(); } ); @@ -64,7 +64,7 @@ describe("Sessions", async () => { }); onTestFinished(async () => { try { - await tcpClient.forceStop(); + await tcpClient.stop(); } catch { // ignore } @@ -84,7 +84,7 @@ describe("Sessions", async () => { }); onTestFinished(async () => { try { - await resumeClient.forceStop(); + await resumeClient.stop(); } catch { // ignore } @@ -96,7 +96,7 @@ describe("Sessions", async () => { await originalSession.disconnect(); }); it("should create and disconnect sessions", async () => { - const session = await client.createSession({ + await using session = await client.createSession({ onPermissionRequest: approveAll, model: "claude-sonnet-4.5", }); @@ -118,7 +118,7 @@ describe("Sessions", async () => { // TODO: Re-enable once test harness CAPI proxy supports this test's session lifecycle it.skip("should list sessions with context field", { timeout: 60000 }, async () => { // Create a session — just creating it is enough for it to appear in listSessions - const session = await client.createSession({ onPermissionRequest: approveAll }); + await using session = await client.createSession({ onPermissionRequest: approveAll }); expect(session.sessionId).toMatch(/^[a-f0-9-]+$/); // Verify it has a start event (confirms session is active) @@ -137,7 +137,7 @@ describe("Sessions", async () => { }); it("should get session metadata by ID", { timeout: 60000 }, async () => { - const session = await client.createSession({ onPermissionRequest: approveAll }); + await using session = await client.createSession({ onPermissionRequest: approveAll }); expect(session.sessionId).toMatch(/^[a-f0-9-]+$/); // Send a message to persist the session to disk @@ -164,7 +164,7 @@ describe("Sessions", async () => { }); it("should have stateful conversation", async () => { - const session = await client.createSession({ onPermissionRequest: approveAll }); + await using session = await client.createSession({ onPermissionRequest: approveAll }); const assistantMessage = await session.sendAndWait({ prompt: "What is 1+1?" }); expect(assistantMessage?.data.content).toContain("2"); @@ -176,7 +176,7 @@ describe("Sessions", async () => { it("should create a session with appended systemMessage config", async () => { const systemMessageSuffix = "End each response with the phrase 'Have a nice day!'"; - const session = await client.createSession({ + await using session = await client.createSession({ onPermissionRequest: approveAll, systemMessage: { mode: "append", @@ -197,7 +197,7 @@ describe("Sessions", async () => { it("should create a session with replaced systemMessage config", async () => { const testSystemMessage = "You are an assistant called Testy McTestface. Reply succinctly."; - const session = await client.createSession({ + await using session = await client.createSession({ onPermissionRequest: approveAll, systemMessage: { mode: "replace", content: testSystemMessage }, }); @@ -218,7 +218,7 @@ describe("Sessions", async () => { async () => { const customTone = "Respond in a warm, professional tone. Be thorough in explanations."; const appendedContent = "Always mention quarterly earnings."; - const session = await client.createSession({ + await using session = await client.createSession({ onPermissionRequest: approveAll, systemMessage: { mode: "customize", @@ -230,66 +230,54 @@ describe("Sessions", async () => { }, }); - try { - await session.send({ prompt: "Who are you?" }); - - // Validate the system message sent to the model - const traffic = await waitForExchanges(); - const systemMessage = getSystemMessage(traffic[0]); - expect(systemMessage).toContain(customTone); - expect(systemMessage).toContain(appendedContent); - // The code_change_rules section should have been removed - expect(systemMessage).not.toContain(""); - } finally { - await session.disconnect(); - } + await session.send({ prompt: "Who are you?" }); + + // Validate the system message sent to the model + const traffic = await waitForExchanges(); + const systemMessage = getSystemMessage(traffic[0]); + expect(systemMessage).toContain(customTone); + expect(systemMessage).toContain(appendedContent); + // The code_change_rules section should have been removed + expect(systemMessage).not.toContain(""); } ); it("should create a session with availableTools", async () => { - const session = await client.createSession({ + await using session = await client.createSession({ onPermissionRequest: approveAll, availableTools: ["view", "edit"], }); - try { - await session.send({ prompt: "What is 1+1?" }); + await session.send({ prompt: "What is 1+1?" }); - // It only tells the model about the specified tools and no others - const traffic = await waitForExchanges(); - expect(traffic[0].request.tools).toMatchObject([ - { function: { name: "view" } }, - { function: { name: "edit" } }, - ]); - } finally { - await session.disconnect(); - } + // It only tells the model about the specified tools and no others + const traffic = await waitForExchanges(); + expect(traffic[0].request.tools).toMatchObject([ + { function: { name: "view" } }, + { function: { name: "edit" } }, + ]); }); it("should create a session with excludedTools", async () => { - const session = await client.createSession({ + await using session = await client.createSession({ onPermissionRequest: approveAll, excludedTools: ["view"], }); - try { - await session.send({ prompt: "What is 1+1?" }); + await session.send({ prompt: "What is 1+1?" }); - // It has other tools, but not the one we excluded - const traffic = await waitForExchanges(); - const functionNames = traffic[0].request.tools?.map( - (t) => (t as { function: { name: string } }).function.name - ); - expect(functionNames).toContain("edit"); - expect(functionNames).toContain("grep"); - expect(functionNames).not.toContain("view"); - } finally { - await session.disconnect(); - } + // It has other tools, but not the one we excluded + const traffic = await waitForExchanges(); + const functionNames = traffic[0].request.tools?.map( + (t) => (t as { function: { name: string } }).function.name + ); + expect(functionNames).toContain("edit"); + expect(functionNames).toContain("grep"); + expect(functionNames).not.toContain("view"); }); it("should create a session with defaultAgent excludedTools", async () => { - const session = await client.createSession({ + await using session = await client.createSession({ onPermissionRequest: approveAll, tools: [ defineTool("secret_tool", { @@ -307,19 +295,15 @@ describe("Sessions", async () => { }, }); - try { - await session.send({ prompt: "What is 1+1?" }); + await session.send({ prompt: "What is 1+1?" }); - // The secret_tool should be registered with the runtime but not advertised - // to the default agent's underlying model call. - const traffic = await waitForExchanges(); - const functionNames = traffic[0].request.tools?.map( - (t) => (t as { function: { name: string } }).function.name - ); - expect(functionNames).not.toContain("secret_tool"); - } finally { - await session.disconnect(); - } + // The secret_tool should be registered with the runtime but not advertised + // to the default agent's underlying model call. + const traffic = await waitForExchanges(); + const functionNames = traffic[0].request.tools?.map( + (t) => (t as { function: { name: string } }).function.name + ); + expect(functionNames).not.toContain("secret_tool"); }); // TODO: This test shows there's a race condition inside client.ts. If createSession is called @@ -362,7 +346,9 @@ describe("Sessions", async () => { expect(answer?.data.content).toContain("2"); // Resume using the same client - const session2 = await client.resumeSession(sessionId, { onPermissionRequest: approveAll }); + await using session2 = await client.resumeSession(sessionId, { + onPermissionRequest: approveAll, + }); expect(session2.sessionId).toBe(sessionId); const messages = await session2.getEvents(); const assistantMessages = messages.filter((m) => m.type === "assistant.message"); @@ -377,19 +363,18 @@ describe("Sessions", async () => { it("should resume a session using a new client", async () => { // Create initial session - const session1 = await client.createSession({ onPermissionRequest: approveAll }); + await using session1 = await client.createSession({ onPermissionRequest: approveAll }); const sessionId = session1.sessionId; const answer = await session1.sendAndWait({ prompt: "What is 1+1?" }); expect(answer?.data.content).toContain("2"); // Resume using a new client - const newClient = new CopilotClient({ - env, + const newClient = createClient({ gitHubToken: isCI ? "fake-token-for-e2e-tests" : undefined, }); - onTestFinished(() => newClient.forceStop()); - const session2 = await newClient.resumeSession(sessionId, { + onTestFinished(() => newClient.stop()); + await using session2 = await newClient.resumeSession(sessionId, { onPermissionRequest: approveAll, }); expect(session2.sessionId).toBe(sessionId); @@ -417,7 +402,7 @@ describe("Sessions", async () => { }); it("should create session with custom tool", async () => { - const session = await client.createSession({ + await using session = await client.createSession({ onPermissionRequest: approveAll, tools: [ { @@ -452,7 +437,7 @@ describe("Sessions", async () => { const sessionId = session.sessionId; // Resume the session with a provider - const session2 = await client.resumeSession(sessionId, { + await using session2 = await client.resumeSession(sessionId, { onPermissionRequest: approveAll, provider: { type: "openai", @@ -467,7 +452,7 @@ describe("Sessions", async () => { it("resumes a persisted session from a new client when an MCP OAuth handler is configured", async () => { // Take a turn so the session is persisted to the store and can be // loaded by a different CLI process. - const session1 = await client.createSession({ + await using session1 = await client.createSession({ onPermissionRequest: approveAll, onMcpAuthRequest: () => ({ kind: "cancelled" }), }); @@ -481,25 +466,23 @@ describe("Sessions", async () => { // `session.eventLog.registerInterest` for `mcp.oauth_required`; that must // be sent AFTER `session.resume`, otherwise the runtime rejects it with // "Session not found: ". - const newClient = new CopilotClient({ - env, + const newClient = createClient({ gitHubToken: isCI ? DEFAULT_GITHUB_TOKEN : (process.env.GITHUB_TOKEN ?? DEFAULT_GITHUB_TOKEN), }); - onTestFinished(() => newClient.forceStop()); + onTestFinished(() => newClient.stop()); - const session2 = await newClient.resumeSession(sessionId, { + await using session2 = await newClient.resumeSession(sessionId, { onPermissionRequest: approveAll, onMcpAuthRequest: () => ({ kind: "cancelled" }), }); expect(session2.sessionId).toBe(sessionId); - await session2.disconnect(); }); it("should abort a session", async () => { - const session = await client.createSession({ onPermissionRequest: approveAll }); + await using session = await client.createSession({ onPermissionRequest: approveAll }); // Set up event listeners BEFORE sending to avoid race conditions const nextToolCallStart = getNextEventOfType(session, "tool.execution_start"); @@ -530,7 +513,7 @@ describe("Sessions", async () => { // if the session weren't registered in the sessions map before the RPC, // the event would be dropped. const earlyEvents: Array<{ type: string }> = []; - const session = await client.createSession({ + await using session = await client.createSession({ onPermissionRequest: approveAll, onEvent: (event) => { earlyEvents.push(event); @@ -562,7 +545,7 @@ describe("Sessions", async () => { }); it("handler exception does not halt event delivery", async () => { - const session = await client.createSession({ onPermissionRequest: approveAll }); + await using session = await client.createSession({ onPermissionRequest: approveAll }); let eventCount = 0; let gotIdle = false; @@ -587,12 +570,10 @@ describe("Sessions", async () => { // Handler saw more than just the first (throwing) event. expect(eventCount).toBeGreaterThan(1); - - await session.disconnect(); }); it("disposeAsync from handler does not deadlock", async () => { - const session = await client.createSession({ onPermissionRequest: approveAll }); + await using session = await client.createSession({ onPermissionRequest: approveAll }); let disposed = false; const disposedPromise = new Promise((resolve) => { @@ -619,25 +600,21 @@ describe("Sessions", async () => { onTestFinished(async () => { await rm(customConfigDir, { recursive: true, force: true }).catch(() => {}); }); - const session = await client.createSession({ + await using session = await client.createSession({ onPermissionRequest: approveAll, configDirectory: customConfigDir, }); expect(session.sessionId).toMatch(/^[a-f0-9-]+$/); - try { - // Session should work normally with custom config dir - await session.send({ prompt: "What is 1+1?" }); - const assistantMessage = await getFinalAssistantMessage(session); - expect(assistantMessage.data.content).toContain("2"); - } finally { - await session.disconnect(); - } + // Session should work normally with custom config dir + await session.send({ prompt: "What is 1+1?" }); + const assistantMessage = await getFinalAssistantMessage(session); + expect(assistantMessage.data.content).toContain("2"); }); it("should log messages at all levels and emit matching session events", async () => { - const session = await client.createSession({ onPermissionRequest: approveAll }); + await using session = await client.createSession({ onPermissionRequest: approveAll }); const events: Array<{ type: string; id?: string; data?: Record }> = []; session.on((event) => { @@ -692,7 +669,7 @@ describe("Sessions", async () => { const { writeFile } = await import("fs/promises"); await writeFile(filePath, "FILE_ATTACHMENT_SENTINEL"); - const session = await client.createSession({ onPermissionRequest: approveAll }); + await using session = await client.createSession({ onPermissionRequest: approveAll }); await session.sendAndWait({ prompt: "Read the attached file and reply with its contents.", @@ -726,8 +703,6 @@ describe("Sessions", async () => { expect(attachment.displayName).toBe("attached-file.txt"); expect(attachment.path).toBe(filePath); expect(attachment.lineRange).toEqual({ start: 1, end: 1 }); - - await session.disconnect(); }); it("should send with directory attachment", async () => { @@ -736,7 +711,7 @@ describe("Sessions", async () => { await mkdir(directoryPath, { recursive: true }); await writeFile(`${directoryPath}/readme.txt`, "DIRECTORY_ATTACHMENT_SENTINEL"); - const session = await client.createSession({ onPermissionRequest: approveAll }); + await using session = await client.createSession({ onPermissionRequest: approveAll }); await session.sendAndWait({ prompt: "List the attached directory.", @@ -759,8 +734,6 @@ describe("Sessions", async () => { expect(attachment.type).toBe("directory"); expect(attachment.displayName).toBe("attached-directory"); expect(attachment.path).toBe(directoryPath); - - await session.disconnect(); }); it("should send with selection attachment", async () => { @@ -768,7 +741,7 @@ describe("Sessions", async () => { const { writeFile } = await import("fs/promises"); await writeFile(filePath, 'class C { string Value = "SELECTION_SENTINEL"; }'); - const session = await client.createSession({ onPermissionRequest: approveAll }); + await using session = await client.createSession({ onPermissionRequest: approveAll }); await session.sendAndWait({ prompt: "Summarize the selected code.", @@ -808,8 +781,6 @@ describe("Sessions", async () => { expect(attachment.text).toBe('string Value = "SELECTION_SENTINEL";'); expect(attachment.selection.start).toEqual({ line: 1, character: 10 }); expect(attachment.selection.end).toEqual({ line: 1, character: 45 }); - - await session.disconnect(); }); it("should accept blob attachments", async () => { @@ -818,7 +789,7 @@ describe("Sessions", async () => { const { writeFile } = await import("fs/promises"); await writeFile(`${workDir}/test-pixel.png`, Buffer.from(pngBase64, "base64")); - const session = await client.createSession({ onPermissionRequest: approveAll }); + await using session = await client.createSession({ onPermissionRequest: approveAll }); await session.sendAndWait({ prompt: "Describe this image", @@ -831,12 +802,10 @@ describe("Sessions", async () => { }, ], }); - - await session.disconnect(); }); it("should send with github reference attachment", async () => { - const session = await client.createSession({ onPermissionRequest: approveAll }); + await using session = await client.createSession({ onPermissionRequest: approveAll }); await session.sendAndWait({ prompt: "Using only the GitHub reference metadata in this message, summarize the reference. Do not call any tools.", @@ -876,12 +845,10 @@ describe("Sessions", async () => { expect(attachment.state).toBe("open"); expect(attachment.title).toBe("Add E2E attachment coverage"); expect(attachment.url).toBe("https://github.com/github/copilot-sdk/issues/1234"); - - await session.disconnect(); }); it("should send with mode property", async () => { - const session = await client.createSession({ onPermissionRequest: approveAll }); + await using session = await client.createSession({ onPermissionRequest: approveAll }); await session.sendAndWait({ prompt: "Say mode ok.", @@ -895,12 +862,10 @@ describe("Sessions", async () => { expect(userMessage).toBeDefined(); expect(userMessage!.data.content).toBe("Say mode ok."); expect(userMessage!.data.agentMode).toBe("plan"); - - await session.disconnect(); }); it("should send with custom requestHeaders", async () => { - const session = await client.createSession({ onPermissionRequest: approveAll }); + await using session = await client.createSession({ onPermissionRequest: approveAll }); await session.sendAndWait({ prompt: "What is 1+1?", @@ -919,8 +884,6 @@ describe("Sessions", async () => { const headerValue = headers[matchingKey!]; const headerStr = Array.isArray(headerValue) ? headerValue.join(",") : (headerValue ?? ""); expect(headerStr).toContain("ts-request-headers"); - - await session.disconnect(); }); }); @@ -933,10 +896,8 @@ function getSystemMessage(exchange: ParsedHttpExchange): string | undefined { describe("Send Blocking Behavior", async () => { // Tests for Issue #17: send() should return immediately, not block until turn completes - const { copilotClient: client } = await createSdkTestContext(); - it("send returns immediately while events stream in background", async () => { - const session = await client.createSession({ + await using session = await client.createSession({ onPermissionRequest: approveAll, }); @@ -960,7 +921,7 @@ describe("Send Blocking Behavior", async () => { }); it("sendAndWait blocks until session.idle and returns final assistant message", async () => { - const session = await client.createSession({ onPermissionRequest: approveAll }); + await using session = await client.createSession({ onPermissionRequest: approveAll }); const events: string[] = []; session.on((event) => { @@ -979,16 +940,17 @@ describe("Send Blocking Behavior", async () => { // This test validates client-side timeout behavior. // The snapshot has no assistant response since we expect timeout before completion. it("sendAndWait throws on timeout", async () => { - const session = await client.createSession({ onPermissionRequest: approveAll }); + await using session = await client.createSession({ onPermissionRequest: approveAll }); // Use a slow command to ensure timeout triggers before completion await expect( session.sendAndWait({ prompt: "Run 'sleep 2 && echo done'" }, 100) ).rejects.toThrow(/Timeout after 100ms/); + await session.abort(); }); it("should set model on existing session", async () => { - const session = await client.createSession({ onPermissionRequest: approveAll }); + await using session = await client.createSession({ onPermissionRequest: approveAll }); // Subscribe for the model change event before calling setModel. const modelChangePromise = getNextEventOfType(session, "session.model_change"); @@ -998,12 +960,10 @@ describe("Send Blocking Behavior", async () => { // Verify a model_change event was emitted with the new model. const event = await modelChangePromise; expect(event.data.newModel).toBe("gpt-4.1"); - - await session.disconnect(); }); it("should set model with reasoningEffort", async () => { - const session = await client.createSession({ onPermissionRequest: approveAll }); + await using session = await client.createSession({ onPermissionRequest: approveAll }); const modelChangePromise = getNextEventOfType(session, "session.model_change"); diff --git a/nodejs/test/e2e/session_fs.e2e.test.ts b/nodejs/test/e2e/session_fs.e2e.test.ts index cba98996ef..11de9582e1 100644 --- a/nodejs/test/e2e/session_fs.e2e.test.ts +++ b/nodejs/test/e2e/session_fs.e2e.test.ts @@ -108,7 +108,7 @@ describe("Session Fs", async () => { connection: RuntimeConnection.forTcp({ connectionToken: tcpConnectionToken }), env, }); - onTestFinished(() => client.forceStop()); + onTestFinished(() => client.stop()); await client.createSession({ onPermissionRequest: approveAll, createSessionFsProvider }); const { runtimePort: port } = client as unknown as { runtimePort: number }; @@ -123,7 +123,7 @@ describe("Session Fs", async () => { }), sessionFs: sessionFsConfig, }); - onTestFinished(() => client2.forceStop()); + onTestFinished(() => client2.stop()); await expect(client2.start()).rejects.toThrow(); }); diff --git a/nodejs/test/e2e/streaming_fidelity.e2e.test.ts b/nodejs/test/e2e/streaming_fidelity.e2e.test.ts index 52f893469a..17b5222616 100644 --- a/nodejs/test/e2e/streaming_fidelity.e2e.test.ts +++ b/nodejs/test/e2e/streaming_fidelity.e2e.test.ts @@ -3,11 +3,11 @@ *--------------------------------------------------------------------------------------------*/ import { describe, expect, it, onTestFinished } from "vitest"; -import { CopilotClient, SessionEvent, approveAll } from "../../src/index.js"; +import { SessionEvent, approveAll } from "../../src/index.js"; import { createSdkTestContext, isCI } from "./harness/sdkTestContext"; describe("Streaming Fidelity", async () => { - const { copilotClient: client, env } = await createSdkTestContext(); + const { copilotClient: client, createClient } = await createSdkTestContext(); it("should produce delta events when streaming is enabled", async () => { const session = await client.createSession({ @@ -81,11 +81,10 @@ describe("Streaming Fidelity", async () => { await session.disconnect(); // Resume using a new client - const newClient = new CopilotClient({ - env, + const newClient = createClient({ gitHubToken: isCI ? "fake-token-for-e2e-tests" : process.env.GITHUB_TOKEN, }); - onTestFinished(() => newClient.forceStop()); + onTestFinished(() => newClient.stop()); const session2 = await newClient.resumeSession(session.sessionId, { onPermissionRequest: approveAll, streaming: true, @@ -120,11 +119,10 @@ describe("Streaming Fidelity", async () => { await session.disconnect(); // Resume using a new client with streaming DISABLED - const newClient = new CopilotClient({ - env, + const newClient = createClient({ gitHubToken: isCI ? "fake-token-for-e2e-tests" : process.env.GITHUB_TOKEN, }); - onTestFinished(() => newClient.forceStop()); + onTestFinished(() => newClient.stop()); const session2 = await newClient.resumeSession(session.sessionId, { onPermissionRequest: approveAll, streaming: false, diff --git a/nodejs/test/e2e/subagent_hooks.e2e.test.ts b/nodejs/test/e2e/subagent_hooks.e2e.test.ts index ac0a694dca..dbc3ca673b 100644 --- a/nodejs/test/e2e/subagent_hooks.e2e.test.ts +++ b/nodejs/test/e2e/subagent_hooks.e2e.test.ts @@ -6,20 +6,79 @@ import { writeFile } from "fs/promises"; import { join } from "path"; import { describe, expect, it } from "vitest"; import type { + CopilotRequestContext, PreToolUseHookInput, PreToolUseHookOutput, PostToolUseHookInput, PostToolUseHookOutput, } from "../../src/index.js"; -import { approveAll } from "../../src/index.js"; +import { approveAll, CopilotRequestHandler } from "../../src/index.js"; import { createSdkTestContext, isCI } from "./harness/sdkTestContext.js"; +interface RequestRecord { + url: string; + agentId?: string; + parentAgentId?: string; + interactionType?: string; +} + +class RecordingRequestHandler extends CopilotRequestHandler { + readonly records: RequestRecord[] = []; + + protected override async sendRequest( + request: Request, + ctx: CopilotRequestContext + ): Promise { + this.records.push({ + url: request.url, + agentId: ctx.agentId, + parentAgentId: ctx.parentAgentId, + interactionType: ctx.interactionType, + }); + return super.sendRequest(request, ctx); + } +} + +function isInferenceUrl(url: string): boolean { + const u = url.toLowerCase(); + return ( + u.endsWith("/chat/completions") || + u.endsWith("/responses") || + u.endsWith("/v1/messages") || + u.endsWith("/messages") + ); +} + +function expectSubagentRequestMetadata(records: RequestRecord[]): void { + const inference = records.filter((r) => isInferenceUrl(r.url)); + expect(inference.length, "request handler should observe inference requests").toBeGreaterThan( + 0 + ); + + const subagentRequest = inference.find((r) => r.parentAgentId); + expect( + subagentRequest, + "sub-agent inference request should carry a parentAgentId" + ).toBeDefined(); + expect( + subagentRequest!.agentId, + "sub-agent inference request should carry an agentId" + ).toBeTruthy(); + expect( + subagentRequest!.interactionType, + "sub-agent inference request should carry an interactionType" + ).toBeTruthy(); + expect(subagentRequest!.parentAgentId).not.toBe(subagentRequest!.agentId); +} + describe("Subagent hooks", async () => { // For snapshot recording (non-CI), use RECORD_GH_TOKEN if available const recordToken = !isCI ? process.env.RECORD_GH_TOKEN : undefined; + const requestHandler = new RecordingRequestHandler(); const { copilotClient: client, workDir } = await createSdkTestContext({ copilotClientOptions: { ...(recordToken ? { gitHubToken: recordToken } : {}), + requestHandler, env: { COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS: "true" }, }, }); @@ -75,6 +134,7 @@ describe("Subagent hooks", async () => { // input.sessionId distinguishes parent from sub-agent: parent tools and // sub-agent tools carry different sessionIds expect(viewPre[0].sessionId).not.toBe(taskPre!.sessionId); + expectSubagentRequestMetadata(requestHandler.records); await session.disconnect(); }, 120_000); diff --git a/nodejs/test/e2e/suspend.e2e.test.ts b/nodejs/test/e2e/suspend.e2e.test.ts index 79e8987260..2c8639ad38 100644 --- a/nodejs/test/e2e/suspend.e2e.test.ts +++ b/nodejs/test/e2e/suspend.e2e.test.ts @@ -4,8 +4,8 @@ import { describe, expect, it, onTestFinished } from "vitest"; import { z } from "zod"; -import { approveAll, CopilotClient, defineTool, RuntimeConnection } from "../../src/index.js"; import type { PermissionRequest, PermissionRequestResult, SessionEvent } from "../../src/index.js"; +import { approveAll, CopilotClient, defineTool, RuntimeConnection } from "../../src/index.js"; import { createSdkTestContext, DEFAULT_GITHUB_TOKEN } from "./harness/sdkTestContext.js"; const SUSPEND_TIMEOUT_MS = 60_000; @@ -47,10 +47,10 @@ async function waitWithTimeout( } } -function onTestFinishedForceStop(client: CopilotClient): void { +function onTestFinishedStop(client: CopilotClient): void { onTestFinished(async () => { try { - await client.forceStop(); + await client.stop(); } catch { // Ignore cleanup errors } @@ -71,7 +71,7 @@ describe("Suspend RPC", async () => { connectionToken: SHARED_TOKEN, }), }); - onTestFinishedForceStop(server); + onTestFinishedStop(server); return server; } @@ -79,7 +79,7 @@ describe("Suspend RPC", async () => { const connectedClient = new CopilotClient({ connection: RuntimeConnection.forUri(cliUrl, { connectionToken: SHARED_TOKEN }), }); - onTestFinishedForceStop(connectedClient); + onTestFinishedStop(connectedClient); return connectedClient; } diff --git a/nodejs/test/e2e/telemetry.e2e.test.ts b/nodejs/test/e2e/telemetry.e2e.test.ts index 9df6b7f88e..c0f71ebfc6 100644 --- a/nodejs/test/e2e/telemetry.e2e.test.ts +++ b/nodejs/test/e2e/telemetry.e2e.test.ts @@ -6,7 +6,7 @@ import { readFile } from "fs/promises"; import { join } from "path"; import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { approveAll, defineTool } from "../../src/index.js"; +import { approveAll, defineTool, RuntimeConnection } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; import { getFinalAssistantMessage } from "./harness/sdkTestHelper.js"; @@ -58,6 +58,12 @@ describe("Telemetry export", async () => { const { copilotClient: client, workDir } = await createSdkTestContext({ copilotClientOptions: { + // Telemetry is lowered to environment variables the native runtime reads, which + // the in-process transport cannot carry per-client (the runtime runs in the shared + // host process); see https://github.com/github/copilot-sdk/issues/1934. Pin the + // child-process (stdio) transport so this scenario is exercised even in the + // in-process CI cell, matching the .NET suite. + connection: RuntimeConnection.forStdio(), telemetry: { filePath: telemetryFileName, exporterType: "file", diff --git a/nodejs/test/e2e/ui_elicitation.e2e.test.ts b/nodejs/test/e2e/ui_elicitation.e2e.test.ts index 3bc9335a21..2e85dd5af2 100644 --- a/nodejs/test/e2e/ui_elicitation.e2e.test.ts +++ b/nodejs/test/e2e/ui_elicitation.e2e.test.ts @@ -5,7 +5,7 @@ import { afterAll, describe, expect, it } from "vitest"; import { CopilotClient, approveAll, RuntimeConnection } from "../../src/index.js"; import type { SessionEvent } from "../../src/index.js"; -import { createSdkTestContext } from "./harness/sdkTestContext.js"; +import { createSdkTestContext, isInProcessTransport } from "./harness/sdkTestContext.js"; describe("UI Elicitation", async () => { const { copilotClient: client } = await createSdkTestContext(); @@ -116,7 +116,7 @@ describe("UI Elicitation Multi-Client Capabilities", async () => { } ); - it( + it.skipIf(isInProcessTransport)( "capabilities.changed fires when elicitation provider disconnects", { timeout: 60_000 }, async () => { diff --git a/nodejs/test/session-event-codegen.test.ts b/nodejs/test/session-event-codegen.test.ts index 86c76f71bc..14340292be 100644 --- a/nodejs/test/session-event-codegen.test.ts +++ b/nodejs/test/session-event-codegen.test.ts @@ -209,6 +209,38 @@ describe("session event codegen", () => { ); }); + it("drops leading underscores from C# member names while preserving JSON names", () => { + const schema: JSONSchema7 = { + definitions: { + SessionEvent: { + anyOf: [ + { + type: "object", + required: ["type", "data"], + properties: { + type: { const: "session.synthetic" }, + data: { + type: "object", + required: ["_meta"], + properties: { + _meta: { type: "string" }, + }, + }, + }, + }, + ], + }, + }, + }; + + const csharpCode = generateCSharpSessionEventsCode(schema); + + expect(csharpCode).toContain( + '[JsonPropertyName("_meta")]\n public required string Meta { get; set; }' + ); + expect(csharpCode).not.toContain("public required string _meta"); + }); + it("collapses redundant callable wrapper lambdas", () => { const schema: JSONSchema7 = { definitions: { diff --git a/python/README.md b/python/README.md index 86f568bd7a..3089c36699 100644 --- a/python/README.md +++ b/python/README.md @@ -32,6 +32,17 @@ python -m copilot download-runtime This caches the runtime binary locally. If you skip this step, the SDK will attempt to download it automatically on first use as a fallback. +To pre-provision the native library required by the in-process (FFI) transport +(see [In-process (FFI) transport](#in-process-ffi-transport)), pass `--in-process`: + +```bash +python -m copilot download-runtime --in-process +``` + +This additionally fetches the native runtime library into the versioned runtime +cache. Stdio/TCP users never download it. When omitted, it is downloaded +lazily on first use of the in-process transport. + | Platform | Cache path | |----------|-----------| | Linux | `~/.cache/github-copilot-sdk/cli//copilot` | @@ -136,7 +147,7 @@ asyncio.run(main()) ## Features - ✅ Full JSON-RPC protocol support -- ✅ stdio and TCP transports +- ✅ stdio, TCP, and in-process (FFI) transports - ✅ Real-time streaming events - ✅ Session history with `get_events()` - ✅ Type hints throughout @@ -184,8 +195,9 @@ CopilotClient(connection=..., log_level="debug", github_token=..., ...) All options are kw-only parameters: - `connection` (RuntimeConnection | None): How to reach the runtime. Use - `RuntimeConnection.for_stdio(...)`, `RuntimeConnection.for_tcp(...)`, or - `RuntimeConnection.for_uri(...)`. Defaults to a stdio connection with the bundled binary. + `RuntimeConnection.for_stdio(...)`, `RuntimeConnection.for_tcp(...)`, + `RuntimeConnection.for_uri(...)`, or `RuntimeConnection.for_inprocess(...)`. + Defaults to a stdio connection with the bundled binary. - `working_directory` (str | None): Working directory for the CLI process (default: current dir). - `log_level` (str): Log level (default: "info"). - `env` (dict | None): Environment variables for the CLI process. @@ -204,6 +216,51 @@ All options are kw-only parameters: - `RuntimeConnection.for_stdio(path=None, args=None)` — spawn a local CLI process and talk over stdio. - `RuntimeConnection.for_tcp(port=0, connection_token=None, path=None, args=None)` — spawn a local CLI in TCP mode. - `RuntimeConnection.for_uri(url, connection_token=None)` — connect to an existing CLI server (e.g. `"localhost:8080"`). +- `RuntimeConnection.for_inprocess()` — host the runtime in-process via its native C ABI (FFI). See [In-process (FFI) transport](#in-process-ffi-transport). + +Child-process connections (`for_stdio`/`for_tcp`) also expose a per-connection +`env` field for the spawned process. Set it on the returned connection instead of +the client-level `env` — setting both raises: + +```python +conn = RuntimeConnection.for_stdio() +conn.env = {"MY_VAR": "value"} +client = CopilotClient(connection=conn) # do NOT also pass env=... here +``` + +### In-process (FFI) transport + +> ⚠️ **Experimental.** The in-process transport loads the runtime's native shared +> library into your process and drives JSON-RPC over its C ABI (via stdlib +> `ctypes`), instead of spawning a child process. + +```python +from copilot import CopilotClient, RuntimeConnection + +client = CopilotClient(connection=RuntimeConnection.for_inprocess()) +await client.start() +try: + pong = await client.ping("hello") + print(pong.message) +finally: + await client.stop() +``` + +**Requirements & behavior:** + +- Pre-provision the native runtime with + `python -m copilot download-runtime --in-process`, or let the SDK download it + lazily on first use of this transport. +- Set `COPILOT_CLI_PATH` only when using an externally provisioned compatible + runtime package. In-process connections do not accept per-connection paths + or raw process arguments. +- Because the runtime shares this single host process, per-client options that + lower to environment variables or a working directory **cannot** be honored and + are rejected: `env`, `telemetry`, and `working_directory` all raise `ValueError` + with `for_inprocess()`. Set the corresponding values on the host process + environment / working directory before creating the client instead. +- Set `COPILOT_SDK_DEFAULT_CONNECTION=inprocess` to select the in-process + transport by default when no explicit `connection` is supplied. **`CopilotClient.create_session()`:** diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index fdf422d2aa..76f79b79f4 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -24,6 +24,7 @@ CanvasHostContext, CanvasHostContextCapabilities, CanvasJsonSchema, + CanvasProviderIdentity, ExtensionInfo, OpenCanvasInstance, ) @@ -35,6 +36,7 @@ CopilotClient, GetAuthStatusResponse, GetStatusResponse, + InProcessRuntimeConnection, LogLevel, ModelBilling, ModelCapabilities, @@ -74,6 +76,7 @@ LlmInferenceHeaders, ) from .generated.rpc import ( + CurrentToolMetadata, GitHubTelemetryClientInfo, GitHubTelemetryEvent, GitHubTelemetryNotification, @@ -156,6 +159,7 @@ SessionUiApi, SessionUiCapabilities, SystemMessageConfig, + ToolSearchConfig, UserInputHandler, UserInputRequest, UserInputResponse, @@ -200,6 +204,7 @@ "CanvasHostContext", "CanvasHostContextCapabilities", "CanvasJsonSchema", + "CanvasProviderIdentity", "CapiSessionOptions", "ChildProcessRuntimeConnection", "CloudSessionOptions", @@ -214,6 +219,7 @@ "CopilotWebSocketCloseStatus", "CopilotWebSocketHandler", "CreateSessionFsHandler", + "CurrentToolMetadata", "ElicitationContext", "ElicitationHandler", "ElicitationParams", @@ -233,6 +239,7 @@ "GitHubTelemetryEvent", "GitHubTelemetryNotification", "InfiniteSessionConfig", + "InProcessRuntimeConnection", "InputOptions", "LargeToolOutputConfig", "LlmInferenceHeaders", @@ -330,6 +337,7 @@ "ToolInvocation", "ToolResult", "ToolResultType", + "ToolSearchConfig", "ToolSet", "UriRuntimeConnection", "UserInputHandler", diff --git a/python/copilot/_cli_download.py b/python/copilot/_cli_download.py index 1a5ebc9024..b831e072ad 100644 --- a/python/copilot/_cli_download.py +++ b/python/copilot/_cli_download.py @@ -16,6 +16,7 @@ from __future__ import annotations +import base64 import hashlib import io import os @@ -35,6 +36,9 @@ get_asset_info, get_checksums_url, get_download_url, + get_npm_platform, + get_runtime_lib_packument_url, + get_runtime_lib_url, ) _CACHE_DIR_NAME = "github-copilot-sdk" @@ -304,6 +308,153 @@ def download_cli(version: str | None = None, *, force: bool = False) -> str: return str(binary_path) +def _fetch_url_bytes(url: str, *, timeout: int) -> bytes: + """Download bytes from ``url`` with retries.""" + last_exc: Exception | None = None + for attempt in range(_MAX_RETRIES): + try: + with urlopen(url, timeout=timeout) as response: + return response.read() + except (HTTPError, URLError) as exc: + last_exc = exc + if attempt < _MAX_RETRIES - 1: + time.sleep(2**attempt) + raise RuntimeError(f"Failed to download from {url}: {last_exc}") from last_exc + + +def _fetch_runtime_integrity(npm_platform: str, version: str) -> str | None: + """Return the npm ``dist.integrity`` (Subresource Integrity) for the tarball. + + Best-effort: returns None if the packument can't be fetched or parsed. + """ + import json + + url = get_runtime_lib_packument_url(npm_platform) + try: + raw = _fetch_url_bytes(url, timeout=30) + packument = json.loads(raw) + dist = packument.get("versions", {}).get(version, {}).get("dist", {}) + integrity = dist.get("integrity") + return integrity if isinstance(integrity, str) else None + except (RuntimeError, ValueError, KeyError): + return None + + +def _verify_integrity(data: bytes, integrity: str) -> None: + """Verify data against an npm Subresource Integrity string (e.g. ``sha512-``).""" + algo, _, b64 = integrity.partition("-") + algo = algo.lower() + if algo not in ("sha512", "sha384", "sha256"): + # Fail closed: an unrecognized algorithm means we cannot verify this native + # library, so refuse rather than loading unverified native code. + raise RuntimeError( + f"Unsupported integrity algorithm '{algo}' for the in-process runtime " + "library; refusing to load unverified native code." + ) + expected = base64.b64decode(b64) + actual = hashlib.new(algo, data).digest() + if actual != expected: + raise RuntimeError( + f"Integrity mismatch for runtime library ({algo}): " + "downloaded tarball does not match the npm registry checksum." + ) + + +def _extract_runtime_node(data: bytes, npm_platform: str) -> bytes: + """Extract ``package/prebuilds//runtime.node`` from an npm tarball.""" + target = f"package/prebuilds/{npm_platform}/runtime.node" + with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tf: + for name in tf.getnames(): + if name == target or name.endswith(f"/prebuilds/{npm_platform}/runtime.node"): + member = tf.getmember(name) + extracted = tf.extractfile(member) + if extracted is not None: + return extracted.read() + raise RuntimeError(f"'{target}' not found in runtime package for {npm_platform}.") + + +def ensure_runtime_library(cli_path: str, version: str | None = None) -> str | None: + """Ensure the native in-process (FFI) runtime library sits next to ``cli_path``. + + The library is NOT part of the GitHub Releases CLI archive; it ships in the npm + platform package ``@github/copilot-`` under + ``package/prebuilds//runtime.node``. This helper downloads that tarball + and writes the library next to the CLI binary under its natural platform name + (``libcopilot_runtime.so`` / ``.dylib`` / ``copilot_runtime.dll``). + + This is opt-in — only invoked when the in-process transport is actually selected + (lazy) or via ``python -m copilot download-runtime --in-process`` (explicit). The + default stdio download path never fetches these extra bytes. + + Returns the absolute path to the library, or None if it could not be provisioned + (e.g. download disabled or unsupported platform). Raises RuntimeError on + download/verification failure. + """ + # Import lazily to avoid a hard dependency for stdio-only users. + from ._ffi_runtime_host import _natural_library_name, resolve_library_path + + # Already present (bundled prebuilds layout in dev, or a prior download)? + existing = resolve_library_path(cli_path) + if existing is not None: + return existing + + if _should_skip_download(): + return None + + ver = version or CLI_VERSION + if not ver: + return None + + try: + npm_platform = get_npm_platform() + except RuntimeError: + return None + + cli_dir = Path(cli_path).resolve().parent + lib_path = cli_dir / _natural_library_name() + if lib_path.exists(): + return str(lib_path) + + url = get_runtime_lib_url(ver, npm_platform) + data = _fetch_url_bytes(url, timeout=600) + + integrity = _fetch_runtime_integrity(npm_platform, ver) + if not integrity: + # Fail closed: this native library is loaded into the host process, so it must + # be verified before use. The npm packument (which carries dist.integrity) was + # unavailable, so refuse rather than loading unverified native code — mirroring + # the CLI download, which requires a checksum. Retry when the registry is + # reachable, or install a runtime package that ships the library. + raise RuntimeError( + "No Subresource Integrity value available for the in-process runtime " + f"library ({npm_platform}@{ver}); refusing to load unverified native code." + ) + _verify_integrity(data, integrity) + + lib_bytes = _extract_runtime_node(data, npm_platform) + + # Write atomically next to the CLI so concurrent starts don't observe a partial + # library. A rename within the same directory is atomic on POSIX and Windows. + cli_dir.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(dir=cli_dir, prefix=".runtime-lib-") + try: + with os.fdopen(fd, "wb") as out: + out.write(lib_bytes) + os.replace(tmp_name, lib_path) + except OSError: + try: + os.unlink(tmp_name) + except OSError: + # Best-effort cleanup of the temp file; ignore if it's already gone or + # can't be removed (the OS reclaims it, and it doesn't affect correctness). + pass + if lib_path.exists(): + return str(lib_path) + raise + + return str(lib_path) + + def get_or_download_cli(version: str | None = None) -> str | None: """Get the cached CLI binary, downloading it if necessary. @@ -361,6 +512,15 @@ def main() -> None: "--version", help="Runtime version to download (default: pinned version)", ) + dl_parser.add_argument( + "--in-process", + action="store_true", + help=( + "Also download the native in-process (FFI) runtime library " + "(prebuilds//runtime.node) and place it next to the CLI. " + "Only needed for the experimental in-process transport." + ), + ) args = parser.parse_args() @@ -378,6 +538,17 @@ def main() -> None: try: path = download_cli(ver, force=args.force) print(f"Runtime cached at: {path}") + if args.in_process: + print("Downloading in-process (FFI) runtime library...") + lib_path = ensure_runtime_library(path, ver) + if lib_path: + print(f"Runtime library cached at: {lib_path}") + else: + print( + "Warning: could not provision the in-process runtime library " + "(download disabled or unsupported platform).", + file=sys.stderr, + ) except RuntimeError as exc: print(f"Error: {exc}", file=sys.stderr) sys.exit(1) diff --git a/python/copilot/_cli_version.py b/python/copilot/_cli_version.py index 4ff9cfb7af..cb5939820a 100644 --- a/python/copilot/_cli_version.py +++ b/python/copilot/_cli_version.py @@ -36,6 +36,31 @@ _DOWNLOAD_BASE_URL = "https://github.com/github/copilot-cli/releases/download" +# The native in-process (FFI) runtime library (`runtime.node`) is NOT part of the +# GitHub Releases `copilot-` archive (that ships only the CLI binary). It +# lives in the npm platform package `@github/copilot-`, under +# `package/prebuilds//runtime.node`. Mirrors the .NET SDK targets, +# which download the same npm tarball. +_NPM_REGISTRY_BASE_URL = "https://registry.npmjs.org" + +# Maps (sys.platform, platform.machine()) → npm platform name (glibc Linux/macOS/Windows). +NPM_PLATFORMS: dict[tuple[str, str], str] = { + ("linux", "x86_64"): "linux-x64", + ("linux", "aarch64"): "linux-arm64", + ("linux", "arm64"): "linux-arm64", + ("darwin", "x86_64"): "darwin-x64", + ("darwin", "arm64"): "darwin-arm64", + ("win32", "AMD64"): "win32-x64", + ("win32", "ARM64"): "win32-arm64", +} + +# Musl (Alpine) npm platform variants — detected at runtime via _is_musl(). +_MUSL_NPM_PLATFORMS: dict[str, str] = { + "x86_64": "linuxmusl-x64", + "aarch64": "linuxmusl-arm64", + "arm64": "linuxmusl-arm64", +} + def _is_musl() -> bool: """Detect whether the current Linux system uses musl libc (e.g. Alpine).""" @@ -93,3 +118,45 @@ def get_checksums_url(version: str) -> str: base = os.environ.get("COPILOT_CLI_DOWNLOAD_BASE_URL", _DOWNLOAD_BASE_URL) return f"{base}/v{version}/SHA256SUMS.txt" + + +def get_npm_platform() -> str: + """Return the npm platform name (e.g. ``linux-x64``) for the current host. + + Used to locate the native in-process runtime library. Raises RuntimeError if + the platform is not supported. + """ + key = get_platform_key() + + if key[0] == "linux" and _is_musl(): + musl = _MUSL_NPM_PLATFORMS.get(key[1]) + if musl: + return musl + + npm_platform = NPM_PLATFORMS.get(key) + if npm_platform is None: + raise RuntimeError( + f"Unsupported platform for in-process runtime: {key[0]}/{key[1]}. " + f"Supported platforms: {', '.join(f'{p}/{m}' for p, m in NPM_PLATFORMS)}" + ) + return npm_platform + + +def get_runtime_lib_packument_url(npm_platform: str) -> str: + """Return the npm packument URL for the platform runtime package.""" + import os + + base = os.environ.get("COPILOT_NPM_REGISTRY_URL", _NPM_REGISTRY_BASE_URL).rstrip("/") + return f"{base}/@github/copilot-{npm_platform}" + + +def get_runtime_lib_url(version: str, npm_platform: str) -> str: + """Return the download URL for the platform runtime tarball. + + Mirrors the .NET targets' URL layout + ``/@github/copilot-/-/copilot--.tgz``. + """ + import os + + base = os.environ.get("COPILOT_NPM_REGISTRY_URL", _NPM_REGISTRY_BASE_URL).rstrip("/") + return f"{base}/@github/copilot-{npm_platform}/-/copilot-{npm_platform}-{version}.tgz" diff --git a/python/copilot/_ffi_runtime_host.py b/python/copilot/_ffi_runtime_host.py new file mode 100644 index 0000000000..e04d1655e6 --- /dev/null +++ b/python/copilot/_ffi_runtime_host.py @@ -0,0 +1,514 @@ +"""In-process (FFI) hosting of the Copilot runtime. + +Instead of spawning the Copilot CLI as a child process and talking JSON-RPC over +stdio/TCP, the in-process transport loads the runtime's native shared library +(``runtime.node`` — a Rust ``cdylib``) into this process and drives JSON-RPC over +its C ABI (FFI). The native ``host_start`` export spawns the residual worker +itself, so the SDK never launches the worker directly; it only pumps opaque LSP +``Content-Length:``-framed JSON-RPC bytes across the boundary: + +- client → server frames go to ``copilot_runtime_connection_write`` +- server → client frames arrive on a native callback that feeds a thread-safe + receive buffer + +The existing :class:`~copilot._jsonrpc.JsonRpcClient` handles framing unchanged — +this is a transport swap, not a new protocol. The host exposes a *process-like* +adapter (``stdin``/``stdout``/``stderr``/``poll``) so ``JsonRpcClient`` can drive +it exactly like a :class:`subprocess.Popen`. + +The C ABI (shared with the .NET, Node.js, and Rust SDKs):: + + uint32 copilot_runtime_host_start(uint8 *argv, size_t argv_len, + uint8 *env, size_t env_len); + bool copilot_runtime_host_shutdown(uint32 server_id); + uint32 copilot_runtime_connection_open(uint32 server_id, outbound cb, + void *user_data, + uint8 *a, size_t a_len, + uint8 *b, size_t b_len, + uint8 *c, size_t c_len); + bool copilot_runtime_connection_write(uint32 conn_id, + uint8 *bytes, size_t len); + bool copilot_runtime_connection_close(uint32 conn_id); + // outbound callback: + void outbound(void *user_data, uint8 *bytes, size_t len); +""" + +from __future__ import annotations + +import ctypes +import json +import logging +import os +import sys +import threading +import time +from collections.abc import Sequence +from pathlib import Path + +logger = logging.getLogger("copilot.ffi") + +_SYMBOL_PREFIX = "copilot_runtime_" + +# The C ABI outbound callback: void(void *user_data, uint8 *bytes, size_t len). +_OutboundCallback = ctypes.CFUNCTYPE( + None, ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t +) + + +def get_prebuilds_folder() -> str | None: + """Return the ``prebuilds/`` folder name for the current host. + + Matches the napi-rs ``-`` layout the runtime package + ships (e.g. ``linux-x64``, ``darwin-arm64``, ``win32-x64``), including the + musl (Alpine) variants. Returns ``None`` for unsupported platforms. + """ + if sys.platform.startswith("linux"): + platform_name = "linuxmusl" if _is_musl() else "linux" + elif sys.platform == "darwin": + platform_name = "darwin" + elif sys.platform == "win32": + platform_name = "win32" + else: + return None + + machine = _normalize_machine() + if machine is None: + return None + return f"{platform_name}-{machine}" + + +def _normalize_machine() -> str | None: + import platform + + machine = platform.machine().lower() + if machine in ("x86_64", "amd64", "x64"): + return "x64" + if machine in ("arm64", "aarch64"): + return "arm64" + return None + + +def _is_musl() -> bool: + """Detect whether the current Linux system uses musl libc (e.g. Alpine).""" + if sys.platform != "linux": + return False + try: + import subprocess + + result = subprocess.run(["ldd", "--version"], capture_output=True, text=True, timeout=5) + return "musl" in (result.stdout + result.stderr).lower() + except (FileNotFoundError, OSError, Exception): # noqa: BLE001 + return False + + +def _natural_library_name() -> str: + """The natural platform shared-library file name for the runtime cdylib. + + The ``.node`` file renamed to what a Rust ``cdylib`` would be called on this + OS. The library is loaded by absolute path, so the on-disk name is ours. + """ + if sys.platform == "win32": + return "copilot_runtime.dll" + if sys.platform == "darwin": + return "libcopilot_runtime.dylib" + return "libcopilot_runtime.so" + + +def resolve_library_path(cli_entrypoint: str) -> str | None: + """Resolve the native runtime library next to the given CLI entrypoint. + + Checks, in order: + + 1. The natural platform library name next to the CLI (bundled/flat layout, + what the Python download-at-first-use path writes). + 2. ``prebuilds//runtime.node`` next to the CLI (dev/package layout). + + Returns the absolute path, or ``None`` when neither exists. + """ + directory = Path(cli_entrypoint).resolve().parent + + flat = directory / _natural_library_name() + if flat.is_file(): + return str(flat) + + folder = get_prebuilds_folder() + if folder is not None: + prebuilt = directory / "prebuilds" / folder / "runtime.node" + if prebuilt.is_file(): + return str(prebuilt) + + return None + + +# The cdylib may only be loaded once per process; a second load of a *different* +# path is unsupported (matches the Node/Rust hosts). Guard it here. +_loaded_library: ctypes.CDLL | None = None +_loaded_library_path: str | None = None +_load_lock = threading.Lock() + + +class _FfiLibrary: + """Binds the ``copilot_runtime_*`` C ABI exports of a loaded cdylib.""" + + def __init__(self, lib: ctypes.CDLL) -> None: + self._lib = lib + + self.host_start = getattr(lib, f"{_SYMBOL_PREFIX}host_start") + self.host_start.argtypes = [ + ctypes.c_char_p, + ctypes.c_size_t, + ctypes.c_char_p, + ctypes.c_size_t, + ] + self.host_start.restype = ctypes.c_uint32 + + self.host_shutdown = getattr(lib, f"{_SYMBOL_PREFIX}host_shutdown") + self.host_shutdown.argtypes = [ctypes.c_uint32] + self.host_shutdown.restype = ctypes.c_bool + + self.connection_open = getattr(lib, f"{_SYMBOL_PREFIX}connection_open") + self.connection_open.argtypes = [ + ctypes.c_uint32, + _OutboundCallback, + ctypes.c_void_p, + ctypes.c_char_p, + ctypes.c_size_t, + ctypes.c_char_p, + ctypes.c_size_t, + ctypes.c_char_p, + ctypes.c_size_t, + ] + self.connection_open.restype = ctypes.c_uint32 + + self.connection_write = getattr(lib, f"{_SYMBOL_PREFIX}connection_write") + self.connection_write.argtypes = [ + ctypes.c_uint32, + ctypes.c_char_p, + ctypes.c_size_t, + ] + self.connection_write.restype = ctypes.c_bool + + self.connection_close = getattr(lib, f"{_SYMBOL_PREFIX}connection_close") + self.connection_close.argtypes = [ctypes.c_uint32] + self.connection_close.restype = ctypes.c_bool + + +def _load_library(library_path: str) -> _FfiLibrary: + global _loaded_library, _loaded_library_path + with _load_lock: + if _loaded_library is not None: + if _loaded_library_path != library_path: + raise RuntimeError( + f"An in-process FFI runtime library is already loaded from " + f"'{_loaded_library_path}'; loading a different library from " + f"'{library_path}' in the same process is not supported." + ) + return _FfiLibrary(_loaded_library) + + # Load with immediate binding (RTLD_NOW) on POSIX, matching the .NET/Rust + # hosts. The runtime cdylib from the npm platform package is self-contained; + # eager binding surfaces any load problem here rather than at first call. + if sys.platform == "win32": + lib = ctypes.WinDLL(library_path) + else: + lib = ctypes.CDLL(library_path, mode=os.RTLD_NOW | os.RTLD_LOCAL) + _loaded_library = lib + _loaded_library_path = library_path + return _FfiLibrary(lib) + + +class _ReceiveBuffer: + """Thread-safe byte buffer feeding blocking ``read(n)`` from a producer thread. + + The native outbound callback (invoked on a foreign runtime thread) appends + frames via :meth:`feed` without ever blocking; the JSON-RPC reader thread + drains them via :meth:`read`, which blocks until data or EOF. + """ + + def __init__(self) -> None: + self._buffer = bytearray() + self._closed = False + self._cond = threading.Condition() + + def feed(self, data: bytes) -> None: + with self._cond: + if self._closed: + return + self._buffer.extend(data) + self._cond.notify_all() + + def close(self) -> None: + with self._cond: + self._closed = True + self._cond.notify_all() + + def read(self, size: int) -> bytes: + if size <= 0: + return b"" + with self._cond: + while not self._buffer and not self._closed: + self._cond.wait() + if not self._buffer: + return b"" # EOF + chunk = bytes(self._buffer[:size]) + del self._buffer[:size] + return chunk + + def readline(self) -> bytes: + """Read through the next ``\\n`` (inclusive), blocking until available. + + Returns whatever remains (possibly without a trailing newline) at EOF, or + ``b""`` if the buffer is empty and closed. Mirrors the blocking + ``BufferedReader.readline`` semantics :class:`JsonRpcClient` expects when + parsing LSP ``Content-Length:`` headers. + """ + with self._cond: + while b"\n" not in self._buffer and not self._closed: + self._cond.wait() + newline_index = self._buffer.find(b"\n") + if newline_index == -1: + # EOF with no newline: return the remaining bytes (may be empty). + line = bytes(self._buffer) + self._buffer.clear() + return line + end = newline_index + 1 + line = bytes(self._buffer[:end]) + del self._buffer[:end] + return line + + +class _FfiStdin: + """Writable side of the process-like adapter; forwards frames to the runtime.""" + + def __init__(self, host: FfiRuntimeHost) -> None: + self._host = host + + def write(self, data: bytes) -> int: + self._host._write_frame(data) + return len(data) + + def flush(self) -> None: + # connection_write enqueues synchronously, so there is nothing to flush. + pass + + +class _FfiProcessAdapter: + """A ``subprocess.Popen``-shaped view over an :class:`FfiRuntimeHost`. + + :class:`~copilot._jsonrpc.JsonRpcClient` only needs ``stdin`` (writable), + ``stdout`` (blocking ``read``), an optional ``stderr``, and ``poll()``. The + in-process transport has no OS pipes, so this adapter bridges those to the + FFI host's frame plumbing. + """ + + def __init__(self, host: FfiRuntimeHost) -> None: + self._host = host + self.stdin = _FfiStdin(host) + self.stdout = host._receive_buffer + # No separate error stream in-process; JsonRpcClient skips the stderr + # thread when this is falsy. + self.stderr = None + + def poll(self) -> int | None: + """Return ``None`` while the connection is live, ``0`` once closed.""" + return None if not self._host._disposed else 0 + + def terminate(self) -> None: + self._host.dispose() + + def kill(self) -> None: + self._host.dispose() + + def wait(self, timeout: float | None = None) -> int: # noqa: ARG002 + self._host.dispose() + return 0 + + +class FfiRuntimeHost: + """Hosts the Copilot runtime in-process via its native C ABI. + + Construct with :meth:`create`, then :meth:`start` to spawn the worker and open + the FFI connection. Expose :attr:`process` to :class:`JsonRpcClient`, and call + :meth:`dispose` to tear everything down. + """ + + def __init__( + self, + library_path: str, + cli_entrypoint: str, + environment: dict[str, str] | None = None, + args: Sequence[str] = (), + ) -> None: + self._library_path = library_path + self._cli_entrypoint = cli_entrypoint + self._environment = environment + self._extra_args = list(args) + self._lib = _load_library(library_path) + + self._server_id = 0 + self._connection_id = 0 + self._disposed = False + self._dispose_lock = threading.Lock() + + self._receive_buffer = _ReceiveBuffer() + # Keep a strong reference to the ctypes callback for its whole lifetime; + # dropping it while native code can still invoke it is a use-after-free. + self._outbound_callback: ctypes._FuncPointer | None = None + # Serializes teardown against in-flight native callbacks. + self._active_callbacks = 0 + self._callback_lock = threading.Lock() + + self._process = _FfiProcessAdapter(self) + + @property + def process(self) -> _FfiProcessAdapter: + """The ``subprocess.Popen``-shaped adapter for :class:`JsonRpcClient`.""" + return self._process + + @staticmethod + def create( + cli_entrypoint: str, + environment: dict[str, str] | None = None, + args: Sequence[str] = (), + ) -> FfiRuntimeHost: + """Resolve the cdylib next to the CLI entrypoint and prepare the host. + + Raises: + RuntimeError: If the native runtime library cannot be found. + """ + full_entrypoint = str(Path(cli_entrypoint).resolve()) + library_path = resolve_library_path(full_entrypoint) + if library_path is None: + raise RuntimeError( + "In-process FFI runtime library not found next to " + f"'{full_entrypoint}'. Download it with " + "`python -m copilot download-runtime --in-process`, or set " + "COPILOT_CLI_PATH to a runtime package that ships it." + ) + return FfiRuntimeHost(library_path, full_entrypoint, environment, args) + + def _build_argv(self) -> bytes: + # A `.js` entrypoint (dev) is launched via node; the packaged single-file + # CLI embeds its own Node and is invoked directly. `--no-auto-update` + # pins the worker to the runtime package matching the loaded cdylib. + if self._cli_entrypoint.lower().endswith(".js"): + argv = ["node", self._cli_entrypoint, "--embedded-host", "--no-auto-update"] + else: + argv = [self._cli_entrypoint, "--embedded-host", "--no-auto-update"] + argv.extend(self._extra_args) + return json.dumps(argv).encode("utf-8") + + def _build_env(self) -> bytes | None: + if not self._environment: + return None + obj = {k: v for k, v in self._environment.items() if v is not None} + if not obj: + return None + return json.dumps(obj).encode("utf-8") + + def start_blocking(self) -> None: + """Spawn the worker and open the FFI connection (blocks up to ~30s). + + Must be run off the event loop (e.g. via :func:`asyncio.to_thread`); + ``host_start`` blocks until the worker connects back and signals + readiness. + """ + argv = self._build_argv() + env = self._build_env() + + self._server_id = self._lib.host_start(argv, len(argv), env, len(env) if env else 0) + if not self._server_id: + raise RuntimeError( + f"copilot_runtime_host_start failed (library '{self._library_path}', " + f"entrypoint '{self._cli_entrypoint}')." + ) + + self._outbound_callback = _OutboundCallback(self._on_outbound) + self._connection_id = self._lib.connection_open( + self._server_id, + self._outbound_callback, + None, + None, + 0, + None, + 0, + None, + 0, + ) + if not self._connection_id: + self._outbound_callback = None + self._lib.host_shutdown(self._server_id) + self._server_id = 0 + raise RuntimeError("copilot_runtime_connection_open failed.") + + def _on_outbound( + self, + _user_data: int | None, + bytes_ptr: ctypes._Pointer, + bytes_len: int, + ) -> None: + """Native server → client callback (invoked on a foreign runtime thread). + + The native pointer is only valid for this call, so the bytes are copied + out before returning. Exceptions must not cross the FFI boundary, so + everything is caught and logged. + """ + with self._callback_lock: + if self._disposed: + return + self._active_callbacks += 1 + try: + if bytes_ptr and bytes_len > 0: + data = ctypes.string_at(bytes_ptr, bytes_len) + self._receive_buffer.feed(data) + except Exception: # noqa: BLE001 + logger.error("In-process FFI inbound callback failed", exc_info=True) + finally: + with self._callback_lock: + self._active_callbacks -= 1 + + def _write_frame(self, frame: bytes) -> None: + if self._disposed or not self._connection_id: + raise RuntimeError("The in-process runtime connection is closed.") + ok = self._lib.connection_write(self._connection_id, frame, len(frame)) + if not ok: + raise RuntimeError("Failed to write a frame to the in-process runtime connection.") + + def dispose(self) -> None: + """Close the FFI connection, shut down the native host, release resources. + + Idempotent. Waits for any in-flight outbound callback to finish before + dropping the callback reference to avoid a use-after-free. + """ + with self._dispose_lock: + if self._disposed: + return + self._disposed = True + + # Stop accepting new callbacks and wait for in-flight ones to drain. + with self._callback_lock: + pass # _disposed is set; new callbacks bail out immediately. + while True: + with self._callback_lock: + if self._active_callbacks == 0: + break + time.sleep(0.001) + + try: + if self._connection_id: + self._lib.connection_close(self._connection_id) + self._connection_id = 0 + except Exception: # noqa: BLE001 + logger.debug("Error closing in-process FFI connection", exc_info=True) + + try: + if self._server_id: + self._lib.host_shutdown(self._server_id) + self._server_id = 0 + except Exception: # noqa: BLE001 + logger.debug("Error shutting down in-process FFI host", exc_info=True) + + self._receive_buffer.close() + # Safe to drop now: no native code can invoke the callback after + # connection_close, and all in-flight callbacks have drained. + self._outbound_callback = None diff --git a/python/copilot/canvas.py b/python/copilot/canvas.py index ddbc8539a2..9b8dec5258 100644 --- a/python/copilot/canvas.py +++ b/python/copilot/canvas.py @@ -39,6 +39,7 @@ "CanvasHostContext", "CanvasHostContextCapabilities", "CanvasJsonSchema", + "CanvasProviderIdentity", "ExtensionInfo", "OpenCanvasInstance", ] @@ -66,6 +67,33 @@ def to_dict(self) -> dict[str, Any]: return {"source": self.source, "name": self.name} +@dataclass +class CanvasProviderIdentity: + """Stable identity for a host/SDK connection that supplies built-in canvases. + + Lets a host advertise a stable canvas-provider extension id so host-provided + canvases restore across a cold session resume. Serializes to + ``{"id": ...}`` (with an optional ``"name"``) on the wire. + + .. note:: + + **Experimental.** This type is part of an experimental wire-protocol + surface and may change or be removed in future SDK or CLI releases. + """ + + id: str + """Stable provider identifier, e.g. ``"app:builtin:window-1"``.""" + + name: str | None = None + """Optional human-readable provider name.""" + + def to_dict(self) -> dict[str, Any]: + result: dict[str, Any] = {"id": self.id} + if self.name is not None: + result["name"] = self.name + return result + + @dataclass class CanvasDeclaration: """Declarative metadata for a single canvas, sent on create/resume. diff --git a/python/copilot/client.py b/python/copilot/client.py index 55d01c5b57..bb0d486d8b 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -32,6 +32,7 @@ from typing import Any, ClassVar, Literal, TypedDict, cast, overload from ._diagnostics import log_timing +from ._ffi_runtime_host import FfiRuntimeHost from ._jsonrpc import JsonRpcClient, JsonRpcError, ProcessExitedError from ._mode import ( CopilotClientMode, @@ -58,6 +59,7 @@ from .canvas import ( CanvasDeclaration, CanvasHandler, + CanvasProviderIdentity, ExtensionInfo, ) from .copilot_request_handler import CopilotRequestHandler, create_copilot_request_adapter @@ -71,6 +73,8 @@ RemoteSessionMode, ServerRpc, _ConnectResult, + _HookInvokeRequest, + _HookInvokeResponse, from_datetime, register_client_global_api_handlers, register_client_session_api_handlers, @@ -106,6 +110,7 @@ SessionHooks, SessionLimitsConfig, SystemMessageConfig, + ToolSearchConfig, UserInputHandler, _capabilities_to_dict, _PermissionHandlerFn, @@ -254,6 +259,16 @@ def _session_limits_to_wire(config: Mapping[str, Any]) -> dict[str, Any]: return wire +def _tool_search_to_wire(config: Mapping[str, Any]) -> dict[str, Any]: + """Convert a ``ToolSearchConfig`` mapping to wire format.""" + wire: dict[str, Any] = {} + if "enabled" in config: + wire["enabled"] = config["enabled"] + if "defer_threshold" in config: + wire["deferThreshold"] = config["defer_threshold"] + return wire + + class TelemetryConfig(TypedDict, total=False): """Configuration for OpenTelemetry integration with the Copilot CLI.""" @@ -348,6 +363,33 @@ def for_uri(url: str, *, connection_token: str | None = None) -> UriRuntimeConne """ return UriRuntimeConnection(url=url, connection_token=connection_token) + @staticmethod + def for_inprocess() -> InProcessRuntimeConnection: + """Host the runtime **in-process** via its native C ABI (FFI). + + **Experimental.** The in-process (FFI) transport is experimental and its + behavior may change or be removed in a future release. + + Instead of spawning the runtime as a child process, the SDK loads the + runtime's native shared library into this process and drives JSON-RPC + over its C ABI. + + Because the runtime loads into this single shared process, per-client + options that lower to environment variables or a working directory + cannot be honored: :attr:`CopilotClientOptions.env`, + :attr:`CopilotClientOptions.telemetry`, and + :attr:`CopilotClientOptions.working_directory` are rejected with this + transport. Set those on the host process before creating the client. + Set ``COPILOT_CLI_PATH`` only when using an externally provisioned + compatible runtime package. + + Note: + Pre-provision the native runtime with + ``python -m copilot download-runtime --in-process`` when automatic + downloads are disabled. + """ + return InProcessRuntimeConnection() + @dataclass class ChildProcessRuntimeConnection(RuntimeConnection): @@ -362,6 +404,13 @@ class ChildProcessRuntimeConnection(RuntimeConnection): args: Sequence[str] = () """Extra command-line arguments passed to the runtime process.""" + env: dict[str, str] | None = None + """Per-connection environment variables for the spawned child process. + + When set, do not also set :attr:`CopilotClientOptions.env` — the client + rejects setting environment in both places. ``None`` inherits the + client-level env (or the current process env).""" + @dataclass class StdioRuntimeConnection(ChildProcessRuntimeConnection): @@ -399,6 +448,19 @@ class UriRuntimeConnection(RuntimeConnection): """Shared secret to authenticate the connection.""" +@dataclass +class InProcessRuntimeConnection(RuntimeConnection): + """Hosts the runtime in-process via its native C ABI (FFI). + + **Experimental.** The in-process (FFI) transport is experimental and its + behavior may change or be removed in a future release. + + Construct via :meth:`RuntimeConnection.for_inprocess`. The runtime's native + shared library is loaded into this process and JSON-RPC is driven over its + C ABI. + """ + + class _GitHubTelemetryAdapter: """Adapts a user-provided ``on_github_telemetry`` callback to the generated ``GitHubTelemetryHandler`` protocol. @@ -419,6 +481,25 @@ async def event(self, params: GitHubTelemetryNotification) -> None: logger.warning("Error handling gitHubTelemetry.event notification", exc_info=True) +class _HooksAdapter: + """Adapts session-scoped hook dispatch to the generated ``HooksHandler`` protocol. + + ``hooks.invoke`` is a client-global RPC method whose payload carries a + ``sessionId``. This adapter routes each invocation to the matching session's + registered hook handlers. + """ + + def __init__(self, get_session: Callable[[str], CopilotSession | None]) -> None: + self._get_session = get_session + + async def invoke(self, params: _HookInvokeRequest) -> _HookInvokeResponse: + session = self._get_session(params.session_id) + if session is None: + raise ValueError(f"unknown session {params.session_id}") + output = await session._handle_hooks_invoke(params.hook_type.value, params.input) + return _HookInvokeResponse(output=output) + + @dataclass class _CopilotClientOptions: """Internal configuration carrier used by :class:`CopilotClient`. @@ -1036,15 +1117,23 @@ def _session_lifecycle_event_from_dict(data: dict) -> SessionLifecycleEvent: _CLI_PROCESS_EXIT_TIMEOUT_SECONDS = 5 -def _get_or_download_cli() -> str | None: +def _get_or_download_cli(*, include_runtime_lib: bool = False) -> str | None: """Get the cached CLI binary, downloading if necessary. Returns the path to the CLI binary, or None if unavailable (dev install with no pinned version, or auto-download disabled). + + When ``include_runtime_lib`` is set, also ensures the native in-process FFI + runtime is available (downloading it on first use). """ from ._cli_download import get_or_download_cli - return get_or_download_cli() + cli_path = get_or_download_cli() + if cli_path and include_runtime_lib: + from ._cli_download import ensure_runtime_library + + ensure_runtime_library(cli_path) + return cli_path def _extract_transform_callbacks( @@ -1082,6 +1171,78 @@ def _extract_transform_callbacks( return wire_payload, callbacks +_DEFAULT_CONNECTION_ENV_VAR = "COPILOT_SDK_DEFAULT_CONNECTION" + + +def _resolve_default_connection(env: Mapping[str, str]) -> RuntimeConnection: + """Resolve the transport when the caller supplies no explicit connection. + + Honors the ``COPILOT_SDK_DEFAULT_CONNECTION`` override (``"inprocess"`` or + ``"stdio"``); defaults to stdio. Matches the Node/.NET/Rust default-transport + override so the CI matrix can run the whole suite under either transport. + """ + value = env.get(_DEFAULT_CONNECTION_ENV_VAR) + if value is None or value == "": + return RuntimeConnection.for_stdio() + normalized = value.strip().lower() + if normalized == "inprocess": + return RuntimeConnection.for_inprocess() + if normalized == "stdio": + return RuntimeConnection.for_stdio() + raise ValueError( + f"Invalid {_DEFAULT_CONNECTION_ENV_VAR}={value!r}. Expected 'inprocess', 'stdio', or unset." + ) + + +def _validate_environment_options( + options: _CopilotClientOptions, connection: RuntimeConnection +) -> None: + """Validate env/telemetry/working-directory options against the transport. + + Per-client environment is only representable for child-process transports + (each client owns its own OS process). The in-process (FFI) transport loads + the native runtime into the shared host process, whose single environment + block and process-global working directory cannot carry per-client values, + so options that lower to them are rejected there (fail loud, not silent). + """ + if isinstance(connection, InProcessRuntimeConnection): + if options.env is not None: + raise ValueError( + "env is not supported with RuntimeConnection.for_inprocess(): the " + "in-process transport loads the native runtime into the shared host " + "process, whose single environment block cannot carry per-client " + "values. Set the variables on the host process environment instead." + ) + if options.telemetry is not None: + raise ValueError( + "telemetry is not supported with RuntimeConnection.for_inprocess(): " + "telemetry configuration is lowered to environment variables read by " + "native runtime code running in the shared host process, so per-client " + "telemetry cannot be honored in-process. Configure telemetry via the " + "host process environment, or use a child-process transport." + ) + if options.working_directory is not None: + raise ValueError( + "working_directory is not supported with RuntimeConnection.for_inprocess(): " + "the native runtime shares the host process working directory, so a " + "per-client working directory cannot be honored in-process. Use a " + "child-process " + "transport, or set the process working directory before creating the client." + ) + return + + if ( + isinstance(connection, ChildProcessRuntimeConnection) + and connection.env is not None + and options.env is not None + ): + raise ValueError( + "Set environment variables via either the client-level env argument or " + "ChildProcessRuntimeConnection.env, not both. Prefer the connection-level " + "env for child-process transports." + ) + + class CopilotClient: """ Main client for interacting with the Copilot CLI. @@ -1139,10 +1300,12 @@ def __init__( """ Initialize a new CopilotClient. - All process-management options (``working_directory``, ``log_level``, - ``env``, ``github_token``, …) apply only when the SDK spawns the runtime - (stdio / tcp connections). They are ignored when connecting to an - existing runtime via :meth:`RuntimeConnection.for_uri`. + Runtime options apply to locally hosted connections. The in-process + transport supports typed runtime options such as ``log_level``, + ``github_token``, and ``base_directory``, but rejects per-client + ``working_directory``, ``env``, and ``telemetry``. Options are ignored + when connecting to an existing runtime via + :meth:`RuntimeConnection.for_uri`. Args: connection: How to reach the runtime. Defaults to @@ -1216,8 +1379,11 @@ def __init__( mode=mode, ) connection = ( - options.connection if options.connection is not None else RuntimeConnection.for_stdio() + options.connection + if options.connection is not None + else _resolve_default_connection(os.environ) ) + _validate_environment_options(options, connection) _require_storage_for_empty_mode( mode=options.mode, base_directory=options.base_directory, @@ -1233,6 +1399,9 @@ def __init__( # Resolve connection-mode-specific state. self._actual_host: str = "localhost" self._is_external_server: bool = isinstance(connection, UriRuntimeConnection) + self._cli_path_source: str | None = None + self._ffi_host: FfiRuntimeHost | None = None + self._inprocess_runtime_path: str | None = None if isinstance(connection, UriRuntimeConnection): if connection.connection_token is not None and len(connection.connection_token) == 0: @@ -1240,6 +1409,15 @@ def __init__( self._actual_host, actual_port = self._parse_cli_url(connection.url) self._runtime_port: int | None = actual_port self._effective_connection_token: str | None = connection.connection_token + elif isinstance(connection, InProcessRuntimeConnection): + # In-process (FFI): no child process and no per-connection token. + self._runtime_port = None + self._effective_connection_token = None + self._inprocess_runtime_path = self._resolve_runtime_entrypoint( + None, include_runtime_lib=True + ) + if options.use_logged_in_user is None: + options.use_logged_in_user = not bool(options.github_token) else: assert isinstance(connection, ChildProcessRuntimeConnection) self._runtime_port = None @@ -1259,26 +1437,17 @@ def __init__( self._effective_connection_token = None # Resolve CLI path: explicit > COPILOT_CLI_PATH env var > downloaded binary. - effective_env = options.env if options.env is not None else os.environ - self._cli_path_source: str | None = "explicit" - if connection.path is None: - env_cli_path = effective_env.get("COPILOT_CLI_PATH") - if env_cli_path: - connection.path = env_cli_path - self._cli_path_source = "environment" - else: - downloaded_path = _get_or_download_cli() - if downloaded_path: - connection.path = downloaded_path - self._cli_path_source = "downloaded" - else: - raise RuntimeError( - "Copilot CLI not found. Install a published wheel (which " - "auto-downloads the CLI on first use), set COPILOT_CLI_PATH, " - "or pass an explicit path via " - "RuntimeConnection.for_stdio(path=...) / " - "RuntimeConnection.for_tcp(path=...)." - ) + # Select the environment by identity, not truthiness, so an intentionally + # empty per-connection or client env stays authoritative (the spawned child + # receives that empty mapping) instead of falling back to os.environ and + # unexpectedly honoring a host COPILOT_CLI_PATH. + if connection.env is not None: + effective_env: Mapping[str, str] = connection.env + elif options.env is not None: + effective_env = options.env + else: + effective_env = os.environ + connection.path = self._resolve_runtime_entrypoint(connection.path, env=effective_env) # Resolve use_logged_in_user default if options.use_logged_in_user is None: @@ -1304,6 +1473,58 @@ def __init__( self._session_fs_config = options.session_fs self._request_handler = options.request_handler + def _resolve_runtime_entrypoint( + self, + path: str | None, + *, + env: Mapping[str, str] | None = None, + include_runtime_lib: bool = False, + ) -> str: + """Resolve the runtime executable path (explicit > env > downloaded). + + Sets ``self._cli_path_source`` for diagnostics. When + ``include_runtime_lib`` is set (in-process transport), also ensures the + native runtime library is downloaded alongside the CLI. + + Raises: + RuntimeError: If no runtime path can be resolved. + """ + if path is not None: + self._cli_path_source = "explicit" + return self._ensure_runtime_lib(path) if include_runtime_lib else path + + lookup = env if env is not None else os.environ + env_cli_path = lookup.get("COPILOT_CLI_PATH") + if env_cli_path: + self._cli_path_source = "environment" + return self._ensure_runtime_lib(env_cli_path) if include_runtime_lib else env_cli_path + + downloaded_path = _get_or_download_cli(include_runtime_lib=include_runtime_lib) + if downloaded_path: + self._cli_path_source = "downloaded" + return downloaded_path + + raise RuntimeError( + "Copilot CLI not found. Install a published wheel (which " + "auto-downloads the CLI on first use), set COPILOT_CLI_PATH, " + "or pass an explicit path via " + "RuntimeConnection.for_stdio(path=...) / " + "RuntimeConnection.for_tcp(path=...)." + ) + + @staticmethod + def _ensure_runtime_lib(cli_path: str) -> str: + """Ensure the in-process runtime library sits next to a user-supplied CLI. + + For explicit/``COPILOT_CLI_PATH`` entrypoints, the native library may + already be bundled (dev ``prebuilds`` layout); otherwise it is fetched on + first use. Returns ``cli_path`` unchanged. + """ + from ._cli_download import ensure_runtime_library + + ensure_runtime_library(cli_path) + return cli_path + @property def rpc(self) -> ServerRpc: """Typed server-scoped RPC methods.""" @@ -1542,7 +1763,11 @@ async def stop(self) -> None: StopError(message=f"Failed to disconnect session {session.session_id}: {e}") ) - if self._rpc is not None and self._cli_process is not None and not self._is_external_server: + if ( + self._rpc is not None + and (self._cli_process is not None or self._ffi_host is not None) + and not self._is_external_server + ): runtime_shutdown_start = time.perf_counter() try: await self._rpc.runtime.shutdown(timeout=_RUNTIME_SHUTDOWN_TIMEOUT_SECONDS) @@ -1572,6 +1797,15 @@ async def stop(self) -> None: async with self._models_cache_lock: self._models_cache = None + # Dispose the in-process FFI host and release the loaded native library. + if self._ffi_host is not None: + try: + self._ffi_host.dispose() + except Exception: + logger.debug("Error while disposing in-process FFI host", exc_info=True) + self._ffi_host = None + self._process = None + # Close TCP socket wrappers without treating them as owned processes. if self._process is not None and self._process is not self._cli_process: try: @@ -1665,6 +1899,15 @@ async def force_stop(self) -> None: except Exception: logger.debug("Error while force-stopping Copilot CLI process", exc_info=True) + # Force-dispose the in-process FFI host before tearing down JSON-RPC. + if self._ffi_host is not None: + try: + self._ffi_host.dispose() + except Exception: + logger.debug("Error while force-disposing in-process FFI host", exc_info=True) + self._ffi_host = None + self._process = None + # Then clean up the JSON-RPC client if self._client: try: @@ -1696,6 +1939,7 @@ async def create_session( context_tier: ContextTier | None = None, tools: list[Tool] | None = None, system_message: SystemMessageConfig | None = None, + tool_search: ToolSearchConfig | None = None, available_tools: list[str] | ToolSet | None = None, excluded_tools: list[str] | ToolSet | None = None, on_user_input_request: UserInputHandler | None = None, @@ -1754,8 +1998,10 @@ async def create_session( request_extensions: bool | None = None, extension_sdk_path: str | None = None, extension_info: ExtensionInfo | None = None, + canvas_provider: CanvasProviderIdentity | None = None, canvas_handler: CanvasHandler | None = None, exp_assignments: dict[str, Any] | None = None, + enable_managed_settings: bool | None = None, ) -> CopilotSession: """ Create a new conversation session with the Copilot CLI. @@ -1887,6 +2133,13 @@ async def create_session( malformed payloads are dropped by the runtime (fail-open). This is an internal/trusted-integrator option. Sent on the wire as ``expAssignments``. + enable_managed_settings: Opt-in flag. When ``True``, the runtime + self-fetches enterprise managed settings (bypass-permissions + policy) at session bootstrap using the session's ``github_token``. + Requires ``github_token`` to be set; if omitted, the runtime is + expected to reject session creation (fail-closed). When unset, + behaves exactly as before. Sent on the wire as + ``enableManagedSettings``. Returns: A :class:`CopilotSession` instance for the new session. @@ -1926,6 +2179,8 @@ async def create_session( definition["skipPermission"] = True if tool.defer is not None: definition["defer"] = tool.defer + if tool.metadata is not None: + definition["metadata"] = tool.metadata tool_defs.append(definition) # Empty-mode validation and normalization @@ -1970,6 +2225,9 @@ async def create_session( if wire_system_message: payload["systemMessage"] = wire_system_message + if tool_search is not None: + payload["toolSearch"] = _tool_search_to_wire(tool_search) + if available_tools is not None: payload["availableTools"] = available_tools if excluded_tools is not None: @@ -2018,6 +2276,10 @@ async def create_session( if exp_assignments is not None: payload["expAssignments"] = exp_assignments + # Opt the runtime into self-fetching enterprise managed settings + if enable_managed_settings is not None: + payload["enableManagedSettings"] = enable_managed_settings + # Add working directory if provided if working_directory: payload["workingDirectory"] = working_directory @@ -2160,6 +2422,8 @@ async def create_session( payload["extensionSdkPath"] = extension_sdk_path if extension_info is not None: payload["extensionInfo"] = extension_info.to_dict() + if canvas_provider is not None: + payload["canvasProvider"] = canvas_provider.to_dict() if not self._client: raise RuntimeError("Client not connected") @@ -2347,6 +2611,7 @@ async def resume_session( context_tier: ContextTier | None = None, tools: list[Tool] | None = None, system_message: SystemMessageConfig | None = None, + tool_search: ToolSearchConfig | None = None, available_tools: list[str] | ToolSet | None = None, excluded_tools: list[str] | ToolSet | None = None, on_user_input_request: UserInputHandler | None = None, @@ -2405,9 +2670,11 @@ async def resume_session( request_extensions: bool | None = None, extension_sdk_path: str | None = None, extension_info: ExtensionInfo | None = None, + canvas_provider: CanvasProviderIdentity | None = None, canvas_handler: CanvasHandler | None = None, open_canvases: list[OpenCanvasInstance] | None = None, exp_assignments: dict[str, Any] | None = None, + enable_managed_settings: bool | None = None, ) -> CopilotSession: """ Resume an existing conversation session by its ID. @@ -2540,6 +2807,13 @@ async def resume_session( malformed payloads are dropped by the runtime (fail-open). This is an internal/trusted-integrator option. Sent on the wire as ``expAssignments``. + enable_managed_settings: Opt-in flag. When ``True``, the runtime + self-fetches enterprise managed settings (bypass-permissions + policy) at session bootstrap using the session's ``github_token``. + Requires ``github_token`` to be set; if omitted, the runtime is + expected to reject session creation (fail-closed). When unset, + behaves exactly as before. Sent on the wire as + ``enableManagedSettings``. Returns: A :class:`CopilotSession` instance for the resumed session. @@ -2581,6 +2855,8 @@ async def resume_session( definition["skipPermission"] = True if tool.defer is not None: definition["defer"] = tool.defer + if tool.metadata is not None: + definition["metadata"] = tool.metadata tool_defs.append(definition) # Empty-mode validation and normalization @@ -2621,6 +2897,8 @@ async def resume_session( wire_system_message, transform_callbacks = _extract_transform_callbacks(system_message) if wire_system_message: payload["systemMessage"] = wire_system_message + if tool_search is not None: + payload["toolSearch"] = _tool_search_to_wire(tool_search) if available_tools is not None: payload["availableTools"] = available_tools if excluded_tools is not None: @@ -2695,6 +2973,10 @@ async def resume_session( if exp_assignments is not None: payload["expAssignments"] = exp_assignments + # Opt the runtime into self-fetching enterprise managed settings + if enable_managed_settings is not None: + payload["enableManagedSettings"] = enable_managed_settings + if working_directory: payload["workingDirectory"] = working_directory if config_directory: @@ -2783,6 +3065,8 @@ async def resume_session( payload["extensionSdkPath"] = extension_sdk_path if extension_info is not None: payload["extensionInfo"] = extension_info.to_dict() + if canvas_provider is not None: + payload["canvasProvider"] = canvas_provider.to_dict() if not self._client: raise RuntimeError("Client not connected") @@ -3500,11 +3784,15 @@ async def _start_cli_server(self) -> None: """Start the runtime process. This spawns the runtime as a subprocess using the configured transport - mode (stdio or TCP). + mode (stdio or TCP), or hosts it in-process for the FFI transport. Raises: RuntimeError: If the server fails to start or times out. """ + if isinstance(self._connection, InProcessRuntimeConnection): + await self._start_inprocess_ffi() + return + assert isinstance(self._connection, ChildProcessRuntimeConnection) conn = self._connection opts = self._options @@ -3557,8 +3845,13 @@ async def _start_cli_server(self) -> None: }, ) - # Get environment variables - if opts.env is None: + # Get environment variables. Per-connection env (ChildProcessRuntimeConnection.env) + # takes precedence over the client-level env; the constructor already rejects + # setting both. When neither is set, inherit the current process environment. + conn_env = conn.env if isinstance(conn, ChildProcessRuntimeConnection) else None + if conn_env is not None: + env = dict(conn_env) + elif opts.env is None: env = dict(os.environ) else: env = dict(opts.env) @@ -3673,6 +3966,69 @@ async def read_port(): except TimeoutError: raise RuntimeError("Timeout waiting for CLI server to start") + async def _start_inprocess_ffi(self) -> None: + """Host the runtime in-process via the native FFI library. + + Loads the native runtime library and opens the FFI JSON-RPC connection. + + Raises: + RuntimeError: If the native library is missing or startup fails. + """ + assert isinstance(self._connection, InProcessRuntimeConnection) + runtime_path = self._inprocess_runtime_path + assert runtime_path is not None # resolved in __init__ + + logger.info( + "CopilotClient._start_inprocess_ffi hosting Copilot runtime in-process", + extra={"runtime_path": runtime_path, "runtime_path_source": self._cli_path_source}, + ) + + opts = self._options + args: list[str] = [] + if opts.log_level: + args.extend(["--log-level", opts.log_level]) + if opts.github_token: + args.extend(["--auth-token-env", "COPILOT_SDK_AUTH_TOKEN"]) + if not opts.use_logged_in_user: + args.append("--no-auto-login") + if opts.session_idle_timeout_seconds is not None and opts.session_idle_timeout_seconds > 0: + args.extend(["--session-idle-timeout", str(opts.session_idle_timeout_seconds)]) + if opts.enable_remote_sessions: + args.append("--remote") + + environment: dict[str, str] = {} + if opts.github_token: + environment["COPILOT_SDK_AUTH_TOKEN"] = opts.github_token + if opts.base_directory: + environment["COPILOT_HOME"] = opts.base_directory + if opts.mode == "empty": + environment["COPILOT_DISABLE_KEYTAR"] = "1" + + host = FfiRuntimeHost.create( + runtime_path, + environment=environment or None, + args=tuple(args), + ) + + # Track the host and expose its process-like adapter *before* the blocking + # handshake. asyncio.to_thread keeps running host_start after a cancellation + # (a thread can't be interrupted), and CancelledError bypasses start()'s + # `except Exception`, so assigning here — as .NET does before StartAsync — + # keeps a completed native host owned so stop()/force_stop() can dispose it + # instead of leaking it. + self._ffi_host = host + self._process = host.process + + ffi_start = time.perf_counter() + # Native startup may block, so run the handshake off the event loop. + await asyncio.to_thread(host.start_blocking) + log_timing( + logger, + logging.DEBUG, + "CopilotClient._start_inprocess_ffi FFI host started", + ffi_start, + ) + async def _connect_to_server(self) -> None: """Connect to the runtime via the configured transport. @@ -3682,7 +4038,9 @@ async def _connect_to_server(self) -> None: RuntimeError: If the connection fails. """ setup_start = time.perf_counter() - if isinstance(self._connection, StdioRuntimeConnection): + if isinstance(self._connection, (StdioRuntimeConnection, InProcessRuntimeConnection)): + # The in-process FFI host exposes a process-like adapter (stdin/stdout), + # so the same stdio JSON-RPC wiring drives it unchanged. await self._connect_via_stdio() else: await self._connect_via_tcp() @@ -3735,7 +4093,6 @@ def handle_notification(method: str, params: dict): self._client.set_request_handler( "autoModeSwitch.request", self._handle_auto_mode_switch_request ) - self._client.set_request_handler("hooks.invoke", self._handle_hooks_invoke) self._client.set_request_handler( "systemMessage.transform", self._handle_system_message_transform ) @@ -3855,7 +4212,6 @@ def handle_notification(method: str, params: dict): self._client.set_request_handler( "autoModeSwitch.request", self._handle_auto_mode_switch_request ) - self._client.set_request_handler("hooks.invoke", self._handle_hooks_invoke) self._client.set_request_handler( "systemMessage.transform", self._handle_system_message_transform ) @@ -3945,16 +4301,19 @@ def _register_client_global_handlers(self) -> None: github_telemetry_adapter = None if self._on_github_telemetry is not None: github_telemetry_adapter = _GitHubTelemetryAdapter(self._on_github_telemetry) - if llm_inference_adapter is None and github_telemetry_adapter is None: - return register_client_global_api_handlers( self._client, ClientGlobalApiHandlers( + hooks=_HooksAdapter(self._get_session), llm_inference=llm_inference_adapter, git_hub_telemetry=github_telemetry_adapter, ), ) + def _get_session(self, session_id: str) -> CopilotSession | None: + with self._sessions_lock: + return self._sessions.get(session_id) + async def _set_llm_inference_provider(self) -> None: if self._request_handler is None or self._rpc is None: return @@ -4027,34 +4386,6 @@ async def _handle_auto_mode_switch_request(self, params: dict) -> dict: response = await session._handle_auto_mode_switch_request(params) return {"response": response} - async def _handle_hooks_invoke(self, params: dict) -> dict: - """ - Handle a hooks invocation from the CLI server. - - Args: - params: The hooks invocation parameters from the server. - - Returns: - A dict containing the hook output. - - Raises: - ValueError: If the request payload is invalid. - """ - session_id = params.get("sessionId") - hook_type = params.get("hookType") - input_data = params.get("input") - - if not session_id or not hook_type: - raise ValueError("invalid hooks invoke payload") - - with self._sessions_lock: - session = self._sessions.get(session_id) - if not session: - raise ValueError(f"unknown session {session_id}") - - output = await session._handle_hooks_invoke(hook_type, input_data) - return {"output": output} - async def _handle_system_message_transform(self, params: dict) -> dict: """Handle a systemMessage.transform request from the CLI server.""" session_id = params.get("sessionId") diff --git a/python/copilot/copilot_request_handler.py b/python/copilot/copilot_request_handler.py index 54e71027c9..e6465b7bbc 100644 --- a/python/copilot/copilot_request_handler.py +++ b/python/copilot/copilot_request_handler.py @@ -96,6 +96,15 @@ class CopilotRequestContext: """Id of the runtime session that triggered this request, when in scope. Absent for out-of-session requests (e.g. the startup model catalog).""" + agent_id: str | None = None + """Stable per-agent-instance id for the agent trajectory that issued this request.""" + + parent_agent_id: str | None = None + """Id of the parent agent when this request was issued by a subagent.""" + + interaction_type: str | None = None + """Runtime classification for the interaction that produced this request.""" + _bridge: _CopilotWebSocketResponseBridge | None = field(default=None, repr=False) @@ -253,6 +262,9 @@ async def _dispatch(self, exchange: _CopilotRequestExchange) -> None: ctx = CopilotRequestContext( request_id=exchange.request_id, session_id=exchange.session_id, + agent_id=exchange.agent_id, + parent_agent_id=exchange.parent_agent_id, + interaction_type=exchange.interaction_type, transport=exchange.transport, url=exchange.url, headers=exchange.headers, @@ -382,6 +394,9 @@ def __init__( ) -> None: self.request_id = request_id self.session_id: str | None = None + self.agent_id: str | None = None + self.parent_agent_id: str | None = None + self.interaction_type: str | None = None self.method: str = "GET" self.url: str = "" self.headers: dict[str, list[str]] = {} @@ -397,6 +412,9 @@ def __init__( def set_context(self, params: LlmInferenceHTTPRequestStartRequest) -> None: """Fill in the request context once the matching start frame arrives.""" self.session_id = params.session_id + self.agent_id = params.agent_id + self.parent_agent_id = params.parent_agent_id + self.interaction_type = params.interaction_type self.method = params.method self.url = params.url self.headers = params.headers diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index 75e186dc49..828eaa3ce4 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -793,65 +793,6 @@ def to_dict(self) -> dict: result["instanceId"] = from_str(self.instance_id) return result -# Experimental: this type is part of an experimental API and may change or be removed. -@dataclass -class OpenCanvasInstance: - """Open canvas instance snapshot.""" - - canvas_id: str - """Provider-local canvas identifier""" - - extension_id: str - """Owning provider identifier""" - - instance_id: str - """Stable caller-supplied canvas instance identifier""" - - extension_name: str | None = None - """Owning extension display name, when available""" - - input: Any = None - """Input supplied when the instance was opened""" - - status: str | None = None - """Provider-supplied status text""" - - title: str | None = None - """Rendered title""" - - url: str | None = None - """URL for web-rendered canvases""" - - @staticmethod - def from_dict(obj: Any) -> 'OpenCanvasInstance': - assert isinstance(obj, dict) - canvas_id = from_str(obj.get("canvasId")) - extension_id = from_str(obj.get("extensionId")) - instance_id = from_str(obj.get("instanceId")) - extension_name = from_union([from_str, from_none], obj.get("extensionName")) - input = obj.get("input") - status = from_union([from_str, from_none], obj.get("status")) - title = from_union([from_str, from_none], obj.get("title")) - url = from_union([from_str, from_none], obj.get("url")) - return OpenCanvasInstance(canvas_id, extension_id, instance_id, extension_name, input, status, title, url) - - def to_dict(self) -> dict: - result: dict = {} - result["canvasId"] = from_str(self.canvas_id) - result["extensionId"] = from_str(self.extension_id) - result["instanceId"] = from_str(self.instance_id) - if self.extension_name is not None: - result["extensionName"] = from_union([from_str, from_none], self.extension_name) - if self.input is not None: - result["input"] = self.input - if self.status is not None: - result["status"] = from_union([from_str, from_none], self.status) - if self.title is not None: - result["title"] = from_union([from_str, from_none], self.title) - if self.url is not None: - result["url"] = from_union([from_str, from_none], self.url) - return result - # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class CanvasOpenRequest: @@ -2505,6 +2446,46 @@ def to_dict(self) -> dict: class HMACAuthInfoType(Enum): HMAC = "hmac" +# Internal: this type is an internal SDK API and is not part of the public surface. +class _HookType(Enum): + """Hook event name dispatched through the SDK callback transport.""" + + AGENT_STOP = "agentStop" + ERROR_OCCURRED = "errorOccurred" + NOTIFICATION = "notification" + PERMISSION_REQUEST = "permissionRequest" + POST_RESULT = "postResult" + POST_TOOL_USE = "postToolUse" + POST_TOOL_USE_FAILURE = "postToolUseFailure" + PRE_COMPACT = "preCompact" + PRE_MCP_TOOL_CALL = "preMcpToolCall" + PRE_PR_DESCRIPTION = "prePRDescription" + PRE_TOOL_USE = "preToolUse" + SESSION_END = "sessionEnd" + SESSION_START = "sessionStart" + SUBAGENT_START = "subagentStart" + SUBAGENT_STOP = "subagentStop" + USER_PROMPT_SUBMITTED = "userPromptSubmitted" + +# Internal: this type is an internal SDK API and is not part of the public surface. +@dataclass +class _HookInvokeResponse: + """Optional output returned by an SDK callback hook.""" + + output: Any = None + + @staticmethod + def from_dict(obj: Any) -> '_HookInvokeResponse': + assert isinstance(obj, dict) + output = obj.get("output") + return _HookInvokeResponse(output) + + def to_dict(self) -> dict: + result: dict = {} + if self.output is not None: + result["output"] = self.output + return result + class PurpleSource(Enum): GITHUB = "github" LOCAL = "local" @@ -3645,6 +3626,13 @@ def to_dict(self) -> dict: result["serverName"] = from_str(self.server_name) return result +# Experimental: this type is part of an experimental API and may change or be removed. +class MCPToolUIVisibility(Enum): + """Consumer allowed to call an MCP tool.""" + + APP = "app" + MODEL = "model" + class MCPOauthPendingRequestResponseKind(Enum): CANCELLED = "cancelled" TOKEN = "token" @@ -3741,6 +3729,124 @@ def to_dict(self) -> dict: result["removed"] = from_bool(self.removed) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class MCPResourceContent: + """MCP resource content with URI, optional MIME type, text or base64 blob, and resource + metadata. + """ + uri: str + """The resource URI""" + + meta: dict[str, Any] | None = None + """Resource-level metadata (CSP, permissions, etc.)""" + + blob: str | None = None + """Base64-encoded binary content""" + + mime_type: str | None = None + """MIME type of the content""" + + text: str | None = None + """Text content (e.g. HTML)""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPResourceContent': + assert isinstance(obj, dict) + uri = from_str(obj.get("uri")) + meta = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("_meta")) + blob = from_union([from_str, from_none], obj.get("blob")) + mime_type = from_union([from_str, from_none], obj.get("mimeType")) + text = from_union([from_str, from_none], obj.get("text")) + return MCPResourceContent(uri, meta, blob, mime_type, text) + + def to_dict(self) -> dict: + result: dict = {} + result["uri"] = from_str(self.uri) + if self.meta is not None: + result["_meta"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.meta) + if self.blob is not None: + result["blob"] = from_union([from_str, from_none], self.blob) + if self.mime_type is not None: + result["mimeType"] = from_union([from_str, from_none], self.mime_type) + if self.text is not None: + result["text"] = from_union([from_str, from_none], self.text) + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class MCPResourcesListRequest: + """MCP server whose resources to enumerate.""" + + server_name: str + """Name of the MCP server whose resources to enumerate""" + + cursor: str | None = None + """Opaque MCP pagination cursor from a prior `nextCursor` value""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPResourcesListRequest': + assert isinstance(obj, dict) + server_name = from_str(obj.get("serverName")) + cursor = from_union([from_str, from_none], obj.get("cursor")) + return MCPResourcesListRequest(server_name, cursor) + + def to_dict(self) -> dict: + result: dict = {} + result["serverName"] = from_str(self.server_name) + if self.cursor is not None: + result["cursor"] = from_union([from_str, from_none], self.cursor) + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class MCPResourcesListTemplatesRequest: + """MCP server whose resource templates to enumerate.""" + + server_name: str + """Name of the MCP server whose resource templates to enumerate""" + + cursor: str | None = None + """Opaque MCP pagination cursor from a prior `nextCursor` value""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPResourcesListTemplatesRequest': + assert isinstance(obj, dict) + server_name = from_str(obj.get("serverName")) + cursor = from_union([from_str, from_none], obj.get("cursor")) + return MCPResourcesListTemplatesRequest(server_name, cursor) + + def to_dict(self) -> dict: + result: dict = {} + result["serverName"] = from_str(self.server_name) + if self.cursor is not None: + result["cursor"] = from_union([from_str, from_none], self.cursor) + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class MCPResourcesReadRequest: + """MCP server and resource URI to fetch.""" + + server_name: str + """Name of the MCP server hosting the resource""" + + uri: str + """Resource URI""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPResourcesReadRequest': + assert isinstance(obj, dict) + server_name = from_str(obj.get("serverName")) + uri = from_str(obj.get("uri")) + return MCPResourcesReadRequest(server_name, uri) + + def to_dict(self) -> dict: + result: dict = {} + result["serverName"] = from_str(self.server_name) + result["uri"] = from_str(self.uri) + return result + # Experimental: this type is part of an experimental API and may change or be removed. class MCPSamplingExecutionAction(Enum): """Outcome of the sampling inference. 'success' produced a response; 'failure' encountered @@ -4061,8 +4167,11 @@ def to_dict(self) -> dict: # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class MetadataSetWorkingDirectoryRequest: - """Absolute path to set as the session's new working directory.""" - + """Absolute path to set as the session's new working directory. For local sessions the path + must be absolute and exist on disk: it is validated before any session state changes, and + a failing validation rejects the call with nothing mutated, persisted, or emitted. Remote + sessions record the path as-is. + """ working_directory: str """Absolute path to set as the session's working directory. The runtime updates the session's recorded cwd so subsequent operations (shell tools, file lookups, telemetry) @@ -4084,9 +4193,13 @@ def to_dict(self) -> dict: @dataclass class MetadataSetWorkingDirectoryResult: """Update the session's working directory. Used by the host when the user explicitly changes - cwd (e.g., the `/cd` slash command). The host is responsible for `process.chdir` and any - related side-effects (file index, etc.); this method only updates the session's own - recorded path. + cwd (e.g., the `/cd` slash command). The host is responsible for any related side-effects + (file index, etc.); it does NOT change the process working directory (a session's cwd is + per-session, not process-global). For local sessions the runtime validates the target + first (an absolute path that exists on disk) and re-bases the permission primary + directory; a rejected validation fails the call before anything is mutated, persisted, or + emitted. Location-scoped permission rules are then re-keyed to the new directory + (best-effort). Remote sessions only record the path. """ working_directory: str """Working directory after the update""" @@ -4171,6 +4284,50 @@ def to_dict(self) -> dict: result["mode"] = to_enum(SessionMode, self.mode) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class ModelBillingPromo: + """Active server-driven promotion for this model, if any. Present when the model is being + promoted with a time-boxed discount. + + Active server-driven promotion for a model, including its discount and expiry. + """ + ends_at: str + """UTC ISO 8601 timestamp marking when the promotion ends. Always present: the API only + surfaces a promo whose expiry parses and is in the future. Consumers should treat a past + value as expired. + """ + discount_percent: float | None = None + """Percentage discount (0-100) applied while the promotion is active. May be fractional.""" + + id: str | None = None + """Stable identifier for the promotion campaign.""" + + message: str | None = None + """Human-readable promotion message. Does not include the expiry timestamp; consumers may + format endsAt and append it. + """ + + @staticmethod + def from_dict(obj: Any) -> 'ModelBillingPromo': + assert isinstance(obj, dict) + ends_at = from_str(obj.get("endsAt")) + discount_percent = from_union([from_float, from_none], obj.get("discountPercent")) + id = from_union([from_str, from_none], obj.get("id")) + message = from_union([from_str, from_none], obj.get("message")) + return ModelBillingPromo(ends_at, discount_percent, id, message) + + def to_dict(self) -> dict: + result: dict = {} + result["endsAt"] = from_str(self.ends_at) + if self.discount_percent is not None: + result["discountPercent"] = from_union([to_float, from_none], self.discount_percent) + if self.id is not None: + result["id"] = from_union([from_str, from_none], self.id) + if self.message is not None: + result["message"] = from_union([from_str, from_none], self.message) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class ModelBillingTokenPricesLongContext: @@ -5590,7 +5747,7 @@ def to_dict(self) -> dict: # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class PluginsMarketplacesAddRequest: - """Marketplace source to register.""" + """Marketplace source and optional working directory for relative-path resolution.""" source: str """Marketplace source. Accepts the same forms as the CLI: "owner/repo" or "owner/repo#ref" @@ -5598,16 +5755,23 @@ class PluginsMarketplacesAddRequest: (user@host:path), or a local path. The marketplace's own name (from its manifest) is used as the registration key. """ + working_directory: str | None = None + """Working directory used to resolve relative local paths in `source`. Defaults to the + server's current working directory. + """ @staticmethod def from_dict(obj: Any) -> 'PluginsMarketplacesAddRequest': assert isinstance(obj, dict) source = from_str(obj.get("source")) - return PluginsMarketplacesAddRequest(source) + working_directory = from_union([from_str, from_none], obj.get("workingDirectory")) + return PluginsMarketplacesAddRequest(source, working_directory) def to_dict(self) -> dict: result: dict = {} result["source"] = from_str(self.source) + if self.working_directory is not None: + result["workingDirectory"] = from_union([from_str, from_none], self.working_directory) return result # Experimental: this type is part of an experimental API and may change or be removed. @@ -6070,16 +6234,18 @@ class RegisterEventInterestParams: """The event type the consumer wants the runtime to treat as 'observed' for behavior-switching gating. Some runtime code paths inspect whether any consumer is interested in a specific event type and choose a different implementation accordingly - (e.g. `mcp.oauth_required`: when interest is registered the runtime delegates OAuth token - acquisition to the consumer; when no interest is registered OAuth-required servers become - needs-auth). SDK clients that long-poll events do NOT automatically appear as listeners - to these gating checks — they must explicitly call `registerInterest` for each event type - they want the runtime to count as having a consumer. Multiple registrations for the same - event type from the same or different consumers are tracked independently and must each - be released. See: `mcp.oauth_required`, `sampling.requested`, - `auto_mode_switch.requested`, `session_limits_exhausted.requested`, - `user_input.requested`, `elicitation.requested`, `command.queued`, - `exit_plan_mode.requested`. + (e.g. `mcp.oauth_required`: when interest is registered the runtime delegates interactive + OAuth token acquisition to the consumer via `mcp.oauth_required` events; when no interest + is registered the runtime still attempts non-interactive reconnect from cached or + refreshable tokens, and only marks the server `needs-auth` if usable credentials are + unavailable — it does not open a browser or start interactive OAuth without a consumer). + SDK clients that long-poll events do NOT automatically appear as listeners to these + gating checks — they must explicitly call `registerInterest` for each event type they + want the runtime to count as having a consumer. Multiple registrations for the same event + type from the same or different consumers are tracked independently and must each be + released. See: `mcp.oauth_required`, `sampling.requested`, `auto_mode_switch.requested`, + `session_limits_exhausted.requested`, `user_input.requested`, `elicitation.requested`, + `command.queued`, `exit_plan_mode.requested`. """ @staticmethod @@ -6646,6 +6812,9 @@ def to_dict(self) -> dict: class SendAgentMode(Enum): """The UI mode the agent was in when this message was sent. Defaults to the session's current mode. + + The UI mode the agent was in when these messages were sent. Defaults to the session's + current mode. """ AUTOPILOT = "autopilot" INTERACTIVE = "interactive" @@ -6682,14 +6851,96 @@ def to_dict(self) -> dict: result["instanceId"] = from_union([from_str, from_none], self.instance_id) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class SendMessageItem: + """A single user message to append to the session as part of a `session.sendMessages` turn""" + + prompt: str + """The user message text""" + + attachments: list[Attachment] | None = None + """Optional attachments (files, directories, selections, blobs, GitHub references) to + include with this message + """ + # Internal: this field is an internal SDK API and is not part of the public surface. + billable: bool | None = None + """If false, this message will not trigger a Premium Request Unit charge. User messages + default to billable. + """ + display_prompt: str | None = None + """If provided, this is shown in the timeline instead of `prompt`""" + + required_tool: str | None = None + """If set, the request will fail if the named tool is not available when this message is + among the user messages at the start of the current exchange + """ + # Internal: this field is an internal SDK API and is not part of the public surface. + source: str | None = None + """Optional provenance tag copied to the resulting user.message event. Must match one of + three forms: the literal `system`, `command-` for messages originating from a + command (e.g. slash command, Mission Control command), or `schedule-` for + messages originating from a scheduled job. + """ + + @staticmethod + def from_dict(obj: Any) -> 'SendMessageItem': + assert isinstance(obj, dict) + prompt = from_str(obj.get("prompt")) + attachments = from_union([lambda x: from_list(Attachment.from_dict, x), from_none], obj.get("attachments")) + billable = from_union([from_bool, from_none], obj.get("billable")) + display_prompt = from_union([from_str, from_none], obj.get("displayPrompt")) + required_tool = from_union([from_str, from_none], obj.get("requiredTool")) + source = from_union([from_str, from_none], obj.get("source")) + return SendMessageItem(prompt, attachments, billable, display_prompt, required_tool, source) + + def to_dict(self) -> dict: + result: dict = {} + result["prompt"] = from_str(self.prompt) + if self.attachments is not None: + result["attachments"] = from_union([lambda x: from_list(lambda x: to_class(Attachment, x), x), from_none], self.attachments) + if self.billable is not None: + result["billable"] = from_union([from_bool, from_none], self.billable) + if self.display_prompt is not None: + result["displayPrompt"] = from_union([from_str, from_none], self.display_prompt) + if self.required_tool is not None: + result["requiredTool"] = from_union([from_str, from_none], self.required_tool) + if self.source is not None: + result["source"] = from_union([from_str, from_none], self.source) + return result + # Experimental: this type is part of an experimental API and may change or be removed. class SendMode(Enum): - """How to deliver the message. `enqueue` (default) appends to the message queue. `immediate` + """How to deliver the messages. `enqueue` (default) appends to the message queue. + `immediate` interjects during an in-progress turn. + + How to deliver the message. `enqueue` (default) appends to the message queue. `immediate` interjects during an in-progress turn. """ ENQUEUE = "enqueue" IMMEDIATE = "immediate" +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class SendMessagesResult: + """Result of sending zero or more user messages""" + + message_ids: list[str] + """Unique identifiers assigned to the messages, one per provided message in order. Empty + when no messages were provided. + """ + + @staticmethod + def from_dict(obj: Any) -> 'SendMessagesResult': + assert isinstance(obj, dict) + message_ids = from_list(from_str, obj.get("messageIds")) + return SendMessagesResult(message_ids) + + def to_dict(self) -> dict: + result: dict = {} + result["messageIds"] = from_list(from_str, self.message_ids) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SendResult: @@ -7331,33 +7582,6 @@ def to_dict(self) -> dict: result["startupPrompts"] = from_list(from_str, self.startup_prompts) return result -# Experimental: this type is part of an experimental API and may change or be removed. -@dataclass -class SessionModelList: - """The list of models available to this session.""" - - list: list[Any] - """Available models, ordered with the most preferred default first. Includes both Copilot - (CAPI) models and any registry BYOK models; a BYOK model appears under its - provider-qualified selection id (`provider/id`). - """ - quota_snapshots: dict[str, Any] | None = None - """Per-quota snapshots returned alongside the model list, keyed by quota type.""" - - @staticmethod - def from_dict(obj: Any) -> 'SessionModelList': - assert isinstance(obj, dict) - list = from_list(lambda x: x, obj.get("list")) - quota_snapshots = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("quotaSnapshots")) - return SessionModelList(list, quota_snapshots) - - def to_dict(self) -> dict: - result: dict = {} - result["list"] = from_list(lambda x: x, self.list) - if self.quota_snapshots is not None: - result["quotaSnapshots"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.quota_snapshots) - return result - # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionOpenOptionsAdditionalContentExclusionPolicyRuleSource: @@ -7809,15 +8033,21 @@ class SessionUpdateOptionsResult: success: bool """Whether the operation succeeded""" + plugin_hook_count: int | None = None + """Number of hooks loaded from installed plugins, returned when installedPlugins is updated""" + @staticmethod def from_dict(obj: Any) -> 'SessionUpdateOptionsResult': assert isinstance(obj, dict) success = from_bool(obj.get("success")) - return SessionUpdateOptionsResult(success) + plugin_hook_count = from_union([from_int, from_none], obj.get("pluginHookCount")) + return SessionUpdateOptionsResult(success, plugin_hook_count) def to_dict(self) -> dict: result: dict = {} result["success"] = from_bool(self.success) + if self.plugin_hook_count is not None: + result["pluginHookCount"] = from_union([from_int, from_none], self.plugin_hook_count) return result # Experimental: this type is part of an experimental API and may change or be removed. @@ -10448,77 +10678,6 @@ def to_dict(self) -> dict: result["mode"] = from_union([lambda x: to_enum(PermissionsAllowAllMode, x), from_none], self.mode) return result -# Experimental: this type is part of an experimental API and may change or be removed. -@dataclass -class DiscoveredCanvas: - """Canvas available in the current session.""" - - canvas_id: str - """Provider-local canvas identifier""" - - description: str - """Short, single-sentence description shown to the agent in canvas catalogs.""" - - display_name: str - """Human-readable canvas name""" - - extension_id: str - """Owning provider identifier""" - - actions: list[CanvasAction] | None = None - """Actions the agent or host may invoke on an open instance""" - - extension_name: str | None = None - """Owning extension display name, when available""" - - input_schema: Any = None - """JSON Schema for canvas open input""" - - @staticmethod - def from_dict(obj: Any) -> 'DiscoveredCanvas': - assert isinstance(obj, dict) - canvas_id = from_str(obj.get("canvasId")) - description = from_str(obj.get("description")) - display_name = from_str(obj.get("displayName")) - extension_id = from_str(obj.get("extensionId")) - actions = from_union([lambda x: from_list(CanvasAction.from_dict, x), from_none], obj.get("actions")) - extension_name = from_union([from_str, from_none], obj.get("extensionName")) - input_schema = obj.get("inputSchema") - return DiscoveredCanvas(canvas_id, description, display_name, extension_id, actions, extension_name, input_schema) - - def to_dict(self) -> dict: - result: dict = {} - result["canvasId"] = from_str(self.canvas_id) - result["description"] = from_str(self.description) - result["displayName"] = from_str(self.display_name) - result["extensionId"] = from_str(self.extension_id) - if self.actions is not None: - result["actions"] = from_union([lambda x: from_list(lambda x: to_class(CanvasAction, x), x), from_none], self.actions) - if self.extension_name is not None: - result["extensionName"] = from_union([from_str, from_none], self.extension_name) - if self.input_schema is not None: - result["inputSchema"] = self.input_schema - return result - -# Experimental: this type is part of an experimental API and may change or be removed. -@dataclass -class CanvasListOpenResult: - """Live open-canvas snapshot.""" - - open_canvases: list[OpenCanvasInstance] - """Currently open canvas instances""" - - @staticmethod - def from_dict(obj: Any) -> 'CanvasListOpenResult': - assert isinstance(obj, dict) - open_canvases = from_list(OpenCanvasInstance.from_dict, obj.get("openCanvases")) - return CanvasListOpenResult(open_canvases) - - def to_dict(self) -> dict: - result: dict = {} - result["openCanvases"] = from_list(lambda x: to_class(OpenCanvasInstance, x), self.open_canvases) - return result - # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SlashCommandInput: @@ -10697,22 +10856,145 @@ def to_dict(self) -> dict: # Experimental: this type is part of an experimental API and may change or be removed. @dataclass -class CanvasHostContextCapabilities: - """Host capabilities""" +class CanvasHostContextCapabilities: + """Host capabilities""" + + canvases: bool | None = None + """Whether canvas rendering is supported""" + + @staticmethod + def from_dict(obj: Any) -> 'CanvasHostContextCapabilities': + assert isinstance(obj, dict) + canvases = from_union([from_bool, from_none], obj.get("canvases")) + return CanvasHostContextCapabilities(canvases) + + def to_dict(self) -> dict: + result: dict = {} + if self.canvases is not None: + result["canvases"] = from_union([from_bool, from_none], self.canvases) + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class DiscoveredCanvas: + """Canvas available in the current session.""" + + canvas_id: str + """Provider-local canvas identifier""" + + description: str + """Short, single-sentence description shown to the agent in canvas catalogs.""" + + display_name: str + """Human-readable canvas name""" + + extension_id: str + """Owning provider identifier""" + + actions: list[CanvasAction] | None = None + """Actions the agent or host may invoke on an open instance""" + + extension_name: str | None = None + """Owning extension display name, when available""" + + icon: str | None = None + """Host-local PNG path for the canvas icon, when supplied""" + + input_schema: Any = None + """JSON Schema for canvas open input""" + + @staticmethod + def from_dict(obj: Any) -> 'DiscoveredCanvas': + assert isinstance(obj, dict) + canvas_id = from_str(obj.get("canvasId")) + description = from_str(obj.get("description")) + display_name = from_str(obj.get("displayName")) + extension_id = from_str(obj.get("extensionId")) + actions = from_union([lambda x: from_list(CanvasAction.from_dict, x), from_none], obj.get("actions")) + extension_name = from_union([from_str, from_none], obj.get("extensionName")) + icon = from_union([from_str, from_none], obj.get("icon")) + input_schema = obj.get("inputSchema") + return DiscoveredCanvas(canvas_id, description, display_name, extension_id, actions, extension_name, icon, input_schema) + + def to_dict(self) -> dict: + result: dict = {} + result["canvasId"] = from_str(self.canvas_id) + result["description"] = from_str(self.description) + result["displayName"] = from_str(self.display_name) + result["extensionId"] = from_str(self.extension_id) + if self.actions is not None: + result["actions"] = from_union([lambda x: from_list(lambda x: to_class(CanvasAction, x), x), from_none], self.actions) + if self.extension_name is not None: + result["extensionName"] = from_union([from_str, from_none], self.extension_name) + if self.icon is not None: + result["icon"] = from_union([from_str, from_none], self.icon) + if self.input_schema is not None: + result["inputSchema"] = self.input_schema + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class OpenCanvasInstance: + """Open canvas instance snapshot.""" - canvases: bool | None = None - """Whether canvas rendering is supported""" + canvas_id: str + """Provider-local canvas identifier""" + + extension_id: str + """Owning provider identifier""" + + instance_id: str + """Stable caller-supplied canvas instance identifier""" + + extension_name: str | None = None + """Owning extension display name, when available""" + + icon: str | None = None + """Host-local PNG path for the canvas icon, when supplied""" + + input: Any = None + """Input supplied when the instance was opened""" + + status: str | None = None + """Provider-supplied status text""" + + title: str | None = None + """Rendered title""" + + url: str | None = None + """URL for web-rendered canvases""" @staticmethod - def from_dict(obj: Any) -> 'CanvasHostContextCapabilities': + def from_dict(obj: Any) -> 'OpenCanvasInstance': assert isinstance(obj, dict) - canvases = from_union([from_bool, from_none], obj.get("canvases")) - return CanvasHostContextCapabilities(canvases) + canvas_id = from_str(obj.get("canvasId")) + extension_id = from_str(obj.get("extensionId")) + instance_id = from_str(obj.get("instanceId")) + extension_name = from_union([from_str, from_none], obj.get("extensionName")) + icon = from_union([from_str, from_none], obj.get("icon")) + input = obj.get("input") + status = from_union([from_str, from_none], obj.get("status")) + title = from_union([from_str, from_none], obj.get("title")) + url = from_union([from_str, from_none], obj.get("url")) + return OpenCanvasInstance(canvas_id, extension_id, instance_id, extension_name, icon, input, status, title, url) def to_dict(self) -> dict: result: dict = {} - if self.canvases is not None: - result["canvases"] = from_union([from_bool, from_none], self.canvases) + result["canvasId"] = from_str(self.canvas_id) + result["extensionId"] = from_str(self.extension_id) + result["instanceId"] = from_str(self.instance_id) + if self.extension_name is not None: + result["extensionName"] = from_union([from_str, from_none], self.extension_name) + if self.icon is not None: + result["icon"] = from_union([from_str, from_none], self.icon) + if self.input is not None: + result["input"] = self.input + if self.status is not None: + result["status"] = from_union([from_str, from_none], self.status) + if self.title is not None: + result["title"] = from_union([from_str, from_none], self.title) + if self.url is not None: + result["url"] = from_union([from_str, from_none], self.url) return result # Experimental: this type is part of an experimental API and may change or be removed. @@ -11051,6 +11333,49 @@ def to_dict(self) -> dict: ExternalToolTextResultForLlmContentResourceDetails = EmbeddedTextResourceContents | EmbeddedBlobResourceContents +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class MCPResourceIcon: + """A resource icon descriptor plus preserved non-standard icon fields.""" + + src: str + """Icon URI""" + + additional_properties: dict[str, Any] | None = None + """Server-provided non-standard icon fields preserved from the MCP response""" + + mime_type: str | None = None + """Icon MIME type, when known""" + + sizes: str | None = None + """Icon sizes hint""" + + theme: str | None = None + """Theme hint for this icon""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPResourceIcon': + assert isinstance(obj, dict) + src = from_str(obj.get("src")) + additional_properties = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("additionalProperties")) + mime_type = from_union([from_str, from_none], obj.get("mimeType")) + sizes = from_union([from_str, from_none], obj.get("sizes")) + theme = from_union([from_str, from_none], obj.get("theme")) + return MCPResourceIcon(src, additional_properties, mime_type, sizes, theme) + + def to_dict(self) -> dict: + result: dict = {} + result["src"] = from_str(self.src) + if self.additional_properties is not None: + result["additionalProperties"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.additional_properties) + if self.mime_type is not None: + result["mimeType"] = from_union([from_str, from_none], self.mime_type) + if self.sizes is not None: + result["sizes"] = from_union([from_str, from_none], self.sizes) + if self.theme is not None: + result["theme"] = from_union([from_str, from_none], self.theme) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class ExternalToolTextResultForLlmContentAudio: @@ -11322,6 +11647,30 @@ def to_dict(self) -> dict: result["summaryContent"] = from_union([from_str, from_none], self.summary_content) return result +# Internal: this type is an internal SDK API and is not part of the public surface. +@dataclass +class _HookInvokeRequest: + """Runtime-owned wire payload for a server-to-client hook callback invocation.""" + + hook_type: _HookType + input: Any + session_id: str + + @staticmethod + def from_dict(obj: Any) -> '_HookInvokeRequest': + assert isinstance(obj, dict) + hook_type = _HookType(obj.get("hookType")) + input = obj.get("input") + session_id = from_str(obj.get("sessionId")) + return _HookInvokeRequest(hook_type, input, session_id) + + def to_dict(self) -> dict: + result: dict = {} + result["hookType"] = to_enum(_HookType, self.hook_type) + result["input"] = self.input + result["sessionId"] = from_str(self.session_id) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class InstalledPluginSource: @@ -12402,6 +12751,9 @@ def to_dict(self) -> dict: class MCPServerConfig: """MCP server configuration (stdio process or remote HTTP/SSE) + Replacement MCP server configuration (stdio process or remote HTTP/SSE). Omit to restart + the server with its already-registered configuration (config-free restart-by-name). + Stdio MCP server configuration launched as a child process. Remote MCP server configuration accessed over HTTP or SSE. @@ -12748,6 +13100,25 @@ def to_dict(self) -> dict: result["tokenType"] = from_union([from_str, from_none], self.token_type) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class MCPResourcesReadResult: + """Resource contents returned by the MCP server.""" + + contents: list[MCPResourceContent] + """Resource contents returned by the server""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPResourcesReadResult': + assert isinstance(obj, dict) + contents = from_list(MCPResourceContent.from_dict, obj.get("contents")) + return MCPResourcesReadResult(contents) + + def to_dict(self) -> dict: + result: dict = {} + result["contents"] = from_list(lambda x: to_class(MCPResourceContent, x), self.contents) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class MCPSamplingExecutionResult: @@ -13045,6 +13416,27 @@ def to_dict(self) -> dict: result["vision"] = from_union([lambda x: to_class(ModelCapabilitiesLimitsVision, x), from_none], self.vision) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class SessionModelPriceCategory: + """Cost-category metadata for a CAPI model.""" + + id: str + price_category: ModelPickerPriceCategory + + @staticmethod + def from_dict(obj: Any) -> 'SessionModelPriceCategory': + assert isinstance(obj, dict) + id = from_str(obj.get("id")) + price_category = ModelPickerPriceCategory(obj.get("priceCategory")) + return SessionModelPriceCategory(id, price_category) + + def to_dict(self) -> dict: + result: dict = {} + result["id"] = from_str(self.id) + result["priceCategory"] = to_enum(ModelPickerPriceCategory, self.price_category) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class ModelPolicy: @@ -15704,6 +16096,77 @@ def to_dict(self) -> dict: result["entry"] = from_union([lambda x: to_class(ScheduleEntry, x), from_none], self.entry) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class SendMessagesRequest: + """Parameters for sending zero or more user messages to the session in a single turn. + Remote-backed (Mission Control) sessions do not support this method and will return an + error. + """ + messages: list[SendMessageItem] + """The user messages to append to the conversation, in order. May be empty, in which case a + single turn runs over the existing history with no new user message. + """ + agent_mode: SendAgentMode | None = None + """The UI mode the agent was in when these messages were sent. Defaults to the session's + current mode. + """ + mode: SendMode | None = None + """How to deliver the messages. `enqueue` (default) appends to the message queue. + `immediate` interjects during an in-progress turn. + """ + prepend: bool | None = None + """If true, adds the messages to the front of the queue instead of the end""" + + request_headers: dict[str, str] | None = None + """Custom HTTP headers to include in outbound model requests for this turn. Merged with + session-level provider headers; per-turn headers augment and overwrite session-level + headers with the same key. + """ + traceparent: str | None = None + """W3C Trace Context traceparent header for distributed tracing of this agent turn""" + + tracestate: str | None = None + """W3C Trace Context tracestate header for distributed tracing""" + + wait: bool | None = None + """If true, await completion of the agentic loop for this turn before returning. Defaults to + false (fire-and-forget). When true, the result still contains the same `messageIds`; the + caller can rely on the agent having processed the messages before the call resolves. + """ + + @staticmethod + def from_dict(obj: Any) -> 'SendMessagesRequest': + assert isinstance(obj, dict) + messages = from_list(SendMessageItem.from_dict, obj.get("messages")) + agent_mode = from_union([SendAgentMode, from_none], obj.get("agentMode")) + mode = from_union([SendMode, from_none], obj.get("mode")) + prepend = from_union([from_bool, from_none], obj.get("prepend")) + request_headers = from_union([lambda x: from_dict(from_str, x), from_none], obj.get("requestHeaders")) + traceparent = from_union([from_str, from_none], obj.get("traceparent")) + tracestate = from_union([from_str, from_none], obj.get("tracestate")) + wait = from_union([from_bool, from_none], obj.get("wait")) + return SendMessagesRequest(messages, agent_mode, mode, prepend, request_headers, traceparent, tracestate, wait) + + def to_dict(self) -> dict: + result: dict = {} + result["messages"] = from_list(lambda x: to_class(SendMessageItem, x), self.messages) + if self.agent_mode is not None: + result["agentMode"] = from_union([lambda x: to_enum(SendAgentMode, x), from_none], self.agent_mode) + if self.mode is not None: + result["mode"] = from_union([lambda x: to_enum(SendMode, x), from_none], self.mode) + if self.prepend is not None: + result["prepend"] = from_union([from_bool, from_none], self.prepend) + if self.request_headers is not None: + result["requestHeaders"] = from_union([lambda x: from_dict(from_str, x), from_none], self.request_headers) + if self.traceparent is not None: + result["traceparent"] = from_union([from_str, from_none], self.traceparent) + if self.tracestate is not None: + result["tracestate"] = from_union([from_str, from_none], self.tracestate) + if self.wait is not None: + result["wait"] = from_union([from_bool, from_none], self.wait) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SendRequest: @@ -16415,7 +16878,7 @@ def to_dict(self) -> dict: @dataclass class SlashCommandAgentPromptResult: """Slash-command invocation result that submits an agent prompt, with display prompt, - optional mode, and settings-change flag. + optional mode, optional user-facing notice, and settings-change flag. """ display_prompt: str """Prompt text to display to the user""" @@ -16429,6 +16892,9 @@ class SlashCommandAgentPromptResult: mode: SessionMode | None = None """Optional target session mode for the agent prompt""" + notice: str | None = None + """Optional user-facing notice to show before the prompt is submitted""" + runtime_settings_changed: bool | None = None """True when the invocation mutated user runtime settings; consumers caching settings should refresh @@ -16440,8 +16906,9 @@ def from_dict(obj: Any) -> 'SlashCommandAgentPromptResult': display_prompt = from_str(obj.get("displayPrompt")) prompt = from_str(obj.get("prompt")) mode = from_union([SessionMode, from_none], obj.get("mode")) + notice = from_union([from_str, from_none], obj.get("notice")) runtime_settings_changed = from_union([from_bool, from_none], obj.get("runtimeSettingsChanged")) - return SlashCommandAgentPromptResult(display_prompt, prompt, mode, runtime_settings_changed) + return SlashCommandAgentPromptResult(display_prompt, prompt, mode, notice, runtime_settings_changed) def to_dict(self) -> dict: result: dict = {} @@ -16450,6 +16917,8 @@ def to_dict(self) -> dict: result["prompt"] = from_str(self.prompt) if self.mode is not None: result["mode"] = from_union([lambda x: to_enum(SessionMode, x), from_none], self.mode) + if self.notice is not None: + result["notice"] = from_union([from_str, from_none], self.notice) if self.runtime_settings_changed is not None: result["runtimeSettingsChanged"] = from_union([from_bool, from_none], self.runtime_settings_changed) return result @@ -16757,27 +17226,32 @@ def to_dict(self) -> dict: # Experimental: this type is part of an experimental API and may change or be removed. @dataclass -class MCPTools: - """MCP tool metadata with tool name and optional description.""" - - name: str - """Tool name.""" +class MCPToolUI: + """Normalized MCP Apps discovery metadata. An empty object indicates that a valid `_meta.ui` + block was present without recognized fields. - description: str | None = None - """Tool description, when provided.""" + Normalized MCP Apps discovery metadata from a tool's `_meta.ui` block. + """ + resource_uri: str | None = None + """URI of the tool's MCP App resource, typically a `ui://` resource identifier. Use + `session.mcp.resources.read` to fetch its HTML and resource metadata. + """ + visibility: list[MCPToolUIVisibility] | None = None + """Tool visibility advertised by the server. When absent, MCP Apps defaults apply.""" @staticmethod - def from_dict(obj: Any) -> 'MCPTools': + def from_dict(obj: Any) -> 'MCPToolUI': assert isinstance(obj, dict) - name = from_str(obj.get("name")) - description = from_union([from_str, from_none], obj.get("description")) - return MCPTools(name, description) + resource_uri = from_union([from_str, from_none], obj.get("resourceUri")) + visibility = from_union([lambda x: from_list(MCPToolUIVisibility, x), from_none], obj.get("visibility")) + return MCPToolUI(resource_uri, visibility) def to_dict(self) -> dict: result: dict = {} - result["name"] = from_str(self.name) - if self.description is not None: - result["description"] = from_union([from_str, from_none], self.description) + if self.resource_uri is not None: + result["resourceUri"] = from_union([from_str, from_none], self.resource_uri) + if self.visibility is not None: + result["visibility"] = from_union([lambda x: from_list(lambda x: to_enum(MCPToolUIVisibility, x), x), from_none], self.visibility) return result # Experimental: this type is part of an experimental API and may change or be removed. @@ -17663,25 +18137,6 @@ def to_dict(self) -> dict: result["logCapture"] = from_union([lambda x: to_class(AgentRegistryLogCapture, x), from_none], self.log_capture) return result -# Experimental: this type is part of an experimental API and may change or be removed. -@dataclass -class CanvasList: - """Declared canvases available in this session.""" - - canvases: list[DiscoveredCanvas] - """Declared canvases available in this session""" - - @staticmethod - def from_dict(obj: Any) -> 'CanvasList': - assert isinstance(obj, dict) - canvases = from_list(DiscoveredCanvas.from_dict, obj.get("canvases")) - return CanvasList(canvases) - - def to_dict(self) -> dict: - result: dict = {} - result["canvases"] = from_list(lambda x: to_class(DiscoveredCanvas, x), self.canvases) - return result - # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SlashCommandInfo: @@ -17789,6 +18244,44 @@ def to_dict(self) -> dict: result["capabilities"] = from_union([lambda x: to_class(CanvasHostContextCapabilities, x), from_none], self.capabilities) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class CanvasList: + """Declared canvases available in this session.""" + + canvases: list[DiscoveredCanvas] + """Declared canvases available in this session""" + + @staticmethod + def from_dict(obj: Any) -> 'CanvasList': + assert isinstance(obj, dict) + canvases = from_list(DiscoveredCanvas.from_dict, obj.get("canvases")) + return CanvasList(canvases) + + def to_dict(self) -> dict: + result: dict = {} + result["canvases"] = from_list(lambda x: to_class(DiscoveredCanvas, x), self.canvases) + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class CanvasListOpenResult: + """Live open-canvas snapshot.""" + + open_canvases: list[OpenCanvasInstance] + """Currently open canvas instances""" + + @staticmethod + def from_dict(obj: Any) -> 'CanvasListOpenResult': + assert isinstance(obj, dict) + open_canvases = from_list(OpenCanvasInstance.from_dict, obj.get("openCanvases")) + return CanvasListOpenResult(open_canvases) + + def to_dict(self) -> dict: + result: dict = {} + result["openCanvases"] = from_list(lambda x: to_class(OpenCanvasInstance, x), self.open_canvases) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class DebugCollectLogsResult: @@ -18799,54 +19292,54 @@ def to_dict(self) -> dict: return result # Experimental: this type is part of an experimental API and may change or be removed. -# Internal: this type is an internal SDK API and is not part of the public surface. @dataclass class MCPRestartServerRequest: - """Server name and opaque configuration for an individual MCP server restart.""" - + """Server name and optional replacement configuration for an individual MCP server restart. + Omit `config` for a config-free restart-by-name of an already-configured server. + """ server_name: str """Name of the MCP server to restart""" - config: Any = None - """Opaque server configuration (MCPServerConfig). Marked internal: an in-process runtime - shape supplied only by in-process CLI callers. + config: MCPServerConfig | None = None + """Replacement MCP server configuration (stdio process or remote HTTP/SSE). Omit to restart + the server with its already-registered configuration (config-free restart-by-name). """ + @staticmethod def from_dict(obj: Any) -> 'MCPRestartServerRequest': assert isinstance(obj, dict) - config = obj.get("config") server_name = from_str(obj.get("serverName")) - return MCPRestartServerRequest(config, server_name) + config = from_union([MCPServerConfig.from_dict, from_none], obj.get("config")) + return MCPRestartServerRequest(server_name, config) def to_dict(self) -> dict: result: dict = {} - result["config"] = self.config result["serverName"] = from_str(self.server_name) + if self.config is not None: + result["config"] = from_union([lambda x: to_class(MCPServerConfig, x), from_none], self.config) return result # Experimental: this type is part of an experimental API and may change or be removed. -# Internal: this type is an internal SDK API and is not part of the public surface. @dataclass class MCPStartServerRequest: - """Server name and opaque configuration for an individual MCP server start.""" + """Server name and configuration for an individual MCP server start.""" + + config: MCPServerConfig + """MCP server configuration (stdio process or remote HTTP/SSE)""" server_name: str """Name of the MCP server to start""" - config: Any = None - """Opaque server configuration (MCPServerConfig). Marked internal: an in-process runtime - shape supplied only by in-process CLI callers. - """ @staticmethod def from_dict(obj: Any) -> 'MCPStartServerRequest': assert isinstance(obj, dict) - config = obj.get("config") + config = MCPServerConfig.from_dict(obj.get("config")) server_name = from_str(obj.get("serverName")) return MCPStartServerRequest(config, server_name) def to_dict(self) -> dict: result: dict = {} - result["config"] = self.config + result["config"] = to_class(MCPServerConfig, self.config) result["serverName"] = from_str(self.server_name) return result @@ -19044,6 +19537,10 @@ class ModelBilling: multiplier: float | None = None """Billing cost multiplier relative to the base rate""" + promo: ModelBillingPromo | None = None + """Active server-driven promotion for this model, if any. Present when the model is being + promoted with a time-boxed discount. + """ token_prices: ModelBillingTokenPrices | None = None """Token-level pricing information for this model""" @@ -19052,8 +19549,9 @@ def from_dict(obj: Any) -> 'ModelBilling': assert isinstance(obj, dict) discount_percent = from_union([from_int, from_none], obj.get("discountPercent")) multiplier = from_union([from_float, from_none], obj.get("multiplier")) + promo = from_union([ModelBillingPromo.from_dict, from_none], obj.get("promo")) token_prices = from_union([ModelBillingTokenPrices.from_dict, from_none], obj.get("tokenPrices")) - return ModelBilling(discount_percent, multiplier, token_prices) + return ModelBilling(discount_percent, multiplier, promo, token_prices) def to_dict(self) -> dict: result: dict = {} @@ -19061,10 +19559,46 @@ def to_dict(self) -> dict: result["discountPercent"] = from_union([from_int, from_none], self.discount_percent) if self.multiplier is not None: result["multiplier"] = from_union([to_float, from_none], self.multiplier) + if self.promo is not None: + result["promo"] = from_union([lambda x: to_class(ModelBillingPromo, x), from_none], self.promo) if self.token_prices is not None: result["tokenPrices"] = from_union([lambda x: to_class(ModelBillingTokenPrices, x), from_none], self.token_prices) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class SessionModelList: + """The list of models available to this session.""" + + list: list[Any] + """Available models, ordered with the most preferred default first. Includes both Copilot + (CAPI) models and any registry BYOK models; a BYOK model appears under its + provider-qualified selection id (`provider/id`). + """ + model_price_categories: list[SessionModelPriceCategory] | None = None + """Cost categories for the full CAPI catalog, including picker-disabled models that Auto may + select. Metadata only; entries absent from `list` are not manually selectable. + """ + quota_snapshots: dict[str, Any] | None = None + """Per-quota snapshots returned alongside the model list, keyed by quota type.""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionModelList': + assert isinstance(obj, dict) + list = from_list(lambda x: x, obj.get("list")) + model_price_categories = from_union([lambda x: from_list(SessionModelPriceCategory.from_dict, x), from_none], obj.get("modelPriceCategories")) + quota_snapshots = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("quotaSnapshots")) + return SessionModelList(list, model_price_categories, quota_snapshots) + + def to_dict(self) -> dict: + result: dict = {} + result["list"] = from_list(lambda x: x, self.list) + if self.model_price_categories is not None: + result["modelPriceCategories"] = from_union([lambda x: from_list(lambda x: to_class(SessionModelPriceCategory, x), x), from_none], self.model_price_categories) + if self.quota_snapshots is not None: + result["quotaSnapshots"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.quota_snapshots) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class ModelCapabilitiesOverride: @@ -19882,21 +20416,36 @@ def to_dict(self) -> dict: # Experimental: this type is part of an experimental API and may change or be removed. @dataclass -class MCPListToolsResult: - """Tools exposed by the connected MCP server. Throws when the server is not connected.""" +class MCPTools: + """MCP tool metadata with tool name, optional description, and normalized MCP Apps discovery + metadata. + """ + name: str + """Tool name.""" - tools: list[MCPTools] - """Tools exposed by the server.""" + description: str | None = None + """Tool description, when provided.""" + + ui: MCPToolUI | None = None + """Normalized MCP Apps discovery metadata. An empty object indicates that a valid `_meta.ui` + block was present without recognized fields. + """ @staticmethod - def from_dict(obj: Any) -> 'MCPListToolsResult': + def from_dict(obj: Any) -> 'MCPTools': assert isinstance(obj, dict) - tools = from_list(MCPTools.from_dict, obj.get("tools")) - return MCPListToolsResult(tools) + name = from_str(obj.get("name")) + description = from_union([from_str, from_none], obj.get("description")) + ui = from_union([MCPToolUI.from_dict, from_none], obj.get("ui")) + return MCPTools(name, description, ui) def to_dict(self) -> dict: result: dict = {} - result["tools"] = from_list(lambda x: to_class(MCPTools, x), self.tools) + result["name"] = from_str(self.name) + if self.description is not None: + result["description"] = from_union([from_str, from_none], self.description) + if self.ui is not None: + result["ui"] = from_union([lambda x: to_class(MCPToolUI, x), from_none], self.ui) return result # Experimental: this type is part of an experimental API and may change or be removed. @@ -21019,6 +21568,25 @@ def to_dict(self) -> dict: result["userPolicy"] = from_union([lambda x: to_class(SandboxConfigUserPolicy, x), from_none], self.user_policy) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class MCPListToolsResult: + """Tools exposed by the connected MCP server. Throws when the server is not connected.""" + + tools: list[MCPTools] + """Tools exposed by the server.""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPListToolsResult': + assert isinstance(obj, dict) + tools = from_list(MCPTools.from_dict, obj.get("tools")) + return MCPListToolsResult(tools) + + def to_dict(self) -> dict: + result: dict = {} + result["tools"] = from_list(lambda x: to_class(MCPTools, x), self.tools) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class UIElicitationSchema: @@ -21189,6 +21757,11 @@ class SessionOpenOptions: feature_flags: dict[str, bool] | None = None """Feature-flag values resolved by the host.""" + included_builtin_agents: list[str] | None = None + """Built-in subagent names to include in this session. When specified, only these built-ins + are available, subject to runtime availability and exclusions. Custom agents with the + same name remain available. + """ installed_plugins: list[InstalledPlugin] | None = None """Installed plugins visible to the session.""" @@ -21317,6 +21890,7 @@ def from_dict(obj: Any) -> 'SessionOpenOptions': excluded_tools = from_union([lambda x: from_list(from_str, x), from_none], obj.get("excludedTools")) exp_assignments = obj.get("expAssignments") feature_flags = from_union([lambda x: from_dict(from_bool, x), from_none], obj.get("featureFlags")) + included_builtin_agents = from_union([lambda x: from_list(from_str, x), from_none], obj.get("includedBuiltinAgents")) installed_plugins = from_union([lambda x: from_list(InstalledPlugin.from_dict, x), from_none], obj.get("installedPlugins")) integration_id = from_union([from_str, from_none], obj.get("integrationId")) is_experimental_mode = from_union([from_bool, from_none], obj.get("isExperimentalMode")) @@ -21348,7 +21922,7 @@ def from_dict(obj: Any) -> 'SessionOpenOptions': verbosity = from_union([Verbosity, from_none], obj.get("verbosity")) working_directory = from_union([from_str, from_none], obj.get("workingDirectory")) working_directory_context = from_union([SessionContext.from_dict, from_none], obj.get("workingDirectoryContext")) - return SessionOpenOptions(additional_content_exclusion_policies, agent_context, allow_all_mcp_server_instructions, ask_user_disabled, auth_info, available_tools, capi, client_kind, client_name, coauthor_enabled, config_dir, continue_on_auto_mode, copilot_url, custom_agents_local_only, detached_from_spawning_parent_engagement_id, detached_from_spawning_parent_session_id, disabled_instruction_sources, disabled_skills, enable_citations, enable_managed_settings, enable_on_demand_instruction_discovery, enable_script_safety, enable_streaming, env_value_mode, events_log_directory, excluded_builtin_agents, excluded_tools, exp_assignments, feature_flags, installed_plugins, integration_id, is_experimental_mode, log_interactive_shells, lsp_client_name, max_inline_binary_bytes, memory, model, model_capabilities_overrides, models, name, provider, providers, reasoning_effort, reasoning_summary, remote_defaulted_on, remote_exporting, remote_steerable, running_in_interactive_mode, sandbox_config, session_capabilities, session_id, session_limits, shell_init_profile, shell_process_flags, skill_directories, skip_custom_instructions, trajectory_file, verbosity, working_directory, working_directory_context) + return SessionOpenOptions(additional_content_exclusion_policies, agent_context, allow_all_mcp_server_instructions, ask_user_disabled, auth_info, available_tools, capi, client_kind, client_name, coauthor_enabled, config_dir, continue_on_auto_mode, copilot_url, custom_agents_local_only, detached_from_spawning_parent_engagement_id, detached_from_spawning_parent_session_id, disabled_instruction_sources, disabled_skills, enable_citations, enable_managed_settings, enable_on_demand_instruction_discovery, enable_script_safety, enable_streaming, env_value_mode, events_log_directory, excluded_builtin_agents, excluded_tools, exp_assignments, feature_flags, included_builtin_agents, installed_plugins, integration_id, is_experimental_mode, log_interactive_shells, lsp_client_name, max_inline_binary_bytes, memory, model, model_capabilities_overrides, models, name, provider, providers, reasoning_effort, reasoning_summary, remote_defaulted_on, remote_exporting, remote_steerable, running_in_interactive_mode, sandbox_config, session_capabilities, session_id, session_limits, shell_init_profile, shell_process_flags, skill_directories, skip_custom_instructions, trajectory_file, verbosity, working_directory, working_directory_context) def to_dict(self) -> dict: result: dict = {} @@ -21410,6 +21984,8 @@ def to_dict(self) -> dict: result["expAssignments"] = self.exp_assignments if self.feature_flags is not None: result["featureFlags"] = from_union([lambda x: from_dict(from_bool, x), from_none], self.feature_flags) + if self.included_builtin_agents is not None: + result["includedBuiltinAgents"] = from_union([lambda x: from_list(from_str, x), from_none], self.included_builtin_agents) if self.installed_plugins is not None: result["installedPlugins"] = from_union([lambda x: from_list(lambda x: to_class(InstalledPlugin, x), x), from_none], self.installed_plugins) if self.integration_id is not None: @@ -21572,6 +22148,11 @@ class SessionUpdateOptionsParams: feature_flags: dict[str, bool] | None = None """Map of feature-flag IDs to their boolean enabled state.""" + included_builtin_agents: list[str] | None = None + """Built-in subagent names to include in this session. When specified, only these built-ins + are available, subject to runtime availability and exclusions. Custom agents with the + same name remain available. Set to null to remove the allowlist restriction. + """ installed_plugins: list[SessionInstalledPlugin] | None = None """Full set of installed plugins for the session. Replaces the existing list; the runtime invalidates the skills cache only when the list materially changes. @@ -21694,6 +22275,7 @@ def from_dict(obj: Any) -> 'SessionUpdateOptionsParams': excluded_builtin_agents = from_union([lambda x: from_list(from_str, x), from_none], obj.get("excludedBuiltinAgents")) excluded_tools = from_union([lambda x: from_list(from_str, x), from_none], obj.get("excludedTools")) feature_flags = from_union([lambda x: from_dict(from_bool, x), from_none], obj.get("featureFlags")) + included_builtin_agents = from_union([lambda x: from_list(from_str, x), from_none], obj.get("includedBuiltinAgents")) installed_plugins = from_union([lambda x: from_list(SessionInstalledPlugin.from_dict, x), from_none], obj.get("installedPlugins")) integration_id = from_union([from_str, from_none], obj.get("integrationId")) is_experimental_mode = from_union([from_bool, from_none], obj.get("isExperimentalMode")) @@ -21721,7 +22303,7 @@ def from_dict(obj: Any) -> 'SessionUpdateOptionsParams': trajectory_file = from_union([from_str, from_none], obj.get("trajectoryFile")) verbosity = from_union([Verbosity, from_none], obj.get("verbosity")) working_directory = from_union([from_str, from_none], obj.get("workingDirectory")) - return SessionUpdateOptionsParams(additional_content_exclusion_policies, agent_context, allow_all_mcp_server_instructions, ask_user_disabled, available_tools, capi, client_name, coauthor_enabled, context_tier, continue_on_auto_mode, copilot_url, custom_agents_local_only, disabled_instruction_sources, disabled_skills, enable_file_hooks, enable_host_git_operations, enable_on_demand_instruction_discovery, enable_reasoning_summaries, enable_script_safety, enable_session_store, enable_skills, enable_streaming, env_value_mode, events_log_directory, excluded_builtin_agents, excluded_tools, feature_flags, installed_plugins, integration_id, is_experimental_mode, log_interactive_shells, lsp_client_name, manage_schedule_enabled, max_inline_binary_bytes, model, model_capabilities_overrides, organization_custom_instructions, provider, reasoning_effort, reasoning_summary, running_in_interactive_mode, sandbox_config, session_capabilities, session_limits, shell_init_profile, shell_process_flags, skill_directories, skip_custom_instructions, skip_embedding_retrieval, suppress_custom_agent_prompt, tool_filter_precedence, trajectory_file, verbosity, working_directory) + return SessionUpdateOptionsParams(additional_content_exclusion_policies, agent_context, allow_all_mcp_server_instructions, ask_user_disabled, available_tools, capi, client_name, coauthor_enabled, context_tier, continue_on_auto_mode, copilot_url, custom_agents_local_only, disabled_instruction_sources, disabled_skills, enable_file_hooks, enable_host_git_operations, enable_on_demand_instruction_discovery, enable_reasoning_summaries, enable_script_safety, enable_session_store, enable_skills, enable_streaming, env_value_mode, events_log_directory, excluded_builtin_agents, excluded_tools, feature_flags, included_builtin_agents, installed_plugins, integration_id, is_experimental_mode, log_interactive_shells, lsp_client_name, manage_schedule_enabled, max_inline_binary_bytes, model, model_capabilities_overrides, organization_custom_instructions, provider, reasoning_effort, reasoning_summary, running_in_interactive_mode, sandbox_config, session_capabilities, session_limits, shell_init_profile, shell_process_flags, skill_directories, skip_custom_instructions, skip_embedding_retrieval, suppress_custom_agent_prompt, tool_filter_precedence, trajectory_file, verbosity, working_directory) def to_dict(self) -> dict: result: dict = {} @@ -21779,6 +22361,8 @@ def to_dict(self) -> dict: result["excludedTools"] = from_union([lambda x: from_list(from_str, x), from_none], self.excluded_tools) if self.feature_flags is not None: result["featureFlags"] = from_union([lambda x: from_dict(from_bool, x), from_none], self.feature_flags) + if self.included_builtin_agents is not None: + result["includedBuiltinAgents"] = from_union([lambda x: from_list(from_str, x), from_none], self.included_builtin_agents) if self.installed_plugins is not None: result["installedPlugins"] = from_union([lambda x: from_list(lambda x: to_class(SessionInstalledPlugin, x), x), from_none], self.installed_plugins) if self.integration_id is not None: @@ -22849,6 +23433,241 @@ def to_dict(self) -> dict: result["transport"] = self.transport return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class MCPResourceAnnotations: + """Model/client annotations associated with this resource + + Standard MCP resource annotations plus preserved non-standard annotation fields. + + Model/client annotations associated with this template + """ + additional_properties: dict[str, Any] | None = None + """Server-provided non-standard annotation fields preserved from the MCP response""" + + audience: list[str] | None = None + """Intended audience roles for this resource""" + + last_modified: str | None = None + """Last-modified timestamp hint""" + + priority: float | None = None + """Priority hint for model/client use""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPResourceAnnotations': + assert isinstance(obj, dict) + additional_properties = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("additionalProperties")) + audience = from_union([lambda x: from_list(from_str, x), from_none], obj.get("audience")) + last_modified = from_union([from_str, from_none], obj.get("lastModified")) + priority = from_union([from_float, from_none], obj.get("priority")) + return MCPResourceAnnotations(additional_properties, audience, last_modified, priority) + + def to_dict(self) -> dict: + result: dict = {} + if self.additional_properties is not None: + result["additionalProperties"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.additional_properties) + if self.audience is not None: + result["audience"] = from_union([lambda x: from_list(from_str, x), from_none], self.audience) + if self.last_modified is not None: + result["lastModified"] = from_union([from_str, from_none], self.last_modified) + if self.priority is not None: + result["priority"] = from_union([to_float, from_none], self.priority) + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class MCPResource: + """An MCP resource descriptor (spec `Resource`): URI, name, and optional title, description, + MIME type, size, icons, annotations, and metadata. Server-provided fields outside the + standard descriptor shape are exposed under `additionalProperties`. + """ + name: str + """The programmatic name of the resource""" + + uri: str + """The resource URI (e.g. ui://... or file:///...)""" + + meta: dict[str, Any] | None = None + """Resource-level metadata""" + + additional_properties: dict[str, Any] | None = None + """Server-provided non-standard descriptor fields preserved from the MCP response""" + + annotations: MCPResourceAnnotations | None = None + """Model/client annotations associated with this resource""" + + description: str | None = None + """Optional description of what this resource represents""" + + icons: list[MCPResourceIcon] | None = None + """Icons associated with this resource""" + + mime_type: str | None = None + """MIME type of the resource, if known""" + + size: int | None = None + """Resource size in bytes, when known""" + + title: str | None = None + """Optional human-readable display title""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPResource': + assert isinstance(obj, dict) + name = from_str(obj.get("name")) + uri = from_str(obj.get("uri")) + meta = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("_meta")) + additional_properties = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("additionalProperties")) + annotations = from_union([MCPResourceAnnotations.from_dict, from_none], obj.get("annotations")) + description = from_union([from_str, from_none], obj.get("description")) + icons = from_union([lambda x: from_list(MCPResourceIcon.from_dict, x), from_none], obj.get("icons")) + mime_type = from_union([from_str, from_none], obj.get("mimeType")) + size = from_union([from_int, from_none], obj.get("size")) + title = from_union([from_str, from_none], obj.get("title")) + return MCPResource(name, uri, meta, additional_properties, annotations, description, icons, mime_type, size, title) + + def to_dict(self) -> dict: + result: dict = {} + result["name"] = from_str(self.name) + result["uri"] = from_str(self.uri) + if self.meta is not None: + result["_meta"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.meta) + if self.additional_properties is not None: + result["additionalProperties"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.additional_properties) + if self.annotations is not None: + result["annotations"] = from_union([lambda x: to_class(MCPResourceAnnotations, x), from_none], self.annotations) + if self.description is not None: + result["description"] = from_union([from_str, from_none], self.description) + if self.icons is not None: + result["icons"] = from_union([lambda x: from_list(lambda x: to_class(MCPResourceIcon, x), x), from_none], self.icons) + if self.mime_type is not None: + result["mimeType"] = from_union([from_str, from_none], self.mime_type) + if self.size is not None: + result["size"] = from_union([from_int, from_none], self.size) + if self.title is not None: + result["title"] = from_union([from_str, from_none], self.title) + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class MCPResourceTemplate: + """An MCP resource template descriptor (spec `ResourceTemplate`): an RFC 6570 URI template, + name, and optional title, description, MIME type, icons, annotations, and metadata. + Server-provided fields outside the standard descriptor shape are exposed under + `additionalProperties`. + """ + name: str + """The programmatic name of the resource template""" + + uri_template: str + """An RFC 6570 URI template for constructing resource URIs""" + + meta: dict[str, Any] | None = None + """Resource-template-level metadata""" + + additional_properties: dict[str, Any] | None = None + """Server-provided non-standard descriptor fields preserved from the MCP response""" + + annotations: MCPResourceAnnotations | None = None + """Model/client annotations associated with this template""" + + description: str | None = None + """Optional description of what this template is for""" + + icons: list[MCPResourceIcon] | None = None + """Icons associated with resources matching this template""" + + mime_type: str | None = None + """MIME type for resources matching this template, if uniform""" + + title: str | None = None + """Optional human-readable display title""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPResourceTemplate': + assert isinstance(obj, dict) + name = from_str(obj.get("name")) + uri_template = from_str(obj.get("uriTemplate")) + meta = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("_meta")) + additional_properties = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("additionalProperties")) + annotations = from_union([MCPResourceAnnotations.from_dict, from_none], obj.get("annotations")) + description = from_union([from_str, from_none], obj.get("description")) + icons = from_union([lambda x: from_list(MCPResourceIcon.from_dict, x), from_none], obj.get("icons")) + mime_type = from_union([from_str, from_none], obj.get("mimeType")) + title = from_union([from_str, from_none], obj.get("title")) + return MCPResourceTemplate(name, uri_template, meta, additional_properties, annotations, description, icons, mime_type, title) + + def to_dict(self) -> dict: + result: dict = {} + result["name"] = from_str(self.name) + result["uriTemplate"] = from_str(self.uri_template) + if self.meta is not None: + result["_meta"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.meta) + if self.additional_properties is not None: + result["additionalProperties"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.additional_properties) + if self.annotations is not None: + result["annotations"] = from_union([lambda x: to_class(MCPResourceAnnotations, x), from_none], self.annotations) + if self.description is not None: + result["description"] = from_union([from_str, from_none], self.description) + if self.icons is not None: + result["icons"] = from_union([lambda x: from_list(lambda x: to_class(MCPResourceIcon, x), x), from_none], self.icons) + if self.mime_type is not None: + result["mimeType"] = from_union([from_str, from_none], self.mime_type) + if self.title is not None: + result["title"] = from_union([from_str, from_none], self.title) + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class MCPResourcesListResult: + """One page of resources advertised by the named MCP server.""" + + resources: list[MCPResource] + """Resources advertised by the server (proxied MCP `resources/list`)""" + + next_cursor: str | None = None + """Opaque cursor for the next page, if the server has more resources""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPResourcesListResult': + assert isinstance(obj, dict) + resources = from_list(MCPResource.from_dict, obj.get("resources")) + next_cursor = from_union([from_str, from_none], obj.get("nextCursor")) + return MCPResourcesListResult(resources, next_cursor) + + def to_dict(self) -> dict: + result: dict = {} + result["resources"] = from_list(lambda x: to_class(MCPResource, x), self.resources) + if self.next_cursor is not None: + result["nextCursor"] = from_union([from_str, from_none], self.next_cursor) + return result + +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class MCPResourcesListTemplatesResult: + """One page of resource templates advertised by the named MCP server.""" + + resource_templates: list[MCPResourceTemplate] + """Resource templates advertised by the server (proxied MCP `resources/templates/list`)""" + + next_cursor: str | None = None + """Opaque cursor for the next page, if the server has more resource templates""" + + @staticmethod + def from_dict(obj: Any) -> 'MCPResourcesListTemplatesResult': + assert isinstance(obj, dict) + resource_templates = from_list(MCPResourceTemplate.from_dict, obj.get("resourceTemplates")) + next_cursor = from_union([from_str, from_none], obj.get("nextCursor")) + return MCPResourcesListTemplatesResult(resource_templates, next_cursor) + + def to_dict(self) -> dict: + result: dict = {} + result["resourceTemplates"] = from_list(lambda x: to_class(MCPResourceTemplate, x), self.resource_templates) + if self.next_cursor is not None: + result["nextCursor"] = from_union([from_str, from_none], self.next_cursor) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class MetadataContextInfoRequest: @@ -23691,6 +24510,9 @@ class RPC: history_truncate_request: HistoryTruncateRequest history_truncate_result: HistoryTruncateResult hmac_auth_info: HMACAuthInfo + hook_invoke_request: _HookInvokeRequest + hook_invoke_response: _HookInvokeResponse + hook_type: _HookType installed_plugin: InstalledPlugin installed_plugin_info: InstalledPluginInfo installed_plugin_source: InstalledPluginSource | str @@ -23789,6 +24611,17 @@ class RPC: mcp_register_external_client_request: MCPRegisterExternalClientRequest mcp_reload_with_config_request: MCPReloadWithConfigRequest mcp_remove_git_hub_result: MCPRemoveGitHubResult + mcp_resource: MCPResource + mcp_resource_annotations: MCPResourceAnnotations + mcp_resource_content: MCPResourceContent + mcp_resource_icon: MCPResourceIcon + mcp_resources_list_request: MCPResourcesListRequest + mcp_resources_list_result: MCPResourcesListResult + mcp_resources_list_templates_request: MCPResourcesListTemplatesRequest + mcp_resources_list_templates_result: MCPResourcesListTemplatesResult + mcp_resources_read_request: MCPResourcesReadRequest + mcp_resources_read_result: MCPResourcesReadResult + mcp_resource_template: MCPResourceTemplate mcp_restart_server_request: MCPRestartServerRequest mcp_sampling_execution_action: MCPSamplingExecutionAction mcp_sampling_execution_result: MCPSamplingExecutionResult @@ -23811,6 +24644,8 @@ class RPC: mcp_start_servers_result: MCPStartServersResult mcp_stop_server_request: MCPStopServerRequest mcp_tools: MCPTools + mcp_tool_ui: MCPToolUI + mcp_tool_ui_visibility: MCPToolUIVisibility mcp_unregister_external_client_request: MCPUnregisterExternalClientRequest memory_configuration: MemoryConfiguration metadata_context_attribution_result: MetadataContextAttributionResult @@ -23831,6 +24666,7 @@ class RPC: metadata_snapshot_remote_metadata_task_type: TaskType model: Model model_billing: ModelBilling + model_billing_promo: ModelBillingPromo model_billing_token_prices: ModelBillingTokenPrices model_billing_token_prices_long_context: ModelBillingTokenPricesLongContext model_capabilities: ModelCapabilities @@ -24074,6 +24910,9 @@ class RPC: secrets_add_filter_values_result: SecretsAddFilterValuesResult send_agent_mode: SendAgentMode send_attachments_to_message_params: SendAttachmentsToMessageParams + send_message_item: SendMessageItem + send_messages_request: SendMessagesRequest + send_messages_result: SendMessagesResult send_mode: SendMode send_request: SendRequest send_result: SendResult @@ -24131,6 +24970,7 @@ class RPC: session_metadata_snapshot: SessionMetadataSnapshot session_mode: SessionMode session_model_list: SessionModelList + session_model_price_category: SessionModelPriceCategory session_open_options: SessionOpenOptions session_open_options_additional_content_exclusion_policy: SessionOpenOptionsAdditionalContentExclusionPolicy session_open_options_additional_content_exclusion_policy_rule: SessionOpenOptionsAdditionalContentExclusionPolicyRule @@ -24521,6 +25361,9 @@ def from_dict(obj: Any) -> 'RPC': history_truncate_request = HistoryTruncateRequest.from_dict(obj.get("HistoryTruncateRequest")) history_truncate_result = HistoryTruncateResult.from_dict(obj.get("HistoryTruncateResult")) hmac_auth_info = HMACAuthInfo.from_dict(obj.get("HMACAuthInfo")) + hook_invoke_request = _HookInvokeRequest.from_dict(obj.get("HookInvokeRequest")) + hook_invoke_response = _HookInvokeResponse.from_dict(obj.get("HookInvokeResponse")) + hook_type = _HookType(obj.get("HookType")) installed_plugin = InstalledPlugin.from_dict(obj.get("InstalledPlugin")) installed_plugin_info = InstalledPluginInfo.from_dict(obj.get("InstalledPluginInfo")) installed_plugin_source = from_union([InstalledPluginSource.from_dict, from_str], obj.get("InstalledPluginSource")) @@ -24619,6 +25462,17 @@ def from_dict(obj: Any) -> 'RPC': mcp_register_external_client_request = MCPRegisterExternalClientRequest.from_dict(obj.get("McpRegisterExternalClientRequest")) mcp_reload_with_config_request = MCPReloadWithConfigRequest.from_dict(obj.get("McpReloadWithConfigRequest")) mcp_remove_git_hub_result = MCPRemoveGitHubResult.from_dict(obj.get("McpRemoveGitHubResult")) + mcp_resource = MCPResource.from_dict(obj.get("McpResource")) + mcp_resource_annotations = MCPResourceAnnotations.from_dict(obj.get("McpResourceAnnotations")) + mcp_resource_content = MCPResourceContent.from_dict(obj.get("McpResourceContent")) + mcp_resource_icon = MCPResourceIcon.from_dict(obj.get("McpResourceIcon")) + mcp_resources_list_request = MCPResourcesListRequest.from_dict(obj.get("McpResourcesListRequest")) + mcp_resources_list_result = MCPResourcesListResult.from_dict(obj.get("McpResourcesListResult")) + mcp_resources_list_templates_request = MCPResourcesListTemplatesRequest.from_dict(obj.get("McpResourcesListTemplatesRequest")) + mcp_resources_list_templates_result = MCPResourcesListTemplatesResult.from_dict(obj.get("McpResourcesListTemplatesResult")) + mcp_resources_read_request = MCPResourcesReadRequest.from_dict(obj.get("McpResourcesReadRequest")) + mcp_resources_read_result = MCPResourcesReadResult.from_dict(obj.get("McpResourcesReadResult")) + mcp_resource_template = MCPResourceTemplate.from_dict(obj.get("McpResourceTemplate")) mcp_restart_server_request = MCPRestartServerRequest.from_dict(obj.get("McpRestartServerRequest")) mcp_sampling_execution_action = MCPSamplingExecutionAction(obj.get("McpSamplingExecutionAction")) mcp_sampling_execution_result = MCPSamplingExecutionResult.from_dict(obj.get("McpSamplingExecutionResult")) @@ -24641,6 +25495,8 @@ def from_dict(obj: Any) -> 'RPC': mcp_start_servers_result = MCPStartServersResult.from_dict(obj.get("McpStartServersResult")) mcp_stop_server_request = MCPStopServerRequest.from_dict(obj.get("McpStopServerRequest")) mcp_tools = MCPTools.from_dict(obj.get("McpTools")) + mcp_tool_ui = MCPToolUI.from_dict(obj.get("McpToolUi")) + mcp_tool_ui_visibility = MCPToolUIVisibility(obj.get("McpToolUiVisibility")) mcp_unregister_external_client_request = MCPUnregisterExternalClientRequest.from_dict(obj.get("McpUnregisterExternalClientRequest")) memory_configuration = MemoryConfiguration.from_dict(obj.get("MemoryConfiguration")) metadata_context_attribution_result = MetadataContextAttributionResult.from_dict(obj.get("MetadataContextAttributionResult")) @@ -24661,6 +25517,7 @@ def from_dict(obj: Any) -> 'RPC': metadata_snapshot_remote_metadata_task_type = TaskType(obj.get("MetadataSnapshotRemoteMetadataTaskType")) model = Model.from_dict(obj.get("Model")) model_billing = ModelBilling.from_dict(obj.get("ModelBilling")) + model_billing_promo = ModelBillingPromo.from_dict(obj.get("ModelBillingPromo")) model_billing_token_prices = ModelBillingTokenPrices.from_dict(obj.get("ModelBillingTokenPrices")) model_billing_token_prices_long_context = ModelBillingTokenPricesLongContext.from_dict(obj.get("ModelBillingTokenPricesLongContext")) model_capabilities = ModelCapabilities.from_dict(obj.get("ModelCapabilities")) @@ -24904,6 +25761,9 @@ def from_dict(obj: Any) -> 'RPC': secrets_add_filter_values_result = SecretsAddFilterValuesResult.from_dict(obj.get("SecretsAddFilterValuesResult")) send_agent_mode = SendAgentMode(obj.get("SendAgentMode")) send_attachments_to_message_params = SendAttachmentsToMessageParams.from_dict(obj.get("SendAttachmentsToMessageParams")) + send_message_item = SendMessageItem.from_dict(obj.get("SendMessageItem")) + send_messages_request = SendMessagesRequest.from_dict(obj.get("SendMessagesRequest")) + send_messages_result = SendMessagesResult.from_dict(obj.get("SendMessagesResult")) send_mode = SendMode(obj.get("SendMode")) send_request = SendRequest.from_dict(obj.get("SendRequest")) send_result = SendResult.from_dict(obj.get("SendResult")) @@ -24961,6 +25821,7 @@ def from_dict(obj: Any) -> 'RPC': session_metadata_snapshot = SessionMetadataSnapshot.from_dict(obj.get("SessionMetadataSnapshot")) session_mode = SessionMode(obj.get("SessionMode")) session_model_list = SessionModelList.from_dict(obj.get("SessionModelList")) + session_model_price_category = SessionModelPriceCategory.from_dict(obj.get("SessionModelPriceCategory")) session_open_options = SessionOpenOptions.from_dict(obj.get("SessionOpenOptions")) session_open_options_additional_content_exclusion_policy = SessionOpenOptionsAdditionalContentExclusionPolicy.from_dict(obj.get("SessionOpenOptionsAdditionalContentExclusionPolicy")) session_open_options_additional_content_exclusion_policy_rule = SessionOpenOptionsAdditionalContentExclusionPolicyRule.from_dict(obj.get("SessionOpenOptionsAdditionalContentExclusionPolicyRule")) @@ -25187,7 +26048,7 @@ def from_dict(obj: Any) -> 'RPC': subagent_settings = from_union([SubagentSettings.from_dict, from_none], obj.get("SubagentSettings")) task_progress = from_union([TaskProgress.from_dict, from_none], obj.get("TaskProgress")) workspace_summary = from_union([WorkspaceSummary.from_dict, from_none], obj.get("WorkspaceSummary")) - return RPC(abort_request, abort_result, account_all_users, account_get_all_users_result, account_get_current_auth_result, account_get_quota_request, account_get_quota_result, account_login_request, account_login_result, account_logout_request, account_logout_result, account_quota_snapshot, adaptive_thinking_support, agent_discovery_path, agent_discovery_path_list, agent_discovery_path_scope, agent_get_current_result, agent_info, agent_info_source, agent_list, agent_registry_live_target_entry, agent_registry_live_target_entry_attention_kind, agent_registry_live_target_entry_kind, agent_registry_live_target_entry_last_terminal_event, agent_registry_live_target_entry_status, agent_registry_log_capture, agent_registry_log_capture_open_error_reason, agent_registry_spawn_error, agent_registry_spawn_permission_mode, agent_registry_spawn_registry_timeout, agent_registry_spawn_request, agent_registry_spawn_result, agent_registry_spawn_spawned, agent_registry_spawn_validation_error, agent_registry_spawn_validation_error_field, agent_registry_spawn_validation_error_reason, agent_reload_result, agents_discover_request, agent_select_request, agent_select_result, agents_get_discovery_paths_request, allow_all_permission_set_result, allow_all_permission_state, api_key_auth_info, auth_info, auth_info_type, cancel_user_requested_shell_command_result, canvas_action, canvas_action_invoke_request, canvas_action_invoke_result, canvas_close_request, canvas_host_context, canvas_host_context_capabilities, canvas_json_schema, canvas_list, canvas_list_open_result, canvas_open_request, canvas_provider_close_request, canvas_provider_invoke_action_request, canvas_provider_open_request, canvas_provider_open_result, canvas_session_context, capi_session_options, command_list, commands_handle_pending_command_request, commands_handle_pending_command_result, commands_invoke_request, commands_list_request, commands_respond_to_queued_command_request, commands_respond_to_queued_command_result, completions_get_trigger_characters_result, completions_request_request, completions_request_result, configure_session_extensions_params, connected_remote_session_metadata, connected_remote_session_metadata_kind, connected_remote_session_metadata_repository, connect_remote_session_params, connect_request, connect_result, content_filter_mode, context_heaviest_message, copilot_api_token_auth_info, copilot_user_response, copilot_user_response_endpoints, copilot_user_response_quota_snapshots, copilot_user_response_quota_snapshots_chat, copilot_user_response_quota_snapshots_completions, copilot_user_response_quota_snapshots_premium_interactions, current_model, current_tool_metadata, debug_collect_logs_collected_entry, debug_collect_logs_destination, debug_collect_logs_entry, debug_collect_logs_entry_kind, debug_collect_logs_include, debug_collect_logs_redaction, debug_collect_logs_request, debug_collect_logs_result, debug_collect_logs_result_kind, debug_collect_logs_skipped_entry, debug_collect_logs_source, discovered_canvas, discovered_mcp_server, discovered_mcp_server_type, enqueue_command_params, enqueue_command_result, env_auth_info, event_log_read_request, event_log_release_interest_result, event_log_tail_result, event_log_types, events_agent_scope, events_cursor_status, events_read_result, execute_command_params, execute_command_result, extension, extension_context_push_input, extension_list, extensions_disable_request, extensions_enable_request, extension_source, extension_status, external_tool_result, external_tool_text_result_for_llm, external_tool_text_result_for_llm_binary_results_for_llm, external_tool_text_result_for_llm_binary_results_for_llm_type, external_tool_text_result_for_llm_content, external_tool_text_result_for_llm_content_audio, external_tool_text_result_for_llm_content_image, external_tool_text_result_for_llm_content_resource, external_tool_text_result_for_llm_content_resource_details, external_tool_text_result_for_llm_content_resource_link, external_tool_text_result_for_llm_content_resource_link_icon, external_tool_text_result_for_llm_content_resource_link_icon_theme, external_tool_text_result_for_llm_content_shell_exit, external_tool_text_result_for_llm_content_terminal, external_tool_text_result_for_llm_content_text, filter_mapping, fleet_start_request, fleet_start_result, folder_trust_add_params, folder_trust_check_params, folder_trust_check_result, gh_cli_auth_info, git_hub_telemetry_client_info, git_hub_telemetry_event, git_hub_telemetry_notification, handle_pending_tool_call_request, handle_pending_tool_call_result, history_abort_manual_compaction_result, history_cancel_background_compaction_result, history_compact_context_window, history_compact_request, history_compact_result, history_summarize_for_handoff_result, history_truncate_request, history_truncate_result, hmac_auth_info, installed_plugin, installed_plugin_info, installed_plugin_source, installed_plugin_source_git_hub, installed_plugin_source_local, installed_plugin_source_url, instruction_discovery_path, instruction_discovery_path_kind, instruction_discovery_path_list, instruction_discovery_path_location, instructions_discover_request, instructions_get_discovery_paths_request, instructions_get_sources_result, instruction_source, instruction_source_location, instruction_source_type, llm_inference_headers, llm_inference_http_request_chunk_request, llm_inference_http_request_chunk_result, llm_inference_http_request_start_request, llm_inference_http_request_start_result, llm_inference_http_request_start_transport, llm_inference_http_response_chunk_error, llm_inference_http_response_chunk_request, llm_inference_http_response_chunk_result, llm_inference_http_response_start_request, llm_inference_http_response_start_result, llm_inference_set_provider_result, local_session_metadata_value, log_request, log_result, lsp_initialize_request, marketplace_add_result, marketplace_browse_result, marketplace_info, marketplace_list_result, marketplace_plugin_info, marketplace_refresh_entry, marketplace_refresh_result, marketplace_remove_result, mcp_allowed_server, mcp_apps_call_tool_request, mcp_apps_diagnose_capability, mcp_apps_diagnose_request, mcp_apps_diagnose_result, mcp_apps_diagnose_server, mcp_apps_host_context, mcp_apps_host_context_details, mcp_apps_host_context_details_available_display_mode, mcp_apps_host_context_details_display_mode, mcp_apps_host_context_details_platform, mcp_apps_host_context_details_theme, mcp_apps_list_tools_request, mcp_apps_list_tools_result, mcp_apps_read_resource_request, mcp_apps_read_resource_result, mcp_apps_resource_content, mcp_apps_set_host_context_details, mcp_apps_set_host_context_details_available_display_mode, mcp_apps_set_host_context_details_display_mode, mcp_apps_set_host_context_details_platform, mcp_apps_set_host_context_details_theme, mcp_apps_set_host_context_request, mcp_cancel_sampling_execution_params, mcp_cancel_sampling_execution_result, mcp_config_add_request, mcp_config_disable_request, mcp_config_enable_request, mcp_config_list, mcp_config_remove_request, mcp_config_update_request, mcp_configure_git_hub_request, mcp_configure_git_hub_result, mcp_disable_request, mcp_discover_request, mcp_discover_result, mcp_enable_request, mcp_execute_sampling_params, mcp_execute_sampling_request, mcp_execute_sampling_result, mcp_filtered_server, mcp_headers_handle_pending_headers_refresh_request, mcp_headers_handle_pending_headers_refresh_request_request, mcp_headers_handle_pending_headers_refresh_request_result, mcp_host_state, mcp_is_server_running_request, mcp_is_server_running_result, mcp_list_tools_request, mcp_list_tools_result, mcp_oauth_handle_pending_request, mcp_oauth_handle_pending_result, mcp_oauth_login_grant_type, mcp_oauth_login_request, mcp_oauth_login_result, mcp_oauth_pending_request_response, mcp_register_external_client_request, mcp_reload_with_config_request, mcp_remove_git_hub_result, mcp_restart_server_request, mcp_sampling_execution_action, mcp_sampling_execution_result, mcp_server, mcp_server_auth_config, mcp_server_auth_config_redirect_port, mcp_server_config, mcp_server_config_defer_tools, mcp_server_config_http, mcp_server_config_http_oauth_grant_type, mcp_server_config_http_type, mcp_server_config_stdio, mcp_server_failure_info, mcp_server_list, mcp_server_needs_auth_info, mcp_set_env_value_mode_details, mcp_set_env_value_mode_params, mcp_set_env_value_mode_result, mcp_start_server_request, mcp_start_servers_result, mcp_stop_server_request, mcp_tools, mcp_unregister_external_client_request, memory_configuration, metadata_context_attribution_result, metadata_context_heaviest_messages_request, metadata_context_heaviest_messages_result, metadata_context_info_request, metadata_context_info_result, metadata_is_processing_result, metadata_recompute_context_tokens_request, metadata_recompute_context_tokens_result, metadata_record_context_change_request, metadata_record_context_change_result, metadata_set_working_directory_request, metadata_set_working_directory_result, metadata_snapshot_current_mode, metadata_snapshot_remote_metadata, metadata_snapshot_remote_metadata_repository, metadata_snapshot_remote_metadata_task_type, model, model_billing, model_billing_token_prices, model_billing_token_prices_long_context, model_capabilities, model_capabilities_limits, model_capabilities_limits_vision, model_capabilities_override, model_capabilities_override_limits, model_capabilities_override_limits_vision, model_capabilities_override_supports, model_capabilities_supports, model_list, model_list_request, model_picker_category, model_picker_price_category, model_policy, model_policy_state, model_set_reasoning_effort_request, model_set_reasoning_effort_result, models_list_request, model_switch_to_request, model_switch_to_result, mode_set_request, named_provider_config, name_get_result, name_set_auto_request, name_set_auto_result, name_set_request, open_canvas_instance, options_update_additional_content_exclusion_policy, options_update_additional_content_exclusion_policy_rule, options_update_additional_content_exclusion_policy_rule_source, options_update_additional_content_exclusion_policy_scope, options_update_context_tier, options_update_env_value_mode, options_update_reasoning_summary, options_update_tool_filter_precedence, pending_permission_request, pending_permission_request_list, permission_decision, permission_decision_approved, permission_decision_approved_for_location, permission_decision_approved_for_session, permission_decision_approve_for_location, permission_decision_approve_for_location_approval, permission_decision_approve_for_location_approval_commands, permission_decision_approve_for_location_approval_custom_tool, permission_decision_approve_for_location_approval_extension_management, permission_decision_approve_for_location_approval_extension_permission_access, permission_decision_approve_for_location_approval_mcp, permission_decision_approve_for_location_approval_mcp_sampling, permission_decision_approve_for_location_approval_memory, permission_decision_approve_for_location_approval_read, permission_decision_approve_for_location_approval_write, permission_decision_approve_for_session, permission_decision_approve_for_session_approval, permission_decision_approve_for_session_approval_commands, permission_decision_approve_for_session_approval_custom_tool, permission_decision_approve_for_session_approval_extension_management, permission_decision_approve_for_session_approval_extension_permission_access, permission_decision_approve_for_session_approval_mcp, permission_decision_approve_for_session_approval_mcp_sampling, permission_decision_approve_for_session_approval_memory, permission_decision_approve_for_session_approval_read, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_cancelled, permission_decision_denied_by_content_exclusion_policy, permission_decision_denied_by_permission_request_hook, permission_decision_denied_by_rules, permission_decision_denied_interactively_by_user, permission_decision_denied_no_approval_rule_and_could_not_request_from_user, permission_decision_reject, permission_decision_request, permission_decision_user_not_available, permission_location_add_tool_approval_params, permission_location_apply_params, permission_location_apply_result, permission_location_resolve_params, permission_location_resolve_result, permission_location_type, permission_paths_add_params, permission_paths_allowed_check_params, permission_paths_allowed_check_result, permission_paths_config, permission_paths_list, permission_paths_update_primary_params, permission_paths_workspace_check_params, permission_paths_workspace_check_result, permission_prompt_shown_notification, permission_request_result, permission_rules_set, permissions_allow_all_mode, permissions_configure_additional_content_exclusion_policy, permissions_configure_additional_content_exclusion_policy_rule, permissions_configure_additional_content_exclusion_policy_rule_source, permissions_configure_additional_content_exclusion_policy_scope, permissions_configure_params, permissions_configure_result, permissions_folder_trust_add_trusted_result, permissions_get_allow_all_request, permissions_locations_add_tool_approval_details, permissions_locations_add_tool_approval_details_commands, permissions_locations_add_tool_approval_details_custom_tool, permissions_locations_add_tool_approval_details_extension_management, permissions_locations_add_tool_approval_details_extension_permission_access, permissions_locations_add_tool_approval_details_mcp, permissions_locations_add_tool_approval_details_mcp_sampling, permissions_locations_add_tool_approval_details_memory, permissions_locations_add_tool_approval_details_read, permissions_locations_add_tool_approval_details_write, permissions_locations_add_tool_approval_result, permissions_modify_rules_params, permissions_modify_rules_result, permissions_modify_rules_scope, permissions_notify_prompt_shown_result, permissions_paths_add_result, permissions_paths_list_request, permissions_paths_update_primary_result, permissions_pending_requests_request, permissions_reset_session_approvals_request, permissions_reset_session_approvals_result, permissions_set_allow_all_request, permissions_set_allow_all_source, permissions_set_approve_all_request, permissions_set_approve_all_result, permissions_set_approve_all_source, permissions_set_required_request, permissions_set_required_result, permissions_urls_set_unrestricted_mode_result, permission_urls_config, permission_urls_set_unrestricted_mode_params, ping_request, ping_result, plan_read_result, plan_read_sql_todos_result, plan_read_sql_todos_with_dependencies_result, plan_sql_todo_dependency, plan_sql_todos_row, plan_update_request, plugin, plugin_install_result, plugin_list, plugin_list_result, plugins_disable_request, plugins_enable_request, plugins_install_request, plugins_marketplaces_add_request, plugins_marketplaces_browse_request, plugins_marketplaces_refresh_request, plugins_marketplaces_remove_request, plugins_reload_request, plugins_uninstall_request, plugins_update_request, plugin_update_all_entry, plugin_update_all_result, plugin_update_result, provider_add_request, provider_add_result, provider_config, provider_config_azure, provider_config_transport, provider_config_type, provider_config_wire_api, provider_endpoint, provider_endpoint_transport, provider_endpoint_type, provider_endpoint_wire_api, provider_get_endpoint_request, provider_model_config, provider_session_token, provider_token_acquire_request, provider_token_acquire_result, push_attachment, push_attachment_blob, push_attachment_directory, push_attachment_file, push_attachment_file_line_range, push_attachment_git_hub_actions_job, push_attachment_git_hub_commit, push_attachment_git_hub_file, push_attachment_git_hub_file_diff, push_attachment_git_hub_file_diff_side, push_attachment_git_hub_reference, push_attachment_git_hub_reference_type, push_attachment_git_hub_release, push_attachment_git_hub_repository, push_attachment_git_hub_snippet, push_attachment_git_hub_tree_comparison, push_attachment_git_hub_tree_comparison_side, push_attachment_git_hub_url, push_attachment_selection, push_attachment_selection_details, push_attachment_selection_details_end, push_attachment_selection_details_start, push_git_hub_repo_ref, queued_command_handled, queued_command_not_handled, queued_command_result, queue_pending_items, queue_pending_items_kind, queue_pending_items_result, queue_remove_most_recent_result, register_event_interest_params, register_event_interest_result, register_extension_tools_params, register_extension_tools_result, release_event_interest_params, remote_control_config, remote_control_config_existing_mc_session, remote_control_status, remote_control_status_active, remote_control_status_connecting, remote_control_status_error, remote_control_status_off, remote_control_status_result, remote_control_stop_result, remote_control_transfer_result, remote_enable_request, remote_enable_result, remote_notify_steerable_changed_request, remote_notify_steerable_changed_result, remote_session_connection_result, remote_session_metadata_repository, remote_session_metadata_task_type, remote_session_metadata_value, remote_session_mode, remote_session_repository, sandbox_config, sandbox_config_user_policy, sandbox_config_user_policy_experimental, sandbox_config_user_policy_experimental_seatbelt, sandbox_config_user_policy_filesystem, sandbox_config_user_policy_network, sandbox_config_user_policy_seatbelt, schedule_entry, schedule_list, schedule_stop_request, schedule_stop_result, secrets_add_filter_values_request, secrets_add_filter_values_result, send_agent_mode, send_attachments_to_message_params, send_mode, send_request, send_result, server_agent_list, server_instruction_source_list, server_skill, server_skill_list, session_activity, session_auth_status, session_bulk_delete_result, session_capability, session_completion_item, session_context, session_context_host_type, session_enrich_metadata_result, session_fs_append_file_request, session_fs_error, session_fs_error_code, session_fs_exists_request, session_fs_exists_result, session_fs_mkdir_request, session_fs_readdir_request, session_fs_readdir_result, session_fs_readdir_with_types_entry, session_fs_readdir_with_types_entry_type, session_fs_readdir_with_types_request, session_fs_readdir_with_types_result, session_fs_read_file_request, session_fs_read_file_result, session_fs_rename_request, session_fs_rm_request, session_fs_set_provider_capabilities, session_fs_set_provider_conventions, session_fs_set_provider_request, session_fs_set_provider_result, session_fs_sqlite_exists_request, session_fs_sqlite_exists_result, session_fs_sqlite_query_request, session_fs_sqlite_query_result, session_fs_sqlite_query_type, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_installed_plugin, session_installed_plugin_source, session_installed_plugin_source_git_hub, session_installed_plugin_source_local, session_installed_plugin_source_url, session_list, session_list_entry, session_list_filter, session_load_deferred_repo_hooks_result, session_log_level, session_mcp_apps_call_tool_result, session_metadata_snapshot, session_mode, session_model_list, session_open_options, session_open_options_additional_content_exclusion_policy, session_open_options_additional_content_exclusion_policy_rule, session_open_options_additional_content_exclusion_policy_rule_source, session_open_options_additional_content_exclusion_policy_scope, session_open_options_env_value_mode, session_open_options_reasoning_summary, session_open_params, session_open_result, session_prune_result, sessions_bulk_delete_request, sessions_check_in_use_request, sessions_check_in_use_result, sessions_close_request, sessions_close_result, sessions_enrich_metadata_request, session_set_credentials_params, session_set_credentials_result, session_settings_built_in_tool_availability_snapshot, session_settings_evaluate_predicate_request, session_settings_evaluate_predicate_result, session_settings_job_snapshot, session_settings_model_snapshot, session_settings_online_evaluation_snapshot, session_settings_predicate_name, session_settings_repo_snapshot, session_settings_snapshot, session_settings_validation_snapshot, sessions_find_by_prefix_request, sessions_find_by_prefix_result, sessions_find_by_task_id_request, sessions_find_by_task_id_result, sessions_fork_request, sessions_fork_result, sessions_get_board_entry_count_request, sessions_get_board_entry_count_result, sessions_get_event_file_path_request, sessions_get_event_file_path_result, sessions_get_last_for_context_request, sessions_get_last_for_context_result, sessions_get_persisted_remote_steerable_request, sessions_get_persisted_remote_steerable_result, session_sizes, sessions_list_request, sessions_load_deferred_repo_hooks_request, sessions_open_attach, sessions_open_cloud, sessions_open_create, sessions_open_handoff, sessions_open_handoff_task_type, sessions_open_progress, sessions_open_progress_status, sessions_open_progress_step, sessions_open_remote, sessions_open_resume, sessions_open_resume_last, sessions_open_status, session_source, sessions_prune_old_request, sessions_register_extension_tools_on_session_options, sessions_release_lock_request, sessions_release_lock_result, sessions_reload_plugin_hooks_request, sessions_reload_plugin_hooks_result, sessions_save_request, sessions_save_result, sessions_set_additional_plugins_request, sessions_set_additional_plugins_result, sessions_set_remote_control_steering_request, sessions_start_remote_control_request, sessions_stop_remote_control_request, sessions_transfer_remote_control_request, session_telemetry_engagement, session_update_options_params, session_update_options_result, session_visibility_status, session_working_directory_context, session_working_directory_context_host_type, shell_cancel_user_requested_request, shell_exec_request, shell_exec_result, shell_execute_user_requested_request, shell_kill_request, shell_kill_result, shell_kill_signal, shutdown_request, skill, skill_discovery_path, skill_discovery_path_list, skill_discovery_scope, skill_list, skills_config_set_disabled_skills_request, skills_disable_request, skills_discover_request, skills_enable_request, skills_get_discovery_paths_request, skills_get_invoked_result, skills_invoked_skill, skills_load_diagnostics, slash_command_agent_prompt_result, slash_command_completed_result, slash_command_info, slash_command_input, slash_command_input_choice, slash_command_input_completion, slash_command_invocation_result, slash_command_kind, slash_command_select_subcommand_option, slash_command_select_subcommand_result, slash_command_text_result, subagent_settings_entry, subagent_settings_entry_context_tier, task_agent_info, task_agent_progress, task_execution_mode, task_info, task_list, task_progress_line, tasks_cancel_request, tasks_cancel_result, tasks_get_current_promotable_result, tasks_get_progress_request, tasks_get_progress_result, task_shell_info, task_shell_info_attachment_mode, task_shell_progress, tasks_promote_current_to_background_result, tasks_promote_to_background_request, tasks_promote_to_background_result, tasks_refresh_result, tasks_remove_request, tasks_remove_result, tasks_send_message_request, tasks_send_message_result, tasks_start_agent_request, tasks_start_agent_result, task_status, tasks_wait_for_pending_result, telemetry_set_feature_overrides_request, token_auth_info, tool, tool_list, tools_get_current_metadata_result, tools_initialize_and_validate_result, tools_list_request, tools_update_subagent_settings_result, ui_auto_mode_switch_response, ui_elicitation_array_any_of_field, ui_elicitation_array_any_of_field_items, ui_elicitation_array_any_of_field_items_any_of, ui_elicitation_array_enum_field, ui_elicitation_array_enum_field_items, ui_elicitation_field_value, ui_elicitation_request, ui_elicitation_response, ui_elicitation_response_action, ui_elicitation_response_content, ui_elicitation_result, ui_elicitation_schema, ui_elicitation_schema_property, ui_elicitation_schema_property_boolean, ui_elicitation_schema_property_number, ui_elicitation_schema_property_number_type, ui_elicitation_schema_property_string, ui_elicitation_schema_property_string_format, ui_elicitation_string_enum_field, ui_elicitation_string_one_of_field, ui_elicitation_string_one_of_field_one_of, ui_ephemeral_query_request, ui_ephemeral_query_result, ui_exit_plan_mode_action, ui_exit_plan_mode_response, ui_handle_pending_auto_mode_switch_request, ui_handle_pending_elicitation_request, ui_handle_pending_exit_plan_mode_request, ui_handle_pending_result, ui_handle_pending_sampling_request, ui_handle_pending_sampling_response, ui_handle_pending_session_limits_exhausted_request, ui_handle_pending_user_input_request, ui_register_direct_auto_mode_switch_handler_result, ui_session_limits_exhausted_response, ui_session_limits_exhausted_response_action, ui_unregister_direct_auto_mode_switch_handler_request, ui_unregister_direct_auto_mode_switch_handler_result, ui_user_input_response, update_subagent_settings_request, usage_get_metrics_result, usage_metrics_code_changes, usage_metrics_model_metric, usage_metrics_model_metric_requests, usage_metrics_model_metric_token_detail, usage_metrics_model_metric_usage, usage_metrics_token_detail, user_auth_info, user_requested_shell_command_result, user_setting_metadata, user_settings_get_result, user_settings_set_request, user_settings_set_result, visibility_get_result, visibility_set_request, visibility_set_result, workspace_diff_file_change, workspace_diff_file_change_type, workspace_diff_mode, workspace_diff_result, workspaces_checkpoints, workspaces_create_file_request, workspaces_diff_request, workspaces_get_workspace_result, workspaces_list_checkpoints_result, workspaces_list_files_result, workspaces_read_checkpoint_request, workspaces_read_checkpoint_result, workspaces_read_file_request, workspaces_read_file_result, workspaces_save_large_paste_request, workspaces_save_large_paste_result, workspace_summary_host_type, workspaces_workspace_details_host_type, session_context_attribution, session_context_info, subagent_settings, task_progress, workspace_summary) + return RPC(abort_request, abort_result, account_all_users, account_get_all_users_result, account_get_current_auth_result, account_get_quota_request, account_get_quota_result, account_login_request, account_login_result, account_logout_request, account_logout_result, account_quota_snapshot, adaptive_thinking_support, agent_discovery_path, agent_discovery_path_list, agent_discovery_path_scope, agent_get_current_result, agent_info, agent_info_source, agent_list, agent_registry_live_target_entry, agent_registry_live_target_entry_attention_kind, agent_registry_live_target_entry_kind, agent_registry_live_target_entry_last_terminal_event, agent_registry_live_target_entry_status, agent_registry_log_capture, agent_registry_log_capture_open_error_reason, agent_registry_spawn_error, agent_registry_spawn_permission_mode, agent_registry_spawn_registry_timeout, agent_registry_spawn_request, agent_registry_spawn_result, agent_registry_spawn_spawned, agent_registry_spawn_validation_error, agent_registry_spawn_validation_error_field, agent_registry_spawn_validation_error_reason, agent_reload_result, agents_discover_request, agent_select_request, agent_select_result, agents_get_discovery_paths_request, allow_all_permission_set_result, allow_all_permission_state, api_key_auth_info, auth_info, auth_info_type, cancel_user_requested_shell_command_result, canvas_action, canvas_action_invoke_request, canvas_action_invoke_result, canvas_close_request, canvas_host_context, canvas_host_context_capabilities, canvas_json_schema, canvas_list, canvas_list_open_result, canvas_open_request, canvas_provider_close_request, canvas_provider_invoke_action_request, canvas_provider_open_request, canvas_provider_open_result, canvas_session_context, capi_session_options, command_list, commands_handle_pending_command_request, commands_handle_pending_command_result, commands_invoke_request, commands_list_request, commands_respond_to_queued_command_request, commands_respond_to_queued_command_result, completions_get_trigger_characters_result, completions_request_request, completions_request_result, configure_session_extensions_params, connected_remote_session_metadata, connected_remote_session_metadata_kind, connected_remote_session_metadata_repository, connect_remote_session_params, connect_request, connect_result, content_filter_mode, context_heaviest_message, copilot_api_token_auth_info, copilot_user_response, copilot_user_response_endpoints, copilot_user_response_quota_snapshots, copilot_user_response_quota_snapshots_chat, copilot_user_response_quota_snapshots_completions, copilot_user_response_quota_snapshots_premium_interactions, current_model, current_tool_metadata, debug_collect_logs_collected_entry, debug_collect_logs_destination, debug_collect_logs_entry, debug_collect_logs_entry_kind, debug_collect_logs_include, debug_collect_logs_redaction, debug_collect_logs_request, debug_collect_logs_result, debug_collect_logs_result_kind, debug_collect_logs_skipped_entry, debug_collect_logs_source, discovered_canvas, discovered_mcp_server, discovered_mcp_server_type, enqueue_command_params, enqueue_command_result, env_auth_info, event_log_read_request, event_log_release_interest_result, event_log_tail_result, event_log_types, events_agent_scope, events_cursor_status, events_read_result, execute_command_params, execute_command_result, extension, extension_context_push_input, extension_list, extensions_disable_request, extensions_enable_request, extension_source, extension_status, external_tool_result, external_tool_text_result_for_llm, external_tool_text_result_for_llm_binary_results_for_llm, external_tool_text_result_for_llm_binary_results_for_llm_type, external_tool_text_result_for_llm_content, external_tool_text_result_for_llm_content_audio, external_tool_text_result_for_llm_content_image, external_tool_text_result_for_llm_content_resource, external_tool_text_result_for_llm_content_resource_details, external_tool_text_result_for_llm_content_resource_link, external_tool_text_result_for_llm_content_resource_link_icon, external_tool_text_result_for_llm_content_resource_link_icon_theme, external_tool_text_result_for_llm_content_shell_exit, external_tool_text_result_for_llm_content_terminal, external_tool_text_result_for_llm_content_text, filter_mapping, fleet_start_request, fleet_start_result, folder_trust_add_params, folder_trust_check_params, folder_trust_check_result, gh_cli_auth_info, git_hub_telemetry_client_info, git_hub_telemetry_event, git_hub_telemetry_notification, handle_pending_tool_call_request, handle_pending_tool_call_result, history_abort_manual_compaction_result, history_cancel_background_compaction_result, history_compact_context_window, history_compact_request, history_compact_result, history_summarize_for_handoff_result, history_truncate_request, history_truncate_result, hmac_auth_info, hook_invoke_request, hook_invoke_response, hook_type, installed_plugin, installed_plugin_info, installed_plugin_source, installed_plugin_source_git_hub, installed_plugin_source_local, installed_plugin_source_url, instruction_discovery_path, instruction_discovery_path_kind, instruction_discovery_path_list, instruction_discovery_path_location, instructions_discover_request, instructions_get_discovery_paths_request, instructions_get_sources_result, instruction_source, instruction_source_location, instruction_source_type, llm_inference_headers, llm_inference_http_request_chunk_request, llm_inference_http_request_chunk_result, llm_inference_http_request_start_request, llm_inference_http_request_start_result, llm_inference_http_request_start_transport, llm_inference_http_response_chunk_error, llm_inference_http_response_chunk_request, llm_inference_http_response_chunk_result, llm_inference_http_response_start_request, llm_inference_http_response_start_result, llm_inference_set_provider_result, local_session_metadata_value, log_request, log_result, lsp_initialize_request, marketplace_add_result, marketplace_browse_result, marketplace_info, marketplace_list_result, marketplace_plugin_info, marketplace_refresh_entry, marketplace_refresh_result, marketplace_remove_result, mcp_allowed_server, mcp_apps_call_tool_request, mcp_apps_diagnose_capability, mcp_apps_diagnose_request, mcp_apps_diagnose_result, mcp_apps_diagnose_server, mcp_apps_host_context, mcp_apps_host_context_details, mcp_apps_host_context_details_available_display_mode, mcp_apps_host_context_details_display_mode, mcp_apps_host_context_details_platform, mcp_apps_host_context_details_theme, mcp_apps_list_tools_request, mcp_apps_list_tools_result, mcp_apps_read_resource_request, mcp_apps_read_resource_result, mcp_apps_resource_content, mcp_apps_set_host_context_details, mcp_apps_set_host_context_details_available_display_mode, mcp_apps_set_host_context_details_display_mode, mcp_apps_set_host_context_details_platform, mcp_apps_set_host_context_details_theme, mcp_apps_set_host_context_request, mcp_cancel_sampling_execution_params, mcp_cancel_sampling_execution_result, mcp_config_add_request, mcp_config_disable_request, mcp_config_enable_request, mcp_config_list, mcp_config_remove_request, mcp_config_update_request, mcp_configure_git_hub_request, mcp_configure_git_hub_result, mcp_disable_request, mcp_discover_request, mcp_discover_result, mcp_enable_request, mcp_execute_sampling_params, mcp_execute_sampling_request, mcp_execute_sampling_result, mcp_filtered_server, mcp_headers_handle_pending_headers_refresh_request, mcp_headers_handle_pending_headers_refresh_request_request, mcp_headers_handle_pending_headers_refresh_request_result, mcp_host_state, mcp_is_server_running_request, mcp_is_server_running_result, mcp_list_tools_request, mcp_list_tools_result, mcp_oauth_handle_pending_request, mcp_oauth_handle_pending_result, mcp_oauth_login_grant_type, mcp_oauth_login_request, mcp_oauth_login_result, mcp_oauth_pending_request_response, mcp_register_external_client_request, mcp_reload_with_config_request, mcp_remove_git_hub_result, mcp_resource, mcp_resource_annotations, mcp_resource_content, mcp_resource_icon, mcp_resources_list_request, mcp_resources_list_result, mcp_resources_list_templates_request, mcp_resources_list_templates_result, mcp_resources_read_request, mcp_resources_read_result, mcp_resource_template, mcp_restart_server_request, mcp_sampling_execution_action, mcp_sampling_execution_result, mcp_server, mcp_server_auth_config, mcp_server_auth_config_redirect_port, mcp_server_config, mcp_server_config_defer_tools, mcp_server_config_http, mcp_server_config_http_oauth_grant_type, mcp_server_config_http_type, mcp_server_config_stdio, mcp_server_failure_info, mcp_server_list, mcp_server_needs_auth_info, mcp_set_env_value_mode_details, mcp_set_env_value_mode_params, mcp_set_env_value_mode_result, mcp_start_server_request, mcp_start_servers_result, mcp_stop_server_request, mcp_tools, mcp_tool_ui, mcp_tool_ui_visibility, mcp_unregister_external_client_request, memory_configuration, metadata_context_attribution_result, metadata_context_heaviest_messages_request, metadata_context_heaviest_messages_result, metadata_context_info_request, metadata_context_info_result, metadata_is_processing_result, metadata_recompute_context_tokens_request, metadata_recompute_context_tokens_result, metadata_record_context_change_request, metadata_record_context_change_result, metadata_set_working_directory_request, metadata_set_working_directory_result, metadata_snapshot_current_mode, metadata_snapshot_remote_metadata, metadata_snapshot_remote_metadata_repository, metadata_snapshot_remote_metadata_task_type, model, model_billing, model_billing_promo, model_billing_token_prices, model_billing_token_prices_long_context, model_capabilities, model_capabilities_limits, model_capabilities_limits_vision, model_capabilities_override, model_capabilities_override_limits, model_capabilities_override_limits_vision, model_capabilities_override_supports, model_capabilities_supports, model_list, model_list_request, model_picker_category, model_picker_price_category, model_policy, model_policy_state, model_set_reasoning_effort_request, model_set_reasoning_effort_result, models_list_request, model_switch_to_request, model_switch_to_result, mode_set_request, named_provider_config, name_get_result, name_set_auto_request, name_set_auto_result, name_set_request, open_canvas_instance, options_update_additional_content_exclusion_policy, options_update_additional_content_exclusion_policy_rule, options_update_additional_content_exclusion_policy_rule_source, options_update_additional_content_exclusion_policy_scope, options_update_context_tier, options_update_env_value_mode, options_update_reasoning_summary, options_update_tool_filter_precedence, pending_permission_request, pending_permission_request_list, permission_decision, permission_decision_approved, permission_decision_approved_for_location, permission_decision_approved_for_session, permission_decision_approve_for_location, permission_decision_approve_for_location_approval, permission_decision_approve_for_location_approval_commands, permission_decision_approve_for_location_approval_custom_tool, permission_decision_approve_for_location_approval_extension_management, permission_decision_approve_for_location_approval_extension_permission_access, permission_decision_approve_for_location_approval_mcp, permission_decision_approve_for_location_approval_mcp_sampling, permission_decision_approve_for_location_approval_memory, permission_decision_approve_for_location_approval_read, permission_decision_approve_for_location_approval_write, permission_decision_approve_for_session, permission_decision_approve_for_session_approval, permission_decision_approve_for_session_approval_commands, permission_decision_approve_for_session_approval_custom_tool, permission_decision_approve_for_session_approval_extension_management, permission_decision_approve_for_session_approval_extension_permission_access, permission_decision_approve_for_session_approval_mcp, permission_decision_approve_for_session_approval_mcp_sampling, permission_decision_approve_for_session_approval_memory, permission_decision_approve_for_session_approval_read, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_cancelled, permission_decision_denied_by_content_exclusion_policy, permission_decision_denied_by_permission_request_hook, permission_decision_denied_by_rules, permission_decision_denied_interactively_by_user, permission_decision_denied_no_approval_rule_and_could_not_request_from_user, permission_decision_reject, permission_decision_request, permission_decision_user_not_available, permission_location_add_tool_approval_params, permission_location_apply_params, permission_location_apply_result, permission_location_resolve_params, permission_location_resolve_result, permission_location_type, permission_paths_add_params, permission_paths_allowed_check_params, permission_paths_allowed_check_result, permission_paths_config, permission_paths_list, permission_paths_update_primary_params, permission_paths_workspace_check_params, permission_paths_workspace_check_result, permission_prompt_shown_notification, permission_request_result, permission_rules_set, permissions_allow_all_mode, permissions_configure_additional_content_exclusion_policy, permissions_configure_additional_content_exclusion_policy_rule, permissions_configure_additional_content_exclusion_policy_rule_source, permissions_configure_additional_content_exclusion_policy_scope, permissions_configure_params, permissions_configure_result, permissions_folder_trust_add_trusted_result, permissions_get_allow_all_request, permissions_locations_add_tool_approval_details, permissions_locations_add_tool_approval_details_commands, permissions_locations_add_tool_approval_details_custom_tool, permissions_locations_add_tool_approval_details_extension_management, permissions_locations_add_tool_approval_details_extension_permission_access, permissions_locations_add_tool_approval_details_mcp, permissions_locations_add_tool_approval_details_mcp_sampling, permissions_locations_add_tool_approval_details_memory, permissions_locations_add_tool_approval_details_read, permissions_locations_add_tool_approval_details_write, permissions_locations_add_tool_approval_result, permissions_modify_rules_params, permissions_modify_rules_result, permissions_modify_rules_scope, permissions_notify_prompt_shown_result, permissions_paths_add_result, permissions_paths_list_request, permissions_paths_update_primary_result, permissions_pending_requests_request, permissions_reset_session_approvals_request, permissions_reset_session_approvals_result, permissions_set_allow_all_request, permissions_set_allow_all_source, permissions_set_approve_all_request, permissions_set_approve_all_result, permissions_set_approve_all_source, permissions_set_required_request, permissions_set_required_result, permissions_urls_set_unrestricted_mode_result, permission_urls_config, permission_urls_set_unrestricted_mode_params, ping_request, ping_result, plan_read_result, plan_read_sql_todos_result, plan_read_sql_todos_with_dependencies_result, plan_sql_todo_dependency, plan_sql_todos_row, plan_update_request, plugin, plugin_install_result, plugin_list, plugin_list_result, plugins_disable_request, plugins_enable_request, plugins_install_request, plugins_marketplaces_add_request, plugins_marketplaces_browse_request, plugins_marketplaces_refresh_request, plugins_marketplaces_remove_request, plugins_reload_request, plugins_uninstall_request, plugins_update_request, plugin_update_all_entry, plugin_update_all_result, plugin_update_result, provider_add_request, provider_add_result, provider_config, provider_config_azure, provider_config_transport, provider_config_type, provider_config_wire_api, provider_endpoint, provider_endpoint_transport, provider_endpoint_type, provider_endpoint_wire_api, provider_get_endpoint_request, provider_model_config, provider_session_token, provider_token_acquire_request, provider_token_acquire_result, push_attachment, push_attachment_blob, push_attachment_directory, push_attachment_file, push_attachment_file_line_range, push_attachment_git_hub_actions_job, push_attachment_git_hub_commit, push_attachment_git_hub_file, push_attachment_git_hub_file_diff, push_attachment_git_hub_file_diff_side, push_attachment_git_hub_reference, push_attachment_git_hub_reference_type, push_attachment_git_hub_release, push_attachment_git_hub_repository, push_attachment_git_hub_snippet, push_attachment_git_hub_tree_comparison, push_attachment_git_hub_tree_comparison_side, push_attachment_git_hub_url, push_attachment_selection, push_attachment_selection_details, push_attachment_selection_details_end, push_attachment_selection_details_start, push_git_hub_repo_ref, queued_command_handled, queued_command_not_handled, queued_command_result, queue_pending_items, queue_pending_items_kind, queue_pending_items_result, queue_remove_most_recent_result, register_event_interest_params, register_event_interest_result, register_extension_tools_params, register_extension_tools_result, release_event_interest_params, remote_control_config, remote_control_config_existing_mc_session, remote_control_status, remote_control_status_active, remote_control_status_connecting, remote_control_status_error, remote_control_status_off, remote_control_status_result, remote_control_stop_result, remote_control_transfer_result, remote_enable_request, remote_enable_result, remote_notify_steerable_changed_request, remote_notify_steerable_changed_result, remote_session_connection_result, remote_session_metadata_repository, remote_session_metadata_task_type, remote_session_metadata_value, remote_session_mode, remote_session_repository, sandbox_config, sandbox_config_user_policy, sandbox_config_user_policy_experimental, sandbox_config_user_policy_experimental_seatbelt, sandbox_config_user_policy_filesystem, sandbox_config_user_policy_network, sandbox_config_user_policy_seatbelt, schedule_entry, schedule_list, schedule_stop_request, schedule_stop_result, secrets_add_filter_values_request, secrets_add_filter_values_result, send_agent_mode, send_attachments_to_message_params, send_message_item, send_messages_request, send_messages_result, send_mode, send_request, send_result, server_agent_list, server_instruction_source_list, server_skill, server_skill_list, session_activity, session_auth_status, session_bulk_delete_result, session_capability, session_completion_item, session_context, session_context_host_type, session_enrich_metadata_result, session_fs_append_file_request, session_fs_error, session_fs_error_code, session_fs_exists_request, session_fs_exists_result, session_fs_mkdir_request, session_fs_readdir_request, session_fs_readdir_result, session_fs_readdir_with_types_entry, session_fs_readdir_with_types_entry_type, session_fs_readdir_with_types_request, session_fs_readdir_with_types_result, session_fs_read_file_request, session_fs_read_file_result, session_fs_rename_request, session_fs_rm_request, session_fs_set_provider_capabilities, session_fs_set_provider_conventions, session_fs_set_provider_request, session_fs_set_provider_result, session_fs_sqlite_exists_request, session_fs_sqlite_exists_result, session_fs_sqlite_query_request, session_fs_sqlite_query_result, session_fs_sqlite_query_type, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_installed_plugin, session_installed_plugin_source, session_installed_plugin_source_git_hub, session_installed_plugin_source_local, session_installed_plugin_source_url, session_list, session_list_entry, session_list_filter, session_load_deferred_repo_hooks_result, session_log_level, session_mcp_apps_call_tool_result, session_metadata_snapshot, session_mode, session_model_list, session_model_price_category, session_open_options, session_open_options_additional_content_exclusion_policy, session_open_options_additional_content_exclusion_policy_rule, session_open_options_additional_content_exclusion_policy_rule_source, session_open_options_additional_content_exclusion_policy_scope, session_open_options_env_value_mode, session_open_options_reasoning_summary, session_open_params, session_open_result, session_prune_result, sessions_bulk_delete_request, sessions_check_in_use_request, sessions_check_in_use_result, sessions_close_request, sessions_close_result, sessions_enrich_metadata_request, session_set_credentials_params, session_set_credentials_result, session_settings_built_in_tool_availability_snapshot, session_settings_evaluate_predicate_request, session_settings_evaluate_predicate_result, session_settings_job_snapshot, session_settings_model_snapshot, session_settings_online_evaluation_snapshot, session_settings_predicate_name, session_settings_repo_snapshot, session_settings_snapshot, session_settings_validation_snapshot, sessions_find_by_prefix_request, sessions_find_by_prefix_result, sessions_find_by_task_id_request, sessions_find_by_task_id_result, sessions_fork_request, sessions_fork_result, sessions_get_board_entry_count_request, sessions_get_board_entry_count_result, sessions_get_event_file_path_request, sessions_get_event_file_path_result, sessions_get_last_for_context_request, sessions_get_last_for_context_result, sessions_get_persisted_remote_steerable_request, sessions_get_persisted_remote_steerable_result, session_sizes, sessions_list_request, sessions_load_deferred_repo_hooks_request, sessions_open_attach, sessions_open_cloud, sessions_open_create, sessions_open_handoff, sessions_open_handoff_task_type, sessions_open_progress, sessions_open_progress_status, sessions_open_progress_step, sessions_open_remote, sessions_open_resume, sessions_open_resume_last, sessions_open_status, session_source, sessions_prune_old_request, sessions_register_extension_tools_on_session_options, sessions_release_lock_request, sessions_release_lock_result, sessions_reload_plugin_hooks_request, sessions_reload_plugin_hooks_result, sessions_save_request, sessions_save_result, sessions_set_additional_plugins_request, sessions_set_additional_plugins_result, sessions_set_remote_control_steering_request, sessions_start_remote_control_request, sessions_stop_remote_control_request, sessions_transfer_remote_control_request, session_telemetry_engagement, session_update_options_params, session_update_options_result, session_visibility_status, session_working_directory_context, session_working_directory_context_host_type, shell_cancel_user_requested_request, shell_exec_request, shell_exec_result, shell_execute_user_requested_request, shell_kill_request, shell_kill_result, shell_kill_signal, shutdown_request, skill, skill_discovery_path, skill_discovery_path_list, skill_discovery_scope, skill_list, skills_config_set_disabled_skills_request, skills_disable_request, skills_discover_request, skills_enable_request, skills_get_discovery_paths_request, skills_get_invoked_result, skills_invoked_skill, skills_load_diagnostics, slash_command_agent_prompt_result, slash_command_completed_result, slash_command_info, slash_command_input, slash_command_input_choice, slash_command_input_completion, slash_command_invocation_result, slash_command_kind, slash_command_select_subcommand_option, slash_command_select_subcommand_result, slash_command_text_result, subagent_settings_entry, subagent_settings_entry_context_tier, task_agent_info, task_agent_progress, task_execution_mode, task_info, task_list, task_progress_line, tasks_cancel_request, tasks_cancel_result, tasks_get_current_promotable_result, tasks_get_progress_request, tasks_get_progress_result, task_shell_info, task_shell_info_attachment_mode, task_shell_progress, tasks_promote_current_to_background_result, tasks_promote_to_background_request, tasks_promote_to_background_result, tasks_refresh_result, tasks_remove_request, tasks_remove_result, tasks_send_message_request, tasks_send_message_result, tasks_start_agent_request, tasks_start_agent_result, task_status, tasks_wait_for_pending_result, telemetry_set_feature_overrides_request, token_auth_info, tool, tool_list, tools_get_current_metadata_result, tools_initialize_and_validate_result, tools_list_request, tools_update_subagent_settings_result, ui_auto_mode_switch_response, ui_elicitation_array_any_of_field, ui_elicitation_array_any_of_field_items, ui_elicitation_array_any_of_field_items_any_of, ui_elicitation_array_enum_field, ui_elicitation_array_enum_field_items, ui_elicitation_field_value, ui_elicitation_request, ui_elicitation_response, ui_elicitation_response_action, ui_elicitation_response_content, ui_elicitation_result, ui_elicitation_schema, ui_elicitation_schema_property, ui_elicitation_schema_property_boolean, ui_elicitation_schema_property_number, ui_elicitation_schema_property_number_type, ui_elicitation_schema_property_string, ui_elicitation_schema_property_string_format, ui_elicitation_string_enum_field, ui_elicitation_string_one_of_field, ui_elicitation_string_one_of_field_one_of, ui_ephemeral_query_request, ui_ephemeral_query_result, ui_exit_plan_mode_action, ui_exit_plan_mode_response, ui_handle_pending_auto_mode_switch_request, ui_handle_pending_elicitation_request, ui_handle_pending_exit_plan_mode_request, ui_handle_pending_result, ui_handle_pending_sampling_request, ui_handle_pending_sampling_response, ui_handle_pending_session_limits_exhausted_request, ui_handle_pending_user_input_request, ui_register_direct_auto_mode_switch_handler_result, ui_session_limits_exhausted_response, ui_session_limits_exhausted_response_action, ui_unregister_direct_auto_mode_switch_handler_request, ui_unregister_direct_auto_mode_switch_handler_result, ui_user_input_response, update_subagent_settings_request, usage_get_metrics_result, usage_metrics_code_changes, usage_metrics_model_metric, usage_metrics_model_metric_requests, usage_metrics_model_metric_token_detail, usage_metrics_model_metric_usage, usage_metrics_token_detail, user_auth_info, user_requested_shell_command_result, user_setting_metadata, user_settings_get_result, user_settings_set_request, user_settings_set_result, visibility_get_result, visibility_set_request, visibility_set_result, workspace_diff_file_change, workspace_diff_file_change_type, workspace_diff_mode, workspace_diff_result, workspaces_checkpoints, workspaces_create_file_request, workspaces_diff_request, workspaces_get_workspace_result, workspaces_list_checkpoints_result, workspaces_list_files_result, workspaces_read_checkpoint_request, workspaces_read_checkpoint_result, workspaces_read_file_request, workspaces_read_file_result, workspaces_save_large_paste_request, workspaces_save_large_paste_result, workspace_summary_host_type, workspaces_workspace_details_host_type, session_context_attribution, session_context_info, subagent_settings, task_progress, workspace_summary) def to_dict(self) -> dict: result: dict = {} @@ -25351,6 +26212,9 @@ def to_dict(self) -> dict: result["HistoryTruncateRequest"] = to_class(HistoryTruncateRequest, self.history_truncate_request) result["HistoryTruncateResult"] = to_class(HistoryTruncateResult, self.history_truncate_result) result["HMACAuthInfo"] = to_class(HMACAuthInfo, self.hmac_auth_info) + result["HookInvokeRequest"] = to_class(_HookInvokeRequest, self.hook_invoke_request) + result["HookInvokeResponse"] = to_class(_HookInvokeResponse, self.hook_invoke_response) + result["HookType"] = to_enum(_HookType, self.hook_type) result["InstalledPlugin"] = to_class(InstalledPlugin, self.installed_plugin) result["InstalledPluginInfo"] = to_class(InstalledPluginInfo, self.installed_plugin_info) result["InstalledPluginSource"] = from_union([lambda x: to_class(InstalledPluginSource, x), from_str], self.installed_plugin_source) @@ -25449,6 +26313,17 @@ def to_dict(self) -> dict: result["McpRegisterExternalClientRequest"] = to_class(MCPRegisterExternalClientRequest, self.mcp_register_external_client_request) result["McpReloadWithConfigRequest"] = to_class(MCPReloadWithConfigRequest, self.mcp_reload_with_config_request) result["McpRemoveGitHubResult"] = to_class(MCPRemoveGitHubResult, self.mcp_remove_git_hub_result) + result["McpResource"] = to_class(MCPResource, self.mcp_resource) + result["McpResourceAnnotations"] = to_class(MCPResourceAnnotations, self.mcp_resource_annotations) + result["McpResourceContent"] = to_class(MCPResourceContent, self.mcp_resource_content) + result["McpResourceIcon"] = to_class(MCPResourceIcon, self.mcp_resource_icon) + result["McpResourcesListRequest"] = to_class(MCPResourcesListRequest, self.mcp_resources_list_request) + result["McpResourcesListResult"] = to_class(MCPResourcesListResult, self.mcp_resources_list_result) + result["McpResourcesListTemplatesRequest"] = to_class(MCPResourcesListTemplatesRequest, self.mcp_resources_list_templates_request) + result["McpResourcesListTemplatesResult"] = to_class(MCPResourcesListTemplatesResult, self.mcp_resources_list_templates_result) + result["McpResourcesReadRequest"] = to_class(MCPResourcesReadRequest, self.mcp_resources_read_request) + result["McpResourcesReadResult"] = to_class(MCPResourcesReadResult, self.mcp_resources_read_result) + result["McpResourceTemplate"] = to_class(MCPResourceTemplate, self.mcp_resource_template) result["McpRestartServerRequest"] = to_class(MCPRestartServerRequest, self.mcp_restart_server_request) result["McpSamplingExecutionAction"] = to_enum(MCPSamplingExecutionAction, self.mcp_sampling_execution_action) result["McpSamplingExecutionResult"] = to_class(MCPSamplingExecutionResult, self.mcp_sampling_execution_result) @@ -25471,6 +26346,8 @@ def to_dict(self) -> dict: result["McpStartServersResult"] = to_class(MCPStartServersResult, self.mcp_start_servers_result) result["McpStopServerRequest"] = to_class(MCPStopServerRequest, self.mcp_stop_server_request) result["McpTools"] = to_class(MCPTools, self.mcp_tools) + result["McpToolUi"] = to_class(MCPToolUI, self.mcp_tool_ui) + result["McpToolUiVisibility"] = to_enum(MCPToolUIVisibility, self.mcp_tool_ui_visibility) result["McpUnregisterExternalClientRequest"] = to_class(MCPUnregisterExternalClientRequest, self.mcp_unregister_external_client_request) result["MemoryConfiguration"] = to_class(MemoryConfiguration, self.memory_configuration) result["MetadataContextAttributionResult"] = to_class(MetadataContextAttributionResult, self.metadata_context_attribution_result) @@ -25491,6 +26368,7 @@ def to_dict(self) -> dict: result["MetadataSnapshotRemoteMetadataTaskType"] = to_enum(TaskType, self.metadata_snapshot_remote_metadata_task_type) result["Model"] = to_class(Model, self.model) result["ModelBilling"] = to_class(ModelBilling, self.model_billing) + result["ModelBillingPromo"] = to_class(ModelBillingPromo, self.model_billing_promo) result["ModelBillingTokenPrices"] = to_class(ModelBillingTokenPrices, self.model_billing_token_prices) result["ModelBillingTokenPricesLongContext"] = to_class(ModelBillingTokenPricesLongContext, self.model_billing_token_prices_long_context) result["ModelCapabilities"] = to_class(ModelCapabilities, self.model_capabilities) @@ -25734,6 +26612,9 @@ def to_dict(self) -> dict: result["SecretsAddFilterValuesResult"] = to_class(SecretsAddFilterValuesResult, self.secrets_add_filter_values_result) result["SendAgentMode"] = to_enum(SendAgentMode, self.send_agent_mode) result["SendAttachmentsToMessageParams"] = to_class(SendAttachmentsToMessageParams, self.send_attachments_to_message_params) + result["SendMessageItem"] = to_class(SendMessageItem, self.send_message_item) + result["SendMessagesRequest"] = to_class(SendMessagesRequest, self.send_messages_request) + result["SendMessagesResult"] = to_class(SendMessagesResult, self.send_messages_result) result["SendMode"] = to_enum(SendMode, self.send_mode) result["SendRequest"] = to_class(SendRequest, self.send_request) result["SendResult"] = to_class(SendResult, self.send_result) @@ -25791,6 +26672,7 @@ def to_dict(self) -> dict: result["SessionMetadataSnapshot"] = to_class(SessionMetadataSnapshot, self.session_metadata_snapshot) result["SessionMode"] = to_enum(SessionMode, self.session_mode) result["SessionModelList"] = to_class(SessionModelList, self.session_model_list) + result["SessionModelPriceCategory"] = to_class(SessionModelPriceCategory, self.session_model_price_category) result["SessionOpenOptions"] = to_class(SessionOpenOptions, self.session_open_options) result["SessionOpenOptionsAdditionalContentExclusionPolicy"] = to_class(SessionOpenOptionsAdditionalContentExclusionPolicy, self.session_open_options_additional_content_exclusion_policy) result["SessionOpenOptionsAdditionalContentExclusionPolicyRule"] = to_class(SessionOpenOptionsAdditionalContentExclusionPolicyRule, self.session_open_options_additional_content_exclusion_policy_rule) @@ -26448,7 +27330,7 @@ async def list(self, *, timeout: float | None = None) -> MarketplaceListResult: return MarketplaceListResult.from_dict(await self._client.request("plugins.marketplaces.list", {}, **_timeout_kwargs(timeout))) async def add(self, params: PluginsMarketplacesAddRequest, *, timeout: float | None = None) -> MarketplaceAddResult: - "Registers a new marketplace from a source (owner/repo, URL, or local path).\n\nArgs:\n params: Marketplace source to register.\n\nReturns:\n Result of registering a new marketplace." + "Registers a new marketplace from a source (owner/repo, URL, or local path).\n\nArgs:\n params: Marketplace source and optional working directory for relative-path resolution.\n\nReturns:\n Result of registering a new marketplace." params_dict = {k: v for k, v in params.to_dict().items() if v is not None} return MarketplaceAddResult.from_dict(await self._client.request("plugins.marketplaces.add", params_dict, **_timeout_kwargs(timeout))) @@ -26568,6 +27450,16 @@ async def get_discovery_paths(self, params: InstructionsGetDiscoveryPathsRequest return InstructionDiscoveryPathList.from_dict(await self._client.request("instructions.getDiscoveryPaths", params_dict, **_timeout_kwargs(timeout))) +# Experimental: this API group is experimental and may change or be removed. +class ServerCommandsApi: + def __init__(self, client: "JsonRpcClient"): + self._client = client + + async def list(self, *, timeout: float | None = None) -> CommandList: + "Lists the well-known built-in slash commands that work as the first message in a new session (e.g. /plan, /env), without requiring an active session. Commands that depend on session state, authentication, or a synced session are omitted.\n\nReturns:\n Slash commands available in the session, after applying any include/exclude filters." + return CommandList.from_dict(await self._client.request("commands.list", {}, **_timeout_kwargs(timeout))) + + # Experimental: this API group is experimental and may change or be removed. class ServerUserSettingsApi: def __init__(self, client: "JsonRpcClient"): @@ -26778,6 +27670,7 @@ def __init__(self, client: "JsonRpcClient"): self.skills = ServerSkillsApi(client) self.agents = ServerAgentsApi(client) self.instructions = ServerInstructionsApi(client) + self.commands = ServerCommandsApi(client) self.user = ServerUserApi(client) self.runtime = ServerRuntimeApi(client) self.session_fs = ServerSessionFsApi(client) @@ -27293,6 +28186,31 @@ async def diagnose(self, params: MCPAppsDiagnoseRequest, *, timeout: float | Non return MCPAppsDiagnoseResult.from_dict(await self._client.request("session.mcp.apps.diagnose", params_dict, **_timeout_kwargs(timeout))) +# Experimental: this API group is experimental and may change or be removed. +class McpResourcesApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def read(self, params: MCPResourcesReadRequest, *, timeout: float | None = None) -> MCPResourcesReadResult: + "Fetch an MCP resource from a connected server by URI (proxies MCP `resources/read`).\n\nArgs:\n params: MCP server and resource URI to fetch.\n\nReturns:\n Resource contents returned by the MCP server." + params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return MCPResourcesReadResult.from_dict(await self._client.request("session.mcp.resources.read", params_dict, **_timeout_kwargs(timeout))) + + async def list(self, params: MCPResourcesListRequest, *, timeout: float | None = None) -> MCPResourcesListResult: + "Enumerate one page of resources a connected MCP server exposes (proxies MCP `resources/list`). Pass `cursor` to continue from a prior result's `nextCursor`.\n\nArgs:\n params: MCP server whose resources to enumerate.\n\nReturns:\n One page of resources advertised by the named MCP server." + params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return MCPResourcesListResult.from_dict(await self._client.request("session.mcp.resources.list", params_dict, **_timeout_kwargs(timeout))) + + async def list_templates(self, params: MCPResourcesListTemplatesRequest, *, timeout: float | None = None) -> MCPResourcesListTemplatesResult: + "Enumerate one page of resource templates a connected MCP server exposes (proxies MCP `resources/templates/list`). Pass `cursor` to continue from a prior result's `nextCursor`.\n\nArgs:\n params: MCP server whose resource templates to enumerate.\n\nReturns:\n One page of resource templates advertised by the named MCP server." + params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return MCPResourcesListTemplatesResult.from_dict(await self._client.request("session.mcp.resources.listTemplates", params_dict, **_timeout_kwargs(timeout))) + + # Experimental: this API group is experimental and may change or be removed. class McpApi: def __init__(self, client: "JsonRpcClient", session_id: str): @@ -27301,13 +28219,14 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self.oauth = McpOauthApi(client, session_id) self.headers = McpHeadersApi(client, session_id) self.apps = McpAppsApi(client, session_id) + self.resources = McpResourcesApi(client, session_id) async def list(self, *, timeout: float | None = None) -> MCPServerList: "Lists MCP servers configured for the session, their connection status, and host-level state. The host-level state (disabled/filtered servers, failed/needs-auth/pending connections, mcp3p policy, full config) is empty/zero when no MCP host has been initialized for the session.\n\nReturns:\n MCP servers configured for the session, with their connection status and host-level state." return MCPServerList.from_dict(await self._client.request("session.mcp.list", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) async def list_tools(self, params: MCPListToolsRequest, *, timeout: float | None = None) -> MCPListToolsResult: - "Lists the tools exposed by a connected MCP server on this session's host.\n\nArgs:\n params: Server name whose tool list should be returned.\n\nReturns:\n Tools exposed by the connected MCP server. Throws when the server is not connected." + "Lists the tools exposed by a connected MCP server on this session's host. This performs a live `tools/list` request. Tool UI metadata is returned independently of whether MCP Apps rendering is enabled for the session.\n\nArgs:\n params: Server name whose tool list should be returned.\n\nReturns:\n Tools exposed by the connected MCP server. Throws when the server is not connected." params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} params_dict["sessionId"] = self._session_id return MCPListToolsResult.from_dict(await self._client.request("session.mcp.listTools", params_dict, **_timeout_kwargs(timeout))) @@ -27350,6 +28269,18 @@ async def remove_git_hub(self, *, timeout: float | None = None) -> MCPRemoveGitH "Removes the auto-managed `github` MCP server when present.\n\nReturns:\n Indicates whether the auto-managed `github` MCP server was removed (false when nothing to remove)." return MCPRemoveGitHubResult.from_dict(await self._client.request("session.mcp.removeGitHub", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) + async def start_server(self, params: MCPStartServerRequest, *, timeout: float | None = None) -> None: + "Starts an individual MCP server on the live session from a caller-supplied config. Session-scoped and ephemeral: the server is added to this session's running set only and is reaped when the session ends. Does NOT modify persistent user configuration (`mcp.config.*`), so it does not affect future sessions. The server surfaces through `session.mcp.list` and the `session.mcp_servers_loaded` / `session.mcp_server_status_changed` events like any other server.\n\nArgs:\n params: Server name and configuration for an individual MCP server start." + params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + await self._client.request("session.mcp.startServer", params_dict, **_timeout_kwargs(timeout)) + + async def restart_server(self, params: MCPRestartServerRequest, *, timeout: float | None = None) -> None: + "Restarts an individual MCP server on the live session (stops then starts). Omit `config` for a config-free restart-by-name of an already-configured server; supply `config` to restart with a replacement configuration. Session-scoped and ephemeral: does NOT modify persistent user configuration (`mcp.config.*`).\n\nArgs:\n params: Server name and optional replacement configuration for an individual MCP server restart. Omit `config` for a config-free restart-by-name of an already-configured server." + params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + await self._client.request("session.mcp.restartServer", params_dict, **_timeout_kwargs(timeout)) + async def stop_server(self, params: MCPStopServerRequest, *, timeout: float | None = None) -> None: "Stops an individual MCP server on the session's host.\n\nArgs:\n params: Server name for an individual MCP server stop." params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} @@ -27808,7 +28739,7 @@ async def record_context_change(self, params: MetadataRecordContextChangeRequest return MetadataRecordContextChangeResult.from_dict(await self._client.request("session.metadata.recordContextChange", params_dict, **_timeout_kwargs(timeout))) async def set_working_directory(self, params: MetadataSetWorkingDirectoryRequest, *, timeout: float | None = None) -> MetadataSetWorkingDirectoryResult: - "Updates the session's recorded working directory.\n\nArgs:\n params: Absolute path to set as the session's new working directory.\n\nReturns:\n Update the session's working directory. Used by the host when the user explicitly changes cwd (e.g., the `/cd` slash command). The host is responsible for `process.chdir` and any related side-effects (file index, etc.); this method only updates the session's own recorded path." + "Updates the session's working directory. For local sessions the target is validated first (an absolute path that exists on disk) and the permission primary directory is re-based; a rejected validation fails the call before any session state changes.\n\nArgs:\n params: Absolute path to set as the session's new working directory. For local sessions the path must be absolute and exist on disk: it is validated before any session state changes, and a failing validation rejects the call with nothing mutated, persisted, or emitted. Remote sessions record the path as-is.\n\nReturns:\n Update the session's working directory. Used by the host when the user explicitly changes cwd (e.g., the `/cd` slash command). The host is responsible for any related side-effects (file index, etc.); it does NOT change the process working directory (a session's cwd is per-session, not process-global). For local sessions the runtime validates the target first (an absolute path that exists on disk) and re-bases the permission primary directory; a rejected validation fails the call before anything is mutated, persisted, or emitted. Location-scoped permission rules are then re-keyed to the new directory (best-effort). Remote sessions only record the path." params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} params_dict["sessionId"] = self._session_id return MetadataSetWorkingDirectoryResult.from_dict(await self._client.request("session.metadata.setWorkingDirectory", params_dict, **_timeout_kwargs(timeout))) @@ -28048,6 +28979,12 @@ async def send(self, params: SendRequest, *, timeout: float | None = None) -> Se params_dict["sessionId"] = self._session_id return SendResult.from_dict(await self._client.request("session.send", params_dict, **_timeout_kwargs(timeout))) + async def send_messages(self, params: SendMessagesRequest, *, timeout: float | None = None) -> SendMessagesResult: + "Sends zero or more user messages to the session in a single turn and returns their message IDs. All provided messages are appended to the conversation in order, then exactly one agent turn runs over the resulting history. When the list is empty, one turn runs over the existing history with no new user message. Remote-backed (Mission Control) sessions do not support this method and will return an error.\n\nArgs:\n params: Parameters for sending zero or more user messages to the session in a single turn. Remote-backed (Mission Control) sessions do not support this method and will return an error.\n\nReturns:\n Result of sending zero or more user messages\n\n.. warning:: This API is experimental and may change or be removed in future versions." + params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SendMessagesResult.from_dict(await self._client.request("session.sendMessages", params_dict, **_timeout_kwargs(timeout))) + async def abort(self, params: AbortRequest, *, timeout: float | None = None) -> AbortResult: "Aborts the current agent turn.\n\nArgs:\n params: Parameters for aborting the current turn\n\nReturns:\n Result of aborting the current turn\n\n.. warning:: This API is experimental and may change or be removed in future versions." params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} @@ -28085,18 +29022,6 @@ async def _configure_git_hub(self, params: MCPConfigureGitHubRequest, *, timeout params_dict["sessionId"] = self._session_id return MCPConfigureGitHubResult.from_dict(await self._client.request("session.mcp.configureGitHub", params_dict, **_timeout_kwargs(timeout))) - async def _start_server(self, params: MCPStartServerRequest, *, timeout: float | None = None) -> None: - "Starts an individual MCP server on the session's host.\n\nArgs:\n params: Server name and opaque configuration for an individual MCP server start.\n\n:meta private:\n\nInternal SDK API; not part of the public surface." - params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} - params_dict["sessionId"] = self._session_id - await self._client.request("session.mcp.startServer", params_dict, **_timeout_kwargs(timeout)) - - async def _restart_server(self, params: MCPRestartServerRequest, *, timeout: float | None = None) -> None: - "Restarts an individual MCP server on the session's host (stops then starts).\n\nArgs:\n params: Server name and opaque configuration for an individual MCP server restart.\n\n:meta private:\n\nInternal SDK API; not part of the public surface." - params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} - params_dict["sessionId"] = self._session_id - await self._client.request("session.mcp.restartServer", params_dict, **_timeout_kwargs(timeout)) - async def _register_external_client(self, params: MCPRegisterExternalClientRequest, *, timeout: float | None = None) -> None: "Registers a pre-connected external MCP client (e.g. IDE) on the session's host. The caller retains lifecycle ownership of the client and transport. Marked internal because the `client` and `transport` arguments are in-process MCP SDK instances that cannot be serialized across the JSON-RPC boundary; once the CLI moves on top of the SDK, external clients will be expressed as transport configs the runtime can construct itself.\n\nArgs:\n params: Registration parameters for an external MCP client.\n\n:meta private:\n\nInternal SDK API; not part of the public surface." params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} @@ -28317,6 +29242,12 @@ async def handle_canvas_action_invoke(params: dict) -> dict | None: return result.value if hasattr(result, 'value') else result client.set_request_handler("canvas.action.invoke", handle_canvas_action_invoke) +# Experimental: this API group is experimental and may change or be removed. +class HooksHandler(Protocol): + async def invoke(self, params: _HookInvokeRequest) -> _HookInvokeResponse: + "Dispatches one SDK callback hook from the runtime to the connection that registered it. Internal transport plumbing: clients opt in through session initialization and the Rust hook processor owns ordering, policy, timeout, and callback routing.\n\nArgs:\n params: Runtime-owned wire payload for a server-to-client hook callback invocation.\n\nReturns:\n Optional output returned by an SDK callback hook." + pass + # Experimental: this API group is experimental and may change or be removed. class LlmInferenceHandler(Protocol): async def http_request_start(self, params: LlmInferenceHTTPRequestStartRequest) -> LlmInferenceHTTPRequestStartResult: @@ -28334,6 +29265,7 @@ async def event(self, params: GitHubTelemetryNotification) -> None: @dataclass class ClientGlobalApiHandlers: + hooks: HooksHandler | None = None llm_inference: LlmInferenceHandler | None = None git_hub_telemetry: GitHubTelemetryHandler | None = None @@ -28347,6 +29279,13 @@ def register_client_global_api_handlers( session_id dispatch key; a single set of handlers serves the entire connection. """ + async def handle_hooks_invoke(params: dict) -> dict | None: + request = _HookInvokeRequest.from_dict(params) + handler = handlers.hooks + if handler is None: raise RuntimeError("No hooks client-global handler registered") + result = await handler.invoke(request) + return result.to_dict() + client.set_request_handler("hooks.invoke", handle_hooks_invoke) async def handle_llm_inference_http_request_start(params: dict) -> dict | None: request = LlmInferenceHTTPRequestStartRequest.from_dict(params) handler = handlers.llm_inference @@ -28565,6 +29504,7 @@ async def handle_git_hub_telemetry_event(params: dict) -> None: "HistorySummarizeForHandoffResult", "HistoryTruncateRequest", "HistoryTruncateResult", + "HooksHandler", "Host", "HostType", "InstalledPlugin", @@ -28656,6 +29596,17 @@ async def handle_git_hub_telemetry_event(params: dict) -> None: "MCPRegisterExternalClientRequest", "MCPReloadWithConfigRequest", "MCPRemoveGitHubResult", + "MCPResource", + "MCPResourceAnnotations", + "MCPResourceContent", + "MCPResourceIcon", + "MCPResourceTemplate", + "MCPResourcesListRequest", + "MCPResourcesListResult", + "MCPResourcesListTemplatesRequest", + "MCPResourcesListTemplatesResult", + "MCPResourcesReadRequest", + "MCPResourcesReadResult", "MCPRestartServerRequest", "MCPSamplingExecutionAction", "MCPSamplingExecutionResult", @@ -28675,6 +29626,8 @@ async def handle_git_hub_telemetry_event(params: dict) -> None: "MCPStartServerRequest", "MCPStartServersResult", "MCPStopServerRequest", + "MCPToolUI", + "MCPToolUIVisibility", "MCPTools", "MCPUnregisterExternalClientRequest", "MarketplaceAddResult", @@ -28699,6 +29652,7 @@ async def handle_git_hub_telemetry_event(params: dict) -> None: "McpHeadersApi", "McpOauthApi", "McpOauthLoginGrantType", + "McpResourcesApi", "McpServerAuthConfig", "McpServerConfigHttpOauthGrantType", "MemoryConfiguration", @@ -28724,6 +29678,7 @@ async def handle_git_hub_telemetry_event(params: dict) -> None: "Model", "ModelApi", "ModelBilling", + "ModelBillingPromo", "ModelBillingTokenPrices", "ModelBillingTokenPricesLongContext", "ModelCapabilities", @@ -29031,6 +29986,9 @@ async def handle_git_hub_telemetry_event(params: dict) -> None: "SecretsAddFilterValuesResult", "SendAgentMode", "SendAttachmentsToMessageParams", + "SendMessageItem", + "SendMessagesRequest", + "SendMessagesResult", "SendMode", "SendRequest", "SendResult", @@ -29038,6 +29996,7 @@ async def handle_git_hub_telemetry_event(params: dict) -> None: "ServerAgentList", "ServerAgentRegistryApi", "ServerAgentsApi", + "ServerCommandsApi", "ServerInstructionSourceList", "ServerInstructionsApi", "ServerLlmInferenceApi", @@ -29110,6 +30069,7 @@ async def handle_git_hub_telemetry_event(params: dict) -> None: "SessionMcpAppsCallToolResult", "SessionMetadataSnapshot", "SessionModelList", + "SessionModelPriceCategory", "SessionOpenOptions", "SessionOpenOptionsAdditionalContentExclusionPolicy", "SessionOpenOptionsAdditionalContentExclusionPolicyRule", diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index ed414d474a..a7c990e169 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -155,6 +155,7 @@ class SessionEventType(Enum): PENDING_MESSAGES_MODIFIED = "pending_messages.modified" ASSISTANT_TURN_START = "assistant.turn_start" ASSISTANT_INTENT = "assistant.intent" + ASSISTANT_SERVER_TOOL_PROGRESS = "assistant.server_tool_progress" ASSISTANT_REASONING = "assistant.reasoning" ASSISTANT_REASONING_DELTA = "assistant.reasoning_delta" ASSISTANT_TOOL_CALL_DELTA = "assistant.tool_call_delta" @@ -207,6 +208,10 @@ class SessionEventType(Enum): AUTO_MODE_SWITCH_COMPLETED = "auto_mode_switch.completed" SESSION_LIMITS_EXHAUSTED_REQUESTED = "session_limits_exhausted.requested" SESSION_LIMITS_EXHAUSTED_COMPLETED = "session_limits_exhausted.completed" + # Experimental: this event is part of an experimental API and may change or be removed. + SESSION_AUTO_MODE_RESOLVED = "session.auto_mode_resolved" + # Experimental: this event is part of an experimental API and may change or be removed. + SESSION_MANAGED_SETTINGS_RESOLVED = "session.managed_settings_resolved" COMMANDS_CHANGED = "commands.changed" CAPABILITIES_CHANGED = "capabilities.changed" EXIT_PLAN_MODE_REQUESTED = "exit_plan_mode.requested" @@ -217,6 +222,9 @@ class SessionEventType(Enum): SESSION_CUSTOM_AGENTS_UPDATED = "session.custom_agents_updated" SESSION_MCP_SERVERS_LOADED = "session.mcp_servers_loaded" SESSION_MCP_SERVER_STATUS_CHANGED = "session.mcp_server_status_changed" + MCP_TOOLS_LIST_CHANGED = "mcp.tools.list_changed" + MCP_RESOURCES_LIST_CHANGED = "mcp.resources.list_changed" + MCP_PROMPTS_LIST_CHANGED = "mcp.prompts.list_changed" SESSION_EXTENSIONS_LOADED = "session.extensions_loaded" # Experimental: this event is part of an experimental API and may change or be removed. SESSION_CANVAS_OPENED = "session.canvas.opened" @@ -446,6 +454,7 @@ class CanvasRegistryChangedCanvas: extension_id: str actions: list[CanvasRegistryChangedCanvasAction] | None = None extension_name: str | None = None + icon: str | None = None input_schema: Any = None @staticmethod @@ -457,6 +466,7 @@ def from_dict(obj: Any) -> "CanvasRegistryChangedCanvas": extension_id = from_str(obj.get("extensionId")) actions = from_union([from_none, lambda x: from_list(CanvasRegistryChangedCanvasAction.from_dict, x)], obj.get("actions")) extension_name = from_union([from_none, from_str], obj.get("extensionName")) + icon = from_union([from_none, from_str], obj.get("icon")) input_schema = obj.get("inputSchema") return CanvasRegistryChangedCanvas( canvas_id=canvas_id, @@ -465,6 +475,7 @@ def from_dict(obj: Any) -> "CanvasRegistryChangedCanvas": extension_id=extension_id, actions=actions, extension_name=extension_name, + icon=icon, input_schema=input_schema, ) @@ -478,6 +489,8 @@ def to_dict(self) -> dict: result["actions"] = from_union([from_none, lambda x: from_list(lambda x: to_class(CanvasRegistryChangedCanvasAction, x), x)], self.actions) if self.extension_name is not None: result["extensionName"] = from_union([from_none, from_str], self.extension_name) + if self.icon is not None: + result["icon"] = from_union([from_none, from_str], self.icon) if self.input_schema is not None: result["inputSchema"] = self.input_schema return result @@ -823,6 +836,51 @@ def to_dict(self) -> dict: return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class SessionAutoModeResolvedData: + "Auto Intent resolution: the concrete model the session settled on for the first prompt of an auto-mode session, and why. Lets SDK clients render the chosen model and the full reason it was picked. The core selection fields (chosenModel/reasoningBucket/categoryScores) are stable; the routing-analytics fields (predictedLabel/confidence/candidateModels) mirror the upstream intent service and may evolve, hence the event's experimental stability." + chosen_model: str + candidate_models: list[str] | None = None + category_scores: dict[str, float] | None = None + confidence: float | None = None + predicted_label: str | None = None + reasoning_bucket: AutoModeResolvedReasoningBucket | None = None + + @staticmethod + def from_dict(obj: Any) -> "SessionAutoModeResolvedData": + assert isinstance(obj, dict) + chosen_model = from_str(obj.get("chosenModel")) + candidate_models = from_union([from_none, lambda x: from_list(from_str, x)], obj.get("candidateModels")) + category_scores = from_union([from_none, lambda x: from_dict(from_float, x)], obj.get("categoryScores")) + confidence = from_union([from_none, from_float], obj.get("confidence")) + predicted_label = from_union([from_none, from_str], obj.get("predictedLabel")) + reasoning_bucket = from_union([from_none, lambda x: parse_enum(AutoModeResolvedReasoningBucket, x)], obj.get("reasoningBucket")) + return SessionAutoModeResolvedData( + chosen_model=chosen_model, + candidate_models=candidate_models, + category_scores=category_scores, + confidence=confidence, + predicted_label=predicted_label, + reasoning_bucket=reasoning_bucket, + ) + + def to_dict(self) -> dict: + result: dict = {} + result["chosenModel"] = from_str(self.chosen_model) + if self.candidate_models is not None: + result["candidateModels"] = from_union([from_none, lambda x: from_list(from_str, x)], self.candidate_models) + if self.category_scores is not None: + result["categoryScores"] = from_union([from_none, lambda x: from_dict(to_float, x)], self.category_scores) + if self.confidence is not None: + result["confidence"] = from_union([from_none, to_float], self.confidence) + if self.predicted_label is not None: + result["predictedLabel"] = from_union([from_none, from_str], self.predicted_label) + if self.reasoning_bucket is not None: + result["reasoningBucket"] = from_union([from_none, lambda x: to_enum(AutoModeResolvedReasoningBucket, x)], self.reasoning_bucket) + return result + + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionCanvasClosedData: @@ -854,11 +912,12 @@ def to_dict(self) -> dict: # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionCanvasOpenedData: - "Payload of `session.canvas.opened` with canvas instance and provider IDs plus optional title, status, URL, and input." + "Payload of `session.canvas.opened` with canvas instance and provider IDs plus optional icon, title, status, URL, and input." canvas_id: str extension_id: str instance_id: str extension_name: str | None = None + icon: str | None = None input: Any = None status: str | None = None title: str | None = None @@ -871,6 +930,7 @@ def from_dict(obj: Any) -> "SessionCanvasOpenedData": extension_id = from_str(obj.get("extensionId")) instance_id = from_str(obj.get("instanceId")) extension_name = from_union([from_none, from_str], obj.get("extensionName")) + icon = from_union([from_none, from_str], obj.get("icon")) input = obj.get("input") status = from_union([from_none, from_str], obj.get("status")) title = from_union([from_none, from_str], obj.get("title")) @@ -880,6 +940,7 @@ def from_dict(obj: Any) -> "SessionCanvasOpenedData": extension_id=extension_id, instance_id=instance_id, extension_name=extension_name, + icon=icon, input=input, status=status, title=title, @@ -893,6 +954,8 @@ def to_dict(self) -> dict: result["instanceId"] = from_str(self.instance_id) if self.extension_name is not None: result["extensionName"] = from_union([from_none, from_str], self.extension_name) + if self.icon is not None: + result["icon"] = from_union([from_none, from_str], self.icon) if self.input is not None: result["input"] = self.input if self.status is not None: @@ -1018,6 +1081,51 @@ def to_dict(self) -> dict: return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class SessionManagedSettingsResolvedData: + "Enterprise managed-settings resolution: the effective managed settings the session applied and where they came from, so SDK clients can show users what is enterprise-managed and by which authority. Fires whenever managed policy is (re)applied — at session start, on resume, and on account switch. This is an ephemeral live snapshot (delivered to subscribers but not persisted to the session event log), because at session start it resolves before `session.start` is emitted; for a session-independent pull, use the SDK `getManagedSettings()` API, which returns the identical payload. Managed settings have a single authoritative source, so the highest-authority present layer (server > device) wins wholesale; `bypassPermissionsDisabled` is deny-wins across layers. Marked experimental while the managed-settings surface stabilizes." + bypass_permissions_disabled: bool + device_managed: bool + fail_closed: bool + managed_keys: list[str] + server_managed: bool + source: ManagedSettingsResolvedSource + settings: Any = None + + @staticmethod + def from_dict(obj: Any) -> "SessionManagedSettingsResolvedData": + assert isinstance(obj, dict) + bypass_permissions_disabled = from_bool(obj.get("bypassPermissionsDisabled")) + device_managed = from_bool(obj.get("deviceManaged")) + fail_closed = from_bool(obj.get("failClosed")) + managed_keys = from_list(from_str, obj.get("managedKeys")) + server_managed = from_bool(obj.get("serverManaged")) + source = parse_enum(ManagedSettingsResolvedSource, obj.get("source")) + settings = obj.get("settings") + return SessionManagedSettingsResolvedData( + bypass_permissions_disabled=bypass_permissions_disabled, + device_managed=device_managed, + fail_closed=fail_closed, + managed_keys=managed_keys, + server_managed=server_managed, + source=source, + settings=settings, + ) + + def to_dict(self) -> dict: + result: dict = {} + result["bypassPermissionsDisabled"] = from_bool(self.bypass_permissions_disabled) + result["deviceManaged"] = from_bool(self.device_managed) + result["failClosed"] = from_bool(self.fail_closed) + result["managedKeys"] = from_list(from_str, self.managed_keys) + result["serverManaged"] = from_bool(self.server_managed) + result["source"] = to_enum(ManagedSettingsResolvedSource, self.source) + if self.settings is not None: + result["settings"] = self.settings + return result + + @dataclass class AbortData: "Turn abort information including the reason for termination" @@ -1084,6 +1192,7 @@ class AssistantMessageData: api_call_id: str | None = None # Experimental: this field is part of an experimental API and may change or be removed. citations: Citations | None = None + client_request_id: str | None = None encrypted_content: str | None = None interaction_id: str | None = None model: str | None = None @@ -1107,6 +1216,7 @@ def from_dict(obj: Any) -> "AssistantMessageData": message_id = from_str(obj.get("messageId")) api_call_id = from_union([from_none, from_str], obj.get("apiCallId")) citations = from_union([from_none, Citations.from_dict], obj.get("citations")) + client_request_id = from_union([from_none, from_str], obj.get("clientRequestId")) encrypted_content = from_union([from_none, from_str], obj.get("encryptedContent")) interaction_id = from_union([from_none, from_str], obj.get("interactionId")) model = from_union([from_none, from_str], obj.get("model")) @@ -1126,6 +1236,7 @@ def from_dict(obj: Any) -> "AssistantMessageData": message_id=message_id, api_call_id=api_call_id, citations=citations, + client_request_id=client_request_id, encrypted_content=encrypted_content, interaction_id=interaction_id, model=model, @@ -1150,6 +1261,8 @@ def to_dict(self) -> dict: result["apiCallId"] = from_union([from_none, from_str], self.api_call_id) if self.citations is not None: result["citations"] = from_union([from_none, lambda x: to_class(Citations, x)], self.citations) + if self.client_request_id is not None: + result["clientRequestId"] = from_union([from_none, from_str], self.client_request_id) if self.encrypted_content is not None: result["encryptedContent"] = from_union([from_none, from_str], self.encrypted_content) if self.interaction_id is not None: @@ -1333,6 +1446,33 @@ def to_dict(self) -> dict: return result +@dataclass +class AssistantServerToolProgressData: + "Live progress signal for a provider-hosted server tool (e.g. hosted web search) while it runs, before the finalized serverTools envelope lands on the terminal assistant.message" + kind: str + output_index: int + status: str + + @staticmethod + def from_dict(obj: Any) -> "AssistantServerToolProgressData": + assert isinstance(obj, dict) + kind = from_str(obj.get("kind")) + output_index = from_int(obj.get("outputIndex")) + status = from_str(obj.get("status")) + return AssistantServerToolProgressData( + kind=kind, + output_index=output_index, + status=status, + ) + + def to_dict(self) -> dict: + result: dict = {} + result["kind"] = from_str(self.kind) + result["outputIndex"] = to_int(self.output_index) + result["status"] = from_str(self.status) + return result + + @dataclass class AssistantStreamingDeltaData: "Streaming response progress with cumulative byte count" @@ -3136,6 +3276,29 @@ def to_dict(self) -> dict: return result +@dataclass +class HeaderEntry: + "Single HTTP header entry as a name/value pair." + name: str + value: str + + @staticmethod + def from_dict(obj: Any) -> "HeaderEntry": + assert isinstance(obj, dict) + name = from_str(obj.get("name")) + value = from_str(obj.get("value")) + return HeaderEntry( + name=name, + value=value, + ) + + def to_dict(self) -> dict: + result: dict = {} + result["name"] = from_str(self.name) + result["value"] = from_str(self.value) + return result + + @dataclass class HookEndData: "Hook invocation completion details including output, success status, and error information" @@ -3446,6 +3609,34 @@ def to_dict(self) -> dict: return result +@dataclass +class McpOauthHttpResponse: + "Raw HTTP response details from the OAuth auth challenge, as observed by the runtime." + headers: list[HeaderEntry] + status_code: int + body: str | None = None + + @staticmethod + def from_dict(obj: Any) -> "McpOauthHttpResponse": + assert isinstance(obj, dict) + headers = from_list(HeaderEntry.from_dict, obj.get("headers")) + status_code = from_int(obj.get("statusCode")) + body = from_union([from_none, from_str], obj.get("body")) + return McpOauthHttpResponse( + headers=headers, + status_code=status_code, + body=body, + ) + + def to_dict(self) -> dict: + result: dict = {} + result["headers"] = from_list(lambda x: to_class(HeaderEntry, x), self.headers) + result["statusCode"] = to_int(self.status_code) + if self.body is not None: + result["body"] = from_union([from_none, from_str], self.body) + return result + + @dataclass class McpOauthRequiredData: "OAuth authentication request for an MCP server" @@ -3453,6 +3644,7 @@ class McpOauthRequiredData: request_id: str server_name: str server_url: str + http_response: McpOauthHttpResponse | None = None resource_metadata: str | None = None static_client_config: McpOauthRequiredStaticClientConfig | None = None www_authenticate_params: McpOauthWWWAuthenticateParams | None = None @@ -3464,6 +3656,7 @@ def from_dict(obj: Any) -> "McpOauthRequiredData": request_id = from_str(obj.get("requestId")) server_name = from_str(obj.get("serverName")) server_url = from_str(obj.get("serverUrl")) + http_response = from_union([from_none, McpOauthHttpResponse.from_dict], obj.get("httpResponse")) resource_metadata = from_union([from_none, from_str], obj.get("resourceMetadata")) static_client_config = from_union([from_none, McpOauthRequiredStaticClientConfig.from_dict], obj.get("staticClientConfig")) www_authenticate_params = from_union([from_none, McpOauthWWWAuthenticateParams.from_dict], obj.get("wwwAuthenticateParams")) @@ -3472,6 +3665,7 @@ def from_dict(obj: Any) -> "McpOauthRequiredData": request_id=request_id, server_name=server_name, server_url=server_url, + http_response=http_response, resource_metadata=resource_metadata, static_client_config=static_client_config, www_authenticate_params=www_authenticate_params, @@ -3483,6 +3677,8 @@ def to_dict(self) -> dict: result["requestId"] = from_str(self.request_id) result["serverName"] = from_str(self.server_name) result["serverUrl"] = from_str(self.server_url) + if self.http_response is not None: + result["httpResponse"] = from_union([from_none, lambda x: to_class(McpOauthHttpResponse, x)], self.http_response) if self.resource_metadata is not None: result["resourceMetadata"] = from_union([from_none, from_str], self.resource_metadata) if self.static_client_config is not None: @@ -3556,6 +3752,44 @@ def to_dict(self) -> dict: return result +@dataclass +class McpPromptsListChangedData: + "Payload identifying the MCP server associated with a list change." + server_name: str + + @staticmethod + def from_dict(obj: Any) -> "McpPromptsListChangedData": + assert isinstance(obj, dict) + server_name = from_str(obj.get("serverName")) + return McpPromptsListChangedData( + server_name=server_name, + ) + + def to_dict(self) -> dict: + result: dict = {} + result["serverName"] = from_str(self.server_name) + return result + + +@dataclass +class McpResourcesListChangedData: + "Payload identifying the MCP server associated with a list change." + server_name: str + + @staticmethod + def from_dict(obj: Any) -> "McpResourcesListChangedData": + assert isinstance(obj, dict) + server_name = from_str(obj.get("serverName")) + return McpResourcesListChangedData( + server_name=server_name, + ) + + def to_dict(self) -> dict: + result: dict = {} + result["serverName"] = from_str(self.server_name) + return result + + @dataclass class McpServersLoadedServer: "A single MCP server status summary in `session.mcp_servers_loaded`, including name, status, source, transport, and plugin metadata." @@ -3604,6 +3838,25 @@ def to_dict(self) -> dict: return result +@dataclass +class McpToolsListChangedData: + "Payload identifying the MCP server associated with a list change." + server_name: str + + @staticmethod + def from_dict(obj: Any) -> "McpToolsListChangedData": + assert isinstance(obj, dict) + server_name = from_str(obj.get("serverName")) + return McpToolsListChangedData( + server_name=server_name, + ) + + def to_dict(self) -> dict: + result: dict = {} + result["serverName"] = from_str(self.server_name) + return result + + @dataclass class ModelCallFailureData: "Failed LLM API call metadata for telemetry" @@ -7501,6 +7754,8 @@ class ToolExecutionCompleteData: error: ToolExecutionCompleteError | None = None interaction_id: str | None = None is_user_requested: bool | None = None + # Experimental: this field is part of an experimental API and may change or be removed. + mcp_meta: Any = None model: str | None = None # Deprecated: this field is deprecated. parent_tool_call_id: str | None = None @@ -7518,6 +7773,7 @@ def from_dict(obj: Any) -> "ToolExecutionCompleteData": error = from_union([from_none, ToolExecutionCompleteError.from_dict], obj.get("error")) interaction_id = from_union([from_none, from_str], obj.get("interactionId")) is_user_requested = from_union([from_none, from_bool], obj.get("isUserRequested")) + mcp_meta = obj.get("mcpMeta") model = from_union([from_none, from_str], obj.get("model")) parent_tool_call_id = from_union([from_none, from_str], obj.get("parentToolCallId")) result = from_union([from_none, ToolExecutionCompleteResult.from_dict], obj.get("result")) @@ -7531,6 +7787,7 @@ def from_dict(obj: Any) -> "ToolExecutionCompleteData": error=error, interaction_id=interaction_id, is_user_requested=is_user_requested, + mcp_meta=mcp_meta, model=model, parent_tool_call_id=parent_tool_call_id, result=result, @@ -7550,6 +7807,8 @@ def to_dict(self) -> dict: result["interactionId"] = from_union([from_none, from_str], self.interaction_id) if self.is_user_requested is not None: result["isUserRequested"] = from_union([from_none, from_bool], self.is_user_requested) + if self.mcp_meta is not None: + result["mcpMeta"] = self.mcp_meta if self.model is not None: result["model"] = from_union([from_none, from_str], self.model) if self.parent_tool_call_id is not None: @@ -7601,6 +7860,8 @@ class ToolExecutionCompleteResult: citable_sources: list[CitableSource] | None = None contents: list[ToolExecutionCompleteContent] | None = None detailed_content: str | None = None + # Experimental: this field is part of an experimental API and may change or be removed. + mcp_meta: Any = None structured_content: Any = None ui_resource: ToolExecutionCompleteUIResource | None = None @@ -7612,6 +7873,7 @@ def from_dict(obj: Any) -> "ToolExecutionCompleteResult": citable_sources = from_union([from_none, lambda x: from_list(CitableSource.from_dict, x)], obj.get("citableSources")) contents = from_union([from_none, lambda x: from_list(_load_ToolExecutionCompleteContent, x)], obj.get("contents")) detailed_content = from_union([from_none, from_str], obj.get("detailedContent")) + mcp_meta = obj.get("mcpMeta") structured_content = obj.get("structuredContent") ui_resource = from_union([from_none, ToolExecutionCompleteUIResource.from_dict], obj.get("uiResource")) return ToolExecutionCompleteResult( @@ -7620,6 +7882,7 @@ def from_dict(obj: Any) -> "ToolExecutionCompleteResult": citable_sources=citable_sources, contents=contents, detailed_content=detailed_content, + mcp_meta=mcp_meta, structured_content=structured_content, ui_resource=ui_resource, ) @@ -7635,6 +7898,8 @@ def to_dict(self) -> dict: result["contents"] = from_union([from_none, lambda x: from_list(lambda x: x.to_dict(), x)], self.contents) if self.detailed_content is not None: result["detailedContent"] = from_union([from_none, from_str], self.detailed_content) + if self.mcp_meta is not None: + result["mcpMeta"] = self.mcp_meta if self.structured_content is not None: result["structuredContent"] = self.structured_content if self.ui_resource is not None: @@ -8758,6 +9023,16 @@ class AttachmentGitHubReferenceType(Enum): DISCUSSION = "discussion" +class AutoModeResolvedReasoningBucket(Enum): + "Coarse request-difficulty bucket for UX explainability" + # The request looks low-reasoning; a lighter model is appropriate. + LOW = "low" + # The request needs a moderate amount of reasoning. + MEDIUM = "medium" + # The request looks high-reasoning; a stronger model is appropriate. + HIGH = "high" + + class AutoModeSwitchResponse(Enum): "The user's auto-mode-switch choice" # Switch models for this request. @@ -8876,6 +9151,16 @@ class HandoffSourceType(Enum): LOCAL = "local" +class ManagedSettingsResolvedSource(Enum): + "Which channel supplied the effective enterprise managed settings (highest-authority present layer wins wholesale)" + # Account/org policy self-fetched from the GitHub managed-settings endpoint (higher authority). + SERVER = "server" + # Device-level MDM policy discovered from plist/registry/file (lower authority). + DEVICE = "device" + # No managed policy is in force (no layer contributed). + NONE = "none" + + class McpHeadersRefreshCompletedOutcome(Enum): "How the pending MCP headers refresh request resolved." # The host supplied dynamic headers. @@ -9190,7 +9475,7 @@ class WorkspaceFileChangedOperation(Enum): UPDATE = "update" -SessionEventData = SessionStartData | SessionResumeData | SessionRemoteSteerableChangedData | SessionErrorData | SessionIdleData | SessionTitleChangedData | SessionScheduleCreatedData | SessionScheduleCancelledData | SessionScheduleRearmedData | SessionAutopilotObjectiveChangedData | SessionInfoData | SessionWarningData | SessionModelChangeData | SessionModeChangedData | SessionSessionLimitsChangedData | SessionPermissionsChangedData | SessionPlanChangedData | SessionTodosChangedData | SessionWorkspaceFileChangedData | SessionHandoffData | SessionTruncationData | SessionSnapshotRewindData | SessionShutdownData | SessionUsageCheckpointData | SessionContextChangedData | SessionUsageInfoData | SessionCompactionStartData | SessionCompactionCompleteData | SessionTaskCompleteData | UserMessageData | PendingMessagesModifiedData | AssistantTurnStartData | AssistantIntentData | AssistantReasoningData | AssistantReasoningDeltaData | AssistantToolCallDeltaData | AssistantStreamingDeltaData | AssistantMessageData | AssistantMessageStartData | AssistantMessageDeltaData | AssistantTurnEndData | AssistantIdleData | AssistantUsageData | ModelCallFailureData | AbortData | ToolUserRequestedData | ToolExecutionStartData | ToolExecutionPartialResultData | ToolExecutionProgressData | ToolExecutionCompleteData | SkillInvokedData | SubagentStartedData | SubagentCompletedData | SubagentFailedData | SubagentSelectedData | SubagentDeselectedData | HookStartData | HookEndData | HookProgressData | SessionBinaryAssetData | SystemMessageData | SystemNotificationData | PermissionRequestedData | PermissionCompletedData | UserInputRequestedData | UserInputCompletedData | ElicitationRequestedData | ElicitationCompletedData | SamplingRequestedData | SamplingCompletedData | McpOauthRequiredData | McpOauthCompletedData | McpHeadersRefreshRequiredData | McpHeadersRefreshCompletedData | SessionCustomNotificationData | ExternalToolRequestedData | ExternalToolCompletedData | CommandQueuedData | CommandExecuteData | CommandCompletedData | AutoModeSwitchRequestedData | AutoModeSwitchCompletedData | SessionLimitsExhaustedRequestedData | SessionLimitsExhaustedCompletedData | CommandsChangedData | CapabilitiesChangedData | ExitPlanModeRequestedData | ExitPlanModeCompletedData | SessionToolsUpdatedData | SessionBackgroundTasksChangedData | SessionSkillsLoadedData | SessionCustomAgentsUpdatedData | SessionMcpServersLoadedData | SessionMcpServerStatusChangedData | SessionExtensionsLoadedData | SessionCanvasOpenedData | SessionCanvasRegistryChangedData | SessionCanvasClosedData | SessionCanvasUnavailableData | SessionCanvasRecordedData | SessionCanvasRemovedData | SessionExtensionsAttachmentsPushedData | McpAppToolCallCompleteData | RawSessionEventData | Data +SessionEventData = SessionStartData | SessionResumeData | SessionRemoteSteerableChangedData | SessionErrorData | SessionIdleData | SessionTitleChangedData | SessionScheduleCreatedData | SessionScheduleCancelledData | SessionScheduleRearmedData | SessionAutopilotObjectiveChangedData | SessionInfoData | SessionWarningData | SessionModelChangeData | SessionModeChangedData | SessionSessionLimitsChangedData | SessionPermissionsChangedData | SessionPlanChangedData | SessionTodosChangedData | SessionWorkspaceFileChangedData | SessionHandoffData | SessionTruncationData | SessionSnapshotRewindData | SessionShutdownData | SessionUsageCheckpointData | SessionContextChangedData | SessionUsageInfoData | SessionCompactionStartData | SessionCompactionCompleteData | SessionTaskCompleteData | UserMessageData | PendingMessagesModifiedData | AssistantTurnStartData | AssistantIntentData | AssistantServerToolProgressData | AssistantReasoningData | AssistantReasoningDeltaData | AssistantToolCallDeltaData | AssistantStreamingDeltaData | AssistantMessageData | AssistantMessageStartData | AssistantMessageDeltaData | AssistantTurnEndData | AssistantIdleData | AssistantUsageData | ModelCallFailureData | AbortData | ToolUserRequestedData | ToolExecutionStartData | ToolExecutionPartialResultData | ToolExecutionProgressData | ToolExecutionCompleteData | SkillInvokedData | SubagentStartedData | SubagentCompletedData | SubagentFailedData | SubagentSelectedData | SubagentDeselectedData | HookStartData | HookEndData | HookProgressData | SessionBinaryAssetData | SystemMessageData | SystemNotificationData | PermissionRequestedData | PermissionCompletedData | UserInputRequestedData | UserInputCompletedData | ElicitationRequestedData | ElicitationCompletedData | SamplingRequestedData | SamplingCompletedData | McpOauthRequiredData | McpOauthCompletedData | McpHeadersRefreshRequiredData | McpHeadersRefreshCompletedData | SessionCustomNotificationData | ExternalToolRequestedData | ExternalToolCompletedData | CommandQueuedData | CommandExecuteData | CommandCompletedData | AutoModeSwitchRequestedData | AutoModeSwitchCompletedData | SessionLimitsExhaustedRequestedData | SessionLimitsExhaustedCompletedData | SessionAutoModeResolvedData | SessionManagedSettingsResolvedData | CommandsChangedData | CapabilitiesChangedData | ExitPlanModeRequestedData | ExitPlanModeCompletedData | SessionToolsUpdatedData | SessionBackgroundTasksChangedData | SessionSkillsLoadedData | SessionCustomAgentsUpdatedData | SessionMcpServersLoadedData | SessionMcpServerStatusChangedData | McpToolsListChangedData | McpResourcesListChangedData | McpPromptsListChangedData | SessionExtensionsLoadedData | SessionCanvasOpenedData | SessionCanvasRegistryChangedData | SessionCanvasClosedData | SessionCanvasUnavailableData | SessionCanvasRecordedData | SessionCanvasRemovedData | SessionExtensionsAttachmentsPushedData | McpAppToolCallCompleteData | RawSessionEventData | Data @dataclass @@ -9249,6 +9534,7 @@ def from_dict(obj: Any) -> "SessionEvent": case SessionEventType.PENDING_MESSAGES_MODIFIED: data = PendingMessagesModifiedData.from_dict(data_obj) case SessionEventType.ASSISTANT_TURN_START: data = AssistantTurnStartData.from_dict(data_obj) case SessionEventType.ASSISTANT_INTENT: data = AssistantIntentData.from_dict(data_obj) + case SessionEventType.ASSISTANT_SERVER_TOOL_PROGRESS: data = AssistantServerToolProgressData.from_dict(data_obj) case SessionEventType.ASSISTANT_REASONING: data = AssistantReasoningData.from_dict(data_obj) case SessionEventType.ASSISTANT_REASONING_DELTA: data = AssistantReasoningDeltaData.from_dict(data_obj) case SessionEventType.ASSISTANT_TOOL_CALL_DELTA: data = AssistantToolCallDeltaData.from_dict(data_obj) @@ -9300,6 +9586,8 @@ def from_dict(obj: Any) -> "SessionEvent": case SessionEventType.AUTO_MODE_SWITCH_COMPLETED: data = AutoModeSwitchCompletedData.from_dict(data_obj) case SessionEventType.SESSION_LIMITS_EXHAUSTED_REQUESTED: data = SessionLimitsExhaustedRequestedData.from_dict(data_obj) case SessionEventType.SESSION_LIMITS_EXHAUSTED_COMPLETED: data = SessionLimitsExhaustedCompletedData.from_dict(data_obj) + case SessionEventType.SESSION_AUTO_MODE_RESOLVED: data = SessionAutoModeResolvedData.from_dict(data_obj) + case SessionEventType.SESSION_MANAGED_SETTINGS_RESOLVED: data = SessionManagedSettingsResolvedData.from_dict(data_obj) case SessionEventType.COMMANDS_CHANGED: data = CommandsChangedData.from_dict(data_obj) case SessionEventType.CAPABILITIES_CHANGED: data = CapabilitiesChangedData.from_dict(data_obj) case SessionEventType.EXIT_PLAN_MODE_REQUESTED: data = ExitPlanModeRequestedData.from_dict(data_obj) @@ -9310,6 +9598,9 @@ def from_dict(obj: Any) -> "SessionEvent": case SessionEventType.SESSION_CUSTOM_AGENTS_UPDATED: data = SessionCustomAgentsUpdatedData.from_dict(data_obj) case SessionEventType.SESSION_MCP_SERVERS_LOADED: data = SessionMcpServersLoadedData.from_dict(data_obj) case SessionEventType.SESSION_MCP_SERVER_STATUS_CHANGED: data = SessionMcpServerStatusChangedData.from_dict(data_obj) + case SessionEventType.MCP_TOOLS_LIST_CHANGED: data = McpToolsListChangedData.from_dict(data_obj) + case SessionEventType.MCP_RESOURCES_LIST_CHANGED: data = McpResourcesListChangedData.from_dict(data_obj) + case SessionEventType.MCP_PROMPTS_LIST_CHANGED: data = McpPromptsListChangedData.from_dict(data_obj) case SessionEventType.SESSION_EXTENSIONS_LOADED: data = SessionExtensionsLoadedData.from_dict(data_obj) case SessionEventType.SESSION_CANVAS_OPENED: data = SessionCanvasOpenedData.from_dict(data_obj) case SessionEventType.SESSION_CANVAS_REGISTRY_CHANGED: data = SessionCanvasRegistryChangedData.from_dict(data_obj) @@ -9365,6 +9656,7 @@ def session_event_to_dict(x: SessionEvent) -> Any: "AssistantMessageToolRequestType", "AssistantReasoningData", "AssistantReasoningDeltaData", + "AssistantServerToolProgressData", "AssistantStreamingDeltaData", "AssistantToolCallDeltaData", "AssistantTurnEndData", @@ -9397,6 +9689,7 @@ def session_event_to_dict(x: SessionEvent) -> Any: "AttachmentSelectionDetailsEnd", "AttachmentSelectionDetailsStart", "AutoApprovalRecommendation", + "AutoModeResolvedReasoningBucket", "AutoModeSwitchCompletedData", "AutoModeSwitchRequestedData", "AutoModeSwitchResponse", @@ -9447,10 +9740,12 @@ def session_event_to_dict(x: SessionEvent) -> Any: "GitHubRepoRef", "HandoffRepository", "HandoffSourceType", + "HeaderEntry", "HookEndData", "HookEndError", "HookProgressData", "HookStartData", + "ManagedSettingsResolvedSource", "McpAppToolCallCompleteData", "McpAppToolCallCompleteError", "McpAppToolCallCompleteToolMeta", @@ -9461,14 +9756,18 @@ def session_event_to_dict(x: SessionEvent) -> Any: "McpHeadersRefreshRequiredReason", "McpOauthCompletedData", "McpOauthCompletionOutcome", + "McpOauthHttpResponse", "McpOauthRequestReason", "McpOauthRequiredData", "McpOauthRequiredStaticClientConfig", "McpOauthWWWAuthenticateParams", + "McpPromptsListChangedData", + "McpResourcesListChangedData", "McpServerSource", "McpServerStatus", "McpServerTransport", "McpServersLoadedServer", + "McpToolsListChangedData", "ModelCallFailureBadRequestKind", "ModelCallFailureData", "ModelCallFailureRequestFingerprint", @@ -9528,6 +9827,7 @@ def session_event_to_dict(x: SessionEvent) -> Any: "ReasoningSummary", "SamplingCompletedData", "SamplingRequestedData", + "SessionAutoModeResolvedData", "SessionAutopilotObjectiveChangedData", "SessionBackgroundTasksChangedData", "SessionBinaryAssetData", @@ -9556,6 +9856,7 @@ def session_event_to_dict(x: SessionEvent) -> Any: "SessionLimitsExhaustedRequestedData", "SessionLimitsExhaustedResponse", "SessionLimitsExhaustedResponseAction", + "SessionManagedSettingsResolvedData", "SessionMcpServerStatusChangedData", "SessionMcpServersLoadedData", "SessionMode", diff --git a/python/copilot/session.py b/python/copilot/session.py index 50de96f191..d0f402ef79 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -87,6 +87,11 @@ logger = logging.getLogger(__name__) +# Fixed name of the runtime's built-in tool-search tool. A client can replace +# its behavior by registering a tool with this exact name and +# ``overrides_built_in_tool=True``. +_TOOL_SEARCH_TOOL_NAME = "tool_search_tool" + if TYPE_CHECKING: from .session_fs_provider import SessionFsProvider @@ -1134,6 +1139,28 @@ class LargeToolOutputConfig(TypedDict, total=False): output_directory: str +class ToolSearchConfig(TypedDict, total=False): + """ + Override for the runtime's built-in tool-search behavior. + + Tool search lets the model discover tools on demand instead of loading every + tool definition up front. When the total tool count exceeds the deferral + threshold, MCP and external tools are marked as deferred and surfaced through + the built-in ``tool_search_tool``. + + To override the tool-search tool's implementation, register a :class:`Tool` + named ``tool_search_tool`` with ``overrides_built_in_tool=True``. To customize + the in-prompt tool-search guidance, use the ``tool_instructions`` section of + the system message in ``"customize"`` mode. + """ + + # Toggle that enables or disables tool search. + enabled: bool + # Overrides the total tool count at which MCP and external tools are + # automatically deferred behind tool search. + defer_threshold: int + + class MemoryConfiguration(TypedDict): """ Configuration for session memory. @@ -1932,11 +1959,25 @@ async def _execute_tool_and_respond( ) -> None: """Execute a tool handler and send the result back via HandlePendingToolCall RPC.""" try: + # The built-in tool-search tool receives a snapshot of the session's + # currently initialized tools so an override can filter the live + # catalog without issuing its own RPC. Fetch it only for that tool to + # avoid a round-trip on every tool call; a failed fetch leaves the + # snapshot as None rather than failing the tool. + available_tools = None + if tool_name == _TOOL_SEARCH_TOOL_NAME: + try: + metadata = await self.rpc.tools.get_current_metadata() + available_tools = metadata.tools + except Exception: + available_tools = None + invocation = ToolInvocation( session_id=self.session_id, tool_call_id=tool_call_id, tool_name=tool_name, arguments=arguments, + available_tools=available_tools, ) with trace_context(traceparent, tracestate): @@ -1997,6 +2038,7 @@ async def _execute_tool_and_respond( text_result_for_llm=tool_result.text_result_for_llm, error=tool_result.error, result_type=tool_result.result_type, + tool_references=tool_result.tool_references, tool_telemetry=tool_result.tool_telemetry, ), ) @@ -2850,7 +2892,7 @@ async def set_model( is preserved. Args: - model: Model ID to switch to (e.g., "gpt-4.1", "claude-sonnet-4"). + model: Model ID to switch to (e.g., "gpt-5.4", "claude-sonnet-4"). reasoning_effort: Optional reasoning effort level for the new model (e.g., "low", "medium", "high", "xhigh"). reasoning_summary: Optional reasoning summary mode for supported @@ -2864,7 +2906,7 @@ async def set_model( Exception: If the session has been destroyed or the connection fails. Example: - >>> await session.set_model("gpt-4.1") + >>> await session.set_model("gpt-5.4") >>> await session.set_model("claude-sonnet-4.6", reasoning_effort="high") """ rpc_caps = None diff --git a/python/copilot/tools.py b/python/copilot/tools.py index 620a8cd58a..b96e303e3b 100644 --- a/python/copilot/tools.py +++ b/python/copilot/tools.py @@ -11,10 +11,13 @@ import json from collections.abc import Awaitable, Callable from dataclasses import dataclass, field -from typing import Any, Literal, TypeVar, get_type_hints, overload +from typing import TYPE_CHECKING, Any, Literal, TypeVar, get_type_hints, overload from pydantic import BaseModel, ValidationError +if TYPE_CHECKING: + from .generated.rpc import CurrentToolMetadata + ToolResultType = Literal["success", "failure", "rejected", "denied", "timeout"] @@ -38,6 +41,7 @@ class ToolResult: binary_results_for_llm: list[ToolBinaryResult] | None = None session_log: str | None = None tool_telemetry: dict[str, Any] | None = None + tool_references: list[str] | None = None _from_exception: bool = field(default=False, repr=False) @@ -49,6 +53,14 @@ class ToolInvocation: tool_call_id: str = "" tool_name: str = "" arguments: Any = None + available_tools: list[CurrentToolMetadata] | None = None + """Snapshot of the session's currently initialized tools. + + Populated by the SDK only when this invocation targets the built-in + tool-search tool (``tool_search_tool``), so a tool-search override can + rank/filter the live catalog -- including MCP tools configured in settings -- + without issuing its own RPC. ``None`` for every other tool invocation. + """ ToolHandler = Callable[[ToolInvocation], ToolResult | Awaitable[ToolResult]] @@ -63,6 +75,7 @@ class Tool: overrides_built_in_tool: bool = False skip_permission: bool = False defer: Literal["auto", "never"] | None = None + metadata: dict[str, Any] | None = None T = TypeVar("T", bound=BaseModel) @@ -77,6 +90,7 @@ def define_tool( overrides_built_in_tool: bool = False, skip_permission: bool = False, defer: Literal["auto", "never"] | None = None, + metadata: dict[str, Any] | None = None, ) -> Callable[[Callable[..., Any]], Tool]: pass @@ -91,6 +105,7 @@ def define_tool( overrides_built_in_tool: bool = False, skip_permission: bool = False, defer: Literal["auto", "never"] | None = None, + metadata: dict[str, Any] | None = None, ) -> Tool: pass @@ -105,6 +120,7 @@ def define_tool( overrides_built_in_tool: bool = False, skip_permission: bool = False, defer: Literal["auto", "never"] | None = None, + metadata: dict[str, Any] | None = None, ) -> Tool: pass @@ -118,6 +134,7 @@ def define_tool( overrides_built_in_tool: bool = False, skip_permission: bool = False, defer: Literal["auto", "never"] | None = None, + metadata: dict[str, Any] | None = None, ) -> Tool | Callable[[Callable[[Any, ToolInvocation], Any]], Tool]: """ Define a tool with automatic JSON schema generation from Pydantic models. @@ -166,6 +183,10 @@ def lookup_issue(params: LookupIssueParams) -> str: rather than always pre-loaded. When "auto", the tool can be deferred and surfaced through tool search. When "never", the tool is always pre-loaded. Optional; defaults to "auto". + metadata: Opaque, host-defined metadata associated with the tool definition. + Keys are namespaced and not part of the stable public API; values + are not interpreted and may be recognized to inform host-specific + behavior. Unknown keys are preserved. Returns: A Tool instance @@ -260,6 +281,7 @@ async def wrapped_handler(invocation: ToolInvocation) -> ToolResult: overrides_built_in_tool=overrides_built_in_tool, skip_permission=skip_permission, defer=defer, + metadata=metadata, ) # If handler is provided, call decorator immediately @@ -279,6 +301,7 @@ async def wrapped_handler(invocation: ToolInvocation) -> ToolResult: overrides_built_in_tool=overrides_built_in_tool, skip_permission=skip_permission, defer=defer, + metadata=metadata, ) # Otherwise return decorator for @define_tool(...) usage diff --git a/python/e2e/conftest.py b/python/e2e/conftest.py index 7409cf556b..f441097f32 100644 --- a/python/e2e/conftest.py +++ b/python/e2e/conftest.py @@ -5,7 +5,19 @@ import pytest import pytest_asyncio -from .testharness import E2ETestContext +from .testharness import E2ETestContext, is_inprocess_transport + +# Host-side auth resolution ranks HMAC above the GitHub token, so an ambient +# COPILOT_HMAC_KEY (CI sets one as a job-level credential) would be picked over +# the token the replay snapshots expect, yielding 401s. For the in-process +# transport the runtime is hosted in this test process and can capture the key as +# early as client construction, so neutralize it at module load — the analogue of +# .NET's InProcessEnvIsolation [ModuleInitializer] and Node's module-init guard. +# Out-of-process children resolve auth in their own process where the token already +# outranks HMAC. See https://github.com/github/copilot-sdk/issues/1934. +if is_inprocess_transport(): + os.environ.pop("COPILOT_HMAC_KEY", None) + os.environ.pop("CAPI_HMAC_KEY", None) @pytest.hookimpl(tryfirst=True, hookwrapper=True) diff --git a/python/e2e/test_copilot_request_session_id_e2e.py b/python/e2e/test_copilot_request_session_id_e2e.py index e40af13a1c..81624d73d0 100644 --- a/python/e2e/test_copilot_request_session_id_e2e.py +++ b/python/e2e/test_copilot_request_session_id_e2e.py @@ -36,6 +36,9 @@ class _InterceptedRequest: url: str session_id: str | None + agent_id: str | None + parent_agent_id: str | None + interaction_type: str | None class _SessionIdHandler(CopilotRequestHandler): @@ -46,7 +49,15 @@ async def send_request( self, request: httpx.Request, ctx: CopilotRequestContext ) -> httpx.Response: url = str(request.url) - self.records.append(_InterceptedRequest(url=url, session_id=ctx.session_id)) + self.records.append( + _InterceptedRequest( + url=url, + session_id=ctx.session_id, + agent_id=ctx.agent_id, + parent_agent_id=ctx.parent_agent_id, + interaction_type=ctx.interaction_type, + ) + ) if is_inference_url(url): return build_inference_response(request) # Force /responses transport so the inference URL is predictable. @@ -56,6 +67,11 @@ async def send_request( session_id_client = isolated_client_fixture(_SessionIdHandler) +def _assert_agent_metadata(record: _InterceptedRequest) -> None: + assert record.agent_id + assert record.interaction_type + + class TestCopilotRequestSessionId: capi_session_id: str | None = None @@ -78,6 +94,7 @@ async def test_threads_session_id_into_capi_session(self, session_id_client): assert r.session_id == session.session_id, ( "CAPI inference request must carry the runtime session id" ) + _assert_agent_metadata(r) # Validate the final assistant response arrived (guards against truncated captures) assert "OK from the synthetic" in text @@ -112,6 +129,7 @@ async def test_threads_session_id_into_byok_session(self, session_id_client): assert r.session_id == byok_session_id, ( "BYOK inference request must carry the runtime session id" ) + _assert_agent_metadata(r) # Session ids are per-session, so the two turns must differ. assert byok_session_id != TestCopilotRequestSessionId.capi_session_id diff --git a/python/e2e/test_inprocess_ffi_e2e.py b/python/e2e/test_inprocess_ffi_e2e.py new file mode 100644 index 0000000000..c119c4ea4e --- /dev/null +++ b/python/e2e/test_inprocess_ffi_e2e.py @@ -0,0 +1,40 @@ +"""E2E smoke test for the in-process (FFI) transport. + +Starts a client over the in-process FFI transport, performs a ``ping`` +round-trip through the native runtime library, and stops cleanly. Resolution of +the transport from ``COPILOT_SDK_DEFAULT_CONNECTION`` is exercised by the full +E2E suite running under the ``inprocess`` CI matrix cell, not here. + +Mirrors nodejs/test/e2e/inprocess_ffi.e2e.test.ts. +""" + +from __future__ import annotations + +import pytest + +from copilot import CopilotClient, RuntimeConnection + +from .testharness import E2ETestContext +from .testharness.context import get_cli_path_for_tests + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +class TestInProcessFfi: + async def test_should_start_and_connect_over_in_process_ffi( + self, ctx: E2ETestContext, monkeypatch: pytest.MonkeyPatch + ): + # In-process hosting loads the runtime cdylib next to the resolved CLI + # entrypoint and lets the native host spawn the worker. ``ping`` is a + # purely local RPC round-trip, so no auth or replay proxy is involved. + # If the native library is unavailable, start() raises and the test fails. + monkeypatch.setenv("COPILOT_CLI_PATH", get_cli_path_for_tests()) + client = CopilotClient(connection=RuntimeConnection.for_inprocess()) + await client.start() + + try: + pong = await client.ping("ffi message") + assert pong.message == "pong: ffi message" + assert pong.timestamp is not None + finally: + await client.stop() diff --git a/python/e2e/test_mode_handlers_e2e.py b/python/e2e/test_mode_handlers_e2e.py index 7ef9519f4a..a53064e744 100644 --- a/python/e2e/test_mode_handlers_e2e.py +++ b/python/e2e/test_mode_handlers_e2e.py @@ -35,7 +35,7 @@ async def mode_ctx(ctx: E2ETestContext): """Configure per-token user responses for mode-handler tests.""" proxy_url = ctx.proxy_url - ctx.client._options.env["COPILOT_DEBUG_GITHUB_API_URL"] = proxy_url + ctx.add_runtime_env("COPILOT_DEBUG_GITHUB_API_URL", proxy_url) await ctx.set_copilot_user_by_token( MODE_HANDLER_TOKEN, diff --git a/python/e2e/test_per_session_auth_e2e.py b/python/e2e/test_per_session_auth_e2e.py index 776408a799..a8d13dc1de 100644 --- a/python/e2e/test_per_session_auth_e2e.py +++ b/python/e2e/test_per_session_auth_e2e.py @@ -18,7 +18,7 @@ async def auth_ctx(ctx: E2ETestContext): # Redirect GitHub API calls to the proxy so per-session auth token # resolution (fetchCopilotUser) is intercepted. Must be set before the # CLI subprocess is spawned (i.e., before the first create_session call). - ctx.client._options.env["COPILOT_DEBUG_GITHUB_API_URL"] = proxy_url + ctx.add_runtime_env("COPILOT_DEBUG_GITHUB_API_URL", proxy_url) await ctx.set_copilot_user_by_token( "token-alice", diff --git a/python/e2e/test_rpc_server_e2e.py b/python/e2e/test_rpc_server_e2e.py index fb422d5b31..bcef26754b 100644 --- a/python/e2e/test_rpc_server_e2e.py +++ b/python/e2e/test_rpc_server_e2e.py @@ -85,7 +85,7 @@ def _paths_equal(left: str, right: str | None) -> bool: @pytest.fixture(scope="module") async def authed_ctx(ctx: E2ETestContext): """Configure proxy to redirect GitHub user lookups so per-token auth works.""" - ctx.client._options.env["COPILOT_DEBUG_GITHUB_API_URL"] = ctx.proxy_url + ctx.add_runtime_env("COPILOT_DEBUG_GITHUB_API_URL", ctx.proxy_url) return ctx diff --git a/python/e2e/test_subagent_hooks_e2e.py b/python/e2e/test_subagent_hooks_e2e.py index 1ca2a54c12..da70265a04 100644 --- a/python/e2e/test_subagent_hooks_e2e.py +++ b/python/e2e/test_subagent_hooks_e2e.py @@ -3,10 +3,14 @@ fire for tool calls made by sub-agents spawned via the task tool. """ +from __future__ import annotations + import os +import httpx import pytest +from copilot import CopilotRequestContext, CopilotRequestHandler from copilot.client import CopilotClient, RuntimeConnection from copilot.session import PermissionHandler @@ -16,12 +20,56 @@ pytestmark = pytest.mark.asyncio(loop_scope="module") +class _RecordingRequestHandler(CopilotRequestHandler): + def __init__(self) -> None: + self.records: list[dict[str, str | None]] = [] + + async def send_request( + self, request: httpx.Request, ctx: CopilotRequestContext + ) -> httpx.Response: + self.records.append( + { + "url": str(request.url), + "agent_id": ctx.agent_id, + "parent_agent_id": ctx.parent_agent_id, + "interaction_type": ctx.interaction_type, + } + ) + return await super().send_request(request, ctx) + + +def _is_inference_url(url: str) -> bool: + u = url.lower() + return ( + u.endswith("/chat/completions") + or u.endswith("/responses") + or u.endswith("/v1/messages") + or u.endswith("/messages") + ) + + +def _assert_subagent_request_metadata(records: list[dict[str, str | None]]) -> None: + inference = [r for r in records if _is_inference_url(r["url"] or "")] + assert len(inference) > 0, "request handler should observe inference requests" + + subagent_request = next((r for r in inference if r["parent_agent_id"]), None) + assert subagent_request is not None, ( + "sub-agent inference request should carry a parent_agent_id" + ) + assert subagent_request["agent_id"], "sub-agent inference request should carry an agent_id" + assert subagent_request["interaction_type"], ( + "sub-agent inference request should carry an interaction_type" + ) + assert subagent_request["parent_agent_id"] != subagent_request["agent_id"] + + class TestSubagentHooks: async def test_should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls( self, ctx: E2ETestContext ): """Test that preToolUse/postToolUse hooks fire for sub-agent tool calls""" hook_log = [] + request_handler = _RecordingRequestHandler() async def on_pre_tool_use(input_data, invocation): hook_log.append( @@ -54,6 +102,7 @@ async def on_post_tool_use(input_data, invocation): working_directory=ctx.work_dir, env=env, github_token=github_token, + request_handler=request_handler, ) session = await client.create_session( @@ -87,6 +136,7 @@ async def on_post_tool_use(input_data, invocation): assert view_pre[0]["sessionId"] != task_pre[0]["sessionId"], ( "Sub-agent tool hooks should have a different sessionId than parent tool hooks" ) + _assert_subagent_request_metadata(request_handler.records) await session.disconnect() await client.stop() diff --git a/python/e2e/testharness/__init__.py b/python/e2e/testharness/__init__.py index 83f548eaa0..75ce76d9c5 100644 --- a/python/e2e/testharness/__init__.py +++ b/python/e2e/testharness/__init__.py @@ -1,6 +1,6 @@ """Test harness for E2E tests.""" -from .context import CLI_PATH, DEFAULT_GITHUB_TOKEN, E2ETestContext +from .context import CLI_PATH, DEFAULT_GITHUB_TOKEN, E2ETestContext, is_inprocess_transport from .helper import get_final_assistant_message, get_next_event_of_type, wait_for_condition from .proxy import CapiProxy @@ -12,4 +12,5 @@ "get_final_assistant_message", "get_next_event_of_type", "wait_for_condition", + "is_inprocess_transport", ] diff --git a/python/e2e/testharness/context.py b/python/e2e/testharness/context.py index de1bf03292..679a36e28c 100644 --- a/python/e2e/testharness/context.py +++ b/python/e2e/testharness/context.py @@ -46,6 +46,15 @@ def get_cli_path_for_tests() -> str: DEFAULT_GITHUB_TOKEN = "fake-token-for-e2e-tests" +def is_inprocess_transport() -> bool: + """Return True when the E2E suite should run over the in-process (FFI) transport. + + Selected by the ``inprocess`` CI matrix cell via + ``COPILOT_SDK_DEFAULT_CONNECTION=inprocess``. Mirrors the Node/.NET harnesses. + """ + return (os.environ.get("COPILOT_SDK_DEFAULT_CONNECTION") or "").lower() == "inprocess" + + class E2ETestContext: """Holds shared resources for E2E tests.""" @@ -56,6 +65,10 @@ def __init__(self): self.proxy_url: str = "" self._proxy: CapiProxy | None = None self._client: CopilotClient | None = None + self._inprocess: bool = is_inprocess_transport() + self._client_inprocess: bool = False + self._restore_env: list[tuple[str, str | None]] = [] + self._restore_cwd: str | None = None async def setup(self, cli_args: list[str] | None = None): """Set up the test context with a shared client. @@ -83,16 +96,87 @@ async def setup(self, cli_args: list[str] | None = None): }, ) - # Create the shared client (like Node.js/Go do) - self._client = CopilotClient( - connection=RuntimeConnection.for_stdio( - path=self.cli_path, - args=tuple(cli_args or []), - ), - working_directory=self.work_dir, - env=self.get_env(), - github_token=DEFAULT_GITHUB_TOKEN, + # Create the shared client (like Node.js/Go do). The in-process (FFI) + # transport loads the runtime into this test host process, so it cannot + # honor a per-client working_directory or env block: the worker inherits + # this process's ambient cwd and environment. We therefore mirror the + # per-test redirects, isolated home, and credentials onto the real process + # (os.environ writes reach native getenv on CPython) and chdir into the + # work dir, then create the client without working_directory/env. This + # matches the Node/.NET in-process harnesses. + self._client_inprocess = self._inprocess and not cli_args + if self._client_inprocess: + self._apply_inprocess_environment() + self._client = CopilotClient( + connection=RuntimeConnection.for_inprocess(), + github_token=DEFAULT_GITHUB_TOKEN, + ) + else: + self._client = CopilotClient( + connection=RuntimeConnection.for_stdio( + path=self.cli_path, + args=tuple(cli_args or []), + ), + working_directory=self.work_dir, + env=self.get_env(), + github_token=DEFAULT_GITHUB_TOKEN, + ) + + def _apply_inprocess_environment(self) -> None: + """Mirror the isolated test environment onto the real process for in-process hosting. + + The in-process worker inherits this process's environment and cwd at + spawn, so the per-test redirects must live on ``os.environ`` and the + process cwd. Auth flows via GH_TOKEN/GITHUB_TOKEN (the FFI argv omits the + stdio ``--auth-token-env`` wiring) and HMAC is disabled so host-side auth + resolution matches the replay snapshots. Restored in ``teardown``. + """ + inprocess_env = dict(self.get_env()) + inprocess_env.update( + { + "GH_TOKEN": DEFAULT_GITHUB_TOKEN, + "GITHUB_TOKEN": DEFAULT_GITHUB_TOKEN, + "COPILOT_CLI_PATH": self.cli_path, + "COPILOT_HMAC_KEY": "", + "CAPI_HMAC_KEY": "", + } ) + for key, value in inprocess_env.items(): + self._restore_env.append((key, os.environ.get(key))) + os.environ[key] = value + + self._restore_cwd = os.getcwd() + os.chdir(self.work_dir) + + def add_runtime_env(self, key: str, value: str) -> None: + """Set an env var seen by the runtime, honoring the active transport. + + Child-process transports read env from the client's env block, but the + in-process worker inherits *this* process's environment, so the var must + live on ``os.environ`` (and be restored in teardown). Must be called + before the runtime starts (i.e., before the first ``create_session``). + """ + if self._client_inprocess: + self._restore_env.append((key, os.environ.get(key))) + os.environ[key] = value + else: + options = self.client._options + if options.env is None: + options.env = {} + options.env[key] = value + + def _restore_inprocess_environment(self) -> None: + """Undo the in-process environment mirror and cwd change from setup.""" + for key, previous in reversed(self._restore_env): + if previous is None: + os.environ.pop(key, None) + else: + os.environ[key] = previous + self._restore_env = [] + if self._restore_cwd is not None: + with contextlib.suppress(OSError): + os.chdir(self._restore_cwd) + self._restore_cwd = None async def teardown(self, test_failed: bool = False): """Clean up the test context. @@ -107,6 +191,9 @@ async def teardown(self, test_failed: bool = False): pass # stop() completes all cleanup before raising; safe to ignore in teardown self._client = None + if self._client_inprocess: + self._restore_inprocess_environment() + if self._proxy: await self._proxy.stop(skip_writing_cache=test_failed) self._proxy = None diff --git a/python/test_canvas.py b/python/test_canvas.py index 8bcb79a230..684cef6b79 100644 --- a/python/test_canvas.py +++ b/python/test_canvas.py @@ -14,6 +14,7 @@ CanvasDeclaration, CanvasError, CanvasHandler, + CanvasProviderIdentity, ExtensionInfo, OpenCanvasInstance, ) @@ -67,6 +68,16 @@ def test_extension_info_serializes(): assert info.to_dict() == {"source": "github-app", "name": "my-ext"} +def test_canvas_provider_identity_serializes(): + provider = CanvasProviderIdentity(id="app:builtin:window-1", name="Built-in") + assert provider.to_dict() == {"id": "app:builtin:window-1", "name": "Built-in"} + + +def test_canvas_provider_identity_drops_optional_name(): + provider = CanvasProviderIdentity(id="app:builtin:window-1") + assert provider.to_dict() == {"id": "app:builtin:window-1"} + + def test_canvas_open_response_drops_none_fields(): assert CanvasProviderOpenResult().to_dict() == {} assert CanvasProviderOpenResult(url="https://x", status="ok").to_dict() == { diff --git a/python/test_cli_download.py b/python/test_cli_download.py new file mode 100644 index 0000000000..36952919df --- /dev/null +++ b/python/test_cli_download.py @@ -0,0 +1,53 @@ +"""Tests for the in-process runtime library download integrity checks.""" + +from __future__ import annotations + +import base64 +import hashlib +from unittest.mock import patch + +import pytest + +from copilot import _cli_download + + +def _integrity(data: bytes, algo: str = "sha512") -> str: + digest = hashlib.new(algo, data).digest() + return f"{algo}-{base64.b64encode(digest).decode('ascii')}" + + +class TestVerifyIntegrity: + def test_accepts_matching_checksum(self): + data = b"native-library-bytes" + _cli_download._verify_integrity(data, _integrity(data)) + + def test_rejects_mismatched_checksum(self): + with pytest.raises(RuntimeError, match="Integrity mismatch"): + _cli_download._verify_integrity(b"tampered", _integrity(b"original")) + + def test_rejects_unsupported_algorithm(self): + # Fail closed rather than silently skipping verification of native code. + with pytest.raises(RuntimeError, match="Unsupported integrity algorithm"): + _cli_download._verify_integrity(b"bytes", "md5-deadbeef") + + +class TestEnsureRuntimeLibraryFailsClosed: + def test_raises_when_integrity_unavailable(self, tmp_path): + """A missing npm integrity value must abort the download, not load unverified code.""" + cli_path = tmp_path / "copilot" + cli_path.write_bytes(b"#!/bin/sh\n") + + with ( + patch("copilot._ffi_runtime_host.resolve_library_path", return_value=None), + patch.object(_cli_download, "_should_skip_download", return_value=False), + patch.object(_cli_download, "get_npm_platform", return_value="linux-x64"), + patch.object(_cli_download, "get_runtime_lib_url", return_value="https://example/lib"), + patch.object(_cli_download, "_fetch_url_bytes", return_value=b"tarball-bytes"), + patch.object(_cli_download, "_fetch_runtime_integrity", return_value=None), + patch.object(_cli_download, "_extract_runtime_node") as extract, + ): + with pytest.raises(RuntimeError, match="refusing to load unverified native code"): + _cli_download.ensure_runtime_library(str(cli_path), version="1.2.3") + + # The library bytes must never be extracted/written when verification is impossible. + extract.assert_not_called() diff --git a/python/test_client.py b/python/test_client.py index 5e1b8be634..66941f289d 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -5,14 +5,17 @@ """ import asyncio +import inspect from datetime import UTC, datetime from unittest.mock import AsyncMock, Mock, patch import pytest from copilot import ( + CanvasProviderIdentity, CapiSessionOptions, CopilotClient, + ExtensionInfo, ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, RuntimeConnection, @@ -37,9 +40,18 @@ SessionEvent, SessionEventType, ) +from copilot.tools import Tool from e2e.testharness import CLI_PATH +def test_inprocess_connection_has_no_child_process_options(): + connection = RuntimeConnection.for_inprocess() + + assert list(inspect.signature(RuntimeConnection.for_inprocess).parameters) == [] + assert not hasattr(connection, "path") + assert not hasattr(connection, "args") + + class TestClientShutdown: @pytest.mark.asyncio async def test_stop_requests_runtime_shutdown_for_owned_process(self): @@ -556,6 +568,89 @@ async def mock_request(method, params, **kwargs): finally: await client.force_stop() + @pytest.mark.asyncio + async def test_create_and_resume_session_forward_tool_metadata(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + try: + captured = {} + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method in ("session.create", "session.resume"): + result = {"sessionId": params.get("sessionId") or "session-1"} + callback = kwargs.get("on_response_inline") + if callback is not None: + callback(result) + return result + return {} + + client._client.request = mock_request + metadata = {"github.com/copilot:safeForTelemetry": {"name": True, "inputsNames": False}} + tool = Tool(name="my_tool", description="a tool", metadata=metadata) + plain_tool = Tool(name="plain_tool", description="a tool") + + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + tools=[tool, plain_tool], + ) + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + tools=[tool], + ) + + create_tools = captured["session.create"]["tools"] + assert create_tools[0]["metadata"] == metadata + # Omitted when unset. + assert "metadata" not in create_tools[1] + assert captured["session.resume"]["tools"][0]["metadata"] == metadata + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_create_and_resume_session_forward_canvas_provider(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + try: + captured = {} + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method in ("session.create", "session.resume"): + result = {"sessionId": params.get("sessionId") or "session-1"} + callback = kwargs.get("on_response_inline") + if callback is not None: + callback(result) + return result + return {} + + client._client.request = mock_request + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + extension_info=ExtensionInfo(source="github-app", name="counter"), + canvas_provider=CanvasProviderIdentity(id="app:builtin:window-1", name="Built-in"), + ) + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + canvas_provider=CanvasProviderIdentity(id="app:builtin:window-1"), + ) + + assert captured["session.create"]["canvasProvider"] == { + "id": "app:builtin:window-1", + "name": "Built-in", + } + assert captured["session.create"]["extensionInfo"] == { + "source": "github-app", + "name": "counter", + } + assert captured["session.resume"]["canvasProvider"] == { + "id": "app:builtin:window-1", + } + finally: + await client.force_stop() + @pytest.mark.asyncio async def test_create_and_resume_session_forward_new_session_options(self): client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) @@ -2521,12 +2616,33 @@ async def on_telemetry(notification): await client.force_stop() @pytest.mark.asyncio - async def test_event_handler_not_registered_without_option(self): + async def test_event_not_forwarded_without_option(self): + # Client-global handlers are always registered (so that hooks.invoke works), + # but without the on_github_telemetry option the telemetry adapter is inert: + # incoming events must not be forwarded to any callback. client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) await client.start() try: - assert "gitHubTelemetry.event" not in client._client.notification_method_handlers - assert "gitHubTelemetry.event" not in client._client.request_handlers + assert client._on_github_telemetry is None + + # Dispatching a telemetry event is a harmless no-op when not opted in. + client._client._handle_message( + { + "jsonrpc": "2.0", + "method": "gitHubTelemetry.event", + "params": { + "sessionId": "sess-no-telemetry", + "restricted": False, + "event": { + "kind": "tool_call_executed", + "metrics": {"duration_ms": 1.0}, + "properties": {"tool": "shell"}, + "session_id": "sess-no-telemetry", + }, + }, + } + ) + await asyncio.sleep(0) finally: await client.force_stop() diff --git a/python/test_event_forward_compatibility.py b/python/test_event_forward_compatibility.py index 7f8c29b6e8..1ffbd59c54 100644 --- a/python/test_event_forward_compatibility.py +++ b/python/test_event_forward_compatibility.py @@ -49,6 +49,20 @@ def test_unknown_event_type_maps_to_unknown(self): event = session_event_from_dict(unknown_event) assert event.type == SessionEventType.UNKNOWN, f"Expected UNKNOWN, got {event.type}" + def test_internal_event_type_maps_to_unknown(self): + """Internal events should use the forward-compatible raw event path.""" + internal_event = { + "id": str(uuid4()), + "timestamp": datetime.now().isoformat(), + "parentId": None, + "type": "session.memory_changed", + "data": {}, + } + + event = session_event_from_dict(internal_event) + assert event.type == SessionEventType.UNKNOWN + assert session_event_to_dict(event)["type"] == "session.memory_changed" + def test_known_event_preserves_top_level_agent_id(self): """Known events should preserve the top-level sub-agent envelope ID.""" known_event = { diff --git a/python/test_tools.py b/python/test_tools.py index c5230385f2..90498c2b8e 100644 --- a/python/test_tools.py +++ b/python/test_tools.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator from copilot import define_tool +from copilot.generated.rpc import ExternalToolTextResultForLlm from copilot.tools import ( ToolInvocation, ToolResult, @@ -512,3 +513,40 @@ def test_call_tool_result_dict_is_json_serialized_by_normalize(self): result = _normalize_result({"content": [{"type": "text", "text": "hello"}]}) parsed = json.loads(result.text_result_for_llm) assert parsed == {"content": [{"type": "text", "text": "hello"}]} + + +class TestToolReferences: + def test_tool_references_pass_through_normalize(self): + input_result = ToolResult( + text_result_for_llm="found 2 tools", + result_type="success", + tool_references=["get_weather", "check_status"], + ) + result = _normalize_result(input_result) + assert result.tool_references == ["get_weather", "check_status"] + + def test_tool_references_serialized_to_wire(self): + wire = ExternalToolTextResultForLlm( + text_result_for_llm="found 2 tools", + result_type="success", + tool_references=["get_weather", "check_status"], + ) + data = wire.to_dict() + assert data["toolReferences"] == ["get_weather", "check_status"] + + def test_tool_references_omitted_when_none(self): + wire = ExternalToolTextResultForLlm( + text_result_for_llm="ok", + result_type="success", + ) + assert "toolReferences" not in wire.to_dict() + + def test_tool_references_round_trip_from_wire(self): + wire = ExternalToolTextResultForLlm.from_dict( + { + "textResultForLlm": "found tools", + "resultType": "success", + "toolReferences": ["alpha", "beta"], + } + ) + assert wire.tool_references == ["alpha", "beta"] diff --git a/rust/.gitignore b/rust/.gitignore index c4095ffc0f..c149fa3946 100644 --- a/rust/.gitignore +++ b/rust/.gitignore @@ -1,3 +1,4 @@ /target Cargo.lock.bak cli-version.txt +cli-version-in-process.txt diff --git a/rust/Cargo.lock b/rust/Cargo.lock index fb7b66e198..8de6797989 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -433,6 +433,9 @@ dependencies = [ "futures-util", "getrandom 0.2.17", "http", + "indexmap", + "libloading", + "native-tls", "parking_lot", "regex", "reqwest", @@ -773,6 +776,16 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libredox" version = "0.1.16" @@ -1217,9 +1230,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ - "log", "once_cell", - "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -1836,11 +1847,9 @@ checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" dependencies = [ "base64", "log", + "native-tls", "once_cell", - "rustls", - "rustls-pki-types", "url", - "webpki-roots 0.26.11", ] [[package]] @@ -2033,24 +2042,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.7", -] - -[[package]] -name = "webpki-roots" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "windows-link" version = "0.2.1" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 66ef69ad21..0f18a9b159 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -13,6 +13,7 @@ readme = "README.md" license = "MIT" include = [ "src/**/*", + "build/**/*", "examples/**/*", "tests/**/*", "build.rs", @@ -20,6 +21,7 @@ include = [ "README.md", "LICENSE", "cli-version.txt", + "cli-version-in-process.txt", ] [lib] @@ -28,6 +30,7 @@ name = "github_copilot_sdk" [features] default = ["bundled-cli"] bundled-cli = ["dep:tar", "dep:flate2", "dep:zip"] +bundled-in-process = ["bundled-cli", "dep:libloading"] derive = ["dep:schemars"] test-support = [] @@ -40,6 +43,7 @@ rustdoc-args = ["--cfg", "docsrs"] [dependencies] async-trait = "0.1" +indexmap = { version = "2", features = ["serde"] } schemars = { version = "1", optional = true } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -48,10 +52,13 @@ tokio-stream = { version = "0.1", features = ["sync"] } tokio-util = { version = "0.7", default-features = false } tracing = "0.1" dirs = "5" +libloading = { version = "0.8", optional = true } parking_lot = "0.12" regex = "1" getrandom = "0.2" uuid = { version = "1", default-features = false, features = ["v4"] } +flate2 = { version = "1", optional = true } +tar = { version = "0.4", optional = true } # LLM inference callback transport: idiomatic HTTP/WebSocket forwarding for the # `CopilotRequestHandler`, plus base64/byte/stream plumbing for the chunk protocol. base64 = "0.22" @@ -64,10 +71,6 @@ tokio-tungstenite = { version = "0.24", default-features = false, features = ["c [target.'cfg(windows)'.dependencies] zip = { version = "2", default-features = false, features = ["deflate"], optional = true } -[target.'cfg(not(windows))'.dependencies] -flate2 = { version = "1", optional = true } -tar = { version = "0.4", optional = true } - [dev-dependencies] rusqlite = { version = "0.35", features = ["bundled"] } schemars = "1" @@ -88,9 +91,12 @@ name = "protocol_version_test" required-features = ["test-support"] [build-dependencies] +base64 = "0.22" dirs = "5" flate2 = "1" +serde_json = "1" sha2 = "0.10" tar = "0.4" -ureq = { version = "2", default-features = false, features = ["tls"] } +ureq = { version = "2", default-features = false, features = ["native-tls"] } +native-tls = "0.2" zip = { version = "2", default-features = false, features = ["deflate"] } diff --git a/rust/README.md b/rust/README.md index 6d92224088..254a2b2167 100644 --- a/rust/README.md +++ b/rust/README.md @@ -72,15 +72,15 @@ client.stop().await?; **`ClientOptions`:** -| Field | Type | Description | -| ------------- | --------------------------- | --------------------------------------------------------------- | -| `program` | `CliProgram` | `Resolve` (default: auto-detect) or `Path(PathBuf)` (explicit) | -| `prefix_args` | `Vec` | Args before `--server` (e.g. script path for node) | -| `cwd` | `PathBuf` | Working directory for CLI process | -| `env` | `Vec<(OsString, OsString)>` | Environment variables for CLI process | -| `env_remove` | `Vec` | Environment variables to remove | -| `extra_args` | `Vec` | Extra CLI flags | -| `transport` | `Transport` | `Stdio` (default), `Tcp { port }`, or `External { host, port }` | +| Field | Type | Description | +| ------------------- | --------------------------- | ----------------------------------------------------------------- | +| `program` | `CliProgram` | `Resolve` (default: auto-detect) or `Path(PathBuf)` (explicit) | +| `prefix_args` | `Vec` | Args before `--server` (e.g. script path for node) | +| `working_directory` | `PathBuf` | Working directory for CLI process (empty = host process's cwd) | +| `env` | `Vec<(OsString, OsString)>` | Environment variables for CLI process | +| `env_remove` | `Vec` | Environment variables to remove | +| `extra_args` | `Vec` | Extra CLI flags | +| `transport` | `Transport` | `Default`, `Stdio`, `InProcess`, `Tcp`, or `External` | With the default `CliProgram::Resolve`, `Client::start()` resolves the CLI in this order: an explicit `CliProgram::Path(path)`, the `COPILOT_CLI_PATH` env var, then the bundled CLI that was embedded at build time. There is no PATH scanning — if you've opted out of bundling (`default-features = false`) you must supply either `CliProgram::Path` or `COPILOT_CLI_PATH`. @@ -749,7 +749,7 @@ none of them are scheduled for removal. caller-supplied `AsyncRead` / `AsyncWrite`. Useful for testing, in-process embedding, or custom transports. Other SDKs are spawn-only or fixed-stdio. -- **`enum Transport { Stdio, Tcp, External }`** — explicit, exhaustive +- **`enum Transport { Default, Stdio, InProcess, Tcp, External }`** — explicit transport selector on `ClientOptions::transport`. Node/Python/Go rely on conditional config field combinations instead. - **Split `prefix_args` / `extra_args`** on `ClientOptions` — separate @@ -776,7 +776,18 @@ none of them are scheduled for removal. ## Embedded CLI -The SDK provisions the Copilot CLI binary at build time. By default the `bundled-cli` feature embeds the verified binary directly in your compiled crate, so end-user binaries are self-contained — no env var setup, no separate install, just `cargo build`. +The SDK provisions its runtime at build time. By default the `bundled-cli` +feature embeds the verified child-process runtime in your compiled crate. +Enable `bundled-in-process` to additionally embed the native runtime library +and use `Transport::InProcess`: + +```toml +github-copilot-sdk = { version = "0.1", features = ["bundled-in-process"] } +``` + +`CliProgram::Path` and raw `ClientOptions::extra_args` apply only to +child-process transports. Set `COPILOT_CLI_PATH` only when using an externally +provisioned compatible runtime package with in-process transport. For builds that prefer a smaller artifact, disable the `bundled-cli` feature: @@ -795,7 +806,7 @@ github-copilot-sdk = { version = "0.1", default-features = false } > together. > > **Convenience on the build machine only.** As a special case, -> `build.rs` downloads and SHA-verifies the compatible CLI version and +> `build.rs` downloads and integrity-verifies the compatible CLI version and > drops it into the build machine's per-user cache; the runtime > resolver on that same machine will pick it up automatically. This > makes local development and CI ergonomic, but it does **not** carry @@ -812,8 +823,11 @@ github-copilot-sdk = { version = "0.1", default-features = false } The resolved version is baked into the crate via `cargo:rustc-env=COPILOT_SDK_CLI_VERSION` regardless of mode. The runtime resolver consumes it to recompute the on-disk path by convention, so no absolute paths leak into the rlib. -2. **Build time:** `build.rs` downloads the platform-appropriate archive from the [`github/copilot-cli` GitHub Releases](https://github.com/github/copilot-cli/releases) (`copilot-{platform}.tar.gz` on macOS/Linux, `.zip` on Windows), live-fetches the matching `SHA256SUMS.txt`, and verifies the archive hash. Then: - - **`bundled-cli` on (default, release):** embeds the raw archive bytes via `include_bytes!()`. Runtime extracts on first `Client::start()`. +2. **Build time:** `build.rs` downloads the platform-specific npm package and + verifies its `sha512` integrity against the lockfile or publish snapshot. + Then: + - **`bundled-cli` on (default):** creates and embeds a minimal archive containing only the CLI executable. + - **`bundled-in-process` on:** the minimal archive additionally contains the platform-native runtime library (`.dll`, `.so`, or `.dylib`); no other npm package files are embedded. - **`bundled-cli` off:** extracts the binary directly into the platform cache (staging file + atomic rename), idempotent across rebuilds. If the extracted binary is already present at the expected path, the download is skipped entirely — the extracted binary *is* the cache. 3. **Runtime:** in both modes the binary lives at: @@ -899,10 +913,11 @@ Supported: `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `win32-x64` ## Features -| Feature | Default | Description | -| -------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `bundled-cli` | ✓ | Build-time CLI embedding. Pulls in `tar`+`flate2` (Linux/macOS) or `zip` (Windows). Disable via `default-features = false` to opt out (e.g. when shipping a smaller binary or when always supplying the CLI via `CliProgram::Path` / `COPILOT_CLI_PATH`). | -| `derive` | — | `schema_for::()` for generating JSON Schema from Rust types (adds `schemars`). Enable when defining [tool parameters](#tool-registration). | +| Feature | Default | Description | +| ------- | ------- | ----------- | +| `bundled-cli` | ✓ | Embeds only the CLI executable. Disable via `default-features = false` when supplying the CLI via `CliProgram::Path` or `COPILOT_CLI_PATH`. | +| `bundled-in-process` | — | Enables `Transport::InProcess`, implies `bundled-cli`, and additionally embeds only the platform-native runtime library. | +| `derive` | — | `schema_for::()` for generating JSON Schema from Rust types (adds `schemars`). | ```toml # These examples use registry syntax for illustration; until the crate is @@ -911,7 +926,10 @@ Supported: `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `win32-x64` # Default — bundles the Copilot CLI in your binary. github-copilot-sdk = "0.1" -# Opt out of bundling — resolve CLI from COPILOT_CLI_PATH or system PATH instead. +# Enable the in-process transport and bundle its native runtime library. +github-copilot-sdk = { version = "0.1", features = ["bundled-in-process"] } + +# Opt out of bundling — supply the CLI explicitly at runtime. github-copilot-sdk = { version = "0.1", default-features = false } # Derive JSON Schema for tool parameters (adds to default bundled-cli). diff --git a/rust/build.rs b/rust/build.rs index 66d1de7bc8..d04cf2870b 100644 --- a/rust/build.rs +++ b/rust/build.rs @@ -1,709 +1,11 @@ -use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; -use std::time::Duration; +#[cfg(feature = "bundled-in-process")] +#[path = "build/in_process.rs"] +mod implementation; -use sha2::Digest; +#[cfg(not(feature = "bundled-in-process"))] +#[path = "build/out_of_process.rs"] +mod implementation; fn main() { - println!("cargo:rerun-if-env-changed=DOCS_RS"); - println!("cargo:rerun-if-env-changed=COPILOT_SKIP_CLI_DOWNLOAD"); - println!("cargo:rerun-if-env-changed=COPILOT_CLI_EXTRACT_DIR"); - println!("cargo:rerun-if-env-changed=BUNDLED_CLI_CACHE_DIR"); - println!("cargo::rustc-check-cfg=cfg(has_bundled_cli)"); - println!("cargo::rustc-check-cfg=cfg(has_extracted_cli)"); - println!("cargo:rerun-if-changed=cli-version.txt"); - - // Only declare the lockfile rerun when the lockfile actually exists. - // Cargo treats `rerun-if-changed` for a missing path as "always rerun" - // — so unconditionally declaring this on consumers without a sibling - // `nodejs/` (vendored slots, published crates) would force build.rs - // to re-run on every `cargo build` even when nothing has changed. - // The lockfile path is only the source-of-truth in this repo's - // contributor builds; everywhere else `cli-version.txt` is canonical. - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set"); - let lockfile = Path::new(&manifest_dir) - .join("..") - .join("nodejs") - .join("package-lock.json"); - if lockfile.is_file() { - println!("cargo:rerun-if-changed={}", lockfile.display()); - } - - // Hard opt-out: disable the entire download / bundle / cache mechanism - // in one step. For consumers who always supply the CLI via - // `CliProgram::Path` or `COPILOT_CLI_PATH` and don't want build.rs to - // touch the network (offline builds, locked-down CI, etc.). Works - // regardless of the `bundled-cli` cargo feature state — with neither - // `has_bundled_cli` nor `has_extracted_cli` emitted, runtime resolution - // falls straight through to `Error::BinaryNotFound` unless an explicit - // path source resolves first. - if std::env::var_os("COPILOT_SKIP_CLI_DOWNLOAD").is_some() { - println!( - "cargo:warning=COPILOT_SKIP_CLI_DOWNLOAD is set — skipping CLI download/bundle/cache" - ); - return; - } - - // docs.rs builds in a sandboxed environment without network access. - // Skip the CLI download so documentation can be generated successfully. - if std::env::var_os("DOCS_RS").is_some() { - println!("cargo:warning=DOCS_RS is set — skipping CLI download/bundle/cache"); - return; - } - - let Some(platform) = target_platform() else { - println!("cargo:warning=Unsupported target platform for Copilot CLI bundling — skipping"); - return; - }; - - let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is always set by cargo"); - let out = Path::new(&out_dir); - - // Resolve version + per-asset SHA-256 from one of two sources, in order: - // 1. `cli-version.txt` snapshot at the crate root (published-crate - // consumer; generated by the publish workflow). Combined format: - // `version=X` line + per-asset hash lines. Committing the hashes - // makes the publish workflow the trust boundary — an attacker who - // later re-points the release tag can't silently poison consumer - // builds. - // 2. Sibling `../nodejs/package-lock.json` (contributor build inside - // the github/copilot-sdk repo; live SHA256SUMS.txt fetch). Matches - // the .NET `_GetCopilotCliVersion` MSBuild target and the Go - // `cmd/bundler` tool. - let (version, expected_hash) = resolve_version_and_hash(platform.asset_name); - - // Bake the version into the crate regardless of mode. This is the - // single source of truth for "what CLI version did build.rs target", - // consumed by both the embed-mode path computation in embeddedcli.rs - // and the runtime path computation in resolve.rs (when `bundled-cli` - // is off). It's a small, machine-independent datum: no absolute - // paths, no username/home leakage, so sccache / cross-machine - // `target/` reuse stays cache-coherent. - println!("cargo:rustc-env=COPILOT_SDK_CLI_VERSION={version}"); - - let base_url = format!("https://github.com/github/copilot-cli/releases/download/v{version}"); - let cache_dir = std::env::var("BUNDLED_CLI_CACHE_DIR") - .ok() - .map(std::path::PathBuf::from); - - // Versioned cache key since copilot asset names don't include the version. - let cache_key = format!("v{version}-{}", platform.asset_name); - - if std::env::var_os("CARGO_FEATURE_BUNDLED_CLI").is_some() { - // Embed mode: we need the archive bytes to bake into the rlib, so - // always run the download (cache hit short-circuits inside - // `cached_download`). - let archive = cached_download( - &format!("{base_url}/{}", platform.asset_name), - &cache_key, - &expected_hash, - &cache_dir, - ); - verify_binary_present_in_archive(&archive, platform.binary_name, platform.asset_name); - emit_embedded(out, &archive); - println!("cargo:rustc-cfg=has_bundled_cli"); - } else { - // With `bundled-cli` off the extracted binary *is* the cache. - // Skip the upstream download entirely when it already exists at - // the expected path. No two separate caches. - // - // Runtime resolution (see `src/resolve.rs::extracted_cli_path`) - // recomputes this same path from `COPILOT_SDK_CLI_VERSION` + the - // OS-derived binary name + optional `COPILOT_CLI_EXTRACT_DIR`, - // so we don't bake an absolute path into the crate. - let install_dir = extracted_install_dir(&version); - let final_path = install_dir.join(platform.binary_name); - - // Invalidate build.rs whenever the cached binary disappears (cache GC, - // manual rm, OS reset, switching extract dir). Without this, cargo - // replays the saved `has_extracted_cli` cfg from its build-script - // output cache even when the file is gone, and runtime resolution - // fails with BinaryNotFound. - println!("cargo:rerun-if-changed={}", final_path.display()); - - if !final_path.is_file() { - let archive = cached_download( - &format!("{base_url}/{}", platform.asset_name), - &cache_key, - &expected_hash, - &cache_dir, - ); - verify_binary_present_in_archive(&archive, platform.binary_name, platform.asset_name); - extract_to_cache(&archive, &install_dir, platform); - } - - // Re-check after potential download+extract above; not an `else` - // because we need to verify the extraction actually produced the file. - if final_path.is_file() { - println!("cargo:rustc-cfg=has_extracted_cli"); - } - } -} - -/// Install directory used when `bundled-cli` is off. Mirrors the runtime -/// convention in `src/resolve.rs::extracted_cli_path`: both sides MUST -/// compute the same path from the same inputs, otherwise the runtime -/// resolver won't find what build.rs extracted. -/// -/// If `COPILOT_CLI_EXTRACT_DIR` is set the binary lives directly under -/// that directory (no per-version subdir) — useful for vendored slots and -/// for `.cargo/config.toml [env]`-style pinning that's symmetric between -/// build-time write and runtime read. Otherwise the binary lives under -/// `/github-copilot-sdk/cli//`. -fn extracted_install_dir(version: &str) -> PathBuf { - if let Some(custom) = std::env::var_os("COPILOT_CLI_EXTRACT_DIR") { - PathBuf::from(custom) - } else { - let cache = dirs::cache_dir().unwrap_or_else(std::env::temp_dir); - cache - .join("github-copilot-sdk") - .join("cli") - .join(sanitize_version(version)) - } -} - -/// Emit the `bundled_cli.rs` glue + `copilot_cli.archive` blob into `OUT_DIR` -/// for embed mode (`bundled-cli` cargo feature on). The version is exposed -/// crate-wide via the unconditional `cargo:rustc-env=COPILOT_SDK_CLI_VERSION` -/// emit; the binary name is OS-derived at runtime — so all we need to -/// generate here is the archive blob include. -fn emit_embedded(out: &Path, archive: &[u8]) { - std::fs::write(out.join("copilot_cli.archive"), archive) - .expect("failed to write copilot_cli.archive"); - - let generated = r#"// Auto-generated by github-copilot-sdk build.rs. Do not edit. -pub(super) static CLI_ARCHIVE: &[u8] = include_bytes!("copilot_cli.archive"); -"#; - - std::fs::write(out.join("bundled_cli.rs"), generated).expect("failed to write bundled_cli.rs"); -} - -/// Resolve the CLI version and the expected SHA-256 hash for the current -/// target's archive. Picks one of two sources in order. Panics with a clear -/// error if neither is available. -fn resolve_version_and_hash(asset_name: &str) -> (String, String) { - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set"); - - // 1. Snapshot file at the crate root (published-crate consumer, - // vendored-slot consumer). Combined version + per-asset hashes. - let snapshot = Path::new(&manifest_dir).join("cli-version.txt"); - if snapshot.is_file() { - let contents = std::fs::read_to_string(&snapshot) - .unwrap_or_else(|e| panic!("failed to read {}: {e}", snapshot.display())); - return parse_snapshot(&contents, asset_name) - .unwrap_or_else(|e| panic!("invalid {}: {e}", snapshot.display())); - } - - // 2. Lockfile fallback (contributor build inside github/copilot-sdk) — - // read version, fetch live SHA256SUMS. - let lockfile = Path::new(&manifest_dir) - .join("..") - .join("nodejs") - .join("package-lock.json"); - if lockfile.is_file() { - let version = read_version_from_package_lock(&lockfile); - let hash = fetch_live_sha256(&version, asset_name); - return (version, hash); - } - - panic!( - "Could not resolve the Copilot CLI version.\n\ - Tried:\n\ - - {} (missing)\n\ - - {} (missing)\n\ - In a published crate or vendored slot, `cli-version.txt` should be present.\n\ - Inside the github/copilot-sdk repo, `../nodejs/package-lock.json` is the source.", - snapshot.display(), - lockfile.display(), - ); -} - -/// Parse the `cli-version.txt` snapshot file. Format is one `key=value` per -/// line. The first non-comment line is `version=X.Y.Z`; subsequent lines map -/// asset filename to hex SHA-256. Blank lines and lines starting with `#` -/// are skipped. -fn parse_snapshot(contents: &str, asset_name: &str) -> Result<(String, String), String> { - let mut version: Option = None; - let mut hash: Option = None; - for (line_no, raw) in contents.lines().enumerate() { - let line = raw.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let (key, value) = line - .split_once('=') - .ok_or_else(|| format!("line {}: expected `key=value`, got `{raw}`", line_no + 1))?; - match key.trim() { - "version" => version = Some(value.trim().to_string()), - k if k == asset_name => hash = Some(value.trim().to_string()), - _ => {} - } - } - let version = version.ok_or("missing `version=` line")?; - let hash = hash.ok_or_else(|| format!("missing hash for asset `{asset_name}`"))?; - Ok((version, hash)) -} - -/// Read the `@github/copilot` version from `nodejs/package-lock.json`. -fn read_version_from_package_lock(path: &Path) -> String { - let contents = std::fs::read_to_string(path) - .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); - // Minimal JSON walk: find `"node_modules/@github/copilot"` object and - // its `"version"` field. Full JSON parsing keeps build.rs dep-light by - // using a regex; the file is generated by npm and we're matching an - // exact key path. - let key = "\"node_modules/@github/copilot\""; - let key_pos = contents - .find(key) - .unwrap_or_else(|| panic!("{} does not contain {key}", path.display())); - let after_key = &contents[key_pos + key.len()..]; - let version_key = "\"version\""; - let v_pos = after_key - .find(version_key) - .unwrap_or_else(|| panic!("no `version` field found near {key} in {}", path.display())); - let after_v = &after_key[v_pos + version_key.len()..]; - let q1 = after_v.find('"').expect("malformed version"); - let after_q1 = &after_v[q1 + 1..]; - let q2 = after_q1.find('"').expect("malformed version"); - after_q1[..q2].to_string() -} - -/// Fetch the live `SHA256SUMS.txt` for the given version from GitHub Releases -/// and pluck out the entry for `asset_name`. -fn fetch_live_sha256(version: &str, asset_name: &str) -> String { - let base_url = format!("https://github.com/github/copilot-cli/releases/download/v{version}"); - let checksums_url = format!("{base_url}/SHA256SUMS.txt"); - let checksums = download_with_retry(&checksums_url); - let checksums_text = - std::str::from_utf8(&checksums).expect("checksums file is not valid UTF-8"); - find_sha256_for_asset(checksums_text, asset_name) -} - -#[derive(Clone, Copy)] -struct Platform { - asset_name: &'static str, - binary_name: &'static str, -} - -fn target_platform() -> Option { - let os = std::env::var("CARGO_CFG_TARGET_OS").ok()?; - let arch = std::env::var("CARGO_CFG_TARGET_ARCH").ok()?; - - match (os.as_str(), arch.as_str()) { - ("macos", "aarch64") => Some(Platform { - asset_name: "copilot-darwin-arm64.tar.gz", - binary_name: "copilot", - }), - ("macos", "x86_64") => Some(Platform { - asset_name: "copilot-darwin-x64.tar.gz", - binary_name: "copilot", - }), - ("linux", "x86_64") => Some(Platform { - asset_name: "copilot-linux-x64.tar.gz", - binary_name: "copilot", - }), - ("linux", "aarch64") => Some(Platform { - asset_name: "copilot-linux-arm64.tar.gz", - binary_name: "copilot", - }), - ("windows", "x86_64") => Some(Platform { - asset_name: "copilot-win32-x64.zip", - binary_name: "copilot.exe", - }), - ("windows", "aarch64") => Some(Platform { - asset_name: "copilot-win32-arm64.zip", - binary_name: "copilot.exe", - }), - _ => None, - } -} - -/// Write the single binary entry from `archive` to -/// `/` and return the resulting path. -/// Idempotent — returns the existing path if a previous build already -/// populated the target. -/// -/// Uses file-level staging + atomic rename so a concurrent reader during -/// a parallel `cargo build` race never observes a partially-written -/// binary. `fs::rename` for files is atomic on both Unix and Windows -/// (Windows uses `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING`); for -/// directories it is not, which is why we stage at file granularity. -fn extract_to_cache(archive: &[u8], install_dir: &Path, platform: Platform) -> PathBuf { - let final_path = install_dir.join(platform.binary_name); - - // Caller already gated on `final_path.is_file()`; this is a safety - // net for any future caller that forgets. - if final_path.is_file() { - return final_path; - } - - std::fs::create_dir_all(install_dir).unwrap_or_else(|e| { - panic!( - "failed to create install dir {}: {e}", - install_dir.display() - ) - }); - - let bytes = extract_binary_bytes(archive, platform); - - // Staging file is a sibling of the final binary so the rename stays - // on the same filesystem (cross-fs rename is not atomic). PID + nanos - // disambiguate concurrent builds racing on the same cache. - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - let staging_path = install_dir.join(format!( - ".{}.staging-{}-{nanos}", - platform.binary_name, - std::process::id(), - )); - - { - let mut f = std::fs::File::create(&staging_path).unwrap_or_else(|e| { - let _ = std::fs::remove_file(&staging_path); - panic!( - "failed to create staging file {}: {e}", - staging_path.display() - ); - }); - - if let Err(e) = f.write_all(&bytes) { - let _ = std::fs::remove_file(&staging_path); - panic!( - "failed to write staging file {}: {e}", - staging_path.display() - ); - } - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Err(e) = f.set_permissions(std::fs::Permissions::from_mode(0o755)) { - let _ = std::fs::remove_file(&staging_path); - panic!("failed to chmod {}: {e}", staging_path.display()); - } - } - - // Backdate the staged binary to the Unix epoch before it lands. We emit - // `cargo:rerun-if-changed` on `final_path` (see caller) so a *deleted* - // cache binary forces a re-extract — but cargo stamps the build-script - // `output` reference when the script is spawned, seconds before this - // freshly-downloaded binary is written. A current mtime would therefore - // be *newer* than that reference, so the next identical `cargo` - // invocation would see the watched file as "changed" and pointlessly - // rerun build.rs + recompile the crate + relink every downstream crate. - // Pinning to the epoch keeps the file unambiguously older than any real - // build reference; `rename` preserves mtime (same inode), so it lands - // already-backdated and a no-change rebuild stays a true no-op. The - // deleted-file recovery contract is untouched: a missing file can't be - // stat'd, so cargo still treats it as stale and reruns regardless. - // - // Best-effort: a filesystem that refuses the epoch (e.g. FAT's 1980 floor - // clamps it — still older than any real reference) or rejects the call - // just reverts to the pre-fix redundant-rebuild behaviour, never a broken - // build. - if let Err(e) = f.set_modified(std::time::SystemTime::UNIX_EPOCH) { - println!( - "cargo:warning=Could not backdate {} (a redundant rebuild may occur): {e}", - staging_path.display() - ); - } - } - - // Atomic file-replace on both Unix and Windows. If a concurrent build - // already produced the same file the rename overwrites it; the bytes - // are SHA-verified-identical so replacement is safe. - if let Err(e) = std::fs::rename(&staging_path, &final_path) { - let _ = std::fs::remove_file(&staging_path); - panic!( - "failed to rename {} -> {}: {e}", - staging_path.display(), - final_path.display() - ); - } - - // Surface where the binary landed so contributors can find it. Quiet - // on the hot path: the caller's `is_file()` short-circuit (and the - // safety net at the top of this function) means this only fires on a - // true cache miss. - println!( - "cargo:warning=Extracted Copilot CLI to {}", - final_path.display() - ); - - final_path -} - -/// Replace characters outside `[a-zA-Z0-9._-]` with `_` so the version -/// string is always safe to use as a path component. Kept in sync with -/// `embeddedcli::sanitize_version` and `resolve::sanitize_version` so all -/// three resolve to the same cache directory for any given version. -fn sanitize_version(version: &str) -> String { - version - .chars() - .map(|c| match c { - 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' => c, - _ => '_', - }) - .collect() -} - -/// Extract the single `binary_name` entry from the release archive. Reused -/// between embed mode's `verify_binary_present_in_archive` and the -/// `extract_to_cache` path used when `bundled-cli` is off. Panics if the -/// entry isn't found — callers have already invoked -/// `verify_binary_present_in_archive`. -fn extract_binary_bytes(archive: &[u8], platform: Platform) -> Vec { - if platform.asset_name.ends_with(".zip") { - let cursor = std::io::Cursor::new(archive); - let mut zip = zip::ZipArchive::new(cursor) - .unwrap_or_else(|e| panic!("failed to open zip archive: {e}")); - for i in 0..zip.len() { - let mut entry = zip - .by_index(i) - .unwrap_or_else(|e| panic!("failed to read zip entry {i}: {e}")); - let name = entry.name().to_string(); - if name == platform.binary_name || name.ends_with(&format!("/{}", platform.binary_name)) - { - let mut bytes = Vec::with_capacity(entry.size() as usize); - std::io::copy(&mut entry, &mut bytes) - .unwrap_or_else(|e| panic!("failed to read zip entry bytes: {e}")); - return bytes; - } - } - } else { - let gz = flate2::read::GzDecoder::new(archive); - let mut tar = tar::Archive::new(gz); - for entry in tar - .entries() - .unwrap_or_else(|e| panic!("failed to read tar entries: {e}")) - { - let mut entry = entry.unwrap_or_else(|e| panic!("failed to read tar entry: {e}")); - let path = entry - .path() - .unwrap_or_else(|e| panic!("failed to read tar entry path: {e}")); - let name = path.to_string_lossy().into_owned(); - if name == platform.binary_name || name.ends_with(&format!("/{}", platform.binary_name)) - { - let mut bytes = Vec::with_capacity(entry.size() as usize); - entry - .read_to_end(&mut bytes) - .unwrap_or_else(|e| panic!("failed to read tar entry bytes: {e}")); - return bytes; - } - } - } - panic!( - "binary `{}` not found in archive `{}`", - platform.binary_name, platform.asset_name - ); -} - -/// Read a file from the download cache, or download it (with retries) and save -/// to cache. Verifies SHA-256 on every path. Evicts stale/corrupt cache entries -/// automatically. Cache I/O failures are treated as cache misses — they never -/// break the build. -fn cached_download( - url: &str, - cache_key: &str, - expected_hash: &str, - cache_dir: &Option, -) -> Vec { - if let Some(dir) = cache_dir { - let cached_path = dir.join(cache_key); - if cached_path.is_file() { - match std::fs::read(&cached_path) { - Ok(data) if hex_sha256(&data) == expected_hash => { - // Silent cache hit — nothing to surface. - return data; - } - Ok(_) => { - println!("cargo:warning=Cached archive hash mismatch, re-downloading"); - let _ = std::fs::remove_file(&cached_path); - } - Err(e) => { - println!( - "cargo:warning=Failed to read cache {}, re-downloading: {e}", - cached_path.display() - ); - } - } - } - } - - println!("cargo:warning=Downloading {url}"); - let data = download_with_retry(url); - let actual_hash = hex_sha256(&data); - if actual_hash != expected_hash { - panic!( - "Archive integrity check failed for {url}!\n expected: {expected_hash}\n actual: {actual_hash}\n \ - This could indicate a corrupted download or a supply-chain attack." - ); - } - - if let Some(dir) = cache_dir { - if let Err(e) = std::fs::create_dir_all(dir) { - println!( - "cargo:warning=Failed to create cache directory {}: {e}", - dir.display() - ); - } else { - let cached_path = dir.join(cache_key); - println!("cargo:warning=Caching archive at {}", cached_path.display()); - if let Err(e) = std::fs::write(&cached_path, &data) { - println!( - "cargo:warning=Failed to write cache file {}: {e}", - cached_path.display() - ); - } - } - } - - data -} - -/// Maximum number of HTTP attempts (one initial + this many retries on transient errors). -const MAX_RETRIES: u32 = 3; - -/// Download `url` with bounded retries on transient network errors. Backoff is -/// exponential starting at 1s. 4xx responses fail fast; 5xx and connect/read -/// errors are retried. -fn download_with_retry(url: &str) -> Vec { - let mut attempt = 0u32; - loop { - attempt += 1; - match try_download(url) { - Ok(bytes) => return bytes, - Err(err) if err.transient && attempt <= MAX_RETRIES => { - let backoff = Duration::from_secs(1u64 << (attempt - 1)); - println!( - "cargo:warning=Transient download failure for {url} (attempt {attempt}/{}): {} — retrying in {}s", - MAX_RETRIES + 1, - err.message, - backoff.as_secs(), - ); - std::thread::sleep(backoff); - } - Err(err) => panic!("Failed to download {url}: {}", err.message), - } - } -} - -struct DownloadError { - message: String, - transient: bool, -} - -fn try_download(url: &str) -> Result, DownloadError> { - let agent = ureq::AgentBuilder::new() - .timeout_connect(Duration::from_secs(30)) - .timeout_read(Duration::from_secs(120)) - .build(); - - match agent.get(url).call() { - Ok(response) => { - let mut bytes = Vec::new(); - response - .into_reader() - .read_to_end(&mut bytes) - .map_err(|e| DownloadError { - message: format!("read error: {e}"), - transient: true, - })?; - Ok(bytes) - } - // 5xx — server-side, treat as transient. - Err(ureq::Error::Status(code, response)) if (500..600).contains(&code) => { - Err(DownloadError { - message: format!("HTTP {code} {}", response.status_text()), - transient: true, - }) - } - // 4xx — client-side, fail fast. - Err(ureq::Error::Status(code, response)) => Err(DownloadError { - message: format!("HTTP {code} {}", response.status_text()), - transient: false, - }), - // Transport-layer (DNS, connect, TLS, read timeout) — treat as transient. - Err(ureq::Error::Transport(t)) => Err(DownloadError { - message: format!("transport error: {t}"), - transient: true, - }), - } -} - -fn find_sha256_for_asset(sums: &str, asset_name: &str) -> String { - for line in sums.lines() { - // Format: " " (two spaces) - if let Some((hash, name)) = line.split_once(" ") - && name.trim() == asset_name - { - return hash.trim().to_string(); - } - } - panic!("SHA256SUMS.txt does not contain an entry for {asset_name}"); -} - -fn sha256(data: &[u8]) -> [u8; 32] { - let mut hasher = sha2::Sha256::new(); - hasher.update(data); - hasher.finalize().into() -} - -/// Walks the downloaded archive at build time to confirm an entry matching -/// `binary_name` exists. Panics with a clear message if not — defends against -/// silent breakage if the upstream archive layout ever changes. -fn verify_binary_present_in_archive(archive: &[u8], binary_name: &str, asset_name: &str) { - let found = if asset_name.ends_with(".zip") { - archive_contains_zip_entry(archive, binary_name) - } else { - archive_contains_tar_entry(archive, binary_name) - }; - if !found { - panic!( - "Copilot CLI archive `{asset_name}` does not contain an entry named `{binary_name}`. \ - The upstream archive layout may have changed; runtime extraction would fail. \ - Update `verify_binary_present_in_archive` in build.rs and the matching `extract_binary` in src/embeddedcli.rs." - ); - } -} - -fn archive_contains_tar_entry(targz: &[u8], binary_name: &str) -> bool { - let gz = flate2::read::GzDecoder::new(targz); - let mut archive = tar::Archive::new(gz); - let Ok(entries) = archive.entries() else { - return false; - }; - for entry in entries.flatten() { - let Ok(path) = entry.path() else { - continue; - }; - let name = path.to_string_lossy(); - if name == binary_name || name.ends_with(&format!("/{binary_name}")) { - return true; - } - } - false -} - -fn archive_contains_zip_entry(zip_bytes: &[u8], binary_name: &str) -> bool { - let cursor = std::io::Cursor::new(zip_bytes); - let Ok(mut archive) = zip::ZipArchive::new(cursor) else { - return false; - }; - for i in 0..archive.len() { - let Ok(entry) = archive.by_index(i) else { - continue; - }; - let name = entry.name(); - if name == binary_name || name.ends_with(&format!("/{binary_name}")) { - return true; - } - } - false -} - -fn hex_sha256(data: &[u8]) -> String { - sha256(data).iter().map(|b| format!("{b:02x}")).collect() + implementation::main(); } diff --git a/rust/build/in_process.rs b/rust/build/in_process.rs new file mode 100644 index 0000000000..5826fbfa76 --- /dev/null +++ b/rust/build/in_process.rs @@ -0,0 +1,726 @@ +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use base64::Engine; +use sha2::Digest; + +pub(crate) fn main() { + println!("cargo:rerun-if-env-changed=DOCS_RS"); + println!("cargo:rerun-if-env-changed=COPILOT_SKIP_CLI_DOWNLOAD"); + println!("cargo:rerun-if-env-changed=COPILOT_CLI_EXTRACT_DIR"); + println!("cargo:rerun-if-env-changed=BUNDLED_CLI_CACHE_DIR"); + println!("cargo::rustc-check-cfg=cfg(has_bundled_cli)"); + println!("cargo::rustc-check-cfg=cfg(has_extracted_cli)"); + println!("cargo:rerun-if-changed=cli-version-in-process.txt"); + + // Only declare the lockfile rerun when the lockfile actually exists. + // Cargo treats `rerun-if-changed` for a missing path as "always rerun" + // — so unconditionally declaring this on consumers without a sibling + // `nodejs/` (vendored slots, published crates) would force build.rs + // to re-run on every `cargo build` even when nothing has changed. + // The lockfile path is only the source-of-truth in this repo's + // contributor builds; everywhere else `cli-version-in-process.txt` is canonical. + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set"); + let lockfile = Path::new(&manifest_dir) + .join("..") + .join("nodejs") + .join("package-lock.json"); + if lockfile.is_file() { + println!("cargo:rerun-if-changed={}", lockfile.display()); + } + + // Hard opt-out: disable the entire download / bundle / cache mechanism + // in one step. For consumers who always supply the CLI via + // `CliProgram::Path` or `COPILOT_CLI_PATH` and don't want build.rs to + // touch the network (offline builds, locked-down CI, etc.). Works + // regardless of the `bundled-cli` cargo feature state — with neither + // `has_bundled_cli` nor `has_extracted_cli` emitted, runtime resolution + // falls straight through to `Error::BinaryNotFound` unless an explicit + // path source resolves first. + if std::env::var_os("COPILOT_SKIP_CLI_DOWNLOAD").is_some() { + println!( + "cargo:warning=COPILOT_SKIP_CLI_DOWNLOAD is set — skipping CLI download/bundle/cache" + ); + return; + } + + // docs.rs builds in a sandboxed environment without network access. + // Skip the CLI download so documentation can be generated successfully. + if std::env::var_os("DOCS_RS").is_some() { + println!("cargo:warning=DOCS_RS is set — skipping CLI download/bundle/cache"); + return; + } + + let Some(platform) = target_platform() else { + println!("cargo:warning=Unsupported target platform for Copilot CLI bundling — skipping"); + return; + }; + + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is always set by cargo"); + let out = Path::new(&out_dir); + + // Resolve version + npm integrity from one of two sources, in order: + // 1. `cli-version-in-process.txt` snapshot at the crate root (published-crate + // consumer; generated by the publish workflow). Combined format: + // `version=X` line + per-package integrity lines. Committing these + // makes the publish workflow the trust boundary — an attacker who + // later re-points the release tag can't silently poison consumer + // builds. + // 2. Sibling `../nodejs/package-lock.json` (contributor build inside + // the github/copilot-sdk repo), whose platform-package integrity is + // the same trust source npm uses. + let (version, expected_integrity) = resolve_version_and_integrity(platform.package_name); + + // Bake the version into the crate regardless of mode. This is the + // single source of truth for "what CLI version did build.rs target", + // consumed by both the embed-mode path computation in embeddedcli.rs + // and the runtime path computation in resolve.rs (when `bundled-cli` + // is off). It's a small, machine-independent datum: no absolute + // paths, no username/home leakage, so sccache / cross-machine + // `target/` reuse stays cache-coherent. + println!("cargo:rustc-env=COPILOT_SDK_CLI_VERSION={version}"); + + let archive_name = format!("{}-{version}.tgz", platform.package_name); + let download_url = format!( + "https://registry.npmjs.org/@github/{}/-/{}", + platform.package_name, archive_name + ); + let cache_dir = std::env::var("BUNDLED_CLI_CACHE_DIR") + .ok() + .map(std::path::PathBuf::from); + + let cache_key = format!("v{version}-{archive_name}"); + let include_runtime = std::env::var_os("CARGO_FEATURE_BUNDLED_IN_PROCESS").is_some(); + + if std::env::var_os("CARGO_FEATURE_BUNDLED_CLI").is_some() { + let archive = cached_download(&download_url, &cache_key, &expected_integrity, &cache_dir); + verify_binary_present_in_archive(&archive, platform.binary_name, &archive_name); + emit_embedded(out, &archive, platform, include_runtime); + println!("cargo:rustc-cfg=has_bundled_cli"); + } else { + // With `bundled-cli` off the extracted binary *is* the cache. + // Skip the upstream download entirely when it already exists at + // the expected path. No two separate caches. + // + // Runtime resolution (see `src/resolve.rs::extracted_cli_path`) + // recomputes this same path from `COPILOT_SDK_CLI_VERSION` + the + // OS-derived binary name + optional `COPILOT_CLI_EXTRACT_DIR`, + // so we don't bake an absolute path into the crate. + let install_dir = extracted_install_dir(&version); + let final_path = install_dir.join(platform.binary_name); + + // Invalidate build.rs whenever the cached binary disappears (cache GC, + // manual rm, OS reset, switching extract dir). Without this, cargo + // replays the saved `has_extracted_cli` cfg from its build-script + // output cache even when the file is gone, and runtime resolution + // fails with BinaryNotFound. + println!("cargo:rerun-if-changed={}", final_path.display()); + + if !final_path.is_file() { + let archive = + cached_download(&download_url, &cache_key, &expected_integrity, &cache_dir); + verify_binary_present_in_archive(&archive, platform.binary_name, &archive_name); + extract_to_cache(&archive, &install_dir, platform); + } + + // Re-check after potential download+extract above; not an `else` + // because we need to verify the extraction actually produced the file. + if final_path.is_file() { + println!("cargo:rustc-cfg=has_extracted_cli"); + } + } +} + +/// Install directory used when `bundled-cli` is off. Mirrors the runtime +/// convention in `src/resolve.rs::extracted_cli_path`: both sides MUST +/// compute the same path from the same inputs, otherwise the runtime +/// resolver won't find what build.rs extracted. +/// +/// If `COPILOT_CLI_EXTRACT_DIR` is set the binary lives directly under +/// that directory (no per-version subdir) — useful for vendored slots and +/// for `.cargo/config.toml [env]`-style pinning that's symmetric between +/// build-time write and runtime read. Otherwise the binary lives under +/// `/github-copilot-sdk/cli//`. +fn extracted_install_dir(version: &str) -> PathBuf { + if let Some(custom) = std::env::var_os("COPILOT_CLI_EXTRACT_DIR") { + PathBuf::from(custom) + } else { + let cache = dirs::cache_dir().unwrap_or_else(std::env::temp_dir); + cache + .join("github-copilot-sdk") + .join("cli") + .join(sanitize_version(version)) + } +} + +/// Emit the `bundled_cli.rs` glue + `copilot_cli.archive` blob into `OUT_DIR` +/// for embed mode (`bundled-cli` cargo feature on). The version is exposed +/// crate-wide via the unconditional `cargo:rustc-env=COPILOT_SDK_CLI_VERSION` +/// emit; the binary name is OS-derived at runtime — so all we need to +/// generate here is the archive blob include. +fn emit_embedded(out: &Path, package: &[u8], platform: Platform, include_runtime: bool) { + let archive = build_embedded_archive(package, platform, include_runtime); + std::fs::write(out.join("copilot_cli.archive"), archive) + .expect("failed to write copilot_cli.archive"); + + let generated = r#"// Auto-generated by github-copilot-sdk build.rs. Do not edit. +pub(super) static CLI_ARCHIVE: &[u8] = include_bytes!("copilot_cli.archive"); +"#; + + std::fs::write(out.join("bundled_cli.rs"), generated).expect("failed to write bundled_cli.rs"); +} + +fn build_embedded_archive(package: &[u8], platform: Platform, include_runtime: bool) -> Vec { + let encoder = flate2::GzBuilder::new() + .mtime(0) + .write(Vec::new(), flate2::Compression::default()); + let mut archive = tar::Builder::new(encoder); + append_archive_file( + &mut archive, + platform.binary_name, + &extract_binary_bytes(package, platform), + 0o755, + ); + if include_runtime { + let runtime = extract_runtime_library_bytes(package).unwrap_or_else(|| { + panic!( + "package `{}` does not contain the native runtime library required by the `bundled-in-process` feature", + platform.package_name + ) + }); + append_archive_file( + &mut archive, + platform.runtime_library_name(), + &runtime, + 0o644, + ); + } + let encoder = archive + .into_inner() + .expect("failed to finish minimal embedded CLI archive"); + encoder + .finish() + .expect("failed to compress minimal embedded CLI archive") +} + +fn append_archive_file( + archive: &mut tar::Builder, + path: &str, + bytes: &[u8], + mode: u32, +) { + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(mode); + header.set_uid(0); + header.set_gid(0); + header.set_mtime(0); + header.set_cksum(); + archive + .append_data(&mut header, path, bytes) + .unwrap_or_else(|e| panic!("failed to add `{path}` to embedded CLI archive: {e}")); +} + +/// Resolve the CLI version and npm integrity for the current target's +/// platform package. Picks one of two sources in order. Panics with a clear +/// error if neither is available. +fn resolve_version_and_integrity(package_name: &str) -> (String, String) { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set"); + + // 1. Snapshot file at the crate root (published-crate consumer, + // vendored-slot consumer). Combined version + per-asset hashes. + let snapshot = Path::new(&manifest_dir).join("cli-version-in-process.txt"); + if snapshot.is_file() { + let contents = std::fs::read_to_string(&snapshot) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", snapshot.display())); + return parse_snapshot(&contents, package_name) + .unwrap_or_else(|e| panic!("invalid {}: {e}", snapshot.display())); + } + + // 2. Lockfile fallback (contributor build inside github/copilot-sdk). + let lockfile = Path::new(&manifest_dir) + .join("..") + .join("nodejs") + .join("package-lock.json"); + if lockfile.is_file() { + return read_version_and_integrity_from_package_lock(&lockfile, package_name); + } + + panic!( + "Could not resolve the Copilot CLI version.\n\ + Tried:\n\ + - {} (missing)\n\ + - {} (missing)\n\ + In a published crate or vendored slot, `cli-version-in-process.txt` should be present.\n\ + Inside the github/copilot-sdk repo, `../nodejs/package-lock.json` is the source.", + snapshot.display(), + lockfile.display(), + ); +} + +/// Parse the `cli-version-in-process.txt` snapshot file. Format is one `key=value` per +/// line. The first non-comment line is `version=X.Y.Z`; subsequent lines map +/// platform package name to npm integrity. Blank lines and lines starting with `#` +/// are skipped. +fn parse_snapshot(contents: &str, package_name: &str) -> Result<(String, String), String> { + let mut version: Option = None; + let mut integrity: Option = None; + for (line_no, raw) in contents.lines().enumerate() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + return Err(format!( + "line {}: expected `key=value`, got `{raw}`", + line_no + 1 + )); + }; + match key.trim() { + "version" => version = Some(value.trim().to_string()), + k if k == package_name => integrity = Some(value.trim().to_string()), + _ => {} + } + } + let version = version.ok_or("missing `version=` line")?; + let integrity = + integrity.ok_or_else(|| format!("missing integrity for package `{package_name}`"))?; + Ok((version, integrity)) +} + +fn read_version_and_integrity_from_package_lock( + path: &Path, + package_name: &str, +) -> (String, String) { + let contents = std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); + let lock: serde_json::Value = serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("failed to parse {}: {e}", path.display())); + let cli_key = "node_modules/@github/copilot"; + let version = lock["packages"][cli_key]["version"] + .as_str() + .unwrap_or_else(|| panic!("{cli_key} has no version in {}", path.display())); + let platform_key = format!("node_modules/@github/{package_name}"); + let integrity = lock["packages"][&platform_key]["integrity"] + .as_str() + .unwrap_or_else(|| panic!("{platform_key} has no integrity in {}", path.display())); + (version.to_string(), integrity.to_string()) +} + +#[derive(Clone, Copy)] +struct Platform { + package_name: &'static str, + binary_name: &'static str, +} + +impl Platform { + fn runtime_library_name(&self) -> &'static str { + if self.package_name.contains("win32") { + "copilot_runtime.dll" + } else if self.package_name.contains("darwin") { + "libcopilot_runtime.dylib" + } else { + "libcopilot_runtime.so" + } + } +} + +fn target_platform() -> Option { + let os = std::env::var("CARGO_CFG_TARGET_OS").ok()?; + let arch = std::env::var("CARGO_CFG_TARGET_ARCH").ok()?; + let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + + match (os.as_str(), arch.as_str(), target_env.as_str()) { + ("macos", "aarch64", _) => Some(Platform { + package_name: "copilot-darwin-arm64", + binary_name: "copilot", + }), + ("macos", "x86_64", _) => Some(Platform { + package_name: "copilot-darwin-x64", + binary_name: "copilot", + }), + ("linux", "x86_64", "musl") => Some(Platform { + package_name: "copilot-linuxmusl-x64", + binary_name: "copilot", + }), + ("linux", "aarch64", "musl") => Some(Platform { + package_name: "copilot-linuxmusl-arm64", + binary_name: "copilot", + }), + ("linux", "x86_64", _) => Some(Platform { + package_name: "copilot-linux-x64", + binary_name: "copilot", + }), + ("linux", "aarch64", _) => Some(Platform { + package_name: "copilot-linux-arm64", + binary_name: "copilot", + }), + ("windows", "x86_64", _) => Some(Platform { + package_name: "copilot-win32-x64", + binary_name: "copilot.exe", + }), + ("windows", "aarch64", _) => Some(Platform { + package_name: "copilot-win32-arm64", + binary_name: "copilot.exe", + }), + _ => None, + } +} + +/// Write the single binary entry from `archive` to +/// `/` and return the resulting path. +/// Idempotent — returns the existing path if a previous build already +/// populated the target. +/// +/// Uses file-level staging + atomic rename so a concurrent reader during +/// a parallel `cargo build` race never observes a partially-written +/// binary. `fs::rename` for files is atomic on both Unix and Windows +/// (Windows uses `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING`); for +/// directories it is not, which is why we stage at file granularity. +fn extract_to_cache(archive: &[u8], install_dir: &Path, platform: Platform) -> PathBuf { + let final_path = install_dir.join(platform.binary_name); + + // Caller already gated on `final_path.is_file()`; this is a safety + // net for any future caller that forgets. + if final_path.is_file() { + return final_path; + } + + std::fs::create_dir_all(install_dir).unwrap_or_else(|e| { + panic!( + "failed to create install dir {}: {e}", + install_dir.display() + ) + }); + + let bytes = extract_binary_bytes(archive, platform); + + // Staging file is a sibling of the final binary so the rename stays + // on the same filesystem (cross-fs rename is not atomic). PID + nanos + // disambiguate concurrent builds racing on the same cache. + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let staging_path = install_dir.join(format!( + ".{}.staging-{}-{nanos}", + platform.binary_name, + std::process::id(), + )); + + { + let mut f = std::fs::File::create(&staging_path).unwrap_or_else(|e| { + let _ = std::fs::remove_file(&staging_path); + panic!( + "failed to create staging file {}: {e}", + staging_path.display() + ); + }); + + if let Err(e) = f.write_all(&bytes) { + let _ = std::fs::remove_file(&staging_path); + panic!( + "failed to write staging file {}: {e}", + staging_path.display() + ); + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Err(e) = f.set_permissions(std::fs::Permissions::from_mode(0o755)) { + let _ = std::fs::remove_file(&staging_path); + panic!("failed to chmod {}: {e}", staging_path.display()); + } + } + + // Backdate the staged binary to the Unix epoch before it lands. We emit + // `cargo:rerun-if-changed` on `final_path` (see caller) so a *deleted* + // cache binary forces a re-extract — but cargo stamps the build-script + // `output` reference when the script is spawned, seconds before this + // freshly-downloaded binary is written. A current mtime would therefore + // be *newer* than that reference, so the next identical `cargo` + // invocation would see the watched file as "changed" and pointlessly + // rerun build.rs + recompile the crate + relink every downstream crate. + // Pinning to the epoch keeps the file unambiguously older than any real + // build reference; `rename` preserves mtime (same inode), so it lands + // already-backdated and a no-change rebuild stays a true no-op. The + // deleted-file recovery contract is untouched: a missing file can't be + // stat'd, so cargo still treats it as stale and reruns regardless. + // + // Best-effort: a filesystem that refuses the epoch (e.g. FAT's 1980 floor + // clamps it — still older than any real reference) or rejects the call + // just reverts to the pre-fix redundant-rebuild behaviour, never a broken + // build. + if let Err(e) = f.set_modified(std::time::SystemTime::UNIX_EPOCH) { + println!( + "cargo:warning=Could not backdate {} (a redundant rebuild may occur): {e}", + staging_path.display() + ); + } + } + + // Atomic file-replace on both Unix and Windows. If a concurrent build + // already produced the same file the rename overwrites it; the bytes + // are integrity-verified-identical so replacement is safe. + if let Err(e) = std::fs::rename(&staging_path, &final_path) { + let _ = std::fs::remove_file(&staging_path); + panic!( + "failed to rename {} -> {}: {e}", + staging_path.display(), + final_path.display() + ); + } + + // Surface where the binary landed so contributors can find it. Quiet + // on the hot path: the caller's `is_file()` short-circuit (and the + // safety net at the top of this function) means this only fires on a + // true cache miss. + println!( + "cargo:warning=Extracted Copilot CLI to {}", + final_path.display() + ); + + final_path +} + +fn extract_runtime_library_bytes(archive: &[u8]) -> Option> { + let gz = flate2::read::GzDecoder::new(archive); + let mut tar = tar::Archive::new(gz); + for entry in tar.entries().ok()? { + let mut entry = entry.ok()?; + let name = entry.path().ok()?.to_string_lossy().into_owned(); + if name == "runtime.node" || name.ends_with("/runtime.node") { + let mut bytes = Vec::with_capacity(entry.size() as usize); + entry.read_to_end(&mut bytes).ok()?; + return Some(bytes); + } + } + None +} + +/// Replace characters outside `[a-zA-Z0-9._-]` with `_` so the version +/// string is always safe to use as a path component. Kept in sync with +/// `embeddedcli::sanitize_version` and `resolve::sanitize_version` so all +/// three resolve to the same cache directory for any given version. +fn sanitize_version(version: &str) -> String { + version + .chars() + .map(|c| match c { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' => c, + _ => '_', + }) + .collect() +} + +/// Extract the single `binary_name` entry from the npm package archive. Reused +/// between embed mode's `verify_binary_present_in_archive` and the +/// `extract_to_cache` path used when `bundled-cli` is off. Panics if the +/// entry isn't found — callers have already invoked +/// `verify_binary_present_in_archive`. +fn extract_binary_bytes(archive: &[u8], platform: Platform) -> Vec { + let gz = flate2::read::GzDecoder::new(archive); + let mut tar = tar::Archive::new(gz); + for entry in tar + .entries() + .unwrap_or_else(|e| panic!("failed to read tar entries: {e}")) + { + let mut entry = entry.unwrap_or_else(|e| panic!("failed to read tar entry: {e}")); + let path = entry + .path() + .unwrap_or_else(|e| panic!("failed to read tar entry path: {e}")); + let name = path.to_string_lossy().into_owned(); + if name == platform.binary_name || name.ends_with(&format!("/{}", platform.binary_name)) { + let mut bytes = Vec::with_capacity(entry.size() as usize); + entry + .read_to_end(&mut bytes) + .unwrap_or_else(|e| panic!("failed to read tar entry bytes: {e}")); + return bytes; + } + } + panic!( + "binary `{}` not found in package `{}`", + platform.binary_name, platform.package_name + ); +} + +/// Read a file from the download cache, or download it (with retries) and save +/// to cache. Verifies npm integrity on every path. Evicts stale/corrupt cache entries +/// automatically. Cache I/O failures are treated as cache misses — they never +/// break the build. +fn cached_download( + url: &str, + cache_key: &str, + expected_integrity: &str, + cache_dir: &Option, +) -> Vec { + if let Some(dir) = cache_dir { + let cached_path = dir.join(cache_key); + if cached_path.is_file() { + match std::fs::read(&cached_path) { + Ok(data) if verify_integrity(&data, expected_integrity) => { + // Silent cache hit — nothing to surface. + return data; + } + Ok(_) => { + println!("cargo:warning=Cached archive hash mismatch, re-downloading"); + let _ = std::fs::remove_file(&cached_path); + } + Err(e) => { + println!( + "cargo:warning=Failed to read cache {}, re-downloading: {e}", + cached_path.display() + ); + } + } + } + } + + println!("cargo:warning=Downloading {url}"); + let data = download_with_retry(url); + if !verify_integrity(&data, expected_integrity) { + panic!( + "Archive integrity check failed for {url}!\n expected: {expected_integrity}\n \ + This could indicate a corrupted download or a supply-chain attack." + ); + } + + if let Some(dir) = cache_dir { + if let Err(e) = std::fs::create_dir_all(dir) { + println!( + "cargo:warning=Failed to create cache directory {}: {e}", + dir.display() + ); + } else { + let cached_path = dir.join(cache_key); + println!("cargo:warning=Caching archive at {}", cached_path.display()); + if let Err(e) = std::fs::write(&cached_path, &data) { + println!( + "cargo:warning=Failed to write cache file {}: {e}", + cached_path.display() + ); + } + } + } + + data +} + +/// Maximum number of HTTP attempts (one initial + this many retries on transient errors). +const MAX_RETRIES: u32 = 3; + +/// Download `url` with bounded retries on transient network errors. Backoff is +/// exponential starting at 1s. 4xx responses fail fast; 5xx and connect/read +/// errors are retried. +fn download_with_retry(url: &str) -> Vec { + let mut attempt = 0u32; + loop { + attempt += 1; + match try_download(url) { + Ok(bytes) => return bytes, + Err(err) if err.transient && attempt <= MAX_RETRIES => { + let backoff = Duration::from_secs(1u64 << (attempt - 1)); + println!( + "cargo:warning=Transient download failure for {url} (attempt {attempt}/{}): {} — retrying in {}s", + MAX_RETRIES + 1, + err.message, + backoff.as_secs(), + ); + std::thread::sleep(backoff); + } + Err(err) => panic!("Failed to download {url}: {}", err.message), + } + } +} + +struct DownloadError { + message: String, + transient: bool, +} + +fn try_download(url: &str) -> Result, DownloadError> { + let connector = native_tls::TlsConnector::new().map_err(|e| DownloadError { + message: format!("native-tls init error: {e}"), + transient: false, + })?; + let agent = ureq::AgentBuilder::new() + .tls_connector(std::sync::Arc::new(connector)) + .timeout_connect(Duration::from_secs(30)) + .timeout_read(Duration::from_secs(120)) + .build(); + + match agent.get(url).call() { + Ok(response) => { + let mut bytes = Vec::new(); + response + .into_reader() + .read_to_end(&mut bytes) + .map_err(|e| DownloadError { + message: format!("read error: {e}"), + transient: true, + })?; + Ok(bytes) + } + // 5xx — server-side, treat as transient. + Err(ureq::Error::Status(code, response)) if (500..600).contains(&code) => { + Err(DownloadError { + message: format!("HTTP {code} {}", response.status_text()), + transient: true, + }) + } + // 4xx — client-side, fail fast. + Err(ureq::Error::Status(code, response)) => Err(DownloadError { + message: format!("HTTP {code} {}", response.status_text()), + transient: false, + }), + // Transport-layer (DNS, connect, TLS, read timeout) — treat as transient. + Err(ureq::Error::Transport(t)) => Err(DownloadError { + message: format!("transport error: {t}"), + transient: true, + }), + } +} + +/// Walks the downloaded archive at build time to confirm an entry matching +/// `binary_name` exists. Panics with a clear message if not. +fn verify_binary_present_in_archive(archive: &[u8], binary_name: &str, package_name: &str) { + let found = archive_contains_tar_entry(archive, binary_name); + if !found { + panic!( + "Copilot CLI package `{package_name}` does not contain an entry named `{binary_name}`. \ + The package layout may have changed; runtime extraction would fail. \ + Update `verify_binary_present_in_archive` in build.rs and the matching `extract_binary` in src/embeddedcli.rs." + ); + } +} + +fn archive_contains_tar_entry(targz: &[u8], binary_name: &str) -> bool { + let gz = flate2::read::GzDecoder::new(targz); + let mut archive = tar::Archive::new(gz); + let Ok(entries) = archive.entries() else { + return false; + }; + for entry in entries.flatten() { + let Ok(path) = entry.path() else { + continue; + }; + let name = path.to_string_lossy(); + if name == binary_name || name.ends_with(&format!("/{binary_name}")) { + return true; + } + } + false +} + +fn verify_integrity(data: &[u8], integrity: &str) -> bool { + let Some(encoded) = integrity.strip_prefix("sha512-") else { + return false; + }; + let Ok(expected) = base64::engine::general_purpose::STANDARD.decode(encoded) else { + return false; + }; + let mut hasher = sha2::Sha512::new(); + hasher.update(data); + hasher.finalize().as_slice() == expected +} diff --git a/rust/build/out_of_process.rs b/rust/build/out_of_process.rs new file mode 100644 index 0000000000..b8cd3acc3f --- /dev/null +++ b/rust/build/out_of_process.rs @@ -0,0 +1,717 @@ +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use sha2::Digest; + +pub(crate) fn main() { + println!("cargo:rerun-if-env-changed=DOCS_RS"); + println!("cargo:rerun-if-env-changed=COPILOT_SKIP_CLI_DOWNLOAD"); + println!("cargo:rerun-if-env-changed=COPILOT_CLI_EXTRACT_DIR"); + println!("cargo:rerun-if-env-changed=BUNDLED_CLI_CACHE_DIR"); + println!("cargo::rustc-check-cfg=cfg(has_bundled_cli)"); + println!("cargo::rustc-check-cfg=cfg(has_extracted_cli)"); + println!("cargo:rerun-if-changed=cli-version.txt"); + + // Only declare the lockfile rerun when the lockfile actually exists. + // Cargo treats `rerun-if-changed` for a missing path as "always rerun" + // — so unconditionally declaring this on consumers without a sibling + // `nodejs/` (vendored slots, published crates) would force build.rs + // to re-run on every `cargo build` even when nothing has changed. + // The lockfile path is only the source-of-truth in this repo's + // contributor builds; everywhere else `cli-version.txt` is canonical. + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set"); + let lockfile = Path::new(&manifest_dir) + .join("..") + .join("nodejs") + .join("package-lock.json"); + if lockfile.is_file() { + println!("cargo:rerun-if-changed={}", lockfile.display()); + } + + // Hard opt-out: disable the entire download / bundle / cache mechanism + // in one step. For consumers who always supply the CLI via + // `CliProgram::Path` or `COPILOT_CLI_PATH` and don't want build.rs to + // touch the network (offline builds, locked-down CI, etc.). Works + // regardless of the `bundled-cli` cargo feature state — with neither + // `has_bundled_cli` nor `has_extracted_cli` emitted, runtime resolution + // falls straight through to `Error::BinaryNotFound` unless an explicit + // path source resolves first. + if std::env::var_os("COPILOT_SKIP_CLI_DOWNLOAD").is_some() { + println!( + "cargo:warning=COPILOT_SKIP_CLI_DOWNLOAD is set — skipping CLI download/bundle/cache" + ); + return; + } + + // docs.rs builds in a sandboxed environment without network access. + // Skip the CLI download so documentation can be generated successfully. + if std::env::var_os("DOCS_RS").is_some() { + println!("cargo:warning=DOCS_RS is set — skipping CLI download/bundle/cache"); + return; + } + + let Some(platform) = target_platform() else { + println!("cargo:warning=Unsupported target platform for Copilot CLI bundling — skipping"); + return; + }; + + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is always set by cargo"); + let out = Path::new(&out_dir); + + // Resolve version + per-asset SHA-256 from one of two sources, in order: + // 1. `cli-version.txt` snapshot at the crate root (published-crate + // consumer; generated by the publish workflow). Combined format: + // `version=X` line + per-asset hash lines. Committing the hashes + // makes the publish workflow the trust boundary — an attacker who + // later re-points the release tag can't silently poison consumer + // builds. + // 2. Sibling `../nodejs/package-lock.json` (contributor build inside + // the github/copilot-sdk repo; live SHA256SUMS.txt fetch). Matches + // the .NET `_GetCopilotCliVersion` MSBuild target and the Go + // `cmd/bundler` tool. + let (version, expected_hash) = resolve_version_and_hash(platform.asset_name); + + // Bake the version into the crate regardless of mode. This is the + // single source of truth for "what CLI version did build.rs target", + // consumed by both the embed-mode path computation in embeddedcli.rs + // and the runtime path computation in resolve.rs (when `bundled-cli` + // is off). It's a small, machine-independent datum: no absolute + // paths, no username/home leakage, so sccache / cross-machine + // `target/` reuse stays cache-coherent. + println!("cargo:rustc-env=COPILOT_SDK_CLI_VERSION={version}"); + + let base_url = format!("https://github.com/github/copilot-cli/releases/download/v{version}"); + let cache_dir = std::env::var("BUNDLED_CLI_CACHE_DIR") + .ok() + .map(std::path::PathBuf::from); + + // Versioned cache key since copilot asset names don't include the version. + let cache_key = format!("v{version}-{}", platform.asset_name); + + if std::env::var_os("CARGO_FEATURE_BUNDLED_CLI").is_some() { + // Embed mode: we need the archive bytes to bake into the rlib, so + // always run the download (cache hit short-circuits inside + // `cached_download`). + let archive = cached_download( + &format!("{base_url}/{}", platform.asset_name), + &cache_key, + &expected_hash, + &cache_dir, + ); + verify_binary_present_in_archive(&archive, platform.binary_name, platform.asset_name); + emit_embedded(out, &archive); + println!("cargo:rustc-cfg=has_bundled_cli"); + } else { + // With `bundled-cli` off the extracted binary *is* the cache. + // Skip the upstream download entirely when it already exists at + // the expected path. No two separate caches. + // + // Runtime resolution (see `src/resolve.rs::extracted_cli_path`) + // recomputes this same path from `COPILOT_SDK_CLI_VERSION` + the + // OS-derived binary name + optional `COPILOT_CLI_EXTRACT_DIR`, + // so we don't bake an absolute path into the crate. + let install_dir = extracted_install_dir(&version); + let final_path = install_dir.join(platform.binary_name); + + // Invalidate build.rs whenever the cached binary disappears (cache GC, + // manual rm, OS reset, switching extract dir). Without this, cargo + // replays the saved `has_extracted_cli` cfg from its build-script + // output cache even when the file is gone, and runtime resolution + // fails with BinaryNotFound. + println!("cargo:rerun-if-changed={}", final_path.display()); + + if !final_path.is_file() { + let archive = cached_download( + &format!("{base_url}/{}", platform.asset_name), + &cache_key, + &expected_hash, + &cache_dir, + ); + verify_binary_present_in_archive(&archive, platform.binary_name, platform.asset_name); + extract_to_cache(&archive, &install_dir, platform); + } + + // Re-check after potential download+extract above; not an `else` + // because we need to verify the extraction actually produced the file. + if final_path.is_file() { + println!("cargo:rustc-cfg=has_extracted_cli"); + } + } +} + +/// Install directory used when `bundled-cli` is off. Mirrors the runtime +/// convention in `src/resolve.rs::extracted_cli_path`: both sides MUST +/// compute the same path from the same inputs, otherwise the runtime +/// resolver won't find what build.rs extracted. +/// +/// If `COPILOT_CLI_EXTRACT_DIR` is set the binary lives directly under +/// that directory (no per-version subdir) — useful for vendored slots and +/// for `.cargo/config.toml [env]`-style pinning that's symmetric between +/// build-time write and runtime read. Otherwise the binary lives under +/// `/github-copilot-sdk/cli//`. +fn extracted_install_dir(version: &str) -> PathBuf { + if let Some(custom) = std::env::var_os("COPILOT_CLI_EXTRACT_DIR") { + PathBuf::from(custom) + } else { + let cache = dirs::cache_dir().unwrap_or_else(std::env::temp_dir); + cache + .join("github-copilot-sdk") + .join("cli") + .join(sanitize_version(version)) + } +} + +/// Emit the `bundled_cli.rs` glue + `copilot_cli.archive` blob into `OUT_DIR` +/// for embed mode (`bundled-cli` cargo feature on). The version is exposed +/// crate-wide via the unconditional `cargo:rustc-env=COPILOT_SDK_CLI_VERSION` +/// emit; the binary name is OS-derived at runtime — so all we need to +/// generate here is the archive blob include. +fn emit_embedded(out: &Path, archive: &[u8]) { + std::fs::write(out.join("copilot_cli.archive"), archive) + .expect("failed to write copilot_cli.archive"); + + let generated = r#"// Auto-generated by github-copilot-sdk build.rs. Do not edit. +pub(super) static CLI_ARCHIVE: &[u8] = include_bytes!("copilot_cli.archive"); +"#; + + std::fs::write(out.join("bundled_cli.rs"), generated).expect("failed to write bundled_cli.rs"); +} + +/// Resolve the CLI version and the expected SHA-256 hash for the current +/// target's archive. Picks one of two sources in order. Panics with a clear +/// error if neither is available. +fn resolve_version_and_hash(asset_name: &str) -> (String, String) { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set"); + + // 1. Snapshot file at the crate root (published-crate consumer, + // vendored-slot consumer). Combined version + per-asset hashes. + let snapshot = Path::new(&manifest_dir).join("cli-version.txt"); + if snapshot.is_file() { + let contents = std::fs::read_to_string(&snapshot) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", snapshot.display())); + return parse_snapshot(&contents, asset_name) + .unwrap_or_else(|e| panic!("invalid {}: {e}", snapshot.display())); + } + + // 2. Lockfile fallback (contributor build inside github/copilot-sdk) — + // read version, fetch live SHA256SUMS. + let lockfile = Path::new(&manifest_dir) + .join("..") + .join("nodejs") + .join("package-lock.json"); + if lockfile.is_file() { + let version = read_version_from_package_lock(&lockfile); + let hash = fetch_live_sha256(&version, asset_name); + return (version, hash); + } + + panic!( + "Could not resolve the Copilot CLI version.\n\ + Tried:\n\ + - {} (missing)\n\ + - {} (missing)\n\ + In a published crate or vendored slot, `cli-version.txt` should be present.\n\ + Inside the github/copilot-sdk repo, `../nodejs/package-lock.json` is the source.", + snapshot.display(), + lockfile.display(), + ); +} + +/// Parse the `cli-version.txt` snapshot file. Format is one `key=value` per +/// line. The first non-comment line is `version=X.Y.Z`; subsequent lines map +/// asset filename to hex SHA-256. Blank lines and lines starting with `#` +/// are skipped. +fn parse_snapshot(contents: &str, asset_name: &str) -> Result<(String, String), String> { + let mut version: Option = None; + let mut hash: Option = None; + for (line_no, raw) in contents.lines().enumerate() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + return Err(format!( + "line {}: expected `key=value`, got `{raw}`", + line_no + 1 + )); + }; + match key.trim() { + "version" => version = Some(value.trim().to_string()), + k if k == asset_name => hash = Some(value.trim().to_string()), + _ => {} + } + } + let version = version.ok_or("missing `version=` line")?; + let hash = hash.ok_or_else(|| format!("missing hash for asset `{asset_name}`"))?; + Ok((version, hash)) +} + +/// Read the `@github/copilot` version from `nodejs/package-lock.json`. +fn read_version_from_package_lock(path: &Path) -> String { + let contents = std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); + // Minimal JSON walk: find `"node_modules/@github/copilot"` object and + // its `"version"` field. Full JSON parsing keeps build.rs dep-light by + // using a regex; the file is generated by npm and we're matching an + // exact key path. + let key = "\"node_modules/@github/copilot\""; + let key_pos = contents + .find(key) + .unwrap_or_else(|| panic!("{} does not contain {key}", path.display())); + let after_key = &contents[key_pos + key.len()..]; + let version_key = "\"version\""; + let v_pos = after_key + .find(version_key) + .unwrap_or_else(|| panic!("no `version` field found near {key} in {}", path.display())); + let after_v = &after_key[v_pos + version_key.len()..]; + let q1 = after_v.find('"').expect("malformed version"); + let after_q1 = &after_v[q1 + 1..]; + let q2 = after_q1.find('"').expect("malformed version"); + after_q1[..q2].to_string() +} + +/// Fetch the live `SHA256SUMS.txt` for the given version from GitHub Releases +/// and pluck out the entry for `asset_name`. +fn fetch_live_sha256(version: &str, asset_name: &str) -> String { + let base_url = format!("https://github.com/github/copilot-cli/releases/download/v{version}"); + let checksums_url = format!("{base_url}/SHA256SUMS.txt"); + let checksums = download_with_retry(&checksums_url); + let checksums_text = + std::str::from_utf8(&checksums).expect("checksums file is not valid UTF-8"); + find_sha256_for_asset(checksums_text, asset_name) +} + +#[derive(Clone, Copy)] +struct Platform { + asset_name: &'static str, + binary_name: &'static str, +} + +fn target_platform() -> Option { + let os = std::env::var("CARGO_CFG_TARGET_OS").ok()?; + let arch = std::env::var("CARGO_CFG_TARGET_ARCH").ok()?; + + match (os.as_str(), arch.as_str()) { + ("macos", "aarch64") => Some(Platform { + asset_name: "copilot-darwin-arm64.tar.gz", + binary_name: "copilot", + }), + ("macos", "x86_64") => Some(Platform { + asset_name: "copilot-darwin-x64.tar.gz", + binary_name: "copilot", + }), + ("linux", "x86_64") => Some(Platform { + asset_name: "copilot-linux-x64.tar.gz", + binary_name: "copilot", + }), + ("linux", "aarch64") => Some(Platform { + asset_name: "copilot-linux-arm64.tar.gz", + binary_name: "copilot", + }), + ("windows", "x86_64") => Some(Platform { + asset_name: "copilot-win32-x64.zip", + binary_name: "copilot.exe", + }), + ("windows", "aarch64") => Some(Platform { + asset_name: "copilot-win32-arm64.zip", + binary_name: "copilot.exe", + }), + _ => None, + } +} + +/// Write the single binary entry from `archive` to +/// `/` and return the resulting path. +/// Idempotent — returns the existing path if a previous build already +/// populated the target. +/// +/// Uses file-level staging + atomic rename so a concurrent reader during +/// a parallel `cargo build` race never observes a partially-written +/// binary. `fs::rename` for files is atomic on both Unix and Windows +/// (Windows uses `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING`); for +/// directories it is not, which is why we stage at file granularity. +fn extract_to_cache(archive: &[u8], install_dir: &Path, platform: Platform) -> PathBuf { + let final_path = install_dir.join(platform.binary_name); + + // Caller already gated on `final_path.is_file()`; this is a safety + // net for any future caller that forgets. + if final_path.is_file() { + return final_path; + } + + std::fs::create_dir_all(install_dir).unwrap_or_else(|e| { + panic!( + "failed to create install dir {}: {e}", + install_dir.display() + ) + }); + + let bytes = extract_binary_bytes(archive, platform); + + // Staging file is a sibling of the final binary so the rename stays + // on the same filesystem (cross-fs rename is not atomic). PID + nanos + // disambiguate concurrent builds racing on the same cache. + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let staging_path = install_dir.join(format!( + ".{}.staging-{}-{nanos}", + platform.binary_name, + std::process::id(), + )); + + { + let mut f = std::fs::File::create(&staging_path).unwrap_or_else(|e| { + let _ = std::fs::remove_file(&staging_path); + panic!( + "failed to create staging file {}: {e}", + staging_path.display() + ); + }); + + if let Err(e) = f.write_all(&bytes) { + let _ = std::fs::remove_file(&staging_path); + panic!( + "failed to write staging file {}: {e}", + staging_path.display() + ); + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Err(e) = f.set_permissions(std::fs::Permissions::from_mode(0o755)) { + let _ = std::fs::remove_file(&staging_path); + panic!("failed to chmod {}: {e}", staging_path.display()); + } + } + + // Backdate the staged binary to the Unix epoch before it lands. We emit + // `cargo:rerun-if-changed` on `final_path` (see caller) so a *deleted* + // cache binary forces a re-extract — but cargo stamps the build-script + // `output` reference when the script is spawned, seconds before this + // freshly-downloaded binary is written. A current mtime would therefore + // be *newer* than that reference, so the next identical `cargo` + // invocation would see the watched file as "changed" and pointlessly + // rerun build.rs + recompile the crate + relink every downstream crate. + // Pinning to the epoch keeps the file unambiguously older than any real + // build reference; `rename` preserves mtime (same inode), so it lands + // already-backdated and a no-change rebuild stays a true no-op. The + // deleted-file recovery contract is untouched: a missing file can't be + // stat'd, so cargo still treats it as stale and reruns regardless. + // + // Best-effort: a filesystem that refuses the epoch (e.g. FAT's 1980 floor + // clamps it — still older than any real reference) or rejects the call + // just reverts to the pre-fix redundant-rebuild behaviour, never a broken + // build. + if let Err(e) = f.set_modified(std::time::SystemTime::UNIX_EPOCH) { + println!( + "cargo:warning=Could not backdate {} (a redundant rebuild may occur): {e}", + staging_path.display() + ); + } + } + + // Atomic file-replace on both Unix and Windows. If a concurrent build + // already produced the same file the rename overwrites it; the bytes + // are SHA-verified-identical so replacement is safe. + if let Err(e) = std::fs::rename(&staging_path, &final_path) { + let _ = std::fs::remove_file(&staging_path); + panic!( + "failed to rename {} -> {}: {e}", + staging_path.display(), + final_path.display() + ); + } + + // Surface where the binary landed so contributors can find it. Quiet + // on the hot path: the caller's `is_file()` short-circuit (and the + // safety net at the top of this function) means this only fires on a + // true cache miss. + println!( + "cargo:warning=Extracted Copilot CLI to {}", + final_path.display() + ); + + final_path +} + +/// Replace characters outside `[a-zA-Z0-9._-]` with `_` so the version +/// string is always safe to use as a path component. Kept in sync with +/// `embeddedcli::sanitize_version` and `resolve::sanitize_version` so all +/// three resolve to the same cache directory for any given version. +fn sanitize_version(version: &str) -> String { + version + .chars() + .map(|c| match c { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' => c, + _ => '_', + }) + .collect() +} + +/// Extract the single `binary_name` entry from the release archive. Reused +/// between embed mode's `verify_binary_present_in_archive` and the +/// `extract_to_cache` path used when `bundled-cli` is off. Panics if the +/// entry isn't found — callers have already invoked +/// `verify_binary_present_in_archive`. +fn extract_binary_bytes(archive: &[u8], platform: Platform) -> Vec { + if platform.asset_name.ends_with(".zip") { + let cursor = std::io::Cursor::new(archive); + let mut zip = zip::ZipArchive::new(cursor) + .unwrap_or_else(|e| panic!("failed to open zip archive: {e}")); + for i in 0..zip.len() { + let mut entry = zip + .by_index(i) + .unwrap_or_else(|e| panic!("failed to read zip entry {i}: {e}")); + let name = entry.name().to_string(); + if name == platform.binary_name || name.ends_with(&format!("/{}", platform.binary_name)) + { + let mut bytes = Vec::with_capacity(entry.size() as usize); + std::io::copy(&mut entry, &mut bytes) + .unwrap_or_else(|e| panic!("failed to read zip entry bytes: {e}")); + return bytes; + } + } + } else { + let gz = flate2::read::GzDecoder::new(archive); + let mut tar = tar::Archive::new(gz); + for entry in tar + .entries() + .unwrap_or_else(|e| panic!("failed to read tar entries: {e}")) + { + let mut entry = entry.unwrap_or_else(|e| panic!("failed to read tar entry: {e}")); + let path = entry + .path() + .unwrap_or_else(|e| panic!("failed to read tar entry path: {e}")); + let name = path.to_string_lossy().into_owned(); + if name == platform.binary_name || name.ends_with(&format!("/{}", platform.binary_name)) + { + let mut bytes = Vec::with_capacity(entry.size() as usize); + entry + .read_to_end(&mut bytes) + .unwrap_or_else(|e| panic!("failed to read tar entry bytes: {e}")); + return bytes; + } + } + } + panic!( + "binary `{}` not found in archive `{}`", + platform.binary_name, platform.asset_name + ); +} + +/// Read a file from the download cache, or download it (with retries) and save +/// to cache. Verifies SHA-256 on every path. Evicts stale/corrupt cache entries +/// automatically. Cache I/O failures are treated as cache misses — they never +/// break the build. +fn cached_download( + url: &str, + cache_key: &str, + expected_hash: &str, + cache_dir: &Option, +) -> Vec { + if let Some(dir) = cache_dir { + let cached_path = dir.join(cache_key); + if cached_path.is_file() { + match std::fs::read(&cached_path) { + Ok(data) if hex_sha256(&data) == expected_hash => { + // Silent cache hit — nothing to surface. + return data; + } + Ok(_) => { + println!("cargo:warning=Cached archive hash mismatch, re-downloading"); + let _ = std::fs::remove_file(&cached_path); + } + Err(e) => { + println!( + "cargo:warning=Failed to read cache {}, re-downloading: {e}", + cached_path.display() + ); + } + } + } + } + + println!("cargo:warning=Downloading {url}"); + let data = download_with_retry(url); + let actual_hash = hex_sha256(&data); + if actual_hash != expected_hash { + panic!( + "Archive integrity check failed for {url}!\n expected: {expected_hash}\n actual: {actual_hash}\n \ + This could indicate a corrupted download or a supply-chain attack." + ); + } + + if let Some(dir) = cache_dir { + if let Err(e) = std::fs::create_dir_all(dir) { + println!( + "cargo:warning=Failed to create cache directory {}: {e}", + dir.display() + ); + } else { + let cached_path = dir.join(cache_key); + println!("cargo:warning=Caching archive at {}", cached_path.display()); + if let Err(e) = std::fs::write(&cached_path, &data) { + println!( + "cargo:warning=Failed to write cache file {}: {e}", + cached_path.display() + ); + } + } + } + + data +} + +/// Maximum number of HTTP attempts (one initial + this many retries on transient errors). +const MAX_RETRIES: u32 = 3; + +/// Download `url` with bounded retries on transient network errors. Backoff is +/// exponential starting at 1s. 4xx responses fail fast; 5xx and connect/read +/// errors are retried. +fn download_with_retry(url: &str) -> Vec { + let mut attempt = 0u32; + loop { + attempt += 1; + match try_download(url) { + Ok(bytes) => return bytes, + Err(err) if err.transient && attempt <= MAX_RETRIES => { + let backoff = Duration::from_secs(1u64 << (attempt - 1)); + println!( + "cargo:warning=Transient download failure for {url} (attempt {attempt}/{}): {} — retrying in {}s", + MAX_RETRIES + 1, + err.message, + backoff.as_secs(), + ); + std::thread::sleep(backoff); + } + Err(err) => panic!("Failed to download {url}: {}", err.message), + } + } +} + +struct DownloadError { + message: String, + transient: bool, +} + +fn try_download(url: &str) -> Result, DownloadError> { + let connector = native_tls::TlsConnector::new().map_err(|e| DownloadError { + message: format!("native-tls init error: {e}"), + transient: false, + })?; + let agent = ureq::AgentBuilder::new() + .tls_connector(std::sync::Arc::new(connector)) + .timeout_connect(Duration::from_secs(30)) + .timeout_read(Duration::from_secs(120)) + .build(); + + match agent.get(url).call() { + Ok(response) => { + let mut bytes = Vec::new(); + response + .into_reader() + .read_to_end(&mut bytes) + .map_err(|e| DownloadError { + message: format!("read error: {e}"), + transient: true, + })?; + Ok(bytes) + } + // 5xx — server-side, treat as transient. + Err(ureq::Error::Status(code, response)) if (500..600).contains(&code) => { + Err(DownloadError { + message: format!("HTTP {code} {}", response.status_text()), + transient: true, + }) + } + // 4xx — client-side, fail fast. + Err(ureq::Error::Status(code, response)) => Err(DownloadError { + message: format!("HTTP {code} {}", response.status_text()), + transient: false, + }), + // Transport-layer (DNS, connect, TLS, read timeout) — treat as transient. + Err(ureq::Error::Transport(t)) => Err(DownloadError { + message: format!("transport error: {t}"), + transient: true, + }), + } +} + +fn find_sha256_for_asset(sums: &str, asset_name: &str) -> String { + for line in sums.lines() { + // Format: " " (two spaces) + if let Some((hash, name)) = line.split_once(" ") + && name.trim() == asset_name + { + return hash.trim().to_string(); + } + } + panic!("SHA256SUMS.txt does not contain an entry for {asset_name}"); +} + +fn sha256(data: &[u8]) -> [u8; 32] { + let mut hasher = sha2::Sha256::new(); + hasher.update(data); + hasher.finalize().into() +} + +/// Walks the downloaded archive at build time to confirm an entry matching +/// `binary_name` exists. Panics with a clear message if not — defends against +/// silent breakage if the upstream archive layout ever changes. +fn verify_binary_present_in_archive(archive: &[u8], binary_name: &str, asset_name: &str) { + let found = if asset_name.ends_with(".zip") { + archive_contains_zip_entry(archive, binary_name) + } else { + archive_contains_tar_entry(archive, binary_name) + }; + if !found { + panic!( + "Copilot CLI archive `{asset_name}` does not contain an entry named `{binary_name}`. \ + The upstream archive layout may have changed; runtime extraction would fail. \ + Update `verify_binary_present_in_archive` in build.rs and the matching `extract_binary` in src/embeddedcli.rs." + ); + } +} + +fn archive_contains_tar_entry(targz: &[u8], binary_name: &str) -> bool { + let gz = flate2::read::GzDecoder::new(targz); + let mut archive = tar::Archive::new(gz); + let Ok(entries) = archive.entries() else { + return false; + }; + for entry in entries.flatten() { + let Ok(path) = entry.path() else { + continue; + }; + let name = path.to_string_lossy(); + if name == binary_name || name.ends_with(&format!("/{binary_name}")) { + return true; + } + } + false +} + +fn archive_contains_zip_entry(zip_bytes: &[u8], binary_name: &str) -> bool { + let cursor = std::io::Cursor::new(zip_bytes); + let Ok(mut archive) = zip::ZipArchive::new(cursor) else { + return false; + }; + for i in 0..archive.len() { + let Ok(entry) = archive.by_index(i) else { + continue; + }; + let name = entry.name(); + if name == binary_name || name.ends_with(&format!("/{binary_name}")) { + return true; + } + } + false +} + +fn hex_sha256(data: &[u8]) -> String { + sha256(data).iter().map(|b| format!("{b:02x}")).collect() +} diff --git a/rust/scripts/snapshot-bundled-in-process-version.sh b/rust/scripts/snapshot-bundled-in-process-version.sh new file mode 100755 index 0000000000..8743f9d17a --- /dev/null +++ b/rust/scripts/snapshot-bundled-in-process-version.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +# Snapshot the Copilot CLI version + per-platform npm integrity values for the +# rust crate's bundled-in-process build path. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RUST_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +REPO_ROOT="$(cd "${RUST_DIR}/.." && pwd)" +LOCKFILE="${REPO_ROOT}/nodejs/package-lock.json" +OUTPUT="${RUST_DIR}/cli-version-in-process.txt" + +if [[ ! -f "${LOCKFILE}" ]]; then + echo "error: ${LOCKFILE} not found" >&2 + exit 1 +fi + +VERSION="$(node -e "console.log(require('${LOCKFILE}').packages['node_modules/@github/copilot'].version)")" +if [[ -z "${VERSION}" ]]; then + echo "error: could not read @github/copilot version from ${LOCKFILE}" >&2 + exit 1 +fi + +PACKAGES=( + "copilot-darwin-arm64" + "copilot-darwin-x64" + "copilot-linux-arm64" + "copilot-linux-x64" + "copilot-linuxmusl-arm64" + "copilot-linuxmusl-x64" + "copilot-win32-arm64" + "copilot-win32-x64" +) + +declare -A INTEGRITIES +for package in "${PACKAGES[@]}"; do + integrity="$(node -e "console.log(require('${LOCKFILE}').packages['node_modules/@github/${package}'].integrity)")" + if [[ -z "${integrity}" ]]; then + echo "error: package-lock.json missing integrity for @github/${package}" >&2 + exit 1 + fi + INTEGRITIES[$package]="${integrity}" +done + +{ + echo "# Auto-generated by rust/scripts/snapshot-bundled-in-process-version.sh" + echo "# Do not edit. Regenerated by the publish workflow on every release." + echo "version=${VERSION}" + for package in "${PACKAGES[@]}"; do + echo "${package}=${INTEGRITIES[$package]}" + done +} > "${OUTPUT}" + +echo "Wrote ${OUTPUT} (version=${VERSION}, ${#PACKAGES[@]} integrity values)" diff --git a/rust/src/copilot_request_handler.rs b/rust/src/copilot_request_handler.rs index b686b6eada..961ae3876e 100644 --- a/rust/src/copilot_request_handler.rs +++ b/rust/src/copilot_request_handler.rs @@ -140,6 +140,12 @@ pub struct CopilotRequestContext { /// Id of the runtime session that triggered this request, or `None` when it /// was issued outside any session (for example the startup model catalog). pub session_id: Option, + /// Stable per-agent-instance id for the agent trajectory that issued this request. + pub agent_id: Option, + /// Id of the parent agent when this request was issued by a subagent. + pub parent_agent_id: Option, + /// Runtime classification for the interaction that produced this request. + pub interaction_type: Option, /// Transport the runtime would otherwise use. pub transport: CopilotRequestTransport, /// Absolute request URL. @@ -594,6 +600,9 @@ struct ResponseState { #[derive(Default)] struct RequestMeta { session_id: Option, + agent_id: Option, + parent_agent_id: Option, + interaction_type: Option, method: String, url: String, headers: HeaderMap, @@ -630,6 +639,9 @@ impl CopilotRequestExchange { fn set_context(&self, params: LlmInferenceHttpRequestStartRequest) { let _ = self.meta.set(RequestMeta { session_id: params.session_id.map(SessionId::into_inner), + agent_id: params.agent_id, + parent_agent_id: params.parent_agent_id, + interaction_type: params.interaction_type, method: params.method, url: params.url, headers: headers_from_wire(¶ms.headers), @@ -649,6 +661,9 @@ impl CopilotRequestExchange { CopilotRequestContext { request_id: self.request_id.clone(), session_id: meta.session_id.clone(), + agent_id: meta.agent_id.clone(), + parent_agent_id: meta.parent_agent_id.clone(), + interaction_type: meta.interaction_type.clone(), transport: meta.transport, url: meta.url.clone(), headers: meta.headers.clone(), diff --git a/rust/src/embeddedcli.rs b/rust/src/embeddedcli.rs index 56f97e0c0e..40900a4d22 100644 --- a/rust/src/embeddedcli.rs +++ b/rust/src/embeddedcli.rs @@ -2,14 +2,11 @@ //! crate (gated on the `bundled-cli` cargo feature, which is in the default //! feature set). //! -//! build.rs downloads the platform's `copilot-{platform}.{tar.gz,zip}` -//! archive from GitHub Releases, SHA-256 verifies it against the version -//! pinned in `cli-version.txt` (or `../nodejs/package-lock.json` when -//! building inside the github/copilot-sdk repo itself), and embeds the -//! **raw archive bytes** -//! into the consumer's compiled artifact via `include_bytes!()`. Extraction -//! to a real on-disk path is deferred until the first call to -//! [`path`] / [`install_at`]. +//! Normal builds embed the platform release archive from GitHub Releases. +//! Builds with `bundled-in-process` instead embed a minimal archive from the +//! platform npm package containing the CLI executable and native runtime +//! library. Extraction to a real on-disk path is deferred until the first call +//! to [`path`] / [`install_at`]. //! //! The embedded bytes are part of the consumer's signed binary and therefore //! trusted *as the source of truth* — but the bytes that land on disk are not. @@ -31,7 +28,7 @@ // off but still needs to exercise them. #[cfg(any(has_bundled_cli, test))] use std::fs; -#[cfg(all(has_bundled_cli, not(windows)))] +#[cfg(all(has_bundled_cli, any(feature = "bundled-in-process", not(windows))))] use std::io::Read; #[cfg(any(has_bundled_cli, test))] use std::io::Write; @@ -44,8 +41,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; use tracing::{info, warn}; // When the `bundled-cli` cargo feature is enabled and the target platform is -// supported, build.rs generates `bundled_cli.rs` exposing the raw archive -// bytes. The CLI version is exposed crate-wide via the +// supported, build.rs generates `bundled_cli.rs` exposing the selected archive. +// The CLI version is exposed crate-wide via the // `cargo:rustc-env=COPILOT_SDK_CLI_VERSION` emit (see `build.rs`), and the // binary name is OS-derived — so no other generated constants are needed. #[cfg(has_bundled_cli)] @@ -157,8 +154,53 @@ fn default_install_dir(version: &str) -> PathBuf { #[cfg(has_bundled_cli)] const MAX_PUBLISH_ATTEMPTS: u32 = 3; +// Natural platform shared-library name for the in-process FFI runtime. +#[cfg(all(has_bundled_cli, feature = "bundled-in-process", windows))] +const RUNTIME_LIBRARY_NAME: &str = "copilot_runtime.dll"; +#[cfg(all(has_bundled_cli, feature = "bundled-in-process", target_os = "macos"))] +const RUNTIME_LIBRARY_NAME: &str = "libcopilot_runtime.dylib"; +#[cfg(all( + has_bundled_cli, + feature = "bundled-in-process", + not(windows), + not(target_os = "macos") +))] +const RUNTIME_LIBRARY_NAME: &str = "libcopilot_runtime.so"; + #[cfg(has_bundled_cli)] fn install(install_dir: &Path, archive: &[u8]) -> Result { + let final_path = install_cli(install_dir, archive)?; + #[cfg(feature = "bundled-in-process")] + { + install_runtime_library(install_dir, archive)?; + } + Ok(final_path) +} + +#[cfg(all(has_bundled_cli, feature = "bundled-in-process"))] +fn install_runtime_library(install_dir: &Path, archive: &[u8]) -> Result<(), EmbeddedCliError> { + let target = install_dir.join(RUNTIME_LIBRARY_NAME); + if fs::metadata(&target).map(|m| m.len() > 0).unwrap_or(false) { + return Ok(()); + } + let bytes = extract_binary(archive, RUNTIME_LIBRARY_NAME)?; + if bytes.is_empty() { + return Err(EmbeddedCliError::with_message( + EmbeddedCliErrorKind::Verification, + "embedded runtime library is empty", + )); + } + let tmp = write_temp_file(install_dir, &bytes)?; + if let Err(e) = publish(&tmp, &target) { + let _ = fs::remove_file(&tmp); + return Err(e); + } + tracing::debug!(path = %target.display(), "in-process FFI runtime library installed"); + Ok(()) +} + +#[cfg(has_bundled_cli)] +fn install_cli(install_dir: &Path, archive: &[u8]) -> Result { let verbose = std::env::var("COPILOT_CLI_INSTALL_VERBOSE").ok().as_deref() == Some("1"); fs::create_dir_all(install_dir) @@ -438,7 +480,7 @@ fn read_marker_len(marker_path: &Path) -> Option { .ok() } -#[cfg(all(has_bundled_cli, not(windows)))] +#[cfg(all(has_bundled_cli, any(feature = "bundled-in-process", not(windows))))] fn extract_binary(archive: &[u8], binary_name: &str) -> Result, EmbeddedCliError> { let gz = flate2::read::GzDecoder::new(archive); let mut tar = tar::Archive::new(gz); @@ -463,7 +505,7 @@ fn extract_binary(archive: &[u8], binary_name: &str) -> Result, Embedded Err(EmbeddedCliErrorKind::BinaryNotFoundInArchive.into()) } -#[cfg(all(has_bundled_cli, windows))] +#[cfg(all(has_bundled_cli, not(feature = "bundled-in-process"), windows))] fn extract_binary(archive: &[u8], binary_name: &str) -> Result, EmbeddedCliError> { let cursor = std::io::Cursor::new(archive); let mut zip = zip::ZipArchive::new(cursor) @@ -499,9 +541,9 @@ fn sanitize_version(version: &str) -> String { #[allow(dead_code)] enum EmbeddedCliErrorKind { CreateDir, - #[cfg(not(windows))] + #[cfg(any(feature = "bundled-in-process", not(windows)))] Archive, - #[cfg(windows)] + #[cfg(all(not(feature = "bundled-in-process"), windows))] Zip, BinaryNotFoundInArchive, Io, @@ -519,9 +561,9 @@ impl std::fmt::Display for EmbeddedCliErrorKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { EmbeddedCliErrorKind::CreateDir => f.write_str("failed to create install directory"), - #[cfg(not(windows))] + #[cfg(any(feature = "bundled-in-process", not(windows)))] EmbeddedCliErrorKind::Archive => f.write_str("failed to read archive entry"), - #[cfg(windows)] + #[cfg(all(not(feature = "bundled-in-process"), windows))] EmbeddedCliErrorKind::Zip => f.write_str("failed to read zip archive"), EmbeddedCliErrorKind::BinaryNotFoundInArchive => { f.write_str("CLI binary not found in embedded archive") @@ -626,6 +668,33 @@ impl std::error::Error for EmbeddedCliError { mod tests { use super::*; + #[cfg(all(has_bundled_cli, feature = "bundled-in-process"))] + #[test] + fn embedded_archive_contains_only_expected_files() { + let gz = flate2::read::GzDecoder::new(build_time::CLI_ARCHIVE); + let mut archive = tar::Archive::new(gz); + let mut names: Vec = archive + .entries() + .expect("archive entries") + .map(|entry| { + entry + .expect("archive entry") + .path() + .expect("archive path") + .to_string_lossy() + .into_owned() + }) + .collect(); + names.sort(); + + let mut expected = vec![ + CLI_BINARY_NAME.to_string(), + RUNTIME_LIBRARY_NAME.to_string(), + ]; + expected.sort(); + assert_eq!(names, expected); + } + /// Bytes whose header looks like a valid executable image on the host /// platform, so `looks_like_valid_image` accepts them. `extra` padding /// bytes follow the magic so size checks have something to disagree about. diff --git a/rust/src/ffi.rs b/rust/src/ffi.rs new file mode 100644 index 0000000000..f784b1a6d1 --- /dev/null +++ b/rust/src/ffi.rs @@ -0,0 +1,633 @@ +//! In-process FFI transport: hosts the Copilot runtime by loading its native +//! library and speaking JSON-RPC over its C ABI, +//! instead of spawning a CLI child process and communicating over stdio/TCP. +//! +//! The runtime's `host_start` export spawns the residual TypeScript worker +//! itself — the packaged single-file CLI (`copilot --embedded-host`) or, for +//! dev, `node dist-cli/index.js --embedded-host`. JSON-RPC frames are pumped +//! across the ABI: writes go to `connection_write`; inbound frames arrive on a +//! native callback that feeds an async reader. The framing is unchanged — the +//! same LSP `Content-Length:` frames the stdio transport uses. + +use std::collections::HashMap; +use std::ffi::c_void; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, AtomicUsize, Ordering}; +use std::sync::{Arc, OnceLock}; +use std::task::{Context, Poll}; + +use libloading::Library; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::sync::mpsc; +use tracing::debug; + +use crate::{Error, ErrorKind}; + +type OutboundCallback = unsafe extern "C" fn(*mut c_void, *const u8, usize); +type HostStartFn = unsafe extern "C" fn(*const u8, usize, *const u8, usize) -> u32; +type HostShutdownFn = unsafe extern "C" fn(u32) -> bool; +#[allow(clippy::type_complexity)] +type ConnectionOpenFn = unsafe extern "C" fn( + u32, + OutboundCallback, + *mut c_void, + *const u8, + usize, + *const u8, + usize, + *const u8, + usize, +) -> u32; +type ConnectionWriteFn = unsafe extern "C" fn(u32, *const u8, usize) -> bool; +type ConnectionCloseFn = unsafe extern "C" fn(u32) -> bool; + +/// State handed to the native side as `user_data` so the outbound callback can +/// route inbound frames back to the reader. +struct CallbackState { + tx: mpsc::UnboundedSender>, + active_callbacks: AtomicUsize, + closing: AtomicBool, +} + +extern "C" fn on_outbound(user_data: *mut c_void, bytes: *const u8, len: usize) { + if user_data.is_null() || bytes.is_null() || len == 0 { + return; + } + let state = unsafe { &*(user_data as *const CallbackState) }; + state.active_callbacks.fetch_add(1, Ordering::SeqCst); + if state.closing.load(Ordering::SeqCst) { + state.active_callbacks.fetch_sub(1, Ordering::SeqCst); + return; + } + let slice = unsafe { std::slice::from_raw_parts(bytes, len) }; + let _ = state.tx.send(slice.to_vec()); + state.active_callbacks.fetch_sub(1, Ordering::SeqCst); +} + +/// Bound exports and connection lifecycle state, shared between the +/// [`FfiWriter`] and the owning [`Client`]. The cdylib itself is loaded +/// process-globally and never unloaded (see [`load_library`]), so this holds +/// only the bound fn pointers and connection state. +pub(crate) struct FfiShared { + host_shutdown: HostShutdownFn, + connection_write: ConnectionWriteFn, + connection_close: ConnectionCloseFn, + server_id: AtomicU32, + connection_id: AtomicU32, + callback_state: AtomicPtr, + closed: AtomicBool, + operation_lock: parking_lot::Mutex<()>, + library_path: PathBuf, +} + +// The raw fn pointers and the boxed callback state are safe to move across +// threads: the native side copies buffers synchronously and the callback only +// forwards to a thread-safe channel sender. +unsafe impl Send for FfiShared {} +unsafe impl Sync for FfiShared {} + +impl FfiShared { + /// Close the connection, shut the host down, and free the callback state. + /// Idempotent; called from [`Client::stop`], drop, and on startup failure. + pub(crate) fn close(&self) { + let _operation = self.operation_lock.lock(); + if self.closed.swap(true, Ordering::SeqCst) { + return; + } + let state = self.callback_state.load(Ordering::SeqCst); + if !state.is_null() { + unsafe { &*state }.closing.store(true, Ordering::SeqCst); + } + let conn = self.connection_id.swap(0, Ordering::SeqCst); + if conn != 0 { + unsafe { (self.connection_close)(conn) }; + } + let server = self.server_id.swap(0, Ordering::SeqCst); + if server != 0 { + unsafe { (self.host_shutdown)(server) }; + } + // Free the callback state only after the connection is closed and the + // host is shut down, so native can no longer invoke the callback. + let state = self + .callback_state + .swap(std::ptr::null_mut(), Ordering::SeqCst); + if !state.is_null() { + while unsafe { &*state }.active_callbacks.load(Ordering::SeqCst) != 0 { + std::thread::yield_now(); + } + drop(unsafe { Box::from_raw(state) }); + } + debug!(library = %self.library_path.display(), "FFI runtime connection closed"); + } + + fn write_frame(&self, frame: &[u8]) -> bool { + let _operation = self.operation_lock.lock(); + if self.closed.load(Ordering::SeqCst) { + return false; + } + let conn = self.connection_id.load(Ordering::SeqCst); + if conn == 0 { + return false; + } + unsafe { (self.connection_write)(conn, frame.as_ptr(), frame.len()) } + } +} + +impl Drop for FfiShared { + fn drop(&mut self) { + self.close(); + } +} + +/// Read side of the FFI transport, fed by the native outbound callback via an +/// unbounded channel. Implements [`AsyncRead`] for the JSON-RPC read loop. +pub(crate) struct FfiReader { + rx: mpsc::UnboundedReceiver>, + leftover: Vec, + pos: usize, +} + +impl AsyncRead for FfiReader { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if self.pos >= self.leftover.len() { + match self.rx.poll_recv(cx) { + Poll::Ready(Some(chunk)) => { + self.leftover = chunk; + self.pos = 0; + } + Poll::Ready(None) => return Poll::Ready(Ok(())), + Poll::Pending => return Poll::Pending, + } + } + let available = self.leftover.len() - self.pos; + let n = available.min(buf.remaining()); + let start = self.pos; + buf.put_slice(&self.leftover[start..start + n]); + self.pos += n; + Poll::Ready(Ok(())) + } +} + +/// Write side of the FFI transport. Each frame is forwarded synchronously to +/// the native `connection_write` export (native copies before returning). +pub(crate) struct FfiWriter { + shared: Arc, +} + +impl AsyncWrite for FfiWriter { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + if self.shared.write_frame(buf) { + Poll::Ready(Ok(buf.len())) + } else { + Poll::Ready(Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "failed to write a frame to the in-process runtime connection", + ))) + } + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +/// Prepared FFI host: the bound cdylib exports plus the spawn arguments needed +/// to start the runtime worker. The cdylib is loaded process-globally and never +/// unloaded (see [`load_library`]). +pub(crate) struct FfiHost { + library_path: PathBuf, + entrypoint: PathBuf, + environment: Vec<(String, String)>, + args: Vec, + host_start: HostStartFn, + host_shutdown: HostShutdownFn, + connection_open: ConnectionOpenFn, + connection_write: ConnectionWriteFn, + connection_close: ConnectionCloseFn, +} + +// SAFETY: as for `FfiShared` — the bound exports are plain fn pointers, safe to +// move to the blocking thread that starts the host. +unsafe impl Send for FfiHost {} + +impl FfiHost { + /// Load the cdylib next to `entrypoint` and bind its exports. + /// + /// `entrypoint` is the packaged single-file CLI binary or, for dev, a + /// `.js` file launched via `node`. The native library is resolved relative + /// to the entrypoint directory, supporting both packaged and development + /// layouts. + pub(crate) fn create( + entrypoint: &Path, + environment: Vec<(String, String)>, + args: Vec, + ) -> Result { + let entrypoint = std::fs::canonicalize(entrypoint) + .map(path_for_child_process) + .map_err(|e| { + Error::with_message( + ErrorKind::InvalidConfig, + format!( + "failed to resolve in-process CLI entrypoint '{}': {e}", + entrypoint.display() + ), + ) + })?; + let library_path = + std::fs::canonicalize(resolve_library_path(&entrypoint)?).map_err(|e| { + Error::with_message( + ErrorKind::InvalidConfig, + format!("failed to resolve in-process runtime library: {e}"), + ) + })?; + let lib = load_library(&library_path)?; + + let host_start = *bind::(lib, b"copilot_runtime_host_start\0", &library_path)?; + let host_shutdown = + *bind::(lib, b"copilot_runtime_host_shutdown\0", &library_path)?; + let connection_open = + *bind::(lib, b"copilot_runtime_connection_open\0", &library_path)?; + let connection_write = + *bind::(lib, b"copilot_runtime_connection_write\0", &library_path)?; + let connection_close = + *bind::(lib, b"copilot_runtime_connection_close\0", &library_path)?; + + Ok(Self { + library_path, + entrypoint, + environment, + args, + host_start, + host_shutdown, + connection_open, + connection_write, + connection_close, + }) + } + + /// Start the runtime worker and open the FFI JSON-RPC connection. + /// + /// `host_start` blocks until the worker connects back and signals + /// readiness (up to ~30s), and must not run on an async executor thread, so + /// the blocking handshake is offloaded to [`tokio::task::spawn_blocking`]. + pub(crate) async fn start(self) -> Result<(FfiReader, FfiWriter, Arc), Error> { + tokio::task::spawn_blocking(move || self.start_blocking()) + .await + .map_err(|e| { + Error::with_message( + ErrorKind::InvalidConfig, + format!("in-process runtime startup task failed: {e}"), + ) + })? + } + + fn start_blocking(self) -> Result<(FfiReader, FfiWriter, Arc), Error> { + let argv = build_argv_json(&self.entrypoint, &self.args); + let env = build_env_json(&self.environment); + + let (env_ptr, env_len) = match &env { + Some(bytes) => (bytes.as_ptr(), bytes.len()), + None => (std::ptr::null(), 0), + }; + + let server_id = unsafe { (self.host_start)(argv.as_ptr(), argv.len(), env_ptr, env_len) }; + + if server_id == 0 { + return Err(Error::with_message( + ErrorKind::InvalidConfig, + format!( + "copilot_runtime_host_start failed (library '{}', entrypoint '{}')", + self.library_path.display(), + self.entrypoint.display() + ), + )); + } + + let (tx, rx) = mpsc::unbounded_channel::>(); + let state_ptr = Box::into_raw(Box::new(CallbackState { + tx, + active_callbacks: AtomicUsize::new(0), + closing: AtomicBool::new(false), + })); + let connection_id = unsafe { + (self.connection_open)( + server_id, + on_outbound, + state_ptr as *mut c_void, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + ) + }; + if connection_id == 0 { + drop(unsafe { Box::from_raw(state_ptr) }); + unsafe { (self.host_shutdown)(server_id) }; + return Err(Error::with_message( + ErrorKind::InvalidConfig, + "copilot_runtime_connection_open failed", + )); + } + + let shared = Arc::new(FfiShared { + host_shutdown: self.host_shutdown, + connection_write: self.connection_write, + connection_close: self.connection_close, + server_id: AtomicU32::new(server_id), + connection_id: AtomicU32::new(connection_id), + callback_state: AtomicPtr::new(state_ptr), + closed: AtomicBool::new(false), + operation_lock: parking_lot::Mutex::new(()), + library_path: self.library_path.clone(), + }); + + debug!( + library = %self.library_path.display(), + server_id, connection_id, "FFI runtime host started" + ); + + let reader = FfiReader { + rx, + leftover: Vec::new(), + pos: 0, + }; + let writer = FfiWriter { + shared: Arc::clone(&shared), + }; + Ok((reader, writer, shared)) + } +} + +fn bind<'lib, T>( + lib: &'lib Library, + symbol: &[u8], + library_path: &Path, +) -> Result, Error> { + match unsafe { lib.get::(symbol) } { + Ok(export) => Ok(export), + Err(e) => Err(Error::with_message( + ErrorKind::InvalidConfig, + format!( + "in-process runtime library '{}' is missing an expected export ({}): {e}", + library_path.display(), + String::from_utf8_lossy(symbol.strip_suffix(b"\0").unwrap_or(symbol)) + ), + )), + } +} + +/// Loads the runtime cdylib once per process and never unloads it, returning a +/// `'static` reference. Subsequent loads of the same path reuse the first +/// handle. +/// +/// The library stays mapped because native worker threads can outlive an +/// individual connection teardown. +fn load_library(library_path: &Path) -> Result<&'static Library, Error> { + static LIBRARIES: OnceLock>> = + OnceLock::new(); + let cache = LIBRARIES.get_or_init(|| parking_lot::Mutex::new(HashMap::new())); + + let mut guard = cache.lock(); + if let Some(lib) = guard.get(library_path) { + return Ok(*lib); + } + + let lib = unsafe { Library::new(library_path) }.map_err(|e| { + Error::with_message( + ErrorKind::InvalidConfig, + format!( + "failed to load in-process runtime library '{}': {e}", + library_path.display() + ), + ) + })?; + // Leak the library so it is never unloaded for the process lifetime. + let leaked: &'static Library = Box::leak(Box::new(lib)); + guard.insert(library_path.to_path_buf(), leaked); + Ok(leaked) +} + +/// The natural platform shared-library file name for the runtime cdylib — the +/// `.node` file renamed to what the Rust cdylib would be called on this OS. +fn natural_library_name() -> &'static str { + if cfg!(windows) { + "copilot_runtime.dll" + } else if cfg!(target_os = "macos") { + "libcopilot_runtime.dylib" + } else { + "libcopilot_runtime.so" + } +} + +/// The package prebuild folder name for the current host. +pub(crate) fn prebuilds_folder() -> Option { + let platform = if cfg!(target_os = "windows") { + "win32" + } else if cfg!(target_os = "macos") { + "darwin" + } else if cfg!(target_os = "linux") { + "linux" + } else { + return None; + }; + let arch = if cfg!(target_arch = "x86_64") { + "x64" + } else if cfg!(target_arch = "aarch64") { + "arm64" + } else { + return None; + }; + Some(format!("{platform}-{arch}")) +} + +fn resolve_library_path(entrypoint: &Path) -> Result { + let dir = entrypoint.parent().ok_or_else(|| { + Error::with_message( + ErrorKind::InvalidConfig, + format!( + "could not determine directory for CLI entrypoint '{}'", + entrypoint.display() + ), + ) + })?; + + // Bundled/flat layout: natural shared-library name next to the CLI. + let flat = dir.join(natural_library_name()); + if flat.is_file() { + return Ok(flat); + } + + // Development package layout. + let prebuilds = + prebuilds_folder().map(|folder| dir.join("prebuilds").join(folder).join("runtime.node")); + if let Some(prebuilds_path) = &prebuilds + && prebuilds_path.is_file() + { + return Ok(prebuilds_path.clone()); + } + + Err(Error::with_message( + ErrorKind::BinaryNotFound { + name: natural_library_name().into(), + hint: Some(format!( + "native runtime library not found next to '{}'. Enable the \ + `bundled-in-process` feature or set COPILOT_CLI_PATH to a compatible CLI package.", + entrypoint.display() + )), + }, + "native runtime library not found", + )) +} + +#[cfg(windows)] +fn path_for_child_process(path: PathBuf) -> PathBuf { + use std::ffi::OsString; + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + + const VERBATIM_PREFIX: &[u16] = &[b'\\' as u16, b'\\' as u16, b'?' as u16, b'\\' as u16]; + const UNC_PREFIX: &[u16] = &[b'U' as u16, b'N' as u16, b'C' as u16, b'\\' as u16]; + + let encoded: Vec = path.as_os_str().encode_wide().collect(); + let Some(stripped) = encoded.strip_prefix(VERBATIM_PREFIX) else { + return path; + }; + let normalized = if let Some(unc_path) = stripped.strip_prefix(UNC_PREFIX) { + let mut result = vec![b'\\' as u16, b'\\' as u16]; + result.extend_from_slice(unc_path); + result + } else { + stripped.to_vec() + }; + PathBuf::from(OsString::from_wide(&normalized)) +} + +#[cfg(not(windows))] +fn path_for_child_process(path: PathBuf) -> PathBuf { + path +} + +fn build_argv_json(entrypoint: &Path, extra_args: &[String]) -> Vec { + // A `.js` entrypoint (dev / dist-cli) is launched via node; the packaged + // single-file CLI binary embeds its own Node and is invoked directly. + let entrypoint_str = entrypoint.to_string_lossy().into_owned(); + let is_js = entrypoint + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("js")); + let mut argv: Vec = if is_js { + vec![ + "node".to_string(), + entrypoint_str, + "--embedded-host".to_string(), + "--no-auto-update".to_string(), + ] + } else { + vec![ + entrypoint_str, + "--embedded-host".to_string(), + "--no-auto-update".to_string(), + ] + }; + argv.extend_from_slice(extra_args); + serde_json::to_vec(&argv).expect("argv serializes") +} + +fn build_env_json(environment: &[(String, String)]) -> Option> { + if environment.is_empty() { + return None; + } + let map: serde_json::Map = environment + .iter() + .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) + .collect(); + Some(serde_json::to_vec(&map).expect("env serializes")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn argv_pins_worker_and_appends_client_options() { + let argv: Vec = serde_json::from_slice(&build_argv_json( + Path::new("copilot"), + &["--log-level".into(), "debug".into()], + )) + .unwrap(); + + assert_eq!( + argv, + [ + "copilot", + "--embedded-host", + "--no-auto-update", + "--log-level", + "debug" + ] + ); + } + + #[test] + fn javascript_entrypoint_uses_node() { + let argv: Vec = + serde_json::from_slice(&build_argv_json(Path::new("index.js"), &[])).unwrap(); + + assert_eq!( + argv, + ["node", "index.js", "--embedded-host", "--no-auto-update"] + ); + } + + #[cfg(windows)] + #[test] + fn child_process_path_removes_windows_verbatim_prefix() { + assert_eq!( + path_for_child_process(PathBuf::from(r"\\?\D:\a\copilot-sdk\index.js")), + PathBuf::from(r"D:\a\copilot-sdk\index.js") + ); + assert_eq!( + path_for_child_process(PathBuf::from(r"\\?\UNC\server\share\copilot-sdk\index.js")), + PathBuf::from(r"\\server\share\copilot-sdk\index.js") + ); + } + + #[test] + fn environment_is_omitted_when_empty() { + assert_eq!(build_env_json(&[]), None); + } + + #[test] + fn environment_serializes_worker_overrides() { + let env: serde_json::Value = serde_json::from_slice( + &build_env_json(&[ + ("COPILOT_HOME".into(), "state".into()), + ("COPILOT_DISABLE_KEYTAR".into(), "1".into()), + ]) + .unwrap(), + ) + .unwrap(); + + assert_eq!( + env, + serde_json::json!({ + "COPILOT_HOME": "state", + "COPILOT_DISABLE_KEYTAR": "1", + }) + ); + } +} diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index b364b4a4d8..e243ec1dc2 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -92,6 +92,8 @@ pub mod rpc_methods { pub const INSTRUCTIONS_DISCOVER: &str = "instructions.discover"; /// `instructions.getDiscoveryPaths` pub const INSTRUCTIONS_GETDISCOVERYPATHS: &str = "instructions.getDiscoveryPaths"; + /// `commands.list` + pub const COMMANDS_LIST: &str = "commands.list"; /// `user.settings.reload` pub const USER_SETTINGS_RELOAD: &str = "user.settings.reload"; /// `user.settings.get` @@ -171,6 +173,8 @@ pub mod rpc_methods { pub const SESSION_SUSPEND: &str = "session.suspend"; /// `session.send` pub const SESSION_SEND: &str = "session.send"; + /// `session.sendMessages` + pub const SESSION_SENDMESSAGES: &str = "session.sendMessages"; /// `session.abort` pub const SESSION_ABORT: &str = "session.abort"; /// `session.shutdown` @@ -344,6 +348,12 @@ pub mod rpc_methods { pub const SESSION_MCP_APPS_GETHOSTCONTEXT: &str = "session.mcp.apps.getHostContext"; /// `session.mcp.apps.diagnose` pub const SESSION_MCP_APPS_DIAGNOSE: &str = "session.mcp.apps.diagnose"; + /// `session.mcp.resources.read` + pub const SESSION_MCP_RESOURCES_READ: &str = "session.mcp.resources.read"; + /// `session.mcp.resources.list` + pub const SESSION_MCP_RESOURCES_LIST: &str = "session.mcp.resources.list"; + /// `session.mcp.resources.listTemplates` + pub const SESSION_MCP_RESOURCES_LISTTEMPLATES: &str = "session.mcp.resources.listTemplates"; /// `session.plugins.list` pub const SESSION_PLUGINS_LIST: &str = "session.plugins.list"; /// `session.plugins.reload` @@ -2208,6 +2218,9 @@ pub struct DiscoveredCanvas { /// Owning extension display name, when available #[serde(skip_serializing_if = "Option::is_none")] pub extension_name: Option, + /// Host-local PNG path for the canvas icon, when supplied + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, /// JSON Schema for canvas open input #[serde(skip_serializing_if = "Option::is_none")] pub input_schema: Option, @@ -2246,6 +2259,9 @@ pub struct OpenCanvasInstance { /// Owning extension display name, when available #[serde(skip_serializing_if = "Option::is_none")] pub extension_name: Option, + /// Host-local PNG path for the canvas icon, when supplied + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, /// Input supplied when the instance was opened #[serde(skip_serializing_if = "Option::is_none")] pub input: Option, @@ -4039,6 +4055,24 @@ pub struct HMACAuthInfo { pub r#type: HMACAuthInfoType, } +/// Runtime-owned wire payload for a server-to-client hook callback invocation. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HookInvokeRequest { + #[doc(hidden)] + pub(crate) hook_type: HookType, + pub input: serde_json::Value, + pub session_id: SessionId, +} + +/// Optional output returned by an SDK callback hook. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HookInvokeResponse { + #[serde(skip_serializing_if = "Option::is_none")] + pub output: Option, +} + /// Installed plugin record from global state, with marketplace, version, install time, enabled state, cache path, and source. /// ///
@@ -5442,7 +5476,26 @@ pub struct McpListToolsRequest { pub server_name: String, } -/// MCP tool metadata with tool name and optional description. +/// Normalized MCP Apps discovery metadata from a tool's `_meta.ui` block. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpToolUi { + /// URI of the tool's MCP App resource, typically a `ui://` resource identifier. Use `session.mcp.resources.read` to fetch its HTML and resource metadata. + #[serde(skip_serializing_if = "Option::is_none")] + pub resource_uri: Option, + /// Tool visibility advertised by the server. When absent, MCP Apps defaults apply. + #[serde(skip_serializing_if = "Option::is_none")] + pub visibility: Option>, +} + +/// MCP tool metadata with tool name, optional description, and normalized MCP Apps discovery metadata. /// ///
/// @@ -5458,6 +5511,9 @@ pub struct McpTools { pub description: Option, /// Tool name. pub name: String, + /// Normalized MCP Apps discovery metadata. An empty object indicates that a valid `_meta.ui` block was present without recognized fields. + #[serde(skip_serializing_if = "Option::is_none")] + pub ui: Option, } /// Tools exposed by the connected MCP server. Throws when the server is not connected. @@ -5634,7 +5690,7 @@ pub struct McpRemoveGitHubResult { pub removed: bool, } -/// Server name and opaque configuration for an individual MCP server restart. +/// Standard MCP resource annotations plus preserved non-standard annotation fields. /// ///
/// @@ -5644,10 +5700,272 @@ pub struct McpRemoveGitHubResult { ///
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct McpRestartServerRequest { - /// Opaque server configuration (MCPServerConfig). Marked internal: an in-process runtime shape supplied only by in-process CLI callers. - #[doc(hidden)] - pub(crate) config: serde_json::Value, +pub struct McpResourceAnnotations { + /// Server-provided non-standard annotation fields preserved from the MCP response + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_properties: Option>, + /// Intended audience roles for this resource + #[serde(skip_serializing_if = "Option::is_none")] + pub audience: Option>, + /// Last-modified timestamp hint + #[serde(skip_serializing_if = "Option::is_none")] + pub last_modified: Option, + /// Priority hint for model/client use + #[serde(skip_serializing_if = "Option::is_none")] + pub priority: Option, +} + +/// A resource icon descriptor plus preserved non-standard icon fields. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpResourceIcon { + /// Server-provided non-standard icon fields preserved from the MCP response + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_properties: Option>, + /// Icon MIME type, when known + #[serde(skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + /// Icon sizes hint + #[serde(skip_serializing_if = "Option::is_none")] + pub sizes: Option, + /// Icon URI + pub src: String, + /// Theme hint for this icon + #[serde(skip_serializing_if = "Option::is_none")] + pub theme: Option, +} + +/// An MCP resource descriptor (spec `Resource`): URI, name, and optional title, description, MIME type, size, icons, annotations, and metadata. Server-provided fields outside the standard descriptor shape are exposed under `additionalProperties`. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpResource { + /// Resource-level metadata + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option>, + /// Server-provided non-standard descriptor fields preserved from the MCP response + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_properties: Option>, + /// Model/client annotations associated with this resource + #[serde(skip_serializing_if = "Option::is_none")] + pub annotations: Option, + /// Optional description of what this resource represents + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Icons associated with this resource + #[serde(skip_serializing_if = "Option::is_none")] + pub icons: Option>, + /// MIME type of the resource, if known + #[serde(skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + /// The programmatic name of the resource + pub name: String, + /// Resource size in bytes, when known + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, + /// Optional human-readable display title + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + /// The resource URI (e.g. ui://... or file:///...) + pub uri: String, +} + +/// MCP resource content with URI, optional MIME type, text or base64 blob, and resource metadata. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpResourceContent { + /// Resource-level metadata (CSP, permissions, etc.) + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option>, + /// Base64-encoded binary content + #[serde(skip_serializing_if = "Option::is_none")] + pub blob: Option, + /// MIME type of the content + #[serde(skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + /// Text content (e.g. HTML) + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + /// The resource URI + pub uri: String, +} + +/// MCP server whose resources to enumerate. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpResourcesListRequest { + /// Opaque MCP pagination cursor from a prior `nextCursor` value + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, + /// Name of the MCP server whose resources to enumerate + pub server_name: String, +} + +/// One page of resources advertised by the named MCP server. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpResourcesListResult { + /// Opaque cursor for the next page, if the server has more resources + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + /// Resources advertised by the server (proxied MCP `resources/list`) + pub resources: Vec, +} + +/// MCP server whose resource templates to enumerate. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpResourcesListTemplatesRequest { + /// Opaque MCP pagination cursor from a prior `nextCursor` value + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, + /// Name of the MCP server whose resource templates to enumerate + pub server_name: String, +} + +/// An MCP resource template descriptor (spec `ResourceTemplate`): an RFC 6570 URI template, name, and optional title, description, MIME type, icons, annotations, and metadata. Server-provided fields outside the standard descriptor shape are exposed under `additionalProperties`. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpResourceTemplate { + /// Resource-template-level metadata + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option>, + /// Server-provided non-standard descriptor fields preserved from the MCP response + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_properties: Option>, + /// Model/client annotations associated with this template + #[serde(skip_serializing_if = "Option::is_none")] + pub annotations: Option, + /// Optional description of what this template is for + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Icons associated with resources matching this template + #[serde(skip_serializing_if = "Option::is_none")] + pub icons: Option>, + /// MIME type for resources matching this template, if uniform + #[serde(skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + /// The programmatic name of the resource template + pub name: String, + /// Optional human-readable display title + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + /// An RFC 6570 URI template for constructing resource URIs + pub uri_template: String, +} + +/// One page of resource templates advertised by the named MCP server. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpResourcesListTemplatesResult { + /// Opaque cursor for the next page, if the server has more resource templates + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + /// Resource templates advertised by the server (proxied MCP `resources/templates/list`) + pub resource_templates: Vec, +} + +/// MCP server and resource URI to fetch. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpResourcesReadRequest { + /// Name of the MCP server hosting the resource + pub server_name: String, + /// Resource URI + pub uri: String, +} + +/// Resource contents returned by the MCP server. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpResourcesReadResult { + /// Resource contents returned by the server + pub contents: Vec, +} + +/// Server name and optional replacement configuration for an individual MCP server restart. Omit `config` for a config-free restart-by-name of an already-configured server. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpRestartServerRequest { + /// Replacement MCP server configuration (stdio process or remote HTTP/SSE). Omit to restart the server with its already-registered configuration (config-free restart-by-name). + #[serde(skip_serializing_if = "Option::is_none")] + pub config: Option, /// Name of the MCP server to restart pub server_name: String, } @@ -5862,7 +6180,7 @@ pub struct McpSetEnvValueModeResult { pub mode: McpSetEnvValueModeDetails, } -/// Server name and opaque configuration for an individual MCP server start. +/// Server name and configuration for an individual MCP server start. /// ///
/// @@ -5872,10 +6190,9 @@ pub struct McpSetEnvValueModeResult { ///
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct McpStartServerRequest { - /// Opaque server configuration (MCPServerConfig). Marked internal: an in-process runtime shape supplied only by in-process CLI callers. - #[doc(hidden)] - pub(crate) config: serde_json::Value, +pub struct McpStartServerRequest { + /// MCP server configuration (stdio process or remote HTTP/SSE) + pub config: serde_json::Value, /// Name of the MCP server to start pub server_name: String, } @@ -6203,7 +6520,7 @@ pub struct MetadataRecordContextChangeRequest { #[serde(rename_all = "camelCase")] pub struct MetadataRecordContextChangeResult {} -/// Absolute path to set as the session's new working directory. +/// Absolute path to set as the session's new working directory. For local sessions the path must be absolute and exist on disk: it is validated before any session state changes, and a failing validation rejects the call with nothing mutated, persisted, or emitted. Remote sessions record the path as-is. /// ///
/// @@ -6218,7 +6535,7 @@ pub struct MetadataSetWorkingDirectoryRequest { pub working_directory: String, } -/// Update the session's working directory. Used by the host when the user explicitly changes cwd (e.g., the `/cd` slash command). The host is responsible for `process.chdir` and any related side-effects (file index, etc.); this method only updates the session's own recorded path. +/// Update the session's working directory. Used by the host when the user explicitly changes cwd (e.g., the `/cd` slash command). The host is responsible for any related side-effects (file index, etc.); it does NOT change the process working directory (a session's cwd is per-session, not process-global). For local sessions the runtime validates the target first (an absolute path that exists on disk) and re-bases the permission primary directory; a rejected validation fails the call before anything is mutated, persisted, or emitted. Location-scoped permission rules are then re-keyed to the new directory (best-effort). Remote sessions only record the path. /// ///
/// @@ -6276,6 +6593,30 @@ pub struct MetadataSnapshotRemoteMetadata { pub task_type: Option, } +/// Active server-driven promotion for a model, including its discount and expiry. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelBillingPromo { + /// Percentage discount (0-100) applied while the promotion is active. May be fractional. + #[serde(skip_serializing_if = "Option::is_none")] + pub discount_percent: Option, + /// UTC ISO 8601 timestamp marking when the promotion ends. Always present: the API only surfaces a promo whose expiry parses and is in the future. Consumers should treat a past value as expired. + pub ends_at: String, + /// Stable identifier for the promotion campaign. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + /// Human-readable promotion message. Does not include the expiry timestamp; consumers may format endsAt and append it. + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + /// Long context tier pricing (available for models with extended context windows) /// ///
@@ -6375,6 +6716,9 @@ pub struct ModelBilling { /// Billing cost multiplier relative to the base rate #[serde(skip_serializing_if = "Option::is_none")] pub multiplier: Option, + /// Active server-driven promotion for this model, if any. Present when the model is being promoted with a time-boxed discount. + #[serde(skip_serializing_if = "Option::is_none")] + pub promo: Option, /// Token-level pricing information for this model #[serde(skip_serializing_if = "Option::is_none")] pub token_prices: Option, @@ -8645,7 +8989,7 @@ pub struct PluginsInstallRequest { pub working_directory: Option, } -/// Marketplace source to register. +/// Marketplace source and optional working directory for relative-path resolution. /// ///
/// @@ -8658,6 +9002,9 @@ pub struct PluginsInstallRequest { pub struct PluginsMarketplacesAddRequest { /// Marketplace source. Accepts the same forms as the CLI: "owner/repo" or "owner/repo#ref" (GitHub), an http/https/ssh URL (optionally with #ref), a git scp-style URL (user@host:path), or a local path. The marketplace's own name (from its manifest) is used as the registration key. pub source: String, + /// Working directory used to resolve relative local paths in `source`. Defaults to the server's current working directory. + #[serde(skip_serializing_if = "Option::is_none")] + pub working_directory: Option, } /// Name of the marketplace whose plugin catalog to fetch. @@ -9601,7 +9948,7 @@ pub struct QueueRemoveMostRecentResult { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RegisterEventInterestParams { - /// The event type the consumer wants the runtime to treat as 'observed' for behavior-switching gating. Some runtime code paths inspect whether any consumer is interested in a specific event type and choose a different implementation accordingly (e.g. `mcp.oauth_required`: when interest is registered the runtime delegates OAuth token acquisition to the consumer; when no interest is registered OAuth-required servers become needs-auth). SDK clients that long-poll events do NOT automatically appear as listeners to these gating checks — they must explicitly call `registerInterest` for each event type they want the runtime to count as having a consumer. Multiple registrations for the same event type from the same or different consumers are tracked independently and must each be released. See: `mcp.oauth_required`, `sampling.requested`, `auto_mode_switch.requested`, `session_limits_exhausted.requested`, `user_input.requested`, `elicitation.requested`, `command.queued`, `exit_plan_mode.requested`. + /// The event type the consumer wants the runtime to treat as 'observed' for behavior-switching gating. Some runtime code paths inspect whether any consumer is interested in a specific event type and choose a different implementation accordingly (e.g. `mcp.oauth_required`: when interest is registered the runtime delegates interactive OAuth token acquisition to the consumer via `mcp.oauth_required` events; when no interest is registered the runtime still attempts non-interactive reconnect from cached or refreshable tokens, and only marks the server `needs-auth` if usable credentials are unavailable — it does not open a browser or start interactive OAuth without a consumer). SDK clients that long-poll events do NOT automatically appear as listeners to these gating checks — they must explicitly call `registerInterest` for each event type they want the runtime to count as having a consumer. Multiple registrations for the same event type from the same or different consumers are tracked independently and must each be released. See: `mcp.oauth_required`, `sampling.requested`, `auto_mode_switch.requested`, `session_limits_exhausted.requested`, `user_input.requested`, `elicitation.requested`, `command.queued`, `exit_plan_mode.requested`. pub event_type: String, } @@ -10301,6 +10648,89 @@ pub struct SendAttachmentsToMessageParams { pub instance_id: Option, } +/// A single user message to append to the session as part of a `session.sendMessages` turn +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SendMessageItem { + /// Optional attachments (files, directories, selections, blobs, GitHub references) to include with this message + #[serde(skip_serializing_if = "Option::is_none")] + pub attachments: Option>, + /// If false, this message will not trigger a Premium Request Unit charge. User messages default to billable. + #[doc(hidden)] + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) billable: Option, + /// If provided, this is shown in the timeline instead of `prompt` + #[serde(skip_serializing_if = "Option::is_none")] + pub display_prompt: Option, + /// The user message text + pub prompt: String, + /// If set, the request will fail if the named tool is not available when this message is among the user messages at the start of the current exchange + #[serde(skip_serializing_if = "Option::is_none")] + pub required_tool: Option, + /// Optional provenance tag copied to the resulting user.message event. Must match one of three forms: the literal `system`, `command-` for messages originating from a command (e.g. slash command, Mission Control command), or `schedule-` for messages originating from a scheduled job. + #[doc(hidden)] + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) source: Option, +} + +/// Parameters for sending zero or more user messages to the session in a single turn. Remote-backed (Mission Control) sessions do not support this method and will return an error. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SendMessagesRequest { + /// The UI mode the agent was in when these messages were sent. Defaults to the session's current mode. + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_mode: Option, + /// The user messages to append to the conversation, in order. May be empty, in which case a single turn runs over the existing history with no new user message. + pub messages: Vec, + /// How to deliver the messages. `enqueue` (default) appends to the message queue. `immediate` interjects during an in-progress turn. + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, + /// If true, adds the messages to the front of the queue instead of the end + #[serde(skip_serializing_if = "Option::is_none")] + pub prepend: Option, + /// Custom HTTP headers to include in outbound model requests for this turn. Merged with session-level provider headers; per-turn headers augment and overwrite session-level headers with the same key. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_headers: Option>, + /// W3C Trace Context traceparent header for distributed tracing of this agent turn + #[serde(skip_serializing_if = "Option::is_none")] + pub traceparent: Option, + /// W3C Trace Context tracestate header for distributed tracing + #[serde(skip_serializing_if = "Option::is_none")] + pub tracestate: Option, + /// If true, await completion of the agentic loop for this turn before returning. Defaults to false (fire-and-forget). When true, the result still contains the same `messageIds`; the caller can rely on the agent having processed the messages before the call resolves. + #[serde(skip_serializing_if = "Option::is_none")] + pub wait: Option, +} + +/// Result of sending zero or more user messages +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SendMessagesResult { + /// Unique identifiers assigned to the messages, one per provided message in order. Empty when no messages were provided. + pub message_ids: Vec, +} + /// Parameters for sending a user message to the session /// ///
@@ -11267,6 +11697,21 @@ pub struct SessionMetadataSnapshot { pub workspace_path: Option, } +/// Cost-category metadata for a CAPI model. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionModelPriceCategory { + pub id: String, + pub price_category: ModelPickerPriceCategory, +} + /// The list of models available to this session. /// ///
@@ -11280,6 +11725,9 @@ pub struct SessionMetadataSnapshot { pub struct SessionModelList { /// Available models, ordered with the most preferred default first. Includes both Copilot (CAPI) models and any registry BYOK models; a BYOK model appears under its provider-qualified selection id (`provider/id`). pub list: Vec, + /// Cost categories for the full CAPI catalog, including picker-disabled models that Auto may select. Metadata only; entries absent from `list` are not manually selectable. + #[serde(skip_serializing_if = "Option::is_none")] + pub model_price_categories: Option>, /// Per-quota snapshots returned alongside the model list, keyed by quota type. #[serde(skip_serializing_if = "Option::is_none")] pub quota_snapshots: Option>, @@ -11452,6 +11900,9 @@ pub struct SessionOpenOptions { /// Feature-flag values resolved by the host. #[serde(skip_serializing_if = "Option::is_none")] pub feature_flags: Option>, + /// Built-in subagent names to include in this session. When specified, only these built-ins are available, subject to runtime availability and exclusions. Custom agents with the same name remain available. + #[serde(skip_serializing_if = "Option::is_none")] + pub included_builtin_agents: Option>, /// Installed plugins visible to the session. #[serde(skip_serializing_if = "Option::is_none")] pub installed_plugins: Option>, @@ -12732,6 +13183,9 @@ pub struct SessionUpdateOptionsParams { /// Map of feature-flag IDs to their boolean enabled state. #[serde(skip_serializing_if = "Option::is_none")] pub feature_flags: Option>, + /// Built-in subagent names to include in this session. When specified, only these built-ins are available, subject to runtime availability and exclusions. Custom agents with the same name remain available. Set to null to remove the allowlist restriction. + #[serde(skip_serializing_if = "Option::is_none")] + pub included_builtin_agents: Option>, /// Full set of installed plugins for the session. Replaces the existing list; the runtime invalidates the skills cache only when the list materially changes. #[serde(skip_serializing_if = "Option::is_none")] pub installed_plugins: Option>, @@ -12826,6 +13280,9 @@ pub struct SessionUpdateOptionsParams { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionUpdateOptionsResult { + /// Number of hooks loaded from installed plugins, returned when installedPlugins is updated + #[serde(skip_serializing_if = "Option::is_none")] + pub plugin_hook_count: Option, /// Whether the operation succeeded pub success: bool, } @@ -13176,7 +13633,7 @@ pub struct SkillsLoadDiagnostics { pub warnings: Vec, } -/// Slash-command invocation result that submits an agent prompt, with display prompt, optional mode, and settings-change flag. +/// Slash-command invocation result that submits an agent prompt, with display prompt, optional mode, optional user-facing notice, and settings-change flag. /// ///
/// @@ -13194,6 +13651,9 @@ pub struct SlashCommandAgentPromptResult { /// Optional target session mode for the agent prompt #[serde(skip_serializing_if = "Option::is_none")] pub mode: Option, + /// Optional user-facing notice to show before the prompt is submitted + #[serde(skip_serializing_if = "Option::is_none")] + pub notice: Option, /// Prompt to submit to the agent pub prompt: String, /// True when the invocation mutated user runtime settings; consumers caching settings should refresh @@ -15496,6 +15956,21 @@ pub struct InstructionsGetDiscoveryPathsResult { pub paths: Vec, } +/// Slash commands available in the session, after applying any include/exclude filters. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CommandsListResult { + /// Commands available in this session + pub commands: Vec, +} + /// Result of opening a session. /// ///
@@ -15788,6 +16263,21 @@ pub struct SessionSendResult { pub message_id: String, } +/// Result of sending zero or more user messages +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionSendMessagesResult { + /// Unique identifiers assigned to the messages, one per provided message in order. Empty when no messages were provided. + pub message_ids: Vec, +} + /// Result of aborting the current turn /// ///
@@ -15969,6 +16459,9 @@ pub struct SessionCanvasOpenResult { /// Owning extension display name, when available #[serde(skip_serializing_if = "Option::is_none")] pub extension_name: Option, + /// Host-local PNG path for the canvas icon, when supplied + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, /// Input supplied when the instance was opened #[serde(skip_serializing_if = "Option::is_none")] pub input: Option, @@ -16082,6 +16575,9 @@ pub struct SessionModelSetReasoningEffortResult { pub struct SessionModelListResult { /// Available models, ordered with the most preferred default first. Includes both Copilot (CAPI) models and any registry BYOK models; a BYOK model appears under its provider-qualified selection id (`provider/id`). pub list: Vec, + /// Cost categories for the full CAPI catalog, including picker-disabled models that Auto may select. Metadata only; entries absent from `list` are not manually selectable. + #[serde(skip_serializing_if = "Option::is_none")] + pub model_price_categories: Option>, /// Per-quota snapshots returned alongside the model list, keyed by quota type. #[serde(skip_serializing_if = "Option::is_none")] pub quota_snapshots: Option>, @@ -17344,6 +17840,57 @@ pub struct SessionMcpAppsDiagnoseResult { pub server: McpAppsDiagnoseServer, } +/// Resource contents returned by the MCP server. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionMcpResourcesReadResult { + /// Resource contents returned by the server + pub contents: Vec, +} + +/// One page of resources advertised by the named MCP server. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionMcpResourcesListResult { + /// Opaque cursor for the next page, if the server has more resources + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + /// Resources advertised by the server (proxied MCP `resources/list`) + pub resources: Vec, +} + +/// One page of resource templates advertised by the named MCP server. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionMcpResourcesListTemplatesResult { + /// Opaque cursor for the next page, if the server has more resource templates + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + /// Resource templates advertised by the server (proxied MCP `resources/templates/list`) + pub resource_templates: Vec, +} + /// Identifies the target session. /// ///
@@ -17431,6 +17978,9 @@ pub struct SessionProviderAddResult { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionOptionsUpdateResult { + /// Number of hooks loaded from installed plugins, returned when installedPlugins is updated + #[serde(skip_serializing_if = "Option::is_none")] + pub plugin_hook_count: Option, /// Whether the operation succeeded pub success: bool, } @@ -18490,7 +19040,7 @@ pub struct SessionMetadataGetContextHeaviestMessagesResult { #[serde(rename_all = "camelCase")] pub struct SessionMetadataRecordContextChangeResult {} -/// Update the session's working directory. Used by the host when the user explicitly changes cwd (e.g., the `/cd` slash command). The host is responsible for `process.chdir` and any related side-effects (file index, etc.); this method only updates the session's own recorded path. +/// Update the session's working directory. Used by the host when the user explicitly changes cwd (e.g., the `/cd` slash command). The host is responsible for any related side-effects (file index, etc.); it does NOT change the process working directory (a session's cwd is per-session, not process-global). For local sessions the runtime validates the target first (an absolute path that exists on disk) and re-bases the permission primary directory; a rejected validation fails the call before anything is mutated, persisted, or emitted. Location-scoped permission rules are then re-keyed to the new directory (best-effort). Remote sessions only record the path. /// ///
/// @@ -20298,6 +20848,63 @@ pub enum HMACAuthInfoType { Hmac, } +/// Hook event name dispatched through the SDK callback transport. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum HookType { + /// Runs before a tool is invoked. + #[serde(rename = "preToolUse")] + PreToolUse, + /// Runs before an MCP tool is invoked. + #[serde(rename = "preMcpToolCall")] + PreMcpToolCall, + /// Runs after a tool completes successfully. + #[serde(rename = "postToolUse")] + PostToolUse, + /// Runs after a tool fails. + #[serde(rename = "postToolUseFailure")] + PostToolUseFailure, + /// Runs after the user submits a prompt. + #[serde(rename = "userPromptSubmitted")] + UserPromptSubmitted, + /// Runs when a session starts. + #[serde(rename = "sessionStart")] + SessionStart, + /// Runs when a session ends. + #[serde(rename = "sessionEnd")] + SessionEnd, + /// Runs after an agent result is produced. + #[serde(rename = "postResult")] + PostResult, + /// Runs before a pull request description is generated. + #[serde(rename = "prePRDescription")] + PrePRDescription, + /// Runs when the agent encounters an error. + #[serde(rename = "errorOccurred")] + ErrorOccurred, + /// Runs when the agent stops. + #[serde(rename = "agentStop")] + AgentStop, + /// Runs when a subagent starts. + #[serde(rename = "subagentStart")] + SubagentStart, + /// Runs when a subagent stops. + #[serde(rename = "subagentStop")] + SubagentStop, + /// Runs before conversation context is compacted. + #[serde(rename = "preCompact")] + PreCompact, + /// Runs when the agent requests permission. + #[serde(rename = "permissionRequest")] + PermissionRequest, + /// Runs when the agent emits a notification. + #[serde(rename = "notification")] + Notification, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + /// Constant value. Always "github". #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum InstalledPluginSourceGitHubSource { @@ -20722,6 +21329,28 @@ pub enum McpHeadersHandlePendingHeadersRefreshRequest { None(McpHeadersHandlePendingHeadersRefreshRequestNone), } +/// Consumer allowed to call an MCP tool. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum McpToolUiVisibility { + /// The model may call the tool. + #[serde(rename = "model")] + Model, + /// An MCP App view may call the tool. + #[serde(rename = "app")] + App, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum McpOauthPendingRequestResponseTokenKind { #[serde(rename = "token")] diff --git a/rust/src/generated/rpc.rs b/rust/src/generated/rpc.rs index b11a689fcf..403933a27c 100644 --- a/rust/src/generated/rpc.rs +++ b/rust/src/generated/rpc.rs @@ -43,6 +43,13 @@ impl<'a> ClientRpc<'a> { } } + /// `commands.*` sub-namespace. + pub fn commands(&self) -> ClientRpcCommands<'a> { + ClientRpcCommands { + client: self.client, + } + } + /// `instructions.*` sub-namespace. pub fn instructions(&self) -> ClientRpcInstructions<'a> { ClientRpcInstructions { @@ -457,6 +464,38 @@ impl<'a> ClientRpcAgents<'a> { } } +/// `commands.*` RPCs. +#[derive(Clone, Copy)] +pub struct ClientRpcCommands<'a> { + pub(crate) client: &'a Client, +} + +impl<'a> ClientRpcCommands<'a> { + /// Lists the well-known built-in slash commands that work as the first message in a new session (e.g. /plan, /env), without requiring an active session. Commands that depend on session state, authentication, or a synced session are omitted. + /// + /// Wire method: `commands.list`. + /// + /// # Returns + /// + /// Slash commands available in the session, after applying any include/exclude filters. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. Pin both the + /// SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn list(&self) -> Result { + let wire_params = serde_json::json!({}); + let _value = self + .client + .call(rpc_methods::COMMANDS_LIST, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } +} + /// `instructions.*` RPCs. #[derive(Clone, Copy)] pub struct ClientRpcInstructions<'a> { @@ -1136,7 +1175,7 @@ impl<'a> ClientRpcPluginsMarketplaces<'a> { /// /// # Parameters /// - /// * `params` - Marketplace source to register. + /// * `params` - Marketplace source and optional working directory for relative-path resolution. /// /// # Returns /// @@ -2848,6 +2887,39 @@ impl<'a> SessionRpc<'a> { Ok(serde_json::from_value(_value)?) } + /// Sends zero or more user messages to the session in a single turn and returns their message IDs. All provided messages are appended to the conversation in order, then exactly one agent turn runs over the resulting history. When the list is empty, one turn runs over the existing history with no new user message. Remote-backed (Mission Control) sessions do not support this method and will return an error. + /// + /// Wire method: `session.sendMessages`. + /// + /// # Parameters + /// + /// * `params` - Parameters for sending zero or more user messages to the session in a single turn. Remote-backed (Mission Control) sessions do not support this method and will return an error. + /// + /// # Returns + /// + /// Result of sending zero or more user messages + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. Pin both the + /// SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn send_messages( + &self, + params: SendMessagesRequest, + ) -> Result { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call(rpc_methods::SESSION_SENDMESSAGES, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } + /// Aborts the current agent turn. /// /// Wire method: `session.abort`. @@ -4245,6 +4317,13 @@ impl<'a> SessionRpcMcp<'a> { } } + /// `session.mcp.resources.*` sub-namespace. + pub fn resources(&self) -> SessionRpcMcpResources<'a> { + SessionRpcMcpResources { + session: self.session, + } + } + /// Lists MCP servers configured for the session, their connection status, and host-level state. The host-level state (disabled/filtered servers, failed/needs-auth/pending connections, mcp3p policy, full config) is empty/zero when no MCP host has been initialized for the session. /// /// Wire method: `session.mcp.list`. @@ -4270,7 +4349,7 @@ impl<'a> SessionRpcMcp<'a> { Ok(serde_json::from_value(_value)?) } - /// Lists the tools exposed by a connected MCP server on this session's host. + /// Lists the tools exposed by a connected MCP server on this session's host. This performs a live `tools/list` request. Tool UI metadata is returned independently of whether MCP Apps rendering is enabled for the session. /// /// Wire method: `session.mcp.listTools`. /// @@ -4569,13 +4648,13 @@ impl<'a> SessionRpcMcp<'a> { Ok(serde_json::from_value(_value)?) } - /// Starts an individual MCP server on the session's host. + /// Starts an individual MCP server on the live session from a caller-supplied config. Session-scoped and ephemeral: the server is added to this session's running set only and is reaped when the session ends. Does NOT modify persistent user configuration (`mcp.config.*`), so it does not affect future sessions. The server surfaces through `session.mcp.list` and the `session.mcp_servers_loaded` / `session.mcp_server_status_changed` events like any other server. /// /// Wire method: `session.mcp.startServer`. /// /// # Parameters /// - /// * `params` - Server name and opaque configuration for an individual MCP server start. + /// * `params` - Server name and configuration for an individual MCP server start. /// ///
/// @@ -4584,7 +4663,7 @@ impl<'a> SessionRpcMcp<'a> { /// SDK and CLI versions if your code depends on it. /// ///
- pub(crate) async fn start_server(&self, params: McpStartServerRequest) -> Result<(), Error> { + pub async fn start_server(&self, params: McpStartServerRequest) -> Result<(), Error> { let mut wire_params = serde_json::to_value(params)?; wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); let _value = self @@ -4595,13 +4674,13 @@ impl<'a> SessionRpcMcp<'a> { Ok(()) } - /// Restarts an individual MCP server on the session's host (stops then starts). + /// Restarts an individual MCP server on the live session (stops then starts). Omit `config` for a config-free restart-by-name of an already-configured server; supply `config` to restart with a replacement configuration. Session-scoped and ephemeral: does NOT modify persistent user configuration (`mcp.config.*`). /// /// Wire method: `session.mcp.restartServer`. /// /// # Parameters /// - /// * `params` - Server name and opaque configuration for an individual MCP server restart. + /// * `params` - Server name and optional replacement configuration for an individual MCP server restart. Omit `config` for a config-free restart-by-name of an already-configured server. /// ///
/// @@ -4610,10 +4689,7 @@ impl<'a> SessionRpcMcp<'a> { /// SDK and CLI versions if your code depends on it. /// ///
- pub(crate) async fn restart_server( - &self, - params: McpRestartServerRequest, - ) -> Result<(), Error> { + pub async fn restart_server(&self, params: McpRestartServerRequest) -> Result<(), Error> { let mut wire_params = serde_json::to_value(params)?; wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); let _value = self @@ -5069,6 +5145,116 @@ impl<'a> SessionRpcMcpOauth<'a> { } } +/// `session.mcp.resources.*` RPCs. +#[derive(Clone, Copy)] +pub struct SessionRpcMcpResources<'a> { + pub(crate) session: &'a Session, +} + +impl<'a> SessionRpcMcpResources<'a> { + /// Fetch an MCP resource from a connected server by URI (proxies MCP `resources/read`). + /// + /// Wire method: `session.mcp.resources.read`. + /// + /// # Parameters + /// + /// * `params` - MCP server and resource URI to fetch. + /// + /// # Returns + /// + /// Resource contents returned by the MCP server. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. Pin both the + /// SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn read( + &self, + params: McpResourcesReadRequest, + ) -> Result { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call(rpc_methods::SESSION_MCP_RESOURCES_READ, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } + + /// Enumerate one page of resources a connected MCP server exposes (proxies MCP `resources/list`). Pass `cursor` to continue from a prior result's `nextCursor`. + /// + /// Wire method: `session.mcp.resources.list`. + /// + /// # Parameters + /// + /// * `params` - MCP server whose resources to enumerate. + /// + /// # Returns + /// + /// One page of resources advertised by the named MCP server. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. Pin both the + /// SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn list( + &self, + params: McpResourcesListRequest, + ) -> Result { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call(rpc_methods::SESSION_MCP_RESOURCES_LIST, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } + + /// Enumerate one page of resource templates a connected MCP server exposes (proxies MCP `resources/templates/list`). Pass `cursor` to continue from a prior result's `nextCursor`. + /// + /// Wire method: `session.mcp.resources.listTemplates`. + /// + /// # Parameters + /// + /// * `params` - MCP server whose resource templates to enumerate. + /// + /// # Returns + /// + /// One page of resource templates advertised by the named MCP server. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. Pin both the + /// SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn list_templates( + &self, + params: McpResourcesListTemplatesRequest, + ) -> Result { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call( + rpc_methods::SESSION_MCP_RESOURCES_LISTTEMPLATES, + Some(wire_params), + ) + .await?; + Ok(serde_json::from_value(_value)?) + } +} + /// `session.metadata.*` RPCs. #[derive(Clone, Copy)] pub struct SessionRpcMetadata<'a> { @@ -5287,17 +5473,17 @@ impl<'a> SessionRpcMetadata<'a> { Ok(serde_json::from_value(_value)?) } - /// Updates the session's recorded working directory. + /// Updates the session's working directory. For local sessions the target is validated first (an absolute path that exists on disk) and the permission primary directory is re-based; a rejected validation fails the call before any session state changes. /// /// Wire method: `session.metadata.setWorkingDirectory`. /// /// # Parameters /// - /// * `params` - Absolute path to set as the session's new working directory. + /// * `params` - Absolute path to set as the session's new working directory. For local sessions the path must be absolute and exist on disk: it is validated before any session state changes, and a failing validation rejects the call with nothing mutated, persisted, or emitted. Remote sessions record the path as-is. /// /// # Returns /// - /// Update the session's working directory. Used by the host when the user explicitly changes cwd (e.g., the `/cd` slash command). The host is responsible for `process.chdir` and any related side-effects (file index, etc.); this method only updates the session's own recorded path. + /// Update the session's working directory. Used by the host when the user explicitly changes cwd (e.g., the `/cd` slash command). The host is responsible for any related side-effects (file index, etc.); it does NOT change the process working directory (a session's cwd is per-session, not process-global). For local sessions the runtime validates the target first (an absolute path that exists on disk) and re-bases the permission primary directory; a rejected validation fails the call before anything is mutated, persisted, or emitted. Location-scoped permission rules are then re-keyed to the new directory (best-effort). Remote sessions only record the path. /// ///
/// diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index 4e073d45bc..cf670c4856 100644 --- a/rust/src/generated/session_events.rs +++ b/rust/src/generated/session_events.rs @@ -77,6 +77,8 @@ pub enum SessionEventType { AssistantTurnStart, #[serde(rename = "assistant.intent")] AssistantIntent, + #[serde(rename = "assistant.server_tool_progress")] + AssistantServerToolProgress, #[serde(rename = "assistant.reasoning")] AssistantReasoning, #[serde(rename = "assistant.reasoning_delta")] @@ -186,6 +188,24 @@ pub enum SessionEventType { SessionLimitsExhaustedRequested, #[serde(rename = "session_limits_exhausted.completed")] SessionLimitsExhaustedCompleted, + /// + ///
+ /// + /// **Experimental.** This type is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. + /// + ///
+ #[serde(rename = "session.auto_mode_resolved")] + SessionAutoModeResolved, + /// + ///
+ /// + /// **Experimental.** This type is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. + /// + ///
+ #[serde(rename = "session.managed_settings_resolved")] + SessionManagedSettingsResolved, #[serde(rename = "commands.changed")] CommandsChanged, #[serde(rename = "capabilities.changed")] @@ -206,6 +226,12 @@ pub enum SessionEventType { SessionMcpServersLoaded, #[serde(rename = "session.mcp_server_status_changed")] SessionMcpServerStatusChanged, + #[serde(rename = "mcp.tools.list_changed")] + McpToolsListChanged, + #[serde(rename = "mcp.resources.list_changed")] + McpResourcesListChanged, + #[serde(rename = "mcp.prompts.list_changed")] + McpPromptsListChanged, #[serde(rename = "session.extensions_loaded")] SessionExtensionsLoaded, /// @@ -344,6 +370,8 @@ pub enum SessionEventData { AssistantTurnStart(AssistantTurnStartData), #[serde(rename = "assistant.intent")] AssistantIntent(AssistantIntentData), + #[serde(rename = "assistant.server_tool_progress")] + AssistantServerToolProgress(AssistantServerToolProgressData), #[serde(rename = "assistant.reasoning")] AssistantReasoning(AssistantReasoningData), #[serde(rename = "assistant.reasoning_delta")] @@ -446,6 +474,24 @@ pub enum SessionEventData { SessionLimitsExhaustedRequested(SessionLimitsExhaustedRequestedData), #[serde(rename = "session_limits_exhausted.completed")] SessionLimitsExhaustedCompleted(SessionLimitsExhaustedCompletedData), + /// + ///
+ /// + /// **Experimental.** This type is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. + /// + ///
+ #[serde(rename = "session.auto_mode_resolved")] + SessionAutoModeResolved(SessionAutoModeResolvedData), + /// + ///
+ /// + /// **Experimental.** This type is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. + /// + ///
+ #[serde(rename = "session.managed_settings_resolved")] + SessionManagedSettingsResolved(SessionManagedSettingsResolvedData), #[serde(rename = "commands.changed")] CommandsChanged(CommandsChangedData), #[serde(rename = "capabilities.changed")] @@ -466,6 +512,12 @@ pub enum SessionEventData { SessionMcpServersLoaded(SessionMcpServersLoadedData), #[serde(rename = "session.mcp_server_status_changed")] SessionMcpServerStatusChanged(SessionMcpServerStatusChangedData), + #[serde(rename = "mcp.tools.list_changed")] + McpToolsListChanged(McpToolsListChangedData), + #[serde(rename = "mcp.resources.list_changed")] + McpResourcesListChanged(McpResourcesListChangedData), + #[serde(rename = "mcp.prompts.list_changed")] + McpPromptsListChanged(McpPromptsListChangedData), #[serde(rename = "session.extensions_loaded")] SessionExtensionsLoaded(SessionExtensionsLoadedData), /// @@ -1431,6 +1483,18 @@ pub struct AssistantIntentData { pub intent: String, } +/// Session event "assistant.server_tool_progress". Live progress signal for a provider-hosted server tool (e.g. hosted web search) while it runs, before the finalized serverTools envelope lands on the terminal assistant.message +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssistantServerToolProgressData { + /// Kind of hosted server tool that is running. Only `web_search` is emitted today. + pub kind: String, + /// Position of the hosted tool call in the response output. Stable across the call's lifecycle events (unlike the provider's per-event item id, which CAPI rotates), so the host keys the live in-progress row on it. + pub output_index: i64, + /// Lifecycle status of the hosted call: `in_progress`, `searching`, or `completed`. + pub status: String, +} + /// Session event "assistant.reasoning". Assistant reasoning content for timeline display with complete thinking text #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1628,6 +1692,9 @@ pub struct AssistantMessageData { ///
#[serde(skip_serializing_if = "Option::is_none")] pub citations: Option, + /// Client-minted request id (x-request-id header) echoed by the server. Distinct from requestId (x-github-request-id) and serviceRequestId (x-copilot-service-request-id). + #[serde(skip_serializing_if = "Option::is_none")] + pub client_request_id: Option, /// The assistant's text response content pub content: String, /// Encrypted reasoning content from OpenAI models. Session-bound and stripped on resume. @@ -2383,6 +2450,16 @@ pub struct ToolExecutionCompleteResult { /// Full detailed tool result for UI/timeline display, preserving complete content such as diffs. Falls back to content when absent. #[serde(skip_serializing_if = "Option::is_none")] pub detailed_content: Option, + /// FIDES IFC label projected from tool ingress metadata (MCP `CallToolResult._meta` or synthesized built-in ingress labels) — persisted as `{ ifc: ... }` (only the `ifc` key, not the whole `_meta`). Persisted so the FIDES IFC label survives session resume: the engine rehydrates accumulated taint by replaying these on load. Populated for ingress sources when FIDES IFC is on. Experimental. + /// + ///
+ /// + /// **Experimental.** This type is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. + /// + ///
+ #[serde(skip_serializing_if = "Option::is_none")] + pub mcp_meta: Option, /// Structured content (arbitrary JSON) returned verbatim by the MCP tool #[serde(skip_serializing_if = "Option::is_none")] pub structured_content: Option, @@ -2439,6 +2516,16 @@ pub struct ToolExecutionCompleteData { /// Whether this tool call was explicitly requested by the user rather than the assistant #[serde(skip_serializing_if = "Option::is_none")] pub is_user_requested: Option, + /// FIDES IFC label projected from tool ingress metadata (MCP `CallToolResult._meta` or synthesized built-in ingress labels). Persisted as `{ ifc: ... }` so the label survives session resume, including model-visible failure results. Experimental. + /// + ///
+ /// + /// **Experimental.** This type is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. + /// + ///
+ #[serde(skip_serializing_if = "Option::is_none")] + pub mcp_meta: Option, /// Model identifier that generated this tool call #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, @@ -3600,6 +3687,29 @@ pub struct SamplingCompletedData { pub request_id: RequestId, } +/// Single HTTP header entry as a name/value pair. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HeaderEntry { + /// HTTP response header name as observed by the runtime. + pub name: String, + /// HTTP response header value as observed by the runtime. + pub value: String, +} + +/// Raw HTTP response details from the OAuth auth challenge, as observed by the runtime. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpOauthHttpResponse { + /// Complete UTF-8 response body for host-specific challenge handling, including an empty string for an empty body. Omitted when the complete body is not valid UTF-8; body read failures fail the HTTP operation rather than exposing a partial response. + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + /// HTTP response headers as observed by the runtime. Order and casing are transport-dependent, and duplicate header names may appear multiple times. + pub headers: Vec, + /// HTTP status code returned with the auth challenge. + pub status_code: i32, +} + /// Static OAuth client configuration, if the server specifies one #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -3636,6 +3746,9 @@ pub struct McpOauthWWWAuthenticateParams { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct McpOauthRequiredData { + /// Raw HTTP response details from the OAuth auth challenge, as observed by the runtime. Header order and casing are transport-dependent, and duplicate header names may appear multiple times. + #[serde(skip_serializing_if = "Option::is_none")] + pub http_response: Option, /// Why the runtime is requesting host-provided OAuth credentials. pub reason: McpOauthRequestReason, /// Unique identifier for this OAuth request; used to respond via session.mcp.oauth.handlePendingRequest @@ -3833,6 +3946,64 @@ pub struct SessionLimitsExhaustedCompletedData { pub response: SessionLimitsExhaustedResponse, } +/// Session event "session.auto_mode_resolved". Auto Intent resolution: the concrete model the session settled on for the first prompt of an auto-mode session, and why. Lets SDK clients render the chosen model and the full reason it was picked. The core selection fields (chosenModel/reasoningBucket/categoryScores) are stable; the routing-analytics fields (predictedLabel/confidence/candidateModels) mirror the upstream intent service and may evolve, hence the event's experimental stability. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionAutoModeResolvedData { + /// Ordered candidate model list the router returned, when not a fallback + #[serde(skip_serializing_if = "Option::is_none")] + pub candidate_models: Option>, + /// Per-category classifier scores (0-1) behind the bucket: the granular HYDRA capability scores (reasoning, code_gen, debugging, tool_use), or the binary needs_reasoning/no_reasoning scores when HYDRA didn't run. Lets clients show a breakdown rather than just the bucket. + #[serde(skip_serializing_if = "Option::is_none")] + pub category_scores: Option>, + /// The concrete model the session will use after any intent refinement + pub chosen_model: String, + /// Classifier confidence for the predicted label, when available + #[serde(skip_serializing_if = "Option::is_none")] + pub confidence: Option, + /// The predicted classifier label (e.g. `needs_reasoning`), when available + #[serde(skip_serializing_if = "Option::is_none")] + pub predicted_label: Option, + /// Coarse request-difficulty bucket, for explaining why a model was chosen ("picked X because this looks like high-reasoning work") + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_bucket: Option, +} + +/// Session event "session.managed_settings_resolved". Enterprise managed-settings resolution: the effective managed settings the session applied and where they came from, so SDK clients can show users what is enterprise-managed and by which authority. Fires whenever managed policy is (re)applied — at session start, on resume, and on account switch. This is an ephemeral live snapshot (delivered to subscribers but not persisted to the session event log), because at session start it resolves before `session.start` is emitted; for a session-independent pull, use the SDK `getManagedSettings()` API, which returns the identical payload. Managed settings have a single authoritative source, so the highest-authority present layer (server > device) wins wholesale; `bypassPermissionsDisabled` is deny-wins across layers. Marked experimental while the managed-settings surface stabilizes. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionManagedSettingsResolvedData { + /// Whether enterprise policy disables bypass-permissions ("yolo") mode for this session. Deny-wins across layers, and forced on when `failClosed` is true. + pub bypass_permissions_disabled: bool, + /// Whether the device (MDM/plist/registry/file) managed-settings layer was present + pub device_managed: bool, + /// Whether managed policy could not be determined (e.g. a failed server fetch) and the session fell back to the fail-closed restriction. When true, restrictions such as disabling bypass-permissions are enforced even though `settings` may be absent. + pub fail_closed: bool, + /// The setting keys under enterprise management in the effective managed settings (e.g. `model`, `enabledPlugins`, `permissions`). Empty when no managed settings are in force. + pub managed_keys: Vec, + /// Whether the server (account/org) managed-settings layer was present + pub server_managed: bool, + /// The effective (resolved) managed settings values, so clients can render exactly what is enforced. Absent when no managed policy is in force. + #[serde(skip_serializing_if = "Option::is_none")] + pub settings: Option, + /// Which channel supplied the effective managed settings (the winning layer), or `none` when no policy is in force + pub source: ManagedSettingsResolvedSource, +} + /// A single slash command available in the session, as listed by the `commands.changed` event. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -4036,6 +4207,30 @@ pub struct SessionMcpServerStatusChangedData { pub status: McpServerStatus, } +/// Session event "mcp.tools.list_changed". Payload identifying the MCP server associated with a list change. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpToolsListChangedData { + /// Name of the MCP server whose list changed + pub server_name: String, +} + +/// Session event "mcp.resources.list_changed". Payload identifying the MCP server associated with a list change. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpResourcesListChangedData { + /// Name of the MCP server whose list changed + pub server_name: String, +} + +/// Session event "mcp.prompts.list_changed". Payload identifying the MCP server associated with a list change. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpPromptsListChangedData { + /// Name of the MCP server whose list changed + pub server_name: String, +} + /// A single extension discovered by `session.extensions_loaded`, including qualified ID, source, and current status. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -4058,7 +4253,7 @@ pub struct SessionExtensionsLoadedData { pub extensions: Vec, } -/// Session event "session.canvas.opened". Payload of `session.canvas.opened` with canvas instance and provider IDs plus optional title, status, URL, and input. +/// Session event "session.canvas.opened". Payload of `session.canvas.opened` with canvas instance and provider IDs plus optional icon, title, status, URL, and input. /// ///
/// @@ -4076,6 +4271,9 @@ pub struct SessionCanvasOpenedData { /// Owning extension display name, when available #[serde(skip_serializing_if = "Option::is_none")] pub extension_name: Option, + /// Host-local PNG path for the canvas icon, when supplied + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, /// Input supplied when the instance was opened #[serde(skip_serializing_if = "Option::is_none")] pub input: Option, @@ -4138,6 +4336,9 @@ pub struct CanvasRegistryChangedCanvas { /// Owning extension display name, when available #[serde(skip_serializing_if = "Option::is_none")] pub extension_name: Option, + /// Host-local PNG path for the canvas icon, when supplied + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, /// JSON Schema for canvas open input #[serde(skip_serializing_if = "Option::is_none")] pub input_schema: Option, @@ -5439,6 +5640,42 @@ pub enum SessionLimitsExhaustedResponseAction { Unknown, } +/// Coarse request-difficulty bucket for UX explainability +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum AutoModeResolvedReasoningBucket { + /// The request looks low-reasoning; a lighter model is appropriate. + #[serde(rename = "low")] + Low, + /// The request needs a moderate amount of reasoning. + #[serde(rename = "medium")] + Medium, + /// The request looks high-reasoning; a stronger model is appropriate. + #[serde(rename = "high")] + High, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + +/// Which channel supplied the effective enterprise managed settings (highest-authority present layer wins wholesale) +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ManagedSettingsResolvedSource { + /// Account/org policy self-fetched from the GitHub managed-settings endpoint (higher authority). + #[serde(rename = "server")] + Server, + /// Device-level MDM policy discovered from plist/registry/file (lower authority). + #[serde(rename = "device")] + Device, + /// No managed policy is in force (no layer contributed). + #[serde(rename = "none")] + None, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + /// Exit plan mode action #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum ExitPlanModeAction { diff --git a/rust/src/hooks.rs b/rust/src/hooks.rs index ec8cdfa3a3..0c3d64076f 100644 --- a/rust/src/hooks.rs +++ b/rust/src/hooks.rs @@ -27,8 +27,8 @@ pub struct HookContext { pub struct PreToolUseInput { /// The runtime session ID of the session that triggered the hook. pub session_id: String, - /// Unix timestamp (ms). - pub timestamp: i64, + /// Unix timestamp in ms (the runtime serializes this as a JSON float). + pub timestamp: f64, /// Working directory. #[serde(rename = "cwd")] pub working_directory: PathBuf, @@ -65,8 +65,8 @@ pub struct PreToolUseOutput { pub struct PreMcpToolCallInput { /// The runtime session ID of the session that triggered the hook. pub session_id: String, - /// Unix timestamp (ms). - pub timestamp: i64, + /// Unix timestamp in ms (the runtime serializes this as a JSON float). + pub timestamp: f64, /// Working directory. #[serde(rename = "cwd")] pub working_directory: PathBuf, @@ -104,8 +104,8 @@ pub struct PreMcpToolCallOutput { pub struct PostToolUseInput { /// The runtime session ID of the session that triggered the hook. pub session_id: String, - /// Unix timestamp (ms). - pub timestamp: i64, + /// Unix timestamp in ms (the runtime serializes this as a JSON float). + pub timestamp: f64, /// Working directory. #[serde(rename = "cwd")] pub working_directory: PathBuf, @@ -144,8 +144,8 @@ pub struct PostToolUseOutput { pub struct PostToolUseFailureInput { /// The runtime session ID of the session that triggered the hook. pub session_id: String, - /// Unix timestamp (ms). - pub timestamp: i64, + /// Unix timestamp in ms (the runtime serializes this as a JSON float). + pub timestamp: f64, /// Working directory. #[serde(rename = "cwd")] pub working_directory: PathBuf, @@ -175,8 +175,8 @@ pub struct PostToolUseFailureOutput { pub struct UserPromptSubmittedInput { /// The runtime session ID of the session that triggered the hook. pub session_id: String, - /// Unix timestamp (ms). - pub timestamp: i64, + /// Unix timestamp in ms (the runtime serializes this as a JSON float). + pub timestamp: f64, /// Working directory. #[serde(rename = "cwd")] pub working_directory: PathBuf, @@ -205,8 +205,8 @@ pub struct UserPromptSubmittedOutput { pub struct SessionStartInput { /// The runtime session ID of the session that triggered the hook. pub session_id: String, - /// Unix timestamp (ms). - pub timestamp: i64, + /// Unix timestamp in ms (the runtime serializes this as a JSON float). + pub timestamp: f64, /// Working directory. #[serde(rename = "cwd")] pub working_directory: PathBuf, @@ -235,8 +235,8 @@ pub struct SessionStartOutput { pub struct SessionEndInput { /// The runtime session ID of the session that triggered the hook. pub session_id: String, - /// Unix timestamp (ms). - pub timestamp: i64, + /// Unix timestamp in ms (the runtime serializes this as a JSON float). + pub timestamp: f64, /// Working directory. #[serde(rename = "cwd")] pub working_directory: PathBuf, @@ -271,8 +271,8 @@ pub struct SessionEndOutput { pub struct ErrorOccurredInput { /// The runtime session ID of the session that triggered the hook. pub session_id: String, - /// Unix timestamp (ms). - pub timestamp: i64, + /// Unix timestamp in ms (the runtime serializes this as a JSON float). + pub timestamp: f64, /// Working directory. #[serde(rename = "cwd")] pub working_directory: PathBuf, diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 0333281f59..65d9dab215 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -10,6 +10,9 @@ mod canvas_dispatch; #[cfg(feature = "bundled-cli")] pub(crate) mod embeddedcli; mod errors; +/// In-process FFI transport hosting the runtime cdylib (`Transport::InProcess`). +#[cfg(feature = "bundled-in-process")] +pub(crate) mod ffi; pub use errors::*; /// Connection-level Copilot request handler — intercept and replace the /// model-layer HTTP and WebSocket traffic the runtime issues for both CAPI and @@ -73,7 +76,11 @@ use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; use async_trait::async_trait; -// JSON-RPC wire types are internal transport details (like Go SDK's internal/jsonrpc2/). +/// Re-export of [`indexmap::IndexMap`], used for order-preserving maps in the +/// public API (e.g. [`Tool::parameters`](types::Tool::parameters) and +/// `SessionConfig::mcp_servers`) so serialized key order stays deterministic. +pub use indexmap::IndexMap; +// JSON-RPC wire types are internal transport details. // External callers interact via Client/Session methods, not raw RPC. pub(crate) use jsonrpc::{ JsonRpcClient, JsonRpcError, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, error_codes, @@ -109,9 +116,25 @@ const RUNTIME_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10); #[derive(Debug, Default)] #[non_exhaustive] pub enum Transport { - /// Communicate over stdin/stdout pipes (default). + /// Resolve the transport from `COPILOT_SDK_DEFAULT_CONNECTION`, falling + /// back to [`Transport::Stdio`] when the variable is unset. #[default] + Default, + /// Communicate over stdin/stdout pipes (default). Stdio, + /// Host the runtime in-process over FFI (no child process). + /// + /// Loads the native runtime library and speaks JSON-RPC over its C ABI. + /// This is **experimental**. Per-client [`ClientOptions::program`], + /// [`ClientOptions::extra_args`], [`ClientOptions::working_directory`], + /// [`ClientOptions::env`]/[`ClientOptions::env_remove`], + /// and [`ClientOptions::telemetry`] are not supported because native + /// runtime code shares the host process. Typed runtime options such as + /// authentication, log level, and [`ClientOptions::base_directory`] remain + /// supported. + /// + /// Requires the `bundled-in-process` Cargo feature. + InProcess, /// Spawn the CLI with `--port` and connect via TCP. Tcp { /// Port to listen on (0 for OS-assigned). @@ -203,17 +226,19 @@ pub fn install_bundled_cli() -> Option { /// This skips auto-resolution entirely. #[non_exhaustive] pub struct ClientOptions { - /// How to locate the CLI binary. + /// How to locate the child-process runtime. pub program: CliProgram, /// Arguments prepended before `--server` (e.g. the script path for node). pub prefix_args: Vec, /// Working directory for the CLI process. + /// + /// Setting this option is not supported with [`Transport::InProcess`]. pub working_directory: PathBuf, /// Environment variables set on the child process. pub env: Vec<(OsString, OsString)>, /// Environment variable names to remove from the child process. pub env_remove: Vec, - /// Extra CLI flags appended after the transport-specific arguments. + /// Extra flags for child-process transports. pub extra_args: Vec, /// Transport mode used to communicate with the CLI server. pub transport: Transport, @@ -589,7 +614,7 @@ impl Default for ClientOptions { Self { program: CliProgram::Resolve, prefix_args: Vec::new(), - working_directory: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + working_directory: PathBuf::new(), env: Vec::new(), env_remove: Vec::new(), extra_args: Vec::new(), @@ -632,7 +657,7 @@ impl ClientOptions { Self::default() } - /// How to locate the CLI binary. See [`CliProgram`]. + /// How to locate the child-process runtime. See [`CliProgram`]. pub fn with_program(mut self, program: impl Into) -> Self { self.program = program.into(); self @@ -850,6 +875,85 @@ fn generate_connection_token() -> String { hex } +/// Environment variable that overrides the transport used when the caller +/// leaves [`ClientOptions::transport`] at [`Transport::Default`]. +/// Accepts `"inprocess"` or `"stdio"` (case-insensitive); unset preserves +/// stdio. Any other value is an error. +const DEFAULT_CONNECTION_ENV_VAR: &str = "COPILOT_SDK_DEFAULT_CONNECTION"; + +/// Resolve a transport override from [`DEFAULT_CONNECTION_ENV_VAR`]. +fn resolve_default_transport(options: &ClientOptions) -> Result { + let configured = options + .env + .iter() + .find(|(key, _)| { + key.to_string_lossy() + .eq_ignore_ascii_case(DEFAULT_CONNECTION_ENV_VAR) + }) + .map(|(_, value)| value.to_string_lossy().into_owned()); + let process = std::env::var(DEFAULT_CONNECTION_ENV_VAR).ok(); + resolve_default_transport_value(configured.as_deref().or(process.as_deref())) +} + +fn resolve_default_transport_value(value: Option<&str>) -> Result { + match value { + None => Ok(Transport::Stdio), + Some(v) if v.is_empty() || v.eq_ignore_ascii_case("stdio") => Ok(Transport::Stdio), + Some(v) if v.eq_ignore_ascii_case("inprocess") => Ok(Transport::InProcess), + Some(v) => Err(Error::with_message( + ErrorKind::InvalidConfig, + format!( + "invalid {DEFAULT_CONNECTION_ENV_VAR} value '{v}'. \ + Expected 'inprocess', 'stdio', or unset." + ), + )), + } +} + +#[cfg(any(feature = "bundled-in-process", test))] +fn validate_inprocess_options(options: &ClientOptions) -> Result<()> { + if !matches!(&options.program, CliProgram::Resolve) { + return Err(Error::with_message( + ErrorKind::InvalidConfig, + "ClientOptions::program is not supported with Transport::InProcess; \ + set COPILOT_CLI_PATH only when using an externally provisioned runtime package", + )); + } + if !options.extra_args.is_empty() { + return Err(Error::with_message( + ErrorKind::InvalidConfig, + "ClientOptions::extra_args is not supported with Transport::InProcess; \ + use typed client options instead", + )); + } + + let unsupported = if !options.working_directory.as_os_str().is_empty() { + Some("working_directory") + } else if !options.env.is_empty() { + Some("env") + } else if !options.env_remove.is_empty() { + Some("env_remove") + } else if options.telemetry.is_some() { + Some("telemetry") + } else if !options.prefix_args.is_empty() { + Some("prefix_args") + } else { + None + }; + + if let Some(option) = unsupported { + return Err(Error::with_message( + ErrorKind::InvalidConfig, + format!( + "ClientOptions::{option} is not supported with Transport::InProcess; \ + configure process-global settings on the host process instead" + ), + )); + } + + Ok(()) +} + /// Connection to a GitHub Copilot CLI server (stdio, TCP, or external). /// /// Cheaply cloneable — cloning shares the underlying connection. @@ -870,6 +974,10 @@ impl std::fmt::Debug for Client { struct ClientInner { child: parking_lot::Mutex>, + #[cfg(feature = "bundled-in-process")] + /// In-process FFI runtime host, set only for [`Transport::InProcess`]. + /// Closing it tears down the native runtime connection. + ffi_host: parking_lot::Mutex>>, rpc: JsonRpcClient, cwd: PathBuf, request_rx: parking_lot::Mutex>>, @@ -916,6 +1024,21 @@ impl Client { /// backend. pub async fn start(options: ClientOptions) -> Result { let start_time = Instant::now(); + let mut options = options; + if matches!(options.transport, Transport::Default) { + options.transport = resolve_default_transport(&options)?; + } + if matches!(options.transport, Transport::InProcess) { + #[cfg(not(feature = "bundled-in-process"))] + { + return Err(Error::with_message( + ErrorKind::InvalidConfig, + "Transport::InProcess requires the `bundled-in-process` Cargo feature", + )); + } + #[cfg(feature = "bundled-in-process")] + validate_inprocess_options(&options)?; + } if options.mode == ClientMode::Empty && options.base_directory.is_none() && options.session_fs.is_none() @@ -970,9 +1093,9 @@ impl Client { // to the server. For Tcp, the SDK auto-generates one when the // caller leaves it unset so the loopback listener is safe by // default. - let mut options = options; let effective_connection_token: Option = match &mut options.transport { - Transport::Stdio => None, + Transport::Default => unreachable!("default transport resolved above"), + Transport::Stdio | Transport::InProcess => None, Transport::Tcp { connection_token, .. } => Some( @@ -1016,8 +1139,17 @@ impl Client { resolved } }; + let working_directory = { + let cwd = options.working_directory.clone(); + if cwd.as_os_str().is_empty() { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) + } else { + cwd + } + }; let client = match options.transport { + Transport::Default => unreachable!("default transport resolved above"), Transport::External { ref host, port, @@ -1037,7 +1169,7 @@ impl Client { reader, writer, None, - options.working_directory, + working_directory, options.on_list_models, session_fs_config.is_some(), session_fs_sqlite_declared, @@ -1051,7 +1183,8 @@ impl Client { port, connection_token: _, } => { - let (mut child, actual_port) = Self::spawn_tcp(&program, &options, port).await?; + let (mut child, actual_port) = + Self::spawn_tcp(&program, &options, &working_directory, port).await?; let connect_start = Instant::now(); let stream = TcpStream::connect(("127.0.0.1", actual_port)).await?; debug!( @@ -1065,7 +1198,7 @@ impl Client { reader, writer, Some(child), - options.working_directory, + working_directory, options.on_list_models, session_fs_config.is_some(), session_fs_sqlite_declared, @@ -1076,7 +1209,7 @@ impl Client { )? } Transport::Stdio => { - let mut child = Self::spawn_stdio(&program, &options)?; + let mut child = Self::spawn_stdio(&program, &options, &working_directory)?; let stdin = child.stdin.take().expect("stdin is piped"); let stdout = child.stdout.take().expect("stdout is piped"); Self::drain_stderr(&mut child); @@ -1084,7 +1217,7 @@ impl Client { stdout, stdin, Some(child), - options.working_directory, + working_directory, options.on_list_models, session_fs_config.is_some(), session_fs_sqlite_declared, @@ -1094,8 +1227,69 @@ impl Client { options.mode, )? } + Transport::InProcess => { + #[cfg(feature = "bundled-in-process")] + { + info!(runtime_path = %program.display(), "hosting copilot runtime in-process (FFI)"); + let mut environment = Vec::new(); + if let Some(base_directory) = &options.base_directory { + let value = base_directory.to_str().ok_or_else(|| { + Error::with_message( + ErrorKind::InvalidConfig, + "base_directory must be valid UTF-8 for Transport::InProcess", + ) + })?; + environment.push(("COPILOT_HOME".to_string(), value.to_string())); + } + if options.mode == ClientMode::Empty { + environment.push(("COPILOT_DISABLE_KEYTAR".to_string(), "1".to_string())); + } + if let Some(github_token) = &options.github_token { + environment + .push(("COPILOT_SDK_AUTH_TOKEN".to_string(), github_token.clone())); + } + let mut args = Vec::new(); + args.extend( + Self::log_level_args(&options) + .into_iter() + .map(str::to_string), + ); + args.extend(Self::session_idle_timeout_args(&options)); + args.extend(Self::remote_args(&options)); + if options.github_token.is_some() { + args.extend([ + "--auth-token-env".to_string(), + "COPILOT_SDK_AUTH_TOKEN".to_string(), + ]); + } + let use_logged_in_user = options + .use_logged_in_user + .unwrap_or(options.github_token.is_none()); + if !use_logged_in_user { + args.push("--no-auto-login".to_string()); + } + let host = crate::ffi::FfiHost::create(&program, environment, args)?; + let (reader, writer, shared) = host.start().await?; + let client = Self::from_transport( + reader, + writer, + None, + working_directory, + options.on_list_models, + session_fs_config.is_some(), + session_fs_sqlite_declared, + options.on_get_trace_context, + options.on_github_telemetry, + effective_connection_token.clone(), + options.mode, + )?; + *client.inner.ffi_host.lock() = Some(shared); + client + } + #[cfg(not(feature = "bundled-in-process"))] + unreachable!("in-process feature validation returned above") + } }; - debug!( elapsed_ms = start_time.elapsed().as_millis(), "Client::start transport setup complete" @@ -1294,6 +1488,8 @@ impl Client { let client = Self { inner: Arc::new(ClientInner { child: parking_lot::Mutex::new(child), + #[cfg(feature = "bundled-in-process")] + ffi_host: parking_lot::Mutex::new(None), rpc, cwd, request_rx: parking_lot::Mutex::new(Some(request_rx)), @@ -1362,7 +1558,7 @@ impl Client { }); } - fn build_command(program: &Path, options: &ClientOptions) -> Command { + fn build_command(program: &Path, options: &ClientOptions, working_directory: &Path) -> Command { let mut command = Command::new(program); for arg in &options.prefix_args { command.arg(arg); @@ -1420,7 +1616,7 @@ impl Client { command.env_remove(key); } command - .current_dir(&options.working_directory) + .current_dir(working_directory) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -1483,9 +1679,13 @@ impl Client { } } - fn spawn_stdio(program: &Path, options: &ClientOptions) -> Result { - info!(cwd = ?options.working_directory, program = %program.display(), "spawning copilot CLI (stdio)"); - let mut command = Self::build_command(program, options); + fn spawn_stdio( + program: &Path, + options: &ClientOptions, + working_directory: &Path, + ) -> Result { + info!(cwd = ?working_directory, program = %program.display(), "spawning copilot CLI (stdio)"); + let mut command = Self::build_command(program, options, working_directory); command .args(["--server", "--stdio", "--no-auto-update"]) .args(Self::log_level_args(options)) @@ -1503,9 +1703,14 @@ impl Client { Ok(child) } - async fn spawn_tcp(program: &Path, options: &ClientOptions, port: u16) -> Result<(Child, u16)> { - info!(cwd = ?options.working_directory, program = %program.display(), port = %port, "spawning copilot CLI (tcp)"); - let mut command = Self::build_command(program, options); + async fn spawn_tcp( + program: &Path, + options: &ClientOptions, + working_directory: &Path, + port: u16, + ) -> Result<(Child, u16)> { + info!(cwd = ?working_directory, program = %program.display(), port = %port, "spawning copilot CLI (tcp)"); + let mut command = Self::build_command(program, options, working_directory); command .args(["--server", "--port", &port.to_string(), "--no-auto-update"]) .args(Self::log_level_args(options)) @@ -2064,6 +2269,9 @@ impl Client { } let should_shutdown_runtime = self.inner.child.lock().is_some(); + #[cfg(feature = "bundled-in-process")] + let should_shutdown_runtime = + should_shutdown_runtime || self.inner.ffi_host.lock().is_some(); if should_shutdown_runtime { let runtime_shutdown_start = Instant::now(); match tokio::time::timeout(RUNTIME_SHUTDOWN_TIMEOUT, self.rpc().runtime().shutdown()) @@ -2120,6 +2328,16 @@ impl Client { } } + // The runtime.shutdown RPC above already asked the runtime to clean up; + // closing here tears down the transport. + #[cfg(feature = "bundled-in-process")] + { + if let Some(host) = self.inner.ffi_host.lock().take() { + self.inner.rpc.force_close(); + host.close(); + } + } + info!(pid = ?pid, errors = errors.len(), "CLI process stopped"); if errors.is_empty() { Ok(()) @@ -2166,6 +2384,12 @@ impl Client { error!(pid = ?pid, error = %e, "failed to send kill signal"); } self.inner.rpc.force_close(); + #[cfg(feature = "bundled-in-process")] + { + if let Some(host) = self.inner.ffi_host.lock().take() { + host.close(); + } + } // Drop all session channels so any awaiters see a closed channel // instead of waiting for responses that will never arrive. self.inner.router.clear(); @@ -2222,6 +2446,13 @@ impl Drop for ClientInner { info!(pid = ?pid, "kill signal sent for CLI process on drop"); } } + #[cfg(feature = "bundled-in-process")] + { + if let Some(host) = self.ffi_host.lock().take() { + self.rpc.force_close(); + host.close(); + } + } } } @@ -2286,6 +2517,63 @@ mod tests { assert!(opts.enable_remote_sessions); } + #[test] + fn default_transport_values_resolve_without_process_state() { + assert!(matches!( + resolve_default_transport_value(None).unwrap(), + Transport::Stdio + )); + assert!(matches!( + resolve_default_transport_value(Some("stdio")).unwrap(), + Transport::Stdio + )); + assert!(matches!( + resolve_default_transport_value(Some("INPROCESS")).unwrap(), + Transport::InProcess + )); + assert!(resolve_default_transport_value(Some("tcp")).is_err()); + } + + #[test] + fn inprocess_rejects_process_scoped_options() { + let invalid = [ + ClientOptions::new().with_cwd("."), + ClientOptions::new().with_env([("KEY", "value")]), + ClientOptions::new().with_env_remove(["KEY"]), + ClientOptions::new().with_telemetry(TelemetryConfig::default()), + ClientOptions::new().with_prefix_args(["index.js"]), + ClientOptions::new().with_program(CliProgram::Path("copilot".into())), + ClientOptions::new().with_extra_args(["--verbose"]), + ]; + + for options in invalid { + assert!(validate_inprocess_options(&options).is_err()); + } + } + + #[test] + fn inprocess_allows_typed_runtime_options() { + let options = ClientOptions::new() + .with_base_directory("state") + .with_log_level(LogLevel::Debug) + .with_session_idle_timeout_seconds(10) + .with_github_token("token") + .with_use_logged_in_user(false) + .with_enable_remote_sessions(true); + + assert!(validate_inprocess_options(&options).is_ok()); + } + + #[cfg(not(feature = "bundled-in-process"))] + #[tokio::test] + async fn inprocess_requires_cargo_feature() { + let error = Client::start(ClientOptions::new().with_transport(Transport::InProcess)) + .await + .unwrap_err(); + + assert!(error.to_string().contains("bundled-in-process")); + } + #[test] fn is_transport_failure_rejects_other_protocol_errors() { let err = Error::from(ErrorKind::Protocol(ProtocolErrorKind::CliStartupTimeout)); @@ -2299,7 +2587,7 @@ mod tests { env_remove: vec![std::ffi::OsString::from("COPILOT_SDK_AUTH_TOKEN")], ..Default::default() }; - let cmd = Client::build_command(Path::new("/bin/echo"), &opts); + let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); // get_envs() iter yields the latest action per key — None means removed. let action = cmd .as_std() @@ -2323,7 +2611,7 @@ mod tests { )], ..Default::default() }; - let cmd = Client::build_command(Path::new("/bin/echo"), &opts); + let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); let value = cmd .as_std() .get_envs() @@ -2338,7 +2626,7 @@ mod tests { github_token: Some("just-the-token".to_string()), ..Default::default() }; - let cmd = Client::build_command(Path::new("/bin/echo"), &opts); + let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); let value = cmd .as_std() .get_envs() @@ -2406,7 +2694,7 @@ mod tests { }), ..Default::default() }; - let cmd = Client::build_command(Path::new("/bin/echo"), &opts); + let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); assert_eq!( env_value(&cmd, "COPILOT_OTEL_ENABLED"), Some(std::ffi::OsStr::new("true")), @@ -2440,7 +2728,7 @@ mod tests { #[test] fn build_command_omits_otel_env_when_telemetry_none() { let opts = ClientOptions::default(); - let cmd = Client::build_command(Path::new("/bin/echo"), &opts); + let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); for key in [ "COPILOT_OTEL_ENABLED", "OTEL_EXPORTER_OTLP_ENDPOINT", @@ -2466,7 +2754,7 @@ mod tests { }), ..Default::default() }; - let cmd = Client::build_command(Path::new("/bin/echo"), &opts); + let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); // The one set field plus the implicit enabled flag should propagate. assert_eq!( env_value(&cmd, "COPILOT_OTEL_ENABLED"), @@ -2501,7 +2789,7 @@ mod tests { )], ..Default::default() }; - let cmd = Client::build_command(Path::new("/bin/echo"), &opts); + let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); assert_eq!( env_value(&cmd, "OTEL_EXPORTER_OTLP_ENDPOINT"), Some(std::ffi::OsStr::new("http://from-user-env:4318")), @@ -2512,14 +2800,14 @@ mod tests { #[test] fn build_command_sets_copilot_home_env_when_configured() { let opts = ClientOptions::new().with_base_directory(PathBuf::from("/custom/copilot")); - let cmd = Client::build_command(Path::new("/bin/echo"), &opts); + let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); assert_eq!( env_value(&cmd, "COPILOT_HOME"), Some(std::ffi::OsStr::new("/custom/copilot")), ); let opts = ClientOptions::default(); - let cmd = Client::build_command(Path::new("/bin/echo"), &opts); + let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); assert!(env_value(&cmd, "COPILOT_HOME").is_none()); } @@ -2529,14 +2817,14 @@ mod tests { port: 0, connection_token: Some("secret-token".to_string()), }); - let cmd = Client::build_command(Path::new("/bin/echo"), &opts); + let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); assert_eq!( env_value(&cmd, "COPILOT_CONNECTION_TOKEN"), Some(std::ffi::OsStr::new("secret-token")), ); let opts = ClientOptions::default(); - let cmd = Client::build_command(Path::new("/bin/echo"), &opts); + let cmd = Client::build_command(Path::new("/bin/echo"), &opts, Path::new("/tmp")); assert!(env_value(&cmd, "COPILOT_CONNECTION_TOKEN").is_none()); } @@ -2587,8 +2875,9 @@ mod tests { }), ..Default::default() }; - let cmd_true = Client::build_command(Path::new("/bin/echo"), &opts_true); - let cmd_false = Client::build_command(Path::new("/bin/echo"), &opts_false); + let cmd_true = Client::build_command(Path::new("/bin/echo"), &opts_true, Path::new("/tmp")); + let cmd_false = + Client::build_command(Path::new("/bin/echo"), &opts_false, Path::new("/tmp")); assert_eq!( env_value( &cmd_true, @@ -2798,6 +3087,8 @@ mod tests { Client { inner: Arc::new(ClientInner { child: parking_lot::Mutex::new(None), + #[cfg(feature = "bundled-in-process")] + ffi_host: parking_lot::Mutex::new(None), rpc: { let (req_tx, _req_rx) = mpsc::unbounded_channel(); let (notif_tx, _notif_rx) = broadcast::channel(16); diff --git a/rust/src/session.rs b/rust/src/session.rs index 307139f20f..35656c657f 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -12,7 +12,8 @@ use tracing::{Instrument, warn}; use crate::canvas::CanvasHandler; use crate::generated::api_types::{ - LogRequest, ModelSwitchToRequest, OpenCanvasInstance, RegisterEventInterestParams, rpc_methods, + LogRequest, ModelSwitchToRequest, OpenCanvasInstance, RegisterEventInterestParams, + ToolsGetCurrentMetadataResult, rpc_methods, }; use crate::generated::session_events::{ CommandExecuteData, ElicitationRequestedData, ExternalToolRequestedData, McpOauthRequiredData, @@ -41,6 +42,11 @@ use crate::{ error_codes, }; +/// Fixed name of the runtime's built-in tool-search tool. A client can replace +/// its behavior by registering a tool with this exact name and +/// `overrides_built_in_tool` set to `true`. +const TOOL_SEARCH_TOOL_NAME: &str = "tool_search_tool"; + /// Bundle of the per-session callbacks the SDK dispatches to. Built from a /// [`SessionConfig`] / [`ResumeSessionConfig`] at /// [`Client::create_session`] / [`Client::resume_session`] time. Each @@ -1516,6 +1522,7 @@ fn tool_failure_result(message: impl Into) -> ToolResult { session_log: None, error: Some(message), tool_telemetry: None, + tool_references: None, }) } @@ -1807,6 +1814,30 @@ async fn handle_notification( } let tool_call_id = data.tool_call_id.clone(); let tool_name = data.tool_name.clone(); + // The built-in tool-search tool receives a snapshot of the + // session's currently initialized tools so an override can + // filter the live catalog without issuing its own RPC. Fetch + // it only for that tool to avoid a round-trip on every tool + // call; a failed fetch leaves the snapshot `None` rather than + // failing the tool. + let available_tools = if tool_name == TOOL_SEARCH_TOOL_NAME { + match client + .call( + rpc_methods::SESSION_TOOLS_GETCURRENTMETADATA, + Some(serde_json::json!({ "sessionId": sid })), + ) + .await + { + Ok(value) => { + serde_json::from_value::(value) + .ok() + .and_then(|result| result.tools) + } + Err(_) => None, + } + } else { + None + }; let invocation = ToolInvocation { session_id: sid.clone(), tool_call_id: data.tool_call_id, @@ -1814,6 +1845,7 @@ async fn handle_notification( arguments: data .arguments .unwrap_or(Value::Object(serde_json::Map::new())), + available_tools, traceparent: data.traceparent, tracestate: data.tracestate, }; diff --git a/rust/src/tool.rs b/rust/src/tool.rs index 189bc6f21a..344d2894ca 100644 --- a/rust/src/tool.rs +++ b/rust/src/tool.rs @@ -13,9 +13,8 @@ //! Enable the `derive` feature for `schema_for`, which generates JSON //! Schema from Rust types via `schemars`. -use std::collections::HashMap; - use async_trait::async_trait; +use indexmap::IndexMap; /// Re-export of [`schemars::JsonSchema`] for deriving tool parameter schemas. #[cfg(feature = "derive")] pub use schemars::JsonSchema; @@ -80,14 +79,14 @@ pub fn schema_for() -> serde_json::Value { /// tool.parameters = tool_parameters(serde_json::json!({"type": "object"})); /// # let _ = tool; /// ``` -pub fn tool_parameters(schema: serde_json::Value) -> HashMap { +pub fn tool_parameters(schema: serde_json::Value) -> IndexMap { try_tool_parameters(schema).expect("tool parameter schema must be a JSON object") } /// Fallible variant of [`tool_parameters`] for callers handling dynamic schema input. pub fn try_tool_parameters( schema: serde_json::Value, -) -> Result, serde_json::Error> { +) -> Result, serde_json::Error> { serde_json::from_value(schema) } @@ -174,6 +173,7 @@ pub fn convert_mcp_call_tool_result(value: &serde_json::Value) -> Option = tool.parameters.keys().map(String::as_str).collect(); + assert_eq!( + keys, + ["additionalProperties", "properties", "required", "type"] + ); + } + #[test] fn convert_mcp_call_tool_result_collects_text_and_binary_content() { let result = convert_mcp_call_tool_result(&serde_json::json!({ @@ -566,6 +604,7 @@ mod tests { tool_call_id: "tc1".to_string(), tool_name: "echo".to_string(), arguments: serde_json::json!({"msg": "hello"}), + available_tools: None, traceparent: None, tracestate: None, }; @@ -606,6 +645,7 @@ mod tests { tool_call_id: "tc1".to_string(), tool_name: "weather".to_string(), arguments: serde_json::json!({"city": "Seattle"}), + available_tools: None, traceparent: None, tracestate: None, }; @@ -688,6 +728,7 @@ mod tests { tool_call_id: "tc1".to_string(), tool_name: "get_weather".to_string(), arguments: serde_json::json!({"city": "Seattle", "unit": "celsius"}), + available_tools: None, traceparent: None, tracestate: None, }; @@ -707,6 +748,7 @@ mod tests { tool_call_id: "tc1".to_string(), tool_name: "get_weather".to_string(), arguments: serde_json::json!({"wrong_field": 42}), + available_tools: None, traceparent: None, tracestate: None, }; @@ -728,6 +770,7 @@ mod tests { tool_call_id: "tc1".to_string(), tool_name: "get_weather".to_string(), arguments: serde_json::json!({"city": "Portland"}), + available_tools: None, traceparent: None, tracestate: None, }) diff --git a/rust/src/types.rs b/rust/src/types.rs index e5ba28a56e..b34d8fff45 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -9,6 +9,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; +use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -19,7 +20,7 @@ pub use crate::copilot_request_handler::{ CopilotWebSocketForwarderBuilder, CopilotWebSocketHandler, CopilotWebSocketMessage, CopilotWebSocketResponse, WebSocketTransform, forward_http, }; -use crate::generated::api_types::OpenCanvasInstance; +use crate::generated::api_types::{CurrentToolMetadata, OpenCanvasInstance}; use crate::generated::session_events::ReasoningSummary; /// Context window tier for models that support tiered context windows. pub use crate::generated::session_events::{ContextTier, SessionLimitsConfig}; @@ -332,8 +333,8 @@ pub struct Tool { #[serde(default, skip_serializing_if = "Option::is_none")] pub instructions: Option, /// JSON Schema for the tool's input parameters. - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub parameters: HashMap, + #[serde(default, skip_serializing_if = "IndexMap::is_empty")] + pub parameters: IndexMap, /// When `true`, this tool replaces a built-in tool of the same name /// (e.g. supplying a custom `grep` that the agent uses in place of the /// CLI's built-in implementation). @@ -351,6 +352,12 @@ pub struct Tool { /// runtime decide. #[serde(default, skip_serializing_if = "Option::is_none")] pub defer: Option, + /// Opaque, host-defined metadata associated with the tool definition. + /// Keys are namespaced and not part of the stable public API; values are + /// not interpreted and may be recognized to inform host-specific behavior. + /// Unknown keys are preserved and round-tripped untouched. + #[serde(default, skip_serializing_if = "IndexMap::is_empty")] + pub metadata: IndexMap, /// Optional runtime implementation. When `Some`, the SDK dispatches /// matching `external_tool.requested` broadcasts to this handler. /// When `None`, the tool is declaration-only. @@ -470,6 +477,13 @@ impl Tool { self } + /// Set opaque, host-defined metadata for the tool. Keys are namespaced and + /// not part of the stable public API. Replaces any previously-set metadata. + pub fn with_metadata(mut self, metadata: IndexMap) -> Self { + self.metadata = metadata; + self + } + /// Attach a runtime implementation. The SDK will dispatch matching /// `external_tool.requested` broadcasts to `handler` for this tool's /// name. Without a handler the tool is declaration-only. @@ -498,6 +512,7 @@ impl std::fmt::Debug for Tool { .field("overrides_built_in_tool", &self.overrides_built_in_tool) .field("skip_permission", &self.skip_permission) .field("defer", &self.defer) + .field("metadata", &self.metadata) .field( "handler", &self.handler.as_ref().map(|_| "").unwrap_or("None"), @@ -614,7 +629,7 @@ pub struct CustomAgentConfig { pub prompt: String, /// MCP servers specific to this agent. #[serde(default, skip_serializing_if = "Option::is_none")] - pub mcp_servers: Option>, + pub mcp_servers: Option>, /// Whether the agent is available for model inference. #[serde(default, skip_serializing_if = "Option::is_none")] pub infer: Option, @@ -668,7 +683,7 @@ impl CustomAgentConfig { } /// Configure agent-specific MCP servers. - pub fn with_mcp_servers(mut self, mcp_servers: HashMap) -> Self { + pub fn with_mcp_servers(mut self, mcp_servers: IndexMap) -> Self { self.mcp_servers = Some(mcp_servers); self } @@ -758,6 +773,45 @@ impl LargeToolOutputConfig { } } +/// Overrides the runtime's built-in tool-search behavior. +/// +/// Tool search defers tools to keep the model's active tool set small. +/// To override the tool-search tool's implementation, register a [`Tool`] +/// named `"tool_search_tool"` with [`Tool::overrides_built_in_tool`] set to `true`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct ToolSearchConfig { + /// Toggle to enable/disable tool search. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + /// The tool count above which MCP and external tools are deferred behind + /// tool search. When unset, the runtime default (30) applies. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub defer_threshold: Option, +} + +impl ToolSearchConfig { + /// Construct an empty [`ToolSearchConfig`]; all fields default to unset + /// (the runtime applies its own defaults). + pub fn new() -> Self { + Self::default() + } + + /// Toggle that enables or disables tool search. + pub fn with_enabled(mut self, enabled: bool) -> Self { + self.enabled = Some(enabled); + self + } + + /// Set the tool count above which MCP and external tools are deferred + /// behind tool search. + pub fn with_defer_threshold(mut self, defer_threshold: u32) -> Self { + self.defer_threshold = Some(defer_threshold); + self + } +} + /// Configures infinite sessions: persistent workspaces with automatic /// context-window compaction. /// @@ -916,6 +970,42 @@ impl ExtensionInfo { } } +/// Stable identity for a host/SDK connection that supplies built-in canvases. +/// +/// When set on session create or resume, the runtime uses [`id`] verbatim as +/// the agent-facing canvas extension id, so canvases declared on a control +/// connection survive stdio reconnect and CLI process restart instead of being +/// re-keyed to a per-connection id. The id is opaque to the runtime; a +/// per-window-stable value such as `app:builtin:` is recommended. An +/// id beginning with `connection:` is reserved and ignored by the runtime. +/// +/// [`id`]: CanvasProviderIdentity::id +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CanvasProviderIdentity { + /// Opaque, stable provider id used verbatim as the canvas extension id. + pub id: String, + /// Optional display name surfaced as the canvas extension name. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +impl CanvasProviderIdentity { + /// Create a canvas provider identity from a stable opaque id. + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + name: None, + } + } + + /// Set the optional display name surfaced as the canvas extension name. + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } +} + /// Configuration for a single MCP server. /// /// MCP (Model Context Protocol) servers expose external tools to the @@ -929,8 +1019,8 @@ impl ExtensionInfo { /// /// ``` /// # use github_copilot_sdk::types::{McpServerConfig, McpStdioServerConfig, McpHttpServerConfig}; -/// # use std::collections::HashMap; -/// let mut servers = HashMap::new(); +/// # use github_copilot_sdk::IndexMap; +/// let mut servers = IndexMap::new(); /// servers.insert( /// "playwright".to_string(), /// McpServerConfig::Stdio(McpStdioServerConfig { @@ -1598,6 +1688,9 @@ pub struct SessionConfig { pub extension_sdk_path: Option, /// Stable extension identity for canvas/tool providers on this connection. pub extension_info: Option, + /// Stable identity for a host/SDK connection that supplies built-in + /// canvases, so they survive reconnect and CLI restart. + pub canvas_provider: Option, /// Allowlist of built-in tool names the agent may use. pub available_tools: Option>, /// Blocklist of built-in tool names the agent must not use. @@ -1609,7 +1702,7 @@ pub struct SessionConfig { /// configured. pub excluded_builtin_agents: Option>, /// MCP server configurations passed through to the CLI. - pub mcp_servers: Option>, + pub mcp_servers: Option>, /// Controls how MCP OAuth tokens are stored for this session. /// /// - `"persistent"` — tokens are stored in the OS keychain (shared across sessions). @@ -1674,6 +1767,10 @@ pub struct SessionConfig { pub plugin_directories: Option>, /// Configuration for large tool output handling, forwarded to the CLI. pub large_output: Option, + /// Overrides the runtime's built-in tool-search behavior, which defers + /// rarely used tools behind a searchable index. When unset, the runtime + /// default applies. + pub tool_search: Option, /// Skill names to disable. Skills in this set will not be available /// even if found in skill directories. pub disabled_skills: Option>, @@ -1770,6 +1867,13 @@ pub struct SessionConfig { /// [`with_exp_assignments`](Self::with_exp_assignments). #[doc(hidden)] pub exp_assignments: Option, + /// Opt-in: when `Some(true)`, the runtime self-fetches enterprise managed + /// settings (bypass-permissions policy) at session bootstrap using the + /// session's [`github_token`](Self::github_token). Requires `github_token` + /// to be set; if omitted, the runtime is expected to reject session creation + /// (fail-closed). When `None`, behaves exactly as before. Set via + /// [`with_enable_managed_settings`](Self::with_enable_managed_settings). + pub enable_managed_settings: Option, /// Custom session filesystem provider for this session. Required when /// the [`Client`](crate::Client) was started with /// [`ClientOptions::session_fs`](crate::ClientOptions::session_fs) set. @@ -1847,6 +1951,7 @@ impl std::fmt::Debug for SessionConfig { .field("request_extensions", &self.request_extensions) .field("extension_sdk_path", &self.extension_sdk_path) .field("extension_info", &self.extension_info) + .field("canvas_provider", &self.canvas_provider) .field("available_tools", &self.available_tools) .field("excluded_tools", &self.excluded_tools) .field("excluded_builtin_agents", &self.excluded_builtin_agents) @@ -1878,6 +1983,7 @@ impl std::fmt::Debug for SessionConfig { .field("instruction_directories", &self.instruction_directories) .field("plugin_directories", &self.plugin_directories) .field("large_output", &self.large_output) + .field("tool_search", &self.tool_search) .field("disabled_skills", &self.disabled_skills) .field("hooks", &self.hooks) .field("custom_agents", &self.custom_agents) @@ -1905,6 +2011,7 @@ impl std::fmt::Debug for SessionConfig { ) .field("commands", &self.commands) .field("exp_assignments", &self.exp_assignments) + .field("enable_managed_settings", &self.enable_managed_settings) .field( "session_fs_provider", &self.session_fs_provider.as_ref().map(|_| ""), @@ -1968,6 +2075,7 @@ impl Default for SessionConfig { request_extensions: None, extension_sdk_path: None, extension_info: None, + canvas_provider: None, available_tools: None, excluded_tools: None, excluded_builtin_agents: None, @@ -1987,6 +2095,7 @@ impl Default for SessionConfig { instruction_directories: None, plugin_directories: None, large_output: None, + tool_search: None, disabled_skills: None, hooks: None, custom_agents: None, @@ -2010,6 +2119,7 @@ impl Default for SessionConfig { include_sub_agent_streaming_events: None, commands: None, exp_assignments: None, + enable_managed_settings: None, session_fs_provider: None, permission_handler: None, elicitation_handler: None, @@ -2057,7 +2167,7 @@ impl SessionConfig { /// /// Wire-format flags are derived from handler presence and the policy /// field; runtime fields are moved out into the returned runtime so - /// the deep `Vec` / `HashMap` clones the previous + /// the deep `Vec` / `IndexMap` clones the previous /// `&self`-based shape required are eliminated, and the order of /// reading-vs-moving is enforced at compile time. /// @@ -2116,6 +2226,7 @@ impl SessionConfig { request_extensions: self.request_extensions, extension_sdk_path: self.extension_sdk_path, extension_info: self.extension_info, + canvas_provider: self.canvas_provider, available_tools: self.available_tools, excluded_tools: self.excluded_tools, excluded_builtin_agents: self.excluded_builtin_agents, @@ -2143,6 +2254,7 @@ impl SessionConfig { instruction_directories: self.instruction_directories, plugin_directories: self.plugin_directories, large_output: self.large_output, + tool_search: self.tool_search, disabled_skills: self.disabled_skills, custom_agents: self.custom_agents, default_agent: self.default_agent, @@ -2166,6 +2278,7 @@ impl SessionConfig { enable_github_telemetry_forwarding: None, commands: wire_commands, exp_assignments: self.exp_assignments, + enable_managed_settings: self.enable_managed_settings, }; let runtime = SessionConfigRuntime { @@ -2390,6 +2503,13 @@ impl SessionConfig { self } + /// Set the canvas provider identity for this connection so host-supplied + /// canvases survive reconnect and CLI restart. + pub fn with_canvas_provider(mut self, canvas_provider: CanvasProviderIdentity) -> Self { + self.canvas_provider = Some(canvas_provider); + self + } + /// Set the allowlist of built-in tool names the agent may use. pub fn with_available_tools(mut self, tools: I) -> Self where @@ -2421,7 +2541,7 @@ impl SessionConfig { } /// Set MCP server configurations passed through to the CLI. - pub fn with_mcp_servers(mut self, servers: HashMap) -> Self { + pub fn with_mcp_servers(mut self, servers: IndexMap) -> Self { self.mcp_servers = Some(servers); self } @@ -2546,6 +2666,13 @@ impl SessionConfig { self } + /// Set the [`ToolSearchConfig`] overriding the runtime's built-in + /// tool-search behavior on session create. + pub fn with_tool_search(mut self, config: ToolSearchConfig) -> Self { + self.tool_search = Some(config); + self + } + /// Set the names of skills to disable (overrides skill discovery). pub fn with_disabled_skills(mut self, names: I) -> Self where @@ -2732,6 +2859,16 @@ impl SessionConfig { self.exp_assignments = Some(assignments); self } + + /// Opt the runtime into self-fetching enterprise managed settings + /// (bypass-permissions policy) at session bootstrap using the session's + /// [`github_token`](Self::github_token). Requires `github_token` to be set; + /// if omitted, the runtime is expected to reject session creation + /// (fail-closed). + pub fn with_enable_managed_settings(mut self, enabled: bool) -> Self { + self.enable_managed_settings = Some(enabled); + self + } } /// /// See [`SessionConfig`] for the construction patterns (chained `with_*` @@ -2782,6 +2919,9 @@ pub struct ResumeSessionConfig { pub extension_sdk_path: Option, /// Stable extension identity for canvas/tool providers on this connection. pub extension_info: Option, + /// Stable identity for a host/SDK connection that supplies built-in + /// canvases, so they rehydrate against a stable extension id on resume. + pub canvas_provider: Option, /// Allowlist of tool names the agent may use. pub available_tools: Option>, /// Blocklist of built-in tool names. @@ -2793,7 +2933,7 @@ pub struct ResumeSessionConfig { /// configured. pub excluded_builtin_agents: Option>, /// Re-supply MCP servers so they remain available after app restart. - pub mcp_servers: Option>, + pub mcp_servers: Option>, /// Controls how MCP OAuth tokens are stored for this session. /// See [`SessionConfig::mcp_oauth_token_storage`] for details. pub mcp_oauth_token_storage: Option, @@ -2830,6 +2970,9 @@ pub struct ResumeSessionConfig { pub plugin_directories: Option>, /// Configuration for large tool output handling, forwarded to the CLI on resume. pub large_output: Option, + /// Overrides the runtime's built-in tool-search behavior on resume. When + /// unset, the runtime default applies. + pub tool_search: Option, /// Skill names to disable on resume. pub disabled_skills: Option>, /// Enable session hooks on resume. @@ -2900,6 +3043,12 @@ pub struct ResumeSessionConfig { /// [`with_exp_assignments`](Self::with_exp_assignments). #[doc(hidden)] pub exp_assignments: Option, + /// Opt-in flag injected on resume. See + /// [`SessionConfig::enable_managed_settings`]. Re-supply on resume so + /// the runtime re-applies the managed-settings self-fetch after a CLI + /// process restart. Set via + /// [`with_enable_managed_settings`](Self::with_enable_managed_settings). + pub enable_managed_settings: Option, /// Custom session filesystem provider. Required on resume when the /// [`Client`](crate::Client) was started with /// [`ClientOptions::session_fs`](crate::ClientOptions::session_fs). @@ -2971,6 +3120,7 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("request_extensions", &self.request_extensions) .field("extension_sdk_path", &self.extension_sdk_path) .field("extension_info", &self.extension_info) + .field("canvas_provider", &self.canvas_provider) .field("available_tools", &self.available_tools) .field("excluded_tools", &self.excluded_tools) .field("excluded_builtin_agents", &self.excluded_builtin_agents) @@ -3002,6 +3152,7 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("instruction_directories", &self.instruction_directories) .field("plugin_directories", &self.plugin_directories) .field("large_output", &self.large_output) + .field("tool_search", &self.tool_search) .field("disabled_skills", &self.disabled_skills) .field("hooks", &self.hooks) .field("custom_agents", &self.custom_agents) @@ -3028,6 +3179,7 @@ impl std::fmt::Debug for ResumeSessionConfig { ) .field("commands", &self.commands) .field("exp_assignments", &self.exp_assignments) + .field("enable_managed_settings", &self.enable_managed_settings) .field( "session_fs_provider", &self.session_fs_provider.as_ref().map(|_| ""), @@ -3128,6 +3280,7 @@ impl ResumeSessionConfig { request_extensions: self.request_extensions, extension_sdk_path: self.extension_sdk_path, extension_info: self.extension_info, + canvas_provider: self.canvas_provider, available_tools: self.available_tools, excluded_tools: self.excluded_tools, excluded_builtin_agents: self.excluded_builtin_agents, @@ -3155,6 +3308,7 @@ impl ResumeSessionConfig { instruction_directories: self.instruction_directories, plugin_directories: self.plugin_directories, large_output: self.large_output, + tool_search: self.tool_search, disabled_skills: self.disabled_skills, custom_agents: self.custom_agents, default_agent: self.default_agent, @@ -3177,6 +3331,7 @@ impl ResumeSessionConfig { enable_github_telemetry_forwarding: None, commands: wire_commands, exp_assignments: self.exp_assignments, + enable_managed_settings: self.enable_managed_settings, suppress_resume_event: self.suppress_resume_event, continue_pending_work: self.continue_pending_work, }; @@ -3223,6 +3378,7 @@ impl ResumeSessionConfig { request_extensions: None, extension_sdk_path: None, extension_info: None, + canvas_provider: None, available_tools: None, excluded_tools: None, excluded_builtin_agents: None, @@ -3242,6 +3398,7 @@ impl ResumeSessionConfig { instruction_directories: None, plugin_directories: None, large_output: None, + tool_search: None, disabled_skills: None, hooks: None, custom_agents: None, @@ -3264,6 +3421,7 @@ impl ResumeSessionConfig { include_sub_agent_streaming_events: None, commands: None, exp_assignments: None, + enable_managed_settings: None, session_fs_provider: None, suppress_resume_event: None, continue_pending_work: None, @@ -3474,6 +3632,13 @@ impl ResumeSessionConfig { self } + /// Set the canvas provider identity for this connection on resume so + /// host-supplied canvases rehydrate against a stable extension id. + pub fn with_canvas_provider(mut self, canvas_provider: CanvasProviderIdentity) -> Self { + self.canvas_provider = Some(canvas_provider); + self + } + /// Set the allowlist of tool names the agent may use. pub fn with_available_tools(mut self, tools: I) -> Self where @@ -3505,7 +3670,7 @@ impl ResumeSessionConfig { } /// Re-supply MCP server configurations on resume. - pub fn with_mcp_servers(mut self, servers: HashMap) -> Self { + pub fn with_mcp_servers(mut self, servers: IndexMap) -> Self { self.mcp_servers = Some(servers); self } @@ -3625,6 +3790,13 @@ impl ResumeSessionConfig { self } + /// Set the [`ToolSearchConfig`] overriding the runtime's built-in + /// tool-search behavior on resume. + pub fn with_tool_search(mut self, config: ToolSearchConfig) -> Self { + self.tool_search = Some(config); + self + } + /// Set the names of skills to disable on resume. pub fn with_disabled_skills(mut self, names: I) -> Self where @@ -3813,6 +3985,13 @@ impl ResumeSessionConfig { self.exp_assignments = Some(assignments); self } + + /// Opt the runtime into self-fetching enterprise managed settings on resume. + /// See [`SessionConfig::with_enable_managed_settings`]. + pub fn with_enable_managed_settings(mut self, enabled: bool) -> Self { + self.enable_managed_settings = Some(enabled); + self + } } /// Controls how the system message is constructed. @@ -4744,6 +4923,15 @@ pub struct ToolInvocation { pub tool_name: String, /// Tool arguments as JSON. pub arguments: Value, + /// Snapshot of the session's currently initialized tools. + /// + /// The SDK populates this only when the invocation targets the built-in + /// tool-search tool (`tool_search_tool`), so a tool-search override can + /// rank/filter the live catalog — including MCP tools configured in + /// settings — without issuing its own RPC. `None` for every other tool + /// invocation. This field is not part of the wire protocol. + #[serde(skip)] + pub available_tools: Option>, /// W3C Trace Context `traceparent` header propagated from the CLI's /// `execute_tool` span. Pass through to OpenTelemetry-aware code so /// child spans created inside the handler are parented to the CLI @@ -4807,8 +4995,14 @@ pub struct ToolBinaryResult { } /// Expanded tool result with metadata for the LLM and session log. +/// +/// This type is `#[non_exhaustive]`: it mirrors a growing wire shape, so +/// construct it via [`ToolResultExpanded::new`] plus the `with_*` chain +/// rather than a struct literal, allowing new fields to land without +/// breaking callers. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub struct ToolResultExpanded { /// Result text sent back to the LLM. pub text_result_for_llm: String, @@ -4826,6 +5020,60 @@ pub struct ToolResultExpanded { /// Tool-specific telemetry emitted with the result. #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_telemetry: Option>, + /// Names of tools returned by a tool-search tool. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_references: Option>, +} + +impl ToolResultExpanded { + /// Construct an expanded result with the required `text_result_for_llm` + /// and `result_type` (`"success"` or `"failure"`). All optional metadata + /// fields start unset; populate them with the `with_*` builders. + pub fn new(text_result_for_llm: impl Into, result_type: impl Into) -> Self { + Self { + text_result_for_llm: text_result_for_llm.into(), + result_type: result_type.into(), + binary_results_for_llm: None, + session_log: None, + error: None, + tool_telemetry: None, + tool_references: None, + } + } + + /// Set the binary payloads returned to the LLM. + pub fn with_binary_results(mut self, results: Vec) -> Self { + self.binary_results_for_llm = Some(results); + self + } + + /// Set the log message for the session timeline. + pub fn with_session_log(mut self, session_log: impl Into) -> Self { + self.session_log = Some(session_log.into()); + self + } + + /// Set the error message, marking the tool as failed. + pub fn with_error(mut self, error: impl Into) -> Self { + self.error = Some(error.into()); + self + } + + /// Set the tool-specific telemetry emitted with the result. + pub fn with_tool_telemetry(mut self, telemetry: HashMap) -> Self { + self.tool_telemetry = Some(telemetry); + self + } + + /// Set the names of tools returned by a tool-search tool. + pub fn with_tool_references(mut self, references: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.tool_references = Some(references.into_iter().map(Into::into).collect()); + self + } } /// Result of a tool invocation — either a plain text string or an expanded result. @@ -5168,10 +5416,11 @@ mod tests { AgentMode, Attachment, AttachmentLineRange, AttachmentSelectionPosition, AttachmentSelectionRange, AzureProviderOptions, CapiSessionOptions, ConnectionState, CustomAgentConfig, DeliveryMode, ExtensionInfo, GitHubReferenceType, InfiniteSessionConfig, - LargeToolOutputConfig, MemoryConfiguration, NamedProviderConfig, ProviderConfig, - ProviderModelConfig, ReasoningSummary, ResumeSessionConfig, SessionConfig, SessionEvent, - SessionId, SystemMessageConfig, Tool, ToolBinaryResult, ToolResult, ToolResultExpanded, - ToolResultResponse, ensure_attachment_display_names, + LargeToolOutputConfig, McpServerConfig, McpStdioServerConfig, MemoryConfiguration, + NamedProviderConfig, ProviderConfig, ProviderModelConfig, ReasoningSummary, + ResumeSessionConfig, SessionConfig, SessionEvent, SessionId, SystemMessageConfig, Tool, + ToolBinaryResult, ToolResult, ToolResultExpanded, ToolResultResponse, + ensure_attachment_display_names, }; use crate::generated::session_events::TypedSessionEvent; @@ -5209,6 +5458,32 @@ mod tests { assert!(value.get("defer").is_none()); } + #[test] + fn tool_metadata_serialization() { + use indexmap::IndexMap; + + let mut metadata = IndexMap::new(); + metadata.insert( + "github.com/copilot:safeForTelemetry".to_string(), + json!({ "name": true, "inputsNames": false }), + ); + let tool = Tool::new("lookup").with_metadata(metadata); + let value = serde_json::to_value(&tool).unwrap(); + assert_eq!( + value + .get("metadata") + .unwrap() + .get("github.com/copilot:safeForTelemetry") + .unwrap(), + &json!({ "name": true, "inputsNames": false }) + ); + + // Empty metadata is omitted on the wire. + let plain = Tool::new("plain"); + let value = serde_json::to_value(&plain).unwrap(); + assert!(value.get("metadata").is_none()); + } + #[test] fn custom_agent_config_builder_with_model() { let agent = CustomAgentConfig::new("my-agent", "You are helpful.") @@ -5255,6 +5530,7 @@ mod tests { session_log: None, error: None, tool_telemetry: None, + tool_references: None, }), }; @@ -5289,6 +5565,7 @@ mod tests { session_log: None, error: None, tool_telemetry: None, + tool_references: None, }), }; @@ -5298,6 +5575,70 @@ mod tests { assert!(wire["result"].get("binaryResultsForLlm").is_none()); } + #[test] + fn tool_result_expanded_serializes_tool_references() { + let response = ToolResultResponse { + result: ToolResult::Expanded( + ToolResultExpanded::new("found 2 tools", "success") + .with_tool_references(["get_weather", "check_status"]), + ), + }; + + let wire = serde_json::to_value(&response).unwrap(); + + assert_eq!( + wire, + json!({ + "result": { + "textResultForLlm": "found 2 tools", + "resultType": "success", + "toolReferences": ["get_weather", "check_status"] + } + }) + ); + } + + #[test] + fn tool_result_expanded_omits_tool_references_when_none() { + let response = ToolResultResponse { + result: ToolResult::Expanded(ToolResultExpanded::new("ok", "success")), + }; + + let wire = serde_json::to_value(&response).unwrap(); + + assert_eq!(wire["result"]["textResultForLlm"], "ok"); + assert!(wire["result"].get("toolReferences").is_none()); + } + + #[test] + fn tool_result_expanded_with_tool_references_accepts_owned_strings() { + // The builder is generic over `Into`, so an owned `Vec` + // must compile and populate the field just like a `&str` array. + let names: Vec = vec!["alpha".to_string(), "beta".to_string()]; + let expanded = ToolResultExpanded::new("ok", "success").with_tool_references(names); + + assert_eq!( + expanded.tool_references.as_deref(), + Some(["alpha".to_string(), "beta".to_string()].as_slice()) + ); + } + + #[test] + fn tool_result_expanded_deserializes_tool_references() { + let wire = json!({ + "textResultForLlm": "found tools", + "resultType": "success", + "toolReferences": ["alpha", "beta"] + }); + + let expanded: ToolResultExpanded = serde_json::from_value(wire).unwrap(); + + assert_eq!( + expanded.tool_references.as_deref(), + Some(["alpha".to_string(), "beta".to_string()].as_slice()) + ); + } + #[test] fn session_config_default_wire_flags_off_without_handlers() { let cfg = SessionConfig::default(); @@ -5720,7 +6061,7 @@ mod tests { #[test] fn session_config_builder_composes() { - use std::collections::HashMap; + use indexmap::IndexMap; let cfg = SessionConfig::default() .with_session_id(SessionId::from("sess-1")) @@ -5733,7 +6074,7 @@ mod tests { .with_tools([Tool::new("greet")]) .with_available_tools(["bash", "view"]) .with_excluded_tools(["dangerous"]) - .with_mcp_servers(HashMap::new()) + .with_mcp_servers(IndexMap::new()) .with_mcp_oauth_token_storage("persistent") .with_enable_config_discovery(true) .with_enable_on_demand_instruction_discovery(true) @@ -5794,7 +6135,7 @@ mod tests { #[test] fn resume_session_config_builder_composes() { - use std::collections::HashMap; + use indexmap::IndexMap; let cfg = ResumeSessionConfig::new(SessionId::from("sess-2")) .with_client_name("test-app") @@ -5804,7 +6145,7 @@ mod tests { .with_tools([Tool::new("greet")]) .with_available_tools(["bash", "view"]) .with_excluded_tools(["dangerous"]) - .with_mcp_servers(HashMap::new()) + .with_mcp_servers(IndexMap::new()) .with_mcp_oauth_token_storage("persistent") .with_enable_config_discovery(true) .with_enable_on_demand_instruction_discovery(false) @@ -5942,13 +6283,13 @@ mod tests { #[test] fn custom_agent_config_builder_composes() { - use std::collections::HashMap; + use indexmap::IndexMap; let cfg = CustomAgentConfig::new("researcher", "You are a research assistant.") .with_display_name("Research Assistant") .with_description("Investigates technical questions.") .with_tools(["bash", "view"]) - .with_mcp_servers(HashMap::new()) + .with_mcp_servers(IndexMap::new()) .with_infer(true) .with_skills(["rust-coding-skill"]); @@ -5971,6 +6312,51 @@ mod tests { ); } + #[test] + fn mcp_servers_serialize_in_insertion_order() { + use indexmap::IndexMap; + + // Regression: `mcp_servers` was a `HashMap`, so the server keys (and + // thus the `session.create` payload) serialized in a per-process + // random order; `IndexMap` pins them to insertion order. The long + // sequence makes a `HashMap` regression reproduce this exact order by + // chance only 1/N!, avoiding a flaky false pass. + let order = [ + "zebra", "quartz", "delta", "ivy", "mango", "bravo", "xenon", "amber", "falcon", + "ceres", "nova", "kelp", "otter", "yodel", "plum", "garnet", + ]; + let mut servers = IndexMap::new(); + for name in order { + servers.insert( + name.to_string(), + McpServerConfig::Stdio(McpStdioServerConfig { + command: "run".to_string(), + ..Default::default() + }), + ); + } + + let (wire, _runtime) = SessionConfig::default() + .with_mcp_servers(servers) + .into_wire(None) + .expect("into_wire should succeed"); + let json = serde_json::to_string(&wire).expect("serialize wire"); + + let positions: Vec = order + .iter() + .map(|name| { + json.find(&format!("\"{name}\"")) + .unwrap_or_else(|| panic!("server {name} missing from wire JSON")) + }) + .collect(); + let mut ascending = positions.clone(); + ascending.sort_unstable(); + assert_eq!( + positions, ascending, + "mcp server keys must serialize in insertion order: {json}" + ); + } + #[test] fn infinite_session_config_builder_composes() { let cfg = InfiniteSessionConfig::new() diff --git a/rust/src/wire.rs b/rust/src/wire.rs index f73870fa53..43188bc274 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -13,9 +13,9 @@ //! configs hold trait-object handlers, the wire structs hold only the //! plain data the runtime needs. -use std::collections::HashMap; use std::path::PathBuf; +use indexmap::IndexMap; use serde::Serialize; use crate::canvas::CanvasDeclaration; @@ -24,10 +24,10 @@ use crate::generated::api_types::{ }; use crate::generated::session_events::ReasoningSummary; use crate::types::{ - CapiSessionOptions, CloudSessionOptions, CustomAgentConfig, DefaultAgentConfig, ExtensionInfo, - InfiniteSessionConfig, LargeToolOutputConfig, McpServerConfig, MemoryConfiguration, - NamedProviderConfig, ProviderConfig, ProviderModelConfig, SessionId, SessionLimitsConfig, - SystemMessageConfig, Tool, + CanvasProviderIdentity, CapiSessionOptions, CloudSessionOptions, CustomAgentConfig, + DefaultAgentConfig, ExtensionInfo, InfiniteSessionConfig, LargeToolOutputConfig, + McpServerConfig, MemoryConfiguration, NamedProviderConfig, ProviderConfig, ProviderModelConfig, + SessionId, SessionLimitsConfig, SystemMessageConfig, Tool, ToolSearchConfig, }; /// Wire representation of a slash command (name + description only). The @@ -74,6 +74,8 @@ pub(crate) struct SessionCreateWire { #[serde(skip_serializing_if = "Option::is_none")] pub extension_info: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub canvas_provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub available_tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub excluded_tools: Option>, @@ -83,7 +85,7 @@ pub(crate) struct SessionCreateWire { /// naturally (everything matching X except Y). pub tool_filter_precedence: &'static str, #[serde(skip_serializing_if = "Option::is_none")] - pub mcp_servers: Option>, + pub mcp_servers: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub mcp_oauth_token_storage: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -121,6 +123,8 @@ pub(crate) struct SessionCreateWire { #[serde(skip_serializing_if = "Option::is_none")] pub large_output: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub tool_search: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub disabled_skills: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub custom_agents: Option>, @@ -169,6 +173,8 @@ pub(crate) struct SessionCreateWire { pub commands: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub exp_assignments: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_managed_settings: Option, } /// The exact JSON shape sent on the `session.resume` JSON-RPC request. @@ -205,6 +211,8 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub extension_info: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub canvas_provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub available_tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub excluded_tools: Option>, @@ -213,7 +221,7 @@ pub(crate) struct SessionResumeWire { /// SDK always sends `"excluded"`. See create-wire docs. pub tool_filter_precedence: &'static str, #[serde(skip_serializing_if = "Option::is_none")] - pub mcp_servers: Option>, + pub mcp_servers: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub mcp_oauth_token_storage: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -251,6 +259,8 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub large_output: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub tool_search: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub disabled_skills: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub custom_agents: Option>, @@ -302,4 +312,6 @@ pub(crate) struct SessionResumeWire { pub continue_pending_work: Option, #[serde(skip_serializing_if = "Option::is_none")] pub exp_assignments: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_managed_settings: Option, } diff --git a/rust/tests/cli_resolution_test.rs b/rust/tests/cli_resolution_test.rs index c3044a9e75..9e4927e676 100644 --- a/rust/tests/cli_resolution_test.rs +++ b/rust/tests/cli_resolution_test.rs @@ -196,21 +196,27 @@ async fn extract_dir_runtime_override_is_honored() { let _ = fake; } -/// Build-time version pin: `cli-version.txt` (when present) must be a -/// combined snapshot — a `version=X.Y.Z` line plus per-asset hash lines. +/// Build-time version pins, when present, must match the selected bundling +/// implementation's checksum format. /// When absent, build.rs falls through to `../nodejs/package-lock.json` — /// both are accepted, this test only checks the pin file's format if it's /// there. #[test] fn pin_file_when_present_is_well_formed() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); - let pin = PathBuf::from(manifest_dir).join("cli-version.txt"); + let (filename, value_prefix) = if cfg!(feature = "bundled-in-process") { + ("cli-version-in-process.txt", Some("sha512-")) + } else { + ("cli-version.txt", None) + }; + let pin = PathBuf::from(manifest_dir).join(filename); if !pin.is_file() { // Contributor build path — no assertion needed. return; } - let contents = std::fs::read_to_string(&pin).expect("read cli-version.txt"); + let contents = std::fs::read_to_string(&pin).expect("read CLI version snapshot"); let mut saw_version = false; + let mut package_count = 0; for raw in contents.lines() { let line = raw.trim(); if line.is_empty() || line.starts_with('#') { @@ -222,9 +228,28 @@ fn pin_file_when_present_is_well_formed() { assert!(!value.trim().is_empty(), "empty value for key {key:?}"); if key.trim() == "version" { saw_version = true; + } else { + if let Some(prefix) = value_prefix { + assert!( + value.trim().starts_with(prefix), + "invalid npm integrity for key {key:?}" + ); + } else { + assert_eq!( + value.trim().len(), + 64, + "invalid SHA-256 hash for key {key:?}" + ); + assert!( + value.trim().bytes().all(|byte| byte.is_ascii_hexdigit()), + "invalid SHA-256 hash for key {key:?}" + ); + } + package_count += 1; } } - assert!(saw_version, "cli-version.txt missing `version=` line"); + assert!(saw_version, "{filename} missing `version=` line"); + assert_eq!(package_count, 6); } /// With `bundled-cli` on AND a supported target, `install_bundled_cli` @@ -246,6 +271,26 @@ fn install_bundled_cli_returns_extracted_path() { first, second, "install_bundled_cli must be idempotent across calls" ); + + #[cfg(feature = "bundled-in-process")] + { + let runtime_name = if cfg!(windows) { + "copilot_runtime.dll" + } else if cfg!(target_os = "macos") { + "libcopilot_runtime.dylib" + } else { + "libcopilot_runtime.so" + }; + let runtime = first + .parent() + .expect("install directory") + .join(runtime_name); + assert!( + runtime.is_file(), + "bundled runtime library was not installed: {}", + runtime.display() + ); + } } /// `install_bundled_cli` returns the same path the runtime resolver diff --git a/rust/tests/e2e.rs b/rust/tests/e2e.rs index 62412963b8..3a698abd18 100644 --- a/rust/tests/e2e.rs +++ b/rust/tests/e2e.rs @@ -37,6 +37,9 @@ mod github_telemetry; mod hooks; #[path = "e2e/hooks_extended.rs"] mod hooks_extended; +#[cfg(feature = "bundled-in-process")] +#[path = "e2e/inprocess.rs"] +mod inprocess; #[path = "e2e/mcp_and_agents.rs"] mod mcp_and_agents; #[path = "e2e/mcp_oauth.rs"] diff --git a/rust/tests/e2e/byok_bearer_token_provider.rs b/rust/tests/e2e/byok_bearer_token_provider.rs index fc3ef89d94..a7989d157f 100644 --- a/rust/tests/e2e/byok_bearer_token_provider.rs +++ b/rust/tests/e2e/byok_bearer_token_provider.rs @@ -142,6 +142,21 @@ async fn run_turn( #[tokio::test] async fn callback_token_is_applied_as_authorization_header() { + // The runtime's LLM inference provider slot is process-global and is never released + // when the registering connection disconnects (runtime `shared_api/llm_inference.rs`). + // Over the in-process transport all clients share this process's runtime, so once a + // BYOK provider is registered here and the client stops, the dangling registration + // routes every later model-inference request (list-models, tool-using turns, hooks, + // …) to the dead connection and hangs them. Registering a BYOK provider in-process + // therefore poisons the shared runtime for the rest of the suite. The BYOK bearer-token + // wiring is covered over stdio (a separate child process per test); the SDK-side + // request/response plumbing is transport-agnostic. + if super::support::skip_inprocess( + "registering a BYOK LLM inference provider is process-global in-process and is never \ + released on disconnect, poisoning later model-inference tests", + ) { + return; + } with_e2e_context_no_snapshot(|ctx| { Box::pin(async move { ctx.set_default_copilot_user(); @@ -189,6 +204,18 @@ async fn callback_token_is_applied_as_authorization_header() { #[tokio::test] async fn reacquires_a_fresh_token_for_each_request() { + // The runtime registers the LLM inference provider per connection and, by design, + // never releases the slot on disconnect (runtime `shared_api/llm_inference.rs`). Over + // the in-process transport every client shares this process's runtime, so a second + // provider-registering client is refused ("Another client is already the LLM + // inference provider"). The BYOK bearer-token behavior over the in-process transport + // is covered by `callback_token_is_applied_as_authorization_header`; this scenario's + // provider-dispatch logic is transport-agnostic and is covered over stdio. + if super::support::skip_inprocess( + "llmInference.setProvider is process-global in-process; a second provider client is refused", + ) { + return; + } with_e2e_context_no_snapshot(|ctx| { Box::pin(async move { ctx.set_default_copilot_user(); @@ -252,6 +279,16 @@ async fn reacquires_a_fresh_token_for_each_request() { #[tokio::test] async fn dispatches_token_acquisition_per_provider() { + // See `reacquires_a_fresh_token_for_each_request`: in-process, the process-global LLM + // inference provider registration is not released on disconnect, so this additional + // provider-registering client is refused. The BYOK transport path is covered in-process + // by `callback_token_is_applied_as_authorization_header`; the per-provider dispatch + // logic exercised here is transport-agnostic and covered over stdio. + if super::support::skip_inprocess( + "llmInference.setProvider is process-global in-process; a second provider client is refused", + ) { + return; + } with_e2e_context_no_snapshot(|ctx| { Box::pin(async move { ctx.set_default_copilot_user(); diff --git a/rust/tests/e2e/client.rs b/rust/tests/e2e/client.rs index 114e828ac9..6dd0f27acf 100644 --- a/rust/tests/e2e/client.rs +++ b/rust/tests/e2e/client.rs @@ -64,8 +64,7 @@ async fn should_get_authenticated_status() { Box::pin(async move { ctx.set_default_copilot_user(); let client = Client::start( - ctx.client_options() - .with_github_token(super::support::DEFAULT_TEST_TOKEN), + ctx.client_options_with_github_token(super::support::DEFAULT_TEST_TOKEN), ) .await .expect("start client"); @@ -85,8 +84,7 @@ async fn should_list_models_when_authenticated() { Box::pin(async move { ctx.set_default_copilot_user(); let client = Client::start( - ctx.client_options() - .with_github_token(super::support::DEFAULT_TEST_TOKEN), + ctx.client_options_with_github_token(super::support::DEFAULT_TEST_TOKEN), ) .await .expect("start client"); diff --git a/rust/tests/e2e/client_options.rs b/rust/tests/e2e/client_options.rs index dbf3c5c83d..85ef835025 100644 --- a/rust/tests/e2e/client_options.rs +++ b/rust/tests/e2e/client_options.rs @@ -6,7 +6,7 @@ use github_copilot_sdk::rpc::{OpenCanvasInstance, RemoteSessionMode}; use github_copilot_sdk::session_events::{ReasoningSummary, SessionLimitsConfig}; use github_copilot_sdk::{ CliProgram, Client, ClientOptions, ExtensionInfo, ProviderConfig, ResumeSessionConfig, - SessionConfig, SessionId, + SessionConfig, SessionId, Transport, }; use serde::Deserialize; use serde_json::{Value, json}; @@ -236,6 +236,7 @@ async fn should_forward_advanced_session_resume_options_to_the_cli() { canvas_id: "resume-canvas".to_string(), extension_id: "github-app/rust-e2e-extension".to_string(), extension_name: None, + icon: None, input: Some(json!({ "value": "from-resume" })), instance_id: "resume-instance".to_string(), status: None, @@ -347,6 +348,7 @@ impl FakeCli { ]) .with_github_token(token) .with_use_logged_in_user(false) + .with_transport(Transport::Stdio) } fn path(&self, name: &str) -> PathBuf { diff --git a/rust/tests/e2e/copilot_request_handler.rs b/rust/tests/e2e/copilot_request_handler.rs index 6ca99393bf..46b4e510cd 100644 --- a/rust/tests/e2e/copilot_request_handler.rs +++ b/rust/tests/e2e/copilot_request_handler.rs @@ -502,6 +502,9 @@ async fn start_ws_upstream(counters: HandlerCounters) -> String { #[tokio::test] async fn services_http_and_websocket_via_handler() { + if super::support::skip_inprocess("LLM inference providers are process-global in-process") { + return; + } with_e2e_context_no_snapshot(|ctx| { Box::pin(async move { ctx.set_default_copilot_user(); @@ -572,16 +575,25 @@ async fn services_http_and_websocket_via_handler() { #[derive(Default)] struct RecordingHandler { - records: std::sync::Mutex)>>, + records: std::sync::Mutex>, +} + +#[derive(Clone)] +struct InterceptedRequest { + url: String, + session_id: Option, + agent_id: Option, + parent_agent_id: Option, + interaction_type: Option, } impl RecordingHandler { - fn inference_records(&self) -> Vec<(String, Option)> { + fn inference_records(&self) -> Vec { self.records .lock() .unwrap() .iter() - .filter(|(url, _)| is_inference_url(url)) + .filter(|record| is_inference_url(&record.url)) .cloned() .collect() } @@ -594,10 +606,13 @@ impl CopilotRequestHandler for RecordingHandler { request: CopilotHttpRequest, ctx: &CopilotRequestContext, ) -> Result { - self.records - .lock() - .unwrap() - .push((request.url.clone(), ctx.session_id.clone())); + self.records.lock().unwrap().push(InterceptedRequest { + url: request.url.clone(), + session_id: ctx.session_id.clone(), + agent_id: ctx.agent_id.clone(), + parent_agent_id: ctx.parent_agent_id.clone(), + interaction_type: ctx.interaction_type.clone(), + }); if is_inference_url(&request.url) { Ok(synth_inference_response( &request.url, @@ -612,6 +627,9 @@ impl CopilotRequestHandler for RecordingHandler { #[tokio::test] async fn threads_session_id_into_inference() { + if super::support::skip_inprocess("LLM inference providers are process-global in-process") { + return; + } with_e2e_context_no_snapshot(|ctx| { Box::pin(async move { ctx.set_default_copilot_user(); @@ -632,12 +650,13 @@ async fn threads_session_id_into_inference() { !inference.is_empty(), "expected at least one intercepted inference request" ); - for (_, session_id) in &inference { + for record in &inference { assert_eq!( - session_id.as_deref(), + record.session_id.as_deref(), Some(capi_session_id.as_str()), "CAPI inference request must carry the session id" ); + assert_agent_metadata(record); } assert!( assistant_text(&result).contains("OK from the synthetic"), @@ -671,12 +690,13 @@ async fn threads_session_id_into_inference() { inference.len() > before, "expected at least one intercepted BYOK inference request" ); - for (_, session_id) in &inference[before..] { + for record in &inference[before..] { assert_eq!( - session_id.as_deref(), + record.session_id.as_deref(), Some(byok_session_id.as_str()), "BYOK inference request must carry the session id" ); + assert_agent_metadata(record); } assert_ne!( byok_session_id, capi_session_id, @@ -694,6 +714,26 @@ async fn threads_session_id_into_inference() { .await; } +fn assert_agent_metadata(record: &InterceptedRequest) { + assert!( + record.agent_id.as_deref().is_some_and(|id| !id.is_empty()), + "inference request must carry an agent id" + ); + if let Some(parent_agent_id) = record.parent_agent_id.as_deref() { + assert!( + !parent_agent_id.is_empty(), + "parent agent id must be non-empty when present" + ); + } + assert!( + record + .interaction_type + .as_deref() + .is_some_and(|kind| !kind.is_empty()), + "inference request must carry an interaction type" + ); +} + // --------------------------------------------------------------------------- // Scenario 3a: errors — a handler that returns `Err` on an inference request // surfaces a transport error rather than hanging the turn. @@ -723,6 +763,9 @@ impl CopilotRequestHandler for ThrowingHandler { #[tokio::test] async fn surfaces_handler_errors() { + if super::support::skip_inprocess("LLM inference providers are process-global in-process") { + return; + } with_e2e_context_no_snapshot(|ctx| { Box::pin(async move { ctx.set_default_copilot_user(); @@ -789,6 +832,9 @@ impl CopilotRequestHandler for CancellingHandler { #[tokio::test] async fn observes_runtime_driven_cancel() { + if super::support::skip_inprocess("LLM inference providers are process-global in-process") { + return; + } with_e2e_context_no_snapshot(|ctx| { Box::pin(async move { ctx.set_default_copilot_user(); diff --git a/rust/tests/e2e/hooks_extended.rs b/rust/tests/e2e/hooks_extended.rs index 259d462d56..ab93a0c3cd 100644 --- a/rust/tests/e2e/hooks_extended.rs +++ b/rust/tests/e2e/hooks_extended.rs @@ -36,7 +36,7 @@ async fn should_invoke_onsessionstart_hook_on_new_session() { session.send_and_wait("Say hi").await.expect("send"); let input = recv_with_timeout(&mut rx, "sessionStart hook").await; assert_eq!(input.source, "new"); - assert!(input.timestamp > 0); + assert!(input.timestamp > 0.0); assert!(!input.working_directory.as_os_str().is_empty()); session.disconnect().await.expect("disconnect session"); @@ -68,7 +68,7 @@ async fn should_invoke_onuserpromptsubmitted_hook_when_sending_a_message() { session.send_and_wait("Say hello").await.expect("send"); let input = recv_with_timeout(&mut rx, "userPromptSubmitted hook").await; assert!(input.prompt.contains("Say hello")); - assert!(input.timestamp > 0); + assert!(input.timestamp > 0.0); assert!(!input.working_directory.as_os_str().is_empty()); session.disconnect().await.expect("disconnect session"); @@ -100,7 +100,7 @@ async fn should_invoke_onsessionend_hook_when_session_is_disconnected() { session.send_and_wait("Say hi").await.expect("send"); session.disconnect().await.expect("disconnect session"); let input = recv_with_timeout(&mut rx, "sessionEnd hook").await; - assert!(input.timestamp > 0); + assert!(input.timestamp > 0.0); assert!(!input.working_directory.as_os_str().is_empty()); client.stop().await.expect("stop client"); @@ -237,7 +237,7 @@ async fn should_invoke_sessionend_hook() { session.send_and_wait("Say bye").await.expect("send"); session.disconnect().await.expect("disconnect session"); let input = recv_with_timeout(&mut rx, "sessionEnd hook").await; - assert!(input.timestamp > 0); + assert!(input.timestamp > 0.0); client.stop().await.expect("stop client"); }) @@ -412,7 +412,7 @@ async fn should_invoke_posttoolusefailure_hook_for_failed_tool_result() { .as_str() .is_some_and(|path| path.contains("missing.txt")) ); - assert!(input.timestamp > 0); + assert!(input.timestamp > 0.0); assert!(!input.working_directory.as_os_str().is_empty()); assert!( assistant_message_content(&answer).contains("HOOK_FAILURE_GUIDANCE_APPLIED") diff --git a/rust/tests/e2e/inprocess.rs b/rust/tests/e2e/inprocess.rs new file mode 100644 index 0000000000..0c183a27df --- /dev/null +++ b/rust/tests/e2e/inprocess.rs @@ -0,0 +1,25 @@ +use super::support::with_e2e_context; + +/// Starts an in-process client, performs a round-trip, and stops cleanly. +/// Fails hard if the in-process runtime library cannot be loaded. +#[tokio::test] +async fn should_start_ping_and_stop_inprocess_client() { + with_e2e_context("client", "should_start_ping_and_stop_stdio_client", |ctx| { + Box::pin(async move { + let client = ctx.start_inprocess_client().await; + + let response = client + .ping(Some("hello from rust in-process")) + .await + .expect("ping over in-process FFI transport"); + assert_eq!(response.message, "pong: hello from rust in-process"); + assert!(!response.timestamp.is_empty()); + + let status = client.get_status().await.expect("get status"); + assert!(status.protocol_version > 0); + + client.stop().await.expect("stop in-process client"); + }) + }) + .await; +} diff --git a/rust/tests/e2e/mcp_oauth.rs b/rust/tests/e2e/mcp_oauth.rs index f330df1155..5a11ef4b7a 100644 --- a/rust/tests/e2e/mcp_oauth.rs +++ b/rust/tests/e2e/mcp_oauth.rs @@ -8,7 +8,7 @@ use github_copilot_sdk::handler::{McpAuthHandler, McpAuthRequest, McpAuthResult} use github_copilot_sdk::rpc::{McpAppsCallToolRequest, McpListToolsRequest}; use github_copilot_sdk::session::Session; use github_copilot_sdk::session_events::{McpOauthRequestReason, McpServerStatus}; -use github_copilot_sdk::{McpHttpServerConfig, McpServerConfig, RequestId, SessionId}; +use github_copilot_sdk::{IndexMap, McpHttpServerConfig, McpServerConfig, RequestId, SessionId}; use parking_lot::Mutex; use serde::Deserialize; use serde_json::Value; @@ -40,7 +40,7 @@ async fn should_satisfy_mcp_oauth_using_host_provided_token() { .create_session( ctx.approve_all_session_config() .with_mcp_auth_handler(handler.clone()) - .with_mcp_servers(HashMap::from([( + .with_mcp_servers(IndexMap::from([( server_name.to_string(), McpServerConfig::Http(McpHttpServerConfig { tools: Some(vec!["*".to_string()]), @@ -129,7 +129,7 @@ async fn should_request_replacement_tokens_across_mcp_oauth_lifecycle() { ctx.approve_all_session_config() .with_enable_mcp_apps(true) .with_mcp_auth_handler(handler.clone()) - .with_mcp_servers(HashMap::from([( + .with_mcp_servers(IndexMap::from([( server_name.to_string(), McpServerConfig::Http(McpHttpServerConfig { tools: Some(vec!["*".to_string()]), @@ -194,7 +194,7 @@ async fn should_cancel_pending_mcp_oauth_request() { .create_session( ctx.approve_all_session_config() .with_mcp_auth_handler(handler.clone()) - .with_mcp_servers(HashMap::from([( + .with_mcp_servers(IndexMap::from([( server_name.to_string(), McpServerConfig::Http(McpHttpServerConfig { tools: Some(vec!["*".to_string()]), @@ -250,7 +250,7 @@ async fn should_resolve_pending_mcp_oauth_request_through_rpc() { ctx.approve_all_session_config() .with_enable_mcp_apps(true) .with_mcp_auth_handler(handler) - .with_mcp_servers(HashMap::from([( + .with_mcp_servers(IndexMap::from([( server_name.to_string(), McpServerConfig::Http(McpHttpServerConfig { tools: Some(vec!["*".to_string()]), diff --git a/rust/tests/e2e/per_session_auth.rs b/rust/tests/e2e/per_session_auth.rs index b2fd11e4d4..efb005b590 100644 --- a/rust/tests/e2e/per_session_auth.rs +++ b/rust/tests/e2e/per_session_auth.rs @@ -7,6 +7,9 @@ use super::support::with_e2e_context; #[tokio::test] async fn session_uses_client_token_when_no_session_token_is_supplied() { + if super::support::skip_inprocess("client-level GitHub tokens are not supported in-process") { + return; + } with_e2e_context( "per-session-auth", "session_uses_client_token_when_no_session_token_is_supplied", @@ -47,6 +50,9 @@ async fn session_uses_client_token_when_no_session_token_is_supplied() { #[tokio::test] async fn session_token_overrides_client_token() { + if super::support::skip_inprocess("client-level GitHub tokens are not supported in-process") { + return; + } with_e2e_context( "per-session-auth", "session_token_overrides_client_token", @@ -93,7 +99,11 @@ async fn session_auth_status_is_unauthenticated_without_token() { "session_auth_status_is_unauthenticated_without_token", |ctx| { Box::pin(async move { - let client = ctx.start_client().await; + let client = github_copilot_sdk::Client::start( + ctx.client_options().with_use_logged_in_user(false), + ) + .await + .expect("start client"); let session = client .create_session( SessionConfig::default() diff --git a/rust/tests/e2e/pre_mcp_tool_call_hook.rs b/rust/tests/e2e/pre_mcp_tool_call_hook.rs index 973672f709..fd05796fcd 100644 --- a/rust/tests/e2e/pre_mcp_tool_call_hook.rs +++ b/rust/tests/e2e/pre_mcp_tool_call_hook.rs @@ -1,23 +1,22 @@ -use std::collections::HashMap; use std::sync::Arc; use async_trait::async_trait; use github_copilot_sdk::hooks::{ HookContext, PreMcpToolCallInput, PreMcpToolCallOutput, SessionHooks, }; -use github_copilot_sdk::{McpServerConfig, McpStdioServerConfig}; +use github_copilot_sdk::{IndexMap, McpServerConfig, McpStdioServerConfig}; use serde_json::{Value, json}; use tokio::sync::mpsc; use super::support::{assistant_message_content, recv_with_timeout, with_e2e_context}; -fn meta_echo_mcp_servers(repo_root: &std::path::Path) -> HashMap { +fn meta_echo_mcp_servers(repo_root: &std::path::Path) -> IndexMap { let harness_dir = repo_root.join("test").join("harness"); let server_path = harness_dir .join("test-mcp-meta-echo-server.mjs") .to_string_lossy() .to_string(); - HashMap::from([( + IndexMap::from([( "meta-echo".to_string(), McpServerConfig::Stdio(McpStdioServerConfig { tools: Some(vec!["*".to_string()]), @@ -127,7 +126,7 @@ async fn should_set_meta_via_premcptoolcall_hook() { assert_eq!(input.server_name, "meta-echo"); assert_eq!(input.tool_name, "echo_meta"); assert!(!input.working_directory.as_os_str().is_empty()); - assert!(input.timestamp > 0); + assert!(input.timestamp > 0.0); session.disconnect().await.expect("disconnect session"); client.stop().await.expect("stop client"); diff --git a/rust/tests/e2e/provider_endpoint.rs b/rust/tests/e2e/provider_endpoint.rs index df6d5941dd..3953aad669 100644 --- a/rust/tests/e2e/provider_endpoint.rs +++ b/rust/tests/e2e/provider_endpoint.rs @@ -15,6 +15,7 @@ fn opt_in_env() -> (OsString, OsString) { } #[tokio::test] +#[allow(deprecated)] async fn byok_provider_endpoint_returns_configured_endpoint() { with_e2e_context( "provider-endpoint", @@ -22,7 +23,9 @@ async fn byok_provider_endpoint_returns_configured_endpoint() { |ctx| { Box::pin(async move { let mut options = ctx.client_options(); - options.env.push(opt_in_env()); + if !super::support::is_inprocess_default() { + options.env.push(opt_in_env()); + } let client = github_copilot_sdk::Client::start(options) .await .expect("start client"); @@ -86,6 +89,7 @@ async fn byok_provider_endpoint_returns_configured_endpoint() { } #[tokio::test] +#[allow(deprecated)] async fn capi_provider_endpoint_returns_resolved_credentials() { with_e2e_context( "provider-endpoint", @@ -93,8 +97,10 @@ async fn capi_provider_endpoint_returns_resolved_credentials() { |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); - let mut options = ctx.client_options().with_github_token(DEFAULT_TEST_TOKEN); - options.env.push(opt_in_env()); + let mut options = ctx.client_options_with_github_token(DEFAULT_TEST_TOKEN); + if !super::support::is_inprocess_default() { + options.env.push(opt_in_env()); + } let client = github_copilot_sdk::Client::start(options) .await .expect("start client"); diff --git a/rust/tests/e2e/rpc_mcp_and_skills.rs b/rust/tests/e2e/rpc_mcp_and_skills.rs index 3f6cc05025..eb8368ebcd 100644 --- a/rust/tests/e2e/rpc_mcp_and_skills.rs +++ b/rust/tests/e2e/rpc_mcp_and_skills.rs @@ -3,16 +3,16 @@ use std::path::Path; use github_copilot_sdk::rpc::{ ExtensionsDisableRequest, ExtensionsEnableRequest, McpAppsCallToolRequest, - McpAppsDiagnoseRequest, McpAppsListToolsRequest, McpAppsReadResourceRequest, - McpAppsSetHostContextDetails, McpAppsSetHostContextDetailsAvailableDisplayMode, - McpAppsSetHostContextDetailsDisplayMode, McpAppsSetHostContextDetailsPlatform, - McpAppsSetHostContextDetailsTheme, McpAppsSetHostContextRequest, - McpCancelSamplingExecutionParams, McpDisableRequest, McpEnableRequest, - McpExecuteSamplingParams, McpExecuteSamplingRequest, McpOauthLoginRequest, - McpSamplingExecutionAction, McpSetEnvValueModeDetails, McpSetEnvValueModeParams, + McpAppsDiagnoseRequest, McpAppsListToolsRequest, McpAppsSetHostContextDetails, + McpAppsSetHostContextDetailsAvailableDisplayMode, McpAppsSetHostContextDetailsDisplayMode, + McpAppsSetHostContextDetailsPlatform, McpAppsSetHostContextDetailsTheme, + McpAppsSetHostContextRequest, McpCancelSamplingExecutionParams, McpDisableRequest, + McpEnableRequest, McpExecuteSamplingParams, McpExecuteSamplingRequest, McpOauthLoginRequest, + McpResourcesReadRequest, McpSamplingExecutionAction, McpSetEnvValueModeDetails, + McpSetEnvValueModeParams, PermissionsAllowAllMode, PermissionsSetAllowAllRequest, SkillsDisableRequest, SkillsEnableRequest, }; -use github_copilot_sdk::{McpServerConfig, McpStdioServerConfig}; +use github_copilot_sdk::{IndexMap, McpServerConfig, McpStdioServerConfig}; use super::support::with_e2e_context; @@ -340,14 +340,22 @@ async fn should_list_extensions() { with_e2e_context("rpc_mcp_and_skills", "should_list_extensions", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); - let client = - github_copilot_sdk::Client::start(ctx.client_options().with_extra_args(["--yolo"])) - .await - .expect("start yolo client"); + let client = ctx.start_client().await; let session = client .create_session(ctx.approve_all_session_config()) .await .expect("create session"); + session + .rpc() + .permissions() + .set_allow_all(PermissionsSetAllowAllRequest { + enabled: None, + mode: Some(PermissionsAllowAllMode::On), + model: None, + source: None, + }) + .await + .expect("enable allow-all"); let result = session .rpc() @@ -510,8 +518,8 @@ async fn should_report_error_when_mcp_app_resource_is_not_available() { let err = session .rpc() .mcp() - .apps() - .read_resource(McpAppsReadResourceRequest { + .resources() + .read(McpResourcesReadRequest { server_name: "missing-app-server".to_string(), uri: "ui://missing/resource.html".to_string(), }) @@ -678,15 +686,22 @@ async fn should_report_error_when_extensions_are_not_available() { |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); - let client = github_copilot_sdk::Client::start( - ctx.client_options().with_extra_args(["--yolo"]), - ) - .await - .expect("start client"); + let client = ctx.start_client().await; let session = client .create_session(ctx.approve_all_session_config()) .await .expect("create session"); + session + .rpc() + .permissions() + .set_allow_all(PermissionsSetAllowAllRequest { + enabled: None, + mode: Some(PermissionsAllowAllMode::On), + model: None, + source: None, + }) + .await + .expect("enable allow-all"); expect_err_contains( session.rpc().extensions().enable(ExtensionsEnableRequest { @@ -761,14 +776,14 @@ fn assert_skill( skill } -fn test_mcp_servers(repo_root: &Path, server_name: &str) -> HashMap { +fn test_mcp_servers(repo_root: &Path, server_name: &str) -> IndexMap { let harness_dir = repo_root.join("test").join("harness"); let server_path = harness_dir .join("test-mcp-server.mjs") .to_string_lossy() .to_string(); - HashMap::from([( + IndexMap::from([( server_name.to_string(), McpServerConfig::Stdio(McpStdioServerConfig { tools: Some(vec!["*".to_string()]), diff --git a/rust/tests/e2e/rpc_mcp_lifecycle.rs b/rust/tests/e2e/rpc_mcp_lifecycle.rs index fa2b15d866..028b5a527f 100644 --- a/rust/tests/e2e/rpc_mcp_lifecycle.rs +++ b/rust/tests/e2e/rpc_mcp_lifecycle.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::path::Path; use github_copilot_sdk::rpc::{ @@ -7,7 +6,7 @@ use github_copilot_sdk::rpc::{ }; use github_copilot_sdk::session::Session; use github_copilot_sdk::session_events::McpServerStatus; -use github_copilot_sdk::{Error, McpServerConfig, McpStdioServerConfig}; +use github_copilot_sdk::{Error, IndexMap, McpServerConfig, McpStdioServerConfig}; use serde::de::DeserializeOwned; use serde_json::{Value, json}; @@ -330,8 +329,8 @@ async fn should_configure_github_mcp_server() { fn create_test_mcp_servers( repo_root: &Path, server_name: &str, -) -> HashMap { - HashMap::from([(server_name.to_string(), test_mcp_server_config(repo_root))]) +) -> IndexMap { + IndexMap::from([(server_name.to_string(), test_mcp_server_config(repo_root))]) } fn test_mcp_server_config(repo_root: &Path) -> McpServerConfig { diff --git a/rust/tests/e2e/rpc_server.rs b/rust/tests/e2e/rpc_server.rs index 665041f49d..d0beab2452 100644 --- a/rust/tests/e2e/rpc_server.rs +++ b/rust/tests/e2e/rpc_server.rs @@ -54,7 +54,7 @@ async fn should_call_rpc_models_list_with_typed_result() { Box::pin(async move { let token = "rpc-models-token"; ctx.set_copilot_user_by_token_with_login(token, "rpc-user"); - let client = Client::start(ctx.client_options().with_github_token(token)) + let client = Client::start(ctx.client_options_with_github_token(token)) .await .expect("start client"); @@ -95,7 +95,7 @@ async fn should_call_rpc_account_get_quota_when_authenticated() { } })), ); - let client = Client::start(ctx.client_options().with_github_token(token)) + let client = Client::start(ctx.client_options_with_github_token(token)) .await .expect("start client"); diff --git a/rust/tests/e2e/rpc_server_plugins.rs b/rust/tests/e2e/rpc_server_plugins.rs index dc841b0af6..054ffa3599 100644 --- a/rust/tests/e2e/rpc_server_plugins.rs +++ b/rust/tests/e2e/rpc_server_plugins.rs @@ -31,6 +31,7 @@ async fn should_install_and_list_plugin_from_local_marketplace() { .marketplaces() .add(PluginsMarketplacesAddRequest { source: marketplace.source(), + working_directory: None, }) .await .expect("add marketplace"); @@ -79,6 +80,7 @@ async fn should_enable_and_disable_marketplace_plugin() { .marketplaces() .add(PluginsMarketplacesAddRequest { source: marketplace.source(), + working_directory: None, }) .await .expect("add marketplace"); @@ -148,6 +150,7 @@ async fn should_update_single_marketplace_plugin() { .marketplaces() .add(PluginsMarketplacesAddRequest { source: marketplace.source(), + working_directory: None, }) .await .expect("add marketplace"); @@ -196,6 +199,7 @@ async fn should_update_all_installed_plugins() { .marketplaces() .add(PluginsMarketplacesAddRequest { source: marketplace.source(), + working_directory: None, }) .await .expect("add marketplace"); @@ -326,6 +330,7 @@ async fn should_list_browse_refresh_and_remove_local_marketplace() { .marketplaces() .add(PluginsMarketplacesAddRequest { source: marketplace.source(), + working_directory: None, }) .await .expect("add marketplace"); diff --git a/rust/tests/e2e/rpc_session_state_extras.rs b/rust/tests/e2e/rpc_session_state_extras.rs index 148a4151c1..3bd6c99bee 100644 --- a/rust/tests/e2e/rpc_session_state_extras.rs +++ b/rust/tests/e2e/rpc_session_state_extras.rs @@ -22,7 +22,7 @@ async fn should_list_models_for_session() { Box::pin(async move { let token = "rpc-session-model-list-token"; ctx.set_copilot_user_by_token_with_login(token, "rpc-session-extras-user"); - let client = Client::start(ctx.client_options().with_github_token(token)) + let client = Client::start(ctx.client_options_with_github_token(token)) .await .expect("start authenticated client"); let session = client diff --git a/rust/tests/e2e/rpc_workspace_checkpoints.rs b/rust/tests/e2e/rpc_workspace_checkpoints.rs index 2c185a535a..0a8bf56152 100644 --- a/rust/tests/e2e/rpc_workspace_checkpoints.rs +++ b/rust/tests/e2e/rpc_workspace_checkpoints.rs @@ -40,6 +40,12 @@ async fn should_list_no_checkpoints_for_fresh_session() { #[tokio::test] async fn should_return_null_or_empty_content_for_unknown_checkpoint() { + // In-process, session.workspaces.readCheckpoint is answered by the native runtime, + // which decodes the checkpoint number as a u32 and rejects the i64::MAX sentinel this + // test uses. Covered by the default (stdio) transport. See issue #1934. + if super::support::skip_inprocess("readCheckpoint decodes the id as u32 in-process") { + return; + } with_e2e_context( "rpc_workspace_checkpoints", "should_return_null_or_empty_content_for_unknown_checkpoint", diff --git a/rust/tests/e2e/session_config.rs b/rust/tests/e2e/session_config.rs index 0a7a5eb11b..dd498e3761 100644 --- a/rust/tests/e2e/session_config.rs +++ b/rust/tests/e2e/session_config.rs @@ -515,6 +515,9 @@ fn assert_anthropic_document_citations_enabled(request_body: &[u8]) { #[tokio::test] async fn should_enable_citations_for_anthropic_file_attachments_on_create() { + if super::support::skip_inprocess("LLM inference providers are process-global in-process") { + return; + } with_e2e_context_no_snapshot(|ctx| { Box::pin(async move { ctx.set_default_copilot_user(); diff --git a/rust/tests/e2e/subagent_hooks.rs b/rust/tests/e2e/subagent_hooks.rs index 99529c433b..fe94c36779 100644 --- a/rust/tests/e2e/subagent_hooks.rs +++ b/rust/tests/e2e/subagent_hooks.rs @@ -5,12 +5,19 @@ use github_copilot_sdk::hooks::{ HookContext, PostToolUseInput, PostToolUseOutput, PreToolUseInput, PreToolUseOutput, SessionHooks, }; +use github_copilot_sdk::{ + CopilotHttpRequest, CopilotHttpResponse, CopilotRequestContext, CopilotRequestError, + CopilotRequestHandler, forward_http, +}; use parking_lot::Mutex; use super::support::with_e2e_context; #[tokio::test] async fn should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls() { + if super::support::skip_inprocess("LLM inference providers are process-global in-process") { + return; + } with_e2e_context( "subagent_hooks", "should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls", @@ -24,16 +31,14 @@ async fn should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls .expect("write test file"); let hook_log = Arc::new(Mutex::new(Vec::::new())); + let request_log = Arc::new(RecordingRequestHandler::default()); - let mut opts = ctx.client_options(); - opts.env.push(( - "COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS".into(), - "true".into(), - )); - - let client = github_copilot_sdk::Client::start(opts) - .await - .expect("start client"); + let client = ctx + .start_llm_client( + Arc::clone(&request_log), + &[("COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS", "true")], + ) + .await; let session = client .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( @@ -88,6 +93,7 @@ async fn should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls task_pre.unwrap().session_id, "Sub-agent tool hooks should have a different sessionId than parent tool hooks" ); + assert_subagent_request_metadata(&request_log.inference_records()); session.disconnect().await.expect("disconnect session"); client.stop().await.expect("stop client"); @@ -104,6 +110,90 @@ struct HookEntry { session_id: String, } +#[derive(Clone, Debug)] +struct RequestEntry { + url: String, + agent_id: Option, + parent_agent_id: Option, + interaction_type: Option, +} + +#[derive(Default)] +struct RecordingRequestHandler { + log: Mutex>, +} + +impl RecordingRequestHandler { + fn inference_records(&self) -> Vec { + self.log + .lock() + .iter() + .filter(|entry| is_inference_url(&entry.url)) + .cloned() + .collect() + } +} + +#[async_trait] +impl CopilotRequestHandler for RecordingRequestHandler { + async fn send_request( + &self, + request: CopilotHttpRequest, + ctx: &CopilotRequestContext, + ) -> Result { + self.log.lock().push(RequestEntry { + url: request.url.clone(), + agent_id: ctx.agent_id.clone(), + parent_agent_id: ctx.parent_agent_id.clone(), + interaction_type: ctx.interaction_type.clone(), + }); + forward_http(request).await + } +} + +fn is_inference_url(url: &str) -> bool { + let url = url.to_lowercase(); + url.ends_with("/chat/completions") + || url.ends_with("/responses") + || url.ends_with("/v1/messages") + || url.ends_with("/messages") +} + +fn assert_subagent_request_metadata(records: &[RequestEntry]) { + assert!( + !records.is_empty(), + "request handler should observe inference requests" + ); + let subagent_request = records + .iter() + .find(|entry| { + entry + .parent_agent_id + .as_deref() + .is_some_and(|id| !id.is_empty()) + }) + .expect("sub-agent inference request should carry a parentAgentId"); + assert!( + subagent_request + .agent_id + .as_deref() + .is_some_and(|id| !id.is_empty()), + "sub-agent inference request should carry an agentId" + ); + assert!( + subagent_request + .interaction_type + .as_deref() + .is_some_and(|kind| !kind.is_empty()), + "sub-agent inference request should carry an interactionType" + ); + assert_ne!( + subagent_request.parent_agent_id.as_deref(), + subagent_request.agent_id.as_deref(), + "sub-agent inference request should have distinct parent and child agent ids" + ); +} + struct RecordingHooks { log: Arc>>, } diff --git a/rust/tests/e2e/support.rs b/rust/tests/e2e/support.rs index 7e47d8fbae..6ad609f58e 100644 --- a/rust/tests/e2e/support.rs +++ b/rust/tests/e2e/support.rs @@ -1,4 +1,4 @@ -use std::ffi::OsString; +use std::ffi::{OsStr, OsString}; use std::future::Future; use std::io::{BufRead, BufReader, Read, Write}; use std::net::TcpStream; @@ -36,6 +36,13 @@ where .await .unwrap_or_else(|err| panic!("create E2E context: {err}")); + // In-process hosting: the runtime loads into this test process and its worker + // inherits the ambient environment (per-client env is not honored in-process, see + // https://github.com/github/copilot-sdk/issues/1934), so mirror this context's env + // onto the process for the duration of the test and restore on drop. Safe because + // E2E_CONCURRENCY is 1 in-process, serializing the whole critical section. + let _env_guard = InProcessEnvGuard::activate(&ctx); + let timed_out = tokio::time::timeout(default_test_timeout(), test(&mut ctx)) .await .is_err(); @@ -65,6 +72,10 @@ where .await .unwrap_or_else(|err| panic!("create E2E context: {err}")); + // See `with_e2e_context` for why the in-process transport mirrors env onto the + // process (restored on drop). + let _env_guard = InProcessEnvGuard::activate(&ctx); + let timed_out = tokio::time::timeout(default_test_timeout(), test(&mut ctx)) .await .is_err(); @@ -104,6 +115,7 @@ impl E2eContext { proxy: Some(proxy), }; ctx.configure(category, snapshot_name)?; + ctx.set_default_copilot_user(); Ok(ctx) } @@ -133,6 +145,7 @@ impl E2eContext { .map_err(|err| { std::io::Error::other(format!("configure proxy without snapshot failed: {err}")) })?; + ctx.set_default_copilot_user(); Ok(ctx) } @@ -164,12 +177,29 @@ impl E2eContext { self.client_options().with_transport(transport) } + pub fn client_options_with_github_token(&self, token: &str) -> ClientOptions { + self.client_options().with_github_token(token) + } + pub async fn start_client(&self) -> Client { Client::start(self.client_options()) .await .expect("start E2E client") } + /// Start a client that hosts the runtime in-process over FFI + /// ([`Transport::InProcess`]). Unlike the stdio harness, the CLI + /// entrypoint is passed as the program directly (the FFI host builds the + /// `node --embedded-host` argv itself and loads the sibling + /// runtime cdylib), so a `.js` entrypoint is not split into node + + /// prefix_args here. + pub async fn start_inprocess_client(&self) -> Client { + let options = ClientOptions::new().with_transport(Transport::InProcess); + Client::start(options) + .await + .expect("start in-process FFI E2E client") + } + /// Start a client wired to a Copilot request handler, appending `extra_env` /// to the spawned runtime's environment (used to flip the WebSocket ExP /// flag for the WS transport tests). @@ -302,11 +332,13 @@ impl E2eContext { ), ("COPILOT_MCP_APPS".into(), "true".into()), ("MCP_APPS".into(), "true".into()), + ("GH_TOKEN".into(), DEFAULT_TEST_TOKEN.into()), + ("GITHUB_TOKEN".into(), DEFAULT_TEST_TOKEN.into()), + ("GH_ENTERPRISE_TOKEN".into(), "".into()), + ("GITHUB_ENTERPRISE_TOKEN".into(), "".into()), + ("COPILOT_HMAC_KEY".into(), "".into()), + ("CAPI_HMAC_KEY".into(), "".into()), ]); - if std::env::var("GITHUB_ACTIONS").as_deref() == Ok("true") { - env.push(("GH_TOKEN".into(), "fake-token-for-e2e-tests".into())); - env.push(("GITHUB_TOKEN".into(), "fake-token-for-e2e-tests".into())); - } env } @@ -528,6 +560,12 @@ fn default_test_timeout() -> Duration { } fn e2e_concurrency() -> usize { + // The in-process transport mirrors per-test environment onto the shared process + // environment (see `InProcessEnvGuard`), which is only coherent when one test runs + // at a time. Force serial execution in-process; otherwise honor RUST_E2E_CONCURRENCY. + if is_inprocess_default() { + return 1; + } std::env::var("RUST_E2E_CONCURRENCY") .ok() .and_then(|value| value.parse::().ok()) @@ -535,6 +573,104 @@ fn e2e_concurrency() -> usize { .unwrap_or(4) } +/// True when the E2E suite runs over the in-process (FFI) transport, i.e. the SDK +/// resolves `COPILOT_SDK_DEFAULT_CONNECTION=inprocess` to [`Transport::InProcess`]. +pub fn is_inprocess_default() -> bool { + std::env::var("COPILOT_SDK_DEFAULT_CONNECTION") + .map(|value| value.eq_ignore_ascii_case("inprocess")) + .unwrap_or(false) +} + +/// Skip guard for E2E tests exercising features the in-process (FFI) transport does not +/// support (the runtime loads into the shared host process). Returns `true` — and logs — +/// when running in-process so the caller can `return` early; such tests remain covered +/// by the default (stdio) transport. See . +pub fn skip_inprocess(reason: &str) -> bool { + if is_inprocess_default() { + eprintln!("skipping test over the in-process (FFI) transport: {reason}"); + true + } else { + false + } +} + +/// Mirrors an [`E2eContext`]'s environment onto the real process environment for the +/// in-process transport, whose worker inherits this process's ambient environment +/// rather than a per-client env block. Restores the previous values on drop. Only the +/// in-process transport needs this; for stdio/tcp the environment is handed to the +/// spawned child directly. Auth flows via GH_TOKEN/GITHUB_TOKEN and HMAC is disabled so +/// host-side auth resolution picks the token the replay snapshots expect. +struct InProcessEnvGuard { + saved: Vec<(OsString, Option)>, + previous_cwd: PathBuf, +} + +impl InProcessEnvGuard { + /// Returns `Some` guard (having applied the env) when in-process, else `None`. + fn activate(ctx: &E2eContext) -> Option { + if !is_inprocess_default() { + return None; + } + let mut pairs: Vec<(OsString, OsString)> = ctx.environment(); + pairs.retain(|(key, _)| { + key.as_os_str() != OsStr::new("COPILOT_HMAC_KEY") + && key.as_os_str() != OsStr::new("CAPI_HMAC_KEY") + }); + pairs.push(("COPILOT_SDK_AUTH_TOKEN".into(), "".into())); + pairs.push(( + "COPILOT_CLI_PATH".into(), + ctx.cli_path.clone().into_os_string(), + )); + // Some tests opt into gated runtime APIs via per-client `options.env`, which the + // in-process transport does not pass to the shared native runtime (see issue #1934). + // These are process-global runtime gates (not per-client behavior), so applying + // them to the host process for the serial in-process suite is equivalent and + // inert for tests that don't exercise the gated API. + pairs.push(("COPILOT_ALLOW_GET_PROVIDER_ENDPOINT".into(), "true".into())); + pairs.push(( + "COPILOT_EXP_COPILOT_CLI_WEBSOCKET_RESPONSES".into(), + "true".into(), + )); + pairs.push(( + "COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS".into(), + "true".into(), + )); + + let mut saved: Vec<(OsString, Option)> = Vec::new(); + for (key, value) in &pairs { + saved.push((key.clone(), std::env::var_os(key))); + // SAFETY: the E2E suite runs serially in-process (concurrency 1), so no + // other thread races these process-wide env mutations. + unsafe { std::env::set_var(key, value) }; + } + for key in ["COPILOT_HMAC_KEY", "CAPI_HMAC_KEY"] { + let key = OsString::from(key); + saved.push((key.clone(), std::env::var_os(&key))); + // SAFETY: as above, the in-process suite is serialized. + unsafe { std::env::remove_var(key) }; + } + let previous_cwd = std::env::current_dir().expect("read in-process test cwd"); + std::env::set_current_dir(ctx.work_dir()).expect("set in-process test cwd"); + Some(Self { + saved, + previous_cwd, + }) + } +} + +impl Drop for InProcessEnvGuard { + fn drop(&mut self) { + std::env::set_current_dir(&self.previous_cwd).expect("restore in-process test cwd"); + for (key, previous) in self.saved.iter().rev() { + // SAFETY: as in `activate` — serial execution in-process. + match previous { + Some(value) => unsafe { std::env::set_var(key, value) }, + None => unsafe { std::env::remove_var(key) }, + } + } + } +} + pub fn get_system_message(exchange: &serde_json::Value) -> String { exchange .get("request") @@ -617,11 +753,15 @@ fn cli_path(repo_root: &Path) -> std::io::Result { )) } +#[allow(deprecated)] fn client_options_for_cli( cli_path: &Path, cwd: &Path, env: Vec<(OsString, OsString)>, ) -> ClientOptions { + if is_inprocess_default() { + return ClientOptions::new(); + } let options = ClientOptions::new() .with_cwd(cwd) .with_env(env) diff --git a/rust/tests/e2e/telemetry.rs b/rust/tests/e2e/telemetry.rs index 38bf4a404a..f6905427ae 100644 --- a/rust/tests/e2e/telemetry.rs +++ b/rust/tests/e2e/telemetry.rs @@ -13,6 +13,11 @@ use super::support::{assistant_message_content, with_e2e_context}; #[tokio::test] async fn should_export_file_telemetry_for_sdk_interactions() { + // Telemetry lowers to environment variables the in-process worker cannot receive + // per-client; covered by the default (stdio) transport. See issue #1934. + if super::support::skip_inprocess("telemetry configuration is not honored in-process") { + return; + } with_e2e_context( "telemetry", "should_export_file_telemetry_for_sdk_interactions", diff --git a/rust/tests/e2e/tool_results.rs b/rust/tests/e2e/tool_results.rs index a6047007ff..e6e62643fa 100644 --- a/rust/tests/e2e/tool_results.rs +++ b/rust/tests/e2e/tool_results.rs @@ -213,14 +213,7 @@ async fn recv_called(receiver: &mut mpsc::UnboundedReceiver<()>, description: &' } fn expanded(text: impl Into, result_type: impl Into) -> ToolResult { - ToolResult::Expanded(ToolResultExpanded { - text_result_for_llm: text.into(), - result_type: result_type.into(), - binary_results_for_llm: None, - session_log: None, - error: None, - tool_telemetry: None, - }) + ToolResult::Expanded(ToolResultExpanded::new(text, result_type)) } fn weather_tool() -> Tool { diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 2599ea6d3a..122ec475df 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -20,10 +20,10 @@ use github_copilot_sdk::session_events::{ McpOauthRequiredData, ReasoningSummary, SessionLimitsConfig, }; use github_copilot_sdk::types::{ - CloudSessionOptions, CloudSessionRepository, CommandContext, CommandDefinition, CommandHandler, - DeliveryMode, ElicitationRequest, ElicitationResult, ExitPlanModeData, ExtensionInfo, - MessageOptions, RequestId, SessionConfig, SessionId, SetModelOptions, Tool, ToolInvocation, - ToolResult, + CanvasProviderIdentity, CloudSessionOptions, CloudSessionRepository, CommandContext, + CommandDefinition, CommandHandler, DeliveryMode, ElicitationRequest, ElicitationResult, + ExitPlanModeData, ExtensionInfo, MessageOptions, RequestId, SessionConfig, SessionId, + SetModelOptions, Tool, ToolInvocation, ToolResult, }; use github_copilot_sdk::{Client, ContextTier, ErrorKind, ProtocolErrorKind, tool}; use serde_json::Value; @@ -720,7 +720,11 @@ async fn create_session_sends_canvas_wire_fields() { .with_canvases([test_canvas("counter")]) .with_request_canvas_renderer(true) .with_request_extensions(true) - .with_extension_info(ExtensionInfo::new("github-app", "counter-provider")), + .with_extension_info(ExtensionInfo::new("github-app", "counter-provider")) + .with_canvas_provider( + CanvasProviderIdentity::new("app:builtin:window-1") + .with_name("Built-in"), + ), ) .await .unwrap() @@ -741,6 +745,11 @@ async fn create_session_sends_canvas_wire_fields() { request["params"]["extensionInfo"]["name"], "counter-provider" ); + assert_eq!( + request["params"]["canvasProvider"]["id"], + "app:builtin:window-1" + ); + assert_eq!(request["params"]["canvasProvider"]["name"], "Built-in"); let id = request["id"].as_u64().unwrap(); let session_id = requested_session_id(&request).to_string(); @@ -3311,11 +3320,13 @@ async fn resume_session_sends_canvas_fields_and_captures_open_canvases() { .with_request_canvas_renderer(true) .with_request_extensions(true) .with_extension_info(ExtensionInfo::new("github-app", "counter-provider")) + .with_canvas_provider(CanvasProviderIdentity::new("app:builtin:window-1")) .with_open_canvases([OpenCanvasInstance { instance_id: "counter-1".to_string(), extension_id: "github-app:counter-provider".to_string(), extension_name: Some("Counter Provider".to_string()), canvas_id: "counter".to_string(), + icon: None, title: Some("Counter".to_string()), status: Some("ready".to_string()), url: Some("https://example.test/counter".to_string()), @@ -3335,6 +3346,14 @@ async fn resume_session_sends_canvas_fields_and_captures_open_canvases() { request["params"]["extensionInfo"]["name"], "counter-provider" ); + assert_eq!( + request["params"]["canvasProvider"]["id"], + "app:builtin:window-1" + ); + assert!( + request["params"]["canvasProvider"].get("name").is_none(), + "name should be omitted from the wire when None, not serialized as null" + ); assert_eq!( request["params"]["openCanvases"][0]["instanceId"], "counter-1" diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index c9a5f8237d..24ec217f5b 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -27,6 +27,7 @@ import { findSharedSchemaDefinitions, postProcessSchema, propagateInternalVisibility, + filterNodeByVisibility, resolveRef, resolveObjectSchema, resolveSchema, @@ -230,7 +231,8 @@ function stripDurationMillisecondsSuffix(name: string): string { } function toCSharpPropertyName(propName: string, schema: JSONSchema7): string { - return toPascalCase(isDurationProperty(schema) ? stripDurationMillisecondsSuffix(propName) : propName); + const normalizedName = propName.replace(/^_+/, "") || propName; + return toPascalCase(isDurationProperty(schema) ? stripDurationMillisecondsSuffix(normalizedName) : normalizedName); } function isSecondsDurationPropertyName(propName: string | undefined): boolean { @@ -1321,7 +1323,7 @@ function emitSessionEventEnvelopeProperty( export function generateSessionEventsCode(schema: JSONSchema7): string { generatedEnums.clear(); sessionDefinitions = collectDefinitionCollections(schema as Record); - const variants = extractEventVariants(schema); + const variants = extractEventVariants(schema).filter((variant) => !isSchemaInternal(variant.dataSchema)); const knownTypes = new Map(); const nestedClasses = new Map(); const enumOutput: string[] = []; @@ -1380,12 +1382,16 @@ namespace GitHub.Copilot; if (variant.eventExperimental) { pushExperimentalAttribute(lines); } - const variantVisibility = isSchemaInternal(variant.dataSchema) ? "internal" : "public"; - lines.push(`${variantVisibility} sealed partial class ${variant.className} : SessionEvent`, `{`); + lines.push(`public sealed partial class ${variant.className} : SessionEvent`, `{`); lines.push(` /// `); lines.push(` [JsonIgnore]`, ` public override string Type => "${variant.typeName}";`, ""); lines.push(` /// The ${escapeXml(variant.typeName)} event payload.`); - lines.push(` [JsonPropertyName("data")]`, ` ${variantVisibility} required ${variant.dataClassName} Data { get; set; }`, `}`, ""); + lines.push( + ` [JsonPropertyName("data")]`, + ` public required ${variant.dataClassName} Data { get; set; }`, + `}`, + "" + ); } // Data classes @@ -2485,11 +2491,23 @@ function generateRpcCode( let sessionRpcParts: string[] = []; if (schema.session) sessionRpcParts = emitSessionRpcClasses(schema.session, classes); + // Client handler surfaces (interfaces, handler properties, RPC registration) + // are only generated for public methods. Internal client methods (e.g. + // `hooks.invoke`) are runtime transport plumbing and must not surface any + // generated code — including their request/result DTOs, which would + // otherwise leak as `internal` types referenced by a `public` handler + // interface (CS0050/CS0051 inconsistent accessibility). let clientSessionParts: string[] = []; - if (schema.clientSession) clientSessionParts = emitClientSessionApiRegistration(schema.clientSession, classes); + if (schema.clientSession) { + const publicClientSession = filterNodeByVisibility(schema.clientSession, "public"); + if (publicClientSession) clientSessionParts = emitClientSessionApiRegistration(publicClientSession, classes); + } let clientGlobalParts: string[] = []; - if (schema.clientGlobal) clientGlobalParts = emitClientGlobalApiRegistration(schema.clientGlobal, classes); + if (schema.clientGlobal) { + const publicClientGlobal = filterNodeByVisibility(schema.clientGlobal, "public"); + if (publicClientGlobal) clientGlobalParts = emitClientGlobalApiRegistration(publicClientGlobal, classes); + } const lines: string[] = []; lines.push(`${COPYRIGHT} diff --git a/scripts/codegen/go.ts b/scripts/codegen/go.ts index 1e6cf0a42c..b1d7474b98 100644 --- a/scripts/codegen/go.ts +++ b/scripts/codegen/go.ts @@ -581,7 +581,8 @@ function extractGoEventVariants(schema: JSONSchema7): GoEventVariant[] { eventExperimental: isSchemaExperimental(variant), dataExperimental: isSchemaExperimental(dataSchema), }; - }); + }) + .filter((variant) => !isSchemaInternal(variant.dataSchema)); } function getGoSharedEventEnvelopeProperties(schema: JSONSchema7, ctx: GoCodegenCtx): GoEventEnvelopeProperty[] { diff --git a/scripts/codegen/python.ts b/scripts/codegen/python.ts index b330eb9a7c..5b343122b6 100644 --- a/scripts/codegen/python.ts +++ b/scripts/codegen/python.ts @@ -1669,7 +1669,8 @@ function extractPyEventVariants(schema: JSONSchema7): PyEventVariant[] { eventExperimental: isSchemaExperimental(variant), dataExperimental: isSchemaExperimental(dataSchema), }; - }); + }) + .filter((variant) => !isSchemaInternal(variant.dataSchema)); } function getPySharedEventEnvelopeProperties(schema: JSONSchema7, ctx: PyCodegenCtx): PyEventEnvelopeProperty[] { diff --git a/scripts/codegen/rust.ts b/scripts/codegen/rust.ts index 3a3ce39a2f..a04f5a21c5 100644 --- a/scripts/codegen/rust.ts +++ b/scripts/codegen/rust.ts @@ -1109,7 +1109,11 @@ function extractEventVariants(schema: JSONSchema7): EventVariant[] { dataExperimental: isSchemaExperimental(dataSchema), }; }) - .filter((v) => !EXCLUDED_EVENT_TYPES.has(v.typeName)); + .filter( + (v) => + !EXCLUDED_EVENT_TYPES.has(v.typeName) && + !isSchemaInternal(v.dataSchema), + ); } export function generateSessionEventsCode(schema: JSONSchema7): string { diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index 497c909ea5..f82e67abe6 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -37,6 +37,7 @@ import { isNodeFullyDeprecated, isVoidSchema, isSchemaExperimental, + isSchemaInternal, appendPropertyMarkerTagsToDescriptions, getEnumValueDescriptions, stripOpaqueJsonMarker, @@ -348,7 +349,39 @@ async function generateSessionEvents(schemaPath?: string): Promise { resolveSchema({ $ref: "#/definitions/SessionEvent" }, definitionCollections) ?? resolveSchema({ $ref: "#/$defs/SessionEvent" }, definitionCollections) ?? processed; - const schemaForCompile = withSharedDefinitions(sessionEvent, definitionCollections); + const excludedDefinitionNames = new Set(); + const publicVariants = (sessionEvent.anyOf ?? []).filter((variant) => { + const variantSchema = variant as JSONSchema7; + const resolvedVariant = resolveSchema(variantSchema, definitionCollections) ?? variantSchema; + const dataSchema = resolvedVariant.properties?.data as JSONSchema7 | undefined; + const resolvedData = dataSchema ? resolveSchema(dataSchema, definitionCollections) ?? dataSchema : undefined; + if (!isSchemaInternal(resolvedData)) { + return true; + } + + for (const ref of [variantSchema.$ref, dataSchema?.$ref]) { + const match = ref?.match(/^#\/(?:definitions|\$defs)\/([^/]+)$/); + if (match) excludedDefinitionNames.add(match[1]); + } + return false; + }); + const publicDefinitions = Object.fromEntries( + Object.entries(definitionCollections.definitions).filter(([name]) => !excludedDefinitionNames.has(name)) + ); + const publicDraftDefinitions = Object.fromEntries( + Object.entries(definitionCollections.$defs).filter(([name]) => !excludedDefinitionNames.has(name)) + ); + const publicSessionEvent = { ...sessionEvent, anyOf: publicVariants }; + if ("SessionEvent" in publicDefinitions) { + publicDefinitions.SessionEvent = publicSessionEvent; + } + if ("SessionEvent" in publicDraftDefinitions) { + publicDraftDefinitions.SessionEvent = publicSessionEvent; + } + const schemaForCompile = withSharedDefinitions( + publicSessionEvent, + { definitions: publicDefinitions, $defs: publicDraftDefinitions } + ); appendPropertyMarkerTagsToDescriptions(schemaForCompile); const ts = await compile(normalizeSchemaForTypeScript(schemaForCompile), "SessionEvent", { diff --git a/test/harness/package-lock.json b/test/harness/package-lock.json index 4f5a2c3b9b..1a8462d661 100644 --- a/test/harness/package-lock.json +++ b/test/harness/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@github/copilot": "^1.0.69", + "@github/copilot": "^1.0.71", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14", @@ -501,9 +501,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.69.tgz", - "integrity": "sha512-sri6PKtRQu7mfok3eV505fmYo6M6PGZpB1vd+QbMEm2rEZaKE2+wJGCAtkpQNKfAWBvvoW/C94IaeFyyulUvqQ==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.71.tgz", + "integrity": "sha512-F3axBi+sXSLYDJbxCBW36bM6MYKNC2rlyAf3Ivo/MjiHKKJW7j5AmaR1IRYS9Gt8r9mxOwWFM1cJFA+CuLaR8g==", "dev": true, "license": "SEE LICENSE IN LICENSE.md", "dependencies": { @@ -513,20 +513,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.69", - "@github/copilot-darwin-x64": "1.0.69", - "@github/copilot-linux-arm64": "1.0.69", - "@github/copilot-linux-x64": "1.0.69", - "@github/copilot-linuxmusl-arm64": "1.0.69", - "@github/copilot-linuxmusl-x64": "1.0.69", - "@github/copilot-win32-arm64": "1.0.69", - "@github/copilot-win32-x64": "1.0.69" + "@github/copilot-darwin-arm64": "1.0.71", + "@github/copilot-darwin-x64": "1.0.71", + "@github/copilot-linux-arm64": "1.0.71", + "@github/copilot-linux-x64": "1.0.71", + "@github/copilot-linuxmusl-arm64": "1.0.71", + "@github/copilot-linuxmusl-x64": "1.0.71", + "@github/copilot-win32-arm64": "1.0.71", + "@github/copilot-win32-x64": "1.0.71" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.69.tgz", - "integrity": "sha512-LpaVS2o21BbYTFUlkqdwUJXqE0l1Lp50Cb/EHru3z+5n5x7zNUU5IlPxdMg5gHMVw78clnMn2zSJbe5hYFyDDQ==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.71.tgz", + "integrity": "sha512-mEWzyqbqRAWgyU7i2uuSRoVPx/TwaFQX0nZmw0bc30aJ0BnO7cy2kYQyCHw8ykmf/tfxT0xauZ6k0BOFmWizzQ==", "cpu": [ "arm64" ], @@ -541,9 +541,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.69.tgz", - "integrity": "sha512-QL5bsmnYqliBVIEE8RY91e6yAr39c2TNvJc/5IzoaKMlQ9MCrD9xakBJwd7LOS0SiOwRE4fqn8pUA4L1+UU5fg==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.71.tgz", + "integrity": "sha512-Md9yEg406OBVBx3w4PeEj62TubulVLBcHleqmCoOoUmPgUxPZotUbrqz3rtbzADbXfrrD7JWvVsbd2UiNL194w==", "cpu": [ "x64" ], @@ -558,9 +558,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.69.tgz", - "integrity": "sha512-SOibp0I6APLoY2KkccbNOISpbhE3lp41s9fM3eANqfdoHQLsgBopfk4ruBP8k/DzjQA6t4MrbV8v6dLixAqv3w==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.71.tgz", + "integrity": "sha512-ykLJYOqBj3jRB5IJCDugLClAqbr7DmtTbUjlNY7+Jdq/n6i+d7xUQGclf1IWL5gnxbGQVAf+zkToD+sRM389Kg==", "cpu": [ "arm64" ], @@ -575,9 +575,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.69.tgz", - "integrity": "sha512-URT7UaR3l1VAAtEpbKjybup2vxHTMSfDTVlu0jinu1O2nWxFOeQ9N4BQgRdcCB7wDu3N8Yz47o3fV1Gob6Z1Yw==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.71.tgz", + "integrity": "sha512-pC0FNHG+BBwZd6yZlM85kkAGN+uJhM6o+THi76N2GnnSxmw7+remb1mvYxdgRVbdCm+LBUIbCKRWJLuMwrfb6A==", "cpu": [ "x64" ], @@ -592,9 +592,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.69.tgz", - "integrity": "sha512-Y6DY9RkyWV6l+S0cNz4d+AkUa38goNV5StQH3j8OoVrndK3+RgQeUjG1XpJ//PzXEXi4vQ0cZuc9XzFj/TY7tw==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.71.tgz", + "integrity": "sha512-hBmDljFTjacxqZTasCEy43H8EIzuXB/hHEBBCMFjhB9J00nIxsO6Dh0woTifKpx7knTYZdpTjjca3D0pAoZlUA==", "cpu": [ "arm64" ], @@ -609,9 +609,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.69.tgz", - "integrity": "sha512-h0aNgUWRu70Tgp4/7Vdqa/4XKJi96wP32R1PoKeXkz1QNxb6i/IQxhoCjjS+xFon9e2kefKdF8pDrDtUJjsiBQ==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.71.tgz", + "integrity": "sha512-CfTXU8pa5dxRz22xQzoi3TiG1PJo9+WR8PRDiPSdkIBSyPJ1NvX87DJmfXjTgeAfR+wkjt/p0keDCaBBVhNmUA==", "cpu": [ "x64" ], @@ -626,9 +626,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.69.tgz", - "integrity": "sha512-7DMoC1uwt01yZf48xq8MgKdnHacXabfsGsOizbgmrlZMfvQQofrF55x1Efu3BKsEKFGRl4fCLjJHJYCz/nWsHg==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.71.tgz", + "integrity": "sha512-+HI1DokixXhHUahj06Fw67ZAigBuXKC58BFma4UJOGrQsDgwOSbqeTQHCw6vuymzjKlg3sactfsCUTaefkjscQ==", "cpu": [ "arm64" ], @@ -643,9 +643,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.69", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.69.tgz", - "integrity": "sha512-5uD1/k6oVzqRhiS9jLrySGa20HjItqUJsaMe+bV6eYT7zQPF/74xihEjxMm3/bJcATW2SesiYQxyNzTcKm3qRA==", + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.71.tgz", + "integrity": "sha512-02kXOBd9CwBbCaztuf71WYWn+uGapCuiaasomN4tcMH3HBVZ4gi3J0ZUoRcgcS80xh81uQyeBHbnUKzb/RE/9A==", "cpu": [ "x64" ], diff --git a/test/harness/package.json b/test/harness/package.json index 51f925f246..18b19e21ac 100644 --- a/test/harness/package.json +++ b/test/harness/package.json @@ -14,7 +14,7 @@ "node": "^20.19.0 || >=22.12.0" }, "devDependencies": { - "@github/copilot": "^1.0.69", + "@github/copilot": "^1.0.71", "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.3.3", "@types/node-forge": "^1.3.14", diff --git a/test/snapshots/session/should_abort_a_session.yaml b/test/snapshots/session/should_abort_a_session.yaml index f1217f7f62..dbbbd32aa7 100644 --- a/test/snapshots/session/should_abort_a_session.yaml +++ b/test/snapshots/session/should_abort_a_session.yaml @@ -50,31 +50,3 @@ conversations: content: What is 2+2? - role: assistant content: 2 + 2 = 4 - - messages: - - role: system - content: ${system} - - role: user - content: run the shell command 'sleep 100' (note this works on both bash and PowerShell) - - role: assistant - content: I'll run the sleep command for 100 seconds. - tool_calls: - - id: toolcall_0 - type: function - function: - name: report_intent - arguments: '{"intent":"Running sleep command"}' - - id: toolcall_1 - type: function - function: - name: ${shell} - arguments: '{"command":"sleep 100","description":"Run sleep 100 command","mode":"sync","initial_wait":105}' - - role: tool - tool_call_id: toolcall_0 - content: The execution of this tool, or a previous tool was interrupted. - - role: tool - tool_call_id: toolcall_1 - content: The execution of this tool, or a previous tool was interrupted. - - role: user - content: What is 2+2? - - role: assistant - content: 2 + 2 = 4