diff --git a/.github/workflows/no-engineering-system-changes.yml b/.github/workflows/no-engineering-system-changes.yml index b567d1258844d..77c04be07181d 100644 --- a/.github/workflows/no-engineering-system-changes.yml +++ b/.github/workflows/no-engineering-system-changes.yml @@ -88,22 +88,54 @@ jobs: fi env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Allow cherry-pick bot PRs + id: cherry_pick_exception + if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && steps.bot_field_exception.outputs.allowed != 'true' && github.event.pull_request.user.login == 'vs-code-engineering[bot]' && startsWith(github.event.pull_request.title, '[cherry-pick]') }} + run: | + # The label is applied ~2s after PR creation, so the webhook payload + # may not include it yet. Fetch current labels from the API with retries. + for attempt in 1 2 3; do + if gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels --jq '.[].name' | grep -qx 'cherry-pick-artifact'; then + echo "Cherry-pick PR by vs-code-engineering bot with cherry-pick-artifact label — allowing" + echo "allowed=true" >> $GITHUB_OUTPUT + exit 0 + fi + if [ "$attempt" -lt 3 ]; then + echo "cherry-pick-artifact label not present yet (attempt $attempt/3); retrying in 2s" + sleep 2 + fi + done + echo "Cherry-pick PR by bot but missing cherry-pick-artifact label after retries — not allowed" + echo "allowed=false" >> $GITHUB_OUTPUT + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Determine if engineering system changes are allowed + id: allowed + if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' }} + run: | + if [[ "${{ steps.bot_field_exception.outputs.allowed }}" == "true" || "${{ steps.cherry_pick_exception.outputs.allowed }}" == "true" ]]; then + echo "Engineering system changes are allowed by an exception" + echo "blocked=false" >> $GITHUB_OUTPUT + else + echo "No exception applies — enforcing restrictions" + echo "blocked=true" >> $GITHUB_OUTPUT + fi - name: Prevent Copilot from modifying engineering systems - if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && steps.bot_field_exception.outputs.allowed != 'true' && github.event.pull_request.user.login == 'Copilot' }} + if: ${{ steps.allowed.outputs.blocked == 'true' && github.event.pull_request.user.login == 'Copilot' }} run: | echo "Copilot is not allowed to modify .github/workflows, build folder files, or package.json files." echo "If you need to update engineering systems, please do so manually or through authorized means." exit 1 - uses: octokit/request-action@b91aabaa861c777dcdb14e2387e30eddf04619ae # v3.0.0 id: get_permissions - if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && steps.bot_field_exception.outputs.allowed != 'true' && github.event.pull_request.user.login != 'Copilot' }} + if: ${{ steps.allowed.outputs.blocked == 'true' && github.event.pull_request.user.login != 'Copilot' }} with: route: GET /repos/microsoft/vscode/collaborators/${{ github.event.pull_request.user.login }}/permission env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Set control output variable id: control - if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && steps.bot_field_exception.outputs.allowed != 'true' && github.event.pull_request.user.login != 'Copilot' }} + if: ${{ steps.allowed.outputs.blocked == 'true' && github.event.pull_request.user.login != 'Copilot' }} run: | echo "user: ${{ github.event.pull_request.user.login }}" echo "role: ${{ fromJson(steps.get_permissions.outputs.data).permission }}" @@ -111,7 +143,7 @@ jobs: echo "should_run: ${{ !contains(fromJson('["admin", "maintain", "write"]'), fromJson(steps.get_permissions.outputs.data).permission) }}" echo "should_run=${{ !contains(fromJson('["admin", "maintain", "write"]'), fromJson(steps.get_permissions.outputs.data).permission) && github.event.pull_request.user.login != 'dependabot[bot]' }}" >> $GITHUB_OUTPUT - name: Check for engineering system changes - if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && steps.bot_field_exception.outputs.allowed != 'true' && steps.control.outputs.should_run == 'true' }} + if: ${{ steps.allowed.outputs.blocked == 'true' && steps.control.outputs.should_run == 'true' }} run: | echo "Changes to .github/workflows/, build/ folder files, or package.json files aren't allowed in PRs." exit 1 diff --git a/build/azure-pipelines/product-copilot-recovery.yml b/build/azure-pipelines/product-copilot-recovery.yml index 92078b5154d53..056bdedaed7b3 100644 --- a/build/azure-pipelines/product-copilot-recovery.yml +++ b/build/azure-pipelines/product-copilot-recovery.yml @@ -71,3 +71,4 @@ extends: publishExtension: ${{ parameters.publishExtension }} ghReleasePublishVSIX: true + ghTagPrefix: 'copilot/' diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index e04a38c9fbe30..d8d040444942d 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -676,10 +676,10 @@ "name": "vs/sessions/contrib/files", "project": "vscode-sessions" }, - { - "name": "vs/sessions/contrib/policyBlocked", - "project": "vscode-sessions" - }, + { + "name": "vs/sessions/contrib/policyBlocked", + "project": "vscode-sessions" + }, { "name": "vs/sessions/contrib/git", "project": "vscode-sessions" @@ -711,6 +711,10 @@ { "name": "vs/sessions/contrib/tunnelHost", "project": "vscode-sessions" + }, + { + "name": "vs/sessions/contrib/editor", + "project": "vscode-sessions" } ] } diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 59dfc331f3501..f0be1a9ea91d7 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -941,6 +941,7 @@ "--inline-chat-frame-progress", "--insert-border-color", "--last-tab-margin-right", + "--last-tab-layout-actions-width", "--list-scroll-right-offset", "--monaco-monospace-font", "--monaco-monospace-font", diff --git a/extensions/copilot/build/pre-release.yml b/extensions/copilot/build/pre-release.yml index 00c83bbbafdf2..06fb599900283 100644 --- a/extensions/copilot/build/pre-release.yml +++ b/extensions/copilot/build/pre-release.yml @@ -257,3 +257,4 @@ extends: publishExtension: ${{ parameters.publishExtension }} ghReleasePublishVSIX: true + ghTagPrefix: 'copilot/' diff --git a/extensions/copilot/build/release.yml b/extensions/copilot/build/release.yml index 7e3022928edd8..7bfcd30b6c705 100644 --- a/extensions/copilot/build/release.yml +++ b/extensions/copilot/build/release.yml @@ -243,3 +243,4 @@ extends: publishExtension: ${{ parameters.publishExtension }} ghReleasePublishVSIX: true + ghTagPrefix: 'copilot/' diff --git a/extensions/copilot/chat-lib/package-lock.json b/extensions/copilot/chat-lib/package-lock.json index cc1de521b7e13..7e8ac762088c6 100644 --- a/extensions/copilot/chat-lib/package-lock.json +++ b/extensions/copilot/chat-lib/package-lock.json @@ -30,7 +30,6 @@ "@anthropic-ai/sdk": "^0.82.0", "@octokit/types": "^14.1.0", "@types/node": "^22.16.3", - "@types/vscode": "^1.109.0", "copyfiles": "^2.4.1", "dotenv": "^17.2.0", "npm-run-all": "^4.1.5", @@ -1221,13 +1220,6 @@ "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.0.3.tgz", "integrity": "sha512-F/IjUGnV6pIN7R4ZV4npHJVoNtaLZWvb+2/9gctxjb99wkpI7Ozg8VPogwDiTRyjLwZXAYxjvdg1KS8LTHKdDA==" }, - "node_modules/@types/vscode": { - "version": "1.109.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.109.0.tgz", - "integrity": "sha512-0Pf95rnwEIwDbmXGC08r0B4TQhAbsHQ5UyTIgVgoieDe4cOnf92usuR5dEczb6bTKEp7ziZH4TV1TRGPPCExtw==", - "dev": true, - "license": "MIT" - }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -1566,9 +1558,9 @@ "dev": true }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -3234,9 +3226,9 @@ } }, "node_modules/minimatch/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4830,13 +4822,13 @@ } }, "node_modules/vite": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", - "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -4927,6 +4919,490 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, "node_modules/vitest": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index 42ffd2a42a190..394b8dbcd09c1 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.2.98", + "@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.28", @@ -180,13 +180,13 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.2.98", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.98.tgz", - "integrity": "sha512-pWUx+xY21rKy5wvX0eBZja7p8J5ykOYaHsykvdj9nkTbAVXmP1WusI1mP6jbBByJ8uBJeBc4beAPSZIFcdIpTA==", + "version": "0.2.112", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.112.tgz", + "integrity": "sha512-vMFoiDKlOive8p3tphpV1gQaaytOipwGJ+uw9mvvaLQUODSC2+fCdRDAY25i2Tsv+lOtxzXBKctmaDuWqZY7ig==", "license": "SEE LICENSE IN README.md", "dependencies": { - "@anthropic-ai/sdk": "^0.80.0", - "@modelcontextprotocol/sdk": "^1.27.1" + "@anthropic-ai/sdk": "^0.81.0", + "@modelcontextprotocol/sdk": "^1.29.0" }, "engines": { "node": ">=18.0.0" @@ -207,9 +207,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/sdk": { - "version": "0.80.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.80.0.tgz", - "integrity": "sha512-WeXLn7zNVk3yjeshn+xZHvld6AoFUOR3Sep6pSoHho5YbSi6HwcirqgPA5ccFuW8QTVJAAU7N8uQQC6Wa9TG+g==", + "version": "0.81.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.81.0.tgz", + "integrity": "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw==", "license": "MIT", "dependencies": { "json-schema-to-ts": "^3.1.1" diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 2f5d24135b8b5..9028db141f0ff 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -3968,15 +3968,6 @@ { "id": "advanced", "properties": { - "github.copilot.chat.sessionSearch.cloudSync.enabled": { - "type": "boolean", - "default": false, - "markdownDescription": "%github.copilot.config.sessionSearch.cloudSync.enabled%", - "tags": [ - "advanced", - "onExp" - ] - }, "github.copilot.chat.reasoningEffortOverride": { "type": [ "string", @@ -6417,7 +6408,7 @@ "zod": "3.25.76" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.2.98", + "@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.28", diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 48568dfeee76e..0b4674414975e 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -173,7 +173,7 @@ "copilot.chronicle.tips.description": "Get personalized tips based on your Copilot usage patterns", "github.copilot.config.sessionSearch.enabled": "Enable session search and /chronicle commands. This is a team-internal setting.", "github.copilot.config.sessionSearch.localIndex.enabled": "Enable local session tracking. When enabled, Copilot tracks session data locally for /chronicle commands.", - "github.copilot.config.sessionSearch.cloudSync.enabled": "Enable cloud sync for session data. When enabled, chat session data is synced to the cloud for cross-device querying.", + "github.copilot.config.sessionSearch.cloudSync.enabled": "Enable cloud sync for session data. When enabled, session data is synced to your Copilot account for cross-device access.", "github.copilot.config.sessionSearch.cloudSync.excludeRepositories": "Repository patterns to exclude from cloud sync. Use exact `owner/repo` names or glob patterns like `my-org/*`. Sessions from matching repos will only be stored locally.", "copilot.workspace.explain.description": "Explain how the code in your active editor works", "copilot.workspace.edit.description": "Edit files in your workspace", diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/permissionHelpers.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/permissionHelpers.ts index db8bea59cc5c9..0d92735543381 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/permissionHelpers.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/permissionHelpers.ts @@ -28,7 +28,7 @@ type CoreTerminalConfirmationToolParams = { command: string | undefined; isBackground: boolean; }; -} +}; type CoreConfirmationToolParams = { tool: ToolName.CoreConfirmationTool; @@ -37,7 +37,7 @@ type CoreConfirmationToolParams = { message: string; confirmationType: 'basic'; }; -} +}; /** * The result of requesting permissions — the full union accepted by `Session.respondToPermission`. diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts index 2213eca09fa7a..8a9f81751d994 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts @@ -373,7 +373,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession public async setSessionParentId(sessionId: string, parentSessionId: string): Promise { await this._intialize.value; - await this.updateMetadataFields(sessionId, { parentSessionId }); + await this.updateMetadataFields(sessionId, { parentSessionId, kind: 'sub-session' }); } public async getSessionParentId(sessionId: string): Promise { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts index 6b24f11ea52be..ea01bc986cca6 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts @@ -1202,6 +1202,7 @@ class FakeGitService extends mock() { } override dispose(): void { + super.dispose(); this._onDidOpenRepository.dispose(); this._onDidCloseRepository.dispose(); } diff --git a/extensions/copilot/src/extension/chronicle/common/sessionIndexingPreference.ts b/extensions/copilot/src/extension/chronicle/common/sessionIndexingPreference.ts index f5816ce96404f..404adb2c5cb10 100644 --- a/extensions/copilot/src/extension/chronicle/common/sessionIndexingPreference.ts +++ b/extensions/copilot/src/extension/chronicle/common/sessionIndexingPreference.ts @@ -46,19 +46,9 @@ export class SessionIndexingPreference { /** * Check if cloud sync is enabled for a given repo. * Returns true if cloudSync.enabled is true AND the repo is not excluded. - * Check both new and old setting for backward compatibility. */ hasCloudConsent(repoNwo?: string): boolean { - let cloudEnabled: boolean; - if (this._configService.isConfigured(ConfigKey.Advanced.SessionSearchCloudSync)) { - // New key explicitly set by user — authoritative - cloudEnabled = this._configService.getConfig(ConfigKey.Advanced.SessionSearchCloudSync); - } else { - // Fall back to old internal key for existing users who haven't migrated yet - cloudEnabled = this._configService.getConfig(ConfigKey.TeamInternal.SessionSearchCloudSyncEnabled); - } - - if (!cloudEnabled) { + if (!this._configService.getConfig(ConfigKey.TeamInternal.SessionSearchCloudSyncEnabled)) { return false; } diff --git a/extensions/copilot/src/extension/chronicle/common/standupPrompt.ts b/extensions/copilot/src/extension/chronicle/common/standupPrompt.ts index f1cd693471d47..eaf68d3a42284 100644 --- a/extensions/copilot/src/extension/chronicle/common/standupPrompt.ts +++ b/extensions/copilot/src/extension/chronicle/common/standupPrompt.ts @@ -132,6 +132,7 @@ Standup for : - Key files: list 2-3 most important files changed - Tools used: mention key tools if visible (e.g., apply_patch, run_in_terminal, search) - PR: [#123](link) — merged/closed (if applicable) + - Sessions: \`session-id-1\`, \`session-id-2\` **🚧 In Progress** @@ -139,6 +140,7 @@ Standup for : - Summary of current work (1-2 sentences based on turn content) - Key files: list 2-3 most important files being worked on - PR: [#789](link) — draft/open (if applicable) + - Sessions: \`session-id\` Formatting rules: - Use the turn data (user messages AND assistant responses) to understand WHAT was done, not just that something happened diff --git a/extensions/copilot/src/extension/chronicle/common/test/sessionIndexingPreference.spec.ts b/extensions/copilot/src/extension/chronicle/common/test/sessionIndexingPreference.spec.ts index a3788ec5897d3..e5b07ca575163 100644 --- a/extensions/copilot/src/extension/chronicle/common/test/sessionIndexingPreference.spec.ts +++ b/extensions/copilot/src/extension/chronicle/common/test/sessionIndexingPreference.spec.ts @@ -9,25 +9,16 @@ import { SessionIndexingPreference } from '../sessionIndexingPreference'; function createMockConfigService(opts: { localIndexEnabled?: boolean; cloudSyncEnabled?: boolean; - cloudSyncPublicEnabled?: boolean; excludeRepositories?: string[]; } = {}) { const configs: Record = {}; // Map by fullyQualifiedId configs['github.copilot.chat.advanced.sessionSearch.localIndex.enabled'] = opts.localIndexEnabled ?? false; configs['github.copilot.chat.advanced.sessionSearch.cloudSync.enabled'] = opts.cloudSyncEnabled ?? false; - configs['github.copilot.chat.sessionSearch.cloudSync.enabled'] = opts.cloudSyncPublicEnabled ?? false; configs['github.copilot.chat.advanced.sessionSearch.cloudSync.excludeRepositories'] = opts.excludeRepositories ?? []; - // Track which keys are explicitly configured (set by the user) - const configuredKeys = new Set(); - if (opts.cloudSyncPublicEnabled !== undefined) { - configuredKeys.add('github.copilot.chat.sessionSearch.cloudSync.enabled'); - } - return { getConfig: (key: { fullyQualifiedId: string }) => configs[key.fullyQualifiedId], - isConfigured: (key: { fullyQualifiedId: string }) => configuredKeys.has(key.fullyQualifiedId), } as unknown as import('../../../../platform/configuration/common/configurationService').IConfigurationService; } @@ -90,19 +81,4 @@ describe('SessionIndexingPreference', () => { expect(pref.hasCloudConsent('private-org/repo-b')).toBe(false); expect(pref.hasCloudConsent('public-org/repo-a')).toBe(true); }); - - it('hasCloudConsent uses new public key when explicitly configured', () => { - const pref = new SessionIndexingPreference(createMockConfigService({ - cloudSyncPublicEnabled: true, - })); - expect(pref.hasCloudConsent()).toBe(true); - }); - - it('hasCloudConsent new public key overrides old internal key', () => { - const pref = new SessionIndexingPreference(createMockConfigService({ - cloudSyncEnabled: true, - cloudSyncPublicEnabled: false, - })); - expect(pref.hasCloudConsent()).toBe(false); - }); }); diff --git a/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts b/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts index 1779504dae207..c469e4e8d0fcd 100644 --- a/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts +++ b/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts @@ -120,16 +120,12 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr // Only set up span listener when both local index and cloud sync are enabled. // Uses autorun to react if settings change at runtime. - // Both new and old settings taken into account for backward compatibility const localEnabled = this._configService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.SessionSearchLocalIndexEnabled, this._expService); - const cloudEnabledInternal = this._configService.getConfigObservable(ConfigKey.TeamInternal.SessionSearchCloudSyncEnabled); - const cloudEnabledPublic = this._configService.getConfigObservable(ConfigKey.Advanced.SessionSearchCloudSync); + const cloudEnabled = this._configService.getConfigObservable(ConfigKey.TeamInternal.SessionSearchCloudSyncEnabled); const spanListenerStore = this._register(new DisposableStore()); this._register(autorun(reader => { spanListenerStore.clear(); - const publicValue = cloudEnabledPublic.read(reader); - const cloudEnabled = this._configService.isConfigured(ConfigKey.Advanced.SessionSearchCloudSync) ? publicValue : cloudEnabledInternal.read(reader); - if (!localEnabled.read(reader) || !cloudEnabled) { + if (!localEnabled.read(reader) || !cloudEnabled.read(reader)) { return; } @@ -268,10 +264,9 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr } // Only export remotely if the user has cloud consent for this repo - // Also require localIndex to be enabled (team-internal gate) as defense-in-depth const repoNwo = `${repo.owner}/${repo.repo}`; - if (!this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.SessionSearchLocalIndexEnabled, this._expService) || !this._indexingPreference.hasCloudConsent(repoNwo)) { + if (!this._indexingPreference.hasCloudConsent(repoNwo)) { this._disabledSessions.add(sessionId); return; } diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/codeReferencing/index.ts b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/codeReferencing/index.ts index 18464815c0744..fc61f1fffd6ca 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/codeReferencing/index.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/codeReferencing/index.ts @@ -21,7 +21,7 @@ export class CodeReference implements IDisposable { constructor( @IInstantiationService private readonly _instantiationService: IInstantiationService, - @ICompletionsRuntimeModeService readonly _runtimeMode: ICompletionsRuntimeModeService, + @ICompletionsRuntimeModeService private readonly _runtimeMode: ICompletionsRuntimeModeService, @ICompletionsLogTargetService private readonly _logTarget: ICompletionsLogTargetService, @IAuthenticationService private readonly _authenticationService: IAuthenticationService, ) { } diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/statusBar.ts b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/statusBar.ts index b936070cf03b3..aaf849637bcc1 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/statusBar.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/statusBar.ts @@ -21,8 +21,8 @@ export class CopilotStatusBar extends StatusReporter implements IDisposable { constructor( id: string, - @ICompletionsExtensionStatus readonly extensionStatusService: ICompletionsExtensionStatus, - @IInstantiationService readonly instantiationService: IInstantiationService, + @ICompletionsExtensionStatus private readonly extensionStatusService: ICompletionsExtensionStatus, + @IInstantiationService private readonly instantiationService: IInstantiationService, ) { super(); diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/experiments/featuresService.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/experiments/featuresService.ts index 62dc7c95b3813..f15af99a66c91 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/experiments/featuresService.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/experiments/featuresService.ts @@ -17,7 +17,7 @@ export type ContextProviderExpSettings = { excludeRelatedFiles: boolean; timeBudget: number; params?: Record; -} +}; export const ICompletionsFeaturesService = createServiceIdentifier('ICompletionsFeaturesService'); export interface ICompletionsFeaturesService { diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/prompt/similarFiles/openTabFiles.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/prompt/similarFiles/openTabFiles.ts index a9dc51ffc7e44..b81b6b6239207 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/prompt/similarFiles/openTabFiles.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/prompt/similarFiles/openTabFiles.ts @@ -15,7 +15,9 @@ import { } from './neighborFiles'; export class OpenTabFiles implements INeighborSource { - constructor(@ICompletionsTextDocumentManagerService readonly docManager: ICompletionsTextDocumentManagerService) { } + constructor( + @ICompletionsTextDocumentManagerService private readonly docManager: ICompletionsTextDocumentManagerService + ) { } private truncateDocs( docs: readonly TextDocumentContents[], diff --git a/extensions/copilot/src/extension/conversation/vscode-node/conversationFeature.ts b/extensions/copilot/src/extension/conversation/vscode-node/conversationFeature.ts index 098cb35b01dac..41f331a5522de 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/conversationFeature.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/conversationFeature.ts @@ -49,9 +49,9 @@ import { generateTerminalFixes, setLastCommandMatchResult } from './terminalFixG */ export class ConversationFeature implements IExtensionContribution { /** Disposables that exist for the lifetime of this object */ - private _disposables = new DisposableStore(); + private readonly _disposables = new DisposableStore(); /** Disposables that are cleared whenever feature enablement is toggled */ - private _activatedDisposables = new DisposableStore(); + private readonly _activatedDisposables = new DisposableStore(); /** For the conversation features to be enabled, the proxy needs to return a token with k/v pair: chat=1 */ public _enabled; /** The feature is marked as active the first time it is enabled. */ diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts index 10ca8aa3c2f8e..addf7d792d568 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts @@ -486,7 +486,6 @@ class LanguageModelAccessPromptBaseCountCache { export class CopilotLanguageModelWrapper extends Disposable { constructor( - @IExperimentationService readonly _expService: IExperimentationService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @IBlockedExtensionService private readonly _blockedExtensionService: IBlockedExtensionService, @IInstantiationService private readonly _instantiationService: IInstantiationService, diff --git a/extensions/copilot/src/extension/conversation/vscode-node/remoteAgents.ts b/extensions/copilot/src/extension/conversation/vscode-node/remoteAgents.ts index 24c3e41dbd18d..8b8539f7163d0 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/remoteAgents.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/remoteAgents.ts @@ -88,7 +88,7 @@ interface IGitHubRepositoryReference { } export class RemoteAgentContribution implements IDisposable { - private disposables = new DisposableStore(); + private readonly disposables = new DisposableStore(); private refreshRemoteAgentsP: Promise | undefined; private enabledSkillsPromise: Promise> | undefined; diff --git a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts index bcb109a7dd545..290c04e472df7 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts @@ -29,7 +29,7 @@ suite('CopilotLanguageModelWrapper', () => { instaService = accessor.get(IInstantiationService); } - suite('validateRequest - invalid', async () => { + suite('validateRequest - invalid', () => { let wrapper: CopilotLanguageModelWrapper; let endpoint: IChatEndpoint; setup(async () => { @@ -59,7 +59,7 @@ suite('CopilotLanguageModelWrapper', () => { }); }); - suite('validateRequest - valid', async () => { + suite('validateRequest - valid', () => { let wrapper: CopilotLanguageModelWrapper; let endpoint: IChatEndpoint; setup(async () => { diff --git a/extensions/copilot/src/extension/inlineEdits/test/vscode-node/isInlineSuggestion.spec.ts b/extensions/copilot/src/extension/inlineEdits/test/vscode-node/isInlineSuggestion.spec.ts index cc9a7b0226b9e..5bb09b87c47dd 100644 --- a/extensions/copilot/src/extension/inlineEdits/test/vscode-node/isInlineSuggestion.spec.ts +++ b/extensions/copilot/src/extension/inlineEdits/test/vscode-node/isInlineSuggestion.spec.ts @@ -101,8 +101,9 @@ suite('toInlineSuggestion', () => { assert.isDefined(result); // Range is an empty range at the cursor for a pure insertion assert.deepStrictEqual(result!.range, new Range(1, 15, 1, 15)); - // Text is prepended with the newline between cursor and original range - assert.strictEqual(result!.newText, '\n' + replaceText); + // Text is prepended with the newline between cursor and original range, + // and the trailing newline is dropped so we don't introduce a blank line. + assert.strictEqual(result!.newText, '\n' + replaceText.replace(/\r?\n$/, '')); }); test('should not use ghost text when inserting on next line when none empty', () => { @@ -149,7 +150,8 @@ suite('toInlineSuggestion', () => { const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText); assert.isDefined(result); assert.deepStrictEqual(result!.range, new Range(0, 13, 0, 13)); - assert.strictEqual(result!.newText, '\n' + replaceText); + // Trailing '\n' is dropped to avoid a spurious blank line. + assert.strictEqual(result!.newText, '\n' + replaceText.replace(/\r?\n$/, '')); }); test('multi-line insertion without trailing newline rejected when target line has content', () => { @@ -318,7 +320,8 @@ function createDocumentSymbol( const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText); assert.isDefined(result); assert.deepStrictEqual(result!.range, new Range(0, 6, 0, 6)); - assert.strictEqual(result!.newText, '\n\n'); + // Trailing '\n' is dropped — only the prepended newline remains. + assert.strictEqual(result!.newText, '\n'); }); test('next-line: cursor at end of an empty line', () => { @@ -330,7 +333,8 @@ function createDocumentSymbol( const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText); assert.isDefined(result); assert.deepStrictEqual(result!.range, new Range(0, 0, 0, 0)); - assert.strictEqual(result!.newText, '\nnew line\n'); + // Trailing '\n' is dropped to avoid a spurious blank line. + assert.strictEqual(result!.newText, '\nnew line'); }); test('next-line: range on line before cursor is rejected', () => { @@ -547,4 +551,118 @@ function createDocumentSymbol( assert.deepStrictEqual(result!.range, new Range(1, 0, 1, 0)); assert.strictEqual(result!.newText, ''); }); + + test('insertion on next line in fieldLabels object', () => { + const doc = `import React, { useState } from "react"; + +interface FormData { + firstName: string; + lastName: string; + password: string; + email: string; + age: string; + city: string; +} + +const initialFormData: FormData = { + firstName: "", + lastName: "", + password: "", + email: "", + age: "", + city: "", +}; + +const fieldLabels: Record = { + firstName: "First Name", + lastName: "Last Name", + email: "Email Address", + age: "Age", + city: "City", +}; +`; + const document = createTextDocumentData(Uri.from({ scheme: 'test', path: '/test/file.tsx' }), doc, 'typescriptreact').document; + const cursorPosition = new Position(22, 26); // end of ` lastName: "Last Name",` + const replaceRange = new Range(23, 0, 23, 0); + const replaceText = ' password: "Password",\n'; + + const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText, true); + assert.isDefined(result); + assert.deepStrictEqual(result!.range, new Range(22, 26, 22, 26)); + // Trailing '\n' is dropped because the original line terminator after + // the cursor is preserved. + assert.strictEqual(result!.newText, '\n password: "Password",'); + }); + + suite('CRLF', () => { + + function createCRLFDocument(lines: string[], languageId: string = 'typescript') { + return createTextDocumentData( + Uri.from({ scheme: 'test', path: '/test/file.ts' }), + lines.join('\r\n'), + languageId, + '\r\n', + ).document; + } + + test('next-line insertion: trailing CRLF is dropped (no dangling \\r)', () => { + const document = createCRLFDocument(['function foo(', '', 'other']); + const cursorPosition = new Position(0, 13); // end of "function foo(" + const replaceRange = new Range(1, 0, 1, 0); // empty line + const replaceText = ' a: string,\r\n b: number\r\n'; + + const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText); + assert.isDefined(result); + assert.deepStrictEqual(result!.range, new Range(0, 13, 0, 13)); + // The trailing CRLF must be stripped entirely; no dangling '\r' + // should leak into the suggestion text. + assert.strictEqual(result!.newText, '\r\n a: string,\r\n b: number'); + }); + + test('next-line insertion: trailing CRLF on non-empty target line', () => { + const document = createCRLFDocument(['function foo(', ')', 'other']); + const cursorPosition = new Position(0, 13); + const replaceRange = new Range(1, 0, 1, 0); + const replaceText = ' a: string,\r\n b: number\r\n'; + + const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText); + assert.isDefined(result); + assert.deepStrictEqual(result!.range, new Range(0, 13, 0, 13)); + assert.strictEqual(result!.newText, '\r\n a: string,\r\n b: number'); + }); + + test('next-line insertion: CRLF-only newText is fully stripped', () => { + const document = createCRLFDocument(['line 0', '', 'line 2']); + const cursorPosition = new Position(0, 6); + const replaceRange = new Range(1, 0, 1, 0); + const replaceText = '\r\n'; + + const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText); + assert.isDefined(result); + assert.deepStrictEqual(result!.range, new Range(0, 6, 0, 6)); + // Only the prepended CRLF between cursor and original range remains. + assert.strictEqual(result!.newText, '\r\n'); + }); + }); + + suite('multi-line range, no common prefix', () => { + + // Regression: when commonLen === 0 and the replaced text starts with '\n', + // `lastIndexOf('\n', -1)` would (incorrectly) clamp to 0 and report a + // match, causing the leading newline to be stripped — which can collapse + // the multi-line range into a same-line "suggestion" that the function + // then accepts. With the original substring-based check, no strip occurs + // and the result is `undefined`. + test('does not strip leading newline when nothing is in common', () => { + const document = createMockDocument(['abc', 'x', 'rest']); + // replacedText = '\nx', newText[0]='Y' differs from '\n', commonLen=0. + const replaceRange = new Range(0, 3, 1, 1); + const cursorPosition = new Position(1, 1); + const replaceText = 'Yx'; + + // The range cannot legitimately be collapsed to a single line, so + // the function must not synthesize a ghost-text suggestion. + assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText)); + }); + }); }); diff --git a/extensions/copilot/src/extension/inlineEdits/vscode-node/features/diagnosticsBasedCompletions/importDiagnosticsCompletionProvider.ts b/extensions/copilot/src/extension/inlineEdits/vscode-node/features/diagnosticsBasedCompletions/importDiagnosticsCompletionProvider.ts index 9c6c314649788..5b4e1d2cc3db9 100644 --- a/extensions/copilot/src/extension/inlineEdits/vscode-node/features/diagnosticsBasedCompletions/importDiagnosticsCompletionProvider.ts +++ b/extensions/copilot/src/extension/inlineEdits/vscode-node/features/diagnosticsBasedCompletions/importDiagnosticsCompletionProvider.ts @@ -361,7 +361,7 @@ export type ImportDetails = { labelShort: string; labelDeduped: string; importSource: ImportSource; -} +}; export interface ILanguageImportHandler { isImportDiagnostic(diagnostic: Diagnostic): boolean; diff --git a/extensions/copilot/src/extension/inlineEdits/vscode-node/isInlineSuggestion.ts b/extensions/copilot/src/extension/inlineEdits/vscode-node/isInlineSuggestion.ts index 3add1ecc00e94..b1876d17e0352 100644 --- a/extensions/copilot/src/extension/inlineEdits/vscode-node/isInlineSuggestion.ts +++ b/extensions/copilot/src/extension/inlineEdits/vscode-node/isInlineSuggestion.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { Position, Range, TextDocument } from 'vscode'; -import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRange'; export interface InlineSuggestionEdit { readonly range: Range; @@ -17,67 +16,112 @@ export interface InlineSuggestionEdit { * which is required for VS Code to render ghost text. */ export function toInlineSuggestion(cursorPos: Position, doc: TextDocument, range: Range, newText: string, advanced: boolean = true): InlineSuggestionEdit | undefined { - // If multi line insertion starts on the next line - // All new lines have to be newly created lines - if (range.isEmpty && cursorPos.line + 1 === range.start.line && range.start.character === 0 - && doc.lineAt(cursorPos.line).text.length === cursorPos.character // cursor is at the end of the line - && (newText.endsWith('\n') || (newText.includes('\n') && doc.lineAt(range.end.line).text.length === range.end.character)) // no remaining content after insertion - ) { - // Use an empty range at the cursor so the suggestion is a pure insertion - const adjustedRange = new Range(cursorPos, cursorPos); - const textBetweenCursorAndRange = doc.getText(new Range(cursorPos, range.start)); - return { range: adjustedRange, newText: textBetweenCursorAndRange + newText }; + // Special case: a multi-line insertion that starts on the line *after* the cursor + // can be re-expressed as a pure insertion at the cursor. + const nextLineInsertion = tryAdjustNextLineInsertion(cursorPos, doc, range, newText); + if (nextLineInsertion) { + return nextLineInsertion; } - if (advanced) { - // If the range spans multiple lines, try to reduce it by stripping a common - // prefix (up to a newline boundary) from the replaced text and newText. - if (range.start.line !== range.end.line) { - const fullReplacedText = doc.getText(range); - let commonLen = 0; - const maxLen = Math.min(fullReplacedText.length, newText.length); - while (commonLen < maxLen && fullReplacedText[commonLen] === newText[commonLen]) { - commonLen++; - } - const lastNewline = fullReplacedText.substring(0, commonLen).lastIndexOf('\n'); - if (lastNewline >= 0) { - const strippedLen = lastNewline + 1; - newText = newText.substring(strippedLen); - const newStart = doc.positionAt(doc.offsetAt(range.start) + strippedLen); - range = new Range(newStart, range.end); - } - } + // If the range spans multiple lines, try to collapse it to a single line by + // trimming a shared prefix up to a newline boundary. + if (advanced && range.start.line !== range.end.line) { + ({ range, newText } = stripCommonLinePrefix(doc, range, newText)); } + // Ghost text requires the edit to be on the cursor's line. if (range.start.line !== range.end.line || range.start.line !== cursorPos.line) { return undefined; } - const cursorOffset = doc.offsetAt(cursorPos); - const offsetRange = new OffsetRange(doc.offsetAt(range.start), doc.offsetAt(range.end)); - - const replacedText = offsetRange.substring(doc.getText()); + return validateSameLineGhostText(cursorPos, doc, range, newText); +} - const cursorOffsetInReplacedText = cursorOffset - offsetRange.start; - if (cursorOffsetInReplacedText < 0) { +/** + * If the cursor is at the end of a line and the edit is an empty-range insertion + * at column 0 of the next line, rewrite it as a pure insertion at the cursor + * position. This is allowed when either: + * - `newText` ends with a newline (any existing content on the target line is + * pushed onto the following line), or + * - `newText` contains a newline and the target line is fully consumed by the + * insertion (no leftover content after the insertion). + */ +function tryAdjustNextLineInsertion(cursorPos: Position, doc: TextDocument, range: Range, newText: string): InlineSuggestionEdit | undefined { + if (!range.isEmpty) { + return undefined; + } + if (cursorPos.line + 1 !== range.start.line || range.start.character !== 0) { return undefined; } + if (doc.lineAt(cursorPos.line).text.length !== cursorPos.character) { + return undefined; // cursor is not at the end of the line + } - const textBeforeCursorIsEqual = replacedText.substring(0, cursorOffsetInReplacedText) === newText.substring(0, cursorOffsetInReplacedText); - if (!textBeforeCursorIsEqual) { + const targetLineFullyConsumed = doc.lineAt(range.end.line).text.length === range.end.character; + const noLeftoverAfterInsertion = newText.endsWith('\n') || (newText.includes('\n') && targetLineFullyConsumed); + if (!noLeftoverAfterInsertion) { return undefined; } + // Use an empty range at the cursor so the suggestion is a pure insertion. + // The original line terminator between the cursor and `range.start` is preserved + // in the document, so: + // - prepend that terminator to `newText` (it lives in the doc, not in the edit), and + // - drop a single trailing line ending from `newText` to avoid an extra blank line. + // CRLF-safe so we don't leak a dangling '\r' into the suggestion. + const lineBreak = doc.getText(new Range(cursorPos, range.start)); + const trimmedNewText = newText.replace(/\r?\n$/, ''); + return { range: new Range(cursorPos, cursorPos), newText: lineBreak + trimmedNewText }; +} + +/** + * Strip the longest shared prefix that ends on a newline boundary from both sides + * of a multi-line edit. This often shrinks the range so it fits on a single line, + * which is required for ghost text rendering. + */ +function stripCommonLinePrefix(doc: TextDocument, range: Range, newText: string): { range: Range; newText: string } { + const replacedText = doc.getText(range); + const maxLen = Math.min(replacedText.length, newText.length); + let commonLen = 0; + while (commonLen < maxLen && replacedText[commonLen] === newText[commonLen]) { + commonLen++; + } + if (commonLen === 0) { + return { range, newText }; + } + const lastNewline = replacedText.lastIndexOf('\n', commonLen - 1); + if (lastNewline < 0) { + return { range, newText }; + } + const strippedLen = lastNewline + 1; + const newStart = doc.positionAt(doc.offsetAt(range.start) + strippedLen); + return { range: new Range(newStart, range.end), newText: newText.substring(strippedLen) }; +} + +/** + * Validate that a single-line edit can be rendered as ghost text at the cursor: + * - the cursor is at or after `range.start` + * - everything before the cursor in the replaced text matches `newText` + * - the replaced text is a subword of `newText` (i.e. only insertions are needed) + */ +function validateSameLineGhostText(cursorPos: Position, doc: TextDocument, range: Range, newText: string): InlineSuggestionEdit | undefined { + const replacedText = doc.getText(range); + const cursorOffsetInReplacedText = cursorPos.character - range.start.character; + if (cursorOffsetInReplacedText < 0) { + return undefined; + } + if (replacedText.substring(0, cursorOffsetInReplacedText) !== newText.substring(0, cursorOffsetInReplacedText)) { + return undefined; + } if (!isSubword(replacedText, newText)) { return undefined; } - return { range, newText }; } + /** * a is subword of b if a can be obtained by removing characters from b */ - export function isSubword(a: string, b: string): boolean { for (let aIdx = 0, bIdx = 0; aIdx < a.length; bIdx++) { if (bIdx >= b.length) { diff --git a/extensions/copilot/src/extension/intents/node/agentIntent.ts b/extensions/copilot/src/extension/intents/node/agentIntent.ts index 8b85a7f39c44e..bbf7d45a66f26 100644 --- a/extensions/copilot/src/extension/intents/node/agentIntent.ts +++ b/extensions/copilot/src/extension/intents/node/agentIntent.ts @@ -17,7 +17,7 @@ import { IAutomodeService } from '../../../platform/endpoint/node/automodeServic import { IEnvService } from '../../../platform/env/common/envService'; import { ILogService } from '../../../platform/log/common/logService'; import { IEditLogService } from '../../../platform/multiFileEdit/common/editLogService'; -import { CUSTOM_TOOL_SEARCH_NAME, isAnthropicContextEditingEnabled } from '../../../platform/networking/common/anthropic'; +import { isAnthropicContextEditingEnabled } from '../../../platform/networking/common/anthropic'; import { IChatEndpoint } from '../../../platform/networking/common/networking'; import { modelsWithoutResponsesContextManagement } from '../../../platform/networking/common/openai'; import { INotebookService } from '../../../platform/notebook/common/notebookService'; @@ -141,8 +141,6 @@ export const getAgentTools = async (accessor: ServicesAccessor, request: vscode. allowTools[ToolName.MultiReplaceString] = true; } - allowTools[CUSTOM_TOOL_SEARCH_NAME] = !!model.supportsToolSearch; - const tools = toolsService.getEnabledTools(request, model, tool => { if (typeof allowTools[tool.name] === 'boolean') { return allowTools[tool.name]; diff --git a/extensions/copilot/src/extension/log/node/chatLogExport.ts b/extensions/copilot/src/extension/log/node/chatLogExport.ts index c61a044b72f26..b4f97d9d0e1d6 100644 --- a/extensions/copilot/src/extension/log/node/chatLogExport.ts +++ b/extensions/copilot/src/extension/log/node/chatLogExport.ts @@ -139,7 +139,7 @@ export async function createExportedPrompt( kind: 'error', error: error?.toString() || 'Unknown error', timestamp: new Date().toISOString() - } as unknown as ExportedLogEntry); + }); } } diff --git a/extensions/copilot/src/extension/mcp/vscode-node/commands.ts b/extensions/copilot/src/extension/mcp/vscode-node/commands.ts index 9cfa94dcd6b9c..eb8bd3b80b9bc 100644 --- a/extensions/copilot/src/extension/mcp/vscode-node/commands.ts +++ b/extensions/copilot/src/extension/mcp/vscode-node/commands.ts @@ -115,10 +115,10 @@ export class McpSetupCommands extends Disposable { }; constructor( - @ITelemetryService readonly telemetryService: ITelemetryService, - @ILogService readonly logService: ILogService, - @IFetcherService readonly fetcherService: IFetcherService, - @IInstantiationService readonly instantiationService: IInstantiationService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + @ILogService private readonly logService: ILogService, + @IFetcherService private readonly fetcherService: IFetcherService, + @IInstantiationService private readonly instantiationService: IInstantiationService, ) { super(); this._register(toDisposable(() => this.pendingSetup?.cts.dispose(true))); diff --git a/extensions/copilot/src/extension/prompts/node/panel/title.tsx b/extensions/copilot/src/extension/prompts/node/panel/title.tsx index 1af4bad430b62..5bd6434b11138 100644 --- a/extensions/copilot/src/extension/prompts/node/panel/title.tsx +++ b/extensions/copilot/src/extension/prompts/node/panel/title.tsx @@ -16,16 +16,21 @@ export class TitlePrompt extends PromptElement { return ( <> - You are an expert in crafting pithy titles for chatbot conversations. You are presented with a chat request, and you reply with a brief title that captures the main topic of that request.
+ You are an expert in crafting ultra-compact titles for chatbot conversations. You are presented with a chat request, and you reply with only a brief title that captures the main topic of that request.
- The title should not be wrapped in quotes. It should be about 8 words or fewer.
+ Write the title in sentence case, not title case. Preserve product names, abbreviations, code symbols, and proper nouns.
+ Aim for 3-6 words. Prefer the shortest accurate title.
+ Drop articles like "a", "an", and "the" unless needed for clarity.
+ Drop filler and generic framing like "help with", "question about", "request for", or "issue with".
+ Prefer short, concrete synonyms and omit unnecessary words.
+ Do not wrap the title in quotes or add trailing punctuation.
Here are some examples of good titles:
- Git rebase question
- - Installing Python packages
- - Location of LinkedList implementation in codebase
- - Adding a tree view to a VS Code extension
- - React useState hook usage + - Install Python packages
+ - LinkedList implementation location
+ - Add VS Code tree view
+ - React useState usage
Please write a brief title for the following request:
diff --git a/extensions/copilot/src/extension/search/vscode-node/commands.ts b/extensions/copilot/src/extension/search/vscode-node/commands.ts index 4a81d810ed8cb..f7241fe3789c9 100644 --- a/extensions/copilot/src/extension/search/vscode-node/commands.ts +++ b/extensions/copilot/src/extension/search/vscode-node/commands.ts @@ -12,7 +12,7 @@ import { SearchFeedbackKind, SemanticSearchTextSearchProvider } from '../../work export class SearchPanelCommands extends Disposable { constructor( - @ITelemetryService readonly telemetryService: ITelemetryService, + @ITelemetryService telemetryService: ITelemetryService, @IFeedbackReporter private readonly feedbackReporter: IFeedbackReporter, ) { super(); diff --git a/extensions/copilot/src/extension/tools/common/virtualTools/preComputedToolEmbeddingsCache.ts b/extensions/copilot/src/extension/tools/common/virtualTools/preComputedToolEmbeddingsCache.ts index 58a306f2347f2..910d543251434 100644 --- a/extensions/copilot/src/extension/tools/common/virtualTools/preComputedToolEmbeddingsCache.ts +++ b/extensions/copilot/src/extension/tools/common/virtualTools/preComputedToolEmbeddingsCache.ts @@ -18,7 +18,7 @@ export class PreComputedToolEmbeddingsCache implements IToolEmbeddingsCache { private embeddingsMap: Map | undefined; constructor( - @ILogService readonly _logService: ILogService, + @ILogService private readonly _logService: ILogService, @IInstantiationService instantiationService: IInstantiationService, @IEnvService envService: IEnvService ) { diff --git a/extensions/copilot/src/extension/tools/node/toolSearchTool.ts b/extensions/copilot/src/extension/tools/node/toolSearchTool.ts index ceca12f5a8b9a..a0f83013ee292 100644 --- a/extensions/copilot/src/extension/tools/node/toolSearchTool.ts +++ b/extensions/copilot/src/extension/tools/node/toolSearchTool.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type * as vscode from 'vscode'; +import * as l10n from '@vscode/l10n'; import { ILogService } from '../../../platform/log/common/logService'; import { CUSTOM_TOOL_SEARCH_NAME } from '../../../platform/networking/common/anthropic'; import { LanguageModelTextPart, LanguageModelToolResult } from '../../../vscodeTypes'; @@ -56,10 +57,13 @@ export class ToolSearchTool implements ICopilotModelSpecificTool(args: T | undefined, defaultTimeBudget: number): ResolvedInput | FailedHandlerResponse => { const requestStartTime = Date.now(); if (args === undefined) { diff --git a/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/node/test/nes.spec.ts b/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/node/test/nes.spec.ts index 7f1ec232603a7..a445ccdf7ac38 100644 --- a/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/node/test/nes.spec.ts +++ b/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/node/test/nes.spec.ts @@ -80,12 +80,12 @@ type TrackedRenameInfo = { oldName: string; newName: string; range: Range; -} +}; type PostRenameTestCase = { trackedRename: TrackedRenameInfo; testCase: NesRenameTestCase; -} +}; function computeNesRenameTestCases(filePath: string): NesRenameTestCase[] { const text = fs.readFileSync(filePath, 'utf8'); diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts index c735d3a8c78a7..7f48633ba47c0 100644 --- a/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts @@ -477,7 +477,7 @@ type ContextRequestState = { type CacheInfo = { version: number; state: CacheState; -} +}; enum CacheState { NotPopulated = 'NotPopulated', @@ -1244,9 +1244,9 @@ export class LanguageContextServiceImpl implements ILanguageContextService, vsco public readonly onContextComputedOnTimeout: vscode.Event; constructor( + @ITelemetryService telemetryService: ITelemetryService, @IConfigurationService private readonly configurationService: IConfigurationService, @IExperimentationService private readonly experimentationService: IExperimentationService, - @ITelemetryService readonly telemetryService: ITelemetryService, @ILogService private readonly logService: ILogService ) { this.isDebugging = process.execArgv.some((arg) => /^--(?:inspect|debug)(?:-brk)?(?:=\d+)?$/i.test(arg)); @@ -1791,7 +1791,7 @@ async function* mapAsyncIterable( const showContextInspectorViewContextKey = `github.copilot.chat.showContextInspectorView`; export class InlineCompletionContribution implements vscode.Disposable, TokenBudgetProvider { - private disposables: DisposableStore; + private readonly disposables: DisposableStore; private registrations: DisposableStore | undefined; private readonly registrationQueue: Queue; @@ -1799,10 +1799,10 @@ export class InlineCompletionContribution implements vscode.Disposable, TokenBud private readonly telemetrySender: TelemetrySender; constructor( + @ITelemetryService telemetryService: ITelemetryService, @IConfigurationService private readonly configurationService: IConfigurationService, @IExperimentationService private readonly experimentationService: IExperimentationService, - @ILogService readonly logService: ILogService, - @ITelemetryService readonly telemetryService: ITelemetryService, + @ILogService private readonly logService: ILogService, @ILanguageContextService private readonly languageContextService: ILanguageContextService, @ILanguageContextProviderService private readonly languageContextProviderService: ILanguageContextProviderService, ) { diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/nesRenameService.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/nesRenameService.ts index fa6fa590995a8..6201e9bfacc0c 100644 --- a/extensions/copilot/src/extension/typescriptContext/vscode-node/nesRenameService.ts +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/nesRenameService.ts @@ -70,7 +70,7 @@ namespace NesRenameRequestArgs { type TextChange = { range: protocol.Range; newText?: string; -} +}; type RenameGroup = { file: vscode.Uri; changes: TextChange[]; @@ -138,14 +138,14 @@ class TelemetrySender { export class NesRenameContribution implements vscode.Disposable { private _isActivated: Promise | undefined; - private disposables: DisposableStore; + private readonly disposables: DisposableStore; private readonly telemetrySender: TelemetrySender; private static readonly ExecConfig: ExecConfig = { executionTarget: ExecutionTarget.Semantic }; constructor( - @ITelemetryService readonly telemetryService: ITelemetryService, - @ILogService readonly logService: ILogService, + @ITelemetryService telemetryService: ITelemetryService, + @ILogService private readonly logService: ILogService, ) { this.telemetrySender = new TelemetrySender(telemetryService, logService); this.disposables = new DisposableStore(); @@ -345,4 +345,4 @@ export class NesRenameContribution implements vscode.Disposable { } return { document, position, oldName, newName }; } -} \ No newline at end of file +} diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/types.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/types.ts index fc2037916e74c..0f4bcdc3904eb 100644 --- a/extensions/copilot/src/extension/typescriptContext/vscode-node/types.ts +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/types.ts @@ -15,7 +15,7 @@ export type ResolvedRunnableResult = { items: protocol.FullContextItem[]; cache?: protocol.CacheInfo; debugPath?: protocol.ContextRunnableResultId | undefined; -} +}; export namespace ResolvedRunnableResult { export function from(result: protocol.ContextRunnableResult, items: protocol.FullContextItem[]): ResolvedRunnableResult { return { @@ -34,7 +34,7 @@ export type ContextComputedEvent = { position: vscode.Position; source?: string; summary: ContextItemSummary; -} +}; export type OnCachePopulatedEvent = ContextComputedEvent & { items: ReadonlyArray }; export type OnContextComputedEvent = ContextComputedEvent & { items: ReadonlyArray }; diff --git a/extensions/copilot/src/extension/workspaceRecorder/vscode-node/workspaceListenerService.ts b/extensions/copilot/src/extension/workspaceRecorder/vscode-node/workspaceListenerService.ts index 140751fe2cc54..96ee0dcc18745 100644 --- a/extensions/copilot/src/extension/workspaceRecorder/vscode-node/workspaceListenerService.ts +++ b/extensions/copilot/src/extension/workspaceRecorder/vscode-node/workspaceListenerService.ts @@ -10,7 +10,7 @@ import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { IRecordableEditorLogEntry, IRecordableLogEntry, ITextModelEditReasonMetadata, IWorkspaceListenerService } from '../common/workspaceListenerService'; export class WorkspacListenerService extends Disposable implements IWorkspaceListenerService { - readonly _serviceBrand = undefined; + declare _serviceBrand: undefined; private readonly _onStructuredData = this._register(new Emitter()); readonly onStructuredData = this._onStructuredData.event; diff --git a/extensions/copilot/src/lib/node/chatLibMain.ts b/extensions/copilot/src/lib/node/chatLibMain.ts index 2f399d4095983..4dd64f67f70b0 100644 --- a/extensions/copilot/src/lib/node/chatLibMain.ts +++ b/extensions/copilot/src/lib/node/chatLibMain.ts @@ -716,7 +716,7 @@ export interface IEditorSession { readonly uiKind?: string; } -export type IActionItem = ActionItem +export type IActionItem = ActionItem; export interface INotificationSender { showWarningMessage(message: string, ...actions: IActionItem[]): Promise; } diff --git a/extensions/copilot/src/platform/authentication/common/authentication.ts b/extensions/copilot/src/platform/authentication/common/authentication.ts index 99a0338673656..d0adc1d09a1f8 100644 --- a/extensions/copilot/src/platform/authentication/common/authentication.ts +++ b/extensions/copilot/src/platform/authentication/common/authentication.ts @@ -277,7 +277,7 @@ export abstract class BaseAuthenticationService extends Disposable implements IA // #endregion //#region ADO Token - abstract getAdoAccessTokenBase64(options?: AuthenticationGetSessionOptions): Promise + abstract getAdoAccessTokenBase64(options?: AuthenticationGetSessionOptions): Promise; //#endregion protected async _handleAuthChangeEvent(): Promise { diff --git a/extensions/copilot/src/platform/chat/common/commonTypes.ts b/extensions/copilot/src/platform/chat/common/commonTypes.ts index 73fe349229dd5..6fb7bae58f98b 100644 --- a/extensions/copilot/src/platform/chat/common/commonTypes.ts +++ b/extensions/copilot/src/platform/chat/common/commonTypes.ts @@ -187,7 +187,7 @@ export type ChatFetchRetriableError = /** * We requested conversation, the response was filtered by RAI, but we want to retry. */ - { type: ChatFetchResponseType.FilteredRetry; reason: string; category: FilterReason; value: T; requestId: string; serverRequestId: string | undefined } + { type: ChatFetchResponseType.FilteredRetry; reason: string; category: FilterReason; value: T; requestId: string; serverRequestId: string | undefined }; export type FetchSuccess = { type: ChatFetchResponseType.Success; value: T; requestId: string; serverRequestId: string | undefined; usage: APIUsage | undefined; resolvedModel: string }; diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index e0bf525b7ef24..9f1a1737e02ca 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -713,8 +713,6 @@ export namespace ConfigKey { /** Internal: override reasoning/thinking effort sent to model APIs (e.g. Responses API, Messages API). Used by evals. */ export const ReasoningEffortOverride = defineSetting('chat.reasoningEffortOverride', ConfigType.Simple, null); - - export const SessionSearchCloudSync = defineAndMigrateSetting('chat.advanced.sessionSearch.cloudSync.enabled', 'chat.sessionSearch.cloudSync.enabled', false); } /** diff --git a/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts b/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts index 64de72693150b..32b19748b143e 100644 --- a/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts +++ b/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts @@ -70,7 +70,7 @@ type ICompletionModelCapabilities = { type: 'completion'; family: string; tokenizer: TokenizerType; -} +}; export enum ModelSupportedEndpoint { ChatCompletions = '/chat/completions', diff --git a/extensions/copilot/src/platform/endpoint/test/node/openaiCompatibleEndpoint.ts b/extensions/copilot/src/platform/endpoint/test/node/openaiCompatibleEndpoint.ts index b76279558497d..cae82f015dc0a 100644 --- a/extensions/copilot/src/platform/endpoint/test/node/openaiCompatibleEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/test/node/openaiCompatibleEndpoint.ts @@ -73,7 +73,7 @@ export type IModelConfig = { max_completion_tokens?: number | null; intent?: boolean | null; }; -} +}; export class OpenAICompatibleTestEndpoint extends ChatEndpoint { constructor( diff --git a/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts b/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts index 8d4949173dbb3..ccee88711f1e8 100644 --- a/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts +++ b/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts @@ -483,7 +483,7 @@ export class GitServiceImpl extends Disposable implements IGitService { } private static repoToRepoContext(repo: Repository): RepoContext; - private static repoToRepoContext(repo: Repository | undefined | null): RepoContext | undefined + private static repoToRepoContext(repo: Repository | undefined | null): RepoContext | undefined; private static repoToRepoContext(repo: Repository | undefined | null): RepoContext | undefined { if (!repo) { return undefined; diff --git a/extensions/copilot/src/platform/inlineCompletions/common/api.ts b/extensions/copilot/src/platform/inlineCompletions/common/api.ts index 60a820a3bd9f7..8634278dbf792 100644 --- a/extensions/copilot/src/platform/inlineCompletions/common/api.ts +++ b/extensions/copilot/src/platform/inlineCompletions/common/api.ts @@ -11,12 +11,12 @@ export namespace Copilot { export type Position = { line: number; character: number; - } + }; export type Range = { start: Position; end: Position; - } + }; /** * The ContextProvider API allows extensions to provide additional context items that diff --git a/extensions/copilot/src/platform/inlineEdits/common/dataTypes/languageContext.ts b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/languageContext.ts index a9b0725f0cf76..16e23a2535ee2 100644 --- a/extensions/copilot/src/platform/inlineEdits/common/dataTypes/languageContext.ts +++ b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/languageContext.ts @@ -11,13 +11,13 @@ export type LanguageContextEntry = { context: ContextItem; timeStamp: number; onTimeout: boolean; -} +}; export type LanguageContextResponse = { start: number; end: number; items: LanguageContextEntry[]; -} +}; type SerializedSnippetContext = { kind: ContextKind.Snippet; @@ -25,21 +25,21 @@ type SerializedSnippetContext = { uri: string; additionalUris?: string[]; value: string; -} +}; type SerializedTraitContext = { kind: ContextKind.Trait; priority: number; name: string; value: string; -} +}; type SerializedDiagnosticBagContext = { kind: ContextKind.DiagnosticBag; priority: number; uri: string; values: Omit[]; -} +}; type SerializedContextItem = SerializedSnippetContext | SerializedTraitContext | SerializedDiagnosticBagContext; @@ -50,7 +50,7 @@ export type SerializedContextResponse = { context: SerializedContextItem; timeStamp: number; }[]; -} +}; export function serializeLanguageContext(response: LanguageContextResponse): SerializedContextResponse { return { @@ -113,7 +113,7 @@ export type SerializedDiagnostic = { source: string; code: string | number | undefined; range: string; -} +}; function serializeDiagnostic(diagnostic: Diagnostic): Omit; function serializeDiagnostic(diagnostic: Diagnostic, resource: Uri): SerializedDiagnostic; diff --git a/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts index 0c9220928a0fc..0dee4e8cf4d98 100644 --- a/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts +++ b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts @@ -31,7 +31,7 @@ export type RecentlyViewedDocumentsOptions = { readonly includeViewedFiles: boolean; readonly includeLineNumbers: IncludeLineNumbersOption; readonly clippingStrategy: RecentFileClippingStrategy; -} +}; export namespace RecentlyViewedDocumentsOptions { export const VALIDATOR: IValidator> = vObj({ @@ -49,14 +49,14 @@ export type LanguageContextOptions = { readonly enabled: boolean; readonly maxTokens: number; readonly traitPosition: 'before' | 'after'; -} +}; export type DiffHistoryOptions = { readonly nEntries: number; readonly maxTokens: number; readonly onlyForDocsInPrompt: boolean; readonly useRelativePaths: boolean; -} +}; export type PagedClipping = { pageSize: number }; @@ -66,7 +66,7 @@ export type CurrentFileOptions = { readonly includeLineNumbers: IncludeLineNumbersOption; readonly includeCursorTag: boolean; readonly prioritizeAboveCursor: boolean; -} +}; export namespace CurrentFileOptions { export const VALIDATOR: IValidator> = vObj({ @@ -96,7 +96,7 @@ export type LintOptions = { maxLineDistance: number; /** When set to a value > 0, also include linter diagnostics from the N most recently edited/viewed files. */ nRecentFiles: number; -} +}; /** * The raw user-facing aggressiveness setting. Includes `Default` to distinguish @@ -241,7 +241,7 @@ export type PromptOptions = { readonly diffHistory: DiffHistoryOptions; readonly includePostScript: boolean; readonly lintOptions: LintOptions | undefined; -} +}; /** * Prompt strategies that tweak prompt in a way that's different from current prod prompting strategy. diff --git a/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts b/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts index 6a3c77f6edacc..3e27f7701fae3 100644 --- a/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts +++ b/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts @@ -26,7 +26,7 @@ import { InlineEditRequestLogContext } from './inlineEditLogContext'; import { stringifyChatMessages } from './utils/stringifyChatMessages'; import { IXtabHistoryEntry } from './workspaceEditTracker/nesXtabHistoryTracker'; -export type EditStreaming = AsyncGenerator +export type EditStreaming = AsyncGenerator; export class WithStatelessProviderTelemetry { constructor( @@ -36,7 +36,7 @@ export class WithStatelessProviderTelemetry { } } -export type EditStreamingWithTelemetry = AsyncGenerator, WithStatelessProviderTelemetry, void> +export type EditStreamingWithTelemetry = AsyncGenerator, WithStatelessProviderTelemetry, void>; export type StreamedEdit = { readonly targetDocument: DocumentId; @@ -49,7 +49,7 @@ export type StreamedEdit = { * in either the original location or the jump target location. */ readonly originalWindow?: OffsetRange; -} +}; export type PushEdit = (edit: Result) => void; @@ -432,7 +432,7 @@ export type FetchResultWithStats = { readonly response: FetchResponse; readonly fetchTime: number; readonly fetchResult: ChatFetchResponseType; -} +}; export class StatelessNextEditTelemetryBuilder { diff --git a/extensions/copilot/src/platform/inlineEdits/common/workspaceEditTracker/nesXtabHistoryTracker.ts b/extensions/copilot/src/platform/inlineEdits/common/workspaceEditTracker/nesXtabHistoryTracker.ts index c755f5bd9862b..ed7eaa8c671ed 100644 --- a/extensions/copilot/src/platform/inlineEdits/common/workspaceEditTracker/nesXtabHistoryTracker.ts +++ b/extensions/copilot/src/platform/inlineEdits/common/workspaceEditTracker/nesXtabHistoryTracker.ts @@ -38,19 +38,19 @@ export interface IXtabHistoryVisibleRangesEntry extends IXtabHistoryDocumentEntr export type IXtabHistoryEntry = | IXtabHistoryEditEntry - | IXtabHistoryVisibleRangesEntry + | IXtabHistoryVisibleRangesEntry; type DocumentChangedEvent = { value: StringText; changes: StringEdit[]; previous: StringText | undefined; -} +}; type DocumentSelectionChangedEvent = { value: readonly OffsetRange[]; changes: unknown[]; previous: readonly OffsetRange[] | undefined; -} +}; /** * Controls how consecutive edits to the same document are merged in history. diff --git a/extensions/copilot/src/platform/inlineEdits/common/workspaceEditTracker/workspaceDocumentEditTracker.ts b/extensions/copilot/src/platform/inlineEdits/common/workspaceEditTracker/workspaceDocumentEditTracker.ts index a764232212ed8..64a029afd5380 100644 --- a/extensions/copilot/src/platform/inlineEdits/common/workspaceEditTracker/workspaceDocumentEditTracker.ts +++ b/extensions/copilot/src/platform/inlineEdits/common/workspaceEditTracker/workspaceDocumentEditTracker.ts @@ -17,7 +17,7 @@ export type DocumentHistoryDifference = { before: StringText; after: StringText; edits: StringEdit; -} +}; export class WorkspaceDocumentEditHistory extends Disposable { private readonly _documentState = new Map(); diff --git a/extensions/copilot/src/platform/inlineEdits/node/inlineEditsModelService.ts b/extensions/copilot/src/platform/inlineEdits/node/inlineEditsModelService.ts index 84a0dde583b62..0066d0d6e10b5 100644 --- a/extensions/copilot/src/platform/inlineEdits/node/inlineEditsModelService.ts +++ b/extensions/copilot/src/platform/inlineEdits/node/inlineEditsModelService.ts @@ -39,7 +39,7 @@ interface ModelConfigurationWithSource extends ModelConfiguration { type ModelInfo = { models: ModelConfigurationWithSource[]; currentModelId: string; -} +}; export class InlineEditsModelService extends Disposable implements IInlineEditsModelService { diff --git a/extensions/copilot/src/platform/networking/common/fetch.ts b/extensions/copilot/src/platform/networking/common/fetch.ts index 5bda09c2f668e..02a3f985c1417 100644 --- a/extensions/copilot/src/platform/networking/common/fetch.ts +++ b/extensions/copilot/src/platform/networking/common/fetch.ts @@ -304,12 +304,12 @@ export type StreamOptions = { * All other chunks will also include a usage field, but with a null value. NOTE: If the stream is interrupted, you may not receive the final usage chunk which contains the total token usage for the request. */ include_usage?: boolean; -} +}; export type Prediction = { type: 'content'; content: string | { type: string; text: string }[]; -} +}; /** based on https://platform.openai.com/docs/api-reference/chat/create * diff --git a/extensions/copilot/src/platform/networking/common/networking.ts b/extensions/copilot/src/platform/networking/common/networking.ts index 927855b3548ac..8c70a781c1e53 100644 --- a/extensions/copilot/src/platform/networking/common/networking.ts +++ b/extensions/copilot/src/platform/networking/common/networking.ts @@ -231,7 +231,7 @@ export type IChatRequestTelemetryProperties = { parentRequestId?: string; /** For a subagent: The tool_call_id from the parent agent's LLM response that triggered this subagent invocation. */ parentToolCallId?: string; -} +}; export interface ICreateEndpointBodyOptions extends IMakeChatRequestOptions { requestId: string; diff --git a/extensions/copilot/src/platform/notebook/common/alternativeContentEditGenerator.ts b/extensions/copilot/src/platform/notebook/common/alternativeContentEditGenerator.ts index 02bc8e99af0cd..2ab7f8dd581dd 100644 --- a/extensions/copilot/src/platform/notebook/common/alternativeContentEditGenerator.ts +++ b/extensions/copilot/src/platform/notebook/common/alternativeContentEditGenerator.ts @@ -23,7 +23,7 @@ export type NotebookEditGenerationTelemtryOptions = { model: Promise | string | undefined; requestId: string | undefined; source: NotebookEditGenrationSource; -} +}; export enum NotebookEditGenrationSource { codeMapperEditNotebook = 'codeMapperEditNotebook', diff --git a/extensions/copilot/src/platform/review/vscode/reviewServiceImpl.ts b/extensions/copilot/src/platform/review/vscode/reviewServiceImpl.ts index 8148929bb4dc9..8861a96de252c 100644 --- a/extensions/copilot/src/platform/review/vscode/reviewServiceImpl.ts +++ b/extensions/copilot/src/platform/review/vscode/reviewServiceImpl.ts @@ -24,8 +24,8 @@ const numberOfReviewCommentsKey = 'github.copilot.chat.review.numberOfComments'; export class ReviewServiceImpl implements IReviewService { declare _serviceBrand: undefined; - private _disposables = new DisposableStore(); - private _repositoryDisposables = new DisposableStore(); + private readonly _disposables = new DisposableStore(); + private readonly _repositoryDisposables = new DisposableStore(); private _reviewDiffReposString: string | undefined; private _diagnosticCollection: vscode.DiagnosticCollection | undefined; private _commentController = vscode.comments.createCommentController('github-copilot-review', 'Code Review'); diff --git a/extensions/copilot/src/platform/telemetry/common/msftTelemetrySender.ts b/extensions/copilot/src/platform/telemetry/common/msftTelemetrySender.ts index 2074272384dd8..0bbc00c2509dc 100644 --- a/extensions/copilot/src/platform/telemetry/common/msftTelemetrySender.ts +++ b/extensions/copilot/src/platform/telemetry/common/msftTelemetrySender.ts @@ -20,7 +20,7 @@ export class BaseMsftTelemetrySender implements IMSFTTelemetrySender { protected _internalLargeEventTelemetryReporter: ITelemetryReporter | undefined; private _externalTelemetryReporter: ITelemetryReporter; - protected _disposables: DisposableStore = new DisposableStore(); + protected readonly _disposables: DisposableStore = new DisposableStore(); private _username: string | undefined; private _vscodeTeamMember: boolean = false; private _sku: string | undefined; diff --git a/extensions/copilot/src/platform/thinking/common/thinking.ts b/extensions/copilot/src/platform/thinking/common/thinking.ts index 60d71b85ed713..f61ef2af8ddf0 100644 --- a/extensions/copilot/src/platform/thinking/common/thinking.ts +++ b/extensions/copilot/src/platform/thinking/common/thinking.ts @@ -46,7 +46,7 @@ export type EncryptedThinkingDelta = { id: string; text?: string; encrypted: string; -} +}; export function isEncryptedThinkingDelta(delta: ThinkingDelta | EncryptedThinkingDelta): delta is EncryptedThinkingDelta { return (delta as EncryptedThinkingDelta).encrypted !== undefined; diff --git a/extensions/copilot/src/util/node/worker.ts b/extensions/copilot/src/util/node/worker.ts index 425387c2bb511..0fface63bf6fe 100644 --- a/extensions/copilot/src/util/node/worker.ts +++ b/extensions/copilot/src/util/node/worker.ts @@ -60,7 +60,7 @@ export class RcpResponseHandler { export type RpcProxy = { [K in keyof ProxyType]: ProxyType[K] extends ((...args: infer Args) => infer R) ? (...args: Args) => Promise> : never; -} +}; export function createRpcProxy(remoteCall: (name: string, args: any[]) => Promise): RpcProxy { const handler = { diff --git a/extensions/copilot/test/base/simulationOptions.ts b/extensions/copilot/test/base/simulationOptions.ts index ad45fd201867f..84f5d40fcc8a3 100644 --- a/extensions/copilot/test/base/simulationOptions.ts +++ b/extensions/copilot/test/base/simulationOptions.ts @@ -14,7 +14,7 @@ export type NesDatagen = { readonly output: string | undefined; readonly rowOffset: number; readonly workerMode: boolean; -} +}; export class SimulationOptions { public static fromProcessArgs(): SimulationOptions { diff --git a/extensions/copilot/test/pipeline/alternativeAction/types.ts b/extensions/copilot/test/pipeline/alternativeAction/types.ts index 40e8733bc6bf8..51e8bce74729e 100644 --- a/extensions/copilot/test/pipeline/alternativeAction/types.ts +++ b/extensions/copilot/test/pipeline/alternativeAction/types.ts @@ -18,7 +18,7 @@ export type IData = { isInlineCompletion: boolean; }; suggestionStatus: NextEditTelemetryStatus; -} +}; export namespace NextUserEdit { export type t = { @@ -36,7 +36,7 @@ export namespace Recording { relativePath: string; originalOpIdx: number; }; - } + }; } export namespace SuggestedEdit { @@ -45,7 +45,7 @@ export namespace SuggestedEdit { edit: ISerializedEdit; scoreCategory: 'nextEdit'; score: number; - } + }; } export namespace Scoring { diff --git a/extensions/copilot/test/pipeline/pipeline.ts b/extensions/copilot/test/pipeline/pipeline.ts index 0a17601a79c08..2e05f9e33e118 100644 --- a/extensions/copilot/test/pipeline/pipeline.ts +++ b/extensions/copilot/test/pipeline/pipeline.ts @@ -40,7 +40,7 @@ export type RunPipelineOptions = { readonly configFile: string | undefined; readonly verbose: number | boolean | undefined; readonly parallelism: number; -} +}; export async function runInputPipeline(opts: RunPipelineOptions, log = console.log.bind(console)): Promise { const nesDatagenOpts = opts.nesDatagen!; diff --git a/extensions/copilot/test/testVisualizationRunner.ts b/extensions/copilot/test/testVisualizationRunner.ts index 2b53cb723d2b8..38fa828ca828b 100644 --- a/extensions/copilot/test/testVisualizationRunner.ts +++ b/extensions/copilot/test/testVisualizationRunner.ts @@ -25,7 +25,7 @@ function run(args: { fileName: string; path: string[] }) { runCurrentTest(); } -const g = globalThis as any as IDebugValueEditorGlobals & IPlaygroundRunnerGlobals; +const g = globalThis as unknown as IDebugValueEditorGlobals & IPlaygroundRunnerGlobals; g.$$playgroundRunner_data = { currentPath: [] }; // The timeout seems to fix a deadlock-issue of tsx, when the run function is called from the debugger. diff --git a/extensions/copilot/test/testVisualizationRunnerSTest.ts b/extensions/copilot/test/testVisualizationRunnerSTest.ts index bdf1bd8195405..8f711a37c5b8c 100644 --- a/extensions/copilot/test/testVisualizationRunnerSTest.ts +++ b/extensions/copilot/test/testVisualizationRunnerSTest.ts @@ -18,7 +18,7 @@ function run(args: { fileName: string; path: string }) { runCurrentTest(); } -const g = globalThis as any as IDebugValueEditorGlobals & IPlaygroundRunnerGlobals; +const g = globalThis as unknown as IDebugValueEditorGlobals & IPlaygroundRunnerGlobals; g.$$playgroundRunner_data = { currentPath: [] }; diff --git a/src/vs/base/browser/mouseEvent.ts b/src/vs/base/browser/mouseEvent.ts index 2f8c99ff327d5..2873a56e1e40c 100644 --- a/src/vs/base/browser/mouseEvent.ts +++ b/src/vs/base/browser/mouseEvent.ts @@ -67,14 +67,8 @@ export class StandardMouseEvent implements IMouseEvent { this.altKey = e.altKey; this.metaKey = e.metaKey; - if (typeof e.pageX === 'number') { - this.posx = e.pageX; - this.posy = e.pageY; - } else { - // Probably hit by MSGestureEvent - this.posx = e.clientX + this.target.ownerDocument.body.scrollLeft + this.target.ownerDocument.documentElement.scrollLeft; - this.posy = e.clientY + this.target.ownerDocument.body.scrollTop + this.target.ownerDocument.documentElement.scrollTop; - } + this.posx = e.pageX; + this.posy = e.pageY; // Find the position of the iframe this code is executing in relative to the iframe where the event was captured. const iframeOffsets = IframeUtils.getPositionOfChildWindowRelativeToAncestorWindow(targetWindow, e.view); diff --git a/src/vs/base/browser/ui/actionbar/actionbar.ts b/src/vs/base/browser/ui/actionbar/actionbar.ts index da20e27e04010..f2b116fb7d2a5 100644 --- a/src/vs/base/browser/ui/actionbar/actionbar.ts +++ b/src/vs/base/browser/ui/actionbar/actionbar.ts @@ -79,7 +79,9 @@ export class ActionBar extends Disposable implements IActionRunner { }; // View Items - viewItems: IActionViewItem[]; + private _viewItems: IActionViewItem[]; + get viewItems(): readonly IActionViewItem[] { return this._viewItems; } + private readonly viewItemDisposables = this._register(new DisposableMap()); private previouslyFocusedItem?: number; protected focusedItem?: number; @@ -91,7 +93,7 @@ export class ActionBar extends Disposable implements IActionRunner { private focusable: boolean = true; // Elements - domNode: HTMLElement; + readonly domNode: HTMLElement; protected readonly actionsList: HTMLElement; private readonly _onDidBlur = this._register(new Emitter()); @@ -130,7 +132,7 @@ export class ActionBar extends Disposable implements IActionRunner { this._actionRunnerDisposables.add(this._actionRunner.onDidRun(e => this._onDidRun.fire(e))); this._actionRunnerDisposables.add(this._actionRunner.onWillRun(e => this._onWillRun.fire(e))); - this.viewItems = []; + this._viewItems = []; this.focusedItem = undefined; this.domNode = document.createElement('div'); @@ -380,10 +382,10 @@ export class ActionBar extends Disposable implements IActionRunner { if (index === null || index < 0 || index >= this.actionsList.children.length) { this.actionsList.appendChild(actionViewItemElement); - this.viewItems.push(item); + this._viewItems.push(item); } else { this.actionsList.insertBefore(actionViewItemElement, this.actionsList.children[index]); - this.viewItems.splice(index, 0, item); + this._viewItems.splice(index, 0, item); index++; } }); @@ -424,32 +426,18 @@ export class ActionBar extends Disposable implements IActionRunner { } getWidth(index: number): number { - if (index >= 0 && index < this.actionsList.children.length) { - const item = this.actionsList.children.item(index); - if (item) { - return item.clientWidth; - } - } - - return 0; + return this.actionsList.children.item(index)?.clientWidth ?? 0; } getHeight(index: number): number { - if (index >= 0 && index < this.actionsList.children.length) { - const item = this.actionsList.children.item(index); - if (item) { - return item.clientHeight; - } - } - - return 0; + return this.actionsList.children.item(index)?.clientHeight ?? 0; } pull(index: number): void { if (index >= 0 && index < this.viewItems.length) { this.actionsList.childNodes[index].remove(); this.viewItemDisposables.deleteAndDispose(this.viewItems[index]); - dispose(this.viewItems.splice(index, 1)); + dispose(this._viewItems.splice(index, 1)); this.refreshRole(); } } @@ -459,7 +447,7 @@ export class ActionBar extends Disposable implements IActionRunner { return; } - this.viewItems = dispose(this.viewItems); + this._viewItems = dispose(this._viewItems); this.viewItemDisposables.clearAndDisposeAll(); DOM.clearNode(this.actionsList); this.refreshRole(); @@ -623,7 +611,7 @@ export class ActionBar extends Disposable implements IActionRunner { override dispose(): void { this._context = undefined; - this.viewItems = dispose(this.viewItems); + this._viewItems = dispose(this._viewItems); this.getContainer().remove(); super.dispose(); } diff --git a/src/vs/base/browser/ui/findinput/replaceInput.ts b/src/vs/base/browser/ui/findinput/replaceInput.ts index 10c5b47b6530e..a9ae899d424b5 100644 --- a/src/vs/base/browser/ui/findinput/replaceInput.ts +++ b/src/vs/base/browser/ui/findinput/replaceInput.ts @@ -280,8 +280,4 @@ export class ReplaceInput extends Widget { this.inputBox.paddingRight = this.cachedOptionsWidth; this.domNode.style.width = newWidth + 'px'; } - - public override dispose(): void { - super.dispose(); - } } diff --git a/src/vs/editor/browser/controller/mouseHandler.ts b/src/vs/editor/browser/controller/mouseHandler.ts index 4ad4ca9d817e5..52291332b2ad2 100644 --- a/src/vs/editor/browser/controller/mouseHandler.ts +++ b/src/vs/editor/browser/controller/mouseHandler.ts @@ -403,10 +403,6 @@ class MouseDownOperation extends Disposable { this._lastMouseEvent = null; } - public override dispose(): void { - super.dispose(); - } - public isActive(): boolean { return this._isActive; } diff --git a/src/vs/editor/browser/services/editorWorkerService.ts b/src/vs/editor/browser/services/editorWorkerService.ts index d5c878af3b386..ec09cf29853e2 100644 --- a/src/vs/editor/browser/services/editorWorkerService.ts +++ b/src/vs/editor/browser/services/editorWorkerService.ts @@ -97,9 +97,6 @@ export class EditorWorkerService extends Disposable implements IEditorWorkerServ this._register(languageFeaturesService.completionProvider.register('*', new WordBasedCompletionItemProvider(this._workerManager, configurationService, this._modelService, this._languageConfigurationService, this._logService, languageFeaturesService))); } - public override dispose(): void { - super.dispose(); - } public canComputeUnicodeHighlights(uri: URI): boolean { return canSyncModel(this._modelService, uri); diff --git a/src/vs/editor/browser/viewParts/blockDecorations/blockDecorations.ts b/src/vs/editor/browser/viewParts/blockDecorations/blockDecorations.ts index 70fe366c06c9f..7cd7ea0be4cd8 100644 --- a/src/vs/editor/browser/viewParts/blockDecorations/blockDecorations.ts +++ b/src/vs/editor/browser/viewParts/blockDecorations/blockDecorations.ts @@ -51,9 +51,6 @@ export class BlockDecorations extends ViewPart { return didChange; } - public override dispose(): void { - super.dispose(); - } // --- begin event handlers diff --git a/src/vs/editor/browser/viewParts/editorScrollbar/editorScrollbar.ts b/src/vs/editor/browser/viewParts/editorScrollbar/editorScrollbar.ts index aa633a1cadfa6..816dc313885dc 100644 --- a/src/vs/editor/browser/viewParts/editorScrollbar/editorScrollbar.ts +++ b/src/vs/editor/browser/viewParts/editorScrollbar/editorScrollbar.ts @@ -105,9 +105,6 @@ export class EditorScrollbar extends ViewPart { this._register(dom.addDisposableListener(this.scrollbarDomNode.domNode, 'scroll', (e: Event) => onBrowserDesperateReveal(this.scrollbarDomNode.domNode, true, false))); } - public override dispose(): void { - super.dispose(); - } private _setLayout(): void { const options = this._context.configuration.options; diff --git a/src/vs/editor/browser/viewParts/margin/margin.ts b/src/vs/editor/browser/viewParts/margin/margin.ts index cd4660c834d13..2edbe484f7077 100644 --- a/src/vs/editor/browser/viewParts/margin/margin.ts +++ b/src/vs/editor/browser/viewParts/margin/margin.ts @@ -50,9 +50,6 @@ export class Margin extends ViewPart { this._domNode.appendChild(this._glyphMarginBackgroundDomNode); } - public override dispose(): void { - super.dispose(); - } public getDomNode(): FastDomNode { return this._domNode; diff --git a/src/vs/editor/browser/viewParts/rulers/rulers.ts b/src/vs/editor/browser/viewParts/rulers/rulers.ts index ec1a5042e9168..cb67b702704eb 100644 --- a/src/vs/editor/browser/viewParts/rulers/rulers.ts +++ b/src/vs/editor/browser/viewParts/rulers/rulers.ts @@ -34,9 +34,6 @@ export class Rulers extends ViewPart { this._typicalHalfwidthCharacterWidth = options.get(EditorOption.fontInfo).typicalHalfwidthCharacterWidth; } - public override dispose(): void { - super.dispose(); - } // --- begin event handlers diff --git a/src/vs/editor/browser/viewParts/scrollDecoration/scrollDecoration.ts b/src/vs/editor/browser/viewParts/scrollDecoration/scrollDecoration.ts index dc5dc3007091b..a703acd26c2c0 100644 --- a/src/vs/editor/browser/viewParts/scrollDecoration/scrollDecoration.ts +++ b/src/vs/editor/browser/viewParts/scrollDecoration/scrollDecoration.ts @@ -35,9 +35,6 @@ export class ScrollDecorationViewPart extends ViewPart { this._domNode.setAttribute('aria-hidden', 'true'); } - public override dispose(): void { - super.dispose(); - } private _updateShouldShow(): boolean { const newShouldShow = (this._useShadows && this._scrollTop > 0); diff --git a/src/vs/editor/common/viewLayout/viewLayout.ts b/src/vs/editor/common/viewLayout/viewLayout.ts index 202187a4aadb2..e64482e192c35 100644 --- a/src/vs/editor/common/viewLayout/viewLayout.ts +++ b/src/vs/editor/common/viewLayout/viewLayout.ts @@ -191,9 +191,6 @@ export class ViewLayout extends Disposable implements IViewLayout { this._updateHeight(); } - public override dispose(): void { - super.dispose(); - } public getScrollable(): Scrollable { return this._scrollable.getScrollable(); diff --git a/src/vs/editor/standalone/browser/standaloneCodeEditor.ts b/src/vs/editor/standalone/browser/standaloneCodeEditor.ts index b55bf1c003802..db742fc9ba8a8 100644 --- a/src/vs/editor/standalone/browser/standaloneCodeEditor.ts +++ b/src/vs/editor/standalone/browser/standaloneCodeEditor.ts @@ -474,9 +474,6 @@ export class StandaloneEditor extends StandaloneCodeEditor implements IStandalon } } - public override dispose(): void { - super.dispose(); - } public override updateOptions(newOptions: Readonly): void { updateConfigurationService(this._configurationService, newOptions, false); @@ -544,9 +541,6 @@ export class StandaloneDiffEditor2 extends DiffEditorWidget implements IStandalo this._register(themeDomRegistration); } - public override dispose(): void { - super.dispose(); - } public override updateOptions(newOptions: Readonly): void { updateConfigurationService(this._configurationService, newOptions, true); diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 05aae838ca03c..337851c052634 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -1505,8 +1505,13 @@ export class ActionListWidget extends Disposable { } } else if (element && element.hover?.content && typeof e.index === 'number') { // Show hover for disabled items that have hover content (with delay) - this._hideSubmenu(); - this._scheduleSubmenuShow(element, e.index); + if (this._currentSubmenuElement === element) { + this._cancelSubmenuHide(); + this._cancelSubmenuShow(); + } else { + this._hideSubmenu(); + this._scheduleSubmenuShow(element, e.index); + } } } diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index ccf83901b21b7..357a57f67bbd2 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -357,6 +357,15 @@ word-wrap: break-word; } +.action-list-submenu-hover-header a { + color: var(--vscode-textLink-foreground); +} + +.action-list-submenu-hover-header a:hover, +.action-list-submenu-hover-header a:active { + color: var(--vscode-textLink-activeForeground); +} + .action-list-submenu-hover-header.has-submenu { border-bottom: 1px solid var(--vscode-menu-separatorBackground, var(--vscode-editorWidget-border)); } diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 910c40db359ce..f4ac668759d06 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -88,6 +88,7 @@ export class MenuId { static readonly EditorContextPeek = new MenuId('EditorContextPeek'); static readonly EditorContextShare = new MenuId('EditorContextShare'); static readonly EditorTitle = new MenuId('EditorTitle'); + static readonly EditorTitleLayout = new MenuId('EditorTitleLayout'); static readonly ModalEditorTitle = new MenuId('ModalEditorTitle'); static readonly ModalEditorEditorTitle = new MenuId('ModalEditorEditorTitle'); static readonly CompactWindowEditorTitle = new MenuId('CompactWindowEditorTitle'); diff --git a/src/vs/platform/agentHost/common/remoteAgentHostService.ts b/src/vs/platform/agentHost/common/remoteAgentHostService.ts index 5214fe52cf6fc..73cf6beb37cfd 100644 --- a/src/vs/platform/agentHost/common/remoteAgentHostService.ts +++ b/src/vs/platform/agentHost/common/remoteAgentHostService.ts @@ -22,6 +22,12 @@ export const RemoteAgentHostsSettingId = 'chat.remoteAgentHosts'; /** Configuration key to enable remote agent host connections. */ export const RemoteAgentHostsEnabledSettingId = 'chat.remoteAgentHostsEnabled'; +/** + * Configuration key that controls whether online dev tunnels and + * configured SSH remote agent hosts are auto-connected at startup. + */ +export const RemoteAgentHostAutoConnectSettingId = 'chat.remoteAgentHostsAutoConnect'; + export const enum RemoteAgentHostEntryType { WebSocket = 'websocket', SSH = 'ssh', diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts index e398c8a13ee76..d78d718861bd7 100644 --- a/src/vs/platform/agentHost/common/sessionDataService.ts +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -173,6 +173,19 @@ export interface ISessionDatabase extends IDisposable { */ remapTurnIds(mapping: ReadonlyMap): Promise; + /** + * Creates a safe, consistent copy of the database at the given path + * using SQLite's `VACUUM INTO` command. + */ + vacuumInto(targetPath: string): Promise; + + /** + * Resolves once all in-flight write operations on this database have + * settled. Used by graceful shutdown to flush fire-and-forget writes + * before the process exits. + */ + whenIdle(): Promise; + /** * Close the database connection. After calling this method, the object is * considered disposed and all other methods will reject with an error. @@ -235,4 +248,12 @@ export interface ISessionDataService { * Called at startup; safe to call multiple times. */ cleanupOrphanedData(knownSessionIds: Set): Promise; + + /** + * Resolves once all in-flight write operations across every currently + * open per-session database have settled. Intended for graceful + * shutdown — fire-and-forget writes (e.g. metadata persistence) would + * otherwise be lost when the process exits. + */ + whenIdle(): Promise; } diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index 7f6112007237b..9b09da0f7ea26 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -16,6 +16,7 @@ globalThis._VSCODE_FILE_ROOT = fileURLToPath(new URL('../../../..', import.meta. import * as fs from 'fs'; import * as os from 'os'; import { DisposableStore } from '../../../base/common/lifecycle.js'; +import { raceTimeout } from '../../../base/common/async.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { localize } from '../../../nls.js'; @@ -253,12 +254,31 @@ async function main(): Promise { // Keep alive until stdin closes or signal process.stdin.resume(); - process.stdin.on('end', shutdown); - process.on('SIGTERM', shutdown); - process.on('SIGINT', shutdown); - - function shutdown(): void { + process.stdin.on('end', () => { void shutdown(); }); + process.on('SIGTERM', () => { void shutdown(); }); + process.on('SIGINT', () => { void shutdown(); }); + + let shuttingDown = false; + async function shutdown(): Promise { + if (shuttingDown) { + return; + } + shuttingDown = true; logService.info('[AgentHostServer] Shutting down...'); + // Close the WebSocket server first so no further actions can be + // dispatched while we wait for in-flight writes to flush — otherwise + // a late-arriving action could keep queuing DB writes and either + // undermine the flush or push us past the timeout. + wsServer.dispose(); + // Wait for in-flight persistence writes to flush to the per-session + // SQLite databases. Without this, a SIGTERM arriving while a + // `setMetadata` write (configValues, customTitle, isRead, isDone, + // diffs) is in flight can drop the latest value — see the + // "Session Config persistence across restarts" integration test. + // Capped so a stuck write cannot hang shutdown indefinitely. + await raceTimeout(sessionDataService.whenIdle(), 3000, () => { + logService.warn('[AgentHostServer] Timed out waiting for session database writes to flush; exiting anyway.'); + }); disposables.dispose(); loggerService?.dispose(); process.exit(0); diff --git a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts index 3193302c67255..f0a77734e4029 100644 --- a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts +++ b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts @@ -38,7 +38,7 @@ export interface ICommandFinishedEvent { */ export interface IAgentHostTerminalManager { readonly _serviceBrand: undefined; - createTerminal(params: ICreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean }): Promise; + createTerminal(params: ICreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean }): Promise; writeInput(uri: string, data: string): void; onData(uri: string, cb: (data: string) => void): IDisposable; onExit(uri: string, cb: (exitCode: number) => void): IDisposable; @@ -172,7 +172,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe * Create a new terminal backed by node-pty. * Spawns the user's default shell. */ - async createTerminal(params: ICreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean }): Promise { + async createTerminal(params: ICreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean }): Promise { const uri = params.terminal; if (this._terminals.has(uri)) { throw new Error(`Terminal already exists: ${uri}`); @@ -199,6 +199,18 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe // prevents agent-executed commands from polluting the user's shell history. env['VSCODE_PREVENT_SHELL_HISTORY'] = '1'; } + if (options?.nonInteractive) { + // Suppress paging and interactive prompts so that tool-spawned + // terminals produce clean, machine-friendly output. An empty + // string disables paging in git, less, and most CLI tools and + // is safe on all platforms (unlike 'cat' which isn't on Windows PATH). + env['LC_ALL'] = 'C.UTF-8'; + env['PAGER'] = ''; + env['GIT_PAGER'] = ''; + env['GH_PAGER'] = ''; + env['GIT_TERMINAL_PROMPT'] = '0'; + env['DEBIAN_FRONTEND'] = 'noninteractive'; + } let shellArgs: string[] = []; const injection = await getShellIntegrationInjection( diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index bdb525dc0f87d..66c0b60fd2151 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -464,15 +464,21 @@ export class CopilotAgent extends Disposable implements IAgent { }); const newSessionId = forkResult.sessionId; - // Copy the source session's database file so the forked session - // inherits turn event IDs and file-edit snapshots. - const sourceDbDir = this._sessionDataService.getSessionDataDir(config.fork!.session); + // Copy the source session's database using VACUUM INTO so the + // forked session inherits turn event IDs and file-edit snapshots. + // VACUUM INTO is safe even while the source DB is open. const targetDbDir = this._sessionDataService.getSessionDataDirById(newSessionId); - const sourceDbPath = URI.joinPath(sourceDbDir, SESSION_DB_FILENAME); const targetDbPath = URI.joinPath(targetDbDir, SESSION_DB_FILENAME); try { - await fs.mkdir(targetDbDir.fsPath, { recursive: true }); - await fs.copyFile(sourceDbPath.fsPath, targetDbPath.fsPath); + const sourceDbRef = await this._sessionDataService.tryOpenDatabase(config.fork!.session); + if (sourceDbRef) { + try { + await fs.mkdir(targetDbDir.fsPath, { recursive: true }); + await sourceDbRef.object.vacuumInto(targetDbPath.fsPath); + } finally { + sourceDbRef.dispose(); + } + } } catch (err) { this._logService.warn(`[Copilot] Failed to copy session database for fork: ${err instanceof Error ? err.message : String(err)}`); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts index c944e862b4441..9282fbd2ef529 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts @@ -109,7 +109,7 @@ export class ShellManager { claim, name: shellDisplayName, cwd: cwd ?? this._workingDirectory?.fsPath, - }, { shell: getShellExecutable(shellType), preventShellHistory: true }); + }, { shell: getShellExecutable(shellType), preventShellHistory: true, nonInteractive: true }); const shell: IManagedShell = { id, terminalUri, shellType }; this._shells.set(id, shell); diff --git a/src/vs/platform/agentHost/node/sessionDataService.ts b/src/vs/platform/agentHost/node/sessionDataService.ts index 0fa6c86a5caf9..96c17fc745dee 100644 --- a/src/vs/platform/agentHost/node/sessionDataService.ts +++ b/src/vs/platform/agentHost/node/sessionDataService.ts @@ -12,6 +12,14 @@ import { ISessionDatabase, ISessionDataService, SESSION_DB_FILENAME } from '../c import { SessionDatabase } from './sessionDatabase.js'; class SessionDatabaseCollection extends ReferenceCollection { + + /** + * The set of currently-open databases. Mirrors what's held by the + * underlying ref-counted map, but exposed so {@link SessionDataService.whenIdle} + * can iterate without reaching into private state. + */ + readonly liveDatabases = new Set(); + constructor( private readonly _getDbPath: (key: string) => string, private readonly _logService: ILogService, @@ -22,10 +30,13 @@ class SessionDatabaseCollection extends ReferenceCollection { protected createReferencedObject(key: string): ISessionDatabase { const dbPath = this._getDbPath(key); this._logService.trace(`[SessionDataService] Opening database: ${dbPath}`); - return new SessionDatabase(dbPath); + const db = new SessionDatabase(dbPath); + this.liveDatabases.add(db); + return db; } protected destroyReferencedObject(_key: string, object: ISessionDatabase): void { + this.liveDatabases.delete(object); object.dispose(); } } @@ -124,4 +135,22 @@ export class SessionDataService implements ISessionDataService { this._logService.warn('[SessionDataService] Failed to run orphan cleanup', err); } } + + async whenIdle(): Promise { + // Each `SessionDatabase.whenIdle()` already loops internally until + // that DB is quiescent, so the outer loop only needs to handle the + // case where a new DB was opened (and writes queued against it) + // while we were awaiting an earlier pass. + while (true) { + const dbs = [...this._databases.liveDatabases]; + if (dbs.length === 0) { + return; + } + await Promise.all(dbs.map(db => db.whenIdle())); + const newOnes = [...this._databases.liveDatabases].filter(db => !dbs.includes(db)); + if (newOnes.length === 0) { + return; + } + } + } } diff --git a/src/vs/platform/agentHost/node/sessionDatabase.ts b/src/vs/platform/agentHost/node/sessionDatabase.ts index b1618942fc4d4..5c80593ddc8b8 100644 --- a/src/vs/platform/agentHost/node/sessionDatabase.ts +++ b/src/vs/platform/agentHost/node/sessionDatabase.ts @@ -195,6 +195,17 @@ export class SessionDatabase implements ISessionDatabase { protected _closed: Promise | true | undefined; private readonly _fileEditSequencer = new SequencerByKey(); + /** + * In-flight write operations. Tracked so {@link whenIdle} can await them + * before the process exits — without this, a `SIGTERM` arriving between + * a fire-and-forget mutating call (e.g. `setMetadata`) being invoked and + * its underlying SQLite query completing would silently drop the write. + * Every public mutating method routes its returned promise through + * {@link _track}; reads (`getMetadata`, `getFileEdits`, ...) skip + * tracking since shutdown does not need to wait for them. + */ + private readonly _pendingWrites = new Set>(); + constructor( private readonly _path: string, private readonly _migrations: readonly ISessionDatabaseMigration[] = sessionDatabaseMigrations, @@ -251,23 +262,29 @@ export class SessionDatabase implements ISessionDatabase { // ---- Turns ---------------------------------------------------------- - async createTurn(turnId: string): Promise { - const db = await this._ensureDb(); - await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]); + createTurn(turnId: string): Promise { + return this._track(async () => { + const db = await this._ensureDb(); + await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]); + }); } - async deleteTurn(turnId: string): Promise { - const db = await this._ensureDb(); - await dbRun(db, 'DELETE FROM turns WHERE id = ?', [turnId]); + deleteTurn(turnId: string): Promise { + return this._track(async () => { + const db = await this._ensureDb(); + await dbRun(db, 'DELETE FROM turns WHERE id = ?', [turnId]); + }); } - async setTurnEventId(turnId: string, eventId: string): Promise { - const db = await this._ensureDb(); - await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]); - // Only set the event ID if not already set — steering messages - // trigger additional user.message events within the same turn, - // and we must preserve the first (boundary) event ID. - await dbRun(db, 'UPDATE turns SET event_id = ? WHERE id = ? AND event_id IS NULL', [eventId, turnId]); + setTurnEventId(turnId: string, eventId: string): Promise { + return this._track(async () => { + const db = await this._ensureDb(); + await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]); + // Only set the event ID if not already set — steering messages + // trigger additional user.message events within the same turn, + // and we must preserve the first (boundary) event ID. + await dbRun(db, 'UPDATE turns SET event_id = ? WHERE id = ? AND event_id IS NULL', [eventId, turnId]); + }); } async getTurnEventId(turnId: string): Promise { @@ -292,36 +309,42 @@ export class SessionDatabase implements ISessionDatabase { return row?.event_id as string | undefined ?? undefined; } - async truncateFromTurn(turnId: string): Promise { - const db = await this._ensureDb(); - // Delete the target turn and all turns inserted after it (by rowid order). - // File edits cascade-delete via the foreign key constraint. - await dbRun(db, - `DELETE FROM turns WHERE rowid >= (SELECT rowid FROM turns WHERE id = ?)`, - [turnId], - ); + truncateFromTurn(turnId: string): Promise { + return this._track(async () => { + const db = await this._ensureDb(); + // Delete the target turn and all turns inserted after it (by rowid order). + // File edits cascade-delete via the foreign key constraint. + await dbRun(db, + `DELETE FROM turns WHERE rowid >= (SELECT rowid FROM turns WHERE id = ?)`, + [turnId], + ); + }); } - async deleteTurnsAfter(turnId: string): Promise { - const db = await this._ensureDb(); - // Delete all turns inserted after the given turn (by rowid order), - // keeping the given turn itself. - // File edits cascade-delete via the foreign key constraint. - await dbRun(db, - `DELETE FROM turns WHERE rowid > (SELECT rowid FROM turns WHERE id = ?)`, - [turnId], - ); + deleteTurnsAfter(turnId: string): Promise { + return this._track(async () => { + const db = await this._ensureDb(); + // Delete all turns inserted after the given turn (by rowid order), + // keeping the given turn itself. + // File edits cascade-delete via the foreign key constraint. + await dbRun(db, + `DELETE FROM turns WHERE rowid > (SELECT rowid FROM turns WHERE id = ?)`, + [turnId], + ); + }); } - async deleteAllTurns(): Promise { - const db = await this._ensureDb(); - await dbExec(db, 'DELETE FROM turns'); + deleteAllTurns(): Promise { + return this._track(async () => { + const db = await this._ensureDb(); + await dbExec(db, 'DELETE FROM turns'); + }); } // ---- File edits ----------------------------------------------------- - async storeFileEdit(edit: IFileEditRecord & IFileEditContent): Promise { - return this._fileEditSequencer.queue(edit.filePath, async () => { + storeFileEdit(edit: IFileEditRecord & IFileEditContent): Promise { + return this._track(() => this._fileEditSequencer.queue(edit.filePath, async () => { const db = await this._ensureDb(); // Ensure the turn exists — lazily insert since the turn record // may not have been created by an explicit createTurn() call. @@ -343,7 +366,7 @@ export class SessionDatabase implements ISessionDatabase { edit.removedLines ?? null, ], ); - }); + })); } async getFileEdits(toolCallIds: string[]): Promise { @@ -459,42 +482,79 @@ export class SessionDatabase implements ISessionDatabase { return result; } - async setMetadata(key: string, value: string): Promise { - const db = await this._ensureDb(); - await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)', [key, value]); + setMetadata(key: string, value: string): Promise { + return this._track(async () => { + const db = await this._ensureDb(); + await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)', [key, value]); + }); } - async remapTurnIds(mapping: ReadonlyMap): Promise { - const db = await this._ensureDb(); - // Defer FK checks to commit time so we can update turns.id and - // file_edits.turn_id in any order without mid-statement violations. - // This pragma auto-resets after the transaction ends. - await dbExec(db, 'PRAGMA defer_foreign_keys = ON'); - await dbExec(db, 'BEGIN TRANSACTION'); - try { - // Delete turns not present in the mapping (e.g. turns beyond - // the fork point). File edits cascade-delete via FK. - const oldIds = [...mapping.keys()]; - if (oldIds.length > 0) { - const placeholders = oldIds.map(() => '?').join(','); - await dbRun(db, - `DELETE FROM turns WHERE id NOT IN (${placeholders})`, - oldIds, - ); - } + remapTurnIds(mapping: ReadonlyMap): Promise { + return this._track(async () => { + const db = await this._ensureDb(); + // Defer FK checks to commit time so we can update turns.id and + // file_edits.turn_id in any order without mid-statement violations. + // This pragma auto-resets after the transaction ends. + await dbExec(db, 'PRAGMA defer_foreign_keys = ON'); + await dbExec(db, 'BEGIN TRANSACTION'); + try { + // Delete turns not present in the mapping (e.g. turns beyond + // the fork point). File edits cascade-delete via FK. + const oldIds = [...mapping.keys()]; + if (oldIds.length > 0) { + const placeholders = oldIds.map(() => '?').join(','); + await dbRun(db, + `DELETE FROM turns WHERE id NOT IN (${placeholders})`, + oldIds, + ); + } - // Remap the remaining turn IDs to their new values - for (const [oldId, newId] of mapping) { - await dbRun(db, 'UPDATE turns SET id = ? WHERE id = ?', [newId, oldId]); - await dbRun(db, 'UPDATE file_edits SET turn_id = ? WHERE turn_id = ?', [newId, oldId]); + // Remap the remaining turn IDs to their new values + for (const [oldId, newId] of mapping) { + await dbRun(db, 'UPDATE turns SET id = ? WHERE id = ?', [newId, oldId]); + await dbRun(db, 'UPDATE file_edits SET turn_id = ? WHERE turn_id = ?', [newId, oldId]); + } + await dbExec(db, 'COMMIT'); + } catch (err) { + await dbExec(db, 'ROLLBACK'); + throw err; } - await dbExec(db, 'COMMIT'); - } catch (err) { - await dbExec(db, 'ROLLBACK'); - throw err; + }); + } + + /** + * Resolves once all currently in-flight write operations have settled. + * Used by graceful shutdown to flush pending fire-and-forget writes + * before the process exits. Should be called from a path where no + * further writes are expected; loops until idle to also drain any + * writes that get queued while we're awaiting. + */ + async whenIdle(): Promise { + while (this._pendingWrites.size > 0) { + await Promise.allSettled([...this._pendingWrites]); } } + async vacuumInto(targetPath: string) { + const db = await this._ensureDb(); + await dbRun(db, 'VACUUM INTO ?', [targetPath]); + } + + /** + * Wrap a mutating operation's promise so {@link whenIdle} can await it. + * Invoke at the **outermost** layer of every public mutating method so + * that any internal awaits (notably `_ensureDb()`) are covered too — + * tracking only the leaf `dbRun`/`dbExec` would miss the window + * between the method being called and the query actually being queued. + */ + private _track(fn: () => Promise): Promise { + const p = fn(); + this._pendingWrites.add(p); + const untrack = () => { this._pendingWrites.delete(p); }; + p.then(untrack, untrack); + return p; + } + async close() { await (this._closed ??= this._dbPromise?.then(db => db.close()).catch(() => { }) || true); } diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index 7b9089a7fa09e..467b5427c7646 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -72,6 +72,8 @@ export class TestSessionDatabase implements ISessionDatabase { async close(): Promise { } + async vacuumInto(_targetPath: string): Promise { } + dispose(): void { } async setTurnEventId(_turnId: string, _eventId: string): Promise { } @@ -90,6 +92,8 @@ export class TestSessionDatabase implements ISessionDatabase { async remapTurnIds(_mapping: ReadonlyMap): Promise { } + async whenIdle(): Promise { } + private _toEditRecords(edits: (IFileEditRecord & IFileEditContent)[]): IFileEditRecord[] { return edits.map(({ beforeContent: _, afterContent: _2, ...metadata }) => metadata); } @@ -130,6 +134,7 @@ export function createSessionDataService(database: ISessionDatabase = new TestSe tryOpenDatabase: async () => createReference(database), deleteSessionData: async () => { }, cleanupOrphanedData: async () => { }, + whenIdle: async () => { }, }; } @@ -142,6 +147,7 @@ export function createNullSessionDataService(): ISessionDataService { tryOpenDatabase: async () => undefined, deleteSessionData: async () => { }, cleanupOrphanedData: async () => { }, + whenIdle: async () => { }, }; } diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 83d962767abc5..6d4c6d43689a6 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -65,6 +65,7 @@ suite('AgentService (node dispatcher)', () => { tryOpenDatabase: async () => undefined, deleteSessionData: async () => { }, cleanupOrphanedData: async () => { }, + whenIdle: async () => { }, }; fileService = disposables.add(new FileService(new NullLogService())); disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); @@ -195,6 +196,7 @@ suite('AgentService (node dispatcher)', () => { }), deleteSessionData: async () => { }, cleanupOrphanedData: async () => { }, + whenIdle: async () => { }, }; // Create a mock that returns a session with that ID diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 909524517da33..4c9a9a1e1aad6 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -103,6 +103,7 @@ class TestSessionDataService extends Disposable implements ISessionDataService { deleteSessionData(): Promise { return Promise.resolve(); } cleanupOrphanedData(): Promise { return Promise.resolve(); } + whenIdle(): Promise { return Promise.resolve(); } } class TestCopilotClient implements ICopilotClient { diff --git a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts index 59386ce3b3fa6..ef56bf3e74f22 100644 --- a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts @@ -19,9 +19,9 @@ import { ShellManager, prefixForHistorySuppression } from '../../node/copilot/co class TestAgentHostTerminalManager implements IAgentHostTerminalManager { declare readonly _serviceBrand: undefined; - readonly created: { params: ICreateTerminalParams; options?: { shell?: string; preventShellHistory?: boolean } }[] = []; + readonly created: { params: ICreateTerminalParams; options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean } }[] = []; - async createTerminal(params: ICreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean }): Promise { + async createTerminal(params: ICreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean }): Promise { this.created.push({ params, options }); } writeInput(): void { } @@ -66,7 +66,7 @@ suite('CopilotShellTools', () => { ]); }); - test('opts every managed shell into shell-history suppression', async () => { + test('opts every managed shell into shell-history suppression and non-interactive mode', async () => { const terminalManager = new TestAgentHostTerminalManager(); const services = new ServiceCollection(); services.set(ILogService, new NullLogService()); @@ -79,6 +79,7 @@ suite('CopilotShellTools', () => { assert.strictEqual(terminalManager.created.length, 1); assert.strictEqual(terminalManager.created[0].options?.preventShellHistory, true); + assert.strictEqual(terminalManager.created[0].options?.nonInteractive, true); }); test('prefixForHistorySuppression prepends a space for POSIX shells, no-op for PowerShell', () => { diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts index 5025cccfd3b7d..66fc211103fe1 100644 --- a/src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts @@ -6,7 +6,6 @@ import assert from 'assert'; import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; -import { timeout } from '../../../../../base/common/async.js'; import { URI } from '../../../../../base/common/uri.js'; import type { IResolveSessionConfigResult, ISessionConfigCompletionsResult, ISubscribeResult } from '../../../common/state/protocol/commands.js'; import { ActionType, type ISessionAddedNotification } from '../../../common/state/sessionActions.js'; @@ -202,13 +201,15 @@ suite('Protocol WebSocket - Session Config persistence across restarts', functio const configChanged = await client1.waitForNotification(n => isActionNotification(n, ActionType.SessionConfigChanged)); assert.strictEqual(getActionEnvelope(configChanged).action.type, ActionType.SessionConfigChanged); - // `_persistConfigValues` is fire-and-forget; give the SQLite write - // a moment to flush before tearing down the server. - await timeout(500); - client1.close(); } finally { - server1.process.kill(); + // Trigger graceful shutdown by closing stdin rather than sending + // SIGTERM — on Windows, `child.kill()` (SIGTERM) unconditionally + // terminates the process without invoking the shutdown handler, + // so in-flight `setMetadata` writes never reach SQLite. Closing + // stdin fires `process.stdin.on('end', shutdown)` in the server + // on every platform. + server1.process.stdin!.end(); await new Promise(resolve => server1.process.once('exit', () => resolve())); } @@ -239,7 +240,7 @@ suite('Protocol WebSocket - Session Config persistence across restarts', functio client2.close(); } finally { - server2.process.kill(); + server2.process.stdin!.end(); await new Promise(resolve => server2.process.once('exit', () => resolve())); } }); diff --git a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts index 23d26104eb73a..9253836f420f7 100644 --- a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts @@ -4,11 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { tmpdir } from 'os'; +import * as fs from 'fs/promises'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { SessionDatabase, runMigrations, sessionDatabaseMigrations, type ISessionDatabaseMigration } from '../../node/sessionDatabase.js'; import { FileEditKind } from '../../common/state/sessionState.js'; import type { Database } from '@vscode/sqlite3'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { join } from '../../../../base/common/path.js'; suite('SessionDatabase', () => { @@ -489,4 +493,35 @@ suite('SessionDatabase', () => { assert.ok(tables.includes('session_metadata')); }); }); + + // ---- vacuumInto ----------------------------------------------------- + + suite('vacuumInto', () => { + + let tmpDir: string; + + setup(async () => { + tmpDir = await fs.mkdtemp(join(tmpdir(), 'session-db-test-' + generateUuid())); + }); + + teardown(async () => { + await Promise.all([db?.close(), db2?.close()]); + db = db2 = undefined; + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + test('produces a copy with the same data', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.createTurn('turn-1'); + await db.setTurnEventId('turn-1', 'evt-1'); + await db.setMetadata('key', 'value'); + + const targetPath = join(tmpDir, 'copy.db'); + await db.vacuumInto(targetPath); + + db2 = disposables.add(await SessionDatabase.open(targetPath)); + assert.strictEqual(await db2.getTurnEventId('turn-1'), 'evt-1'); + assert.strictEqual(await db2.getMetadata('key'), 'value'); + }); + }); }); diff --git a/src/vs/platform/keybinding/common/abstractKeybindingService.ts b/src/vs/platform/keybinding/common/abstractKeybindingService.ts index 787e20858a203..19ec19eaf5031 100644 --- a/src/vs/platform/keybinding/common/abstractKeybindingService.ts +++ b/src/vs/platform/keybinding/common/abstractKeybindingService.ts @@ -80,9 +80,6 @@ export abstract class AbstractKeybindingService extends Disposable implements IK this._logging = false; } - public override dispose(): void { - super.dispose(); - } protected abstract _getResolver(): KeybindingResolver; protected abstract _documentHasFocus(): boolean; diff --git a/src/vs/sessions/browser/parts/media/editorPart.css b/src/vs/sessions/browser/parts/media/editorPart.css index b75b69460c996..841b364322f49 100644 --- a/src/vs/sessions/browser/parts/media/editorPart.css +++ b/src/vs/sessions/browser/parts/media/editorPart.css @@ -19,3 +19,47 @@ .agent-sessions-workbench.nosidebar.nochatbar .part.editor:not(.modal-editor-part) { margin-left: 10px; } + +/* Editor Layout Actions Toolbar */ + +.agent-sessions-workbench .part.editor > .content .editor-group-container > .title .editor-actions { + padding-right: 0; +} + +.agent-sessions-workbench .part.editor > .content .editor-group-container > .title .editor-actions-separator { + display: block; + align-self: center; + width: 1px; + height: 16px; + margin: 0 4px; + background-color: var(--vscode-titleBar-activeForeground); + opacity: 0.3; +} + +.agent-sessions-workbench .part.editor > .content .editor-group-container > .title .editor-layout-actions { + display: block; + cursor: default; + flex: initial; + padding: 0 8px 0 4px; + height: var(--editor-group-tab-height); +} + +.agent-sessions-workbench .part.editor > .content .editor-group-container > .title .editor-layout-actions.hidden { + display: none; +} + +.agent-sessions-workbench .part.editor > .content .editor-group-container > .title .editor-layout-actions .action-item { + margin-right: 4px; +} + +.agent-sessions-workbench .part.editor > .content .editor-group-container > .title > .tabs-and-actions-container.wrapping .editor-layout-actions { + /* When tabs are wrapped, position the trailing editor actions at the end of the very last row */ + position: absolute; + bottom: 0; + right: 0; +} + +.agent-sessions-workbench .part.editor > .content .editor-group-container > .title.two-tab-bars > .tabs-and-actions-container:first-child .editor-layout-actions { + /* When multiple tab bars are visible, only show editor actions for the last tab bar */ + display: none; +} diff --git a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts index 77b2797d5c871..a8bbc82367d44 100644 --- a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -14,8 +14,8 @@ import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { AgentSession, IAgentConnection, IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js'; -import { NotificationType } from '../../../../platform/agentHost/common/state/protocol/notifications.js'; import { IResolveSessionConfigResult } from '../../../../platform/agentHost/common/state/protocol/commands.js'; +import { NotificationType } from '../../../../platform/agentHost/common/state/protocol/notifications.js'; import type { IFileEdit, IModelSelection, ISessionConfigPropertySchema, ISessionState, ISessionSummary } from '../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; import { StateComponents } from '../../../../platform/agentHost/common/state/sessionState.js'; @@ -24,12 +24,12 @@ import { IChatSendRequestOptions, IChatService } from '../../../../workbench/con import { IChatSessionFileChange, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; -import { agentHostSessionWorkspaceKey } from '../../../common/agentHostSessionWorkspace.js'; -import { diffsToChanges, diffsEqual, mapProtocolStatus } from '../../../common/agentHostDiffs.js'; +import { diffsEqual, diffsToChanges, mapProtocolStatus } from '../../../common/agentHostDiffs.js'; import { buildMutableConfigSchema, IAgentHostSessionsProvider, resolvedConfigsEqual } from '../../../common/agentHostSessionsProvider.js'; +import { agentHostSessionWorkspaceKey } from '../../../common/agentHostSessionWorkspace.js'; import { isSessionConfigComplete } from '../../../common/sessionConfig.js'; -import { ISendRequestOptions, ISessionChangeEvent } from '../../../services/sessions/common/sessionsProvider.js'; import { IChat, IGitHubInfo, ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, SessionStatus } from '../../../services/sessions/common/session.js'; +import { ISendRequestOptions, ISessionChangeEvent } from '../../../services/sessions/common/sessionsProvider.js'; // ============================================================================ // AgentHostSessionAdapter — shared adapter for local and remote sessions diff --git a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts index b185328525a2c..86ec33bab13fc 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts @@ -26,8 +26,6 @@ import { ViewContainerLocation } from '../../../../workbench/common/views.js'; import { ChangesViewPane } from './changesView.js'; import { SESSIONS_FILES_CONTAINER_ID } from '../../files/browser/files.contribution.js'; import { SESSIONS_FILES_VIEW_ID } from '../../files/browser/filesView.js'; -import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; -import { EditorMaximizedContext } from '../../../common/contextkeys.js'; const openChangesViewActionOptions: IAction2Options = { id: 'workbench.action.agentSessions.openChangesView', @@ -199,59 +197,3 @@ class OpenPullRequestAction extends Action2 { } registerAction2(OpenPullRequestAction); - -class MaximizeMainEditorPartAction extends Action2 { - static readonly ID = 'workbench.action.agentSessions.maximizeMainEditorPart'; - - constructor() { - super({ - id: MaximizeMainEditorPartAction.ID, - title: localize2('maximizeMainEditorPart', "Maximize Editor"), - icon: Codicon.screenFull, - f1: false, - menu: { - id: MenuId.EditorTitle, - group: 'navigation', - order: 100001, - when: ContextKeyExpr.and( - IsSessionsWindowContext, - EditorMaximizedContext.negate()) - } - }); - } - - async run(accessor: ServicesAccessor): Promise { - const layoutService = accessor.get(IAgentWorkbenchLayoutService); - layoutService.setEditorMaximized(true); - } -} - -registerAction2(MaximizeMainEditorPartAction); - -class RestoreMainEditorPartAction extends Action2 { - static readonly ID = 'workbench.action.agentSessions.restoreMainEditorPart'; - - constructor() { - super({ - id: RestoreMainEditorPartAction.ID, - title: localize2('restoreMainEditorPart', "Restore Editor"), - icon: Codicon.screenNormal, - f1: false, - menu: { - id: MenuId.EditorTitle, - group: 'navigation', - order: 100001, - when: ContextKeyExpr.and( - IsSessionsWindowContext, - EditorMaximizedContext) - } - }); - } - - async run(accessor: ServicesAccessor): Promise { - const layoutService = accessor.get(IAgentWorkbenchLayoutService); - layoutService.setEditorMaximized(false); - } -} - -registerAction2(RestoreMainEditorPartAction); diff --git a/src/vs/sessions/contrib/chat/browser/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/chat/browser/agentHostSessionConfigPicker.ts index 1c57397e18aea..f9528deea19f1 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHostSessionConfigPicker.ts @@ -188,9 +188,17 @@ async function confirmAutoApproveLevel(value: string, dialogService: IDialogServ custom: { icon: isAutopilot ? Codicon.rocket : Codicon.warning, markdownDetails: [{ - markdown: new MarkdownString(isAutopilot - ? localize('agentHostAutoApprove.autopilot.warning.detail', "Autopilot will auto-approve all tool calls and continue working autonomously until the task is complete. This includes terminal commands, file edits, and external tool calls. The agent will make decisions on your behalf without asking for confirmation.\n\nYou can stop the agent at any time by clicking the stop button. This applies to the current session only.") - : localize('agentHostAutoApprove.bypass.warning.detail', "Bypass Approvals will auto-approve all tool calls without asking for confirmation. This includes file edits, terminal commands, and external tool calls.")), + markdown: new MarkdownString( + localize( + 'agentHostAutoApprove.warning.detailWithDefaultSetting', + "{0}\n\nTo make this the starting permission level for new chat sessions, change the [{1}](command:workbench.action.openSettings?%5B%22{1}%22%5D) setting.", + isAutopilot + ? localize('agentHostAutoApprove.autopilot.warning.detail', "Autopilot will auto-approve all tool calls and continue working autonomously until the task is complete. This includes terminal commands, file edits, and external tool calls. The agent will make decisions on your behalf without asking for confirmation.\n\nYou can stop the agent at any time by clicking the stop button. This applies to the current session only.") + : localize('agentHostAutoApprove.bypass.warning.detail', "Bypass Approvals will auto-approve all tool calls without asking for confirmation. This includes file edits, terminal commands, and external tool calls."), + ChatConfiguration.DefaultPermissionLevel, + ), + { isTrusted: { enabledCommands: ['workbench.action.openSettings'] } }, + ), }], }, }); diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index 5d05396bfb118..ba888405c0894 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -197,11 +197,10 @@ export class WorkspacePicker extends Disposable { return; } if (item.remoteProvider && item.browseActionIndex === undefined) { - if (item.remoteProvider.remoteAddress?.startsWith(TUNNEL_ADDRESS_PREFIX)) { - // Disconnected tunnel — trigger connection flow - this.commandService.executeCommand('workbench.action.sessions.connectViaTunnel'); - } else { - // Disconnected SSH host — show options menu after widget hides + if (!item.remoteProvider.remoteAddress?.startsWith(TUNNEL_ADDRESS_PREFIX)) { + // Disconnected SSH host — show options menu after widget hides. + // (Disconnected tunnels are rendered as disabled with a + // refresh toolbar action, so onSelect doesn't fire for them.) this._showRemoteHostOptionsDelayed(item.remoteProvider); } } else if (item.browseActionIndex !== undefined) { @@ -437,11 +436,27 @@ export class WorkspacePicker extends Disposable { const status = provider.connectionStatus!.get(); const isConnected = status === RemoteAgentHostConnectionStatus.Connected; const providerBrowseIndex = allBrowseActions.findIndex(a => a.providerId === provider.id); + const isTunnel = provider.remoteAddress?.startsWith(TUNNEL_ADDRESS_PREFIX); const toolbarActions: IAction[] = []; - // Gear menu only for SSH hosts, not tunnel providers - if (!provider.remoteAddress?.startsWith(TUNNEL_ADDRESS_PREFIX)) { + if (isTunnel) { + // Offline/connecting tunnels: surface a refresh button that + // attempts to (re)connect in case the cached status is stale. + if (!isConnected && providerBrowseIndex >= 0) { + const browseIndex = providerBrowseIndex; + toolbarActions.push(toAction({ + id: `workspacePicker.remote.refresh.${provider.id}`, + label: localize('workspacePicker.refreshTunnel', "Attempt to Connect"), + class: ThemeIcon.asClassName(Codicon.refresh), + run: () => { + this.actionWidgetService.hide(); + this._executeBrowseAction(browseIndex); + }, + })); + } + } else { + // Gear menu only for SSH hosts, not tunnel providers toolbarActions.push(toAction({ id: `workspacePicker.remote.gear.${provider.id}`, label: localize('workspacePicker.remoteOptions', "Options"), @@ -453,15 +468,13 @@ export class WorkspacePicker extends Disposable { })); } - const isTunnel = provider.remoteAddress?.startsWith(TUNNEL_ADDRESS_PREFIX); - items.push({ kind: ActionListItemKind.Action, label: provider.label, description: this._getStatusDescription(status), hover: { content: this._getStatusHover(status, provider.remoteAddress) }, group: { title: '', icon: isTunnel ? Codicon.cloud : Codicon.remote }, - disabled: isTunnel ? false : !isConnected, + disabled: !isConnected, item: { browseActionIndex: isConnected && providerBrowseIndex >= 0 ? providerBrowseIndex : undefined, remoteProvider: provider, diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts index 8c20dcdefd8a1..9507116fa346e 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts @@ -16,7 +16,7 @@ import { ISessionsManagementService } from '../../../services/sessions/common/se import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; -import { ChatConfiguration, ChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; +import { ChatConfiguration, ChatPermissionLevel, isChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; import Severity from '../../../../base/common/severity.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; @@ -77,6 +77,15 @@ export class PermissionPicker extends Disposable { render(container: HTMLElement): HTMLElement { this._renderDisposables.clear(); + // Initialize the picker to reflect the configured default permission level + // (`chat.permissions.default`) whenever it is (re-)rendered. If enterprise + // policy disables global auto-approval, clamp to Default regardless of the + // configured default so we never show an elevated level the user can't pick. + const policyRestricted = this.configurationService.inspect(ChatConfiguration.GlobalAutoApprove).policyValue === false; + const configuredDefault = this.configurationService.getValue(ChatConfiguration.DefaultPermissionLevel); + const initialLevel = isChatPermissionLevel(configuredDefault) ? configuredDefault : ChatPermissionLevel.Default; + this._currentLevel = policyRestricted ? ChatPermissionLevel.Default : initialLevel; + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); this._renderDisposables.add({ dispose: () => slot.remove() }); @@ -220,7 +229,10 @@ export class PermissionPicker extends Disposable { custom: { icon: Codicon.warning, markdownDetails: [{ - markdown: new MarkdownString(localize('permissions.autoApprove.warning.detail', "Bypass Approvals will auto-approve all tool calls without asking for confirmation. This includes file edits, terminal commands, and external tool calls.")), + markdown: new MarkdownString( + localize('permissions.autoApprove.warning.detail', "Bypass Approvals will auto-approve all tool calls without asking for confirmation. This includes file edits, terminal commands, and external tool calls.\n\nTo make this the starting permission level for new chat sessions, change the [{0}](command:workbench.action.openSettings?%5B%22{0}%22%5D) setting.", ChatConfiguration.DefaultPermissionLevel), + { isTrusted: { enabledCommands: ['workbench.action.openSettings'] } }, + ), }], }, }); @@ -247,7 +259,10 @@ export class PermissionPicker extends Disposable { custom: { icon: Codicon.rocket, markdownDetails: [{ - markdown: new MarkdownString(localize('permissions.autopilot.warning.detail', "Autopilot will auto-approve all tool calls and continue working autonomously until the task is complete. The agent will make decisions on your behalf without asking for confirmation.\n\nYou can stop the agent at any time by clicking the stop button. This applies to the current session only.")), + markdown: new MarkdownString( + localize('permissions.autopilot.warning.detail', "Autopilot will auto-approve all tool calls and continue working autonomously until the task is complete. The agent will make decisions on your behalf without asking for confirmation.\n\nYou can stop the agent at any time by clicking the stop button. This applies to the current session only.\n\nTo make this the starting permission level for new chat sessions, change the [{0}](command:workbench.action.openSettings?%5B%22{0}%22%5D) setting.", ChatConfiguration.DefaultPermissionLevel), + { isTrusted: { enabledCommands: ['workbench.action.openSettings'] } }, + ), }], }, }); diff --git a/src/vs/sessions/contrib/editor/browser/editor.contribution.ts b/src/vs/sessions/contrib/editor/browser/editor.contribution.ts new file mode 100644 index 0000000000000..f8f9928377b6c --- /dev/null +++ b/src/vs/sessions/contrib/editor/browser/editor.contribution.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize2 } from '../../../../nls.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; +import { EditorMaximizedContext } from '../../../common/contextkeys.js'; + +class MaximizeMainEditorPartAction extends Action2 { + static readonly ID = 'workbench.action.agentSessions.maximizeMainEditorPart'; + + constructor() { + super({ + id: MaximizeMainEditorPartAction.ID, + title: localize2('maximizeMainEditorPart', "Maximize Editor"), + icon: Codicon.screenFull, + f1: false, + menu: { + id: MenuId.EditorTitleLayout, + group: 'navigation', + order: 1, + when: ContextKeyExpr.and( + IsSessionsWindowContext, + EditorMaximizedContext.negate()) + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const layoutService = accessor.get(IAgentWorkbenchLayoutService); + layoutService.setEditorMaximized(true); + } +} + +registerAction2(MaximizeMainEditorPartAction); + +class RestoreMainEditorPartAction extends Action2 { + static readonly ID = 'workbench.action.agentSessions.restoreMainEditorPart'; + + constructor() { + super({ + id: RestoreMainEditorPartAction.ID, + title: localize2('restoreMainEditorPart', "Restore Editor"), + icon: Codicon.screenNormal, + f1: false, + menu: { + id: MenuId.EditorTitleLayout, + group: 'navigation', + order: 1, + when: ContextKeyExpr.and( + IsSessionsWindowContext, + EditorMaximizedContext) + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const layoutService = accessor.get(IAgentWorkbenchLayoutService); + layoutService.setEditorMaximized(false); + } +} + +registerAction2(RestoreMainEditorPartAction); + +class CloseMainEditorPartAction extends Action2 { + static readonly ID = 'workbench.action.agentSessions.closeMainEditorPart'; + + constructor() { + super({ + id: CloseMainEditorPartAction.ID, + title: localize2('closeMainEditorPart', "Close Editor"), + icon: Codicon.close, + f1: false, + menu: { + id: MenuId.EditorTitleLayout, + group: 'navigation', + order: 100, + when: IsSessionsWindowContext + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const commandService = accessor.get(ICommandService); + await commandService.executeCommand('workbench.action.closeAllGroups'); + } +} + +registerAction2(CloseMainEditorPartAction); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 2349a0a4699d6..0f12071dbdc3f 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -10,7 +10,7 @@ import { URI } from '../../../../base/common/uri.js'; import * as nls from '../../../../nls.js'; import { agentHostAuthority } from '../../../../platform/agentHost/common/agentHostUri.js'; import { type AgentProvider, type IAgentConnection } from '../../../../platform/agentHost/common/agentService.js'; -import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostEntry, IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, getEntryAddress } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostEntry, IRemoteAgentHostService, RemoteAgentHostAutoConnectSettingId, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, getEntryAddress } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { TunnelAgentHostsSettingId } from '../../../../platform/agentHost/common/tunnelAgentHost.js'; import { type IProtectedResourceMetadata, type URI as ProtocolURI } from '../../../../platform/agentHost/common/state/protocol/state.js'; import { type IAgentInfo, type ICustomizationRef, type IRootState } from '../../../../platform/agentHost/common/state/sessionState.js'; @@ -102,7 +102,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc // Reconcile providers when configured entries change this._register(this._configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(RemoteAgentHostsSettingId) || e.affectsConfiguration(RemoteAgentHostsEnabledSettingId)) { + if (e.affectsConfiguration(RemoteAgentHostsSettingId) || e.affectsConfiguration(RemoteAgentHostsEnabledSettingId) || e.affectsConfiguration(RemoteAgentHostAutoConnectSettingId)) { this._reconcile(); } })); @@ -194,6 +194,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc * sshConfigHost but no active connection. */ private _reconnectSSHEntries(): void { + const autoConnect = this._configurationService.getValue(RemoteAgentHostAutoConnectSettingId); const entries = this._remoteAgentHostService.configuredEntries; for (const entry of entries) { if (entry.connection.type !== RemoteAgentHostEntryType.SSH || !entry.connection.sshConfigHost) { @@ -208,6 +209,9 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc if (hasConnection || this._pendingSSHReconnects.has(sshConfigHost)) { continue; } + if (!autoConnect) { + continue; + } this._pendingSSHReconnects.add(sshConfigHost); this._logService.info(`[RemoteAgentHost] Re-establishing SSH tunnel for ${sshConfigHost}`); this._sshService.reconnect(sshConfigHost, entry.name).then(() => { @@ -216,6 +220,10 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc }).catch(err => { this._pendingSSHReconnects.delete(sshConfigHost); this._logService.error(`[RemoteAgentHost] SSH reconnect failed for ${sshConfigHost}`, err); + // Host is unreachable — unpublish any cached sessions we + // were showing so the UI doesn't list stale entries for a + // host we cannot currently reach. + this._providerInstances.get(address)?.unpublishCachedSessions(); }); } } @@ -590,6 +598,13 @@ Registry.as(ConfigurationExtensions.Configuration).regis scope: ConfigurationScope.APPLICATION, tags: ['experimental', 'advanced'], }, + [RemoteAgentHostAutoConnectSettingId]: { + type: 'boolean', + description: nls.localize('chat.remoteAgentHosts.autoConnect', "Automatically connect to online dev tunnel and SSH-configured remote agent hosts on startup. When disabled, cached sessions are still shown but connections are established only on demand."), + default: true, + scope: ConfigurationScope.APPLICATION, + tags: ['experimental', 'advanced'], + }, 'chat.sshRemoteAgentHostCommand': { type: 'string', description: nls.localize('chat.sshRemoteAgentHostCommand', "For development: Override the command used to start the remote agent host over SSH. When set, skips automatic CLI installation and runs this command instead. The command must print a WebSocket URL matching ws://127.0.0.1:PORT (optionally with ?tkn=TOKEN) to stdout or stderr./"), diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 17786c2c4c9f0..c4c81f5a9c7e4 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -19,6 +19,7 @@ import { AgentSession, type IAgentConnection, type IAgentSessionMetadata } from import { RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; @@ -53,6 +54,62 @@ function wellKnownAgentProvider(sessionType: string): string | undefined { return undefined; } +/** Storage key prefix for cached session summaries, per remote address. */ +const CACHED_SESSIONS_STORAGE_PREFIX = 'remoteAgentHost.cachedSessions.'; + +/** Maximum number of cached session summaries persisted per host. */ +const CACHED_SESSIONS_MAX_PER_HOST = 100; + +/** + * Serialized shape of an {@link IAgentSessionMetadata} suitable for + * persisting via {@link IStorageService}. URIs are stored as strings + * and diffs are intentionally omitted (they are re-populated when the + * connection refreshes sessions). + */ +interface ISerializedSessionMetadata { + readonly session: string; + readonly startTime: number; + readonly modifiedTime: number; + readonly summary?: string; + readonly model?: IAgentSessionMetadata['model']; + readonly workingDirectory?: string; + readonly isRead?: boolean; + readonly isDone?: boolean; + readonly project?: { readonly uri: string; readonly displayName: string }; +} + +function serializeMetadata(meta: IAgentSessionMetadata): ISerializedSessionMetadata { + return { + session: meta.session.toString(), + startTime: meta.startTime, + modifiedTime: meta.modifiedTime, + summary: meta.summary, + model: meta.model, + workingDirectory: meta.workingDirectory?.toString(), + isRead: meta.isRead, + isDone: meta.isDone, + project: meta.project ? { uri: meta.project.uri.toString(), displayName: meta.project.displayName } : undefined, + }; +} + +function deserializeMetadata(raw: ISerializedSessionMetadata): IAgentSessionMetadata | undefined { + try { + return { + session: URI.parse(raw.session), + startTime: raw.startTime, + modifiedTime: raw.modifiedTime, + summary: raw.summary, + model: raw.model, + workingDirectory: raw.workingDirectory ? URI.parse(raw.workingDirectory) : undefined, + isRead: raw.isRead, + isDone: raw.isDone, + project: raw.project ? { uri: URI.parse(raw.project.uri), displayName: raw.project.displayName } : undefined, + }; + } catch { + return undefined; + } +} + function toLocalProjectUri(uri: URI, connectionAuthority: string): URI { return uri.scheme === Schemas.file ? toAgentHostUri(uri, connectionAuthority) : uri; } @@ -120,11 +177,36 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid private readonly _connectionListeners = this._register(new DisposableStore()); private readonly _connectionAuthority: string; private readonly _connectOnDemand: (() => Promise) | undefined; + /** Storage key used for persisting {@link _sessionCache} snapshots. */ + private readonly _storageKey: string; + /** + * Set when {@link _sessionCache} has changed since the last persist. + * The actual write happens on the next `onWillSaveState` signal from + * {@link IStorageService} so that bursts of notifications do not + * repeatedly re-serialize the whole cache. + */ + private _cacheDirty = false; + /** + * Snapshot of the source metadata for each adapter in {@link _sessionCache}, + * keyed by raw session ID. Captured in {@link createAdapter} and re-used by + * {@link _persistCache} to serialize sessions without having to reconstruct + * every `IAgentSessionMetadata` field from observables. + */ + private readonly _metaByRawId = new Map(); + /** + * When `true`, the provider has been marked unreachable and sessions are + * hidden from {@link getSessions}, even though {@link _sessionCache} and + * persistent storage are retained. Cleared when a new connection is wired + * up in {@link setConnection}, at which point the cached entries are + * re-announced so the UI can repopulate. + */ + private _unpublished = false; constructor( config: IRemoteAgentHostSessionsProviderConfig, @IFileDialogService private readonly _fileDialogService: IFileDialogService, @INotificationService private readonly _notificationService: INotificationService, + @IStorageService private readonly _storageService: IStorageService, @IChatSessionsService chatSessionsService: IChatSessionsService, @IChatService chatService: IChatService, @IChatWidgetService chatWidgetService: IChatWidgetService, @@ -139,6 +221,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid this.id = `agenthost-${this._connectionAuthority}`; this.label = displayName; this.remoteAddress = config.address; + this._storageKey = `${CACHED_SESSIONS_STORAGE_PREFIX}${this._connectionAuthority}`; this.browseActions = [{ label: localize('folders', "Folders"), @@ -146,6 +229,30 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid providerId: this.id, run: () => this._browseForFolder(), }]; + + this._loadCachedSessions(); + + this._register(this._onDidChangeSessions.event(e => { + if (this._unpublished) { + return; + } + if (e.added.length > 0 || e.removed.length > 0 || e.changed.length > 0) { + this._cacheDirty = true; + } + for (const removed of e.removed) { + const rawId = this._rawIdFromChatId(removed.sessionId); + if (rawId) { + this._metaByRawId.delete(rawId); + } + } + })); + + this._register(this._storageService.onWillSaveState(() => { + if (this._cacheDirty) { + this._persistCache(); + this._cacheDirty = false; + } + })); } // -- BaseAgentHostSessionsProvider hooks --------------------------------- @@ -158,6 +265,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid const provider = AgentSession.provider(meta.session) ?? DEFAULT_AGENT_HOST_PROVIDER; const resourceScheme = remoteAgentHostSessionTypeId(this._connectionAuthority, provider); const logicalType = this._logicalSessionTypeForProvider(provider); + this._metaByRawId.set(AgentSession.id(meta.session), meta); return new AgentHostSessionAdapter(meta, this.id, resourceScheme, logicalType, { icon: this.icon, description: new MarkdownString().appendText(this.label), @@ -175,6 +283,10 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid return wellKnownAgentProvider(sessionType) ?? sessionType.substring(`remote-${this._connectionAuthority}-`.length); } + override getSessions(): ISession[] { + return this._unpublished ? [] : super.getSessions(); + } + protected override mapWorkingDirectoryUri(uri: URI): URI { return toAgentHostUri(uri, this._connectionAuthority); } @@ -237,6 +349,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid this._sessionStateSubscriptions.clearAndDisposeAll(); this._connection = connection; this._defaultDirectory = defaultDirectory; + this._unpublished = false; // Dynamically discover session types from the host's advertised agents. const rootStateValue = connection.rootState.value; @@ -256,7 +369,10 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid /** * Clear the connection, e.g. when the remote host disconnects. - * Retains the provider registration so it remains visible in the UI. + * Retains the provider registration so it remains visible in the UI, + * and **preserves** the cached session list so previously loaded + * sessions stay visible while we're offline. Callers that know the + * host is unreachable should follow up with {@link unpublishCachedSessions}. */ clearConnection(): void { this._connectionListeners.clear(); @@ -279,19 +395,91 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid this._onDidChangeSessionTypes.fire(); } - const removed: ISession[] = Array.from(this._sessionCache.values()); + // Drop only the transient pending/draft session; keep the persisted + // cache so the workspace picker keeps showing offline sessions. if (this._pendingSession) { - removed.push(this._pendingSession); + const pending = this._pendingSession; this._pendingSession = undefined; + this._onDidChangeSessions.fire({ added: [], removed: [pending], changed: [] }); } - this._sessionCache.clear(); - this._runningSessionConfigs.clear(); + + // Reset the in-memory cache-initialized flag so a fresh connection + // triggers a full list refresh (which will reconcile against the + // persisted entries we keep on disk). this._cacheInitialized = false; + } + + /** + * Hide cached sessions from the UI without discarding them. Called by the + * host-tracking contributions when they determine the remote host is + * unreachable (tunnel offline or SSH reconnect failed). The in-memory + * cache and persisted storage are left intact so the sessions can be + * restored if the host comes back online in this session, or on the next + * launch. The next {@link setConnection} call re-announces the cached + * entries. + */ + unpublishCachedSessions(): void { + if (this._unpublished) { + return; + } + this._unpublished = true; + const removed: ISession[] = Array.from(this._sessionCache.values()); if (removed.length > 0) { this._onDidChangeSessions.fire({ added: [], removed, changed: [] }); } } + /** Load persisted session summaries into {@link _sessionCache}. */ + private _loadCachedSessions(): void { + const parsed = this._storageService.getObject(this._storageKey, StorageScope.APPLICATION); + if (!Array.isArray(parsed)) { + return; + } + for (const entry of parsed as readonly ISerializedSessionMetadata[]) { + const meta = deserializeMetadata(entry); + if (!meta) { + continue; + } + const rawId = AgentSession.id(meta.session); + if (this._sessionCache.has(rawId)) { + continue; + } + const cached = this.createAdapter(meta); + this._sessionCache.set(rawId, cached); + } + } + + /** + * Persist the current {@link _sessionCache} to storage, capping at + * {@link CACHED_SESSIONS_MAX_PER_HOST} most-recently-modified entries. + * Mutable fields are read from each adapter's observables and overlaid on + * top of the original metadata snapshot captured in {@link _metaByRawId}. + */ + private _persistCache(): void { + const entries: ISerializedSessionMetadata[] = []; + for (const [rawId, adapter] of this._sessionCache) { + const base = this._metaByRawId.get(rawId); + if (!base) { + continue; + } + entries.push(serializeMetadata({ + ...base, + summary: adapter.title.get() || base.summary, + modifiedTime: adapter.updatedAt.get().getTime(), + model: adapter.modelSelection ?? base.model, + isRead: adapter.isRead.get(), + isDone: adapter.isArchived.get(), + })); + } + if (entries.length === 0) { + this._storageService.remove(this._storageKey, StorageScope.APPLICATION); + return; + } + entries.sort((a, b) => b.modifiedTime - a.modifiedTime); + const limited = entries.slice(0, CACHED_SESSIONS_MAX_PER_HOST); + this._storageService.store(this._storageKey, JSON.stringify(limited), StorageScope.APPLICATION, StorageTarget.USER); + } + // -- Session-type sync --------------------------------------------------- /** @@ -365,7 +553,12 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid private async _browseForFolder(): Promise { // Establish connection on demand if a hook is provided (e.g. tunnel relay) if (!this._connection && this._connectOnDemand) { - await this._connectOnDemand(); + try { + await this._connectOnDemand(); + } catch (err) { + this._notificationService.error(localize('connectFailed', "Failed to connect to remote agent host '{0}': {1}", this.label, err instanceof Error ? err.message : String(err))); + return undefined; + } } if (!this._connection) { diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts index 4f8e3fd342f4f..2bd9cf5652544 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts @@ -7,7 +7,7 @@ import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../ import { isWeb } from '../../../../base/common/platform.js'; import { mainWindow } from '../../../../base/browser/window.js'; import * as nls from '../../../../nls.js'; -import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IRemoteAgentHostService, RemoteAgentHostAutoConnectSettingId, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { ITunnelAgentHostService, TUNNEL_ADDRESS_PREFIX, type ITunnelInfo } from '../../../../platform/agentHost/common/tunnelAgentHost.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -42,6 +42,12 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc private readonly _providerInstances = new Map(); private readonly _pendingConnects = new Map>(); private _lastStatusCheck = 0; + /** + * `false` until the first {@link _silentStatusCheck} resolves. Until then + * we keep newly-created providers in the `Connecting` state so the picker + * doesn't briefly show every cached tunnel as "Offline" on startup. + */ + private _initialStatusChecked = false; /** Previous connection status per address — used to detect Connected→Disconnected transitions. */ private readonly _previousStatuses = new Map(); @@ -171,9 +177,13 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc RemoteAgentHostSessionsProvider, { address, name, - connectOnDemand: () => this._connectTunnel(address), + connectOnDemand: () => this._connectTunnel(address, { userInitiated: true }), }, ); + // Surface as "Connecting" until the first silent status check or an + // auto-connect attempt determines the real state; otherwise the picker + // flashes "Offline" for every cached tunnel on startup. + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connecting); store.add(provider); store.add(this._sessionsProvidersService.registerProvider(provider)); this._providerInstances.set(address, provider); @@ -190,6 +200,10 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc provider.setConnectionStatus(connectionInfo.status); } else if (this._pendingConnects.has(address)) { provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connecting); + } else if (!this._initialStatusChecked) { + // Keep the initial "Connecting" state so the picker doesn't + // flash "Offline" before the first silent status check runs. + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connecting); } else { provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Disconnected); } @@ -219,7 +233,7 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc * Establish a relay connection to a cached tunnel. Called on demand * when the user invokes the browse action on an online-but-not-connected tunnel. */ - private _connectTunnel(address: string): Promise { + private _connectTunnel(address: string, options: { readonly userInitiated: boolean }): Promise { const existing = this._pendingConnects.get(address); if (existing) { return existing; @@ -239,15 +253,16 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc const promise = (async () => { // Show a progress notification after a short delay so quick - // connects don't flash a notification. + // connects don't flash a notification. Only show for user-initiated + // connects; background auto-connects and reconnects stay silent. let handle: { close(): void } | undefined; - const timer = setTimeout(() => { + const timer = options.userInitiated ? setTimeout(() => { handle = this._notificationService.notify({ severity: Severity.Info, message: nls.localize('tunnelConnecting', "Connecting to tunnel '{0}'...", cached.name), progress: { infinite: true }, }); - }, 1000); + }, 1000) : undefined; this._updateConnectionStatuses(); try { @@ -279,7 +294,9 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc } throw err; } finally { - clearTimeout(timer); + if (timer !== undefined) { + clearTimeout(timer); + } handle?.close(); this._pendingConnects.delete(address); this._updateConnectionStatuses(); @@ -412,7 +429,7 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc } this._reconnectAttempts.set(address, attempt + 1); - this._connectTunnel(address).catch(() => { /* _connectTunnel already re-schedules on failure */ }); + this._connectTunnel(address, { userInitiated: false }).catch(() => { /* _connectTunnel already re-schedules on failure */ }); }, delay); this._reconnectTimeouts.set(address, timer); } @@ -607,6 +624,8 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc private async _silentStatusCheck(): Promise { const enabled = this._configurationService.getValue(RemoteAgentHostsEnabledSettingId); if (!enabled) { + this._initialStatusChecked = true; + this._updateConnectionStatuses(); return; } @@ -618,6 +637,8 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc onlineTunnels = await this._tunnelService.listTunnels({ silent: true }); } catch { // No cached token or network error — leave statuses as-is + this._initialStatusChecked = true; + this._updateConnectionStatuses(); return; } @@ -643,7 +664,8 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc // Update online/offline status based on hostConnectionCount. // For tunnels, Connected means "host is online" (clickable to connect), // Disconnected means "host is offline". Actual relay connection - // establishment happens when the user clicks the tunnel. + // establishment happens when the user clicks the tunnel (or via + // auto-connect below when enabled). const onlineTunnelMap = new Map(onlineTunnels.map(t => [t.tunnelId, t])); for (const [address, provider] of this._providerInstances) { // Skip tunnels that already have an active relay connection @@ -660,13 +682,18 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connected); } else { provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Disconnected); + // Host is not online — drop any cached sessions we were + // showing for it so the UI doesn't list stale entries. + provider.unpublishCachedSessions(); } } - // Auto-connect online tunnels that aren't connected yet. - // On web there is no workspace picker to trigger manual connection, - // so we connect eagerly when a tunnel is discovered and online. - if (isWeb) { + // Auto-connect online tunnels that aren't connected yet when the + // user has opted into auto-connect (default on). This mirrors the + // web embedder behaviour where no workspace picker is available + // to trigger manual connection. + const autoConnect = this._configurationService.getValue(RemoteAgentHostAutoConnectSettingId); + if (autoConnect) { for (const tunnel of onlineTunnels) { if (tunnel.hostConnectionCount > 0) { const address = `${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`; @@ -674,12 +701,15 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc c => c.address === address && c.status === RemoteAgentHostConnectionStatus.Connected ); if (!alreadyConnected) { - this._connectTunnel(address); + this._connectTunnel(address, { userInitiated: false }); } } } } } + + this._initialStatusChecked = true; + this._updateConnectionStatuses(); } } diff --git a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 5109b3ebad754..04d2ee6d5d65f 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -177,7 +177,7 @@ function createSession(id: string, opts?: { provider?: string; summary?: string; }; } -function createProvider(disposables: DisposableStore, connection: MockAgentConnection, overrides?: { address?: string; connectionName?: string | undefined; sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; openSession?: boolean }): RemoteAgentHostSessionsProvider { +function createProvider(disposables: DisposableStore, connection: MockAgentConnection, overrides?: { address?: string; connectionName?: string | undefined; sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; openSession?: boolean; storageService?: IStorageService; noConnection?: boolean }): RemoteAgentHostSessionsProvider { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IFileDialogService, {}); @@ -196,7 +196,7 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne instantiationService.stub(ILanguageModelsService, { lookupLanguageModel: () => undefined, }); - instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService())); + instantiationService.stub(IStorageService, overrides?.storageService ?? disposables.add(new InMemoryStorageService())); const config: IRemoteAgentHostSessionsProviderConfig = { address: overrides?.address ?? 'localhost:4321', @@ -204,7 +204,9 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne }; const provider = disposables.add(instantiationService.createInstance(RemoteAgentHostSessionsProvider, config)); - provider.setConnection(connection); + if (!overrides?.noConnection) { + provider.setConnection(connection); + } return provider; } @@ -761,6 +763,61 @@ suite('RemoteAgentHostSessionsProvider', () => { assert.strictEqual(session!.loading.get(), false); })); + test('unpublishCachedSessions hides sessions but retains persisted cache', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const storageService = disposables.add(new InMemoryStorageService()); + connection.addSession(createSession('keep-me', { summary: 'Keep Me' })); + const provider = createProvider(disposables, connection, { storageService }); + await timeout(0); + assert.strictEqual(provider.getSessions().length, 1); + + const events: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => events.push(e))); + + provider.unpublishCachedSessions(); + + // Sessions are hidden from the listing immediately. + assert.deepStrictEqual( + { + sessionCount: provider.getSessions().length, + eventRemovedTitles: events.flatMap(e => e.removed.map(s => s.title.get())), + }, + { sessionCount: 0, eventRemovedTitles: ['Keep Me'] }, + ); + + // Flush triggers onWillSaveState; the metadata must survive so the + // session re-serializes instead of being dropped from storage. + await storageService.flush(); + + const provider2 = createProvider(disposables, new MockAgentConnection(), { storageService, noConnection: true }); + assert.deepStrictEqual( + provider2.getSessions().map(s => s.title.get()), + ['Keep Me'], + ); + })); + + test('setConnection after unpublishCachedSessions restores cached sessions', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + connection.addSession(createSession('restore-me', { summary: 'Restore Me' })); + const provider = createProvider(disposables, connection); + await timeout(0); + assert.strictEqual(provider.getSessions().length, 1); + + provider.unpublishCachedSessions(); + assert.strictEqual(provider.getSessions().length, 0); + + // Simulate the host coming back online with a fresh connection that + // still reports the same session. + const reconnected = new MockAgentConnection(); + disposables.add(toDisposable(() => reconnected.dispose())); + reconnected.addSession(createSession('restore-me', { summary: 'Restore Me' })); + provider.setConnection(reconnected); + await timeout(0); + + assert.deepStrictEqual( + provider.getSessions().map(s => s.title.get()), + ['Restore Me'], + ); + })); + test('sendAndCreateChat throws for unknown session', async () => { const provider = createProvider(disposables, connection); await assert.rejects( diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index c6f81b34beacb..2bf8add8446ee 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -457,7 +457,7 @@ class SessionItemRenderer implements ITreeRenderer