diff --git a/.eslint-allowed-javascript-files b/.eslint-allowed-javascript-files index a90f84180f1261..7d31572290daa2 100644 --- a/.eslint-allowed-javascript-files +++ b/.eslint-allowed-javascript-files @@ -20,7 +20,6 @@ extensions/copilot/.mocha-multi-reporters.js extensions/copilot/.mocharc.js extensions/copilot/.vscode-test.mjs extensions/copilot/.vscode/extensions/visualization-runner/entry.js -extensions/copilot/build/listBuildCacheFiles.js extensions/copilot/script/electron/simulationWorkbenchMain.js extensions/copilot/src/extension/completions-core/vscode-node/extension/test/run.js extensions/copilot/src/extension/test/node/fixtures/gitdiff/generate-diffs.js @@ -98,13 +97,8 @@ scripts/code-sessions-web.js scripts/code-web.js scripts/xterm-update.js src/vs/base/browser/dompurify/dompurify.js -src/vs/base/common/lit-html/directive-helpers.js -src/vs/base/common/lit-html/directive.js -src/vs/base/common/lit-html/directives/repeat.js -src/vs/base/common/lit-html/lit-html.js src/vs/base/common/marked/marked.js src/vs/base/common/semver/semver.js -src/vs/base/common/signals-core/signals-core.js src/vs/base/test/common/filters.perf.data.js src/vs/editor/test/node/diffing/fixtures/difficult-move/1.js src/vs/editor/test/node/diffing/fixtures/difficult-move/2.js diff --git a/.github/actions/restore-node-modules/action.yml b/.github/actions/restore-node-modules/action.yml new file mode 100644 index 00000000000000..1e4e483f6e9afa --- /dev/null +++ b/.github/actions/restore-node-modules/action.yml @@ -0,0 +1,60 @@ +name: Restore node_modules cache +description: Computes the node_modules cache key, restores the cache, and extracts it on a hit. + +inputs: + key-prefix: + description: Prefix for the cache key. The package-lock hash is appended automatically. + required: true + key-args: + description: Arguments passed to build/azure-pipelines/common/computeNodeModulesCacheKey.ts. + required: true + extract: + description: When 'true', the restored archive is extracted into node_modules on a cache hit. + default: 'true' + +outputs: + cache-hit: + description: "'true' when the node_modules cache was restored from an exact key match." + value: ${{ steps.restore.outputs.cache-hit }} + +runs: + using: composite + steps: + - name: Prepare node_modules cache key + if: runner.os != 'Windows' + shell: bash + run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts ${{ inputs.key-args }} > .build/packagelockhash + + - name: Prepare node_modules cache key + if: runner.os == 'Windows' + shell: pwsh + run: | + mkdir .build -ea 0 + node build/azure-pipelines/common/computeNodeModulesCacheKey.ts ${{ inputs.key-args }} > .build/packagelockhash + + - name: Restore node_modules cache + id: restore + uses: actions/cache/restore@v5 + with: + path: .build/node_modules_cache + key: "${{ inputs.key-prefix }}-${{ hashFiles('.build/packagelockhash') }}" + + - name: Export node_modules cache key + if: runner.os != 'Windows' + shell: bash + run: echo "NODE_MODULES_CACHE_KEY=${{ steps.restore.outputs.cache-primary-key }}" >> "$GITHUB_ENV" + + - name: Export node_modules cache key + if: runner.os == 'Windows' + shell: pwsh + run: Add-Content -Path $env:GITHUB_ENV -Value "NODE_MODULES_CACHE_KEY=${{ steps.restore.outputs.cache-primary-key }}" + + - name: Extract node_modules cache + if: runner.os != 'Windows' && inputs.extract == 'true' && steps.restore.outputs.cache-hit == 'true' + shell: bash + run: ./.github/workflows/node_modules_cache/cache.sh extract + + - name: Extract node_modules cache + if: runner.os == 'Windows' && inputs.extract == 'true' && steps.restore.outputs.cache-hit == 'true' + shell: pwsh + run: ./.github/workflows/node_modules_cache/cache.ps1 extract diff --git a/.github/actions/save-node-modules/action.yml b/.github/actions/save-node-modules/action.yml new file mode 100644 index 00000000000000..16d8ce96d83a32 --- /dev/null +++ b/.github/actions/save-node-modules/action.yml @@ -0,0 +1,21 @@ +name: Save node_modules cache +description: Archives node_modules and saves the archive to the GitHub Actions cache. + +runs: + using: composite + steps: + - name: Create node_modules archive + if: runner.os != 'Windows' + shell: bash + run: ./.github/workflows/node_modules_cache/cache.sh archive + + - name: Create node_modules archive + if: runner.os == 'Windows' + shell: pwsh + run: ./.github/workflows/node_modules_cache/cache.ps1 archive + + - name: Save node_modules cache + uses: actions/cache/save@v5 + with: + path: .build/node_modules_cache + key: ${{ env.NODE_MODULES_CACHE_KEY }} diff --git a/.github/workflows/component-fixtures.yml b/.github/workflows/component-fixtures.yml index 04bac6ac7d6f5f..e42f20b623d5e2 100644 --- a/.github/workflows/component-fixtures.yml +++ b/.github/workflows/component-fixtures.yml @@ -38,19 +38,12 @@ jobs: with: node-version-file: .nvmrc - - name: Prepare node_modules cache key - run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts compile $(node -p process.arch) > .build/packagelockhash - - name: Restore node_modules cache id: cache-node-modules - uses: actions/cache@v5 + uses: ./.github/actions/restore-node-modules with: - path: .build/node_modules_cache - key: "node_modules-component-fixtures-${{ hashFiles('.build/packagelockhash') }}" - - - name: Extract node_modules cache - if: steps.cache-node-modules.outputs.cache-hit == 'true' - run: tar -xzf .build/node_modules_cache/cache.tgz + key-prefix: node_modules-component-fixtures + key-args: "compile $(node -p process.arch)" - name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -70,13 +63,10 @@ jobs: run: npm ci working-directory: build/rspack - - name: Create node_modules archive - if: steps.cache-node-modules.outputs.cache-hit != 'true' - run: | - set -e - node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt - mkdir -p .build/node_modules_cache - tar -czf .build/node_modules_cache/cache.tgz --files-from .build/node_modules_list.txt + - name: Save node_modules cache + # Only seed the cache on branch pushes — PR-scoped caches are not shared with other refs. + if: github.event_name != 'pull_request' && steps.cache-node-modules.outputs.cache-hit != 'true' + uses: ./.github/actions/save-node-modules - name: Copy codicons run: cp node_modules/@vscode/codicons/dist/codicon.ttf src/vs/base/browser/ui/codicons/codicon/codicon.ttf diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index c3a390e905f593..0f1b025b10a2d3 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -50,19 +50,12 @@ jobs: sudo update-rc.d xvfb defaults sudo service xvfb start - - name: Prepare node_modules cache key - run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts linux x64 $(node -p process.arch) > .build/packagelockhash - - name: Restore node_modules cache id: cache-node-modules - uses: actions/cache/restore@v5 + uses: ./.github/actions/restore-node-modules with: - path: .build/node_modules_cache - key: "node_modules-linux-${{ hashFiles('.build/packagelockhash') }}" - - - name: Extract node_modules cache - if: steps.cache-node-modules.outputs.cache-hit == 'true' - run: tar -xzf .build/node_modules_cache/cache.tgz + key-prefix: node_modules-linux + key-args: "linux x64 $(node -p process.arch)" - name: Install build dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -103,14 +96,6 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Create node_modules archive - if: steps.cache-node-modules.outputs.cache-hit != 'true' - run: | - set -e - node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt - mkdir -p .build/node_modules_cache - tar -czf .build/node_modules_cache/cache.tgz --files-from .build/node_modules_list.txt - - name: Create .build folder run: mkdir -p .build diff --git a/.github/workflows/css-order-scan.yml b/.github/workflows/css-order-scan.yml index 4be9a52ad4d23d..49f6b27e0aafba 100644 --- a/.github/workflows/css-order-scan.yml +++ b/.github/workflows/css-order-scan.yml @@ -29,19 +29,12 @@ jobs: with: node-version-file: .nvmrc - - name: Prepare node_modules cache key - run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts compile $(node -p process.arch) > .build/packagelockhash - - name: Restore node_modules cache id: cache-node-modules - uses: actions/cache@v5 + uses: ./.github/actions/restore-node-modules with: - path: .build/node_modules_cache - key: "node_modules-css-order-scan-${{ hashFiles('.build/packagelockhash') }}" - - - name: Extract node_modules cache - if: steps.cache-node-modules.outputs.cache-hit == 'true' - run: tar -xzf .build/node_modules_cache/cache.tgz + key-prefix: node_modules-css-order-scan + key-args: "compile $(node -p process.arch)" - name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -61,13 +54,9 @@ jobs: run: npm ci working-directory: build/rspack - - name: Create node_modules archive + - name: Save node_modules cache if: steps.cache-node-modules.outputs.cache-hit != 'true' - run: | - set -e - node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt - mkdir -p .build/node_modules_cache - tar -czf .build/node_modules_cache/cache.tgz --files-from .build/node_modules_list.txt + uses: ./.github/actions/save-node-modules - name: Copy codicons run: cp node_modules/@vscode/codicons/dist/codicon.ttf src/vs/base/browser/ui/codicons/codicon/codicon.ttf diff --git a/.github/workflows/node_modules_cache/README.md b/.github/workflows/node_modules_cache/README.md new file mode 100644 index 00000000000000..00ea968111928a --- /dev/null +++ b/.github/workflows/node_modules_cache/README.md @@ -0,0 +1,35 @@ +# node_modules cache scripts + +Shared helpers used by the GitHub Actions workflows to store and restore the +`node_modules` cache. They exist so the archive format and compression flags +live in a single place instead of being duplicated across every workflow. + +Each script takes an action argument: + +| Script | Platform | `archive` | `extract` | +| ------ | -------- | --------- | --------- | +| `cache.sh ` | Linux / macOS | Create `node-modules.tzst` (cache miss) | Restore `node-modules.tzst` (cache hit) | +| `cache.ps1 ` | Windows | Create `cache.7z` (cache miss) | Restore `cache.7z` (cache hit) | + +Linux/macOS use multi-threaded `zstd` (`node-modules.tzst`); Windows uses 7-Zip +(`cache.7z`). The archive must NOT be named `cache.tzst`: `actions/cache` names +its own tarball `cache.tzst` and passes `--exclude cache.tzst`, which would drop +our archive and save an empty cache. The two families are not interchangeable, +so the cache key already encodes the OS (`node_modules-linux-*`, +`node_modules-windows-*`, …). + +Example usage from a workflow step: + +```yaml +- name: Extract node_modules cache + if: steps.cache-node-modules.outputs.cache-hit == 'true' + run: ./.github/workflows/node_modules_cache/cache.sh extract + +- name: Create node_modules archive + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: ./.github/workflows/node_modules_cache/cache.sh archive +``` + +If you change the archive format or flags, update both the `archive` and +`extract` branch of the script, and bump `build/.cachesalt` to invalidate +existing caches. diff --git a/.github/workflows/node_modules_cache/cache.ps1 b/.github/workflows/node_modules_cache/cache.ps1 new file mode 100644 index 00000000000000..a0355d6dc9b98b --- /dev/null +++ b/.github/workflows/node_modules_cache/cache.ps1 @@ -0,0 +1,27 @@ +# Store or restore the node_modules cache on Windows. +# +# Usage: +# cache.ps1 archive # create .build/node_modules_cache/cache.7z (cache miss) +# cache.ps1 extract # restore .build/node_modules_cache/cache.7z (cache hit) +# +# Uses 7-Zip. The archive and extract format must stay compatible; if you change +# the format here, bump build/.cachesalt to invalidate old caches. +param( + [Parameter(Mandatory = $true)] + [ValidateSet("archive", "extract")] + [string]$Action +) + +. build/azure-pipelines/win32/exec.ps1 +$ErrorActionPreference = "Stop" + +switch ($Action) { + "archive" { + exec { node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt } + exec { mkdir -Force .build/node_modules_cache } + exec { 7z.exe a .build/node_modules_cache/cache.7z -mx3 `@.build/node_modules_list.txt } + } + "extract" { + exec { 7z.exe x .build/node_modules_cache/cache.7z -aoa } + } +} diff --git a/.github/workflows/node_modules_cache/cache.sh b/.github/workflows/node_modules_cache/cache.sh new file mode 100755 index 00000000000000..19d109f67091fa --- /dev/null +++ b/.github/workflows/node_modules_cache/cache.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Store or restore the node_modules cache on Linux / macOS. +# +# Usage: +# cache.sh archive # create .build/node_modules_cache/node-modules.tzst (cache miss) +# cache.sh extract # restore .build/node_modules_cache/node-modules.tzst (cache hit) +# +# Uses multi-threaded zstd. The archive must NOT be named cache.tzst because +# actions/cache reserves that name and excludes it from the saved cache. The +# archive and extract flags must stay compatible; if you change the format +# here, bump build/.cachesalt to invalidate old caches. +set -e + +case "$1" in + archive) + node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt + mkdir -p .build/node_modules_cache + tar --use-compress-program='zstd -T0 -3' -cf .build/node_modules_cache/node-modules.tzst --files-from .build/node_modules_list.txt + ;; + extract) + tar --use-compress-program='zstd -d -T0' -xf .build/node_modules_cache/node-modules.tzst + ;; + *) + echo "Usage: $0 {archive|extract}" >&2 + exit 1 + ;; +esac diff --git a/.github/workflows/pr-darwin-test.yml b/.github/workflows/pr-darwin-test.yml index c9622e23a8966b..c36da018996eb9 100644 --- a/.github/workflows/pr-darwin-test.yml +++ b/.github/workflows/pr-darwin-test.yml @@ -33,19 +33,12 @@ jobs: with: node-version-file: .nvmrc - - name: Prepare node_modules cache key - run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts darwin $VSCODE_ARCH $(node -p process.arch) > .build/packagelockhash - - name: Restore node_modules cache id: cache-node-modules - uses: actions/cache/restore@v5 + uses: ./.github/actions/restore-node-modules with: - path: .build/node_modules_cache - key: "node_modules-macos-${{ hashFiles('.build/packagelockhash') }}" - - - name: Extract node_modules cache - if: steps.cache-node-modules.outputs.cache-hit == 'true' - run: tar -xzf .build/node_modules_cache/cache.tgz + key-prefix: node_modules-macos + key-args: "darwin ${{ env.VSCODE_ARCH }} $(node -p process.arch)" - name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -75,14 +68,6 @@ jobs: # on macOS. GYP_DEFINES: "kerberos_use_rtld=false" - - name: Create node_modules archive - if: steps.cache-node-modules.outputs.cache-hit != 'true' - run: | - set -e - node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt - mkdir -p .build/node_modules_cache - tar -czf .build/node_modules_cache/cache.tgz --files-from .build/node_modules_list.txt - - name: Create .build folder run: mkdir -p .build diff --git a/.github/workflows/pr-linux-test.yml b/.github/workflows/pr-linux-test.yml index 665bfd36550bde..452f974f802f7c 100644 --- a/.github/workflows/pr-linux-test.yml +++ b/.github/workflows/pr-linux-test.yml @@ -55,19 +55,12 @@ jobs: sudo update-rc.d xvfb defaults sudo service xvfb start - - name: Prepare node_modules cache key - run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts linux $VSCODE_ARCH $(node -p process.arch) > .build/packagelockhash - - name: Restore node_modules cache id: cache-node-modules - uses: actions/cache/restore@v5 + uses: ./.github/actions/restore-node-modules with: - path: .build/node_modules_cache - key: "node_modules-linux-${{ hashFiles('.build/packagelockhash') }}" - - - name: Extract node_modules cache - if: steps.cache-node-modules.outputs.cache-hit == 'true' - run: tar -xzf .build/node_modules_cache/cache.tgz + key-prefix: node_modules-linux + key-args: "linux ${{ env.VSCODE_ARCH }} $(node -p process.arch)" - name: Install build dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -108,14 +101,6 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Create node_modules archive - if: steps.cache-node-modules.outputs.cache-hit != 'true' - run: | - set -e - node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt - mkdir -p .build/node_modules_cache - tar -czf .build/node_modules_cache/cache.tgz --files-from .build/node_modules_list.txt - - name: Create .build folder run: mkdir -p .build diff --git a/.github/workflows/pr-node-modules.yml b/.github/workflows/pr-node-modules.yml index 1f83561abb101a..000c17b3782e38 100644 --- a/.github/workflows/pr-node-modules.yml +++ b/.github/workflows/pr-node-modules.yml @@ -20,19 +20,12 @@ jobs: with: node-version-file: .nvmrc - - name: Prepare node_modules cache key - run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts compile $(node -p process.arch) > .build/packagelockhash - - name: Restore node_modules cache id: cache-node-modules - uses: actions/cache@v5 + uses: ./.github/actions/restore-node-modules with: - path: .build/node_modules_cache - key: "node_modules-compile-${{ hashFiles('.build/packagelockhash') }}" - - - name: Extract node_modules cache - if: steps.cache-node-modules.outputs.cache-hit == 'true' - run: tar -xzf .build/node_modules_cache/cache.tgz + key-prefix: node_modules-compile + key-args: "compile $(node -p process.arch)" - name: Install build tools if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -56,13 +49,9 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: ${{ secrets.VSCODE_OSS }} - - name: Create node_modules archive + - name: Save node_modules cache if: steps.cache-node-modules.outputs.cache-hit != 'true' - run: | - set -e - node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt - mkdir -p .build/node_modules_cache - tar -czf .build/node_modules_cache/cache.tgz --files-from .build/node_modules_list.txt + uses: ./.github/actions/save-node-modules - name: Prepare built-in extensions cache key run: | @@ -99,15 +88,13 @@ jobs: with: node-version-file: .nvmrc - - name: Prepare node_modules cache key - run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts linux $VSCODE_ARCH $(node -p process.arch) > .build/packagelockhash - - name: Restore node_modules cache id: cache-node-modules - uses: actions/cache@v5 + uses: ./.github/actions/restore-node-modules with: - path: .build/node_modules_cache - key: "node_modules-linux-${{ hashFiles('.build/packagelockhash') }}" + key-prefix: node_modules-linux + key-args: "linux ${{ env.VSCODE_ARCH }} $(node -p process.arch)" + extract: 'false' - name: Install build dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -148,13 +135,9 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: ${{ secrets.VSCODE_OSS }} - - name: Create node_modules archive + - name: Save node_modules cache if: steps.cache-node-modules.outputs.cache-hit != 'true' - run: | - set -e - node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt - mkdir -p .build/node_modules_cache - tar -czf .build/node_modules_cache/cache.tgz --files-from .build/node_modules_list.txt + uses: ./.github/actions/save-node-modules macOS: name: macOS @@ -171,15 +154,13 @@ jobs: with: node-version-file: .nvmrc - - name: Prepare node_modules cache key - run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts darwin $VSCODE_ARCH $(node -p process.arch) > .build/packagelockhash - - name: Restore node_modules cache id: cache-node-modules - uses: actions/cache@v5 + uses: ./.github/actions/restore-node-modules with: - path: .build/node_modules_cache - key: "node_modules-macos-${{ hashFiles('.build/packagelockhash') }}" + key-prefix: node_modules-macos + key-args: "darwin ${{ env.VSCODE_ARCH }} $(node -p process.arch)" + extract: 'false' - name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -209,13 +190,9 @@ jobs: # on macOS. GYP_DEFINES: "kerberos_use_rtld=false" - - name: Create node_modules archive + - name: Save node_modules cache if: steps.cache-node-modules.outputs.cache-hit != 'true' - run: | - set -e - node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt - mkdir -p .build/node_modules_cache - tar -czf .build/node_modules_cache/cache.tgz --files-from .build/node_modules_list.txt + uses: ./.github/actions/save-node-modules windows: name: Windows @@ -232,18 +209,13 @@ jobs: with: node-version-file: .nvmrc - - name: Prepare node_modules cache key - shell: pwsh - run: | - mkdir .build -ea 0 - node build/azure-pipelines/common/computeNodeModulesCacheKey.ts win32 ${{ env.VSCODE_ARCH }} $(node -p process.arch) > .build/packagelockhash - - name: Restore node_modules cache - uses: actions/cache@v5 id: node-modules-cache + uses: ./.github/actions/restore-node-modules with: - path: .build/node_modules_cache - key: "node_modules-windows-${{ hashFiles('.build/packagelockhash') }}" + key-prefix: node_modules-windows + key-args: "win32 ${{ env.VSCODE_ARCH }} $(node -p process.arch)" + extract: 'false' - name: Install dependencies if: steps.node-modules-cache.outputs.cache-hit != 'true' @@ -273,12 +245,70 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: ${{ secrets.VSCODE_OSS }} - - name: Create node_modules archive + - name: Save node_modules cache if: steps.node-modules-cache.outputs.cache-hit != 'true' - shell: pwsh - run: | - . build/azure-pipelines/win32/exec.ps1 - $ErrorActionPreference = "Stop" - exec { node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt } - exec { mkdir -Force .build/node_modules_cache } - exec { 7z.exe a .build/node_modules_cache/cache.7z -mx3 `@.build/node_modules_list.txt } + uses: ./.github/actions/save-node-modules + + copilot-linux: + name: Copilot (Linux) + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=copilot-linux-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] + steps: + - name: Checkout microsoft/vscode + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: extensions/copilot/.nvmrc + + - name: Restore node_modules cache + id: cache-node-modules + uses: ./.github/actions/restore-node-modules + with: + key-prefix: copilot-node_modules-linux + key-args: "$(node -p process.platform) $(node -p process.arch)" + + - name: Install root dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: npm ci --ignore-scripts --no-workspaces + + - name: Install copilot dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + working-directory: extensions/copilot + run: npm ci + + - name: Save node_modules cache + if: steps.cache-node-modules.outputs.cache-hit != 'true' + uses: ./.github/actions/save-node-modules + + copilot-windows: + name: Copilot (Windows) + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-windows-2022-x64, "JobId=copilot-windows-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] + steps: + - name: Checkout microsoft/vscode + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: extensions/copilot/.nvmrc + + - name: Restore node_modules cache + id: cache-node-modules + uses: ./.github/actions/restore-node-modules + with: + key-prefix: copilot-node_modules-windows + key-args: "$(node -p process.platform) $(node -p process.arch)" + + - name: Install root dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: npm ci --ignore-scripts --no-workspaces + + - name: Install copilot dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + working-directory: extensions/copilot + run: npm ci + + - name: Save node_modules cache + if: steps.cache-node-modules.outputs.cache-hit != 'true' + uses: ./.github/actions/save-node-modules diff --git a/.github/workflows/pr-win32-test.yml b/.github/workflows/pr-win32-test.yml index fbd1479b6afb95..a1255348f21c85 100644 --- a/.github/workflows/pr-win32-test.yml +++ b/.github/workflows/pr-win32-test.yml @@ -33,26 +33,15 @@ jobs: with: node-version-file: .nvmrc - - name: Prepare node_modules cache key - shell: pwsh - run: | - mkdir .build -ea 0 - node build/azure-pipelines/common/computeNodeModulesCacheKey.ts win32 ${{ env.VSCODE_ARCH }} $(node -p process.arch) > .build/packagelockhash - - name: Restore node_modules cache - uses: actions/cache/restore@v5 - id: node-modules-cache + id: cache-node-modules + uses: ./.github/actions/restore-node-modules with: - path: .build/node_modules_cache - key: "node_modules-windows-${{ hashFiles('.build/packagelockhash') }}" - - - name: Extract node_modules cache - if: steps.node-modules-cache.outputs.cache-hit == 'true' - shell: pwsh - run: 7z.exe x .build/node_modules_cache/cache.7z -aoa + key-prefix: node_modules-windows + key-args: "win32 ${{ env.VSCODE_ARCH }} $(node -p process.arch)" - name: Install dependencies - if: steps.node-modules-cache.outputs.cache-hit != 'true' + if: steps.cache-node-modules.outputs.cache-hit != 'true' shell: pwsh run: | . build/azure-pipelines/win32/exec.ps1 @@ -84,16 +73,6 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Create node_modules archive - if: steps.node-modules-cache.outputs.cache-hit != 'true' - shell: pwsh - run: | - . build/azure-pipelines/win32/exec.ps1 - $ErrorActionPreference = "Stop" - exec { node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt } - exec { mkdir -Force .build/node_modules_cache } - exec { 7z.exe a .build/node_modules_cache/cache.7z -mx3 `@.build/node_modules_list.txt } - - name: Create .build folder shell: pwsh run: mkdir .build -ea 0 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d6e29e867ddb89..96fb9a707d1504 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -31,19 +31,12 @@ jobs: with: node-version-file: .nvmrc - - name: Prepare node_modules cache key - run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts compile $(node -p process.arch) > .build/packagelockhash - - name: Restore node_modules cache id: cache-node-modules - uses: actions/cache/restore@v5 + uses: ./.github/actions/restore-node-modules with: - path: .build/node_modules_cache - key: "node_modules-compile-${{ hashFiles('.build/packagelockhash') }}" - - - name: Extract node_modules cache - if: steps.cache-node-modules.outputs.cache-hit == 'true' - run: tar -xzf .build/node_modules_cache/cache.tgz + key-prefix: node_modules-compile + key-args: "compile $(node -p process.arch)" - name: Install build tools if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -67,14 +60,6 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Create node_modules archive - if: steps.cache-node-modules.outputs.cache-hit != 'true' - run: | - set -e - node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt - mkdir -p .build/node_modules_cache - tar -czf .build/node_modules_cache/cache.tgz --files-from .build/node_modules_list.txt - - name: Type check /build/ scripts run: npm run typecheck working-directory: build @@ -191,20 +176,19 @@ jobs: with: node-version-file: extensions/copilot/.nvmrc - - name: Restore build cache - uses: actions/cache/restore@v5 - id: build-cache + - name: Restore node_modules cache + id: cache-node-modules + uses: ./.github/actions/restore-node-modules with: - key: copilot-build_cache-${{ hashFiles('extensions/copilot/build/.cachesalt', 'extensions/copilot/package-lock.json') }} - path: extensions/copilot/.build/build_cache + key-prefix: copilot-node_modules-linux + key-args: "$(node -p process.platform) $(node -p process.arch)" - - name: Extract build cache - if: steps.build-cache.outputs.cache-hit == 'true' - working-directory: extensions/copilot - run: tar -xzf .build/build_cache/cache.tgz + - name: Install root dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: npm ci --ignore-scripts --no-workspaces - - name: Install dependencies - if: steps.build-cache.outputs.cache-hit != 'true' + - name: Install copilot dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' working-directory: extensions/copilot run: npm ci @@ -275,36 +259,22 @@ jobs: sudo apt-get update sudo apt-get install -y xvfb libgtk-3-0 libgbm1 - - name: Restore build cache - uses: actions/cache/restore@v5 - id: build-cache + - name: Restore node_modules cache + id: cache-node-modules + uses: ./.github/actions/restore-node-modules with: - key: copilot-build_cache-${{ hashFiles('extensions/copilot/build/.cachesalt', 'extensions/copilot/package-lock.json') }} - path: extensions/copilot/.build/build_cache - - - name: Extract build cache - if: steps.build-cache.outputs.cache-hit == 'true' - working-directory: extensions/copilot - run: tar -xzf .build/build_cache/cache.tgz + key-prefix: copilot-node_modules-linux + key-args: "$(node -p process.platform) $(node -p process.arch)" - name: Install root dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' run: npm ci --ignore-scripts --no-workspaces - - name: Install dependencies - if: steps.build-cache.outputs.cache-hit != 'true' + - name: Install copilot dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' working-directory: extensions/copilot run: npm ci - - name: Create build cache archive - if: steps.build-cache.outputs.cache-hit != 'true' - working-directory: extensions/copilot - run: | - set -e - mkdir -p .build - node build/listBuildCacheFiles.js .build/build_cache_list.txt - mkdir -p .build/build_cache - tar -czf .build/build_cache/cache.tgz --files-from .build/build_cache_list.txt - - name: TypeScript type checking working-directory: extensions/copilot run: npm run typecheck @@ -378,35 +348,22 @@ jobs: - name: Install setuptools run: pip install setuptools - - name: Restore build cache - uses: actions/cache/restore@v5 - id: build-cache + - name: Restore node_modules cache + id: cache-node-modules + uses: ./.github/actions/restore-node-modules with: - key: copilot-windows-build_cache-${{ hashFiles('extensions/copilot/build/.cachesalt', 'extensions/copilot/package-lock.json') }} - path: extensions/copilot/.build/build_cache - - - name: Extract build cache - if: steps.build-cache.outputs.cache-hit == 'true' - working-directory: extensions/copilot - run: 7z.exe x .build/build_cache/cache.7z -aoa + key-prefix: copilot-node_modules-windows + key-args: "$(node -p process.platform) $(node -p process.arch)" - name: Install root dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' run: npm ci --ignore-scripts --no-workspaces - - name: Install dependencies - if: steps.build-cache.outputs.cache-hit != 'true' + - name: Install copilot dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' working-directory: extensions/copilot run: npm ci - - name: Create build cache archive - if: steps.build-cache.outputs.cache-hit != 'true' - working-directory: extensions/copilot - run: | - mkdir -Force .build - node build/listBuildCacheFiles.js .build/build_cache_list.txt - mkdir -Force .build/build_cache - 7z.exe a .build/build_cache/cache.7z -mx3 `@.build/build_cache_list.txt - - name: TypeScript type checking working-directory: extensions/copilot run: npm run typecheck diff --git a/build/.cachesalt b/build/.cachesalt index 57519bc2df234f..df1d8c82aab641 100644 --- a/build/.cachesalt +++ b/build/.cachesalt @@ -1 +1 @@ -2026-05-27T16:21:42.961Z +2026-06-19T12:30:00.000Z diff --git a/build/azure-pipelines/common/computeNodeModulesCacheKey.ts b/build/azure-pipelines/common/computeNodeModulesCacheKey.ts index 0dbe8b1b4210c2..e5dbc06aa946ef 100644 --- a/build/azure-pipelines/common/computeNodeModulesCacheKey.ts +++ b/build/azure-pipelines/common/computeNodeModulesCacheKey.ts @@ -12,7 +12,6 @@ const ROOT = path.join(import.meta.dirname, '../../../'); const shasum = crypto.createHash('sha256'); shasum.update(fs.readFileSync(path.join(ROOT, 'build/.cachesalt'))); -shasum.update(fs.readFileSync(path.join(ROOT, 'extensions/copilot/build/.cachesalt'))); // TODO: remove this one when all build scripts are cleaned up shasum.update(fs.readFileSync(path.join(ROOT, '.npmrc'))); shasum.update(fs.readFileSync(path.join(ROOT, 'build', '.npmrc'))); shasum.update(fs.readFileSync(path.join(ROOT, 'remote', '.npmrc'))); diff --git a/build/lib/policies/objectPolicy.ts b/build/lib/policies/objectPolicy.ts index b565b06e8bb3cf..27638a68f4bbfc 100644 --- a/build/lib/policies/objectPolicy.ts +++ b/build/lib/policies/objectPolicy.ts @@ -13,7 +13,11 @@ export class ObjectPolicy extends BasePolicy { static from(category: CategoryDto, policy: PolicyDto): ObjectPolicy | undefined { const { type, name, minimumVersion, localization } = policy; - if (type !== 'object' && type !== 'array') { + // `type` may be a single type or a union (e.g. `['array', 'null']`, where `null` + // models the unset default). Object- and array-typed settings are carried as JSON + // and render as a multiText element. + const types = Array.isArray(type) ? type : [type]; + if (!types.includes('object') && !types.includes('array')) { return undefined; } diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 764e3e89665856..e1bf0baae55ddc 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -128,6 +128,21 @@ "default": "*", "included": true }, + { + "key": "chat.agentHost.enabled", + "name": "ChatAgentHostEnabled", + "category": "InteractiveSession", + "minimumVersion": "1.126", + "localization": { + "description": { + "key": "chat.agentHost.enabled", + "value": "When enabled, some agents run in a separate agent host process." + } + }, + "type": "boolean", + "default": true, + "included": true + }, { "key": "chat.agentHost.codexAgent.enabled", "name": "Codex3PIntegration", @@ -237,21 +252,6 @@ "default": true, "included": true }, - { - "key": "chat.agentHost.enabled", - "name": "ChatAgentHostEnabled", - "category": "InteractiveSession", - "minimumVersion": "1.126", - "localization": { - "description": { - "key": "chat.agentHost.enabled", - "value": "When enabled, some agents run in a separate agent host process." - } - }, - "type": "boolean", - "default": true, - "included": true - }, { "key": "chat.plugins.enabled", "name": "ChatPluginsEnabled", @@ -290,11 +290,14 @@ "localization": { "description": { "key": "chat.plugins.strictMarketplaces.policy", - "value": "Only trust marketplaces supplied via enterprise policy; plugins from any other marketplace will not load." + "value": "Allowlist of plugin marketplace sources. When set, only marketplaces matching an entry are trusted; an empty array blocks all marketplaces." } }, - "type": "boolean", - "default": false, + "type": [ + "array", + "null" + ], + "default": null, "included": true }, { diff --git a/build/lib/test/policyConversion.test.ts b/build/lib/test/policyConversion.test.ts index 5c3f75e958655f..434d038e0d6d30 100644 --- a/build/lib/test/policyConversion.test.ts +++ b/build/lib/test/policyConversion.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { suite, test } from 'node:test'; import { promises as fs } from 'fs'; import path from 'path'; -import type { ExportedPolicyDataDto, CategoryDto } from '../policies/policyDto.ts'; +import type { ExportedPolicyDataDto, CategoryDto, PolicyDto } from '../policies/policyDto.ts'; import { BooleanPolicy } from '../policies/booleanPolicy.ts'; import { NumberPolicy } from '../policies/numberPolicy.ts'; import { ObjectPolicy } from '../policies/objectPolicy.ts'; @@ -520,4 +520,18 @@ suite('Policy E2E conversion', () => { assert.ok(parsed.length > 0, 'Should parse at least one policy from policyData.jsonc'); }); + test('ObjectPolicy.from accepts a union type (e.g. array | null)', () => { + const category: CategoryDto = { key: 'Extensions', name: { key: 'Extensions', value: 'Extensions' } }; + const policy: PolicyDto = { + key: 'chat.plugins.strictMarketplaces', + name: 'ChatStrictMarketplaces', + category: 'Extensions', + minimumVersion: '1.0', + localization: { description: { key: 'desc', value: 'desc' } }, + type: ['array', 'null'], + default: null, + }; + assert.ok(ObjectPolicy.from(category, policy), 'A union array|null type should be classified as an object policy'); + }); + }); diff --git a/extensions/copilot/.github/workflows/copilot-setup-steps.yml b/extensions/copilot/.github/workflows/copilot-setup-steps.yml deleted file mode 100644 index 1c99b58e538497..00000000000000 --- a/extensions/copilot/.github/workflows/copilot-setup-steps.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Copilot Setup Steps - -# Automatically run the setup steps when they are changed to allow for easy validation, and -# allow manual testing through the repository's "Actions" tab -on: - workflow_dispatch: - push: - paths: - - .github/workflows/copilot-setup-steps.yml - pull_request: - paths: - - .github/workflows/copilot-setup-steps.yml - -jobs: - copilot-setup-steps: - name: Setup Development Environment - runs-on: vscode-large-runners - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - lfs: true - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version-file: .nvmrc - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: '3.12' - architecture: 'x64' - - - name: Setup .NET - uses: actions/setup-dotnet@v5 - with: - dotnet-version: '10.0' - - - name: Install setuptools - run: pip install setuptools - - - name: Restore build cache - uses: actions/cache/restore@v5 - id: build-cache - with: - key: build_cache-${{ hashFiles('build/.cachesalt', 'package-lock.json') }} - path: .build/build_cache - - - name: Extract build cache - if: steps.build-cache.outputs.cache-hit == 'true' - run: tar -xzf .build/build_cache/cache.tgz - - - name: Install dependencies - if: steps.build-cache.outputs.cache-hit != 'true' - run: npm ci - - - name: Create build cache archive - if: steps.build-cache.outputs.cache-hit != 'true' - run: | - set -e - mkdir -p .build - node build/listBuildCacheFiles.js .build/build_cache_list.txt - mkdir -p .build/build_cache - tar -czf .build/build_cache/cache.tgz --files-from .build/build_cache_list.txt - - - name: Verify installation - run: | - node --version - npm --version - python --version - dotnet --version \ No newline at end of file diff --git a/extensions/copilot/.github/workflows/ensure-node-modules-cache.yml b/extensions/copilot/.github/workflows/ensure-node-modules-cache.yml deleted file mode 100644 index a82dab5344bdfa..00000000000000 --- a/extensions/copilot/.github/workflows/ensure-node-modules-cache.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Ensure node modules cache - -on: - push: - branches: - - main - -permissions: - contents: read - -jobs: - linux: - name: Linux - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-ubuntu-22.04-x64 ] - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - lfs: true - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version-file: .nvmrc - - - name: Restore build cache - uses: actions/cache@v5 - id: build-cache - with: - key: build_cache-${{ hashFiles('build/.cachesalt', 'package-lock.json') }} - path: .build/build_cache - - - name: Install dependencies - if: steps.build-cache.outputs.cache-hit != 'true' - run: npm ci - - - name: Create build cache archive - if: steps.build-cache.outputs.cache-hit != 'true' - run: | - set -e - mkdir -p .build - node build/listBuildCacheFiles.js .build/build_cache_list.txt - mkdir -p .build/build_cache - tar -czf .build/build_cache/cache.tgz --files-from .build/build_cache_list.txt - - windows: - name: Windows - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-windows-2022-x64 ] - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - lfs: true - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: '22.21.x' - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: '3.12' - architecture: 'x64' - - - name: Restore build cache - uses: actions/cache@v5 - id: build-cache - with: - key: windows-build_cache-${{ hashFiles('build/.cachesalt', 'package-lock.json') }} - path: .build/build_cache - - - name: Install dependencies - if: steps.build-cache.outputs.cache-hit != 'true' - run: npm ci - - - name: Create build cache archive - if: steps.build-cache.outputs.cache-hit != 'true' - run: | - mkdir -Force .build - node build/listBuildCacheFiles.js .build/build_cache_list.txt - mkdir -Force .build/build_cache - 7z.exe a .build/build_cache/cache.7z -mx3 `@.build/build_cache_list.txt diff --git a/extensions/copilot/.github/workflows/pr.yml b/extensions/copilot/.github/workflows/pr.yml deleted file mode 100644 index 8bb30f5ae88a22..00000000000000 --- a/extensions/copilot/.github/workflows/pr.yml +++ /dev/null @@ -1,235 +0,0 @@ -name: PR Checks - -on: - push: - branches: - - 'gh-readonly-queue/main/*' - pull_request: - branches: - - main - - 'release/*' - -permissions: - contents: read - pull-requests: read - -jobs: - check-test-cache: - name: Check test cache - runs-on: ubuntu-22.04 - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - lfs: true - - - uses: actions/setup-node@v6 - with: - node-version-file: .nvmrc - - - name: Restore build cache - uses: actions/cache/restore@v5 - id: build-cache - with: - key: build_cache-${{ hashFiles('build/.cachesalt', 'package-lock.json') }} - path: .build/build_cache - - - name: Extract build cache - if: steps.build-cache.outputs.cache-hit == 'true' - run: tar -xzf .build/build_cache/cache.tgz - - - name: Install dependencies - if: steps.build-cache.outputs.cache-hit != 'true' - run: npm ci - - - name: Ensure no duplicate cache keys - run: npx tsx test/base/cache-cli check - - - name: Ensure no untrusted cache changes - run: npx tsx build/pr-check-cache-files.ts - if: github.event_name == 'pull_request' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPOSITORY: ${{ github.repository }} - PULL_REQUEST: ${{ github.event.pull_request.number }} - - check-telemetry: - name: Check telemetry events - runs-on: ubuntu-22.04 - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - lfs: true - - - uses: actions/setup-node@v6 - with: - node-version-file: .nvmrc - - - name: Validate telemetry events - run: npx --package=@vscode/telemetry-extractor --yes vscode-telemetry-extractor -s . > /dev/null - - linux-tests: - name: Test (Linux) - runs-on: ubuntu-22.04 - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - lfs: true - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version-file: .nvmrc - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: '3.12' - architecture: 'x64' - - - name: Setup .NET - uses: actions/setup-dotnet@v5 - with: - dotnet-version: '10.0' - - - name: Install setuptools - run: pip install setuptools - - - name: Restore build cache - uses: actions/cache/restore@v5 - id: build-cache - with: - key: build_cache-${{ hashFiles('build/.cachesalt', 'package-lock.json') }} - path: .build/build_cache - - - name: Extract build cache - if: steps.build-cache.outputs.cache-hit == 'true' - run: tar -xzf .build/build_cache/cache.tgz - - - name: Install dependencies - if: steps.build-cache.outputs.cache-hit != 'true' - run: npm ci - - - name: Create build cache archive - if: steps.build-cache.outputs.cache-hit != 'true' - run: | - set -e - mkdir -p .build - node build/listBuildCacheFiles.js .build/build_cache_list.txt - mkdir -p .build/build_cache - tar -czf .build/build_cache/cache.tgz --files-from .build/build_cache_list.txt - - - name: TypeScript type checking - run: npm run typecheck - - - name: Lint - run: npm run lint - - - name: Compile - run: npm run compile - - - name: Run vitest unit tests - run: npm run test:unit - - - name: Run simulation tests with cache - run: npm run simulate-ci - - - name: Run extension tests using VS Code - run: xvfb-run -a npm run test:extension - - - name: Run Completions Core prompt tests - run: npm run test:prompt - - - name: Run Completions Core lib tests using VS Code - run: xvfb-run -a npm run test:completions-core - - - name: Archive simulation output - if: always() - run: | - set -e - mkdir -p .simulation-archive - tar -czf .simulation-archive/simulation.tgz -C .simulation . - - - name: Upload simulation output - if: always() - uses: actions/upload-artifact@v7 - with: - name: simulation-output-linux-${{ github.run_attempt }} - path: .simulation-archive/simulation.tgz - - windows-tests: - name: Test (Windows) - runs-on: windows-latest - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - lfs: true - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version-file: .nvmrc - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: '3.12' - architecture: 'x64' - - - name: Setup .NET - uses: actions/setup-dotnet@v5 - with: - dotnet-version: '10.0' - - - name: Install setuptools - run: pip install setuptools - - - name: Restore build cache - uses: actions/cache/restore@v5 - id: build-cache - with: - key: windows-build_cache-${{ hashFiles('build/.cachesalt', 'package-lock.json') }} - path: .build/build_cache - - - name: Extract build cache - if: steps.build-cache.outputs.cache-hit == 'true' - run: 7z.exe x .build/build_cache/cache.7z -aoa - - - name: Install dependencies - if: steps.build-cache.outputs.cache-hit != 'true' - run: npm ci - - - name: Create build cache archive - if: steps.build-cache.outputs.cache-hit != 'true' - run: | - mkdir -Force .build - node build/listBuildCacheFiles.js .build/build_cache_list.txt - mkdir -Force .build/build_cache - 7z.exe a .build/build_cache/cache.7z -mx3 `@.build/build_cache_list.txt - - - name: TypeScript type checking - run: npm run typecheck - - - name: Lint - run: npm run lint - - - name: Compile - run: npm run compile - - - name: Run vitest unit tests - run: npm run test:unit - - - name: Run simulation tests with cache - run: npm run simulate-ci - - - name: Run extension tests using VS Code - run: npm run test:extension - - - name: Run Completions Core prompt tests - run: npm run test:prompt - - - name: Run Completions Core lib tests using VS Code - run: npm run test:completions-core diff --git a/extensions/copilot/build/.cachesalt b/extensions/copilot/build/.cachesalt deleted file mode 100644 index 08be458a78babe..00000000000000 --- a/extensions/copilot/build/.cachesalt +++ /dev/null @@ -1 +0,0 @@ -2026-06-06T10:06:34.000Z diff --git a/extensions/copilot/build/listBuildCacheFiles.js b/extensions/copilot/build/listBuildCacheFiles.js deleted file mode 100644 index 8cb1797e583da0..00000000000000 --- a/extensions/copilot/build/listBuildCacheFiles.js +++ /dev/null @@ -1,48 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -//@ts-check -'use strict'; - -const fs = require('fs'); -const path = require('path'); - -if (process.argv.length !== 3) { - console.error('Usage: node listBuildCacheFiles.js OUTPUT_FILE'); - process.exit(-1); -} - -const ROOT = path.join(__dirname, '../'); - -/** - * @param {string} location - * @param {string[]} result - */ -function listAllFiles(location, result) { - const entries = fs.readdirSync(path.join(ROOT, location)); - for (const entry of entries) { - const entryPath = `${location}/${entry}`; - - /** @type {import('fs').Stats} */ - let stat; - try { - stat = fs.statSync(path.join(ROOT, entryPath)); - } catch (err) { - continue; - } - - if (stat.isDirectory()) { - listAllFiles(entryPath, result); - } else { - result.push(entryPath); - } - } -} - -/** @type {string[]} */ -const result = []; -listAllFiles('node_modules', result); // node modules -listAllFiles('dist', result); // contains wasm files -fs.writeFileSync(process.argv[2], result.join('\n') + '\n'); diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index 8aba310e900d0f..fe491f3040f954 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -13,7 +13,7 @@ "@anthropic-ai/claude-agent-sdk": "0.2.112", "@anthropic-ai/sdk": "^0.82.0", "@github/blackbird-external-ingest-utils": "^0.3.0", - "@github/copilot": "^1.0.63", + "@github/copilot": "^1.0.64-0", "@google/genai": "^1.22.0", "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", @@ -2948,9 +2948,9 @@ "license": "MIT" }, "node_modules/@github/copilot": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.63.tgz", - "integrity": "sha512-e8DRYiWJQc4kepVXsXjC8vpDU2FXS/TfR+Z6p/KAojfcwIUZzKMAfCV5D1lD25hV4CryVH1Z9t7mHqChickj0Q==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.64-0.tgz", + "integrity": "sha512-PlH7ByBHjmPLqLXS4CE2q8hN6CFEfkCMV6ScBEzW/u73+KYQB4fGNouo8Lr8okL6D5CW5rzPJbsXyISyJqVOZg==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2", @@ -2960,20 +2960,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.63", - "@github/copilot-darwin-x64": "1.0.63", - "@github/copilot-linux-arm64": "1.0.63", - "@github/copilot-linux-x64": "1.0.63", - "@github/copilot-linuxmusl-arm64": "1.0.63", - "@github/copilot-linuxmusl-x64": "1.0.63", - "@github/copilot-win32-arm64": "1.0.63", - "@github/copilot-win32-x64": "1.0.63" + "@github/copilot-darwin-arm64": "1.0.64-0", + "@github/copilot-darwin-x64": "1.0.64-0", + "@github/copilot-linux-arm64": "1.0.64-0", + "@github/copilot-linux-x64": "1.0.64-0", + "@github/copilot-linuxmusl-arm64": "1.0.64-0", + "@github/copilot-linuxmusl-x64": "1.0.64-0", + "@github/copilot-win32-arm64": "1.0.64-0", + "@github/copilot-win32-x64": "1.0.64-0" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.63.tgz", - "integrity": "sha512-z6CMBxNDlKvT6bvOpqhu4M2bhb0daEbVwSe9SN9WfDUJbt7bpoL7OKKas428iyPSWHoL2WXwxSsy/FjIwSLV6w==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.64-0.tgz", + "integrity": "sha512-97DUGiuYrkCYOlSSLWMmr+K0uGzAxz1JOL/GyO/7mNl6V/1xgs6Van1Jj+Dpj4ly96iHE8lUIW8cQNCG66644g==", "cpu": [ "arm64" ], @@ -2987,9 +2987,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.63.tgz", - "integrity": "sha512-YKd7cXZgAGxhudzrtWdWh2NS35p2G5bV22Gz3jhEyBTqmq45o4sD4OwO87+UpkvM+3nZpwsHaLd3a+ILYX6OXg==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.64-0.tgz", + "integrity": "sha512-2PXY4mSFtIjFdRaAt8PakegRgGtf6Sz9z6U/dIgVygNfctVNzaL5FH65PNPm8Y80jaDvEcz1/XY5MiQtxnlzZQ==", "cpu": [ "x64" ], @@ -3003,9 +3003,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.63.tgz", - "integrity": "sha512-A3DOeEfmsJH9j1N+QLc7WXmESBskbezmhDyhyAJcHkw0ngRbKctuWQf/evUHFMh/kgwy1Lr/+9jXJm3NZqr0MA==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.64-0.tgz", + "integrity": "sha512-PLP+vR508fOTlCr9CSZiXi9geicHKXuX9jLGdwNqK2TMZO5TqCLz8wP7dBEmkdkeXcFKovMb8nQVB1Toc6xutw==", "cpu": [ "arm64" ], @@ -3022,9 +3022,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.63.tgz", - "integrity": "sha512-OMKfZJRoDaJOV7vuWX/nFPNdLa9/H+nhajdE83v4YT9mKLXr86aWrkXE3pPoDYsKWvgQFHg4APA6oZPao0Fyow==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.64-0.tgz", + "integrity": "sha512-NvVjQ69zr390ijzo2f75+v0DHm6xnvPbi67ugnKDk7ZPbx8P3vSxVdAnrzrrL4T3T8ng3pJANcC4p+eGbx+UDw==", "cpu": [ "x64" ], @@ -3041,9 +3041,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.63.tgz", - "integrity": "sha512-jcIo6B3uHgcOluNfUHp+6atShKKrXYBPLaRyF6aDT699lwI83gW9KTDuEvDs5FDg8qWsWFfOl+al2dkWDYD3CQ==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.64-0.tgz", + "integrity": "sha512-qCnVF5vIcTO74CukAENZo8e5nqXm4QUshuKN69aiZb5GOhVvyyIKsf5Jo7ikZt54jJBHycAMUKlTA8L3/nK+KA==", "cpu": [ "arm64" ], @@ -3060,9 +3060,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.63.tgz", - "integrity": "sha512-BEdBbEF3fG7VqXzuaAY4JtmbdGSkpJFeb2ZQYaMpq7OP3aS7ssGe1cCX8ehZNegcMM/eb4GC6PXNXsvl3X/PAQ==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.64-0.tgz", + "integrity": "sha512-WDBEmkBk1RulTfdLK5IuttNBadjLOBpvQyonGQ/aLeaetRNNdapoygrSjFU7q1QBSenmCyanXH6D+TS7tP3Qsw==", "cpu": [ "x64" ], @@ -3079,9 +3079,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.63.tgz", - "integrity": "sha512-7FqUwOmtoeBoOn4zkKQqRL+WGFwektVRSr5Po2FvPAbKxGXGyFXApZTmRLqVcHhMKDRzMb8KLST1LU1TMTY/wg==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.64-0.tgz", + "integrity": "sha512-PC7yuUKcVbhli4bpzWFVT3juxj+v/iONazetNe3tMpHWza3W7MeFRifzAseSErKQCt2fHJth3m8bQAwFN2jfrA==", "cpu": [ "arm64" ], @@ -3095,9 +3095,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.63.tgz", - "integrity": "sha512-RC/6y9KHdw/YRCrCEksF2RzbeblfBUNE7bkYZxygaQGYThuv1GeZL2YD2jVqxC2LxKzsUmWGvwEMxerfR6pmeQ==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.64-0.tgz", + "integrity": "sha512-d2fnUTIlqNxCqS2PuV+FD99ZOYBaX72OLtAmphbKyz36KyZ6D4ssiu8M4vHVTKWWdyc3TWiLsnIB+ryWdv1gGw==", "cpu": [ "x64" ], diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 5e832bbc0961b0..d843396cb9d907 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -7127,7 +7127,7 @@ "@anthropic-ai/claude-agent-sdk": "0.2.112", "@anthropic-ai/sdk": "^0.82.0", "@github/blackbird-external-ingest-utils": "^0.3.0", - "@github/copilot": "^1.0.63", + "@github/copilot": "^1.0.64-0", "@google/genai": "^1.22.0", "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", diff --git a/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts b/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts index acb97f05e93a9b..0c654168a52028 100644 --- a/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts +++ b/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts @@ -19,7 +19,7 @@ import { IEnvService, isScenarioAutomation } from '../../../platform/env/common/ import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; import { collectErrorMessages, collectSingleLineErrorMessage, ILogService, sanitizeNetworkErrorForTelemetry } from '../../../platform/log/common/logService'; import { outputChannel } from '../../../platform/log/vscode/outputChannelLogTarget'; -import { FetchEvent, IFetcherService } from '../../../platform/networking/common/fetcherService'; +import { FetchEvent, IFetcherService, Response } from '../../../platform/networking/common/fetcherService'; import { IFetcher, userAgentLibraryHeader } from '../../../platform/networking/common/networking'; import { NodeFetcher } from '../../../platform/networking/node/nodeFetcher'; import { NodeFetchFetcher } from '../../../platform/networking/node/nodeFetchFetcher'; @@ -210,24 +210,50 @@ User Settings: const useVSCodeTelemetryLibForGH = this.configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.UseVSCodeTelemetryLibForGH, this.experimentationService); const githubTelemetryFetcher = useVSCodeTelemetryLibForGH ? currentFetcher : nodeFetcher; const microsoftAIKey = (this._context.extension.packageJSON as { ariaKey?: string }).ariaKey ?? ''; - const microsoftTelemetryUrl = !microsoftAIKey || isOneDataSystemKey(microsoftAIKey) - ? 'https://mobile.events.data.microsoft.com' // Microsoft 1DS/OneCollector telemetry endpoint (newer). - : 'https://dc.services.visualstudio.com'; // Azure Application Insights telemetry endpoint (older). - const secondaryUrls = [ - { url: microsoftTelemetryUrl, fetcher: currentFetcher }, - // GitHub Copilot telemetry endpoint (configured/effective host). - { url: vscode.Uri.parse(this.capiClientService.copilotTelemetryURL).with({ path: '/_ping' }).toString(), fetcher: githubTelemetryFetcher }, - // Experimentation (ExP/TAS) service endpoint. - { url: 'https://default.exp-tas.com', fetcher: nodeFetchFetcher }, + const microsoftIsOneDS = !microsoftAIKey || isOneDataSystemKey(microsoftAIKey); + const secondaryUrls: { + url: string; + fetcher: { name: string; fetcher: IFetcher }; + connect: (fetcher: IFetcher, url: string) => Promise; + }[] = [ + microsoftIsOneDS + ? { + // Microsoft 1DS/OneCollector telemetry endpoint (newer). + url: oneCollectorTelemetryUrl, + fetcher: currentFetcher, + connect: (fetcher, url) => vscode.env.isTelemetryEnabled + ? sendRawTelemetry(fetcher, this.envService, url, this._context, 'GitHub.copilot-chat/diagnosticsTelemetryProbe', {}) + : Promise.resolve('Telemetry is not enabled'), + } + : { + // Azure Application Insights telemetry endpoint (older). + url: 'https://dc.services.visualstudio.com/v2.1/track', + fetcher: currentFetcher, + connect: (fetcher, url) => sendRawAITelemetry(fetcher, this.envService, url), + }, + { + // GitHub Copilot telemetry endpoint (App Insights-compatible ingestion; the URL already + // ends in `/telemetry`, which is the path production posts telemetry to). + url: this.capiClientService.copilotTelemetryURL, + fetcher: githubTelemetryFetcher, + connect: (fetcher, url) => sendRawAITelemetry(fetcher, this.envService, url), + }, + { + // Experimentation (ExP/TAS) service endpoint (path used by vscode-tas-client). + url: 'https://default.exp-tas.com/vscode/ab', + fetcher: nodeFetchFetcher, + connect: (fetcher, url) => fetcher.fetch(url, { callSite: 'diagnostics-secondary-probe' }), + }, ]; await appendText(editor, `\n`); - for (const { url, fetcher } of secondaryUrls) { - const authHeaders = await this.getAuthHeaders(isGHEnterprise, url); + for (const { url, fetcher, connect } of secondaryUrls) { await appendText(editor, `Connecting to ${url} (${fetcher.name}): `); const start = Date.now(); try { - const response = await Promise.race([fetcher.fetcher.fetch(url, { headers: authHeaders, callSite: 'diagnostics-secondary-probe' }), timeout(timeoutSeconds * 1000)]); - if (response) { + const response = await Promise.race([connect(fetcher.fetcher, url), timeout(timeoutSeconds * 1000)]); + if (typeof response === 'string') { + await appendText(editor, `${response}\n`); + } else if (response) { await appendText(editor, `HTTP ${response.status} (${Date.now() - start} ms)\n`); } else { await appendText(editor, `timed out after ${timeoutSeconds} seconds\n`); @@ -504,7 +530,7 @@ function collectFetcherTelemetry(accessor: ServicesAccessor): void { const key = library.replace(/-/g, ''); const requestStartTime = Date.now(); try { - const response = await sendRawTelemetry(fetcher, envService, extensionContext, 'GitHub.copilot-chat/fetcherTelemetryProbe', {}); + const response = await sendRawTelemetry(fetcher, envService, oneCollectorTelemetryUrl, extensionContext, 'GitHub.copilot-chat/fetcherTelemetryProbe', {}); probeResults[key] = `Status: ${response.status}`; logService.debug(`Fetcher telemetry probe: ${library} ${probeResults[key]} (${Date.now() - requestStartTime}ms)`); } catch (e) { @@ -539,7 +565,7 @@ function collectFetcherTelemetry(accessor: ServicesAccessor): void { remoteName: vscode.env.remoteName ?? 'none', ...probeResults, }; - const response = await sendRawTelemetry(fetcher, envService, extensionContext, 'GitHub.copilot-chat/fetcherTelemetry', properties); + const response = await sendRawTelemetry(fetcher, envService, oneCollectorTelemetryUrl, extensionContext, 'GitHub.copilot-chat/fetcherTelemetry', properties); logService.debug(`Fetcher telemetry: Succeeded in ${Date.now() - requestStartTime}ms using ${fetcher.getUserAgentLibrary()} with status ${response.status} (${response.statusText}).`); } catch (e) { @@ -551,8 +577,9 @@ function collectFetcherTelemetry(accessor: ServicesAccessor): void { }); } -async function sendRawTelemetry(fetcher: IFetcher, envService: IEnvService, extensionContext: IVSCodeExtensionContext, eventName: string, properties: Record) { - const url = 'https://mobile.events.data.microsoft.com/OneCollector/1.0?cors=true&content-type=application/x-json-stream'; +const oneCollectorTelemetryUrl = 'https://mobile.events.data.microsoft.com/OneCollector/1.0?cors=true&content-type=application/x-json-stream'; + +async function sendRawTelemetry(fetcher: IFetcher, envService: IEnvService, url: string, extensionContext: IVSCodeExtensionContext, eventName: string, properties: Record) { const product = require(path.join(vscode.env.appRoot, 'product.json')); const vscodeCommitHash: string = product.commit || ''; const ariaKey = (extensionContext.extension.packageJSON as { ariaKey?: string }).ariaKey ?? ''; @@ -621,6 +648,30 @@ async function sendRawTelemetry(fetcher: IFetcher, envService: IEnvService, exte return response; } +/** + * Sends a raw Application Insights telemetry request with an empty batch (zero events) to probe + * connectivity to an Application Insights ingestion endpoint. + */ +async function sendRawAITelemetry(fetcher: IFetcher, envService: IEnvService, url: string) { + const body = '[]'; + const headers: Record = { + 'Content-Type': 'application/json', + 'User-Agent': `GitHubCopilotChat/${envService.getVersion()}`, + [userAgentLibraryHeader]: fetcher.getUserAgentLibrary(), + }; + if (fetcher.getUserAgentLibrary() === NodeFetcher.ID) { + headers['content-length'] = String(Buffer.byteLength(body)); + } + const response = await fetcher.fetch(url, { + method: 'POST', + headers, + body, + callSite: 'diagnostics-telemetry-probe', + }); + await response.text(); + return response; +} + const ids_paths = /(^|\b)[\p{L}\p{Nd}]+((=""?[^"]+""?)|(([.:=/"_-]+[\p{L}\p{Nd}]+)+))(\b|$)/giu; export function sanitizeValue(input: string | undefined): string { return (input || '').replace(ids_paths, (m) => maskByClass(m)); diff --git a/extensions/copilot/src/platform/promptFiles/node/automaticInstructionsCollector.ts b/extensions/copilot/src/platform/promptFiles/node/automaticInstructionsCollector.ts index bb169a0a41c070..b50f361e0b512f 100644 --- a/extensions/copilot/src/platform/promptFiles/node/automaticInstructionsCollector.ts +++ b/extensions/copilot/src/platform/promptFiles/node/automaticInstructionsCollector.ts @@ -857,14 +857,14 @@ export class CustomInstructionsReferenceLogger { const customInstructionsDebugInfo = await this.toCustomInstructionsDebugInfo(references); const span = this._otelService.startSpan('collect_automatic_instructions', { attributes: { - [GenAiAttr.OPERATION_NAME]: GenAiOperationName.CHAT, + [GenAiAttr.OPERATION_NAME]: GenAiOperationName.CONTENT_EVENT, ...(sessionId ? { [CopilotChatAttr.CHAT_SESSION_ID]: sessionId } : {}), }, }); span.setAttributes({ - [CopilotChatAttr.DEBUG_NAME]: `Agent Instructions`, + [CopilotChatAttr.DEBUG_NAME]: `Custom Instructions`, 'copilot_chat.event_category': 'discovery', - 'copilot_chat.event_details': ICustomInstructionsDebugInfo.formatCompact(customInstructionsDebugInfo) + (collectInstructionsInExtension ? '\n(collected in extension)' : '\n(collected in core)'), + 'copilot_chat.event_details': ICustomInstructionsDebugInfo.formatAgentDebugLog(customInstructionsDebugInfo) + (collectInstructionsInExtension ? '\n(collected in extension)' : '\n(collected in core)'), }); span.end(); } @@ -937,18 +937,14 @@ namespace ICustomInstructionsDebugInfo { function formatSkillNames(a: string[]): string { return a.map(i => posix.basename(posix.dirname(i))).join(', '); } - export function formatCompact(a: ICustomInstructionsDebugInfo): string { + export function formatAgentDebugLog(a: ICustomInstructionsDebugInfo): string { const result = []; - result.push(`instructions: [${a.instructions.length} entries]`); - if (a.instructions.length > 0) { - result.push(`(${a.instructions.map(i => posix.basename(i)).join(', ')})`); - } + result.push(`context included: [${a.instructions.length}] ${formatFileNames(a.instructions)}`); if (a.index) { - result.push(`index: {`); - result.push(`agents: [${a.index.agents.length}] ${formatFileNames(a.index.agents)}`); - result.push(`instructions: [${a.index.instructions.length}] ${formatFileNames(a.index.instructions)}`); - result.push(`skills: [${a.index.skills.length}] ${formatSkillNames(a.index.skills)}`); - result.push(`}`); + result.push(`on-demand loading:`); + result.push(` instructions: [${a.index.instructions.length}] ${formatFileNames(a.index.instructions)}`); + result.push(` skills: [${a.index.skills.length}] ${formatSkillNames(a.index.skills)}`); + result.push(` agents: [${a.index.agents.length}] ${formatFileNames(a.index.agents)}`); } return result.join('\n'); } diff --git a/extensions/theme-defaults/themes/2026-dark.json b/extensions/theme-defaults/themes/2026-dark.json index f88f582c9c12e1..da580e40bce47f 100644 --- a/extensions/theme-defaults/themes/2026-dark.json +++ b/extensions/theme-defaults/themes/2026-dark.json @@ -48,9 +48,9 @@ "inputValidation.errorBorder": "#BE1100", "inputValidation.errorForeground": "#bfbfbf", "scrollbar.shadow": "#191B1D4D", - "scrollbarSlider.background": "#83848533", - "scrollbarSlider.hoverBackground": "#83848566", - "scrollbarSlider.activeBackground": "#83848599", + "scrollbarSlider.background": "#A8A9AA85", + "scrollbarSlider.hoverBackground": "#A8A9AA90", + "scrollbarSlider.activeBackground": "#A8A9AA9C", "badge.background": "#3994BCF0", "badge.foreground": "#FFFFFF", "progressBar.background": "#878889", @@ -276,9 +276,9 @@ "charts.green": "#86CF86", "charts.purple": "#AD80D7", "inlineChat.border": "#00000000", - "minimapSlider.background": "#83848533", - "minimapSlider.hoverBackground": "#83848566", - "minimapSlider.activeBackground": "#83848599", + "minimapSlider.background": "#A8A9AA85", + "minimapSlider.hoverBackground": "#A8A9AA90", + "minimapSlider.activeBackground": "#A8A9AA9C", "agents.background": "#121314", "agentsPanel.background": "#191A1B", diff --git a/extensions/theme-defaults/themes/2026-light.json b/extensions/theme-defaults/themes/2026-light.json index abfcdf33f7e0ff..13af896372f767 100644 --- a/extensions/theme-defaults/themes/2026-light.json +++ b/extensions/theme-defaults/themes/2026-light.json @@ -58,9 +58,9 @@ "sideBarStickyScroll.shadow": "#00000000", "panelStickyScroll.shadow": "#00000000", "listFilterWidget.shadow": "#00000000", - "scrollbarSlider.background": "#99999926", - "scrollbarSlider.hoverBackground": "#99999940", - "scrollbarSlider.activeBackground": "#99999955", + "scrollbarSlider.background": "#64646480", + "scrollbarSlider.hoverBackground": "#64646490", + "scrollbarSlider.activeBackground": "#646464A0", "badge.background": "#0069CC", "badge.foreground": "#FFFFFF", "progressBar.background": "#0069CC", @@ -282,9 +282,9 @@ "charts.purple": "#652D90", "agentStatusIndicator.background": "#FFFFFF", "inlineChat.border": "#00000000", - "minimapSlider.background": "#99999926", - "minimapSlider.hoverBackground": "#99999940", - "minimapSlider.activeBackground": "#99999955", + "minimapSlider.background": "#64646480", + "minimapSlider.hoverBackground": "#64646490", + "minimapSlider.activeBackground": "#646464A0", "agents.background": "#FAFAFD", "agentsPanel.background": "#FFFFFF", diff --git a/package-lock.json b/package-lock.json index c1ebbfa6c7eb0e..91303890d143bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,8 @@ "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.82.0", - "@github/copilot": "^1.0.63", - "@github/copilot-sdk": "^1.0.1", + "@github/copilot": "^1.0.64-0", + "@github/copilot-sdk": "^1.0.2", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", @@ -1084,9 +1084,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.63.tgz", - "integrity": "sha512-e8DRYiWJQc4kepVXsXjC8vpDU2FXS/TfR+Z6p/KAojfcwIUZzKMAfCV5D1lD25hV4CryVH1Z9t7mHqChickj0Q==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.64-0.tgz", + "integrity": "sha512-PlH7ByBHjmPLqLXS4CE2q8hN6CFEfkCMV6ScBEzW/u73+KYQB4fGNouo8Lr8okL6D5CW5rzPJbsXyISyJqVOZg==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2", @@ -1096,20 +1096,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.63", - "@github/copilot-darwin-x64": "1.0.63", - "@github/copilot-linux-arm64": "1.0.63", - "@github/copilot-linux-x64": "1.0.63", - "@github/copilot-linuxmusl-arm64": "1.0.63", - "@github/copilot-linuxmusl-x64": "1.0.63", - "@github/copilot-win32-arm64": "1.0.63", - "@github/copilot-win32-x64": "1.0.63" + "@github/copilot-darwin-arm64": "1.0.64-0", + "@github/copilot-darwin-x64": "1.0.64-0", + "@github/copilot-linux-arm64": "1.0.64-0", + "@github/copilot-linux-x64": "1.0.64-0", + "@github/copilot-linuxmusl-arm64": "1.0.64-0", + "@github/copilot-linuxmusl-x64": "1.0.64-0", + "@github/copilot-win32-arm64": "1.0.64-0", + "@github/copilot-win32-x64": "1.0.64-0" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.63.tgz", - "integrity": "sha512-z6CMBxNDlKvT6bvOpqhu4M2bhb0daEbVwSe9SN9WfDUJbt7bpoL7OKKas428iyPSWHoL2WXwxSsy/FjIwSLV6w==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.64-0.tgz", + "integrity": "sha512-97DUGiuYrkCYOlSSLWMmr+K0uGzAxz1JOL/GyO/7mNl6V/1xgs6Van1Jj+Dpj4ly96iHE8lUIW8cQNCG66644g==", "cpu": [ "arm64" ], @@ -1123,9 +1123,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.63.tgz", - "integrity": "sha512-YKd7cXZgAGxhudzrtWdWh2NS35p2G5bV22Gz3jhEyBTqmq45o4sD4OwO87+UpkvM+3nZpwsHaLd3a+ILYX6OXg==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.64-0.tgz", + "integrity": "sha512-2PXY4mSFtIjFdRaAt8PakegRgGtf6Sz9z6U/dIgVygNfctVNzaL5FH65PNPm8Y80jaDvEcz1/XY5MiQtxnlzZQ==", "cpu": [ "x64" ], @@ -1139,12 +1139,15 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.63.tgz", - "integrity": "sha512-A3DOeEfmsJH9j1N+QLc7WXmESBskbezmhDyhyAJcHkw0ngRbKctuWQf/evUHFMh/kgwy1Lr/+9jXJm3NZqr0MA==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.64-0.tgz", + "integrity": "sha512-PLP+vR508fOTlCr9CSZiXi9geicHKXuX9jLGdwNqK2TMZO5TqCLz8wP7dBEmkdkeXcFKovMb8nQVB1Toc6xutw==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1155,12 +1158,15 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.63.tgz", - "integrity": "sha512-OMKfZJRoDaJOV7vuWX/nFPNdLa9/H+nhajdE83v4YT9mKLXr86aWrkXE3pPoDYsKWvgQFHg4APA6oZPao0Fyow==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.64-0.tgz", + "integrity": "sha512-NvVjQ69zr390ijzo2f75+v0DHm6xnvPbi67ugnKDk7ZPbx8P3vSxVdAnrzrrL4T3T8ng3pJANcC4p+eGbx+UDw==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1171,12 +1177,15 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.63.tgz", - "integrity": "sha512-jcIo6B3uHgcOluNfUHp+6atShKKrXYBPLaRyF6aDT699lwI83gW9KTDuEvDs5FDg8qWsWFfOl+al2dkWDYD3CQ==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.64-0.tgz", + "integrity": "sha512-qCnVF5vIcTO74CukAENZo8e5nqXm4QUshuKN69aiZb5GOhVvyyIKsf5Jo7ikZt54jJBHycAMUKlTA8L3/nK+KA==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1187,12 +1196,15 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.63.tgz", - "integrity": "sha512-BEdBbEF3fG7VqXzuaAY4JtmbdGSkpJFeb2ZQYaMpq7OP3aS7ssGe1cCX8ehZNegcMM/eb4GC6PXNXsvl3X/PAQ==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.64-0.tgz", + "integrity": "sha512-WDBEmkBk1RulTfdLK5IuttNBadjLOBpvQyonGQ/aLeaetRNNdapoygrSjFU7q1QBSenmCyanXH6D+TS7tP3Qsw==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1203,12 +1215,12 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.1.tgz", - "integrity": "sha512-w6AaS0WqqTE/3iyUrZznvgCLQhsUF7ZmEVCneacuHCfOzlH0r6ww9WUmyA0zgqmXO75V0IYrkIcnFke/qJkkDg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.2.tgz", + "integrity": "sha512-JJDsGM/bA1LGy1Ro/8iC8RLpKsLmuiFdQ67oFAVfi0Hfxyx289teHwmM70ehK76DXBkrPqqcxcliJi56k1ggFA==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.61", + "@github/copilot": "^1.0.64-0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -1226,9 +1238,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.63.tgz", - "integrity": "sha512-7FqUwOmtoeBoOn4zkKQqRL+WGFwektVRSr5Po2FvPAbKxGXGyFXApZTmRLqVcHhMKDRzMb8KLST1LU1TMTY/wg==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.64-0.tgz", + "integrity": "sha512-PC7yuUKcVbhli4bpzWFVT3juxj+v/iONazetNe3tMpHWza3W7MeFRifzAseSErKQCt2fHJth3m8bQAwFN2jfrA==", "cpu": [ "arm64" ], @@ -1242,9 +1254,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.63.tgz", - "integrity": "sha512-RC/6y9KHdw/YRCrCEksF2RzbeblfBUNE7bkYZxygaQGYThuv1GeZL2YD2jVqxC2LxKzsUmWGvwEMxerfR6pmeQ==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.64-0.tgz", + "integrity": "sha512-d2fnUTIlqNxCqS2PuV+FD99ZOYBaX72OLtAmphbKyz36KyZ6D4ssiu8M4vHVTKWWdyc3TWiLsnIB+ryWdv1gGw==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 882bc400d0cecb..c726770761a07b 100644 --- a/package.json +++ b/package.json @@ -95,8 +95,8 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.82.0", - "@github/copilot": "^1.0.63", - "@github/copilot-sdk": "^1.0.1", + "@github/copilot": "^1.0.64-0", + "@github/copilot-sdk": "^1.0.2", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", diff --git a/remote/package-lock.json b/remote/package-lock.json index eb4eaf7bc83b02..55824d04ad9f69 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -8,8 +8,8 @@ "name": "vscode-reh", "version": "0.0.0", "dependencies": { - "@github/copilot": "^1.0.63", - "@github/copilot-sdk": "^1.0.1", + "@github/copilot": "^1.0.64-0", + "@github/copilot-sdk": "^1.0.2", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/mxc-sdk": "0.6.0", @@ -59,9 +59,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.63.tgz", - "integrity": "sha512-e8DRYiWJQc4kepVXsXjC8vpDU2FXS/TfR+Z6p/KAojfcwIUZzKMAfCV5D1lD25hV4CryVH1Z9t7mHqChickj0Q==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.64-0.tgz", + "integrity": "sha512-PlH7ByBHjmPLqLXS4CE2q8hN6CFEfkCMV6ScBEzW/u73+KYQB4fGNouo8Lr8okL6D5CW5rzPJbsXyISyJqVOZg==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2", @@ -71,20 +71,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.63", - "@github/copilot-darwin-x64": "1.0.63", - "@github/copilot-linux-arm64": "1.0.63", - "@github/copilot-linux-x64": "1.0.63", - "@github/copilot-linuxmusl-arm64": "1.0.63", - "@github/copilot-linuxmusl-x64": "1.0.63", - "@github/copilot-win32-arm64": "1.0.63", - "@github/copilot-win32-x64": "1.0.63" + "@github/copilot-darwin-arm64": "1.0.64-0", + "@github/copilot-darwin-x64": "1.0.64-0", + "@github/copilot-linux-arm64": "1.0.64-0", + "@github/copilot-linux-x64": "1.0.64-0", + "@github/copilot-linuxmusl-arm64": "1.0.64-0", + "@github/copilot-linuxmusl-x64": "1.0.64-0", + "@github/copilot-win32-arm64": "1.0.64-0", + "@github/copilot-win32-x64": "1.0.64-0" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.63.tgz", - "integrity": "sha512-z6CMBxNDlKvT6bvOpqhu4M2bhb0daEbVwSe9SN9WfDUJbt7bpoL7OKKas428iyPSWHoL2WXwxSsy/FjIwSLV6w==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.64-0.tgz", + "integrity": "sha512-97DUGiuYrkCYOlSSLWMmr+K0uGzAxz1JOL/GyO/7mNl6V/1xgs6Van1Jj+Dpj4ly96iHE8lUIW8cQNCG66644g==", "cpu": [ "arm64" ], @@ -98,9 +98,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.63.tgz", - "integrity": "sha512-YKd7cXZgAGxhudzrtWdWh2NS35p2G5bV22Gz3jhEyBTqmq45o4sD4OwO87+UpkvM+3nZpwsHaLd3a+ILYX6OXg==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.64-0.tgz", + "integrity": "sha512-2PXY4mSFtIjFdRaAt8PakegRgGtf6Sz9z6U/dIgVygNfctVNzaL5FH65PNPm8Y80jaDvEcz1/XY5MiQtxnlzZQ==", "cpu": [ "x64" ], @@ -114,9 +114,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.63.tgz", - "integrity": "sha512-A3DOeEfmsJH9j1N+QLc7WXmESBskbezmhDyhyAJcHkw0ngRbKctuWQf/evUHFMh/kgwy1Lr/+9jXJm3NZqr0MA==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.64-0.tgz", + "integrity": "sha512-PLP+vR508fOTlCr9CSZiXi9geicHKXuX9jLGdwNqK2TMZO5TqCLz8wP7dBEmkdkeXcFKovMb8nQVB1Toc6xutw==", "cpu": [ "arm64" ], @@ -133,9 +133,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.63.tgz", - "integrity": "sha512-OMKfZJRoDaJOV7vuWX/nFPNdLa9/H+nhajdE83v4YT9mKLXr86aWrkXE3pPoDYsKWvgQFHg4APA6oZPao0Fyow==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.64-0.tgz", + "integrity": "sha512-NvVjQ69zr390ijzo2f75+v0DHm6xnvPbi67ugnKDk7ZPbx8P3vSxVdAnrzrrL4T3T8ng3pJANcC4p+eGbx+UDw==", "cpu": [ "x64" ], @@ -152,9 +152,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.63.tgz", - "integrity": "sha512-jcIo6B3uHgcOluNfUHp+6atShKKrXYBPLaRyF6aDT699lwI83gW9KTDuEvDs5FDg8qWsWFfOl+al2dkWDYD3CQ==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.64-0.tgz", + "integrity": "sha512-qCnVF5vIcTO74CukAENZo8e5nqXm4QUshuKN69aiZb5GOhVvyyIKsf5Jo7ikZt54jJBHycAMUKlTA8L3/nK+KA==", "cpu": [ "arm64" ], @@ -171,9 +171,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.63.tgz", - "integrity": "sha512-BEdBbEF3fG7VqXzuaAY4JtmbdGSkpJFeb2ZQYaMpq7OP3aS7ssGe1cCX8ehZNegcMM/eb4GC6PXNXsvl3X/PAQ==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.64-0.tgz", + "integrity": "sha512-WDBEmkBk1RulTfdLK5IuttNBadjLOBpvQyonGQ/aLeaetRNNdapoygrSjFU7q1QBSenmCyanXH6D+TS7tP3Qsw==", "cpu": [ "x64" ], @@ -190,12 +190,12 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.1.tgz", - "integrity": "sha512-w6AaS0WqqTE/3iyUrZznvgCLQhsUF7ZmEVCneacuHCfOzlH0r6ww9WUmyA0zgqmXO75V0IYrkIcnFke/qJkkDg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.2.tgz", + "integrity": "sha512-JJDsGM/bA1LGy1Ro/8iC8RLpKsLmuiFdQ67oFAVfi0Hfxyx289teHwmM70ehK76DXBkrPqqcxcliJi56k1ggFA==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.61", + "@github/copilot": "^1.0.64-0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -213,9 +213,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.63.tgz", - "integrity": "sha512-7FqUwOmtoeBoOn4zkKQqRL+WGFwektVRSr5Po2FvPAbKxGXGyFXApZTmRLqVcHhMKDRzMb8KLST1LU1TMTY/wg==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.64-0.tgz", + "integrity": "sha512-PC7yuUKcVbhli4bpzWFVT3juxj+v/iONazetNe3tMpHWza3W7MeFRifzAseSErKQCt2fHJth3m8bQAwFN2jfrA==", "cpu": [ "arm64" ], @@ -229,9 +229,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.63.tgz", - "integrity": "sha512-RC/6y9KHdw/YRCrCEksF2RzbeblfBUNE7bkYZxygaQGYThuv1GeZL2YD2jVqxC2LxKzsUmWGvwEMxerfR6pmeQ==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.64-0.tgz", + "integrity": "sha512-d2fnUTIlqNxCqS2PuV+FD99ZOYBaX72OLtAmphbKyz36KyZ6D4ssiu8M4vHVTKWWdyc3TWiLsnIB+ryWdv1gGw==", "cpu": [ "x64" ], diff --git a/remote/package.json b/remote/package.json index ebb945beef7ccb..86894ffa3302a5 100644 --- a/remote/package.json +++ b/remote/package.json @@ -3,8 +3,8 @@ "version": "0.0.0", "private": true, "dependencies": { - "@github/copilot": "^1.0.63", - "@github/copilot-sdk": "^1.0.1", + "@github/copilot": "^1.0.64-0", + "@github/copilot-sdk": "^1.0.2", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/mxc-sdk": "0.6.0", diff --git a/src/vs/base/browser/ui/splitview/paneview.ts b/src/vs/base/browser/ui/splitview/paneview.ts index 42e000a5dcddb5..024b21479868fb 100644 --- a/src/vs/base/browser/ui/splitview/paneview.ts +++ b/src/vs/base/browser/ui/splitview/paneview.ts @@ -5,7 +5,7 @@ import { isFirefox } from '../../browser.js'; import { DataTransfers } from '../../dnd.js'; -import { $, addDisposableListener, append, clearNode, EventHelper, EventType, getWindow, isHTMLElement, trackFocus } from '../../dom.js'; +import { $, addDisposableListener, append, clearNode, EventHelper, EventType, getWindow, isHTMLElement, scheduleAtNextAnimationFrame, trackFocus } from '../../dom.js'; import { DomEmitter } from '../../event.js'; import { StandardKeyboardEvent } from '../../keyboardEvent.js'; import { Gesture, EventType as TouchEventType } from '../../touch.js'; @@ -13,7 +13,7 @@ import { IBoundarySashes, Orientation } from '../sash/sash.js'; import { Color, RGBA } from '../../../common/color.js'; import { Emitter, Event } from '../../../common/event.js'; import { KeyCode } from '../../../common/keyCodes.js'; -import { Disposable, DisposableStore, IDisposable } from '../../../common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../common/lifecycle.js'; import { ScrollEvent } from '../../../common/scrollable.js'; import './paneview.css'; import { localize } from '../../../../nls.js'; @@ -85,6 +85,15 @@ export abstract class Pane extends Disposable implements IView { */ private _headerSize: number | undefined = undefined; + /** + * Deferred re-clamp scheduled when {@link Pane.layout} detects that the header + * size changed between passes (e.g. the `--pane-header-size` CSS variable was + * overridden at runtime). Fires {@link Pane.onDidChange} on the next frame so + * the split view re-clamps the size it reserves for this pane without + * reentering the current layout pass. + */ + private readonly _headerSizeRelayout = this._register(new MutableDisposable()); + private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; @@ -327,9 +336,21 @@ export abstract class Pane extends Disposable implements IView { layout(size: number): void { // Re-read the header size from CSS once per layout pass; subsequent // `minimumSize` / `maximumSize` reads within the pass reuse the cache. + const previousHeaderSize = this._headerSize; this._headerSize = undefined; const headerSize = this.headerSize; + // If the header size changed since the previous pass — e.g. the + // `--pane-header-size` CSS variable was overridden at runtime (a style + // override toggled) — the containing split view still reserves the old + // size for this pane, most visibly when it is collapsed. Refresh the + // header and, on the next frame, fire `onDidChange` so the split view + // re-clamps the reservation. Deferring avoids reentering this layout pass. + if (previousHeaderSize !== undefined && previousHeaderSize !== headerSize) { + this.updateHeader(); + this._headerSizeRelayout.value = scheduleAtNextAnimationFrame(getWindow(this.element), () => this._onDidChange.fire(undefined)); + } + const width = this._orientation === Orientation.VERTICAL ? this.orthogonalSize : size; const height = this._orientation === Orientation.VERTICAL ? size - headerSize : this.orthogonalSize - headerSize; diff --git a/src/vs/base/common/managedSettings.ts b/src/vs/base/common/managedSettings.ts index 53edd6b2cd5b65..56e99e0ef0b7f1 100644 --- a/src/vs/base/common/managedSettings.ts +++ b/src/vs/base/common/managedSettings.ts @@ -11,6 +11,25 @@ export type IExtraKnownMarketplaceEntry = | { readonly name: string; readonly source: { readonly source: 'github'; readonly repo: string; readonly ref?: string } } | { readonly name: string; readonly source: { readonly source: 'git'; readonly url: string; readonly ref?: string } }; +/** + * A single entry in the enterprise-managed `strictKnownMarketplaces` allowlist + * (the `chat.plugins.strictMarketplaces` setting), a discriminated union on + * `source`. Delivered as JSON via managed settings (the server endpoint or + * native MDM) and validated at match time, so the optional fields are only + * meaningful for their corresponding `source`. + */ +export interface IStrictMarketplaceSource { + readonly source: 'github' | 'git' | 'url' | 'npm' | 'file' | 'directory' | 'hostPattern' | 'pathPattern'; + readonly repo?: string; + readonly url?: string; + readonly ref?: string; + readonly path?: string; + readonly package?: string; + readonly hostPattern?: string; + readonly pathPattern?: string; + readonly headers?: Readonly>; +} + /** * Converts an {@link IExtraKnownMarketplaceEntry} array into the * `{ [name]: url-or-shorthand }` dict stored on the `chat.plugins.extraMarketplaces` diff --git a/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts b/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts index f5c5219618cf08..e85de8374f994c 100644 --- a/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts @@ -13,6 +13,7 @@ import { CustomizationType, SkillCustomization } from '../common/state/sessionSt import { CompletionTriggerCharacter, IAgentHostCompletionItemProvider } from './agentHostCompletions.js'; import { extractWhitespaceDelimitedSlashToken } from './agentHostSlashCompletion.js'; + /** * Generic completion provider that contributes slash completions for skills * exposed through an agent's global and session-effective customizations. @@ -49,46 +50,67 @@ export class AgentHostSkillCompletionProvider extends Disposable implements IAge const skillsSeen = new Set(); return candidates .filter(skill => { - const uri = skill.uri.toString(); - if ((!typed.length || skill.name.startsWith(typed)) && !skillsSeen.has(uri)) { + const uri = skill.uri; + if ((!typed.length || skill.slashCommandName.startsWith(typed)) && !skillsSeen.has(uri)) { skillsSeen.add(uri); return true; } return false; }) .map(skill => ({ - insertText: '/' + skill.name + ' ', + insertText: '/' + skill.slashCommandName + ' ', rangeStart: leading.rangeStart, rangeEnd: leading.rangeEnd, attachment: { type: MessageAttachmentKind.Simple, - label: '/' + skill.name, + label: '/' + skill.slashCommandName, _meta: { - uri: skill.uri.toString(), + uri: skill.uri, name: skill.name, - displayName: skill.name, + displayName: skill.slashCommandName, ...(skill.description !== undefined ? { description: skill.description } : {}), }, }, })); } - private async _getCandidates(agent: IAgent, session: URI): Promise { + private async _getCandidates(agent: IAgent, session: URI): Promise { if (!agent.getSessionCustomizations) { return []; } const customizations = await agent.getSessionCustomizations(session); - const result: SkillCustomization[] = []; + const result: SlashCommmandCandidate[] = []; for (const c of customizations) { if (c.type === CustomizationType.McpServer || !c.enabled || !c.children) { continue; } for (const child of c.children) { if (child.type === CustomizationType.Skill) { - result.push(child); + result.push(this._toSlashCommandCandidate(c.type === CustomizationType.Plugin ? c.name : undefined, child)); } } } return result; } + + private _toSlashCommandCandidate(pluginId: string | undefined, skill: SkillCustomization): SlashCommmandCandidate { + // see getCanonicalPluginCommandId + let slashCommandName = skill.name; + if (pluginId && skill.name !== pluginId) { + slashCommandName = `${pluginId}:${skill.name}`; + } + return { + slashCommandName: slashCommandName, + name: skill.name, + description: skill.description, + uri: skill.uri, + }; + } +} + +interface SlashCommmandCandidate { + readonly slashCommandName: string; + readonly name: string; + readonly description: string | undefined; + readonly uri: string; } diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index cd61f749aa049d..2639977dd1d3e5 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -333,7 +333,7 @@ export class ClaudeAgent extends Disposable implements IAgent { .sort((a, b) => Number(b.is_chat_default) - Number(a.is_chat_default)) .map(m => toAgentModelInfo(m, this.id)); - this._logService.info(`[Claude] Models refreshed. Count: ${filtered.length}`); + this._logService.info(`[Claude] Models refreshed. Count: ${filtered.length}, ${filtered.map(m => m.name).join(', ')}`); this._models.set(filtered, undefined); } catch (err) { this._logService.error(err, '[Claude] Failed to refresh models'); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index df5101c9fa676b..34b6ea80046105 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CopilotClient, RuntimeConnection, type CopilotClientOptions, type ModelInfo } from '@github/copilot-sdk'; +import { CopilotClient, RuntimeConnection, type CopilotClientOptions } from '@github/copilot-sdk'; import * as fs from 'fs/promises'; import * as os from 'os'; import { CancelablePromise, createCancelablePromise, Delayer, Limiter, SequencerByKey } from '../../../../base/common/async.js'; @@ -42,7 +42,6 @@ import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDa import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ProtectedResourceMetadata, type AgentSelection, type ChildCustomizationType, type ConfigPropertySchema, type ConfigSchema, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; -import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; import { AgentCustomization, CustomizationLoadStatus, CustomizationType, ResponsePartKind, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, isDefaultChatUri, parseChatUri, parseSubagentSessionUri, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ResponsePart, type ChatInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; import { ActiveClientState } from '../activeClientState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; @@ -123,6 +122,8 @@ interface IProvisionalSession { export { COPILOT_AGENT_HOST_SYSTEM_MESSAGE } from './copilotSessionLauncher.js'; +type ModelInfo = Awaited>['models'][number]; + interface ISerializedModelSelection { id?: unknown; config?: unknown; @@ -475,10 +476,6 @@ export class CopilotAgent extends Disposable implements IAgent { this._githubToken = token; this._updateRestrictedTelemetry(token); this._logService.info(`[Copilot] Auth token ${tokenChanged ? 'updated' : 'unchanged'}`); - if (tokenChanged && this._client && this._sessions.size === 0) { - this._logService.info('[Copilot] Restarting CopilotClient with new token'); - await this._stopClient(); - } if (tokenChanged) { void this._refreshModels(); } @@ -501,7 +498,7 @@ export class CopilotAgent extends Disposable implements IAgent { return; } try { - const models = await this._listModels(); + const models = await this._listModels(tokenAtRefreshStart); if (this._githubToken === tokenAtRefreshStart) { this._models.set(models, undefined); } @@ -561,10 +558,6 @@ export class CopilotAgent extends Disposable implements IAgent { // ---- client lifecycle --------------------------------------------------- private async _ensureClient(): Promise { - const tokenAtStartup = this._githubToken; - if (!tokenAtStartup) { - throw new ProtocolError(AHP_AUTH_REQUIRED, 'Authentication is required to use Copilot', this.getProtectedResources()); - } if (this._client) { return this._client; } @@ -577,7 +570,7 @@ export class CopilotAgent extends Disposable implements IAgent { const sessionSyncAtStartup = this._isSessionSyncEnabled(); const rubberDuckAtStartup = this._isRubberDuckEnabled(); const clientStarting = (async () => { - this._logService.info('[Copilot] Starting CopilotClient... (with token)'); + this._logService.info('[Copilot] Starting CopilotClient...'); // Build a clean env for the CLI subprocess, stripping Electron/VS Code vars // that can interfere with the Node.js process the SDK spawns. @@ -654,7 +647,6 @@ export class CopilotAgent extends Disposable implements IAgent { const telemetry = await this._otelService.getSdkTelemetryConfig(); const clientOptions: CopilotClientOptions = { - gitHubToken: tokenAtStartup, useLoggedInUser: false, connection: RuntimeConnection.forStdio({ path: cliPath }), env, @@ -664,10 +656,6 @@ export class CopilotAgent extends Disposable implements IAgent { }; const client = this._createCopilotClient(clientOptions); await client.start(); - if (this._githubToken !== tokenAtStartup) { - await client.stop(); - throw new Error('Copilot authentication changed while the client was starting'); - } if (this._isSessionSyncEnabled() !== sessionSyncAtStartup || this._isRubberDuckEnabled() !== rubberDuckAtStartup) { await client.stop(); throw new Error('Copilot startup config changed while the client was starting'); @@ -712,7 +700,7 @@ export class CopilotAgent extends Disposable implements IAgent { * `ModelBilling` type — narrow through {@link ICopilotModelBilling} until the SDK catches up. */ private _createContextTierConfigSchemaProperty(billing: ModelInfo['billing'] | undefined): ConfigPropertySchema | undefined { - const tokenPrices = (billing as ICopilotModelBilling | undefined)?.tokenPrices; + const tokenPrices = billing?.tokenPrices; const defaultMax = tokenPrices?.contextMax; const longContextMax = tokenPrices?.longContext?.contextMax; if (!defaultMax || !longContextMax || defaultMax >= longContextMax) { @@ -748,25 +736,25 @@ export class CopilotAgent extends Disposable implements IAgent { * `billing.tokenPrices` / `billing.priceCategory` are present on the runtime CAPI `/models` payload but not yet * declared on the published SDK `ModelBilling` type — narrow through {@link ICopilotModelBilling}. */ - private _createModelPricingMeta(billing: ModelInfo['billing'] | undefined): Record | undefined { - const typedBilling = billing as ICopilotModelBilling | undefined; - const tokenPrices = typedBilling?.tokenPrices; + private _createModelPricingMeta(modelInfo: ModelInfo | undefined): Record | undefined { + const billing = modelInfo?.billing; + const tokenPrices = billing?.tokenPrices; const longContext = tokenPrices?.longContext; const differsFromDefault = (longValue: number | undefined, defaultValue: number | undefined): number | undefined => longValue !== undefined && longValue !== defaultValue ? longValue : undefined; return createAgentModelPricingMeta({ - multiplierNumeric: typeof typedBilling?.multiplier === 'number' ? typedBilling.multiplier : undefined, + multiplierNumeric: typeof billing?.multiplier === 'number' ? billing.multiplier : undefined, inputCost: tokenPrices?.inputPrice, cacheCost: tokenPrices?.cachePrice, - cacheWriteCost: tokenPrices?.cacheWritePrice, + cacheWriteCost: tokenPrices?.cachePrice, outputCost: tokenPrices?.outputPrice, longContextInputCost: differsFromDefault(longContext?.inputPrice, tokenPrices?.inputPrice), longContextCacheCost: differsFromDefault(longContext?.cachePrice, tokenPrices?.cachePrice), - longContextCacheWriteCost: differsFromDefault(longContext?.cacheWritePrice, tokenPrices?.cacheWritePrice), + longContextCacheWriteCost: differsFromDefault(longContext?.cachePrice, tokenPrices?.cachePrice), longContextOutputCost: differsFromDefault(longContext?.outputPrice, tokenPrices?.outputPrice), - priceCategory: typeof typedBilling?.priceCategory === 'string' ? typedBilling.priceCategory : undefined, + priceCategory: typeof modelInfo?.modelPickerPriceCategory === 'string' ? modelInfo.modelPickerPriceCategory : undefined, }); } @@ -929,10 +917,10 @@ export class CopilotAgent extends Disposable implements IAgent { }; } - private async _listModels(): Promise { + private async _listModels(gitHubToken: string): Promise { this._logService.info('[Copilot] Listing models...'); const client = await this._ensureClient(); - const models = await client.listModels(); + const { models } = await client.rpc.models.list({ gitHubToken }); const result = models.map((m): IAgentModelInfo => ({ provider: this.id, id: m.id, @@ -943,9 +931,9 @@ export class CopilotAgent extends Disposable implements IAgent { supportsVision: !!m.capabilities?.supports?.vision, configSchema: this._createModelConfigSchema(m), policyState: m.policy?.state as PolicyState | undefined, - _meta: this._createModelPricingMeta(m.billing), + _meta: this._createModelPricingMeta(m), })); - this._logService.info(`[Copilot] Found ${result.length} models`); + this._logService.info(`[Copilot] Found ${result.length} models: ${result.map(m => m.name).join(', ')}`); return result; } @@ -972,7 +960,6 @@ export class CopilotAgent extends Disposable implements IAgent { const sessionId = sessionConfig.session ? AgentSession.id(sessionConfig.session) : generateUuid(); const workingDirectory = await this._resolveCreateWorkingDirectory(sessionConfig, sessionId); const client = await this._ensureClient(); - // When forking, use the SDK's sessions.fork RPC. Forking from a source // session that has no turns is equivalent to creating a fresh session; // in that case the agent service drops `config.fork` before calling us, diff --git a/src/vs/platform/agentHost/test/node/agentHostSkillCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostSkillCompletionProvider.test.ts index 8eff59dc59d8db..86cce7880d606d 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSkillCompletionProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSkillCompletionProvider.test.ts @@ -63,32 +63,58 @@ suite('AgentHostSkillCompletionProvider', () => { assert.deepStrictEqual([...completions.triggerCharacters], [CompletionTriggerCharacter.Slash]); }); - test('returns session-effective skills with attachment metadata and trailing space', async () => { + test('complete skills from a plugin', async () => { const agent = new MockAgent('mock'); agent.getSessionCustomizations = async () => [ - plugin('skills', [skill('agent-host-docs', 'Use this skill when working on Agent Host code')]), + plugin('my-skill', [skill('agent-host-docs', 'Use this skill when working on Agent Host code')]), ]; const provider = createProvider(agent); const result = await run(provider, '/'); assert.deepStrictEqual(result, [{ - insertText: '/agent-host-docs ', + insertText: '/my-skill:agent-host-docs ', rangeStart: 0, rangeEnd: 1, attachment: { type: MessageAttachmentKind.Simple, - label: '/agent-host-docs', + label: '/my-skill:agent-host-docs', _meta: { uri: 'file:///skills/agent-host-docs/SKILL.md', name: 'agent-host-docs', - displayName: 'agent-host-docs', + displayName: 'my-skill:agent-host-docs', description: 'Use this skill when working on Agent Host code', }, }, }]); }); + test('complete skills from a plugin with the same name as the skill', async () => { + const agent = new MockAgent('mock'); + agent.getSessionCustomizations = async () => [ + plugin('monitor-pr', [skill('monitor-pr', 'Use this skill when working with PRs')]), + ]; + const provider = createProvider(agent); + + const result = await run(provider, '/'); + + assert.deepStrictEqual(result, [{ + insertText: '/monitor-pr ', + rangeStart: 0, + rangeEnd: 1, + attachment: { + type: MessageAttachmentKind.Simple, + label: '/monitor-pr', + _meta: { + uri: 'file:///skills/monitor-pr/SKILL.md', + name: 'monitor-pr', + displayName: 'monitor-pr', + description: 'Use this skill when working with PRs', + }, + }, + }]); + }); + test('flattens skill children in session-effective order and ignores non-skill children', async () => { const agent = new MockAgent('mock'); agent.getSessionCustomizations = async () => [ @@ -99,7 +125,7 @@ suite('AgentHostSkillCompletionProvider', () => { const result = await run(provider, '/'); - assert.deepStrictEqual(result.map(item => item.insertText), ['/session-skill ', '/global-skill ']); + assert.deepStrictEqual(result.map(item => item.insertText), ['/first:session-skill ', '/second:global-skill ']); }); test('ignores disabled customization containers', async () => { @@ -112,7 +138,7 @@ suite('AgentHostSkillCompletionProvider', () => { const result = await run(provider, '/'); - assert.deepStrictEqual(result.map(item => item.insertText), ['/visible-skill ']); + assert.deepStrictEqual(result.map(item => item.insertText), ['/enabled:visible-skill ']); }); test('returns an empty list when the agent has no session customizations hook', async () => { @@ -129,10 +155,10 @@ suite('AgentHostSkillCompletionProvider', () => { agent.getSessionCustomizations = async () => [plugin('skills', [skill('alpha'), skill('beta')])]; const provider = createProvider(agent); - const result = await run(provider, '/b extra', 2); + const result = await run(provider, '/skills:b extra', '/skills:b'.length); assert.deepStrictEqual(result.map(item => ({ insertText: item.insertText, rangeStart: item.rangeStart, rangeEnd: item.rangeEnd })), [ - { insertText: '/beta ', rangeStart: 0, rangeEnd: 2 }, + { insertText: '/skills:beta ', rangeStart: 0, rangeEnd: 9 }, ]); }); @@ -140,12 +166,12 @@ suite('AgentHostSkillCompletionProvider', () => { const agent = new MockAgent('mock'); agent.getSessionCustomizations = async () => [plugin('skills', [skill('alpha'), skill('beta')])]; const provider = createProvider(agent); - const text = 'use /b extra'; + const text = 'use /skills:b extra'; - const result = await run(provider, text, text.indexOf('/b') + '/b'.length); + const result = await run(provider, text, text.indexOf('/skills:b') + '/skills:b'.length); assert.deepStrictEqual(result.map(item => ({ insertText: item.insertText, rangeStart: item.rangeStart, rangeEnd: item.rangeEnd })), [ - { insertText: '/beta ', rangeStart: 4, rangeEnd: 6 }, + { insertText: '/skills:beta ', rangeStart: 4, rangeEnd: 13 }, ]); }); @@ -158,8 +184,8 @@ suite('AgentHostSkillCompletionProvider', () => { const result = await run(provider, text); assert.deepStrictEqual(result.map(item => ({ insertText: item.insertText, rangeStart: item.rangeStart, rangeEnd: item.rangeEnd })), [ - { insertText: '/alpha ', rangeStart: 4, rangeEnd: 5 }, - { insertText: '/beta ', rangeStart: 4, rangeEnd: 5 }, + { insertText: '/skills:alpha ', rangeStart: 4, rangeEnd: 5 }, + { insertText: '/skills:beta ', rangeStart: 4, rangeEnd: 5 }, ]); }); @@ -177,7 +203,7 @@ suite('AgentHostSkillCompletionProvider', () => { const agent = new MockAgent('mock'); agent.getSessionCustomizations = async () => [plugin('skills', [skill('cached-skill')])]; const provider = createProvider(agent); - const text = 'use /cached-skill trailing'; + const text = 'use /skills:cached-skill trailing'; const result = await run(provider, text, text.indexOf('trailing')); @@ -188,8 +214,9 @@ suite('AgentHostSkillCompletionProvider', () => { const agent = new MockAgent('mock'); agent.getSessionCustomizations = async () => [plugin('skills', [skill('cached-skill')])]; const provider = createProvider(agent); + const text = '/skills:cached-skill trailing'; - const result = await run(provider, '/cached-skill trailing', 14); + const result = await run(provider, text, text.indexOf('trailing')); assert.deepStrictEqual(result, []); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 2ee65166854dd3..e705a90d54f094 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -32,7 +32,6 @@ import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, type AgentSignal, type IAgentActionSignal, type IAgentSessionMetadata } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; -import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; import { buildSubagentSessionUri, buildChatUri, CustomizationLoadStatus, MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, customizationId, type ClientPluginCustomization, type MarkdownResponsePart, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js'; import { CustomizationType, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, type ChatAction, type IDeltaAction, type SessionAction } from '../../common/state/sessionActions.js'; @@ -187,6 +186,7 @@ class TestSessionDataService extends Disposable implements ISessionDataService { cleanupOrphanedData(): Promise { return Promise.resolve(); } whenIdle(): Promise { return Promise.resolve(); } } +type CopilotModelsList = CopilotClient['rpc']['models']['list']; interface ITestCopilotModelInfo { readonly id: string; @@ -207,12 +207,16 @@ interface ITestCopilotModelInfo { readonly longContext?: { readonly contextMax?: number; readonly inputPrice?: number; readonly cachePrice?: number; readonly cacheWritePrice?: number; readonly outputPrice?: number }; }; }; + readonly modelPickerPriceCategory?: string; readonly supportedReasoningEfforts?: ModelInfo['supportedReasoningEfforts']; readonly defaultReasoningEffort?: ModelInfo['defaultReasoningEffort']; } interface ITestCopilotClient extends Pick { - readonly rpc: { readonly sessions: { readonly fork: CopilotClient['rpc']['sessions']['fork'] } }; + readonly rpc: { + readonly sessions: { readonly fork: CopilotClient['rpc']['sessions']['fork'] }; + readonly models: { readonly list: CopilotModelsList }; + }; } function toSdkModelInfo(model: ITestCopilotModelInfo): ModelInfo { @@ -230,14 +234,31 @@ function toSdkModelInfo(model: ITestCopilotModelInfo): ModelInfo { }, ...(model.policy ? { policy: { state: model.policy.state ?? 'enabled', terms: '' } } : {}), ...(model.billing ? { billing: model.billing } : {}), + ...(model.modelPickerPriceCategory ? { modelPickerPriceCategory: model.modelPickerPriceCategory } : {}), ...(model.supportedReasoningEfforts ? { supportedReasoningEfforts: model.supportedReasoningEfforts } : {}), ...(model.defaultReasoningEffort ? { defaultReasoningEffort: model.defaultReasoningEffort } : {}), }; } class TestCopilotClient implements ITestCopilotClient { - readonly rpc: ITestCopilotClient['rpc'] = { sessions: { fork: async () => ({ sessionId: 'forked-session' }) } }; + readonly rpc: ITestCopilotClient['rpc'] = { + sessions: { fork: async () => ({ sessionId: 'forked-session' }) }, + models: { + list: async params => { + this.modelListRequests.push(params); + const error = this.modelListErrors.shift(); + if (error) { + throw error; + } + return { models: this._models.map(toSdkModelInfo) }; + } + }, + }; + startCallCount = 0; + stopCallCount = 0; listSessionCallCount = 0; + readonly modelListRequests: Parameters[0][] = []; + readonly modelListErrors: Error[] = []; readonly getSessionMetadataCalls: string[] = []; readonly deletedSessionIds: string[] = []; @@ -246,8 +267,13 @@ class TestCopilotClient implements ITestCopilotClient { private readonly _models: readonly ITestCopilotModelInfo[] = [], ) { } - async start(): Promise { } - async stop(): ReturnType { return []; } + async start(): Promise { + this.startCallCount++; + } + async stop(): ReturnType { + this.stopCallCount++; + return []; + } async listSessions(): ReturnType { this.listSessionCallCount++; return this._sessions; @@ -601,26 +627,99 @@ suite('CopilotAgent', () => { ); }); - test('returns empty models and throws AuthRequired for sessions before authentication', async () => { - const agent = createTestAgent(disposables); + test('returns empty models and lists sessions before authentication', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const ownedSession = AgentSession.uri('copilotcli', 'owned-before-auth'); + const ownedDb = sessionDataService.openDatabase(ownedSession); + ownedDb.dispose(); + const client = new TestCopilotClient([sdkSession('owned-before-auth')]); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); try { - assert.deepStrictEqual(agent.models.get(), []); - await assert.rejects( - () => agent.listSessions(), - (error: Error) => error instanceof ProtocolError && error.code === AHP_AUTH_REQUIRED, - ); + const sessions = await agent.listSessions(); + assert.deepStrictEqual({ + models: agent.models.get(), + sessions: sessions.map(session => AgentSession.id(session.session)), + starts: client.startCallCount, + listCalls: client.listSessionCallCount, + }, { + models: [], + sessions: ['owned-before-auth'], + starts: 1, + listCalls: 1, + }); } finally { await disposeAgent(agent); } }); - test('requires authentication before creating a session', async () => { - const agent = createTestAgent(disposables); + test('starts the client and creates a provisional session before authentication', async () => { + const client = new TestCopilotClient([]); + const agent = createTestAgent(disposables, { copilotClient: client }); + const session = AgentSession.uri('copilotcli', 'unauth-create'); + const workingDirectory = URI.file('/workspace'); try { - await assert.rejects( - () => agent.createSession({ workingDirectory: URI.file('/workspace') }), - (error: Error) => error instanceof ProtocolError && error.code === AHP_AUTH_REQUIRED, - ); + const result = await agent.createSession({ session, workingDirectory }); + assert.ok(result.workingDirectory); + assert.deepStrictEqual({ + session: result.session.toString(), + workingDirectory: result.workingDirectory.toString(), + provisional: result.provisional, + starts: client.startCallCount, + stops: client.stopCallCount, + }, { + session: session.toString(), + workingDirectory: workingDirectory.toString(), + provisional: true, + starts: 1, + stops: 0, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('passes the GitHub token when refreshing models', async () => { + const client = new TestCopilotClient([], [{ + id: 'gpt-4o', + name: 'GPT-4o', + }]); + const agent = createTestAgent(disposables, { copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'model-token'); + await waitForState(agent.models, models => models.length > 0); + + assert.deepStrictEqual(client.modelListRequests, [{ gitHubToken: 'model-token' }]); + } finally { + await disposeAgent(agent); + } + }); + + test('does not stop the client when the auth token changes', async () => { + const client = new TestCopilotClient([], [{ + id: 'gpt-4o', + name: 'GPT-4o', + }]); + const agent = createTestAgent(disposables, { copilotClient: client }); + try { + await agent.listSessions(); + await agent.authenticate('https://api.github.com', 'model-token-a'); + for (let i = 0; i < 200 && client.modelListRequests.length < 1; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + await agent.authenticate('https://api.github.com', 'model-token-b'); + for (let i = 0; i < 200 && client.modelListRequests.length < 2; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + + assert.deepStrictEqual({ + starts: client.startCallCount, + stops: client.stopCallCount, + requests: client.modelListRequests, + }, { + starts: 1, + stops: 0, + requests: [{ gitHubToken: 'model-token-a' }, { gitHubToken: 'model-token-b' }], + }); } finally { await disposeAgent(agent); } @@ -648,7 +747,7 @@ suite('CopilotAgent', () => { } await disposeAgent(agent); } - }); + }).timeout(30_000); suite('restart on startup config change', () => { @@ -751,7 +850,6 @@ suite('CopilotAgent', () => { capabilities: { limits: { max_context_window_tokens: 200_000 } }, billing: { multiplier: 1, - priceCategory: 'medium', tokenPrices: { contextMax: 200_000, inputPrice: 3, @@ -760,6 +858,7 @@ suite('CopilotAgent', () => { longContext: { contextMax: 1_000_000, inputPrice: 6, cachePrice: 1, outputPrice: 22.5 }, }, }, + modelPickerPriceCategory: 'medium', }]), }); try { @@ -770,6 +869,7 @@ suite('CopilotAgent', () => { multiplierNumeric: 1, inputCost: 3, cacheCost: 1, + cacheWriteCost: 1, outputCost: 15, longContextInputCost: 6, longContextOutputCost: 22.5, diff --git a/src/vs/platform/browserView/common/browserHistory.ts b/src/vs/platform/browserView/common/browserHistory.ts index 7f0a3d185cdb8d..50131aab41661b 100644 --- a/src/vs/platform/browserView/common/browserHistory.ts +++ b/src/vs/platform/browserView/common/browserHistory.ts @@ -133,10 +133,12 @@ export class BrowserHistoryEntriesStore extends Disposable { const nextFaviconHash = patch.faviconHash === undefined ? existing.icon : (patch.faviconHash ?? undefined); - if (nextUrl === existing.url && nextTitle === existing.title && nextFaviconHash === existing.icon) { + // Update the time if the URL has been updated. + const nextTime = patch.url ? Date.now() : existing.time; + if (nextUrl === existing.url && nextTitle === existing.title && nextFaviconHash === existing.icon && nextTime === existing.time) { return false; } - this._items[idx] = { ...existing, url: nextUrl, title: nextTitle, icon: nextFaviconHash }; + this._items[idx] = { ...existing, url: nextUrl, title: nextTitle, icon: nextFaviconHash, time: nextTime }; this._onDidChange.fire(); return true; } diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 5fb177994d1669..15d04338ca45cb 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -44,6 +44,11 @@ export class BrowserView extends Disposable { private _currentHistoryHandle: IBrowserHistoryItemHandle | undefined; private _explicitNavigationPending = false; + /** + * Active index in the webContents navigation history list. + * Used to tell whether a navigation appended a new entry or replaced the current one in place. + */ + private _lastCommittedEntryIndex = -1; readonly debugger: BrowserViewDebugger; readonly emulator: BrowserViewEmulator; @@ -287,7 +292,7 @@ export class BrowserView extends Disposable { this._currentHistoryHandle?.update({ title }); }); - const fireNavigationEvent = (url: string, createNewHistoryItem: boolean) => { + const fireNavigationEvent = (url: string) => { this._onDidNavigate.fire({ url, title: webContents.getTitle(), @@ -295,11 +300,7 @@ export class BrowserView extends Disposable { canGoForward: webContents.navigationHistory.canGoForward(), certificateError: this.session.trust.getCertificateError(url) }); - if (createNewHistoryItem) { - this._trackVisit(url); - } else { - this._currentHistoryHandle?.update({ url }); - } + this._recordNavigation(url); }; const fireLoadingEvent = (loading: boolean) => { @@ -369,8 +370,14 @@ export class BrowserView extends Disposable { }); // Navigation events (when URL actually changes) - webContents.on('did-navigate', (_, url) => fireNavigationEvent(url, true)); - webContents.on('did-navigate-in-page', (_, url) => fireNavigationEvent(url, false)); + webContents.on('did-navigate', (_, url) => fireNavigationEvent(url)); + webContents.on('did-navigate-in-page', (_, url, isMainFrame) => { + // Ignore subframe (iframe) navigations: they must not rewrite the + // main frame's URL bar or its history entry. + if (isMainFrame) { + fireNavigationEvent(url); + } + }); webContents.on('did-navigate', () => { // Chromium resets the zoom factor to its per-origin default (100%) when @@ -495,19 +502,33 @@ export class BrowserView extends Disposable { } /** - * Record a successful navigation in the session's history and remember the - * resulting handle so subsequent title/favicon updates can refine it. + * Record a committed navigation in the session's history. */ - private _trackVisit(url: string): void { + private _recordNavigation(url: string): void { + const webContents = this._view.webContents; + const activeIndex = webContents.navigationHistory.getActiveIndex(); + if (!isTrackableHistoryUrl(url)) { this._currentHistoryHandle = undefined; + this._lastCommittedEntryIndex = activeIndex; + return; + } + + // A commit that leaves the active index unchanged replaced the current + // entry in place; refine the existing history item rather than appending + // a duplicate. + const handle = this._currentHistoryHandle; + if (handle && activeIndex === this._lastCommittedEntryIndex) { + handle.update({ url, title: webContents.getTitle() }); return; } + this._lastCommittedEntryIndex = activeIndex; + const userInitiated = this._explicitNavigationPending; this._explicitNavigationPending = false; this._currentHistoryHandle = this.session.history.add( url, - this._view.webContents.getTitle(), + webContents.getTitle(), this._lastFavicon, userInitiated, ); diff --git a/src/vs/platform/configuration/common/configurations.ts b/src/vs/platform/configuration/common/configurations.ts index b43fdcb443eb17..0a2e5ca0e6f353 100644 --- a/src/vs/platform/configuration/common/configurations.ts +++ b/src/vs/platform/configuration/common/configurations.ts @@ -130,11 +130,15 @@ export class PolicyConfiguration extends Disposable implements IPolicyConfigurat } private toPolicyDefinitionType(configType: unknown, policyName: PolicyName): 'string' | 'number' | 'boolean' | undefined { - if (configType !== 'string' && configType !== 'number' && configType !== 'array' && configType !== 'object' && configType !== 'boolean') { + // `configType` may be a single type or a union (e.g. `['array', 'null']`). + // Normalize to an array and keep only the types we can represent as policies. + const configTypes = Array.isArray(configType) ? configType : [configType]; + const supportedTypes = configTypes.filter(type => type === 'string' || type === 'number' || type === 'array' || type === 'object' || type === 'boolean'); + if (supportedTypes.length === 0) { this.logService.warn(`PolicyConfiguration#updatePolicyDefinitions - policy '${policyName}' has unsupported type '${configType}'`); return undefined; } - return configType === 'number' ? 'number' : configType === 'boolean' ? 'boolean' : 'string'; + return supportedTypes.includes('number') ? 'number' : supportedTypes.includes('boolean') ? 'boolean' : 'string'; } private async updatePolicyDefinitions(properties: string[]): Promise { @@ -240,7 +244,11 @@ export class PolicyConfiguration extends Disposable implements IPolicyConfigurat const policyName = property?.policy?.name ?? property?.policyReference?.name; if (policyName) { let policyValue: PolicyValue | ParsedType | undefined = this.policyService.getPolicyValue(policyName); - if (isString(policyValue) && property.type !== 'string') { + // `property.type` may be a single type or a union (e.g. `['array', 'null']`). + // A string policy value carries a JSON payload that must be parsed unless the + // setting itself is (or can be) a plain string. + const acceptsStringType = Array.isArray(property.type) ? property.type.includes('string') : property.type === 'string'; + if (isString(policyValue) && !acceptsStringType) { try { policyValue = this.parse(policyValue); } catch (e) { diff --git a/src/vs/platform/policy/common/copilotManagedSettings.ts b/src/vs/platform/policy/common/copilotManagedSettings.ts index 47a2c71db6d7dd..700589f1ef1922 100644 --- a/src/vs/platform/policy/common/copilotManagedSettings.ts +++ b/src/vs/platform/policy/common/copilotManagedSettings.ts @@ -29,7 +29,7 @@ export const COPILOT_ENABLED_PLUGINS_KEY = 'enabledPlugins'; /** Managed-settings key for enterprise marketplaces (carried as a JSON-encoded `{ [name]: url-or-shorthand }`). */ export const COPILOT_EXTRA_MARKETPLACES_KEY = 'extraKnownMarketplaces'; -/** Managed-settings key for the strict-marketplace flag (boolean). */ +/** Managed-settings key for the strict-marketplace allowlist (carried as a JSON-encoded array of source entries; absent = no restrictions, `[]` = lockdown). */ export const COPILOT_STRICT_MARKETPLACES_KEY = 'strictKnownMarketplaces'; export const ICopilotManagedSettingsService = createDecorator('copilotManagedSettingsService'); diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 3b5eb26ae5b696..30f62a68fd1d83 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -331,15 +331,17 @@ --tab-border-top-color: transparent !important; } -/* Allow tabs to shrink when space is constrained so the close button stays - * reachable, while still letting them grow to fit their label when there is - * space. The default `sizing-fit` rule sets `width: 120px; min-width: - * fit-content; flex-shrink: 0;` — clearing min-width alone leaves the tab - * pinned at 120px and prevents it from expanding to its full content width. */ +/* Size tabs to their content and never shrink them, so that when more tabs + * exist than fit the container they overflow and the horizontal scrollbar + * appears (matching the default editor behaviour). The default `sizing-fit` + * rule sets `width: 120px; min-width: fit-content; flex-shrink: 0;` — pinning + * tabs at 120px. We instead let the width follow the label (`width: auto`) + * while keeping `flex-shrink: 0` so tabs keep their content width and the tab + * strip scrolls instead of squashing the tabs. */ .agent-sessions-workbench .part.editor .tabs-container > .tab.sizing-fit { width: auto !important; min-width: 0 !important; - flex: 0 1 auto !important; + flex: 0 0 auto !important; } .agent-sessions-workbench .part.editor .tabs-container > .tab.sizing-fit .monaco-icon-label, diff --git a/src/vs/sessions/browser/parts/panelPart.ts b/src/vs/sessions/browser/parts/panelPart.ts index a314fb87a6e5a0..8a38fcc14e725d 100644 --- a/src/vs/sessions/browser/parts/panelPart.ts +++ b/src/vs/sessions/browser/parts/panelPart.ts @@ -88,7 +88,7 @@ export class PanelPart extends AbstractPaneCompositePart { ) { super( Parts.PANEL_PART, - { hasTitle: true, trailingSeparator: false }, + { hasTitle: true, trailingSeparator: true }, PanelPart.activePanelSettingsKey, ActivePanelContext.bindTo(contextKeyService), PanelFocusContext.bindTo(contextKeyService), diff --git a/src/vs/sessions/contrib/github/browser/github.contribution.ts b/src/vs/sessions/contrib/github/browser/github.contribution.ts index 73a95b79ec0d93..69158cc2a25df1 100644 --- a/src/vs/sessions/contrib/github/browser/github.contribution.ts +++ b/src/vs/sessions/contrib/github/browser/github.contribution.ts @@ -9,6 +9,7 @@ import { structuralEquals } from '../../../../base/common/equals.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { ISession } from '../../../services/sessions/common/session.js'; import { ISessionsChangeEvent, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; @@ -16,6 +17,8 @@ import { ISessionsService } from '../../../services/sessions/browser/sessionsSer import { GitHubPullRequestState } from '../common/types.js'; import { GitHubService, IGitHubService } from './githubService.js'; +const TRACE_PREFIX = '[PR-ICON-TRACE]'; + export class GitHubPullRequestPollingContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'sessions.contrib.githubPullRequestPolling'; @@ -27,6 +30,7 @@ export class GitHubPullRequestPollingContribution extends Disposable implements @IGitHubService private readonly _gitHubService: IGitHubService, @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @ISessionsService private readonly _sessionsService: ISessionsService, + @ILogService private readonly _logService: ILogService, ) { super(); @@ -146,10 +150,12 @@ export class GitHubPullRequestPollingContribution extends Disposable implements const identity = pullRequestIdentityObs.read(reader); if (!identity) { // No PR number yet (or archived); this autorun re-runs once it resolves. + this._logService.trace(`${TRACE_PREFIX} [PollingContribution] Session ${session.sessionId} has no PR identity yet (no PR number or archived); waiting`); return; } const { owner, repo, prNumber } = identity; + this._logService.trace(`${TRACE_PREFIX} [PollingContribution] Session ${session.sessionId} resolved PR identity ${owner}/${repo}#${prNumber}; acquiring model and refreshing`); const modelRef = reader.store.add(this._gitHubService.createPullRequestModelReference(owner, repo, prNumber)); const model = modelRef.object; @@ -167,9 +173,11 @@ export class GitHubPullRequestPollingContribution extends Disposable implements }); reader.store.add(autorun(pollReader => { if (!shouldPollObs.read(pollReader)) { + this._logService.trace(`${TRACE_PREFIX} [PollingContribution] Session ${session.sessionId} PR ${owner}/${repo}#${prNumber} is merged and not active; not polling`); return; } + this._logService.trace(`${TRACE_PREFIX} [PollingContribution] Session ${session.sessionId} starting PR polling for ${owner}/${repo}#${prNumber}`); pollReader.store.add(model.startPolling()); })); @@ -182,6 +190,8 @@ export class GitHubPullRequestPollingContribution extends Disposable implements return; } + this._logService.trace(`${TRACE_PREFIX} [PollingContribution] Session ${session.sessionId} starting CI + review-thread polling for ${owner}/${repo}#${prNumber}@${prDetails.headSha}`); + const ciModelRef = statusReader.store.add(this._gitHubService.createPullRequestCIModelReference(owner, repo, prNumber, prDetails.headSha)); ciModelRef.object.refresh(); statusReader.store.add(ciModelRef.object.startPolling()); diff --git a/src/vs/sessions/contrib/github/browser/githubApiClient.ts b/src/vs/sessions/contrib/github/browser/githubApiClient.ts index 3f286e6e21383e..115749bebd761a 100644 --- a/src/vs/sessions/contrib/github/browser/githubApiClient.ts +++ b/src/vs/sessions/contrib/github/browser/githubApiClient.ts @@ -10,6 +10,7 @@ import { IRequestService, asJson } from '../../../../platform/request/common/req import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; const LOG_PREFIX = '[GitHubApiClient]'; +const TRACE_PREFIX = '[PR-ICON-TRACE]'; const GITHUB_API_BASE = 'https://api.github.com'; const GITHUB_GRAPHQL_ENDPOINT = `${GITHUB_API_BASE}/graphql`; @@ -94,6 +95,7 @@ export class GitHubApiClient extends Disposable { const token = await this._getAuthToken(); this._logService.trace(`${LOG_PREFIX} ${method} ${pathForLogging}`); + this._logService.trace(`${TRACE_PREFIX} [GitHubApiClient] -> ${method} ${pathForLogging} (callSite ${callSite}${options?.etag !== undefined ? `, ifNoneMatch ${options.etag}` : ''})`); const response = await this._requestService.request({ type: method, @@ -117,6 +119,8 @@ export class GitHubApiClient extends Disposable { const statusCode = response.res.statusCode ?? 0; const responseETag = response.res.headers?.['etag']; + this._logService.trace(`${TRACE_PREFIX} [GitHubApiClient] <- ${method} ${pathForLogging} status ${statusCode}${responseETag ? `, etag ${responseETag}` : ''}${rateLimitRemaining !== undefined ? `, rateLimitRemaining ${rateLimitRemaining}` : ''} (callSite ${callSite})`); + if ( statusCode === 204 /* No Content */ || statusCode === 304 /* Not Modified */ diff --git a/src/vs/sessions/contrib/github/browser/githubService.ts b/src/vs/sessions/contrib/github/browser/githubService.ts index 66b17977380357..c3694684d1784e 100644 --- a/src/vs/sessions/contrib/github/browser/githubService.ts +++ b/src/vs/sessions/contrib/github/browser/githubService.ts @@ -5,6 +5,7 @@ import { Disposable, IReference } from '../../../../base/common/lifecycle.js'; import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { IGitHubChangedFile } from '../common/types.js'; import { GitHubApiClient } from './githubApiClient.js'; import { GitHubRepositoryModel, GitHubRepositoryModelReferenceCollection } from './models/githubRepositoryModel.js'; @@ -16,6 +17,14 @@ import { getPullRequestKey } from '../common/utils.js'; import { derived, derivedOpts, IObservable } from '../../../../base/common/observable.js'; import { structuralEquals } from '../../../../base/common/equals.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; + +/** + * Shared trace prefix for the pull-request polling/fetching pipeline that feeds + * the session PR status icons. Filter the logs on this token to follow the + * whole flow end-to-end (lookup -> fetch -> poll -> icon). + */ +const TRACE_PREFIX = '[PR-ICON-TRACE]'; + export interface IGitHubService { readonly _serviceBrand: undefined; @@ -89,6 +98,7 @@ export class GitHubService extends Disposable implements IGitHubService { constructor( @IInstantiationService instantiationService: IInstantiationService, @ISessionsService sessionsService: ISessionsService, + @ILogService private readonly _logService: ILogService, ) { super(); @@ -195,6 +205,7 @@ export class GitHubService extends Disposable implements IGitHubService { const key = `${owner}/${repo}#${branch}`; let promise = this._findPRByBranchCache.get(key); if (!promise) { + this._logService.trace(`${TRACE_PREFIX} [GitHubService] findPullRequestNumberByHeadBranch cache MISS for ${key}; starting lookup`); promise = this._fetchPullRequestNumberByHeadBranch(owner, repo, branch); this._findPRByBranchCache.set(key, promise); // Only cache successful, numeric results indefinitely; the PR number @@ -204,13 +215,19 @@ export class GitHubService extends Disposable implements IGitHubService { promise.then( value => { if (typeof value !== 'number') { + this._logService.trace(`${TRACE_PREFIX} [GitHubService] findPullRequestNumberByHeadBranch for ${key} resolved with NO pr number; dropping cache entry so a later call retries`); this._findPRByBranchCache.delete(key); + } else { + this._logService.trace(`${TRACE_PREFIX} [GitHubService] findPullRequestNumberByHeadBranch for ${key} resolved PR #${value}; caching indefinitely`); } }, - () => { + err => { + this._logService.trace(`${TRACE_PREFIX} [GitHubService] findPullRequestNumberByHeadBranch for ${key} FAILED; dropping cache entry.`, err); this._findPRByBranchCache.delete(key); }, ); + } else { + this._logService.trace(`${TRACE_PREFIX} [GitHubService] findPullRequestNumberByHeadBranch cache HIT for ${key}`); } return promise.catch(() => undefined); } @@ -221,12 +238,14 @@ export class GitHubService extends Disposable implements IGitHubService { // surfaces the PR after the agent run finishes and the PR is merged. // `per_page=1` + `sort=updated` gives us the most recent match. const path = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?head=${encodeURIComponent(`${owner}:${branch}`)}&state=all&sort=updated&direction=desc&per_page=1`; + this._logService.trace(`${TRACE_PREFIX} [GitHubService] Fetching PR number for head ${owner}:${branch} via GET ${path}`); const response = await this._apiClient.request( 'GET', path, 'githubApi.findPullRequestByHeadBranch', ); const first = response.data?.[0]; + this._logService.trace(`${TRACE_PREFIX} [GitHubService] PR number lookup for ${owner}:${branch} -> ${first ? `#${first.number}` : 'no match'} (status ${response.statusCode}, ${response.data?.length ?? 0} result(s))`); return first ? first.number : undefined; } } diff --git a/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts b/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts index 99b8c8ddec9e9b..cda9daee18c6ec 100644 --- a/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts +++ b/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts @@ -12,6 +12,7 @@ import { GitHubApiClient } from '../githubApiClient.js'; import { computeOverallCIStatus, GitHubPRCIFetcher } from '../fetchers/githubPRCIFetcher.js'; const LOG_PREFIX = '[GitHubPullRequestCIModel]'; +const TRACE_PREFIX = '[PR-ICON-TRACE]'; const DEFAULT_POLL_INTERVAL_MS = 60_000; export class GitHubPullRequestCIModelReferenceCollection extends ReferenceCollection { @@ -90,6 +91,7 @@ export class GitHubPullRequestCIModel extends Disposable { } private async _refresh(): Promise { + this._logService.trace(`${TRACE_PREFIX} [CIModel] Refreshing CI for ${this.owner}/${this.repo}#${this.prNumber}@${this.headSha} (checksEtag ${this._checksEtag ?? 'none'})`); try { const response = await this._fetcher.getCheckRuns(this.owner, this.repo, this.headSha, this._checksEtag); if (response.statusCode === 200 && response.data) { @@ -97,8 +99,9 @@ export class GitHubPullRequestCIModel extends Disposable { this._checks.set(response.data, undefined); this._overallStatus.set(computeOverallCIStatus(response.data), undefined); } + this._logService.trace(`${TRACE_PREFIX} [CIModel] Refreshed CI for ${this.owner}/${this.repo}#${this.prNumber}@${this.headSha}: status ${response.statusCode}, ${this._checks.get().length} check(s), overallStatus ${this._overallStatus.get()}`); } catch (err) { - this._logService.error(`${LOG_PREFIX} Failed to refresh CI checks for ${this.owner}/${this.repo}#${this.prNumber}@${this.headSha}:`, err); + this._logService.error(`${TRACE_PREFIX} ${LOG_PREFIX} Failed to refresh CI checks for ${this.owner}/${this.repo}#${this.prNumber}@${this.headSha}:`, err); } } @@ -128,6 +131,7 @@ export class GitHubPullRequestCIModel extends Disposable { */ startPolling(intervalMs: number = DEFAULT_POLL_INTERVAL_MS): IDisposable { if (this._pollingClientCount++ === 0) { + this._logService.trace(`${TRACE_PREFIX} [CIModel] Start polling ${this.owner}/${this.repo}#${this.prNumber}@${this.headSha} every ${intervalMs}ms`); this._pollScheduler.schedule(intervalMs); } @@ -143,6 +147,7 @@ export class GitHubPullRequestCIModel extends Disposable { } private async _poll(): Promise { + this._logService.trace(`${TRACE_PREFIX} [CIModel] Poll cycle for ${this.owner}/${this.repo}#${this.prNumber}@${this.headSha}`); await this.refresh(); // Re-schedule if not disposed (RunOnceScheduler is one-shot) if (!this._store.isDisposed && this._pollingClientCount > 0) { diff --git a/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts b/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts index 3c39d0d76d4412..bfad0d6446efc7 100644 --- a/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts +++ b/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts @@ -12,6 +12,7 @@ import { computeMergeability, GitHubPRFetcher } from '../fetchers/githubPRFetche import { GitHubApiClient } from '../githubApiClient.js'; const LOG_PREFIX = '[GitHubPullRequestModel]'; +const TRACE_PREFIX = '[PR-ICON-TRACE]'; const DEFAULT_POLL_INTERVAL_MS = 60_000; export class GitHubPullRequestModelReferenceCollection extends ReferenceCollection { @@ -105,6 +106,7 @@ export class GitHubPullRequestModel extends Disposable { this._pollingDisposables.add(disposable); if (this._pollingDisposables.size === 1) { + this._logService.trace(`${TRACE_PREFIX} [PRModel] Start polling ${this.owner}/${this.repo}#${this.prNumber} every ${intervalMs}ms`); this._pollScheduler.schedule(intervalMs); } @@ -112,6 +114,7 @@ export class GitHubPullRequestModel extends Disposable { } private async _poll(): Promise { + this._logService.trace(`${TRACE_PREFIX} [PRModel] Poll cycle for ${this.owner}/${this.repo}#${this.prNumber}`); await this.refresh(); // Re-schedule for next poll cycle (RunOnceScheduler is one-shot) if (!this._store.isDisposed && this._pollingDisposables.size > 0) { @@ -120,6 +123,7 @@ export class GitHubPullRequestModel extends Disposable { } private async _refresh(): Promise { + this._logService.trace(`${TRACE_PREFIX} [PRModel] Refreshing ${this.owner}/${this.repo}#${this.prNumber} (prEtag ${this._pullRequestEtag ?? 'none'}, reviewsEtag ${this._reviewsEtag ?? 'none'})`); try { const [pr, reviews] = await Promise.all([ this._fetcher.getPullRequest(this.owner, this.repo, this.prNumber, this._pullRequestEtag), @@ -150,8 +154,11 @@ export class GitHubPullRequestModel extends Disposable { } } }); + + const current = this._pullRequest.get(); + this._logService.trace(`${TRACE_PREFIX} [PRModel] Refreshed ${this.owner}/${this.repo}#${this.prNumber}: prStatus ${pr.statusCode}, reviewsStatus ${reviews.statusCode}, state ${current?.state ?? 'unknown'}, isDraft ${current?.isDraft ?? 'unknown'}, headSha ${current?.headSha ?? 'unknown'}`); } catch (err) { - this._logService.error(`${LOG_PREFIX} Failed to refresh PR #${this.prNumber}:`, err); + this._logService.error(`${TRACE_PREFIX} ${LOG_PREFIX} Failed to refresh PR #${this.prNumber}:`, err); } } diff --git a/src/vs/sessions/contrib/github/browser/models/githubPullRequestReviewThreadsModel.ts b/src/vs/sessions/contrib/github/browser/models/githubPullRequestReviewThreadsModel.ts index 8d46b366339b58..5629c9c18ce8e4 100644 --- a/src/vs/sessions/contrib/github/browser/models/githubPullRequestReviewThreadsModel.ts +++ b/src/vs/sessions/contrib/github/browser/models/githubPullRequestReviewThreadsModel.ts @@ -12,6 +12,7 @@ import { GitHubApiClient } from '../githubApiClient.js'; import { GitHubPRFetcher } from '../fetchers/githubPRFetcher.js'; const LOG_PREFIX = '[GitHubPullRequestReviewThreadsModel]'; +const TRACE_PREFIX = '[PR-ICON-TRACE]'; const DEFAULT_POLL_INTERVAL_MS = 60_000; export class GitHubPullRequestReviewThreadsModelReferenceCollection extends ReferenceCollection { @@ -86,11 +87,14 @@ export class GitHubPullRequestReviewThreadsModel extends Disposable { } private async _refresh(): Promise { + this._logService.trace(`${TRACE_PREFIX} [ReviewThreadsModel] Refreshing review threads for ${this.owner}/${this.repo}#${this.prNumber}`); try { const data = await this._fetcher.getReviewThreads(this.owner, this.repo, this.prNumber); this._reviewThreads.set(data, undefined); + const unresolved = data.filter(thread => !thread.isResolved).length; + this._logService.trace(`${TRACE_PREFIX} [ReviewThreadsModel] Refreshed review threads for ${this.owner}/${this.repo}#${this.prNumber}: ${data.length} thread(s), ${unresolved} unresolved`); } catch (err) { - this._logService.error(`${LOG_PREFIX} Failed to refresh threads for PR #${this.prNumber}:`, err); + this._logService.error(`${TRACE_PREFIX} ${LOG_PREFIX} Failed to refresh threads for PR #${this.prNumber}:`, err); } } @@ -116,6 +120,7 @@ export class GitHubPullRequestReviewThreadsModel extends Disposable { */ startPolling(intervalMs: number = DEFAULT_POLL_INTERVAL_MS): IDisposable { if (this._pollingClientCount++ === 0) { + this._logService.trace(`${TRACE_PREFIX} [ReviewThreadsModel] Start polling ${this.owner}/${this.repo}#${this.prNumber} every ${intervalMs}ms`); this._pollScheduler.schedule(intervalMs); } @@ -131,6 +136,7 @@ export class GitHubPullRequestReviewThreadsModel extends Disposable { } private async _poll(): Promise { + this._logService.trace(`${TRACE_PREFIX} [ReviewThreadsModel] Poll cycle for ${this.owner}/${this.repo}#${this.prNumber}`); await this.refresh(); if (!this._store.isDisposed && this._pollingClientCount > 0) { this._pollScheduler.schedule(); diff --git a/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts b/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts index 46e210019da3f3..d37a9e6c9abc2e 100644 --- a/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts @@ -9,6 +9,7 @@ import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { DisposableStore, IDisposable, ImmortalReference, IReference, toDisposable } from '../../../../../base/common/lifecycle.js'; import { constObservable, IObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; import { GitHubPullRequestModel } from '../../browser/models/githubPullRequestModel.js'; import { GitHubPullRequestCIModel } from '../../browser/models/githubPullRequestCIModel.js'; import { GitHubPullRequestReviewThreadsModel } from '../../browser/models/githubPullRequestReviewThreadsModel.js'; @@ -25,6 +26,7 @@ import { ISessionsService } from '../../../../services/sessions/browser/sessions suite('GitHubPullRequestPollingContribution', () => { const store = new DisposableStore(); + const logService = new NullLogService(); let sessionsManagementService: TestSessionsManagementService; let sessionsService: ISessionsService; let gitHubService: TestGitHubService; @@ -46,7 +48,7 @@ suite('GitHubPullRequestPollingContribution', () => { test('starts polling existing and added pull request sessions', () => { const existingSession = sessionsManagementService.addSession('existing', makeGitHubInfo(1)); - store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService)); + store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService, logService)); const addedSession = sessionsManagementService.addSession('added', makeGitHubInfo(2)); sessionsManagementService.fireSessionsChanged({ added: [addedSession] }); @@ -60,7 +62,7 @@ suite('GitHubPullRequestPollingContribution', () => { test('stops polling when a session is archived, then resumes when unarchived', () => { const session = sessionsManagementService.addSession('session', makeGitHubInfo(1)); - store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService)); + store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService, logService)); sessionsManagementService.setArchived(session, true); sessionsManagementService.fireSessionsChanged({ changed: [session] }); @@ -79,7 +81,7 @@ suite('GitHubPullRequestPollingContribution', () => { test('does not poll archived sessions until they are unarchived', () => { const session = sessionsManagementService.addSession('session', makeGitHubInfo(1), true); - store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService)); + store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService, logService)); assert.deepStrictEqual(gitHubService.snapshot(), {}); @@ -93,7 +95,7 @@ suite('GitHubPullRequestPollingContribution', () => { test('stops polling tracked pull requests when disposed', () => { const session = sessionsManagementService.addSession('session', makeGitHubInfo(1)); - const contribution = store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService)); + const contribution = store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService, logService)); contribution.dispose(); @@ -105,7 +107,7 @@ suite('GitHubPullRequestPollingContribution', () => { test('polls CI checks and review threads once an open pull request resolves', () => { sessionsManagementService.addSession('session', makeGitHubInfo(1)); - store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService)); + store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService, logService)); // Until the PR details load, only the PR model is polled. assert.deepStrictEqual(gitHubService.statusModelSnapshot(), { ci: {}, reviewThreads: {} }); @@ -120,7 +122,7 @@ suite('GitHubPullRequestPollingContribution', () => { test('does not poll CI checks or review threads for draft pull requests', () => { sessionsManagementService.addSession('session', makeGitHubInfo(1)); - store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService)); + store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService, logService)); gitHubService.setPullRequestDetails('owner', 'repo', 1, { state: GitHubPullRequestState.Open, isDraft: true, headSha: 'sha1' }); @@ -131,7 +133,7 @@ suite('GitHubPullRequestPollingContribution', () => { // Mirrors the agent-host provider, whose `gitHubInfo` initially has no PR // number (it is resolved asynchronously via findPullRequestNumberByHeadBranch). const session = sessionsManagementService.addSession('async', { owner: 'owner', repo: 'repo' }); - store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService)); + store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService, logService)); // No PR number yet → nothing is polled. assert.deepStrictEqual(gitHubService.snapshot(), {}); @@ -146,7 +148,7 @@ suite('GitHubPullRequestPollingContribution', () => { test('stops polling a merged pull request unless it is the active session', () => { const session = sessionsManagementService.addSession('session', makeGitHubInfo(1)); - store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService)); + store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService, logService)); // Open PR → polling. gitHubService.setPullRequestDetails('owner', 'repo', 1, { state: GitHubPullRequestState.Open, isDraft: false, headSha: 'sha1' }); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts index 35132c6bc365b7..f03e8b9298e835 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts @@ -159,8 +159,18 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { private readonly _options: IAgentHostAdapterOptions, private readonly _dialogService: IDialogService, ) { + // Throttle only the changes path; `isLoadingChanges` and `operations` + // keep reading `this.changesetStateObs` directly so spinners/buttons stay + // responsive. Throttle (not debounce) so a continuous stream keeps + // updating instead of starving until edits stop. The `derived` wrapper + // defers reading the abstract `changesetStateObs` until the observable is + // actually observed (it isn't assigned yet during base construction). + const throttledChangesetStateObs = throttledObservable( + derived(reader => this.changesetStateObs.read(reader).read(reader)), + CHANGESET_UPDATE_THROTTLE_MS); + this.isLoadingChanges = derived(reader => { - const changesetState = this.changesetStateObs.read(reader).read(reader); + const changesetState = throttledChangesetStateObs.read(reader); // If the changeset state is `undefined`, it means that the first snapshot // has not yet arrived, so in order to avoid any flickering in the Changes @@ -182,19 +192,11 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { const mapDiffUri = this._options.mapDiffUri; - // Throttle only the changes path; `isLoadingChanges` and `operations` - // keep reading `this.changesetStateObs` directly so spinners/buttons stay - // responsive. Throttle (not debounce) so a continuous stream keeps - // updating instead of starving until edits stop. The `derived` wrapper - // defers reading the abstract `changesetStateObs` until the observable is - // actually observed (it isn't assigned yet during base construction). - const throttledChangesetValueObs = throttledObservable(derived(reader => this.changesetStateObs.read(reader).read(reader)), CHANGESET_UPDATE_THROTTLE_MS); - // Hold the raw `ChangesetFile[]` (with last-value semantics) so unchanged // files keep their reference across reducer updates, enabling the // per-file cache below to skip rebuilding them. const changesetFilesObs = derivedObservableWithCache(this, (reader, lastValue) => { - const changesetState = throttledChangesetValueObs.read(reader); + const changesetState = throttledChangesetStateObs.read(reader); if (changesetState === null || changesetState instanceof Error) { return []; } @@ -216,13 +218,11 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { // Build one change per file, reusing the cached result for files whose // `ChangesetFile` reference is unchanged so only changed files are // re-parsed and re-mapped. - const mappedChangesObs = mapObservableArrayCached(this, changesetFilesObs.map(files => files ?? []), file => changesetFileToChange(file, mapDiffUri)); + const mappedChangesObs = mapObservableArrayCached(this, + changesetFilesObs.map(files => files ?? []), + file => changesetFileToChange(file, mapDiffUri)); const changesObs = derived(this, reader => { - const files = changesetFilesObs.read(reader); - if (files === undefined) { - return undefined; - } return mappedChangesObs.read(reader).filter(isDefined); }); @@ -231,7 +231,7 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { }); const operationsObs = derivedObservableWithCache(this, (reader, lastValue) => { - const changesetState = this.changesetStateObs.read(reader).read(reader); + const changesetState = throttledChangesetStateObs.read(reader); if (changesetState === null || changesetState instanceof Error) { return []; } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 32729fb6f977c8..bfea62dee75908 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -53,6 +53,7 @@ import { changesetFileToChange, mapProtocolStatus } from './agentHostDiffs.js'; import { createChangesets } from './agentHostSessionChangesets.js'; const STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES = 'sessions.agentHost.sessionConfigPicker.selectedValues'; +const TRACE_PREFIX = '[PR-ICON-TRACE]'; const UNSAFE_SESSION_CONFIG_KEYS = new Set(['__proto__', 'constructor', 'prototype']); function isSafeSessionConfigKey(property: string): boolean { @@ -301,7 +302,8 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { resourceScheme: string, logicalSessionType: string, private readonly _options: IAgentHostAdapterOptions, - @ISessionsService private readonly _sessionsService: ISessionsService + @ISessionsService private readonly _sessionsService: ISessionsService, + @ILogService private readonly _logService: ILogService, ) { super(); const rawId = AgentSession.id(metadata.session); @@ -360,11 +362,13 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this.gitHubInfo = derived(this, reader => { const coords = gitHubCoords.read(reader); if (!coords) { + this._logService.trace(`${TRACE_PREFIX} [IconAdapter] Session ${this.sessionId} has no GitHub coords (missing owner/repo/branch in git state); no PR icon`); return undefined; } const innerObs = pullRequestNumberObs.read(reader); const prNumber = innerObs?.read(reader)?.value; if (prNumber === undefined) { + this._logService.trace(`${TRACE_PREFIX} [IconAdapter] Session ${this.sessionId} coords ${coords.owner}/${coords.repo}@${coords.branch}: PR number not resolved yet; emitting gitHubInfo without pullRequest`); return { owner: coords.owner, repo: coords.repo }; } const uri = URI.parse(`https://github.com/${coords.owner}/${coords.repo}/pull/${prNumber}`); @@ -383,7 +387,12 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { hasUnresolvedComments = reviewThreadsRef.object.reviewThreads.read(reader).some(thread => !thread.isResolved); } icon = computePullRequestIcon(livePR.isDraft ? 'draft' : livePR.state, { hasFailingChecks, hasUnresolvedComments }); + this._logService.trace(`${TRACE_PREFIX} [IconAdapter] Session ${this.sessionId} PR ${coords.owner}/${coords.repo}#${prNumber}: livePR present (state ${livePR.state}, isDraft ${livePR.isDraft}, headSha ${livePR.headSha}), hasFailingChecks ${hasFailingChecks}, hasUnresolvedComments ${hasUnresolvedComments} -> icon ${icon?.id ?? 'none'}`); + } else { + this._logService.trace(`${TRACE_PREFIX} [IconAdapter] Session ${this.sessionId} PR ${coords.owner}/${coords.repo}#${prNumber}: livePR not loaded yet; icon undefined (waiting for PR model refresh)`); } + } else { + this._logService.trace(`${TRACE_PREFIX} [IconAdapter] Session ${this.sessionId} PR ${coords.owner}/${coords.repo}#${prNumber}: no GitHub service available; icon undefined`); } return { owner: coords.owner, @@ -2482,7 +2491,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._onDidChangeSessions.fire({ added: [skeleton], removed: [], changed: [] }); try { - const committedSession = await this._waitForNewSession(existingKeys); + const committedSession = await this._waitForNewSession(existingKeys, chatResource.scheme); if (committedSession) { this._preserveNewSessionConfig(newSession, committedSession.sessionId); // Session graduated: release the eager subscription without @@ -2880,10 +2889,21 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } - private async _waitForNewSession(existingKeys: Set): Promise { + /** + * Resolve the freshly-committed backend session for an in-flight send. + * + * The local agent host runs a single provider whose session cache holds + * **every** agent-host session type (codex, claude, copilot, …). A send + * therefore has to identify *its own* new session by both novelty (a raw id + * not present before the send) **and** type: `expectedScheme` is the + * `chatResource` scheme (e.g. `agent-host-codex`), so a session of another + * type that happens to appear mid-send — a slow codex send racing against a + * restored claude session, say — is never mistaken for this send's commit. + */ + private async _waitForNewSession(existingKeys: Set, expectedScheme: string): Promise { await this._refreshSessions(); for (const [key, cached] of this._sessionCache) { - if (!existingKeys.has(key)) { + if (!existingKeys.has(key) && cached.resource.scheme === expectedScheme) { return cached; } } @@ -2894,7 +2914,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement waitDisposables.add(this._onDidChangeSessions.event(e => { const newSession = e.added.find(s => { const rawId = s.resource.path.substring(1); - return !existingKeys.has(rawId); + return !existingKeys.has(rawId) && s.resource.scheme === expectedScheme; }); if (newSession) { resolve(newSession); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 917c11f82c26af..b3eac872025e9a 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -2123,6 +2123,40 @@ suite('LocalAgentHostSessionsProvider', () => { ); }); + test('sendRequest only commits a session of the same type, ignoring a foreign-type session that appears mid-send', async () => { + // Regression test: the local agent host runs a single provider whose + // session cache holds every agent-host session type (codex, claude, + // copilot). When a slow session (e.g. codex cold start) is sent while a + // session of a DIFFERENT type appears in the cache, `_waitForNewSession` + // must not latch onto that foreign session and return it as the codex + // commit — otherwise the active session is swapped to the wrong type. + const codexAndClaude = [ + { type: 'agent-host-codex', name: 'codex', displayName: 'Codex', description: 'test', icon: undefined }, + { type: 'agent-host-claude', name: 'claude', displayName: 'Claude', description: 'test', icon: undefined }, + ]; + agentHost.setAgents([ + { provider: 'codex', displayName: 'Codex', description: '', models: [] } as AgentInfo, + { provider: 'claude', displayName: 'Claude', description: '', models: [] } as AgentInfo, + ]); + const provider = createProvider(disposables, agentHost, codexAndClaude, { + openSession: true, + sendRequest: async (): Promise => { + // While the codex send is in flight, a foreign-type (claude) + // session shows up in the host's list (e.g. restored from an + // earlier run), and the real codex session also commits. + agentHost.addSession(createSession('foreign-claude', { provider: 'claude', summary: 'Foreign Claude' })); + agentHost.addSession(createSession('real-codex', { provider: 'codex', summary: 'Real Codex' })); + return { kind: 'sent' as const, data: {} as ChatSendResult extends { kind: 'sent'; data: infer D } ? D : never }; + }, + }); + + const session = provider.createNewSession(URI.parse('file:///home/user/project'), 'codex'); + const chat = await provider.createNewChat(session.sessionId); + const committed = await provider.sendRequest(session.sessionId, chat.resource, { query: 'hello' }); + + assert.strictEqual(committed.resource.scheme, 'agent-host-codex', `expected the committed session to be the codex session, got ${committed.resource.toString()}`); + }); + test('sendRequest forwards resolved session config to chat service', async () => { const sendOptions: IChatSendRequestOptions[] = []; const provider = createProvider(disposables, agentHost, undefined, { diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index 89d7fd156d11b1..f7701c3972b78f 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -438,8 +438,8 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.updateShadows(); } - // Floating Panels - if (e.affectsConfiguration(LayoutSettings.FLOATING_PANELS)) { + // Modern UI Update (floating panels presentation) + if (e.affectsConfiguration(LayoutSettings.MODERN_UI)) { this.updateFloatingPanels(); this.layout(); // re-layout so parts pick up the new floating margins } @@ -619,7 +619,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi } isFloatingPanelsEnabled(): boolean { - return this.configurationService.getValue(LayoutSettings.FLOATING_PANELS) === true; + return this.configurationService.getValue(LayoutSettings.MODERN_UI) === true; } private updateFloatingPanels(): void { diff --git a/src/vs/workbench/browser/media/floatingPanels.css b/src/vs/workbench/browser/media/floatingPanels.css index cc391c7855db88..16a28090628ebd 100644 --- a/src/vs/workbench/browser/media/floatingPanels.css +++ b/src/vs/workbench/browser/media/floatingPanels.css @@ -6,7 +6,7 @@ /* ---- Floating Panels (experimental) ---- */ /* - * When `workbench.experimental.floatingPanels` is set, the primary side + * When `workbench.experimental.modernUI` (Modern UI Update) is set, the primary side * bar, secondary side bar and bottom panel are rendered as floating cards with a * margin, border and rounded corners — echoing the agents window design. The * matching content dimensions are reduced in code (AbstractPaneCompositePart) so diff --git a/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts b/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts index b287cf081caebb..ee371523d83184 100644 --- a/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts +++ b/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts @@ -105,7 +105,7 @@ export class ActivitybarPart extends Part { // Floating panels changes the reserved left/bottom gutter (and therefore // the fixed part width): signal the grid that the size constraint changed. - if (e.affectsConfiguration(LayoutSettings.FLOATING_PANELS)) { + if (e.affectsConfiguration(LayoutSettings.MODERN_UI)) { this._onDidChange.fire(undefined); } })); diff --git a/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts b/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts index 870daad10df026..05bec26f27aa72 100644 --- a/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts +++ b/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts @@ -223,7 +223,7 @@ class StatusbarPart extends Part implements IStatusbarEntryContainer { // part height) for the main status bar only: signal the grid that the size // constraint changed. this._register(this.configurationService.onDidChangeConfiguration(e => { - if (this.getId() === Parts.STATUSBAR_PART && e.affectsConfiguration(LayoutSettings.FLOATING_PANELS)) { + if (this.getId() === Parts.STATUSBAR_PART && e.affectsConfiguration(LayoutSettings.MODERN_UI)) { this._onDidChange.fire(undefined); } })); diff --git a/src/vs/workbench/browser/web.main.ts b/src/vs/workbench/browser/web.main.ts index d2beac1382d644..ee42b29aad34c7 100644 --- a/src/vs/workbench/browser/web.main.ts +++ b/src/vs/workbench/browser/web.main.ts @@ -5,7 +5,6 @@ import { mark } from '../../base/common/performance.js'; import { domContentLoaded, detectFullscreen, getCookieValue, getWindow } from '../../base/browser/dom.js'; -import { assertReturnsDefined } from '../../base/common/types.js'; import { ServiceCollection } from '../../platform/instantiation/common/serviceCollection.js'; import { ILogService, ConsoleLogger, getLogLevel, ILoggerService, ILogger } from '../../platform/log/common/log.js'; import { ConsoleLogInAutomationLogger } from '../../platform/log/browser/log.js'; @@ -25,7 +24,7 @@ import { FileService } from '../../platform/files/common/fileService.js'; import { Schemas, connectionTokenCookieName } from '../../base/common/network.js'; import { IAnyWorkspaceIdentifier, IWorkspaceContextService, UNKNOWN_EMPTY_WINDOW_WORKSPACE, isTemporaryWorkspace, isWorkspaceIdentifier } from '../../platform/workspace/common/workspace.js'; import { IWorkbenchConfigurationService } from '../services/configuration/common/configuration.js'; -import { onUnexpectedError } from '../../base/common/errors.js'; +import { onUnexpectedError, ErrorNoTelemetry } from '../../base/common/errors.js'; import { setFullscreen } from '../../base/browser/browser.js'; import { URI, UriComponents } from '../../base/common/uri.js'; import { WorkspaceService } from '../services/configuration/browser/configurationService.js'; @@ -219,7 +218,7 @@ export class BrowserMain extends Disposable { await remoteAuthorityResolverService.resolveAuthority(this.configuration.remoteAuthority); }, openTunnel: async tunnelOptions => { - const tunnel = assertReturnsDefined(await remoteExplorerService.forward({ + const tunnel = await remoteExplorerService.forward({ remote: tunnelOptions.remoteAddress, local: tunnelOptions.localAddressPort, name: tunnelOptions.label, @@ -235,7 +234,11 @@ export class BrowserMain extends Disposable { onAutoForward: undefined, requireLocalPort: undefined, protocol: tunnelOptions.protocol === TunnelProtocol.Https ? tunnelOptions.protocol : TunnelProtocol.Http - })); + }); + + if (tunnel === undefined) { + throw new ErrorNoTelemetry(`Could not open tunnel to ${tunnelOptions.remoteAddress.host}:${tunnelOptions.remoteAddress.port}.`); + } if (typeof tunnel === 'string') { throw new Error(tunnel); diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index 9e631577682eda..f2a3b35e6eac48 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -807,11 +807,11 @@ const registry = Registry.as(ConfigurationExtensions.Con 'default': true, 'description': localize('shadows', "Controls whether shadow effects are shown around the side panels and other workbench elements.") }, - [LayoutSettings.FLOATING_PANELS]: { + [LayoutSettings.MODERN_UI]: { 'type': 'boolean', 'default': false, 'tags': ['experimental'], - 'description': localize('floatingPanels', "Controls whether the side bars and bottom panel are shown as floating cards with rounded corners and gaps, similar to the agents window design."), + 'description': localize('modernUI', "Controls whether the experimental Modern UI Update is enabled. When on, the side bars and bottom panel are shown as floating cards with rounded corners and gaps, and a set of refreshed workbench styles is applied, matching the Agents window design."), }, } }); @@ -1033,3 +1033,26 @@ Registry.as(Extensions.ConfigurationMigration) return result; } }]); + +// Migrate the previous standalone experiments (`workbench.experimental.floatingPanels` +// and `workbench.experimental.styleOverrides`) onto the consolidated +// `workbench.experimental.modernUI` toggle. Either of the old settings being on +// enables the unified Modern UI Update experiment. +Registry.as(Extensions.ConfigurationMigration) + .registerConfigurationMigrations([{ + key: 'workbench.experimental.floatingPanels', migrateFn: (value: unknown, valueAccessor) => { + const result: ConfigurationKeyValuePairs = [['workbench.experimental.floatingPanels', { value: undefined }]]; + if (value === true && valueAccessor(LayoutSettings.MODERN_UI) === undefined) { + result.push([LayoutSettings.MODERN_UI, { value: true }]); + } + return result; + } + }, { + key: 'workbench.experimental.styleOverrides', migrateFn: (value: unknown, valueAccessor) => { + const result: ConfigurationKeyValuePairs = [['workbench.experimental.styleOverrides', { value: undefined }]]; + if (Array.isArray(value) && value.length > 0 && valueAccessor(LayoutSettings.MODERN_UI) === undefined) { + result.push([LayoutSettings.MODERN_UI, { value: true }]); + } + return result; + } + }]); diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 963845986e8592..e1449fc6d41938 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -1051,9 +1051,27 @@ configurationRegistry.registerConfiguration({ }, }, [ChatConfiguration.StrictMarketplaces]: { - type: 'boolean', - markdownDescription: nls.localize('chat.plugins.strictMarketplaces', "When enabled, only marketplaces supplied via enterprise policy are trusted. Plugins from any other marketplace will not load."), - default: false, + type: ['array', 'null'], + items: { + type: 'object', + properties: { + source: { + type: 'string', + enum: ['github', 'git', 'url', 'npm', 'file', 'directory', 'hostPattern', 'pathPattern'], + }, + repo: { type: 'string' }, + url: { type: 'string' }, + ref: { type: 'string' }, + path: { type: 'string' }, + package: { type: 'string' }, + hostPattern: { type: 'string' }, + pathPattern: { type: 'string' }, + headers: { type: 'object', additionalProperties: { type: 'string' } }, + }, + required: ['source'], + }, + markdownDescription: nls.localize('chat.plugins.strictMarketplaces', "Enterprise-managed allowlist of plugin marketplace sources. When set, only marketplaces matching one of these entries can be installed; an empty array blocks all marketplaces. This does not retroactively disable already-installed plugins. Each entry is an object with a `source` discriminator (`github`, `git`, `url`, `npm`, `file`, `directory`, `hostPattern`, or `pathPattern`) and the corresponding fields. Typically delivered via enterprise policy."), + default: null, restricted: true, scope: ConfigurationScope.APPLICATION, tags: ['experimental'], @@ -1063,12 +1081,12 @@ configurationRegistry.registerConfiguration({ minimumVersion: '1.122', value: (policyData) => policyData.managedSettings?.[COPILOT_STRICT_MARKETPLACES_KEY], managedSettings: { - [COPILOT_STRICT_MARKETPLACES_KEY]: { type: 'boolean' }, + [COPILOT_STRICT_MARKETPLACES_KEY]: { type: 'string' }, }, localization: { description: { key: 'chat.plugins.strictMarketplaces.policy', - value: nls.localize('chat.plugins.strictMarketplaces.policy', "Only trust marketplaces supplied via enterprise policy; plugins from any other marketplace will not load."), + value: nls.localize('chat.plugins.strictMarketplaces.policy', "Allowlist of plugin marketplace sources. When set, only marketplaces matching an entry are trusted; an empty array blocks all marketplaces."), } }, }, diff --git a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts index ec8c55f1ad6251..46cc7a297abada 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts @@ -423,6 +423,23 @@ export class PluginInstallService implements IPluginInstallService { return true; } + // Under the strict-marketplace enterprise policy, a marketplace that is not + // on the allowlist is blocked outright — the user cannot grant trust to + // bypass it. Surface a non-actionable enterprise-policy notification that + // points at the managed setting (shown as "Managed by organization"). + if (this._pluginMarketplaceService.isStrictMarketplacePolicyActive()) { + this._notificationService.notify({ + severity: Severity.Warning, + message: localize('strictMarketplaceBlockedInstall', "Plugins from '{0}' are blocked by your organization's policy.", plugin.marketplaceReference.displayLabel), + actions: { + primary: [new Action('chat.plugins.viewMarketplacePolicy', localize('viewPolicySettings', "View Policy Settings"), undefined, true, () => { + return this._commandService.executeCommand('workbench.action.openSettings', ChatConfiguration.StrictMarketplaces); + })], + }, + }); + return false; + } + const { confirmed } = await this._dialogService.confirm({ type: 'question', message: localize('trustMarketplace', "Trust Plugins from '{0}'?", plugin.marketplaceReference.displayLabel), diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index 512ad7e6da5a6f..98bebe70d916c3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -712,7 +712,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart const action = new Action( TerminalContribCommandId.ContinueInBackground, localize('continueInBackground', 'Continue in Background'), - ThemeIcon.asClassName(Codicon.debugContinue), + ThemeIcon.asClassName(Codicon.debugContinueSmall), true, () => this.continueInBackground() ); diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css index 9c2cf73fb3b0d2..edc68225a6a476 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css @@ -67,7 +67,6 @@ font-size: 11px; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.5px; .agent-sessions-title { cursor: pointer; diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index c114aba6974c8f..050d6678edb624 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -11,7 +11,7 @@ import { untildify } from '../../../../../base/common/labels.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../../base/common/map.js'; import { equals } from '../../../../../base/common/objects.js'; -import { autorun, derived, derivedOpts, IObservable, ISettableObservable, ITransaction, observableFromEvent, observableSignalFromEvent, ObservablePromise, observableSignal, observableValue, transaction } from '../../../../../base/common/observable.js'; +import { autorun, derived, derivedOpts, IObservable, ISettableObservable, ITransaction, observableFromEvent, ObservablePromise, observableSignal, observableValue, transaction } from '../../../../../base/common/observable.js'; import { posix, win32 @@ -54,7 +54,7 @@ import { HookType } from '../promptSyntax/hookTypes.js'; import { IAgentPluginRepositoryService } from './agentPluginRepositoryService.js'; import { agentPluginDiscoveryRegistry, IAgentPlugin, IAgentPluginDiscovery, IAgentPluginHook, IAgentPluginInstruction, IAgentPluginMcpServerDefinition, IAgentPluginService } from './agentPluginService.js'; import { IMarketplacePlugin, IPluginMarketplaceService } from './pluginMarketplaceService.js'; -import { type IMarketplaceReference, parseMarketplaceReferences, readConfiguredMarketplaces } from './marketplaceReference.js'; +import { type IMarketplaceReference } from './marketplaceReference.js'; // Re-export shared helpers so existing consumers (including tests) continue to work. export { shellQuotePluginRootInCommand, resolveMcpServersMap, convertBareEnvVarsToVsCodeSyntax } from '../../../../../platform/agentPlugins/common/pluginParsers.js'; @@ -98,7 +98,6 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic @IInstantiationService instantiationService: IInstantiationService, @IConfigurationService configurationService: IConfigurationService, @IStorageService storageService: IStorageService, - @IPluginMarketplaceService pluginMarketplaceService: IPluginMarketplaceService, @ILogService logService: ILogService, ) { super(); @@ -122,11 +121,6 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic Event.filter(configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(ChatConfiguration.EnabledPlugins)), () => configurationService.inspect>(ChatConfiguration.EnabledPlugins).policyValue, ); - const strictMarketplaces = observableConfigValue(ChatConfiguration.StrictMarketplaces, false, configurationService); - // Re-evaluate marketplace trust when the policy-delivered extra - // marketplaces change (consulted under strict mode). - const extraMarketplacesChanged = observableSignalFromEvent('extraMarketplaces', - Event.filter(configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(ChatConfiguration.ExtraMarketplaces))); this.plugins = derived(read => { if (!pluginsEnabled.read(read)) { @@ -141,14 +135,9 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic this._register(autorun(reader => { const plugins = this.plugins.read(reader); const policy = enabledPluginsPolicy.read(reader); - const strict = strictMarketplaces.read(reader); - extraMarketplacesChanged.read(reader); - const trustedExtras = strict - ? parseMarketplaceReferences(readConfiguredMarketplaces(configurationService).extraValues) - : []; transaction(tx => { for (const plugin of plugins) { - setPolicyBlocked(plugin, this._isBlockedByPolicy(plugin, policy, strict, trustedExtras, pluginMarketplaceService, logService), tx); + setPolicyBlocked(plugin, this._isBlockedByPolicy(plugin, policy, logService), tx); } }); })); @@ -158,22 +147,18 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic * Determines whether a plugin is blocked by enterprise policy: * - If `chat.plugins.enabledPlugins` (policy-managed via `ChatEnabledPlugins`) * is set, only plugins whose ID appears with value `true` are allowed. - * - If `chat.plugins.strictMarketplaces` is on, only plugins from a - * marketplace listed in `chat.plugins.extraMarketplaces` are allowed. + * + * Marketplace trust (`chat.plugins.strictMarketplaces`) is enforced at + * install time only, already-installed plugins are not + * retroactively unloaded. * * Plugins without a marketplace provenance (e.g. user-configured filesystem * paths from `chat.pluginLocations`) are never blocked — they are user-side - * concerns outside the enterprise enforcement boundary. Copilot-CLI-installed - * plugins under `~/.copilot/installed-plugins///` are - * gated using their install-path identity even when they lack rich - * marketplace metadata. + * concerns outside the enterprise enforcement boundary. */ private _isBlockedByPolicy( plugin: IAgentPlugin, enabledPluginsPolicy: Record | undefined, - strictMarketplaces: boolean, - trustedExtras: readonly IMarketplaceReference[], - pluginMarketplaceService: IPluginMarketplaceService, logService: ILogService, ): boolean { const identity = getPolicyIdentity(plugin); @@ -187,15 +172,6 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic return true; } } - if (strictMarketplaces) { - const trusted = identity.marketplaceReference - ? pluginMarketplaceService.isMarketplaceTrusted(identity.marketplaceReference) - : isCliBucketTrusted(identity.marketplace, trustedExtras); - if (!trusted) { - logService.debug(`[AgentPluginService] Plugin '${pluginId}' blocked — marketplace not trusted under strict mode`); - return true; - } - } return false; } @@ -280,28 +256,6 @@ function getPolicyIdentity(plugin: IAgentPlugin): IPolicyIdentity | undefined { return { name, marketplace }; } -/** - * Under strict mode, decide whether a Copilot-CLI-installed plugin's bucket - * name (the `` segment of its install path) corresponds to a - * trusted marketplace listed in `chat.plugins.extraMarketplaces`. Uses - * heuristics covering common CLI bucket-naming conventions (GitHub repo name, - * shorthand `owner/repo`, raw reference value, canonical id tail). - */ -function isCliBucketTrusted(bucket: string, trustedExtras: readonly IMarketplaceReference[]): boolean { - return trustedExtras.some(ref => { - if (ref.githubRepo && ref.githubRepo.split('/').pop() === bucket) { - return true; - } - if (ref.displayLabel === bucket || ref.displayLabel.endsWith(`/${bucket}`)) { - return true; - } - if (ref.canonicalId.endsWith(`/${bucket}`) || ref.canonicalId.endsWith(`/${bucket}.git`)) { - return true; - } - return false; - }); -} - /** * Minimal shape of a parsed plugin manifest. Known fields are typed; unknown * keys (e.g. `commands`, `skills`, `hooks`, `mcpServers`) remain `unknown` and diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index d9fdab4cc3846f..761c0e2b2da8bf 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -29,6 +29,7 @@ import { FileBackedInstalledPluginsStore, IStoredInstalledPlugin } from './fileB import { IWorkspacePluginSettingsService } from './workspacePluginSettingsService.js'; import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; import { type IMarketplaceReference, deduplicateMarketplaceReferences, MarketplaceReferenceKind, parseMarketplaceObjectEntry, parseMarketplaceReference, parseMarketplaceReferences, readConfiguredMarketplaces } from './marketplaceReference.js'; +import { getStrictKnownMarketplaces, isMarketplaceReferenceAllowed } from './strictKnownMarketplaces.js'; // Re-export marketplace reference types for downstream consumers. export { deduplicateMarketplaceReferences, extraKnownMarketplacesToConfigDict, MarketplaceReferenceKind, parseMarketplaceReference, parseMarketplaceReferences, readConfiguredMarketplaces } from './marketplaceReference.js'; @@ -171,8 +172,14 @@ export interface IPluginMarketplaceService { getMarketplacePluginMetadata(pluginUri: URI): IMarketplacePlugin | undefined; addInstalledPlugin(pluginUri: URI, plugin: IMarketplacePlugin): void; removeInstalledPlugin(pluginUri: URI): void; - /** Returns whether the given marketplace has been explicitly trusted by the user. */ + /** Returns whether the given marketplace is trusted — either explicitly trusted by the user, or allowed by the enterprise allowlist when strict mode is active. */ isMarketplaceTrusted(ref: IMarketplaceReference): boolean; + /** + * Returns whether the strict-marketplace enterprise policy + * (`chat.plugins.strictMarketplaces`) is active — i.e. an allowlist is + * configured. When active, blocked marketplaces cannot be trusted by the user. + */ + isStrictMarketplacePolicyActive(): boolean; /** Records that the user trusts the given marketplace, persisted permanently. */ trustMarketplace(ref: IMarketplaceReference): void; /** @@ -613,19 +620,22 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke } isMarketplaceTrusted(ref: IMarketplaceReference): boolean { - // In strict mode (`chat.plugins.strictMarketplaces`, typically enabled via the - // `ChatStrictMarketplaces` enterprise policy), trust is restricted to - // marketplaces in `chat.plugins.extraMarketplaces` — the policy-only slot. - // User-configured entries in `chat.plugins.marketplaces` do NOT grant trust - // under strict mode; that's the whole point of "strict" — the enterprise - // fully controls the allowed marketplaces. - if (this._configurationService.getValue(ChatConfiguration.StrictMarketplaces)) { - const refs = parseMarketplaceReferences(readConfiguredMarketplaces(this._configurationService).extraValues); - return refs.some(r => r.canonicalId === ref.canonicalId); + // In strict mode (`chat.plugins.strictMarketplaces`, typically delivered via the + // `ChatStrictMarketplaces` enterprise policy), trust is governed entirely by the + // allowlist: a marketplace is trusted only if it matches one of the configured + // source entries. The user-trusted store is bypassed — that's the whole point of + // "strict": the enterprise fully controls the allowed marketplaces. + const allowlist = getStrictKnownMarketplaces(this._configurationService.getValue(ChatConfiguration.StrictMarketplaces)); + if (allowlist !== undefined) { + return isMarketplaceReferenceAllowed(allowlist, ref); } return this._trustedMarketplacesStore.get().includes(ref.canonicalId); } + isStrictMarketplacePolicyActive(): boolean { + return getStrictKnownMarketplaces(this._configurationService.getValue(ChatConfiguration.StrictMarketplaces)) !== undefined; + } + // --- Plugin metadata hydration ----------------------------------------------- /** diff --git a/src/vs/workbench/contrib/chat/common/plugins/strictKnownMarketplaces.ts b/src/vs/workbench/contrib/chat/common/plugins/strictKnownMarketplaces.ts new file mode 100644 index 00000000000000..789f0648333f83 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/plugins/strictKnownMarketplaces.ts @@ -0,0 +1,160 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IStrictMarketplaceSource } from '../../../../../base/common/managedSettings.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IMarketplaceReference, MarketplaceReferenceKind, parseMarketplaceReference } from './marketplaceReference.js'; + +/** + * The allowlist entry type for `chat.plugins.strictMarketplaces`. Re-exported + * from `base/common` so chat plugin code can import it alongside the matcher it + * operates with; the same type is consumed by the managed-settings adapter. + */ +export type { IStrictMarketplaceSource }; + +/** + * The value of the `chat.plugins.strictMarketplaces` allowlist. + * - `undefined`: no restrictions — all marketplaces are allowed. + * - `[]`: complete lockdown — no marketplace is allowed. + * - non-empty array: only marketplaces matching an entry are allowed. + */ +export type StrictKnownMarketplaces = readonly IStrictMarketplaceSource[]; + +/** + * Coerces a resolved configuration value into a {@link StrictKnownMarketplaces} + * allowlist. Returns `undefined` (meaning "no restrictions") when the value is + * not an array — which is also how an unset policy surfaces (the registered + * `null` default). Malformed entries (non-objects or entries without a string + * `source`) are dropped so a bad managed-settings payload degrades to "no match" + * rather than throwing during matching. + */ +export function getStrictKnownMarketplaces(value: unknown): StrictKnownMarketplaces | undefined { + if (!Array.isArray(value)) { + return undefined; + } + return value.filter((entry): entry is IStrictMarketplaceSource => + typeof entry === 'object' && entry !== null && typeof entry.source === 'string'); +} + +/** + * Checks whether a marketplace reference is allowed by the strict allowlist. + * + * @param allowlist `undefined` allows everything, `[]` blocks everything, + * otherwise the reference must match at least one entry. + */ +export function isMarketplaceReferenceAllowed(allowlist: StrictKnownMarketplaces | undefined, ref: IMarketplaceReference): boolean { + if (allowlist === undefined) { + return true; + } + if (allowlist.length === 0) { + return false; + } + return allowlist.some(entry => matchesAllowlistEntry(entry, ref)); +} + +function matchesAllowlistEntry(entry: IStrictMarketplaceSource, ref: IMarketplaceReference): boolean { + switch (entry.source) { + case 'github': { + // VS Code marketplace references do not model an in-repo path, so an + // entry that pins a `path` can never match. + if (typeof entry.repo !== 'string' || entry.path !== undefined) { + return false; + } + const candidate = parseMarketplaceReference(appendRef(entry.repo, entry.ref)); + return !!candidate && candidate.canonicalId === ref.canonicalId; + } + case 'git': { + if (typeof entry.url !== 'string' || entry.path !== undefined) { + return false; + } + const candidate = parseMarketplaceReference(appendRef(entry.url, entry.ref)); + return !!candidate && candidate.canonicalId === ref.canonicalId; + } + case 'url': { + if (typeof entry.url !== 'string') { + return false; + } + const candidate = parseMarketplaceReference(appendRef(entry.url, entry.ref)); + return !!candidate && candidate.canonicalId === ref.canonicalId; + } + case 'npm': { + // npm is not a supported marketplace source in VS Code. + return false; + } + case 'file': + case 'directory': { + if (ref.kind !== MarketplaceReferenceKind.LocalFileUri || !ref.localRepositoryUri || typeof entry.path !== 'string') { + return false; + } + // Note: `~` is not expanded here (no home-dir resolution in the common + // layer); enterprise allowlists should use absolute paths. + return isEqual(ref.localRepositoryUri, URI.file(entry.path)); + } + case 'hostPattern': { + if (typeof entry.hostPattern !== 'string') { + return false; + } + const host = extractHost(ref); + return !!host && testPattern(entry.hostPattern, host); + } + case 'pathPattern': { + if (typeof entry.pathPattern !== 'string' || ref.kind !== MarketplaceReferenceKind.LocalFileUri || !ref.localRepositoryUri) { + return false; + } + return testPattern(entry.pathPattern, ref.localRepositoryUri.fsPath); + } + default: + return false; + } +} + +/** Appends (or replaces) a `#ref` fragment on a marketplace value. */ +function appendRef(value: string, ref: string | undefined): string { + if (!ref) { + return value; + } + const fragmentIndex = value.indexOf('#'); + const base = fragmentIndex === -1 ? value : value.slice(0, fragmentIndex); + return `${base}#${ref}`; +} + +/** Extracts the host of a marketplace reference for `hostPattern` matching. */ +function extractHost(ref: IMarketplaceReference): string | undefined { + if (ref.kind === MarketplaceReferenceKind.GitHubShorthand) { + return 'github.com'; + } + if (ref.kind !== MarketplaceReferenceKind.GitUri) { + return undefined; + } + // scp-style git URLs: `git@host:path`. + const scpMatch = /^[\w._-]+@([\w.-]+):/.exec(ref.cloneUrl); + if (scpMatch) { + return scpMatch[1].toLowerCase(); + } + try { + let authority = URI.parse(ref.cloneUrl).authority.toLowerCase(); + const at = authority.lastIndexOf('@'); + if (at !== -1) { + authority = authority.slice(at + 1); + } + const colon = authority.indexOf(':'); + if (colon !== -1) { + authority = authority.slice(0, colon); + } + return authority || undefined; + } catch { + return undefined; + } +} + +/** Tests a regex pattern against a value, treating invalid patterns as non-matching. */ +function testPattern(pattern: string, value: string): boolean { + try { + return new RegExp(pattern).test(value); + } catch { + return false; + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts index dcf3d709148d48..47492f7b69fdc5 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts @@ -181,11 +181,16 @@ CommandsRegistry.registerCommand('_listExtensionPromptFiles', async (accessor): promptsService.listPromptFiles(PromptsType.hook, CancellationToken.None), ]); - // Combine all files and collect extension-contributed ones + // Combine all files and collect extension- and plugin-contributed ones. + // Plugin files are included so the copilot extension can trust them and + // serve them to the LLM without a confirmation dialog when connected to a + // remote (where they are emitted as vscode-local:/... URIs). const result: IExtensionPromptFileResult[] = []; for (const file of [...agents, ...instructions, ...prompts, ...skills, ...hooks]) { if (file.storage === PromptsStorage.extension) { result.push({ uri: file.uri.toJSON(), type: file.type, extensionId: file.extension.identifier.value }); + } else if (file.storage === PromptsStorage.plugin) { + result.push({ uri: file.uri.toJSON(), type: file.type, extensionId: file.pluginUri.toString() }); } } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 89a4d7ec81aace..73ad16f3249af7 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -349,7 +349,8 @@ export class ComputeAutomaticInstructions { const remoteEnv = await this._remoteAgentService.getEnvironment(); const remoteOS = remoteEnv?.os; - const filePath = (uri: URI) => getFilePath(uri, remoteOS); + const isRemote = this._remoteAgentService.getConnection() !== null; + const filePath = (uri: URI) => getFilePath(uri, remoteOS, isRemote); const entries: string[] = []; if (fileReadTool) { @@ -632,7 +633,14 @@ export class ComputeAutomaticInstructions { } -export function getFilePath(uri: URI, remoteOS: OperatingSystem | undefined): string { +export function getFilePath(uri: URI, remoteOS: OperatingSystem | undefined, isRemote = false): string { + // When connected to a remote, local file:// URIs must be represented using + // the vscode-local scheme so the remote extension host can read them via the + // local file bridge. This works for WSL, SSH, and dev containers without + // any cache migration. + if (isRemote && uri.scheme === Schemas.file) { + return uri.with({ scheme: 'vscode-local' }).toString(); + } if (uri.scheme === Schemas.file || uri.scheme === Schemas.vscodeRemote) { const fsPath = uri.fsPath; // uri.fsPath uses the local OS's path separators, but the path diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts index ebd0ea996896e3..a0b7f92a14e4dc 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts @@ -68,6 +68,8 @@ suite('PluginInstallService', () => { updatePluginSourceCalls: { plugin: IMarketplacePlugin; options?: IPullRepositoryOptions }[]; /** Whether the marketplace is already trusted */ marketplaceTrusted: boolean; + /** Whether the strict-marketplace enterprise policy is active */ + strictMarketplacePolicyActive?: boolean; /** Canonical IDs that were trusted via trustMarketplace() */ trustedMarketplaces: string[]; /** Plugins returned by readPluginsFromDirectory */ @@ -99,6 +101,7 @@ suite('PluginInstallService', () => { pullRepositoryCalls: [], updatePluginSourceCalls: [], marketplaceTrusted: true, + strictMarketplacePolicyActive: false, trustedMarketplaces: [], readPluginsResult: [], singlePluginManifestResult: undefined, @@ -269,6 +272,7 @@ suite('PluginInstallService', () => { state.addedPlugins.push({ uri: uri.toString(), plugin }); }, isMarketplaceTrusted: () => state.marketplaceTrusted, + isStrictMarketplacePolicyActive: () => state.strictMarketplacePolicyActive ?? false, trustMarketplace: (ref: IMarketplaceReference) => { state.trustedMarketplaces.push(ref.canonicalId); }, diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/strictKnownMarketplaces.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/strictKnownMarketplaces.test.ts new file mode 100644 index 00000000000000..e29c4bb09aaca5 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/plugins/strictKnownMarketplaces.test.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { parseMarketplaceReference } from '../../../common/plugins/marketplaceReference.js'; +import { getStrictKnownMarketplaces, isMarketplaceReferenceAllowed, IStrictMarketplaceSource } from '../../../common/plugins/strictKnownMarketplaces.js'; + +suite('strictKnownMarketplaces', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + function ref(value: string) { + const parsed = parseMarketplaceReference(value); + assert.ok(parsed, `expected '${value}' to parse`); + return parsed!; + } + + test('getStrictKnownMarketplaces coerces non-arrays to undefined', () => { + assert.strictEqual(getStrictKnownMarketplaces(null), undefined); + assert.strictEqual(getStrictKnownMarketplaces(undefined), undefined); + assert.strictEqual(getStrictKnownMarketplaces(true), undefined); + assert.strictEqual(getStrictKnownMarketplaces('[]'), undefined); + assert.deepStrictEqual(getStrictKnownMarketplaces([]), []); + }); + + test('getStrictKnownMarketplaces drops malformed entries', () => { + const value = [ + { source: 'github', repo: 'owner/repo' }, + null, + 42, + 'github', + { repo: 'owner/repo' }, // missing source + ]; + assert.deepStrictEqual(getStrictKnownMarketplaces(value), [{ source: 'github', repo: 'owner/repo' }]); + }); + + test('undefined allowlist allows everything; empty allowlist blocks everything', () => { + const r = ref('microsoft/vscode'); + assert.strictEqual(isMarketplaceReferenceAllowed(undefined, r), true); + assert.strictEqual(isMarketplaceReferenceAllowed([], r), false); + }); + + test('github entry matches shorthand and equivalent URI forms', () => { + const allowlist: IStrictMarketplaceSource[] = [{ source: 'github', repo: 'microsoft/vscode-team-kit' }]; + assert.strictEqual(isMarketplaceReferenceAllowed(allowlist, ref('microsoft/vscode-team-kit')), true); + assert.strictEqual(isMarketplaceReferenceAllowed(allowlist, ref('https://github.com/microsoft/vscode-team-kit.git')), true); + assert.strictEqual(isMarketplaceReferenceAllowed(allowlist, ref('git@github.com:microsoft/vscode-team-kit.git')), true); + assert.strictEqual(isMarketplaceReferenceAllowed(allowlist, ref('microsoft/other-repo')), false); + }); + + test('github entry ref must match exactly (tri-state)', () => { + const pinned: IStrictMarketplaceSource[] = [{ source: 'github', repo: 'owner/repo', ref: 'main' }]; + assert.strictEqual(isMarketplaceReferenceAllowed(pinned, ref('owner/repo#main')), true); + assert.strictEqual(isMarketplaceReferenceAllowed(pinned, ref('owner/repo')), false); + assert.strictEqual(isMarketplaceReferenceAllowed(pinned, ref('owner/repo#dev')), false); + + const unpinned: IStrictMarketplaceSource[] = [{ source: 'github', repo: 'owner/repo' }]; + assert.strictEqual(isMarketplaceReferenceAllowed(unpinned, ref('owner/repo')), true); + assert.strictEqual(isMarketplaceReferenceAllowed(unpinned, ref('owner/repo#main')), false); + }); + + test('github entry with a path never matches (paths are not modeled)', () => { + const allowlist: IStrictMarketplaceSource[] = [{ source: 'github', repo: 'owner/repo', path: 'sub' }]; + assert.strictEqual(isMarketplaceReferenceAllowed(allowlist, ref('owner/repo')), false); + }); + + test('git entry matches a git URL reference', () => { + const allowlist: IStrictMarketplaceSource[] = [{ source: 'git', url: 'https://example.com/team/kit.git' }]; + assert.strictEqual(isMarketplaceReferenceAllowed(allowlist, ref('https://example.com/team/kit.git')), true); + assert.strictEqual(isMarketplaceReferenceAllowed(allowlist, ref('https://example.com/team/other.git')), false); + }); + + test('url entry honors a pinned ref', () => { + const pinned: IStrictMarketplaceSource[] = [{ source: 'url', url: 'https://example.com/team/kit.git', ref: 'main' }]; + assert.strictEqual(isMarketplaceReferenceAllowed(pinned, ref('https://example.com/team/kit.git#main')), true); + assert.strictEqual(isMarketplaceReferenceAllowed(pinned, ref('https://example.com/team/kit.git')), false); + assert.strictEqual(isMarketplaceReferenceAllowed(pinned, ref('https://example.com/team/kit.git#dev')), false); + }); + + test('npm entries never match', () => { + const allowlist: IStrictMarketplaceSource[] = [{ source: 'npm', package: 'whatever' }]; + assert.strictEqual(isMarketplaceReferenceAllowed(allowlist, ref('owner/repo')), false); + }); + + test('file entry matches a local file URI reference', () => { + const r = ref('file:///opt/approved/marketplace'); + assert.strictEqual(isMarketplaceReferenceAllowed([{ source: 'file', path: r.localRepositoryUri!.fsPath }], r), true); + assert.strictEqual(isMarketplaceReferenceAllowed([{ source: 'directory', path: r.localRepositoryUri!.fsPath }], r), true); + assert.strictEqual(isMarketplaceReferenceAllowed([{ source: 'file', path: '/opt/unapproved' }], r), false); + }); + + test('hostPattern matches by host; invalid regex is treated as non-matching', () => { + assert.strictEqual(isMarketplaceReferenceAllowed([{ source: 'hostPattern', hostPattern: '^github\\.com$' }], ref('microsoft/vscode')), true); + assert.strictEqual(isMarketplaceReferenceAllowed([{ source: 'hostPattern', hostPattern: '\\.internal\\.example\\.com$' }], ref('https://plugins.internal.example.com/team/kit.git')), true); + assert.strictEqual(isMarketplaceReferenceAllowed([{ source: 'hostPattern', hostPattern: '^github\\.com$' }], ref('https://example.com/team/kit.git')), false); + assert.strictEqual(isMarketplaceReferenceAllowed([{ source: 'hostPattern', hostPattern: '(' }], ref('microsoft/vscode')), false); + }); + + test('pathPattern matches resolved local path only', () => { + const local = ref('file:///opt/approved/marketplace'); + assert.strictEqual(isMarketplaceReferenceAllowed([{ source: 'pathPattern', pathPattern: 'approved' }], local), true); + assert.strictEqual(isMarketplaceReferenceAllowed([{ source: 'pathPattern', pathPattern: 'approved' }], ref('microsoft/vscode')), false); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts index 964cc7c15339bd..8aba1c4e01eef0 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts @@ -190,6 +190,7 @@ suite('ComputeAutomaticInstructions', () => { instaService.stub(IRemoteAgentService, { getEnvironment: () => Promise.resolve(null), + getConnection: () => null, }); instaService.stub(IContextKeyService, new MockContextKeyService()); @@ -2798,4 +2799,30 @@ suite('getFilePath', () => { const result = getFilePath(uri, undefined); assert.strictEqual(result, uri.fsPath); }); + + test('should return vscode-local:/ URI string for file:// URIs when connected to a remote', () => { + const uri = URI.file('/C:/Users/user/AppData/Roaming/agent-plugins/my-skill/SKILL.md'); + const result = getFilePath(uri, OperatingSystem.Linux, /* isRemote */ true); + assert.strictEqual(result, uri.with({ scheme: 'vscode-local' }).toString()); + }); + + test('should return vscode-local:/ URI string for file:// URIs when connected to a Windows remote', () => { + const uri = URI.file('/C:/Users/user/AppData/Roaming/agent-plugins/my-skill/SKILL.md'); + const result = getFilePath(uri, OperatingSystem.Windows, /* isRemote */ true); + assert.strictEqual(result, uri.with({ scheme: 'vscode-local' }).toString()); + }); + + test('should not convert file:// URIs to vscode-local:/ when not connected to a remote', () => { + const uri = URI.file('/home/user/.copilot/agent-plugins/my-skill/SKILL.md'); + const result = getFilePath(uri, undefined, /* isRemote */ false); + assert.strictEqual(result, uri.fsPath); + }); + + test('should not convert vscode-remote:// URIs when connected to a remote', () => { + const uri = URI.from({ scheme: Schemas.vscodeRemote, authority: 'wsl+ubuntu', path: '/home/user/project/file.ts' }); + const result = getFilePath(uri, OperatingSystem.Linux, /* isRemote */ true); + // Do not use uri.fsPath here — it is host-OS-dependent and returns + // backslashes on Windows CI, but the function normalizes to the remote OS. + assert.strictEqual(result, '/home/user/project/file.ts'); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 03be925a35d762..5c5ccd5800e75e 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -194,6 +194,7 @@ suite('PromptsService', () => { instaService.stub(IRemoteAgentService, { getEnvironment: () => Promise.resolve(null), + getConnection: () => null, }); instaService.stub(IContextKeyService, new MockContextKeyService()); diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/commandCenter.css b/src/vs/workbench/contrib/styleOverrides/browser/media/commandCenter.css index 9be0e34850e454..8695ade49d1da7 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/commandCenter.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/commandCenter.css @@ -15,7 +15,7 @@ * * All rules are gated behind the `.style-override-commandCenter` class, which * the StyleOverridesContribution toggles onto the workbench container(s) when - * the `workbench.experimental.styleOverrides` setting includes "commandCenter". + * the `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. */ /* diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css b/src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css index fb6f0fa9dc03bb..208e73835ed0b5 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css @@ -25,7 +25,7 @@ * * All rules are gated behind the `.style-override-fontRamp` class, which the * StyleOverridesContribution toggles onto the workbench container(s) when the - * `workbench.experimental.styleOverrides` setting includes "fontRamp". Without + * `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. Without * that class these rules never apply. */ diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/keyboardFocusOnly.css b/src/vs/workbench/contrib/styleOverrides/browser/media/keyboardFocusOnly.css index 880dbd6fa26247..ff78c1cc2dd38f 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/keyboardFocusOnly.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/keyboardFocusOnly.css @@ -15,7 +15,7 @@ * * All rules are gated behind the `.style-override-keyboardFocusOnly` class, which * the StyleOverridesContribution toggles onto the workbench container(s) when the - * `workbench.experimental.styleOverrides` setting includes "keyboardFocusOnly". + * `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. * * Scoped to the panel surfaces (side bar, bottom panel, auxiliary bar, status * bar) so editor and global widget focus rings are unaffected. diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css b/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css index 6207f026add76c..2e1ef09a342917 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css @@ -11,7 +11,7 @@ * * All rules are gated behind the `.style-override-padding` class, which the * StyleOverridesContribution toggles onto the workbench container(s) when the - * `workbench.experimental.styleOverrides` setting includes "padding". + * `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. * Without that class these rules never apply. */ diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/paneHeaders.css b/src/vs/workbench/contrib/styleOverrides/browser/media/paneHeaders.css index 4f3e1a3b3c56d1..da82c6d158d1e3 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/paneHeaders.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/paneHeaders.css @@ -17,7 +17,7 @@ * * All rules are gated behind the `.style-override-paneHeaders` class, which the * StyleOverridesContribution toggles onto the workbench container(s) when the - * `workbench.experimental.styleOverrides` setting includes "paneHeaders". + * `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. * Without that class these rules never apply. */ diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css b/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css index 37589163ed6495..47e756af0f8341 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css @@ -19,7 +19,7 @@ * * All rules are gated behind the `.style-override-roundedCorners` class, which * the StyleOverridesContribution toggles onto the workbench container(s) when - * the `workbench.experimental.styleOverrides` setting includes "roundedCorners". + * the `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. * Without that class these rules never apply. */ diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css b/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css index 27b9b8cdee82ef..0a96b9dabb0b24 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css @@ -22,7 +22,7 @@ * * All rules are gated behind the `.style-override-scrollShadows` class, which * the StyleOverridesContribution toggles onto the workbench container(s) when - * the `workbench.experimental.styleOverrides` setting includes "scrollShadows". + * the `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. */ /* @@ -63,11 +63,6 @@ z-index: 10; } -.style-override-scrollShadows .monaco-list > .monaco-scrollable-element::after { - bottom: 0; - background: linear-gradient(to top, var(--scroll-shadow-surface), transparent); -} - /* ============================================================================= * Editor area. The main editor viewport scrolls inside `.editor-scrollable`; * pin the fades to that scrollable element so they share the scrollbar's @@ -97,3 +92,14 @@ bottom: 0; background: linear-gradient(to top, var(--scroll-shadow-surface), transparent); } + +/* ============================================================================= + * Embedded single-line editors used as text inputs (settings / keybindings + * search boxes, etc.) live inside the editor part but should read as plain + * inputs, not as scrolling editor surfaces. Suppress the edge fades there so the + * input keeps a flat background and never shows the editor-coloured rectangle. + * ========================================================================== */ +.style-override-scrollShadows .suggest-input-container .monaco-editor .editor-scrollable::before, +.style-override-scrollShadows .suggest-input-container .monaco-editor .editor-scrollable::after { + display: none; +} diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css b/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css index 18d6122c6aac1b..711927464d02d3 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css @@ -11,8 +11,8 @@ * * All rules are gated behind the `.style-override-tabs` class, which the * StyleOverridesContribution toggles onto the workbench container(s) when the - * `workbench.experimental.styleOverrides` setting includes "tabs". Without that - * class these rules never apply. Mirrors the Agents window styling defined in + * `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. Mirrors + * the Agents window styling defined in * src/vs/sessions/browser/media/style.css. */ diff --git a/src/vs/workbench/contrib/styleOverrides/browser/styleOverrides.contribution.ts b/src/vs/workbench/contrib/styleOverrides/browser/styleOverrides.contribution.ts index c1b736c16ad14c..f26669cd628f9b 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/styleOverrides.contribution.ts +++ b/src/vs/workbench/contrib/styleOverrides/browser/styleOverrides.contribution.ts @@ -3,15 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { localize } from '../../../../nls.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; -import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js'; +import { IWorkbenchLayoutService, LayoutSettings } from '../../../services/layout/browser/layoutService.js'; import { Extensions as WorkbenchExtensions, IWorkbenchContribution, IWorkbenchContributionsRegistry } from '../../../common/contributions.js'; import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; -import { workbenchConfigurationNodeBase } from '../../../common/configuration.js'; // Bundle the CSS for every style-override module. Each file gates all of its // rules behind a `.style-override-` ancestor class, so the styles are inert @@ -27,12 +24,8 @@ import './media/scrollShadows.css'; import './media/statusBar.css'; import './media/tabs.css'; -const SETTING_ID = 'workbench.experimental.styleOverrides'; - interface IStyleOverrideModule { readonly id: string; - readonly label: string; - readonly description: string; /** * Whether this module changes layout-affecting CSS variables (e.g. the pane * header size). Toggling such a module requires a workbench relayout so the @@ -42,63 +35,22 @@ interface IStyleOverrideModule { } /** - * The fixed catalog of available style-override experiments. The CSS for each - * module ships with the product (imported above) and is gated behind the - * `.style-override-` class. Users can only enable modules from this list; - * arbitrary CSS cannot be injected. + * The fixed catalog of built-in style-override modules. The CSS for each module + * ships with the product (imported above) and is gated behind the + * `.style-override-` class. All modules are enabled together as part of the + * Modern UI Update experiment (`LayoutSettings.MODERN_UI`). */ const STYLE_OVERRIDE_MODULES: readonly IStyleOverrideModule[] = [ - { - id: 'activityBar', - label: localize('styleOverrides.activityBar', "Activity Bar"), - description: localize('styleOverrides.activityBar.description', "Replaces the active activity bar item's left highlight border with a rounded background behind the icon, and forces item foregrounds to the editor foreground so icons stay legible.") - }, - { - id: 'commandCenter', - label: localize('styleOverrides.commandCenter', "Agents Window Command Center"), - description: localize('styleOverrides.commandCenter.description', "Makes the command center and agent status input transparent at rest, revealing their background on hover to match the Agents window.") - }, - { - id: 'fontRamp', - label: localize('styleOverrides.fontRamp', "Font Ramp"), - description: localize('styleOverrides.fontRamp.description', "Applies a unified typographic ramp across the workbench: headings at 26/18px, 13px body, 12px section titles and tabs, 11px metadata and 10px badges.") - }, - { - id: 'keyboardFocusOnly', - label: localize('styleOverrides.keyboardFocusOnly', "Keyboard-Only Focus Borders"), - description: localize('styleOverrides.keyboardFocusOnly.description', "Hides focus borders that appear when panels are focused with the mouse, while keeping them for keyboard navigation.") - }, - { - id: 'padding', - label: localize('styleOverrides.padding', "Padding"), - description: localize('styleOverrides.padding.description', "Adds extra padding to view pane headers and bodies for more breathing room around content.") - }, - { - id: 'paneHeaders', - label: localize('styleOverrides.paneHeaders', "Pane Headers"), - description: localize('styleOverrides.paneHeaders.description', "Insets the view pane header separators, rounds their corners and adds a background tint on hover."), - layoutAffecting: true - }, - { - id: 'roundedCorners', - label: localize('styleOverrides.roundedCorners', "Rounded Corners"), - description: localize('styleOverrides.roundedCorners.description', "Applies a three-tier corner radius system: 8px for overlays (quick input, hovers, menus, dialogs), 6px for non-control containers and 4px for interactable controls (inputs, lists).") - }, - { - id: 'scrollShadows', - label: localize('styleOverrides.scrollShadows', "Scroll Shadows"), - description: localize('styleOverrides.scrollShadows.description', "Adds soft inset shadows to the top and bottom of lists, trees and the editor so content reads as scrolling under a fixed frame.") - }, - { - id: 'statusBar', - label: localize('styleOverrides.statusBar', "Status Bar"), - description: localize('styleOverrides.statusBar.description', "Forces the status bar foreground to the general editor foreground so its text and icons stay legible when the status bar background is neutralized.") - }, - { - id: 'tabs', - label: localize('styleOverrides.tabs', "Agents Window Tabs"), - description: localize('styleOverrides.tabs.description', "Styles editor tabs as transparent, rounded pills to match the Agents window.") - } + { id: 'activityBar' }, + { id: 'commandCenter' }, + { id: 'fontRamp' }, + { id: 'keyboardFocusOnly' }, + { id: 'padding' }, + { id: 'paneHeaders', layoutAffecting: true }, + { id: 'roundedCorners' }, + { id: 'scrollShadows' }, + { id: 'statusBar' }, + { id: 'tabs' } ]; function classNameFor(moduleId: string): string { @@ -106,20 +58,17 @@ function classNameFor(moduleId: string): string { } /** - * A development-oriented contribution that toggles built-in CSS style-override - * modules on or off based on the `workbench.experimental.styleOverrides` setting. - * - * Unlike a free-form CSS loader, the available styles are fixed and shipped with - * the product. The setting merely selects which of the predefined modules are - * active, so the styling itself cannot be changed by end users. + * A contribution that toggles the built-in CSS style-override modules on or off + * as a group, based on the `workbench.experimental.modernUI` setting. When the + * Modern UI Update experiment is enabled, all modules are applied together; + * otherwise none are. */ export class StyleOverridesContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.styleOverrides'; - private readonly knownModuleIds = new Set(STYLE_OVERRIDE_MODULES.map(m => m.id)); private readonly knownClassNames = STYLE_OVERRIDE_MODULES.map(m => classNameFor(m.id)); - private readonly layoutAffectingClassNames = new Set(STYLE_OVERRIDE_MODULES.filter(m => m.layoutAffecting).map(m => classNameFor(m.id))); + private readonly hasLayoutAffectingModule = STYLE_OVERRIDE_MODULES.some(m => m.layoutAffecting); /** Whether a layout-affecting module was active at the last applied selection. */ private layoutAffectingActive = false; @@ -135,11 +84,17 @@ export class StyleOverridesContribution extends Disposable implements IWorkbench // A config change re-applies to every container (the global `update()` // covers all windows, including auxiliary ones). this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(SETTING_ID)) { + if (e.affectsConfiguration(LayoutSettings.MODERN_UI)) { this.update(); // Some modules drive layout-affecting CSS variables (e.g. the - // `paneHeaders` header size). Only relayout when one of those is - // toggled, to avoid an unnecessary full workbench relayout. + // `paneHeaders` header size) that the JS layout reads back, so a + // relayout is required once the classes are toggled. The base layout + // (`Layout`) also relayouts for this same setting, but its listener + // runs earlier (startup) than this contribution's (Restored phase), + // so that pass happens *before* these classes are applied and reads + // stale values. This relayout therefore runs last and is the + // authoritative one — do not remove it as "redundant". Guarded so it + // only fires when the enabled state actually flips. const layoutAffectingActive = this.hasActiveLayoutAffectingModule(); if (layoutAffectingActive !== this.layoutAffectingActive) { this.layoutAffectingActive = layoutAffectingActive; @@ -151,45 +106,30 @@ export class StyleOverridesContribution extends Disposable implements IWorkbench // Apply the current selection to windows opened after startup (e.g. // auxiliary windows). Subsequent config changes are handled by `update()`. this._register(this.layoutService.onDidAddContainer(({ container }) => { - this.applyTo(container, this.activeClassNames()); + this.applyTo(container, this.isEnabled()); })); this.update(); } - private activeClassNames(): Set { - const selection = this.configurationService.getValue(SETTING_ID); - const active = new Set(); - if (Array.isArray(selection)) { - for (const id of selection) { - if (this.knownModuleIds.has(id)) { - active.add(classNameFor(id)); - } - } - } - return active; + private isEnabled(): boolean { + return this.configurationService.getValue(LayoutSettings.MODERN_UI) === true; } private hasActiveLayoutAffectingModule(): boolean { - const active = this.activeClassNames(); - for (const className of this.layoutAffectingClassNames) { - if (active.has(className)) { - return true; - } - } - return false; + return this.isEnabled() && this.hasLayoutAffectingModule; } private update(): void { - const active = this.activeClassNames(); + const enabled = this.isEnabled(); for (const container of this.layoutService.containers) { - this.applyTo(container, active); + this.applyTo(container, enabled); } } - private applyTo(container: HTMLElement, active: Set): void { + private applyTo(container: HTMLElement, enabled: boolean): void { for (const className of this.knownClassNames) { - container.classList.toggle(className, active.has(className)); + container.classList.toggle(className, enabled); } } @@ -204,23 +144,5 @@ export class StyleOverridesContribution extends Disposable implements IWorkbench } } -Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ - ...workbenchConfigurationNodeBase, - properties: { - [SETTING_ID]: { - type: 'array', - items: { - type: 'string', - enum: STYLE_OVERRIDE_MODULES.map(m => m.id), - enumDescriptions: STYLE_OVERRIDE_MODULES.map(m => `${m.label}: ${m.description}`) - }, - uniqueItems: true, - default: [], - tags: ['experimental'], - markdownDescription: localize('styleOverrides', "Enables one or more built-in style-override modules that adjust the appearance of the workbench. Each module is a predefined set of styles that ships with the product; only modules from this list can be enabled and the styles themselves cannot be customized. Leave empty to disable all overrides. This is an experimental setting intended for trying out style ideas.") - } - } -}); - Registry.as(WorkbenchExtensions.Workbench) .registerWorkbenchContribution(StyleOverridesContribution, LifecyclePhase.Restored); diff --git a/src/vs/workbench/services/accounts/browser/managedSettings.ts b/src/vs/workbench/services/accounts/browser/managedSettings.ts index e14435565662b7..48131260194e5b 100644 --- a/src/vs/workbench/services/accounts/browser/managedSettings.ts +++ b/src/vs/workbench/services/accounts/browser/managedSettings.ts @@ -4,10 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { IPolicyData } from '../../../../base/common/defaultAccount.js'; -import { IExtraKnownMarketplaceEntry, extraKnownMarketplacesToConfigDict } from '../../../../base/common/managedSettings.js'; +import { IExtraKnownMarketplaceEntry, IStrictMarketplaceSource, extraKnownMarketplacesToConfigDict } from '../../../../base/common/managedSettings.js'; import { ManagedSettingValue } from '../../../../base/common/policy.js'; import { isObject, isString } from '../../../../base/common/types.js'; -import { COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, flattenManagedSettings } from '../../../../platform/policy/common/copilotManagedSettings.js'; +import { COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, COPILOT_STRICT_MARKETPLACES_KEY, flattenManagedSettings } from '../../../../platform/policy/common/copilotManagedSettings.js'; /** * Response shape from the Copilot `/copilot_internal/managed_settings` endpoint. @@ -30,7 +30,7 @@ export interface IManagedSettingsResponse { | { readonly source: 'github'; readonly repo: string; readonly ref?: string } | { readonly source: 'git'; readonly url: string; readonly ref?: string }; }>; - readonly strictKnownMarketplaces?: boolean; + readonly strictKnownMarketplaces?: readonly IStrictMarketplaceSource[]; /** Any unknown keys in the response are accepted for forward compatibility. */ readonly [key: string]: unknown; } @@ -45,13 +45,14 @@ export interface IManagedSettingsResponse { * dropping undeclared or type-mismatched keys happens later, at the * `projectManagedSettings` step. * - * - Scalar leaves (`permissions.*`, `strictKnownMarketplaces`, and any - * forward-compatible scalar keys) are flattened into dot-separated keys. - * - Structured settings (`enabledPlugins`, `extraKnownMarketplaces`) are carried - * as canonical JSON strings under a single key each — the same shape an admin - * authors via native MDM. `PolicyConfiguration` parses the JSON back into the - * object-typed setting on read. `extraKnownMarketplaces` is normalized from the - * API's `Record` map to the `{ [name]: url-or-shorthand }` dict. + * - Scalar leaves (`permissions.*` and any forward-compatible scalar keys) are + * flattened into dot-separated keys. + * - Structured settings (`enabledPlugins`, `extraKnownMarketplaces`, + * `strictKnownMarketplaces`) are carried as canonical JSON strings under a + * single key each — the same shape an admin authors via native MDM. + * `PolicyConfiguration` parses the JSON back into the object-typed setting on + * read. `extraKnownMarketplaces` is normalized from the API's + * `Record` map to the `{ [name]: url-or-shorthand }` dict. * * Malformed marketplace entries are dropped (with an optional warning via * {@link onWarn}) rather than throwing, so a bad enterprise settings file degrades @@ -60,7 +61,7 @@ export interface IManagedSettingsResponse { * Exported for unit-testing the shape transformation independently of network I/O. */ export function adaptManagedSettings(response: IManagedSettingsResponse, onWarn?: (msg: string) => void): Partial { - const { enabledPlugins, extraKnownMarketplaces, ...rest } = response; + const { enabledPlugins, extraKnownMarketplaces, strictKnownMarketplaces, ...rest } = response; const managedSettings: Record = { ...flattenManagedSettings(rest) }; @@ -68,6 +69,10 @@ export function adaptManagedSettings(response: IManagedSettingsResponse, onWarn? managedSettings[COPILOT_ENABLED_PLUGINS_KEY] = JSON.stringify(enabledPlugins); } + if (Array.isArray(strictKnownMarketplaces)) { + managedSettings[COPILOT_STRICT_MARKETPLACES_KEY] = JSON.stringify(strictKnownMarketplaces); + } + const marketplaceDict = extraKnownMarketplacesToConfigDict(normalizeExtraKnownMarketplaces(extraKnownMarketplaces, onWarn)); if (marketplaceDict) { managedSettings[COPILOT_EXTRA_MARKETPLACES_KEY] = JSON.stringify(marketplaceDict); diff --git a/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts b/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts index 27dbce49ba97d3..067eda3a3392c3 100644 --- a/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts +++ b/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts @@ -41,12 +41,19 @@ suite('adaptManagedSettings', () => { }); }); - test('carries strictKnownMarketplaces as a boolean managed setting', () => { - assert.deepStrictEqual(adaptManagedSettings({ strictKnownMarketplaces: true }), { - managedSettings: { strictKnownMarketplaces: true }, + test('carries strictKnownMarketplaces as a canonical JSON string under a single key', () => { + assert.deepStrictEqual(adaptManagedSettings({ + strictKnownMarketplaces: [{ source: 'github', repo: 'rwoll/markdown-review' }], + }), { + managedSettings: { + strictKnownMarketplaces: '[{"source":"github","repo":"rwoll/markdown-review"}]', + }, }); - assert.deepStrictEqual(adaptManagedSettings({ strictKnownMarketplaces: false }), { - managedSettings: { strictKnownMarketplaces: false }, + }); + + test('carries an empty strictKnownMarketplaces array (lockdown) as a JSON string', () => { + assert.deepStrictEqual(adaptManagedSettings({ strictKnownMarketplaces: [] }), { + managedSettings: { strictKnownMarketplaces: '[]' }, }); }); @@ -95,10 +102,10 @@ suite('adaptManagedSettings', () => { extraKnownMarketplaces: { 'a': { source: { source: 'github', repo: 'a/b', ref: 'r' } }, }, - strictKnownMarketplaces: true, + strictKnownMarketplaces: [{ source: 'github', repo: 'a/b' }], }), { managedSettings: { - strictKnownMarketplaces: true, + strictKnownMarketplaces: '[{"source":"github","repo":"a/b"}]', enabledPlugins: '{"p@m":true}', extraKnownMarketplaces: '{"a":"a/b#r"}', }, @@ -108,11 +115,11 @@ suite('adaptManagedSettings', () => { test('resilience: unknown scalar keys flatten into the bag alongside structured keys', () => { assert.deepStrictEqual(adaptManagedSettings({ enabledPlugins: { 'p@m': true }, - strictKnownMarketplaces: false, + strictKnownMarketplaces: [], joshsFakeSetting: true, } as IManagedSettingsResponse), { managedSettings: { - strictKnownMarketplaces: false, + strictKnownMarketplaces: '[]', joshsFakeSetting: true, enabledPlugins: '{"p@m":true}', }, diff --git a/src/vs/workbench/services/layout/browser/layoutService.ts b/src/vs/workbench/services/layout/browser/layoutService.ts index 395c0573026cc5..c8b014a5e6c0f4 100644 --- a/src/vs/workbench/services/layout/browser/layoutService.ts +++ b/src/vs/workbench/services/layout/browser/layoutService.ts @@ -50,15 +50,15 @@ export const enum LayoutSettings { COMMAND_CENTER = 'window.commandCenter', LAYOUT_ACTIONS = 'workbench.layoutControl.enabled', SHADOWS = 'workbench.shadows', - FLOATING_PANELS = 'workbench.experimental.floatingPanels' + MODERN_UI = 'workbench.experimental.modernUI' } /** - * The margin (in pixels) reserved on each side of a part when the floating - * panels experiment (`LayoutSettings.FLOATING_PANELS`) is enabled. Parts grow - * or shrink their content by this amount to leave room for the card margin and - * border applied in CSS (`part.css`, `.floating-panels`). This value must be - * kept in sync with the `--floating-panel-margin` custom property defined there. + * The margin (in pixels) reserved on each side of a part when the Modern UI Update + * experiment (`LayoutSettings.MODERN_UI`) is enabled. Parts grow or shrink their + * content by this amount to leave room for the margin/border applied in CSS + * (`src/vs/workbench/browser/media/floatingPanels.css`, `.floating-panels`). + * Keep in sync with the `--vscode-spacing-size60` (6px) token used there. */ export const FLOATING_PANEL_MARGIN = 6; @@ -225,9 +225,11 @@ export interface IWorkbenchLayoutService extends ILayoutService { hasFocus(part: Parts): boolean; /** - * Returns whether the floating panels experiment is enabled for this - * workbench. Always `false` for the agents window, which has its own floating - * card design and must not apply the experiment's content insets. + * Returns whether the floating panels presentation is enabled for this + * workbench, i.e. whether the Modern UI Update experiment + * (`LayoutSettings.MODERN_UI`) is on. Always `false` for the agents window, + * which has its own floating card design and must not apply the experiment's + * content insets. */ isFloatingPanelsEnabled(): boolean; diff --git a/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts index b2771c0c25c6e0..2ccad35322e719 100644 --- a/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts @@ -144,6 +144,28 @@ suite('MultiplexPolicyService', () => { value: policyData => policyData.cloud_session_storage_enabled === false ? false : undefined, } }, + 'setting.G': { + 'type': ['array', 'null'], + 'default': null, + policy: { + name: 'PolicySettingG', + category: PolicyCategory.Extensions, + minimumVersion: '1.0.0', + localization: { description: { key: '', value: '' } }, + value: policyData => policyData.chat_preview_features_enabled === false ? JSON.stringify(['policyValueG1', 'policyValueG2']) : undefined, + } + }, + 'setting.H': { + 'type': ['array', 'null'], + 'default': null, + policy: { + name: 'PolicySettingH', + category: PolicyCategory.Extensions, + minimumVersion: '1.0.0', + localization: { description: { key: '', value: '' } }, + value: policyData => policyData.chat_preview_features_enabled === false ? JSON.stringify([]) : undefined, + } + }, } }; @@ -369,4 +391,41 @@ suite('MultiplexPolicyService', () => { assert.strictEqual(policyService.getPolicyValue('PolicySettingF'), undefined); assert.strictEqual(policyConfiguration.configurationModel.getValue('setting.F'), undefined); }); + + test('union-typed (array | null) policy registers and parses JSON string value', async () => { + await clear(); + + const policyData: IPolicyData = { chat_preview_features_enabled: false }; + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT, policyData)); + await defaultAccountService.refresh(); + + await policyConfiguration.initialize(); + + assert.strictEqual(policyService.getPolicyValue('PolicySettingG'), JSON.stringify(['policyValueG1', 'policyValueG2'])); + assert.deepStrictEqual(policyConfiguration.configurationModel.getValue('setting.G'), ['policyValueG1', 'policyValueG2']); + }); + + test('union-typed (array | null) policy preserves an empty array (lockdown) distinct from unset', async () => { + await clear(); + + // Policy set to an empty array (e.g. a lockdown allowlist): must round-trip to `[]`, not `undefined`. + const setPolicyData: IPolicyData = { chat_preview_features_enabled: false }; + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT, setPolicyData)); + await defaultAccountService.refresh(); + await policyConfiguration.initialize(); + + assert.strictEqual(policyService.getPolicyValue('PolicySettingH'), JSON.stringify([])); + assert.deepStrictEqual(policyConfiguration.configurationModel.getValue('setting.H'), []); + }); + + test('union-typed (array | null) policy unset leaves the setting at its default (distinct from empty array)', async () => { + await clear(); + + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT, {})); + await defaultAccountService.refresh(); + await policyConfiguration.initialize(); + + assert.strictEqual(policyService.getPolicyValue('PolicySettingH'), undefined); + assert.strictEqual(policyConfiguration.configurationModel.getValue('setting.H'), undefined); + }); }); diff --git a/src/vs/workbench/test/browser/parts/activitybar/activitybarPart.test.ts b/src/vs/workbench/test/browser/parts/activitybar/activitybarPart.test.ts index ff6ae3e61be9b5..d20d907c18e4a0 100644 --- a/src/vs/workbench/test/browser/parts/activitybar/activitybarPart.test.ts +++ b/src/vs/workbench/test/browser/parts/activitybar/activitybarPart.test.ts @@ -74,7 +74,7 @@ suite('ActivitybarPart', () => { function createActivitybarPart(compact: boolean, floatingPanelsEnabled = false): { part: ActivitybarPart; configService: TestConfigurationService; layoutService: TestFloatingPanelsLayoutService } { const configService = new TestConfigurationService({ [LayoutSettings.ACTIVITY_BAR_COMPACT]: compact, - [LayoutSettings.FLOATING_PANELS]: floatingPanelsEnabled, + [LayoutSettings.MODERN_UI]: floatingPanelsEnabled, }); const storageService = disposables.add(new TestStorageService()); const themeService = new TestThemeService(); @@ -244,8 +244,8 @@ suite('ActivitybarPart', () => { disposables.add(part.onDidChange(e => events.push(e))); layoutService.floatingPanelsEnabled = true; - configService.setUserConfiguration(LayoutSettings.FLOATING_PANELS, true); - fireConfigChange(configService, LayoutSettings.FLOATING_PANELS); + configService.setUserConfiguration(LayoutSettings.MODERN_UI, true); + fireConfigChange(configService, LayoutSettings.MODERN_UI); assert.deepStrictEqual(events, [undefined]); assert.strictEqual(part.minimumWidth, ActivitybarPart.ACTIVITYBAR_WIDTH + ActivitybarPart.FLOATING_MARGIN); diff --git a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts index f035be815b0f77..a9aa87a33c1238 100644 --- a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts +++ b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts @@ -557,7 +557,7 @@ export function setup(logger: Logger) { }); }); - describe.skip('Agents Window (Codex)', () => { + describe('Agents Window (Codex)', () => { const codex = setupAgentHostSuite(logger, { serverLabel: 'Codex',