From 00915f87d0892344ff120094ec3e2e1d7162355f Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 16 Apr 2026 12:43:16 -0400 Subject: [PATCH 01/56] workbench: fix workspace folder path handling in settings reader (#310774) * workbench: fix workspace folder path handling in settings reader - Fixes handling of workspace folders that have null or undefined URI paths in the settings folder observer. Adds fallback path handling using URI.with() method for folders without explicit paths. Fixes #310367 (Commit message generated by Copilot) * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../chat/common/plugins/workspacePluginSettingsService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts b/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts index 4ccf86b083c58..052b93699ae29 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts @@ -187,7 +187,7 @@ class WorkspaceSettingsReader extends Disposable { const settingsDirs = observableFromEvent( this, workspaceContextService.onDidChangeWorkspaceFolders, - () => workspaceContextService.getWorkspace().folders.map(f => joinPath(f.uri, configFolder)), + () => workspaceContextService.getWorkspace().folders.map(f => f.uri.path ? joinPath(f.uri, configFolder) : joinPath(f.uri.with({ path: '/' }), configFolder)), ); const watcherStore = this._register(new DisposableStore()); From fb4f555b86e84b76609d57e1195d6f8e8bc10c4b Mon Sep 17 00:00:00 2001 From: Maruthan G <113752568+maruthang@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:13:36 +0530 Subject: [PATCH 02/56] fix(tasks): add hover description for required property in taskDefinitions contribution schema (#275670) (#310764) --- src/vs/workbench/contrib/tasks/common/taskDefinitionRegistry.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/workbench/contrib/tasks/common/taskDefinitionRegistry.ts b/src/vs/workbench/contrib/tasks/common/taskDefinitionRegistry.ts index 153e1af45ff34..7f48c40dce3f4 100644 --- a/src/vs/workbench/contrib/tasks/common/taskDefinitionRegistry.ts +++ b/src/vs/workbench/contrib/tasks/common/taskDefinitionRegistry.ts @@ -27,6 +27,7 @@ const taskDefinitionSchema: IJSONSchema = { }, required: { type: 'array', + markdownDescription: nls.localize('TaskDefinition.required', 'The names of the properties from the `properties` object that must be provided for a task of this type to be considered a match. Used by VS Code to associate a `tasks.json` entry with a registered task provider.'), items: { type: 'string' } From 1482ee8d606874134c09a51156ece1129edd7f4c Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Thu, 16 Apr 2026 16:29:55 +0200 Subject: [PATCH 03/56] Fix chat-lib pipeline --- extensions/copilot/build/npm-package-next.yml | 147 ++++++++++++++++++ extensions/copilot/build/npm-package.yml | 78 +++++++--- 2 files changed, 201 insertions(+), 24 deletions(-) create mode 100644 extensions/copilot/build/npm-package-next.yml diff --git a/extensions/copilot/build/npm-package-next.yml b/extensions/copilot/build/npm-package-next.yml new file mode 100644 index 0000000000000..117d6c141cd1f --- /dev/null +++ b/extensions/copilot/build/npm-package-next.yml @@ -0,0 +1,147 @@ +trigger: none + +pr: none + +resources: + repositories: + - repository: templates + type: github + name: microsoft/vscode-engineering + ref: main + endpoint: Monaco + pipelines: + - pipeline: vscode + source: 'โญ๏ธ VS Code' + trigger: + stages: + - Release + branches: + include: + - main + - release/* + +parameters: + - name: NPM_REGISTRY + displayName: "Custom NPM Registry" + type: string + default: 'https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/npm/registry/' + - name: nextVersion + displayName: '๐Ÿš€ Release Version (eg: none, major, minor, patch, prerelease, or X.X.X)' + type: string + default: 'none' + +name: "$(Date:yyyyMMdd).$(Rev:r)${{ replace(format(' (๐Ÿš€ {0})', parameters.nextVersion), ' (๐Ÿš€ none)', '') }}" + +extends: + template: azure-pipelines/npm-package/pipeline.yml@templates + parameters: + npmPackages: + - name: vscode-copilot-chat + buildSteps: + - task: NodeTool@0 + inputs: + versionSpec: 22.x + displayName: ๐Ÿ›  Install Node.js (22.x) + + - bash: | + npm config set registry ${{ parameters.NPM_REGISTRY }} + echo "##vso[task.setvariable variable=NPMRC_PATH]$(npm config get userconfig)" + displayName: ๐Ÿ”ง Set npm registry + + - task: npmAuthenticate@0 + inputs: + workingFile: $(NPMRC_PATH) + displayName: ๐Ÿ”‘ Authenticate npm + + - download: vscode + artifact: copilot_vsix + displayName: ๐Ÿ“ฅ Download Copilot VSIX + condition: and(succeeded(), ne(variables['resources.pipeline.vscode.runName'], '')) + + - bash: | + set -e + if [ -f "$(Pipeline.Workspace)/vscode/copilot_vsix/copilot-chat.vsix" ]; then + unzip -o "$(Pipeline.Workspace)/vscode/copilot_vsix/copilot-chat.vsix" 'extension/package.json' + VERSION=$(node -p "require('./extension/package.json').version") + rm -rf extension + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + pkg.version = '$VERSION'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + echo "Set extension version to $VERSION" + else + echo "No Copilot VSIX found, using version from package.json" + fi + displayName: ๐Ÿ“‹ Sync version from extension + workingDirectory: extensions/copilot + + - bash: npm ci && npm run extract-chat-lib && rm -rf node_modules + displayName: ๐Ÿ“‚ Extract chat-lib + workingDirectory: extensions/copilot + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + + - bash: | + set -e + VERSION=$(node -p "require('../package.json').version") + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + pkg.version = '$VERSION'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + echo "Set chat-lib version to $VERSION" + displayName: ๐Ÿ“‹ Sync chat-lib version + workingDirectory: extensions/copilot/chat-lib + + - script: npm ci + displayName: ๐Ÿ“ฆ Install chat-lib dependencies + workingDirectory: extensions/copilot/chat-lib + + - script: npm run build + displayName: ๐Ÿ”จ Build chat-lib + workingDirectory: extensions/copilot/chat-lib + testPlatforms: + - name: Linux + nodeVersions: [22.x] + - name: MacOS + nodeVersions: [22.x] + - name: Windows + nodeVersions: [22.x] + workingDirectory: extensions/copilot/chat-lib + testSteps: + - bash: | + npm config set registry ${{ parameters.NPM_REGISTRY }} + echo "##vso[task.setvariable variable=NPMRC_PATH]$(npm config get userconfig)" + displayName: ๐Ÿ”ง Set npm registry + + - task: npmAuthenticate@0 + inputs: + workingFile: $(NPMRC_PATH) + displayName: ๐Ÿ”‘ Authenticate npm + + - bash: npm ci && npm run extract-chat-lib && rm -rf node_modules + displayName: ๐Ÿ“‚ Extract chat-lib + workingDirectory: extensions/copilot + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + + - script: npm ci + displayName: ๐Ÿ“ฆ Install chat-lib dependencies + workingDirectory: extensions/copilot/chat-lib + + - script: npm run build + displayName: ๐Ÿ”จ Build chat-lib + workingDirectory: extensions/copilot/chat-lib + + - script: npm test + displayName: ๐Ÿงช Run chat-lib tests + workingDirectory: extensions/copilot/chat-lib + # Triggered by โญ๏ธ VS Code pipeline โ†’ always publish to next + publishPackage: true + publishRequiresApproval: false + tag: next + ghCreateRelease: false + ghReleaseAddChangeLog: false diff --git a/extensions/copilot/build/npm-package.yml b/extensions/copilot/build/npm-package.yml index ee92f2caf15a4..e816765eea65c 100644 --- a/extensions/copilot/build/npm-package.yml +++ b/extensions/copilot/build/npm-package.yml @@ -3,11 +3,9 @@ trigger: branches: include: - main - tags: - include: - - v* + - release/* -pr: [main] +pr: none resources: repositories: @@ -18,6 +16,10 @@ resources: endpoint: Monaco parameters: + - name: NPM_REGISTRY + displayName: "Custom NPM Registry" + type: string + default: 'https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/npm/registry/' - name: nextVersion displayName: '๐Ÿš€ Release Version (eg: none, major, minor, patch, prerelease, or X.X.X)' type: string @@ -36,16 +38,42 @@ extends: versionSpec: 22.x displayName: ๐Ÿ›  Install Node.js (22.x) + - bash: | + npm config set registry ${{ parameters.NPM_REGISTRY }} + echo "##vso[task.setvariable variable=NPMRC_PATH]$(npm config get userconfig)" + displayName: ๐Ÿ”ง Set npm registry + + - task: npmAuthenticate@0 + inputs: + workingFile: $(NPMRC_PATH) + displayName: ๐Ÿ”‘ Authenticate npm + - bash: npm ci && npm run extract-chat-lib && rm -rf node_modules displayName: ๐Ÿ“‚ Extract chat-lib + workingDirectory: extensions/copilot + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + + - bash: | + set -e + VERSION=$(node -p "require('../package.json').version") + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + pkg.version = '$VERSION'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + echo "Set chat-lib version to $VERSION" + displayName: ๐Ÿ“‹ Sync chat-lib version + workingDirectory: extensions/copilot/chat-lib - script: npm ci displayName: ๐Ÿ“ฆ Install chat-lib dependencies - workingDirectory: chat-lib + workingDirectory: extensions/copilot/chat-lib - script: npm run build displayName: ๐Ÿ”จ Build chat-lib - workingDirectory: chat-lib + workingDirectory: extensions/copilot/chat-lib testPlatforms: - name: Linux nodeVersions: [22.x] @@ -53,36 +81,38 @@ extends: nodeVersions: [22.x] - name: Windows nodeVersions: [22.x] - workingDirectory: chat-lib + workingDirectory: extensions/copilot/chat-lib testSteps: + - bash: | + npm config set registry ${{ parameters.NPM_REGISTRY }} + echo "##vso[task.setvariable variable=NPMRC_PATH]$(npm config get userconfig)" + displayName: ๐Ÿ”ง Set npm registry + + - task: npmAuthenticate@0 + inputs: + workingFile: $(NPMRC_PATH) + displayName: ๐Ÿ”‘ Authenticate npm + - bash: npm ci && npm run extract-chat-lib && rm -rf node_modules displayName: ๐Ÿ“‚ Extract chat-lib + workingDirectory: extensions/copilot + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - script: npm ci displayName: ๐Ÿ“ฆ Install chat-lib dependencies - workingDirectory: chat-lib + workingDirectory: extensions/copilot/chat-lib - script: npm run build displayName: ๐Ÿ”จ Build chat-lib - workingDirectory: chat-lib + workingDirectory: extensions/copilot/chat-lib - script: npm test displayName: ๐Ÿงช Run chat-lib tests - workingDirectory: chat-lib - # Tag-triggered: date-stamped patch (e.g., v0.40.2026031601) โ†’ publish to next - ${{ if and(startsWith(variables['Build.SourceBranch'], 'refs/tags/v'), gt(length(variables['Build.SourceBranchName']), 13)) }}: - publishPackage: true - publishRequiresApproval: false - nextVersion: ${{ replace(variables['Build.SourceBranchName'], 'v', '') }} - tag: next - # Tag-triggered: short patch (e.g., v0.39.1) โ†’ publish to latest - ${{ elseif startsWith(variables['Build.SourceBranch'], 'refs/tags/v') }}: - publishPackage: true - publishRequiresApproval: false - nextVersion: ${{ replace(variables['Build.SourceBranchName'], 'v', '') }} - ${{ elseif eq(parameters.nextVersion, 'none') }}: + workingDirectory: extensions/copilot/chat-lib + # Manually triggered โ†’ publish to latest or next + ${{ if eq(parameters.nextVersion, 'none') }}: publishPackage: false - # Manual prerelease โ†’ publish to next ${{ elseif eq(parameters.nextVersion, 'prerelease') }}: publishPackage: true publishRequiresApproval: false @@ -92,4 +122,4 @@ extends: publishPackage: true nextVersion: ${{ parameters.nextVersion }} ghCreateRelease: false - ghReleaseAddChangeLog: false \ No newline at end of file + ghReleaseAddChangeLog: false From 57c365ca77c240321dcd8e247cc513670e5f95f7 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Thu, 16 Apr 2026 16:30:28 +0200 Subject: [PATCH 04/56] Consolidate chat-lib pipelines --- extensions/copilot/build/npm-package-next.yml | 48 ++---------- extensions/copilot/build/npm-package.yml | 74 ++----------------- .../copilot/build/steps/build-chat-lib.yml | 40 ++++++++++ extensions/copilot/build/steps/setup-npm.yml | 14 ++++ .../copilot/build/steps/test-chat-lib.yml | 26 +++++++ 5 files changed, 93 insertions(+), 109 deletions(-) create mode 100644 extensions/copilot/build/steps/build-chat-lib.yml create mode 100644 extensions/copilot/build/steps/setup-npm.yml create mode 100644 extensions/copilot/build/steps/test-chat-lib.yml diff --git a/extensions/copilot/build/npm-package-next.yml b/extensions/copilot/build/npm-package-next.yml index 117d6c141cd1f..48ccf3a466f48 100644 --- a/extensions/copilot/build/npm-package-next.yml +++ b/extensions/copilot/build/npm-package-next.yml @@ -25,12 +25,8 @@ parameters: displayName: "Custom NPM Registry" type: string default: 'https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/npm/registry/' - - name: nextVersion - displayName: '๐Ÿš€ Release Version (eg: none, major, minor, patch, prerelease, or X.X.X)' - type: string - default: 'none' -name: "$(Date:yyyyMMdd).$(Rev:r)${{ replace(format(' (๐Ÿš€ {0})', parameters.nextVersion), ' (๐Ÿš€ none)', '') }}" +name: "$(Date:yyyyMMdd).$(Rev:r)" extends: template: azure-pipelines/npm-package/pipeline.yml@templates @@ -43,15 +39,9 @@ extends: versionSpec: 22.x displayName: ๐Ÿ›  Install Node.js (22.x) - - bash: | - npm config set registry ${{ parameters.NPM_REGISTRY }} - echo "##vso[task.setvariable variable=NPMRC_PATH]$(npm config get userconfig)" - displayName: ๐Ÿ”ง Set npm registry - - - task: npmAuthenticate@0 - inputs: - workingFile: $(NPMRC_PATH) - displayName: ๐Ÿ”‘ Authenticate npm + - template: extensions/copilot/build/steps/setup-npm.yml@self + parameters: + NPM_REGISTRY: ${{ parameters.NPM_REGISTRY }} - download: vscode artifact: copilot_vsix @@ -112,33 +102,9 @@ extends: nodeVersions: [22.x] workingDirectory: extensions/copilot/chat-lib testSteps: - - bash: | - npm config set registry ${{ parameters.NPM_REGISTRY }} - echo "##vso[task.setvariable variable=NPMRC_PATH]$(npm config get userconfig)" - displayName: ๐Ÿ”ง Set npm registry - - - task: npmAuthenticate@0 - inputs: - workingFile: $(NPMRC_PATH) - displayName: ๐Ÿ”‘ Authenticate npm - - - bash: npm ci && npm run extract-chat-lib && rm -rf node_modules - displayName: ๐Ÿ“‚ Extract chat-lib - workingDirectory: extensions/copilot - env: - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - - - script: npm ci - displayName: ๐Ÿ“ฆ Install chat-lib dependencies - workingDirectory: extensions/copilot/chat-lib - - - script: npm run build - displayName: ๐Ÿ”จ Build chat-lib - workingDirectory: extensions/copilot/chat-lib - - - script: npm test - displayName: ๐Ÿงช Run chat-lib tests - workingDirectory: extensions/copilot/chat-lib + - template: extensions/copilot/build/steps/test-chat-lib.yml@self + parameters: + NPM_REGISTRY: ${{ parameters.NPM_REGISTRY }} # Triggered by โญ๏ธ VS Code pipeline โ†’ always publish to next publishPackage: true publishRequiresApproval: false diff --git a/extensions/copilot/build/npm-package.yml b/extensions/copilot/build/npm-package.yml index e816765eea65c..7780cfbe7b2fc 100644 --- a/extensions/copilot/build/npm-package.yml +++ b/extensions/copilot/build/npm-package.yml @@ -33,47 +33,9 @@ extends: npmPackages: - name: vscode-copilot-chat buildSteps: - - task: NodeTool@0 - inputs: - versionSpec: 22.x - displayName: ๐Ÿ›  Install Node.js (22.x) - - - bash: | - npm config set registry ${{ parameters.NPM_REGISTRY }} - echo "##vso[task.setvariable variable=NPMRC_PATH]$(npm config get userconfig)" - displayName: ๐Ÿ”ง Set npm registry - - - task: npmAuthenticate@0 - inputs: - workingFile: $(NPMRC_PATH) - displayName: ๐Ÿ”‘ Authenticate npm - - - bash: npm ci && npm run extract-chat-lib && rm -rf node_modules - displayName: ๐Ÿ“‚ Extract chat-lib - workingDirectory: extensions/copilot - env: - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - - - bash: | - set -e - VERSION=$(node -p "require('../package.json').version") - node -e " - const fs = require('fs'); - const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); - pkg.version = '$VERSION'; - fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); - " - echo "Set chat-lib version to $VERSION" - displayName: ๐Ÿ“‹ Sync chat-lib version - workingDirectory: extensions/copilot/chat-lib - - - script: npm ci - displayName: ๐Ÿ“ฆ Install chat-lib dependencies - workingDirectory: extensions/copilot/chat-lib - - - script: npm run build - displayName: ๐Ÿ”จ Build chat-lib - workingDirectory: extensions/copilot/chat-lib + - template: extensions/copilot/build/steps/build-chat-lib.yml@self + parameters: + NPM_REGISTRY: ${{ parameters.NPM_REGISTRY }} testPlatforms: - name: Linux nodeVersions: [22.x] @@ -83,33 +45,9 @@ extends: nodeVersions: [22.x] workingDirectory: extensions/copilot/chat-lib testSteps: - - bash: | - npm config set registry ${{ parameters.NPM_REGISTRY }} - echo "##vso[task.setvariable variable=NPMRC_PATH]$(npm config get userconfig)" - displayName: ๐Ÿ”ง Set npm registry - - - task: npmAuthenticate@0 - inputs: - workingFile: $(NPMRC_PATH) - displayName: ๐Ÿ”‘ Authenticate npm - - - bash: npm ci && npm run extract-chat-lib && rm -rf node_modules - displayName: ๐Ÿ“‚ Extract chat-lib - workingDirectory: extensions/copilot - env: - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - - - script: npm ci - displayName: ๐Ÿ“ฆ Install chat-lib dependencies - workingDirectory: extensions/copilot/chat-lib - - - script: npm run build - displayName: ๐Ÿ”จ Build chat-lib - workingDirectory: extensions/copilot/chat-lib - - - script: npm test - displayName: ๐Ÿงช Run chat-lib tests - workingDirectory: extensions/copilot/chat-lib + - template: extensions/copilot/build/steps/test-chat-lib.yml@self + parameters: + NPM_REGISTRY: ${{ parameters.NPM_REGISTRY }} # Manually triggered โ†’ publish to latest or next ${{ if eq(parameters.nextVersion, 'none') }}: publishPackage: false diff --git a/extensions/copilot/build/steps/build-chat-lib.yml b/extensions/copilot/build/steps/build-chat-lib.yml new file mode 100644 index 0000000000000..a196cf329aeba --- /dev/null +++ b/extensions/copilot/build/steps/build-chat-lib.yml @@ -0,0 +1,40 @@ +parameters: + - name: NPM_REGISTRY + type: string + +steps: + - task: NodeTool@0 + inputs: + versionSpec: 22.x + displayName: ๐Ÿ›  Install Node.js (22.x) + + - template: setup-npm.yml + parameters: + NPM_REGISTRY: ${{ parameters.NPM_REGISTRY }} + + - bash: npm ci && npm run extract-chat-lib && rm -rf node_modules + displayName: ๐Ÿ“‚ Extract chat-lib + workingDirectory: extensions/copilot + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + + - bash: | + set -e + VERSION=$(node -p "require('../package.json').version") + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + pkg.version = '$VERSION'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + echo "Set chat-lib version to $VERSION" + displayName: ๐Ÿ“‹ Sync chat-lib version + workingDirectory: extensions/copilot/chat-lib + + - script: npm ci + displayName: ๐Ÿ“ฆ Install chat-lib dependencies + workingDirectory: extensions/copilot/chat-lib + + - script: npm run build + displayName: ๐Ÿ”จ Build chat-lib + workingDirectory: extensions/copilot/chat-lib diff --git a/extensions/copilot/build/steps/setup-npm.yml b/extensions/copilot/build/steps/setup-npm.yml new file mode 100644 index 0000000000000..fff5ad3d78ee1 --- /dev/null +++ b/extensions/copilot/build/steps/setup-npm.yml @@ -0,0 +1,14 @@ +parameters: + - name: NPM_REGISTRY + type: string + +steps: + - bash: | + npm config set registry ${{ parameters.NPM_REGISTRY }} + echo "##vso[task.setvariable variable=NPMRC_PATH]$(npm config get userconfig)" + displayName: ๐Ÿ”ง Set npm registry + + - task: npmAuthenticate@0 + inputs: + workingFile: $(NPMRC_PATH) + displayName: ๐Ÿ”‘ Authenticate npm diff --git a/extensions/copilot/build/steps/test-chat-lib.yml b/extensions/copilot/build/steps/test-chat-lib.yml new file mode 100644 index 0000000000000..2dc8b352eec13 --- /dev/null +++ b/extensions/copilot/build/steps/test-chat-lib.yml @@ -0,0 +1,26 @@ +parameters: + - name: NPM_REGISTRY + type: string + +steps: + - template: setup-npm.yml + parameters: + NPM_REGISTRY: ${{ parameters.NPM_REGISTRY }} + + - bash: npm ci && npm run extract-chat-lib && rm -rf node_modules + displayName: ๐Ÿ“‚ Extract chat-lib + workingDirectory: extensions/copilot + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + + - script: npm ci + displayName: ๐Ÿ“ฆ Install chat-lib dependencies + workingDirectory: extensions/copilot/chat-lib + + - script: npm run build + displayName: ๐Ÿ”จ Build chat-lib + workingDirectory: extensions/copilot/chat-lib + + - script: npm test + displayName: ๐Ÿงช Run chat-lib tests + workingDirectory: extensions/copilot/chat-lib From 06cd43f576c29d021c4a3c6c3957119ffde123a2 Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Thu, 16 Apr 2026 12:44:58 -0400 Subject: [PATCH 05/56] Cleanup usage dash further + fix it not refreshing (#310778) Co-authored-by: Copilot --- .../defaultAccount/common/defaultAccount.ts | 4 +- .../browser/chatStatus/chatStatusDashboard.ts | 56 +++++++++---------- .../accounts/browser/defaultAccount.ts | 32 +++++------ .../chat/common/chatEntitlementService.ts | 2 +- 4 files changed, 44 insertions(+), 50 deletions(-) diff --git a/src/vs/platform/defaultAccount/common/defaultAccount.ts b/src/vs/platform/defaultAccount/common/defaultAccount.ts index 5e543ddd942a7..ff1f863bbb3e7 100644 --- a/src/vs/platform/defaultAccount/common/defaultAccount.ts +++ b/src/vs/platform/defaultAccount/common/defaultAccount.ts @@ -15,7 +15,7 @@ export interface IDefaultAccountProvider { readonly copilotTokenInfo: ICopilotTokenInfo | null; readonly onDidChangeCopilotTokenInfo: Event; getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider; - refresh(): Promise; + refresh(options?: { forceRefresh?: boolean }): Promise; signIn(options?: { additionalScopes?: readonly string[];[key: string]: unknown }): Promise; signOut(): Promise; } @@ -32,7 +32,7 @@ export interface IDefaultAccountService { getDefaultAccount(): Promise; getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider; setDefaultAccountProvider(provider: IDefaultAccountProvider): void; - refresh(): Promise; + refresh(options?: { forceRefresh?: boolean }): Promise; signIn(options?: { additionalScopes?: readonly string[];[key: string]: unknown }): Promise; signOut(): Promise; } diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts index 18d166f82fbdf..457502172acd9 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts @@ -155,10 +155,15 @@ export class ChatStatusDashboard extends DomWidget { private render(): void { const token = cancelOnDispose(this._store); - const hasQuotas = !!(this.chatEntitlementService.quotas.chat || this.chatEntitlementService.quotas.premiumChat); + const { chat, premiumChat, completions } = this.chatEntitlementService.quotas; + const hasQuotas = !!(chat || premiumChat); const isAnonymousWithSentiment = this.chatEntitlementService.anonymous && this.chatEntitlementService.sentiment.completed; const hasUsageSection = hasQuotas || isAnonymousWithSentiment; - const hasInlineSuggestionsSection = !!(this.chatEntitlementService.quotas.completions) || + const hasVisibleUsageContent = !!(chat && !chat.unlimited && chat.total > 0) || + !!(premiumChat && !premiumChat.unlimited && premiumChat.total > 0) || + !!(completions && !completions.unlimited && completions.total > 0) || + isAnonymousWithSentiment; + const hasInlineSuggestionsSection = !this.options?.disableInlineSuggestionsSettings || !this.options?.disableModelSelection || !this.options?.disableProviderOptions || @@ -176,8 +181,11 @@ export class ChatStatusDashboard extends DomWidget { })); } + // Always trigger a fresh quota fetch when the dashboard opens + const updatePromise = this.chatEntitlementService.update(token); + // Tabbed layout when both Usage and Inline Suggestions sections are available - if (hasUsageSection && hasInlineSuggestionsSection) { + if (hasVisibleUsageContent && hasInlineSuggestionsSection) { const usageContent = $('div.tab-content.active'); usageContent.setAttribute('role', 'tabpanel'); usageContent.id = 'chat-status-usage-panel'; @@ -241,13 +249,12 @@ export class ChatStatusDashboard extends DomWidget { tabContentContainer.appendChild(usageContent); tabContentContainer.appendChild(inlineSuggestionsContent); - const updatePromise = this.chatEntitlementService.update(token); this.renderUsageContent(usageContent, token, updatePromise); this.renderInlineSuggestionsContent(inlineSuggestionsContent, token, updatePromise); - } else if (hasUsageSection) { - this.renderUsageContent(this.element, token); + } else if (hasVisibleUsageContent) { + this.renderUsageContent(this.element, token, updatePromise); } else if (hasInlineSuggestionsSection) { - this.renderInlineSuggestionsContent(this.element, token); + this.renderInlineSuggestionsContent(this.element, token, updatePromise); } // New to Chat / Signed out @@ -308,7 +315,7 @@ export class ChatStatusDashboard extends DomWidget { private renderUsageContent(container: HTMLElement, token: CancellationToken, updatePromise?: Promise): void { const { chat: chatQuota, completions: completionsQuota, premiumChat: premiumChatQuota, resetDate, resetDateHasTime } = this.chatEntitlementService.quotas; - if (chatQuota || premiumChatQuota) { + if (chatQuota || premiumChatQuota || completionsQuota) { const resetLabel = resetDate ? (resetDateHasTime ? localize('quotaResetsAt', "Resets {0} at {1}", this.dateFormatter.value.format(new Date(resetDate)), this.timeFormatter.value.format(new Date(resetDate))) : localize('quotaResets', "Resets {0}", this.dateFormatter.value.format(new Date(resetDate)))) : undefined; let chatQuotaIndicator: ((quota: IQuotaSnapshot | string) => void) | undefined; @@ -322,6 +329,11 @@ export class ChatStatusDashboard extends DomWidget { premiumChatQuotaIndicator = this.createQuotaIndicator(container, this._store, premiumChatQuota, premiumChatLabel, true, resetLabel); } + let completionsQuotaIndicator: ((quota: IQuotaSnapshot | string) => void) | undefined; + if (completionsQuota && !completionsQuota.unlimited && completionsQuota.total > 0) { + completionsQuotaIndicator = this.createQuotaIndicator(container, this._store, completionsQuota, localize('completionsLabel', "Inline Suggestions"), false, resetLabel); + } + if (this.chatEntitlementService.entitlement === ChatEntitlement.Free && (Number(chatQuota?.percentRemaining) <= 25 || Number(completionsQuota?.percentRemaining) <= 25)) { const upgradeProButton = this._store.add(new Button(container, { ...defaultButtonStyles, hoverDelegate: nativeHoverDelegate, secondary: this.canUseChat() /* use secondary color when chat can still be used */ })); upgradeProButton.label = localize('upgradeToCopilotPro', "Upgrade to GitHub Copilot Pro"); @@ -334,13 +346,16 @@ export class ChatStatusDashboard extends DomWidget { return; } - const { chat: chatQuota, premiumChat: premiumChatQuota } = this.chatEntitlementService.quotas; + const { chat: chatQuota, premiumChat: premiumChatQuota, completions: completionsQuota } = this.chatEntitlementService.quotas; if (chatQuota) { chatQuotaIndicator?.(chatQuota); } if (premiumChatQuota) { premiumChatQuotaIndicator?.(premiumChatQuota); } + if (completionsQuota) { + completionsQuotaIndicator?.(completionsQuota); + } })(); } @@ -350,28 +365,7 @@ export class ChatStatusDashboard extends DomWidget { } } - private renderInlineSuggestionsContent(container: HTMLElement, token: CancellationToken, updatePromise?: Promise): void { - // Completions quota - { - const { completions: completionsQuota, resetDate, resetDateHasTime } = this.chatEntitlementService.quotas; - const resetLabel = resetDate ? (resetDateHasTime ? localize('quotaResetsAt', "Resets {0} at {1}", this.dateFormatter.value.format(new Date(resetDate)), this.timeFormatter.value.format(new Date(resetDate))) : localize('quotaResets', "Resets {0}", this.dateFormatter.value.format(new Date(resetDate)))) : undefined; - if (completionsQuota && !completionsQuota.unlimited && completionsQuota.total > 0) { - const completionsQuotaIndicator = this.createQuotaIndicator(container, this._store, completionsQuota, localize('completionsLabel', "Inline Suggestions"), false, resetLabel); - (async () => { - await (updatePromise ?? this.chatEntitlementService.update(token)); - if (token.isCancellationRequested) { - return; - } - const { completions } = this.chatEntitlementService.quotas; - if (completions) { - completionsQuotaIndicator(completions); - } - })(); - } else if (this.chatEntitlementService.anonymous && this.chatEntitlementService.sentiment.completed) { - this.createQuotaIndicator(container, this._store, localize('quotaLimited', "Limited"), localize('completionsLabel', "Inline Suggestions"), false); - } - } - + private renderInlineSuggestionsContent(container: HTMLElement, _token: CancellationToken, _updatePromise?: Promise): void { // Settings (editor-specific) if (!this.options?.disableInlineSuggestionsSettings) { this.createSettings(container, this._store); diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index 26ae17e3109f4..ab423fdf32f68 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -172,10 +172,10 @@ export class DefaultAccountService extends Disposable implements IDefaultAccount }); } - async refresh(): Promise { + async refresh(options?: { forceRefresh?: boolean }): Promise { await this.initBarrier.wait(); - const account = await this.defaultAccountProvider?.refresh(); + const account = await this.defaultAccountProvider?.refresh(options); this.setDefaultAccount(account ?? null); return this.defaultAccount; } @@ -385,7 +385,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid })); } - async refresh(): Promise { + async refresh(options?: { forceRefresh?: boolean }): Promise { if (!this.initialized) { await this.initPromise; return this.defaultAccount; @@ -393,7 +393,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid this.logService.debug('[DefaultAccount] Refreshing default account'); - await this.updateDefaultAccount(); + await this.updateDefaultAccount(options); return this.defaultAccount; } @@ -410,13 +410,13 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid await this.updateDefaultAccount(); } - private async updateDefaultAccount(): Promise { - await this.updateThrottler.trigger(() => this.doUpdateDefaultAccount()); + private async updateDefaultAccount(options?: { forceRefresh?: boolean }): Promise { + await this.updateThrottler.trigger(() => this.doUpdateDefaultAccount(options)); } - private async doUpdateDefaultAccount(): Promise { + private async doUpdateDefaultAccount(options?: { forceRefresh?: boolean }): Promise { try { - const defaultAccount = await this.fetchDefaultAccount(); + const defaultAccount = await this.fetchDefaultAccount(options); this.setDefaultAccount(defaultAccount); this.scheduleAccountDataPoll(); } catch (error) { @@ -424,7 +424,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid } } - private async fetchDefaultAccount(): Promise { + private async fetchDefaultAccount(options?: { forceRefresh?: boolean }): Promise { const defaultAccountProvider = this.getDefaultAccountAuthenticationProvider(); this.logService.debug('[DefaultAccount] Default account provider ID:', defaultAccountProvider.id); @@ -434,7 +434,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return null; } - return await this.getDefaultAccountForAuthenticationProvider(defaultAccountProvider); + return await this.getDefaultAccountForAuthenticationProvider(defaultAccountProvider, options); } private setDefaultAccount(account: IDefaultAccountData | null): void { @@ -511,7 +511,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return result; } - private async getDefaultAccountForAuthenticationProvider(authenticationProvider: IDefaultAccountAuthenticationProvider): Promise { + private async getDefaultAccountForAuthenticationProvider(authenticationProvider: IDefaultAccountAuthenticationProvider, options?: { forceRefresh?: boolean }): Promise { try { this.logService.debug('[DefaultAccount] Getting Default Account from authenticated sessions for provider:', authenticationProvider.id); const sessions = await this.findMatchingProviderSession(authenticationProvider.id, this.defaultAccountConfig.authenticationProvider.scopes); @@ -521,19 +521,19 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return null; } - return this.getDefaultAccountFromAuthenticatedSessions(authenticationProvider, sessions); + return this.getDefaultAccountFromAuthenticatedSessions(authenticationProvider, sessions, options); } catch (error) { this.logService.error('[DefaultAccount] Failed to get default account for provider:', authenticationProvider.id, getErrorMessage(error)); return null; } } - private async getDefaultAccountFromAuthenticatedSessions(authenticationProvider: IDefaultAccountAuthenticationProvider, sessions: AuthenticationSession[]): Promise { + private async getDefaultAccountFromAuthenticatedSessions(authenticationProvider: IDefaultAccountAuthenticationProvider, sessions: AuthenticationSession[], options?: { forceRefresh?: boolean }): Promise { try { const accountId = sessions[0].account.id; const accountPolicyData = this._policyData?.accountId === accountId ? this._policyData : undefined; - const entitlementsResult = await this.getEntitlements(sessions, accountPolicyData); + const entitlementsResult = await this.getEntitlements(sessions, accountPolicyData, options); const entitlementsData = entitlementsResult?.data; const entitlementsFetchedAt = entitlementsResult?.fetchedAt; const tokenEntitlementsResult = entitlementsData?.chat_enabled ? await this.getTokenEntitlements(sessions, accountPolicyData) : undefined; @@ -682,10 +682,10 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return undefined; } - private async getEntitlements(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined): Promise<{ data: IEntitlementsData | undefined | null; fetchedAt: number | undefined }> { + private async getEntitlements(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined, options?: { forceRefresh?: boolean }): Promise<{ data: IEntitlementsData | undefined | null; fetchedAt: number | undefined }> { const accountId = sessions[0].account.id; const existingData = this._defaultAccount?.accountId === accountId ? this._defaultAccount?.defaultAccount.entitlementsData : undefined; - if (existingData && accountPolicyData?.entitlementsFetchedAt && !this.isDataStale(accountPolicyData.entitlementsFetchedAt)) { + if (!options?.forceRefresh && existingData && accountPolicyData?.entitlementsFetchedAt && !this.isDataStale(accountPolicyData.entitlementsFetchedAt)) { this.logService.debug('[DefaultAccount] Using last fetched entitlements data'); return { data: existingData, fetchedAt: accountPolicyData.entitlementsFetchedAt }; } diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index 01d3d5ef72b97..54a061cec5a7c 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -857,7 +857,7 @@ export class ChatEntitlementRequests extends Disposable { } async forceResolveEntitlement(token = CancellationToken.None): Promise { - const defaultAccount = await this.defaultAccountService.refresh(); + const defaultAccount = await this.defaultAccountService.refresh({ forceRefresh: true }); if (!defaultAccount) { return undefined; } From df871147de755e0b7e5a42b5f22b8487f4b4484a Mon Sep 17 00:00:00 2001 From: Maruthan G <113752568+maruthang@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:16:00 +0530 Subject: [PATCH 06/56] Merge pull request #310763 from maruthang/fix/issue-289678-disassembly-breakpoint-address-lookup fix(debug): identify instruction breakpoints by resolved address to allow removal when instructionReference changes (#289678) --- .../contrib/debug/browser/debugService.ts | 4 +- .../contrib/debug/browser/disassemblyView.ts | 12 +- .../workbench/contrib/debug/common/debug.ts | 16 ++- .../contrib/debug/common/debugModel.ts | 18 ++- .../debug/test/common/debugModel.test.ts | 115 +++++++++++++++++- 5 files changed, 152 insertions(+), 13 deletions(-) diff --git a/src/vs/workbench/contrib/debug/browser/debugService.ts b/src/vs/workbench/contrib/debug/browser/debugService.ts index af14fd7446779..4bdacd758f281 100644 --- a/src/vs/workbench/contrib/debug/browser/debugService.ts +++ b/src/vs/workbench/contrib/debug/browser/debugService.ts @@ -1199,8 +1199,8 @@ export class DebugService implements IDebugService { this.debugStorage.storeBreakpoints(this.model); } - async removeInstructionBreakpoints(instructionReference?: string, offset?: number): Promise { - this.model.removeInstructionBreakpoints(instructionReference, offset); + async removeInstructionBreakpoints(instructionReference?: string, offset?: number, address?: bigint): Promise { + this.model.removeInstructionBreakpoints(instructionReference, offset, address); this.debugStorage.storeBreakpoints(this.model); await this.sendInstructionBreakpoints(); } diff --git a/src/vs/workbench/contrib/debug/browser/disassemblyView.ts b/src/vs/workbench/contrib/debug/browser/disassemblyView.ts index ffe31218173f1..42d6a0bd7c49f 100644 --- a/src/vs/workbench/contrib/debug/browser/disassemblyView.ts +++ b/src/vs/workbench/contrib/debug/browser/disassemblyView.ts @@ -736,11 +736,17 @@ class BreakpointRenderer implements ITableRenderer; /** - * Removes all instruction breakpoints. If address is passed only removes the instruction breakpoint with the passed address. - * The address should be the address string supplied by the debugger from the "Disassemble" request. - * Notifies debug adapter of breakpoint changes. - */ - removeInstructionBreakpoints(instructionReference?: string, offset?: number): Promise; + * Removes all instruction breakpoints. If `address` is passed, only the + * instruction breakpoint with the matching resolved memory address is + * removed; this is preferred because the debug adapter is allowed to + * return different `instructionReference` strings for the same memory + * location on subsequent disassemble requests. If `address` is not + * provided, falls back to matching on `instructionReference` (and + * `offset` when specified). When no arguments are provided, all + * instruction breakpoints are removed. Notifies the debug adapter of + * breakpoint changes. + */ + removeInstructionBreakpoints(instructionReference?: string, offset?: number, address?: bigint): Promise; setExceptionBreakpointCondition(breakpoint: IExceptionBreakpoint, condition: string | undefined): Promise; diff --git a/src/vs/workbench/contrib/debug/common/debugModel.ts b/src/vs/workbench/contrib/debug/common/debugModel.ts index 5de69ed78ca80..e5f1f6e871ecc 100644 --- a/src/vs/workbench/contrib/debug/common/debugModel.ts +++ b/src/vs/workbench/contrib/debug/common/debugModel.ts @@ -2064,9 +2064,23 @@ export class DebugModel extends Disposable implements IDebugModel { this._onDidChangeBreakpoints.fire({ added: [newInstructionBreakpoint], sessionOnly: true }); } - removeInstructionBreakpoints(instructionReference?: string, offset?: number): void { + removeInstructionBreakpoints(instructionReference?: string, offset?: number, address?: bigint): void { let removed: InstructionBreakpoint[] = []; - if (instructionReference) { + if (address !== undefined) { + // Prefer matching by resolved memory address: `instructionReference` is + // allowed by the Debug Adapter Protocol to change between disassemble + // requests (e.g. after symbol reloads), so matching on reference+offset + // alone would fail to locate the breakpoint that the user is trying to + // toggle off. The `address` on an `InstructionBreakpoint` is the stable + // resolved memory address and uniquely identifies it. + for (let i = 0; i < this.instructionBreakpoints.length; i++) { + const ibp = this.instructionBreakpoints[i]; + if (ibp.address === address) { + removed.push(ibp); + this.instructionBreakpoints.splice(i--, 1); + } + } + } else if (instructionReference) { for (let i = 0; i < this.instructionBreakpoints.length; i++) { const ibp = this.instructionBreakpoints[i]; if (ibp.instructionReference === instructionReference && (offset === undefined || ibp.offset === offset)) { diff --git a/src/vs/workbench/contrib/debug/test/common/debugModel.test.ts b/src/vs/workbench/contrib/debug/test/common/debugModel.test.ts index 9b85f86ee8f06..bf9cd33945524 100644 --- a/src/vs/workbench/contrib/debug/test/common/debugModel.test.ts +++ b/src/vs/workbench/contrib/debug/test/common/debugModel.test.ts @@ -11,7 +11,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { NullLogService } from '../../../../../platform/log/common/log.js'; import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; import { TestStorageService } from '../../../../test/common/workbenchTestServices.js'; -import { IDebugSession } from '../../common/debug.js'; +import { IDebugSession, IInstructionBreakpoint } from '../../common/debug.js'; import { DebugModel, ExceptionBreakpoint, FunctionBreakpoint, Thread } from '../../common/debugModel.js'; import { MockDebugStorage } from './mockDebug.js'; import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; @@ -28,6 +28,119 @@ suite('DebugModel', () => { }); }); + suite('InstructionBreakpoint', () => { + function createModel(disposable: DisposableStore): DebugModel { + const storage = disposable.add(new TestStorageService()); + const model = new DebugModel( + disposable.add(new MockDebugStorage(storage)), + upcastPartial({ isDirty: (_: unknown) => false }), + undefined!, + new NullLogService() + ); + disposable.add(model); + return model; + } + + // Regression test for microsoft/vscode#289678: if the debug adapter hands + // out a new `instructionReference` for the same memory location (e.g. + // after a symbol reload or certain stepping operations), removal by + // reference+offset must still succeed when the caller supplies the + // resolved address. + test('removeInstructionBreakpoints prefers address match when instructionReference has changed', () => { + const disposable = new DisposableStore(); + try { + const model = createModel(disposable); + const address = BigInt(0x1000); + model.addInstructionBreakpoint({ + instructionReference: 'oldRef', + offset: 0, + address, + canPersist: false, + enabled: true, + hitCondition: undefined, + condition: undefined, + logMessage: undefined, + }); + + assert.strictEqual(model.getInstructionBreakpoints().length, 1); + + // Simulate the disassembly view asking for removal after the + // debug adapter handed out a new instruction reference. + model.removeInstructionBreakpoints('newRef', 0, address); + + assert.strictEqual(model.getInstructionBreakpoints().length, 0); + } finally { + disposable.dispose(); + } + }); + + test('removeInstructionBreakpoints falls back to instructionReference+offset when address not supplied', () => { + const disposable = new DisposableStore(); + try { + const model = createModel(disposable); + model.addInstructionBreakpoint({ + instructionReference: 'ref', + offset: 4, + address: BigInt(0x2000), + canPersist: false, + enabled: true, + hitCondition: undefined, + condition: undefined, + logMessage: undefined, + }); + + // Non-matching reference leaves the breakpoint in place. + model.removeInstructionBreakpoints('other', 4); + assert.strictEqual(model.getInstructionBreakpoints().length, 1); + + // Matching reference+offset removes it. + model.removeInstructionBreakpoints('ref', 4); + assert.strictEqual(model.getInstructionBreakpoints().length, 0); + } finally { + disposable.dispose(); + } + }); + + test('removeInstructionBreakpoints with only address removes the matching entry and leaves others', () => { + const disposable = new DisposableStore(); + try { + const model = createModel(disposable); + const keep: IInstructionBreakpoint[] = []; + + model.addInstructionBreakpoint({ + instructionReference: 'refA', + offset: 0, + address: BigInt(0x3000), + canPersist: false, + enabled: true, + hitCondition: undefined, + condition: undefined, + logMessage: undefined, + }); + model.addInstructionBreakpoint({ + instructionReference: 'refB', + offset: 0, + address: BigInt(0x4000), + canPersist: false, + enabled: true, + hitCondition: undefined, + condition: undefined, + logMessage: undefined, + }); + + model.removeInstructionBreakpoints(undefined, undefined, BigInt(0x3000)); + + const remaining = model.getInstructionBreakpoints(); + assert.strictEqual(remaining.length, 1); + assert.strictEqual(remaining[0].address, BigInt(0x4000)); + keep.push(...remaining); + assert.strictEqual(keep.length, 1); + } finally { + disposable.dispose(); + } + }); + }); + suite('ExceptionBreakpoint', () => { test('Restored matches new', () => { const ebp = new ExceptionBreakpoint({ From 5cc9a1911dca939a2421d9e96b48c2a10573c70d Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Thu, 16 Apr 2026 19:16:55 +0200 Subject: [PATCH 07/56] remove setting SubagentToolCustomAgents, enabled by default (#310103) * remove setting SubagentToolCustomAgents, enabled by default * update * fix tests --- .../contrib/chat/browser/chat.contribution.ts | 8 ----- .../contrib/chat/common/constants.ts | 1 - .../computeAutomaticInstructions.ts | 3 +- .../languageProviders/promptValidator.ts | 17 +--------- .../tools/builtinTools/runSubagentTool.ts | 24 +++++-------- .../languageProviders/promptValidator.test.ts | 28 --------------- .../computeAutomaticInstructions.test.ts | 34 ------------------- .../builtinTools/runSubagentTool.test.ts | 6 ++-- 8 files changed, 13 insertions(+), 108 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index c297ac02dc18a..77de1315f4d62 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -1473,14 +1473,6 @@ configurationRegistry.registerConfiguration({ mode: 'auto' } }, - [ChatConfiguration.SubagentToolCustomAgents]: { - type: 'boolean', - description: nls.localize('chat.subagentTool.customAgents', "Whether the runSubagent tool is able to use custom agents. When enabled, the tool can take the name of a custom agent, but it must be given the exact name of the agent."), - default: true, - experiment: { - mode: 'auto' - } - }, [ChatConfiguration.GeneralPurposeAgentEnabled]: { type: 'boolean', description: nls.localize('chat.generalPurposeAgent.enabled', "Controls whether the built-in General Purpose agent is available as a subagent."), diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 4e721a84a071b..10ccfa8f6cf4a 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -47,7 +47,6 @@ export enum ChatConfiguration { ChatViewSessionsOrientation = 'chat.viewSessions.orientation', ChatViewProgressBadgeEnabled = 'chat.viewProgressBadge.enabled', ChatContextUsageEnabled = 'chat.contextUsage.enabled', - SubagentToolCustomAgents = 'chat.customAgentInSubagent.enabled', GeneralPurposeAgentEnabled = 'chat.generalPurposeAgent.enabled', SubagentsAllowInvocationsFromSubagents = 'chat.subagents.allowInvocationsFromSubagents', ShowCodeBlockProgressAnimation = 'chat.agent.codeBlockProgress', diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 83e6007518189..5a6b648cd1bb3 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -457,7 +457,6 @@ export class ComputeAutomaticInstructions { if (runSubagentTool) { const generalPurposeAgentEnabled = !!this._configurationService.getValue(ChatConfiguration.GeneralPurposeAgentEnabled); - const customAgentsEnabled = !!this._configurationService.getValue(ChatConfiguration.SubagentToolCustomAgents); const canUseAgent = (() => { if (!this._enabledSubagents || this._enabledSubagents.includes('*')) { return (agent: ICustomAgent) => agent.visibility.agentInvocable && (!agent.when || this._contextKeyService.contextMatchesRules(agent.when)); @@ -466,7 +465,7 @@ export class ComputeAutomaticInstructions { return (agent: ICustomAgent) => subagents.includes(agent.name) && (!agent.when || this._contextKeyService.contextMatchesRules(agent.when)); } })(); - const agents = customAgentsEnabled ? await this._promptsService.getCustomAgents(token) : []; + const agents = await this._promptsService.getCustomAgents(token); if (generalPurposeAgentEnabled || agents.length > 0) { entries.push(''); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts index f351c7f8a9626..7919e29d3f785 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -10,7 +10,7 @@ import { localize } from '../../../../../../nls.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IMarkerData, MarkerSeverity, MarkerTag } from '../../../../../../platform/markers/common/markers.js'; import { ChatMode, IChatMode, IChatModeService } from '../../chatModes.js'; -import { ChatConfiguration, ChatModeKind } from '../../constants.js'; +import { ChatModeKind } from '../../constants.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; import { ILanguageModelToolsService, SpecedToolAliases } from '../../tools/languageModelToolsService.js'; import { PromptsType, Target } from '../promptTypes.js'; @@ -812,12 +812,6 @@ export class PromptValidator { report(toMarker(localize('promptValidator.disableModelInvocationMustBeBoolean', "The 'disable-model-invocation' attribute must be 'true' or 'false'."), attribute.value.range, MarkerSeverity.Error)); return; } - - if (attribute.value.type === 'scalar' && attribute.value.value === 'false') { - if (!this.isCustomAgentInSubagentEnabled()) { - report(toMarker(localize('promptValidator.inferRequiresConfig', "For agents to be used as subagent you also need to enable the 'chat.customAgentInSubagent.enabled' setting."), attribute.value.range, MarkerSeverity.Warning)); - } - } } private async validateAgentsAttribute(attributes: IHeaderAttribute[], header: PromptHeader, report: (markers: IMarkerData) => void): Promise { @@ -830,11 +824,6 @@ export class PromptValidator { return; } - // Check if the configuration setting is enabled - if (!this.isCustomAgentInSubagentEnabled()) { - report(toMarker(localize('promptValidator.agentsRequiresConfig', "For agents to be used as subagent you also need to enable the 'chat.customAgentInSubagent.enabled' setting."), attribute.range, MarkerSeverity.Warning)); - } - // Collect available agent names const agents = await this.promptsService.getCustomAgents(CancellationToken.None); const availableAgentNames = new Set(agents.map(agent => agent.name)); @@ -862,10 +851,6 @@ export class PromptValidator { } } - private isCustomAgentInSubagentEnabled(): boolean { - return !!this.configurationService.getValue(ChatConfiguration.SubagentToolCustomAgents); - } - private validateGithubPermissions(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): void { const attribute = attributes.find(attr => attr.key === GithubPromptHeaderAttributes.github); if (!attribute) { diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index 1e4990de7cda9..963689f55ba7f 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -89,7 +89,6 @@ export class RunSubagentTool extends Disposable implements IToolImpl { super(); this._register(Event.filter(this.configurationService.onDidChangeConfiguration, e => - e.affectsConfiguration(ChatConfiguration.SubagentToolCustomAgents) || e.affectsConfiguration(ChatConfiguration.GeneralPurposeAgentEnabled) )(() => this._onDidUpdateToolData.fire())); } @@ -97,7 +96,6 @@ export class RunSubagentTool extends Disposable implements IToolImpl { getToolData(): IToolData { const modelDescription = BaseModelDescription; const generalPurposeAgentEnabled = this.configurationService.getValue(ChatConfiguration.GeneralPurposeAgentEnabled); - const customAgentsEnabled = this.configurationService.getValue(ChatConfiguration.SubagentToolCustomAgents); const properties: IJSONSchemaMap = { prompt: { @@ -109,16 +107,12 @@ export class RunSubagentTool extends Disposable implements IToolImpl { description: 'A short (3-5 word) description of the task' } }; - - if (customAgentsEnabled || generalPurposeAgentEnabled) { - properties.agentName = { - type: 'string', - description: generalPurposeAgentEnabled - ? 'Name of the agent to invoke.' - : 'Optional name of a specific agent to invoke. If not provided, uses the current agent.' - }; - } - + properties.agentName = { + type: 'string', + description: generalPurposeAgentEnabled + ? 'Name of the agent to invoke.' + : 'Optional name of a specific agent to invoke. If not provided, uses the current agent.' + }; properties.model = { type: 'string', description: 'Optional model for the subagent. Format: "Model Name (Vendor)", vendor is usually "copilot". Only use to enforce a specific model.', @@ -183,12 +177,11 @@ export class RunSubagentTool extends Disposable implements IToolImpl { const subAgentName = args.agentName; // Defensive: model may omit agentName despite schema requiring it const gpEnabled = this.configurationService.getValue(ChatConfiguration.GeneralPurposeAgentEnabled); - const customAgentsEnabled = this.configurationService.getValue(ChatConfiguration.SubagentToolCustomAgents); const isGeneralPurpose = gpEnabled && (!subAgentName || subAgentName === GeneralPurposeAgentName); const effectiveSubAgentName = isGeneralPurpose ? GeneralPurposeAgentName : subAgentName; if (subAgentName && !isGeneralPurpose) { - subagent = customAgentsEnabled ? await this.getSubAgentByName(subAgentName) : undefined; + subagent = await this.getSubAgentByName(subAgentName); if (subagent) { // Check the pre-resolved model cache from prepareToolInvocation const cached = this._resolvedModels.get(invocation.callId); @@ -544,9 +537,8 @@ export class RunSubagentTool extends Disposable implements IToolImpl { // Defensive: model may omit agentName despite schema requiring it const gpEnabled = this.configurationService.getValue(ChatConfiguration.GeneralPurposeAgentEnabled); - const customAgentsEnabled = this.configurationService.getValue(ChatConfiguration.SubagentToolCustomAgents); const isGeneralPurpose = gpEnabled && (!args.agentName || args.agentName === GeneralPurposeAgentName); - const subagent = (args.agentName && !isGeneralPurpose && customAgentsEnabled) ? await this.getSubAgentByName(args.agentName) : undefined; + const subagent = (args.agentName && !isGeneralPurpose) ? await this.getSubAgentByName(args.agentName) : undefined; // Resolve the model early and cache it for invoke() const resolved = this.resolveSubagentModel(subagent, context.modelId, args.model); diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts index c873a9b81e95d..dbcaa1e948c4c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts @@ -44,7 +44,6 @@ suite('PromptValidator', () => { testConfigService = new TestConfigurationService(); testConfigService.setUserConfiguration(ChatConfiguration.ExtensionToolsEnabled, true); testConfigService.setUserConfiguration(PromptsConfig.USE_CUSTOM_AGENT_HOOKS, true); - testConfigService.setUserConfiguration(ChatConfiguration.SubagentToolCustomAgents, true); instaService = workbenchInstantiationService({ contextKeyService: () => disposables.add(new ContextKeyService(testConfigService)), configurationService: () => testConfigService @@ -1104,7 +1103,6 @@ suite('PromptValidator', () => { // Valid infer: true (maps to 'all') - shows deprecation warning { - testConfigService.setUserConfiguration(ChatConfiguration.SubagentToolCustomAgents, true); const content = [ '---', 'name: "TestAgent"', @@ -1165,28 +1163,8 @@ suite('PromptValidator', () => { } }); - test('disable-model-invocation: false warns when customAgentInSubagent.enabled is disabled', async () => { - testConfigService.setUserConfiguration(ChatConfiguration.SubagentToolCustomAgents, false); - - // disable-model-invocation: false should warn when config is disabled - { - const content = [ - '---', - 'name: "TestAgent"', - 'description: "Test agent"', - 'disable-model-invocation: false', - '---', - 'Body', - ].join('\n'); - const markers = await validate(content, PromptsType.agent); - assert.strictEqual(markers.length, 1); - assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); - assert.strictEqual(markers[0].message, `For agents to be used as subagent you also need to enable the 'chat.customAgentInSubagent.enabled' setting.`); - } - }); test('agents attribute must be an array', async () => { - testConfigService.setUserConfiguration(ChatConfiguration.SubagentToolCustomAgents, true); const content = [ '---', 'description: "Test"', @@ -1198,7 +1176,6 @@ suite('PromptValidator', () => { }); test('each agent name in agents attribute must be a string', async () => { - testConfigService.setUserConfiguration(ChatConfiguration.SubagentToolCustomAgents, true); const content = [ '---', 'description: "Test"', @@ -1226,7 +1203,6 @@ suite('PromptValidator', () => { }); test('agents attribute with non-empty value requires agent tool 1', async () => { - testConfigService.setUserConfiguration(ChatConfiguration.SubagentToolCustomAgents, true); const content = [ '---', 'description: "Test"', @@ -1238,7 +1214,6 @@ suite('PromptValidator', () => { }); test('agents attribute with non-empty value requires agent tool 2', async () => { - testConfigService.setUserConfiguration(ChatConfiguration.SubagentToolCustomAgents, true); const content = [ '---', 'description: "Test"', @@ -1251,7 +1226,6 @@ suite('PromptValidator', () => { }); test('agents attribute with non-empty value requires agent tool 3', async () => { - testConfigService.setUserConfiguration(ChatConfiguration.SubagentToolCustomAgents, true); const content = [ '---', 'description: "Test"', @@ -1264,7 +1238,6 @@ suite('PromptValidator', () => { }); test('agents attribute with non-empty value requires agent tool 4', async () => { - testConfigService.setUserConfiguration(ChatConfiguration.SubagentToolCustomAgents, true); const content = [ '---', 'description: "Test"', @@ -1277,7 +1250,6 @@ suite('PromptValidator', () => { }); test('agents attribute with empty array does not require agent tool', async () => { - testConfigService.setUserConfiguration(ChatConfiguration.SubagentToolCustomAgents, true); const content = [ '---', 'description: "Test"', diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts index 44390778ccb14..ffdda973dc385 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts @@ -1043,7 +1043,6 @@ suite('ComputeAutomaticInstructions', () => { const rootFolderUri = URI.file(rootFolder); workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); - testConfigService.setUserConfiguration('chat.customAgentInSubagent.enabled', true); testConfigService.setUserConfiguration(PromptsConfig.AGENTS_LOCATION_KEY, { [AGENTS_SOURCE_FOLDER]: true, '.claude/agents': true, @@ -1501,9 +1500,6 @@ suite('ComputeAutomaticInstructions', () => { workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); - // Enable the config for custom agents - testConfigService.setUserConfiguration('chat.customAgentInSubagent.enabled', true); - await mockFiles(fileService, [ { path: `${rootFolder}/.github/agents/test-agent-1.agent.md`, @@ -1596,8 +1592,6 @@ suite('ComputeAutomaticInstructions', () => { workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); - testConfigService.setUserConfiguration('chat.customAgentInSubagent.enabled', true); - testConfigService.setUserConfiguration('chat.generalPurposeAgent.enabled', true); testConfigService.setUserConfiguration(PromptsConfig.AGENTS_LOCATION_KEY, { @@ -1642,34 +1636,6 @@ suite('ComputeAutomaticInstructions', () => { assert.equal(xmlContents(agents[1], 'description')[0], 'Test agent 1'); }); - test('should include General Purpose agent even without custom agents config', async () => { - workspaceContextService.setWorkspace(testWorkspace(URI.file('/gp-only-test'))); - - // Explicitly do NOT set chat.customAgentInSubagent.enabled - - testConfigService.setUserConfiguration('chat.generalPurposeAgent.enabled', true); - - const contextComputer = instaService.createInstance( - ComputeAutomaticInstructions, - ChatModeKind.Agent, - { 'vscode_runSubagent': true }, - ['*'], - ); - const variables = new ChatRequestVariableSet(); - - await contextComputer.collect(variables, CancellationToken.None); - - const textVariables = variables.asArray().filter(v => isPromptTextVariableEntry(v)); - assert.equal(textVariables.length, 1, 'There should be one text variable for agents list'); - - const agentsList = xmlContents(textVariables[0].value, 'agents'); - assert.equal(agentsList.length, 1, 'There should be one agents list'); - - const agents = xmlContents(agentsList[0], 'agent'); - assert.equal(agents.length, 1, 'There should be only the GP agent'); - assert.equal(xmlContents(agents[0], 'name')[0], GeneralPurposeAgentName); - }); - test('should include skills list when readFile tool available', async () => { const rootFolderName = 'skills-list-test'; const rootFolder = `/${rootFolderName}`; diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts index 5a08e498bdfdc..8fc420d914f36 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts @@ -237,7 +237,7 @@ suite('RunSubagentTool', () => { assert.ok(toolData.inputSchema); assert.ok(toolData.inputSchema.properties?.prompt); assert.ok(toolData.inputSchema.properties?.description); - assert.strictEqual(toolData.inputSchema.properties?.agentName, undefined, 'agentName should not be in schema when neither GP nor custom agents is enabled'); + assert.ok(toolData.inputSchema.properties?.agentName, 'agentName should be in schema properties'); assert.deepStrictEqual(toolData.inputSchema.required, ['prompt', 'description']); }); @@ -374,7 +374,7 @@ suite('RunSubagentTool', () => { mockToolsService, mockLanguageModelsService as ILanguageModelsService, new NullLogService(), - new TestConfigurationService({ [ChatConfiguration.SubagentToolCustomAgents]: true }), + new TestConfigurationService(), promptsService, {} as IInstantiationService, {} as IProductService, @@ -649,7 +649,7 @@ suite('RunSubagentTool', () => { mockToolsService, mockLanguageModelsService as ILanguageModelsService, new NullLogService(), - new TestConfigurationService({ [ChatConfiguration.SubagentToolCustomAgents]: true }), + new TestConfigurationService(), promptsService, {} as IInstantiationService, {} as IProductService, From 3b7a361194ab3779642796a6248427eacdb0c66e Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Thu, 16 Apr 2026 19:18:45 +0200 Subject: [PATCH 08/56] chat customizations: add ChatResourceEnablement (#310212) * chat customizations: add ChatResourceEnablement * update * filter by sessionTypes Co-authored-by: Copilot * inline ChatResourceEnablement Co-authored-by: Copilot * update Co-authored-by: Copilot * update * revert version change --------- Co-authored-by: Copilot --- extensions/copilot/package.json | 40 ++-- .../copilotcli/node/copilotCli.ts | 21 +- .../node/copilotcliSessionService.ts | 5 +- .../claudeCustomizationProvider.ts | 6 +- .../copilotCLICustomizationProvider.ts | 11 +- .../platform/extensions/common/extensions.ts | 1 + .../api/browser/mainThreadChatAgents2.ts | 25 ++- .../workbench/api/common/extHost.api.impl.ts | 4 +- .../workbench/api/common/extHost.protocol.ts | 6 +- .../api/common/extHostChatAgents2.ts | 6 +- .../chatPromptFilesContribution.ts | 8 +- .../promptSyntax/service/promptsService.ts | 28 ++- .../service/promptsServiceImpl.ts | 12 +- .../service/mockPromptsService.ts | 2 +- .../service/promptsService.test.ts | 185 ++++++++++++++++++ .../vscode.proposed.chatPromptFiles.d.ts | 37 +++- 16 files changed, 354 insertions(+), 43 deletions(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index ebbe263093253..3b354505c8070 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -6171,57 +6171,71 @@ "chatPromptFiles": [ { "path": "./assets/prompts/plan.prompt.md", - "when": "chatSessionType == local" + "when": "chatSessionType == local", + "sessionTypes": ["local"] } ], "chatSkills": [ { "path": "./assets/prompts/skills/project-setup-info-local/SKILL.md", - "when": "chatSessionType == local && config.github.copilot.chat.projectSetupInfoSkill.enabled && !config.github.copilot.chat.newWorkspace.useContext7" + "when": "chatSessionType == local && config.github.copilot.chat.projectSetupInfoSkill.enabled && !config.github.copilot.chat.newWorkspace.useContext7", + "sessionTypes": ["local"] }, { "path": "./assets/prompts/skills/project-setup-info-context7/SKILL.md", - "when": "chatSessionType == local && config.github.copilot.chat.projectSetupInfoSkill.enabled && config.github.copilot.chat.newWorkspace.useContext7" + "when": "chatSessionType == local && config.github.copilot.chat.projectSetupInfoSkill.enabled && config.github.copilot.chat.newWorkspace.useContext7", + "sessionTypes": ["local"] }, { "path": "./assets/prompts/skills/install-vscode-extension/SKILL.md", - "when": "chatSessionType == local && config.github.copilot.chat.installExtensionSkill.enabled && config.github.copilot.chat.newWorkspaceCreation.enabled" + "when": "chatSessionType == local && config.github.copilot.chat.installExtensionSkill.enabled && config.github.copilot.chat.newWorkspaceCreation.enabled", + "sessionTypes": ["local"] }, { "path": "./assets/prompts/skills/get-search-view-results/SKILL.md", - "when": "chatSessionType == local && config.github.copilot.chat.getSearchViewResultsSkill.enabled" + "when": "chatSessionType == local && config.github.copilot.chat.getSearchViewResultsSkill.enabled", + "sessionTypes": ["local"] }, { "path": "./assets/prompts/skills/troubleshoot/SKILL.md", - "when": "chatSessionType == local || chatSessionType == copilotcli" + "when": "chatSessionType == local || chatSessionType == copilotcli", + "sessionTypes": ["local", "copilotcli"] }, { "path": "./assets/prompts/skills/agent-customization/SKILL.md", - "when": "chatSessionType == local || chatSessionType == copilotcli" + "when": "chatSessionType == local || chatSessionType == copilotcli", + "sessionTypes": ["local", "copilotcli"] }, { "path": "./assets/prompts/skills/init/SKILL.md", - "when": "chatSessionType == local" + "when": "chatSessionType == local", + "sessionTypes": ["local"] }, { "path": "./assets/prompts/skills/create-prompt/SKILL.md", - "when": "chatSessionType == local" + "when": "chatSessionType == local", + "sessionTypes": ["local"] }, { "path": "./assets/prompts/skills/create-instructions/SKILL.md", - "when": "chatSessionType == local" + "when": "chatSessionType == local", + "sessionTypes": ["local"] + }, { "path": "./assets/prompts/skills/create-skill/SKILL.md", - "when": "chatSessionType == local" + "when": "chatSessionType == local", + "sessionTypes": ["local"] }, { "path": "./assets/prompts/skills/create-agent/SKILL.md", - "when": "chatSessionType == local" + "when": "chatSessionType == local", + "sessionTypes": ["local"] }, { "path": "./assets/prompts/skills/create-hook/SKILL.md", - "when": "chatSessionType == local" + "when": "chatSessionType == local", + "sessionTypes": ["local"] } ], "terminal": { diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts index 8d886790a4298..72308407e8c8c 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts @@ -302,9 +302,9 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents { } async resolveAgent(agentId: string): Promise { - for (const promptFile of await this.promptsService.getCustomAgents(CancellationToken.None)) { - if (agentId === promptFile.uri.toString()) { - return this.toCustomAgent(promptFile)?.agent; + for (const customAgent of await this.promptsService.getCustomAgents(CancellationToken.None)) { + if (isEnabledForCopilotCLI(customAgent) && agentId === customAgent.uri.toString()) { + return this.toCustomAgent(customAgent)?.agent; } } const customAgents = await this.getAgents(); @@ -334,13 +334,16 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents { sourceUri: URI.from({ scheme: 'copilotcli', path: `/agents/${agent.name}` }), }); } - for (const promptFile of await this.promptsService.getCustomAgents(CancellationToken.None)) { + for (const customAgent of await this.promptsService.getCustomAgents(CancellationToken.None)) { + if (!isEnabledForCopilotCLI(customAgent)) { + continue; + } // Skip legacy .chatmode.md files โ€” they are a deprecated format // and should not appear in the Copilot CLI agent list. - if (promptFile.uri.path.toLowerCase().endsWith('.chatmode.md')) { + if (customAgent.uri.path.toLowerCase().endsWith('.chatmode.md')) { continue; } - const info = this.toCustomAgent(promptFile); + const info = this.toCustomAgent(customAgent); if (!info) { continue; } @@ -543,3 +546,9 @@ async function checkFileExists(filePath: string): Promise { return false; } } + +export function isEnabledForCopilotCLI(customization: { sessionTypes?: readonly string[] }): boolean { + const sessionTypes = customization.sessionTypes; + return sessionTypes === undefined || sessionTypes.includes('copilotcli') || false; +} + diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index 8c6723ae1e47a..0b9367e73d8b4 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -44,7 +44,7 @@ import { ICustomSessionTitleService } from '../common/customSessionTitleService' import { IChatDelegationSummaryService } from '../common/delegationSummaryService'; import { SessionIdForCLI } from '../common/utils'; import { getCopilotCLISessionDir } from './cliHelpers'; -import { getAgentFileNameFromFilePath, ICopilotCLIAgents, ICopilotCLISDK } from './copilotCli'; +import { getAgentFileNameFromFilePath, ICopilotCLIAgents, ICopilotCLISDK, isEnabledForCopilotCLI } from './copilotCli'; import { CopilotCliBridgeSpanProcessor } from './copilotCliBridgeSpanProcessor'; import { CopilotCLISession, ICopilotCLISession } from './copilotcliSession'; import { ICopilotCLISkills } from './copilotCLISkills'; @@ -849,6 +849,9 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS const agents = await this._promptsService.getCustomAgents(CancellationToken.None); const lookup = new Map>]>(); for (const agent of agents) { + if (!isEnabledForCopilotCLI(agent)) { + continue; + } const lazyContent = new Lazy(() => this._promptsService.parseFile(agent.uri, CancellationToken.None).then(parsed => parsed.body?.getContent() ?? '')); const keys = [ agent.name?.trim(), diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts index a8345dd0c3bd7..89f766fd1a620 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts @@ -114,7 +114,7 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch // File-based agents from .claude/ paths โ€” shown pre-session, deduplicated with SDK for (const agent of await this.promptsService.getCustomAgents(token)) { - if (this.isClaudePath(agent.uri)) { + if (isEnabledForClaudeCode(agent) && this.isClaudePath(agent.uri)) { const name = agent.name; if (!sdkAgentNames.has(name.toLowerCase())) { items.push({ @@ -275,3 +275,7 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch } } +export function isEnabledForClaudeCode(customization: { sessionTypes?: readonly string[] }): boolean { + const sessionTypes = customization.sessionTypes; + return sessionTypes === undefined || sessionTypes.includes('claude-code') || false; +} diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts index 2690a53fb78ac..fb9a238d52616 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts @@ -16,7 +16,7 @@ import { Emitter } from '../../../util/vs/base/common/event'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { basename } from '../../../util/vs/base/common/resources'; import { URI } from '../../../util/vs/base/common/uri'; -import { ICopilotCLIAgents } from '../copilotcli/node/copilotCli'; +import { ICopilotCLIAgents, isEnabledForCopilotCLI } from '../copilotcli/node/copilotCli'; export class CopilotCLICustomizationProvider extends Disposable implements vscode.ChatSessionCustomizationProvider { @@ -139,6 +139,9 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod for (const instruction of await this.promptsService.getInstructions(token)) { const uri = instruction.uri; + if (!isEnabledForCopilotCLI(instruction)) { + continue; // only include instructions that are relevant for copilotcli + } if (seenUris.has(uri.toString())) { continue; // already emitted as agent instruction @@ -182,7 +185,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod * Collects all skill items from the prompt file service. */ private async getSkillItems(token: vscode.CancellationToken): Promise { - return (await this.promptsService.getSkills(token)).map(s => ({ + return (await this.promptsService.getSkills(token)).filter(isEnabledForCopilotCLI).map(s => ({ uri: s.uri, type: vscode.ChatSessionCustomizationType.Skill, name: s.name, @@ -194,7 +197,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod * Each item is a hook configuration file (JSON). */ private async getHookItems(token: vscode.CancellationToken): Promise { - return (await this.promptsService.getHooks(token)).map(h => ({ + return (await this.promptsService.getHooks(token)).filter(isEnabledForCopilotCLI).map(h => ({ uri: h.uri, type: vscode.ChatSessionCustomizationType.Hook, name: basename(h.uri).replace(/\.json$/i, ''), @@ -205,7 +208,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod * Collects all plugin items from the prompt file service. */ private async getPluginItems(token: vscode.CancellationToken): Promise { - return (await this.promptsService.getPlugins(token)).map(p => ({ + return (await this.promptsService.getPlugins(token)).filter(isEnabledForCopilotCLI).map(p => ({ uri: p.uri, type: vscode.ChatSessionCustomizationType.Plugins, name: basename(p.uri), diff --git a/src/vs/platform/extensions/common/extensions.ts b/src/vs/platform/extensions/common/extensions.ts index e1a24c7190bde..bd90ac80c01d0 100644 --- a/src/vs/platform/extensions/common/extensions.ts +++ b/src/vs/platform/extensions/common/extensions.ts @@ -204,6 +204,7 @@ export interface IChatFileContribution { readonly name?: string; readonly description?: string; readonly when?: string; + readonly sessionTypes?: readonly string[]; } export interface IExtensionContributions { diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 58632789a3ca0..fa577f8d06c9d 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -30,7 +30,7 @@ import { IChatWidget, IChatWidgetService } from '../../contrib/chat/browser/chat import { AgentSessionProviders, getAgentSessionProvider } from '../../contrib/chat/browser/agentSessions/agentSessions.js'; import { AddDynamicVariableAction, IAddDynamicVariableContext } from '../../contrib/chat/browser/attachments/chatDynamicVariables.js'; import { IChatAgentHistoryEntry, IChatAgentImplementation, IChatAgentRequest, IChatAgentService } from '../../contrib/chat/common/participants/chatAgents.js'; -import { IAgentSkill, IChatPromptSlashCommand, ICustomAgent, IInstructionFile, IPromptFileContext, IPromptsService, PromptsStorage } from '../../contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IAgentSkill, IChatPromptSlashCommand, ICustomAgent, IInstructionFile, IPromptFileContext, IPromptPath, IPromptsService, PromptsStorage } from '../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { isValidPromptType, PromptsType } from '../../contrib/chat/common/promptSyntax/promptTypes.js'; import { IChatModel } from '../../contrib/chat/common/model/chatModel.js'; import { ChatRequestAgentPart } from '../../contrib/chat/common/requestParser/chatParserTypes.js'; @@ -48,7 +48,7 @@ import { NotebookDto } from './mainThreadNotebookDto.js'; import { isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js'; import { ICustomizationHarnessService, ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor } from '../../contrib/chat/common/customizationHarnessService.js'; import { AICustomizationManagementSection, BUILTIN_STORAGE } from '../../contrib/chat/common/aiCustomizationWorkspaceService.js'; -import { IAgentPluginService } from '../../contrib/chat/common/plugins/agentPluginService.js'; +import { IAgentPlugin, IAgentPluginService } from '../../contrib/chat/common/plugins/agentPluginService.js'; import { IWorkbenchEnvironmentService } from '../../services/environment/common/environmentService.js'; interface AgentData { @@ -218,6 +218,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA source: this._toChatResourceSource(agent.source.storage), extensionId: agent.source.storage === PromptsStorage.extension ? agent.source.extensionId.value : undefined, pluginUri: agent.source.storage === PromptsStorage.plugin ? agent.source.pluginUri : undefined, + sessionTypes: agent.sessionTypes, argumentHint: agent.argumentHint, tools: agent.tools, model: agent.model, @@ -234,6 +235,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA source: this._toChatResourceSource(instruction.storage), extensionId: instruction.extension?.identifier.value, pluginUri: instruction.pluginUri, + sessionTypes: instruction.sessionTypes, pattern: instruction.pattern, }; } @@ -246,6 +248,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA source: this._toChatResourceSource(skill.storage), extensionId: skill.extension?.identifier.value, pluginUri: skill.pluginUri, + sessionTypes: skill.sessionTypes, userInvocable: skill.userInvocable, }; } @@ -258,11 +261,25 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA source: this._toChatResourceSource(slashCommand.storage), extensionId: slashCommand.extension?.identifier.value, pluginUri: slashCommand.pluginUri, + sessionTypes: slashCommand.sessionTypes, argumentHint: slashCommand.argumentHint, userInvocable: slashCommand.userInvocable, }; } + private _toHookDto(hookFile: IPromptPath): IHookDto { + return { + uri: hookFile.uri, + sessionTypes: hookFile.sessionTypes, + }; + } + + private _toPluginDto(plugin: IAgentPlugin): IPluginDto { + return { + uri: plugin.uri + }; + } + async $provideCustomAgents(token: CancellationToken): Promise { const customAgents = await this._promptsService.getCustomAgents(token); return customAgents.map(agent => this._toCustomAgentDto(agent)); @@ -285,12 +302,12 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA async $provideHooks(token: CancellationToken): Promise { const hookFiles = await this._promptsService.listPromptFiles(PromptsType.hook, token); - return hookFiles.map(hookFile => ({ uri: hookFile.uri })); + return hookFiles.map(hookFile => this._toHookDto(hookFile)); } async $providePlugins(_token: CancellationToken): Promise { const plugins = this._agentPluginService.plugins.get(); - return plugins.map(plugin => ({ uri: plugin.uri })); + return plugins.map(plugin => this._toPluginDto(plugin)); } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index c00f7cee4c9ec..21341b82e79b0 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1751,7 +1751,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I }, getHooks(token: vscode.CancellationToken) { checkProposedApiEnabled(extension, 'chatPromptFiles'); - return extHostChatAgents2.provideHooks(token) as Thenable; + return extHostChatAgents2.provideHooks(token) as Thenable; }, onDidChangeHooks: (listener, thisArgs?, disposables?) => { checkProposedApiEnabled(extension, 'chatPromptFiles'); @@ -1759,7 +1759,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I }, getPlugins(token: vscode.CancellationToken) { checkProposedApiEnabled(extension, 'chatPromptFiles'); - return extHostChatAgents2.providePlugins(token) as Thenable; + return extHostChatAgents2.providePlugins(token) as Thenable; }, onDidChangePlugins: (listener, thisArgs?, disposables?) => { checkProposedApiEnabled(extension, 'chatPromptFiles'); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 782da9c73ed4f..4634caf814add 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1692,6 +1692,7 @@ export interface IChatResourceDto { readonly source: IChatResourceSourceDto; readonly extensionId?: string; readonly pluginUri?: UriComponents; + readonly sessionTypes?: readonly string[]; } export interface ICustomAgentDto extends IChatResourceDto { @@ -1716,11 +1717,12 @@ export interface ISlashCommandDto extends IChatResourceDto { } export interface IHookDto { - uri: UriComponents; + readonly uri: UriComponents; + readonly sessionTypes?: readonly string[]; } export interface IPluginDto { - uri: UriComponents; + readonly uri: UriComponents; } export interface IChatSessionCustomizationProviderMetadataDto { diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 0b43f3f2fdf25..6be676e44dbc6 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -531,6 +531,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS source: dto.source, extensionId: dto.extensionId, pluginUri: dto.pluginUri ? URI.revive(dto.pluginUri) : undefined, + sessionTypes: dto.sessionTypes, argumentHint: dto.argumentHint, tools: dto.tools, model: dto.model, @@ -547,6 +548,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS source: dto.source, extensionId: dto.extensionId, pluginUri: dto.pluginUri ? URI.revive(dto.pluginUri) : undefined, + sessionTypes: dto.sessionTypes, pattern: dto.pattern, }); } @@ -559,6 +561,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS source: dto.source, extensionId: dto.extensionId, pluginUri: dto.pluginUri ? URI.revive(dto.pluginUri) : undefined, + sessionTypes: dto.sessionTypes, userInvocable: dto.userInvocable, }); } @@ -571,13 +574,14 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS source: dto.source, extensionId: dto.extensionId, pluginUri: dto.pluginUri ? URI.revive(dto.pluginUri) : undefined, + sessionTypes: dto.sessionTypes, argumentHint: dto.argumentHint, userInvocable: dto.userInvocable, }); } private toHook(dto: IHookDto): vscode.ChatHook { - return Object.freeze({ uri: URI.revive(dto.uri) }); + return Object.freeze({ uri: URI.revive(dto.uri), sessionTypes: dto.sessionTypes }); } private toPlugin(dto: IPluginDto): vscode.ChatPlugin { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts index e60fd7f7663bb..dcf3d709148d4 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts @@ -25,6 +25,7 @@ interface IRawChatFileContribution { readonly name?: string; readonly description?: string; readonly when?: string; + readonly sessionTypes?: readonly string[]; } enum ChatContributionPoint { @@ -71,6 +72,11 @@ function registerChatFilesExtensionPoint(point: ChatContributionPoint) { when: { description: localize('chatContribution.property.when', '(Optional) A condition which must be true to enable this entry.'), type: 'string' + }, + sessionTypes: { + description: localize('chatContribution.property.sessionTypes', '(Optional) The chat session types where this entry should be offered.'), + type: 'array', + items: { type: 'string' } } } } @@ -133,7 +139,7 @@ export class ChatPromptFilesExtensionPointHandler implements IWorkbenchContribut continue; } try { - const d = this.promptsService.registerContributedFile(type, fileUri, ext.description, raw.name, raw.description, raw.when); + const d = this.promptsService.registerContributedFile(type, fileUri, ext.description, raw.name, raw.description, raw.when, raw.sessionTypes); this.registrations.set(key(ext.description.identifier, type, raw.path), d); } catch (e) { const msg = e instanceof Error ? e.message : String(e); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index dac0ce3bb67f1..d8a533d5fbc0c 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -87,6 +87,10 @@ export interface IPromptFileResource { * Optional externally provided prompt command description. */ readonly description?: string; + /** + * Optional session types that describe when this resource should be offered. + */ + readonly sessionTypes?: readonly string[]; } /** @@ -145,6 +149,11 @@ export interface IPromptPathBase { readonly name?: string; readonly description?: string; + + /** + * Optional session types that describe when this resource should be offered. + */ + readonly sessionTypes?: readonly string[]; } export interface IExtensionPromptPath extends IPromptPathBase { @@ -276,6 +285,11 @@ export interface ICustomAgent { * when this expression evaluates to true against a scoped context. */ readonly when?: ContextKeyExpression; + + /** + * Optional session types that describe when this agent should be offered. + */ + readonly sessionTypes?: readonly string[]; } export interface IAgentInstructions { @@ -296,6 +310,10 @@ export interface IChatPromptSlashCommand { readonly extension?: IExtensionDescription; readonly pluginUri?: URI; readonly when: ContextKeyExpression | undefined; + /** + * Optional session types that describe when this slash command should be offered. + */ + readonly sessionTypes?: readonly string[]; } export interface IResolvedChatPromptSlashCommand extends IChatPromptSlashCommand { @@ -348,6 +366,10 @@ export interface IInstructionFile { * when this expression evaluates to true against a scoped context. */ readonly when?: ContextKeyExpression; + /** + * Optional session types that describe when this instruction should be offered. + */ + readonly sessionTypes?: readonly string[]; } /** @@ -381,6 +403,10 @@ export interface IAgentSkill { * Optional extension metadata describing where this skill originated. */ readonly extension?: IExtensionDescription; + /** + * Optional session types that describe when this skill should be offered. + */ + readonly sessionTypes?: readonly string[]; } /** @@ -601,7 +627,7 @@ export interface IPromptsService extends IDisposable { * Internal: register a contributed file. Returns a disposable that removes the contribution. * Not intended for extension authors; used by contribution point handler. */ - registerContributedFile(type: PromptsType, uri: URI, extension: IExtensionDescription, name: string | undefined, description: string | undefined, when?: string): IDisposable; + registerContributedFile(type: PromptsType, uri: URI, extension: IExtensionDescription, name: string | undefined, description: string | undefined, when?: string, sessionTypes?: readonly string[]): IDisposable; getPromptLocationLabel(promptPath: IPromptPath): string; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 1631b30603f8d..1aa966316e9c8 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -481,6 +481,7 @@ export class PromptsService extends Disposable implements IPromptsService { source: PromptFileSource.ExtensionAPI, name: file.name, description: file.description, + sessionTypes: file.sessionTypes, } satisfies IExtensionPromptPath); } } catch (e) { @@ -702,6 +703,7 @@ export class PromptsService extends Disposable implements IPromptsService { argumentHint: argumentHint, userInvocable: userInvocable ?? true, when, + sessionTypes: promptPath.sessionTypes, }; } @@ -805,7 +807,7 @@ export class PromptsService extends Disposable implements IPromptsService { ? ContextKeyExpr.deserialize(promptPath.when) ?? undefined : undefined; if (!ast.header) { - const agent: ICustomAgent = { uri, name, agentInstructions, source, target, visibility: { userInvocable: true, agentInvocable: true }, ...(when !== undefined ? { when } : undefined) }; + const agent: ICustomAgent = { uri, name, agentInstructions, source, target, visibility: { userInvocable: true, agentInvocable: true }, sessionTypes: promptPath.sessionTypes, ...(when !== undefined ? { when } : undefined) }; return { status: 'loaded', promptPath: this.withPromptPathMetadata(promptPath, name, description), agent }; } const visibility = { @@ -832,7 +834,7 @@ export class PromptsService extends Disposable implements IPromptsService { hooks = parseSubagentHooksFromYaml(hooksRaw, workspaceRootUri, userHome, target); } - const agent: ICustomAgent = { uri, name, description, model, tools, handOffs, argumentHint, target, visibility, agents, hooks, agentInstructions, source, ...(when !== undefined ? { when } : undefined) }; + const agent: ICustomAgent = { uri, name, description, model, tools, handOffs, argumentHint, target, visibility, agents, hooks, agentInstructions, source, sessionTypes: promptPath.sessionTypes, ...(when !== undefined ? { when } : undefined) }; return { status: 'loaded', promptPath: this.withPromptPathMetadata(promptPath, name, description), agent }; } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); @@ -868,7 +870,7 @@ export class PromptsService extends Disposable implements IPromptsService { return new PromptFileParser().parse(uri, fileContent.value.toString()); } - public registerContributedFile(type: PromptsType, uri: URI, extension: IExtensionDescription, name?: string, description?: string, when?: string) { + public registerContributedFile(type: PromptsType, uri: URI, extension: IExtensionDescription, name?: string, description?: string, when?: string, sessionTypes?: readonly string[]) { const bucket = this.contributedFiles[type]; if (bucket.has(uri)) { // keep first registration per extension (handler filters duplicates per extension already) @@ -894,7 +896,7 @@ export class PromptsService extends Disposable implements IPromptsService { const msg = e instanceof Error ? e.message : String(e); this.logger.error(`[registerContributedFile] Failed to make prompt file readonly: ${uri}`, msg); } - return { uri, name, description, when, storage: PromptsStorage.extension, type, extension, source: PromptFileSource.ExtensionContribution } satisfies IExtensionPromptPath; + return { uri, name, description, when, sessionTypes, storage: PromptsStorage.extension, type, extension, source: PromptFileSource.ExtensionContribution } satisfies IExtensionPromptPath; })(); bucket.set(uri, entryPromise); if (when) { @@ -1175,6 +1177,7 @@ export class PromptsService extends Disposable implements IPromptsService { when, pluginUri: file.promptPath.pluginUri, extension: file.promptPath.extension, + sessionTypes: file.promptPath.sessionTypes, }); } } @@ -1329,6 +1332,7 @@ export class PromptsService extends Disposable implements IPromptsService { description: file.promptPath.description, pattern: file.pattern, when, + sessionTypes: file.promptPath.sessionTypes, }); } } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts index 56bbd14445ca4..c76355b016e57 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts @@ -54,7 +54,7 @@ export class MockPromptsService implements IPromptsService { // eslint-disable-next-line @typescript-eslint/no-explicit-any parseNew(_uri: URI, _token: CancellationToken): Promise { throw new Error('Not implemented'); } getParsedPromptFile(textModel: ITextModel): ParsedPromptFile { throw new Error('Not implemented'); } - registerContributedFile(type: PromptsType, uri: URI, extension: IExtensionDescription, name: string | undefined, description: string | undefined, when?: string): IDisposable { throw new Error('Not implemented'); } + registerContributedFile(type: PromptsType, uri: URI, extension: IExtensionDescription, name: string | undefined, description: string | undefined, when?: string, sessionTypes?: readonly string[]): IDisposable { throw new Error('Not implemented'); } getPromptLocationLabel(promptPath: IPromptPath): string { throw new Error('Not implemented'); } listNestedAgentMDs(token: CancellationToken): Promise { throw new Error('Not implemented'); } listAgentInstructions(token: CancellationToken): Promise { throw new Error('Not implemented'); } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 13430413d8a8e..b6d615d2e0116 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -799,6 +799,7 @@ suite('PromptsService', () => { visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, hooks: undefined, + sessionTypes: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'), source: { storage: PromptsStorage.local } }, @@ -856,6 +857,7 @@ suite('PromptsService', () => { visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, hooks: undefined, + sessionTypes: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'), source: { storage: PromptsStorage.local }, }, @@ -869,6 +871,7 @@ suite('PromptsService', () => { ], metadata: undefined }, + sessionTypes: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent2.agent.md'), source: { storage: PromptsStorage.local }, target: Target.Undefined, @@ -932,6 +935,7 @@ suite('PromptsService', () => { visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, hooks: undefined, + sessionTypes: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'), source: { storage: PromptsStorage.local } }, @@ -951,6 +955,7 @@ suite('PromptsService', () => { visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, hooks: undefined, + sessionTypes: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent2.agent.md'), source: { storage: PromptsStorage.local } }, @@ -1023,6 +1028,7 @@ suite('PromptsService', () => { agents: undefined, hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/github-agent.agent.md'), + sessionTypes: undefined, source: { storage: PromptsStorage.local } }, { @@ -1042,6 +1048,7 @@ suite('PromptsService', () => { agents: undefined, hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/vscode-agent.agent.md'), + sessionTypes: undefined, source: { storage: PromptsStorage.local } }, { @@ -1061,6 +1068,7 @@ suite('PromptsService', () => { agents: undefined, hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/generic-agent.agent.md'), + sessionTypes: undefined, source: { storage: PromptsStorage.local } }, ]; @@ -1139,6 +1147,7 @@ suite('PromptsService', () => { agents: undefined, hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/copilot-agent.agent.md'), + sessionTypes: undefined, source: { storage: PromptsStorage.local } }, { @@ -1160,6 +1169,7 @@ suite('PromptsService', () => { agents: undefined, hooks: undefined, uri: URI.joinPath(rootFolderUri, '.claude/agents/claude-agent.md'), + sessionTypes: undefined, source: { storage: PromptsStorage.local } }, { @@ -1180,6 +1190,7 @@ suite('PromptsService', () => { agents: undefined, hooks: undefined, uri: URI.joinPath(rootFolderUri, '.claude/agents/claude-agent2.md'), + sessionTypes: undefined, source: { storage: PromptsStorage.local } }, ]; @@ -1236,6 +1247,7 @@ suite('PromptsService', () => { visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, hooks: undefined, + sessionTypes: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/demonstrate.md'), source: { storage: PromptsStorage.local } } @@ -1307,6 +1319,7 @@ suite('PromptsService', () => { target: Target.Undefined, visibility: { userInvocable: true, agentInvocable: true }, hooks: undefined, + sessionTypes: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/restricted-agent.agent.md'), source: { storage: PromptsStorage.local } }, @@ -1326,6 +1339,7 @@ suite('PromptsService', () => { target: Target.Undefined, visibility: { userInvocable: true, agentInvocable: true }, hooks: undefined, + sessionTypes: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/no-access-agent.agent.md'), source: { storage: PromptsStorage.local } }, @@ -1345,6 +1359,7 @@ suite('PromptsService', () => { target: Target.Undefined, visibility: { userInvocable: true, agentInvocable: true }, hooks: undefined, + sessionTypes: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/full-access-agent.agent.md'), source: { storage: PromptsStorage.local } }, @@ -2062,6 +2077,86 @@ suite('PromptsService', () => { registered.dispose(); }); + + test('Contributed file sessionTypes metadata is preserved in core prompt models', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const agentUri = URI.parse('file://extensions/my-extension/contributed.agent.md'); + const instructionUri = URI.parse('file://extensions/my-extension/contributed.instructions.md'); + const promptUri = URI.parse('file://extensions/my-extension/contributed.prompt.md'); + const skillUri = URI.parse('file://extensions/my-extension/contributed-skill/SKILL.md'); + const extension = { + identifier: { value: 'test.my-extension' }, + } as IExtensionDescription; + const sessionTypes = ['copilotcli']; + + await mockFiles(fileService, [ + { + path: agentUri.path, + contents: [ + '---', + 'name: "contributed-agent"', + 'description: "Contributed agent"', + '---', + 'Agent body', + ], + }, + { + path: instructionUri.path, + contents: [ + '---', + 'name: "contributed-instruction"', + 'description: "Contributed instruction"', + '---', + 'Instruction body', + ], + }, + { + path: promptUri.path, + contents: [ + '---', + 'name: "contributed-prompt"', + 'description: "Contributed prompt"', + '---', + 'Prompt body', + ], + }, + { + path: skillUri.path, + contents: [ + '---', + 'name: "contributed-skill"', + 'description: "Contributed skill"', + '---', + 'Skill body', + ], + }, + ]); + + const registrations = [ + service.registerContributedFile(PromptsType.agent, agentUri, extension, undefined, undefined, undefined, sessionTypes), + service.registerContributedFile(PromptsType.instructions, instructionUri, extension, undefined, undefined, undefined, sessionTypes), + service.registerContributedFile(PromptsType.prompt, promptUri, extension, undefined, undefined, undefined, sessionTypes), + service.registerContributedFile(PromptsType.skill, skillUri, extension, undefined, undefined, undefined, sessionTypes), + ]; + + try { + const agent = (await service.getCustomAgents(CancellationToken.None)).find(item => item.uri.toString() === agentUri.toString()); + const instruction = (await service.getInstructionFiles(CancellationToken.None)).find(item => item.uri.toString() === instructionUri.toString()); + const prompt = (await service.getPromptSlashCommands(CancellationToken.None)).find(item => item.uri.toString() === promptUri.toString()); + const skill = (await service.findAgentSkills(CancellationToken.None))?.find(item => item.uri.toString() === skillUri.toString()); + + assert.deepStrictEqual(agent?.sessionTypes, sessionTypes); + assert.deepStrictEqual(instruction?.sessionTypes, sessionTypes); + assert.deepStrictEqual(prompt?.sessionTypes, sessionTypes); + assert.deepStrictEqual(skill?.sessionTypes, sessionTypes); + } finally { + for (const registration of registrations) { + registration.dispose(); + } + } + }); }); suite('listPromptFiles - parent repo folder', () => { @@ -2264,6 +2359,96 @@ suite('PromptsService', () => { assert.strictEqual(foundAfterDispose, undefined); }); + test('Provider sessionTypes metadata is preserved in core prompt models', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const agentUri = URI.parse('file://extensions/my-extension/enabled.agent.md'); + const instructionUri = URI.parse('file://extensions/my-extension/enabled.instructions.md'); + const promptUri = URI.parse('file://extensions/my-extension/enabled.prompt.md'); + const skillUri = URI.parse('file://extensions/my-extension/enabled-skill/SKILL.md'); + const extension = { + identifier: { value: 'test.my-extension' }, + enabledApiProposals: ['chatParticipantPrivate'] + } as unknown as IExtensionDescription; + + const sessionTypes = ['copilotcli']; + + await mockFiles(fileService, [ + { + path: agentUri.path, + contents: [ + '---', + 'name: "enabled-agent"', + 'description: "An enabled agent"', + '---', + 'Agent body', + ] + }, + { + path: instructionUri.path, + contents: [ + '---', + 'name: "enabled-instruction"', + 'description: "An enabled instruction"', + '---', + 'Instruction body', + ] + }, + { + path: promptUri.path, + contents: [ + '---', + 'name: "enabled-prompt"', + 'description: "An enabled prompt"', + '---', + 'Prompt body', + ] + }, + { + path: skillUri.path, + contents: [ + '---', + 'name: "enabled-skill"', + 'description: "An enabled skill"', + '---', + 'Skill body', + ] + }, + ]); + + const registrations = [ + service.registerPromptFileProvider(extension, PromptsType.agent, { + providePromptFiles: async () => [{ uri: agentUri, sessionTypes }] + }), + service.registerPromptFileProvider(extension, PromptsType.instructions, { + providePromptFiles: async () => [{ uri: instructionUri, sessionTypes }] + }), + service.registerPromptFileProvider(extension, PromptsType.prompt, { + providePromptFiles: async () => [{ uri: promptUri, sessionTypes }] + }), + service.registerPromptFileProvider(extension, PromptsType.skill, { + providePromptFiles: async () => [{ uri: skillUri, sessionTypes }] + }), + ]; + + try { + const agent = (await service.getCustomAgents(CancellationToken.None)).find(item => item.uri.toString() === agentUri.toString()); + const instruction = (await service.getInstructionFiles(CancellationToken.None)).find(item => item.uri.toString() === instructionUri.toString()); + const prompt = (await service.getPromptSlashCommands(CancellationToken.None)).find(item => item.uri.toString() === promptUri.toString()); + const skill = (await service.findAgentSkills(CancellationToken.None))?.find(item => item.uri.toString() === skillUri.toString()); + + assert.deepStrictEqual(agent?.sessionTypes, sessionTypes); + assert.deepStrictEqual(instruction?.sessionTypes, sessionTypes); + assert.deepStrictEqual(prompt?.sessionTypes, sessionTypes); + assert.deepStrictEqual(skill?.sessionTypes, sessionTypes); + } finally { + for (const registration of registrations) { + registration.dispose(); + } + } + }); + test('Prompt file provider', async () => { const promptUri = URI.parse('file://extensions/my-extension/myPrompt.prompt.md'); const extension = { diff --git a/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts b/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts index 70b992a956c5b..91f6b30534524 100644 --- a/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts +++ b/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts @@ -21,6 +21,11 @@ declare module 'vscode' { * Uri to the chat resource. This is typically a `.agent.md`, `.instructions.md`, `.prompt.md`, or `SKILL.md` file. */ readonly uri: Uri; + + /** + * Optional session types that describe when the resource should be offered. + */ + readonly sessionTypes?: readonly string[]; } /** @@ -47,6 +52,11 @@ declare module 'vscode' { */ readonly source: ChatResourceSource; + /** + * Optional session types that describe when the custom agent should be offered. + */ + readonly sessionTypes?: readonly string[]; + /** * The contributing extension identifier when {@link source} is `extension`. */ @@ -107,6 +117,11 @@ declare module 'vscode' { */ readonly source: ChatResourceSource; + /** + * Optional session types that describe when the instruction should be offered. + */ + readonly sessionTypes?: readonly string[]; + /** * The contributing extension identifier when {@link source} is `extension`. */ @@ -147,6 +162,11 @@ declare module 'vscode' { */ readonly source: ChatResourceSource; + /** + * Optional session types that describe when the skill should be offered. + */ + readonly sessionTypes?: readonly string[]; + /** * The contributing extension identifier when {@link source} is `extension`. */ @@ -187,6 +207,11 @@ declare module 'vscode' { */ readonly source: ChatResourceSource; + /** + * Optional session types that describe when the slash command should be offered. + */ + readonly sessionTypes?: readonly string[]; + /** * The contributing extension identifier when {@link source} is `extension`. */ @@ -210,10 +235,18 @@ declare module 'vscode' { export interface ChatHook { readonly uri: Uri; + /** + * Optional session types that describe when the hook should be offered. + */ + readonly sessionTypes?: readonly string[]; } export interface ChatPlugin { readonly uri: Uri; + /** + * Optional session types that describe when the plugin should be offered. + */ + readonly sessionTypes?: readonly string[]; } // #endregion @@ -385,7 +418,7 @@ declare module 'vscode' { * Provide the list of currently available hook configuration files. These are JSON files that define lifecycle hooks from all sources (workspace, user, and extension-provided). * @param token A cancellation token. */ - export function getHooks(token: CancellationToken): Thenable; + export function getHooks(token: CancellationToken): Thenable; /** * An event that fires when the list of {@link plugins plugins} changes. @@ -397,7 +430,7 @@ declare module 'vscode' { * Provide the list of currently installed agent plugins. * @param token A cancellation token. */ - export function getPlugins(token: CancellationToken): Thenable; + export function getPlugins(token: CancellationToken): Thenable; /** * Register a provider for custom agents. From f563e5b84c115d4f098c60283752dc93bf4ac967 Mon Sep 17 00:00:00 2001 From: Ulugbek Abdullaev Date: Thu, 16 Apr 2026 22:20:02 +0500 Subject: [PATCH 09/56] Add reverse-agreement rebase for NES cache behind experiment flag (#310793) * nes: cache: add a test to show how rebasing fails * feat: add reverse-agreement rebase for NES cache behind experiment flag When the user types more text than the model predicted at the same position (e.g., model: '{', user: '{\n\t'), the rebase engine now recognizes that the model's edit is consumed by the user's typing and offers the remaining unconsumed model edits as rebased suggestions. The new behavior is gated behind the 'reverseAgreement' experiment flag in NesRebaseConfigs (config key: chat.advanced.inlineEdits.reverseAgreement, default: false). Adds 10 unit tests covering positive, negative, and edge cases. --- .../inlineEdits/common/editRebase.ts | 77 ++++++ .../inlineEdits/node/nextEditCache.ts | 1 + .../inlineEdits/node/rebaseResult.ts | 13 +- .../test/common/editRebase.spec.ts | 241 ++++++++++++++++++ .../test/node/nextEditCacheRebase.spec.ts | 177 +++++++++++++ .../common/configurationService.ts | 1 + 6 files changed, 507 insertions(+), 3 deletions(-) create mode 100644 extensions/copilot/src/extension/inlineEdits/test/node/nextEditCacheRebase.spec.ts diff --git a/extensions/copilot/src/extension/inlineEdits/common/editRebase.ts b/extensions/copilot/src/extension/inlineEdits/common/editRebase.ts index 2a86014f85c0a..dd9ae35aca565 100644 --- a/extensions/copilot/src/extension/inlineEdits/common/editRebase.ts +++ b/extensions/copilot/src/extension/inlineEdits/common/editRebase.ts @@ -22,6 +22,14 @@ export interface NesRebaseConfigs { * the typed pair instead of failing. */ readonly absorbSubsequenceTyping?: boolean; + /** + * When enabled, allows rebase to succeed when the user typed more text + * than the model predicted at the same position (reverse agreement). + * Model edits consumed by the user's typing are absorbed, and any + * unconsumed portion of subsequent model edits is offered as the + * rebased suggestion. + */ + readonly reverseAgreement?: boolean; } export class EditDataWithIndex implements IEditData { @@ -209,6 +217,75 @@ function tryRebaseEdits>(content: string, ours: Annotated )); ourIdx++; offset += delta; + } else if (nesConfigs.reverseAgreement && ourEdit.replaceRange.equals(baseEdit.replaceRange)) { + // Reverse agreement: user's edit (base) covers model's edit (ours) + // at the same range. The user typed more than the model predicted. + // Use ourEdit (pre-shift) to avoid false matches from shift alignment. + // Iterate over consecutive our-edits consumed by this base edit. + let baseNewTextOffset = 0; + let previousOurE: AnnotatedStringReplacement | undefined; + + while (ourIdx < ours.replacements.length && baseEdit.replaceRange.containsRange(ours.replacements[ourIdx].replaceRange)) { + const curOurE = ours.replacements[ourIdx]; + + // Account for gap content between previous our-edit end and current our-edit start + const gapStart = previousOurE ? previousOurE.replaceRange.endExclusive : baseEdit.replaceRange.start; + const gapText = gapStart < curOurE.replaceRange.start ? content.substring(gapStart, curOurE.replaceRange.start) : ''; + const effectiveText = gapText + curOurE.newText; + + // Try full consumption: model text found entirely within user text + const j = baseEdit.newText.indexOf(effectiveText, baseNewTextOffset); + const strictRejected = j !== -1 && resolution === 'strict' && ( + j - baseNewTextOffset > maxAgreementOffset || + (j - baseNewTextOffset > 0 && effectiveText.length > maxImperfectAgreementLength) + ); + + if (j !== -1 && !strictRejected) { + // Full consumption โ€” model edit absorbed by user typing + baseNewTextOffset = j + effectiveText.length; + previousOurE = curOurE; + ourIdx++; + continue; + } + + // Try partial consumption: remaining user text is a prefix of model text + const remainingBase = baseEdit.newText.substring(baseNewTextOffset); + if (remainingBase.length > 0 && effectiveText.startsWith(remainingBase)) { + const consumedFromNewText = Math.max(0, remainingBase.length - gapText.length); + const unconsumedNewText = curOurE.newText.substring(consumedFromNewText); + if (unconsumedNewText.length > 0) { + newEdits.push(new AnnotatedStringReplacement( + OffsetRange.emptyAt(baseEdit.replaceRange.start + offset + baseEdit.newText.length), + unconsumedNewText, + curOurE.data, + )); + } + baseNewTextOffset = baseEdit.newText.length; + previousOurE = curOurE; + ourIdx++; + break; + } + + // Conflicting + return undefined; + } + + // Verify trailing gap in strict mode: any original content between the + // last consumed our-edit and the end of the base range must be preserved. + // Remaining user text beyond the gap is the user's own typing and is fine. + if (baseNewTextOffset < baseEdit.newText.length && resolution === 'strict') { + const lastOurEnd = previousOurE ? previousOurE.replaceRange.endExclusive : baseEdit.replaceRange.start; + const trailingGap = content.substring(lastOurEnd, baseEdit.replaceRange.endExclusive); + if (trailingGap.length > 0) { + const remainingBase = baseEdit.newText.substring(baseNewTextOffset); + if (!remainingBase.startsWith(trailingGap)) { + return undefined; + } + } + } + + baseIdx++; + offset += baseEdit.newText.length - baseEdit.replaceRange.length; } else { // Conflicting return undefined; diff --git a/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts b/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts index 0a3a27444a102..bcaa766ee382d 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts @@ -127,6 +127,7 @@ export class NextEditCache extends Disposable { private _getNesRebaseConfigs(): NesRebaseConfigs { return { absorbSubsequenceTyping: this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsAbsorbSubsequenceTyping, this._expService), + reverseAgreement: this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsReverseAgreement, this._expService), }; } diff --git a/extensions/copilot/src/extension/inlineEdits/node/rebaseResult.ts b/extensions/copilot/src/extension/inlineEdits/node/rebaseResult.ts index 8d7f4e82cbf8e..bc278d8feea61 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/rebaseResult.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/rebaseResult.ts @@ -114,15 +114,22 @@ export class RebaseFailureInfo implements MarkdownLoggable { lines.push(`\tconst currentSelection = [${this.currentSelection.map(s => `new OffsetRange(${s.start}, ${s.endExclusive})`).join(', ')}];`); - if (this.nesRebaseConfigs.absorbSubsequenceTyping) { - lines.push(`\tconst nesConfigs = { absorbSubsequenceTyping: ${this.nesRebaseConfigs.absorbSubsequenceTyping} };`); + if (this.nesRebaseConfigs.absorbSubsequenceTyping || this.nesRebaseConfigs.reverseAgreement) { + const configEntries: string[] = []; + if (this.nesRebaseConfigs.absorbSubsequenceTyping) { + configEntries.push(`absorbSubsequenceTyping: ${this.nesRebaseConfigs.absorbSubsequenceTyping}`); + } + if (this.nesRebaseConfigs.reverseAgreement) { + configEntries.push(`reverseAgreement: ${this.nesRebaseConfigs.reverseAgreement}`); + } + lines.push(`\tconst nesConfigs = { ${configEntries.join(', ')} };`); } lines.push(''); lines.push('\tconst logger = new TestLogService();'); lines.push('\texpect(userEditSince.apply(originalDocument)).toBe(currentDocumentContent);'); - const configsArg = this.nesRebaseConfigs.absorbSubsequenceTyping ? ', nesConfigs' : ''; + const configsArg = (this.nesRebaseConfigs.absorbSubsequenceTyping || this.nesRebaseConfigs.reverseAgreement) ? ', nesConfigs' : ''; lines.push(`\texpect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger${configsArg})).toMatchInlineSnapshot();`); lines.push('});'); diff --git a/extensions/copilot/src/extension/inlineEdits/test/common/editRebase.spec.ts b/extensions/copilot/src/extension/inlineEdits/test/common/editRebase.spec.ts index 701ac20559dfd..45790da6b1fa1 100644 --- a/extensions/copilot/src/extension/inlineEdits/test/common/editRebase.spec.ts +++ b/extensions/copilot/src/extension/inlineEdits/test/common/editRebase.spec.ts @@ -1091,4 +1091,245 @@ class Point3D { expect(lenient2?.apply(current2)).toStrictEqual(applied); expect(lenient2?.removeCommonSuffixAndPrefix(current2).replacements.toString()).toMatchInlineSnapshot(`"[7, ${7 + maxImperfectAgreementLength + 1}) -> "x${'h'.repeat(maxImperfectAgreementLength + 2)}x""`); }); + + test('reverse agreement: user typed more than model predicted at same position', () => { + // Model predicts two edits: insert "{" and insert body. + // User typed "{\n\t" which covers the first edit and the start of the second. + // Rebase should succeed, offering the unconsumed portion of the second edit. + const originalDocument = 'class Fibonacci \n'; + const originalEdits = [ + StringReplacement.replace(new OffsetRange(0, 16), 'class Fibonacci {'), + StringReplacement.replace(OffsetRange.emptyAt(17), '\n\tprivate memo: Map;\n}'), + ]; + const userEditSince = StringEdit.create([ + StringReplacement.replace(new OffsetRange(0, 16), 'class Fibonacci {\n\t'), + ]); + const currentDocumentContent = 'class Fibonacci {\n\t\n'; + const nesConfigs = { reverseAgreement: true }; + + const logger = new TestLogService(); + // Without flag: rebase fails + expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger)).toBe('rebaseFailed'); + // With flag: rebase succeeds + const res = tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs); + expect(res).toBeTypeOf('object'); + const result = res as Exclude; + expect(result.length).toBe(1); + expect(result[0].rebasedEditIndex).toBe(1); + // The unconsumed portion of the body edit should be offered + expect(result[0].rebasedEdit.newText).toContain('private memo'); + }); + + test('reverse agreement: user typed exactly the first model edit', () => { + // User typed exactly "{" which is the model's first edit. + // The second edit (body) should be offered in full. + // Note: this case is actually handled by the existing forward agreement path + // (user text length == model text length), so it works regardless of the flag. + const originalDocument = 'class Foo \n'; + const originalEdits = [ + StringReplacement.replace(new OffsetRange(0, 10), 'class Foo {'), + StringReplacement.replace(OffsetRange.emptyAt(12), '\n\tbar(): void {}\n}'), + ]; + const userEditSince = StringEdit.create([ + StringReplacement.replace(new OffsetRange(0, 10), 'class Foo {'), + ]); + const currentDocumentContent = 'class Foo {\n'; + + const logger = new TestLogService(); + // Works without reverse agreement flag (handled by forward agreement) + const res = tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger); + expect(res).toBeTypeOf('object'); + const result = res as Exclude; + expect(result.length).toBe(1); + expect(result[0].rebasedEditIndex).toBe(1); + expect(result[0].rebasedEdit.newText).toContain('bar(): void {}'); + }); + + test('reverse agreement: user typed completely different text โ€” should conflict', () => { + // Model: "class Foo " โ†’ "class Foo {" + // User: "class Foo " โ†’ "class Foo XYZ" + // "XYZ" is NOT found in "{", so this should fail. + const originalDocument = 'class Foo \n'; + const originalEdits = [ + StringReplacement.replace(new OffsetRange(0, 10), 'class Foo {'), + ]; + const userEditSince = StringEdit.create([ + StringReplacement.replace(new OffsetRange(0, 10), 'class Foo XYZ'), + ]); + const currentDocumentContent = 'class Foo XYZ\n'; + const nesConfigs = { reverseAgreement: true }; + + const logger = new TestLogService(); + expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs)).toBe('rebaseFailed'); + expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'lenient', logger, nesConfigs)).toBe('rebaseFailed'); + }); + + test('reverse agreement: user typed text that accidentally contains model text as substring', () => { + // Model: replace [0,5) "hello" โ†’ "hello{" (diff: insert "{" at 5), then insert body at 6. + // User: replace [0,5) "hello" โ†’ "helloXX{YY" (diff: insert "XX{YY" at 5). + // The model's first diff ("{") IS found in user's "XX{YY" at offset 2, so it's consumed. + // But the model's second edit ("\n\tworld\n}") can't be found in the remaining + // user text "YY" โ€” partial consumption also fails ("YY" doesn't start with "\n\tworld\n}"). + // So the rebase correctly fails for the second edit. + const originalDocument = 'hello\n'; + const originalEdits = [ + StringReplacement.replace(new OffsetRange(0, 5), 'hello{'), + StringReplacement.replace(OffsetRange.emptyAt(6), '\n\tworld\n}'), + ]; + const userEditSince = StringEdit.create([ + StringReplacement.replace(new OffsetRange(0, 5), 'helloXX{YY'), + ]); + const currentDocumentContent = 'helloXX{YY\n'; + const nesConfigs = { reverseAgreement: true }; + + const logger = new TestLogService(); + // Fails because user's remaining text "YY" doesn't match model's second edit + expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs)).toBe('rebaseFailed'); + expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'lenient', logger, nesConfigs)).toBe('rebaseFailed'); + }); + + test('reverse agreement: user typed text with model text at large offset โ€” strict rejects', () => { + // Model: "a" โ†’ "a{" + // User: "a" โ†’ "a" + "X".repeat(15) + "{" + // The "{" is at offset 15 into the user text, which exceeds maxAgreementOffset (10). + // Strict should reject; lenient should also fail since there's no lenient fallback + // in the reverse branch. + const pad = 'X'.repeat(maxAgreementOffset + 1); + const originalDocument = 'a\n'; + const originalEdits = [ + StringReplacement.replace(new OffsetRange(0, 1), 'a{'), + ]; + const userEditSince = StringEdit.create([ + StringReplacement.replace(new OffsetRange(0, 1), 'a' + pad + '{'), + ]); + const currentDocumentContent = 'a' + pad + '{\n'; + const nesConfigs = { reverseAgreement: true }; + + const logger = new TestLogService(); + expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs)).toBe('rebaseFailed'); + }); + + test('reverse agreement: user typed long text at small offset โ€” strict rejects imperfect agreement', () => { + // Model: "a" โ†’ "a{" + // User: "a" โ†’ "aX" + "{".repeat(maxImperfectAgreementLength + 1) + // The model text "{" is found at offset 1 (> 0) and the effective text length + // is 1 (โ‰ค maxImperfectAgreementLength), so this should pass strict. + // But if effectiveText were longer... + const longText = 'Z'.repeat(maxImperfectAgreementLength + 1); + const originalDocument = 'a\n'; + const originalEdits = [ + StringReplacement.replace(new OffsetRange(0, 1), 'a' + longText), + ]; + const userEditSince = StringEdit.create([ + StringReplacement.replace(new OffsetRange(0, 1), 'aX' + longText), + ]); + const currentDocumentContent = 'aX' + longText + '\n'; + const nesConfigs = { reverseAgreement: true }; + + const logger = new TestLogService(); + // offset = 1 > 0, effectiveText.length = longText.length > maxImperfectAgreementLength + // โ†’ strict rejected + expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs)).toBe('rebaseFailed'); + }); + + test('reverse agreement: all model edits fully consumed by user โ€” no rebased edit emitted', () => { + // Model predicts single edit: insert "{\n\t" + // User typed "{\n\tfoo\n}" which fully contains "{\n\t" + // All model edits consumed โ†’ nothing to offer + const originalDocument = 'fn \n'; + const originalEdits = [ + StringReplacement.replace(new OffsetRange(0, 3), 'fn {\n\t'), + ]; + const userEditSince = StringEdit.create([ + StringReplacement.replace(new OffsetRange(0, 3), 'fn {\n\tfoo\n}'), + ]); + const currentDocumentContent = 'fn {\n\tfoo\n}\n'; + const nesConfigs = { reverseAgreement: true }; + + const logger = new TestLogService(); + // Without flag: rebase fails + expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger)).toBe('rebaseFailed'); + // With flag: succeeds with no edits to offer + const res = tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs); + expect(res).toBeTypeOf('object'); + const result = res as Exclude; + // The single model edit was fully consumed โ€” nothing left to suggest + expect(result.length).toBe(0); + }); + + test('reverse agreement: consistency check โ€” rebased edit applied to current doc produces expected result', () => { + // This is the key correctness check: applying the rebased edit to the current + // document should produce the same result as applying the original edits to + // the original document. + const originalDocument = 'class Fibonacci \n'; + const originalEdits = [ + StringReplacement.replace(new OffsetRange(0, 16), 'class Fibonacci {'), + StringReplacement.replace(OffsetRange.emptyAt(17), '\n\tprivate memo: Map;\n}'), + ]; + const userEditSince = StringEdit.create([ + StringReplacement.replace(new OffsetRange(0, 16), 'class Fibonacci {\n\t'), + ]); + const currentDocumentContent = 'class Fibonacci {\n\t\n'; + const nesConfigs = { reverseAgreement: true }; + + // Expected final: apply both model edits in sequence to original + const expectedFinal = new StringEdit([originalEdits[0]]).apply(originalDocument); + const expectedFinal2 = new StringEdit([originalEdits[1]]).apply(expectedFinal); + + const logger = new TestLogService(); + const res = tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs); + expect(res).toBeTypeOf('object'); + const result = res as Exclude; + expect(result.length).toBe(1); + + // Apply rebased edit to current document + const actualFinal = StringEdit.single(result[0].rebasedEdit).apply(currentDocumentContent); + expect(actualFinal).toBe(expectedFinal2); + }); + + test('reverse agreement: pure inserts at same position โ€” user insert is superset of model insert', () => { + // Both edits are pure inserts at position 5. + // Model inserts "X", user inserts "XY". + // After removeCommonSuffixAndPrefix on user edit: + // user edit: insert at 5 โ†’ "XY", model edit: insert at 5 โ†’ "X" + // These have equal replaceRange (both emptyAt(5)). + // The reverse branch should fire: "X" found in "XY" at offset 0 โ†’ consumed. + // Nothing left to suggest from this model edit. + const originalDocument = 'hello world\n'; + const suggestedEdit = StringEdit.create([ + StringReplacement.replace(OffsetRange.emptyAt(5), 'X'), + ]); + const userEdit = StringEdit.create([ + StringReplacement.replace(OffsetRange.emptyAt(5), 'XY'), + ]); + const current = userEdit.apply(originalDocument); + expect(current).toBe('helloXY world\n'); + + // Without flag: rebase fails + expect(tryRebaseStringEdits(originalDocument, suggestedEdit, userEdit, 'strict')).toBeUndefined(); + // With flag: model edit fully consumed โ†’ empty result + const nesConfigs = { reverseAgreement: true }; + const res = tryRebaseStringEdits(originalDocument, suggestedEdit, userEdit, 'strict', nesConfigs); + expect(res).toBeDefined(); + expect(res!.replacements.length).toBe(0); + }); + + test('reverse agreement: does NOT fire when ranges differ', () => { + // Model replaces [0,3), user replaces [0,5) โ€” different ranges. + // The reverse branch requires equal ranges, so this should NOT trigger it. + // Instead, this falls through to the conflict branch. + const originalDocument = 'abcde\n'; + const originalEdits = [ + StringReplacement.replace(new OffsetRange(0, 3), 'XYZ'), + ]; + const userEditSince = StringEdit.create([ + StringReplacement.replace(new OffsetRange(0, 5), 'XYZWV'), + ]); + const currentDocumentContent = 'XYZWV\n'; + const nesConfigs = { reverseAgreement: true }; + + const logger = new TestLogService(); + // The ranges don't match after removeCommonSuffixAndPrefix, so this conflicts + expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs)).toBe('rebaseFailed'); + }); }); diff --git a/extensions/copilot/src/extension/inlineEdits/test/node/nextEditCacheRebase.spec.ts b/extensions/copilot/src/extension/inlineEdits/test/node/nextEditCacheRebase.spec.ts new file mode 100644 index 0000000000000..6020bf4716d15 --- /dev/null +++ b/extensions/copilot/src/extension/inlineEdits/test/node/nextEditCacheRebase.spec.ts @@ -0,0 +1,177 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { assert, beforeEach, describe, it } from 'vitest'; +import { ConfigKey } from '../../../../platform/configuration/common/configurationService'; +import { DefaultsOnlyConfigurationService } from '../../../../platform/configuration/common/defaultsOnlyConfigurationService'; +import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService'; +import { DocumentId } from '../../../../platform/inlineEdits/common/dataTypes/documentId'; +import { InlineEditRequestLogContext } from '../../../../platform/inlineEdits/common/inlineEditLogContext'; +import { MutableObservableWorkspace } from '../../../../platform/inlineEdits/common/observableWorkspace'; +import { LogServiceImpl } from '../../../../platform/log/common/logService'; +import { NullExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService'; +import { URI } from '../../../../util/vs/base/common/uri'; +import { generateUuid } from '../../../../util/vs/base/common/uuid'; +import { StringEdit, StringReplacement } from '../../../../util/vs/editor/common/core/edits/stringEdit'; +import { OffsetRange } from '../../../../util/vs/editor/common/core/ranges/offsetRange'; +import { StringText } from '../../../../util/vs/editor/common/core/text/abstractText'; +import { NextEditCache } from '../../node/nextEditCache'; +import { NextEditFetchRequest } from '../../node/nextEditProvider'; + +/** + * Regression test from a real scenario: + * + * User typed `class Fibonacci {\n\t` character by character. Two NES requests + * were made at different points during typing: + * + * - Request #6 (early): doc ended with `class `, model predicted `class FibonacciCalculator {` + * - Request #18 (later): doc ended with `class Fibonacci `, model predicted `class Fibonacci {` + * + * When `lookupNextEdit` runs, it should find and rebase the compatible cached edit + * from request #18 (whose prediction matches the user's typing). + */ +describe('NextEditCache rebase โ€” Fibonacci scenario', () => { + + let configService: InMemoryConfigurationService; + let obsWorkspace: MutableObservableWorkspace; + let logService: LogServiceImpl; + let expService: NullExperimentationService; + let cache: NextEditCache; + let docId: DocumentId; + + // Common prefix of all document states โ€” everything before the class declaration + const docPrefix = + 'import * as vscode from \'vscode\';\n' + + 'import { ASTNodeWithOffset } from \'./nodeTypes\';\n' + + 'import { NodeTypesIndex } from \'./nodeTypesIndex\';\n' + + 'import { Result } from \'./util/common/result\';\n' + + 'import { LRUCache } from \'./util/vs/base/common/map\';\n' + + '\n' + + 'export class NodeTypesDefinitionProvider implements vscode.DefinitionProvider {\n' + + '\n' + + '\tprivate _cache: LRUCache;\n' + + '\tprivate _definitions: Map;\n' + + '\n' + + '\tconstructor() {\n' + + '\t\tthis._definitions = new Map();\n' + + '\t\tthis._cache = new LRUCache(10);\n' + + '\t}\n' + + '\n' + + '\tasync provideDefinition(\n' + + '\t\tdocument: vscode.TextDocument,\n' + + '\t\tposition: vscode.Position,\n' + + '\t\ttoken: vscode.CancellationToken\n' + + '\t): Promise {\n' + + '\t\tconst word = NodeTypesDefinitionProvider.positionToSymbol(document, position);\n' + + '\t\tif (!word) {\n' + + '\t\t\treturn null;\n' + + '\t\t}\n' + + '\t\tconst def = this.computeDefForSymbol(document, word);\n' + + '\t\tif (!def) {\n' + + '\t\t\treturn null;\n' + + '\t\t}\n' + + '\t\treturn [{\n' + + '\t\t\ttargetUri: document.uri,\n' + + '\t\t\ttargetRange: new vscode.Range(document.positionAt(def.offset), document.positionAt(def.offset + def.length))\n' + + '\t\t}];\n' + + '\t}\n' + + '\n' + + '\tprivate computeDefForSymbol(document: vscode.TextDocument, symbol: string) {\n' + + '\t\tconst index = new NodeTypesIndex(document);\n' + + '\t\tconst astNodes = index.nodes;\n' + + '\t\tif (Result.isErr(astNodes)) {\n' + + '\t\t\treturn null;\n' + + '\t\t}\n' + + '\t\tthis.recomputeDefinitions(astNodes.val);\n' + + '\t\treturn this._definitions.get(symbol) || null;\n' + + '\t}\n' + + '\n' + + '\tprivate recomputeDefinitions(nodes: ASTNodeWithOffset[]) {\n' + + '\t\tif (this._cache.has(nodes)) {\n' + + '\t\t\treturn;\n' + + '\t\t}\n' + + '\t\tfor (const node of nodes) {\n' + + '\t\t\tthis._definitions.set(node.type.value, node);\n' + + '\t\t}\n' + + '\t\tthis._cache.set(nodes, true);\n' + + '\t}\n' + + '\n' + + '\tprivate static positionToSymbol(document: vscode.TextDocument, position: vscode.Position) {\n' + + '\t\tconst wordRange = document.getWordRangeAtPosition(position);\n' + + '\t\treturn wordRange ? document.getText(wordRange) : null;\n' + + '\t}\n' + + '}\n' + + '\n' + + 'function fibonacci(n: number): number {\n' + + '\tif (n <= 1) {\n' + + '\t\treturn n;\n' + + '\t}\n' + + '\treturn fibonacci(n - 1) + fibonacci(n - 2);\n' + + '}\n' + + '\n'; + + // Document states at different points in time โ€” offsets derived from docPrefix.length + const classStart = docPrefix.length; // where "class " begins + const docAtRequest18 = docPrefix + 'class Fibonacci '; // "class Fibonacci " ends at classStart + 16 + const classEndAtRequest18 = classStart + 'class Fibonacci '.length; // = classStart + 16 + const currentDoc = docPrefix + 'class Fibonacci {\n\t'; // "class Fibonacci {\n\t" ends at classStart + 19 + const cursorOffset = classStart + 'class Fibonacci {\n\t'.length; // = classStart + 19 + + function makeSource(): NextEditFetchRequest { + const logContext = new InlineEditRequestLogContext('test', 0, undefined); + return new NextEditFetchRequest(generateUuid(), logContext, undefined, false); + } + + beforeEach(async () => { + configService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService()); + await configService.setConfig(ConfigKey.TeamInternal.InlineEditsReverseAgreement, true); + obsWorkspace = new MutableObservableWorkspace(); + logService = new LogServiceImpl([]); + expService = new NullExperimentationService(); + + docId = DocumentId.create(URI.file('/test/nodeTypesDefinitionProvider.ts').toString()); + // Initialize workspace doc with the CURRENT document state + // (so checkEditConsistency(documentBeforeEdit + userEditSince = currentDoc) passes) + obsWorkspace.addDocument({ id: docId, initialValue: currentDoc }); + + cache = new NextEditCache(obsWorkspace, logService, configService, expService); + }); + + it('rebases cached edit when model predicted class Fibonacci { and user typed the same', () => { + // Scenario from real usage: + // documentBeforeEdit (at cache time): ...class Fibonacci \n (ends at offset 1960) + // Model's edit: replace [1944,1960) "class Fibonacci " โ†’ "class Fibonacci {" + // Model also has a 2nd edit: insert at 1961 โ†’ class body + // User then typed "{\n\t" โ†’ userEditSince: [1944,1960) โ†’ "class Fibonacci {\n\t" + // + // The user's typing is a superset of the model's first edit (model: "{", user: "{\n\t"), + // so rebase should succeed and the 2nd edit (class body) should be offered. + const cachedEdit = cache.setKthNextEdit( + docId, + new StringText(docAtRequest18), + new OffsetRange(classStart, classEndAtRequest18), // editWindow + new StringReplacement(new OffsetRange(classStart, classEndAtRequest18), 'class Fibonacci {'), + 0, + [ + new StringReplacement(new OffsetRange(classStart, classEndAtRequest18), 'class Fibonacci {'), + new StringReplacement(OffsetRange.emptyAt(classStart + 'class Fibonacci {'.length), '\n\tprivate memo: Map;\n\n\tconstructor() {\n\t\tthis.memo = new Map();\n\t}\n\n\tcalc(n: number): number {\n\t\tif (n <= 1) {\n\t\t\treturn n;\n\t\t}\n\t\tif (this.memo.has(n)) {\n\t\t\treturn this.memo.get(n)!;\n\t\t}\n\t\tconst result = this.calc(n - 1) + this.calc(n - 2);\n\t\tthis.memo.set(n, result);\n\t\treturn result;\n\t}\n}'), + ], + StringEdit.single(new StringReplacement(new OffsetRange(classStart, classEndAtRequest18), 'class Fibonacci {\n\t')), + makeSource(), + { isFromCursorJump: false, cursorOffset: classEndAtRequest18 }, + ); + + assert(cachedEdit !== undefined, 'setKthNextEdit should return the cached edit'); + assert(cachedEdit.userEditSince !== undefined, 'userEditSince should be set'); + + const rebaseResult = cache.tryRebaseCacheEntry( + cachedEdit, + new StringText(currentDoc), + [new OffsetRange(cursorOffset, cursorOffset)], + ); + + assert(rebaseResult.edit !== undefined, 'should rebase successfully'); + assert(rebaseResult.edit.rebasedEdit !== undefined, 'should have a rebased edit for the class body'); + }); +}); diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 548b3ab8febb5..526b37e3bc23d 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -777,6 +777,7 @@ export namespace ConfigKey { export const InlineEditsSpeculativeRequestDelay = defineTeamInternalSetting('chat.advanced.inlineEdits.speculativeRequestDelay', ConfigType.ExperimentBased, 0); export const InlineEditsRebasedCacheDelay = defineTeamInternalSetting('chat.advanced.inlineEdits.rebasedCacheDelay', ConfigType.ExperimentBased, 0); export const InlineEditsAbsorbSubsequenceTyping = defineTeamInternalSetting('chat.advanced.inlineEdits.absorbSubsequenceTyping', ConfigType.ExperimentBased, false); + export const InlineEditsReverseAgreement = defineTeamInternalSetting('chat.advanced.inlineEdits.reverseAgreement', ConfigType.ExperimentBased, false); export const InlineEditsBackoffDebounceEnabled = defineTeamInternalSetting('chat.advanced.inlineEdits.backoffDebounceEnabled', ConfigType.ExperimentBased, true); export const InlineEditsExtraDebounceEndOfLine = defineTeamInternalSetting('chat.advanced.inlineEdits.extraDebounceEndOfLine', ConfigType.ExperimentBased, 2000); export const InlineEditsSpeculativeRequests = defineTeamInternalSetting('chat.advanced.inlineEdits.speculativeRequests', ConfigType.ExperimentBased, SpeculativeRequestsEnablement.Off, SpeculativeRequestsEnablement.VALIDATOR); From 2d68d33f4244de1e4dc7ca13b6b5879d20a39144 Mon Sep 17 00:00:00 2001 From: Aashna Garg Date: Thu, 16 Apr 2026 10:31:45 -0700 Subject: [PATCH 10/56] Fix router model selection: filter available_models, remove same-provider override, iterate candidates Bug fixes only (no telemetry changes): 1. Filter available_models through knownEndpoints before sending to router 2. Remove same-provider override from router path - trust router ranking 3. Iterate all candidate_models via _findFirstAvailableModel 4. Warn-level logs for sync mismatches --- .../platform/endpoint/node/automodeService.ts | 28 +++- .../node/test/automodeService.spec.ts | 131 ++++++++++++++++++ 2 files changed, 155 insertions(+), 4 deletions(-) diff --git a/extensions/copilot/src/platform/endpoint/node/automodeService.ts b/extensions/copilot/src/platform/endpoint/node/automodeService.ts index eb4c97318ee9f..d55fbab8570c6 100644 --- a/extensions/copilot/src/platform/endpoint/node/automodeService.ts +++ b/extensions/copilot/src/platform/endpoint/node/automodeService.ts @@ -276,7 +276,23 @@ export class AutomodeService extends Disposable implements IAutomodeService { turn_number: (entry?.turnCount ?? 0) + 1, }; const routingMethod = this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.AutoModeRoutingMethod, this._expService) || undefined; - const result = await this._routerDecisionFetcher.getRouterDecision(prompt, token.session_token, token.available_models, undefined, contextSignals, conversationId, chatRequest?.id, routingMethod, hasImage(chatRequest)); + + // Filter available_models to only those the client can actually serve. + // The AutoModels API and Models API are separate CAPI calls that can be + // out of sync (e.g. a new model appears in available_models before the + // Models API returns it). Sending unresolvable models to the router + // causes it to recommend models the client must silently discard. + const knownModelIds = new Set(knownEndpoints.map(e => e.model)); + const routableModels = token.available_models.filter(m => knownModelIds.has(m)); + if (!routableModels.length) { + this._logService.warn(`[AutomodeService] No available_models matched knownEndpoints. available_models=[${token.available_models.join(', ')}], knownEndpoints=[${knownEndpoints.map(e => e.model).join(', ')}]`); + return { lastRoutedPrompt: prompt, fallbackReason: 'noMatchingEndpoint' }; + } + if (routableModels.length < token.available_models.length) { + this._logService.info(`[AutomodeService] Filtered ${token.available_models.length - routableModels.length} unresolvable model(s) before routing: [${token.available_models.filter(m => !knownModelIds.has(m)).join(', ')}]`); + } + + const result = await this._routerDecisionFetcher.getRouterDecision(prompt, token.session_token, routableModels, undefined, contextSignals, conversationId, chatRequest?.id, routingMethod, hasImage(chatRequest)); if (result.fallback) { this._logService.info(`[AutomodeService] Router signaled fallback: ${result.fallback_reason ?? 'unknown'}, routing_method=${result.routing_method ?? 'n/a'}`); @@ -287,11 +303,15 @@ export class AutomodeService extends Disposable implements IAutomodeService { return { lastRoutedPrompt: prompt, fallbackReason: 'emptyCandidateList' }; } - // Prefer same-provider model, then fall back to the router's top candidate - const selectedModel = (entry?.endpoint && this._findSameProviderModel(entry.endpoint.modelProvider, result.candidate_models, knownEndpoints)) - ?? knownEndpoints.find(e => e.model === result.candidate_models[0]); + // Trust the router's ranked candidate list directly. + // Same-provider preference is intentionally NOT applied here โ€” the router + // already accounts for available models and re-runs after /compact, so + // overriding its pick with same-provider negates cost-saving decisions. + // Same-provider is still used in _selectDefaultModel (the non-router fallback). + const selectedModel = this._findFirstAvailableModel(result.candidate_models, knownEndpoints); if (!selectedModel) { + this._logService.warn(`[AutomodeService] None of the router's candidate_models matched knownEndpoints: [${result.candidate_models.join(', ')}]`); return { lastRoutedPrompt: prompt, fallbackReason: 'noMatchingEndpoint' }; } diff --git a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts index 0da8ebc9828b2..fa867cf10f726 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts @@ -1018,4 +1018,135 @@ describe('AutomodeService', () => { ); }); }); + + describe('available_models / knownEndpoints sync', () => { + function mockRouterResponse(available_models: string[], routerResult: { chosen_model: string; candidate_models: string[] }, session_token = 'test-token'): void { + (mockCAPIClientService.makeRequest as ReturnType).mockImplementation((_body: any, opts: any) => { + if (opts?.type === RequestType.ModelRouter) { + return Promise.resolve({ + ok: true, + status: 200, + headers: createMockHeaders(), + text: vi.fn().mockResolvedValue(JSON.stringify({ + predicted_label: 'no_reasoning', + confidence: 0.96, + latency_ms: 23, + chosen_model: routerResult.chosen_model, + candidate_models: routerResult.candidate_models, + scores: { needs_reasoning: 0.04, no_reasoning: 0.96 }, + sticky_override: false + })) + }); + } + return Promise.resolve( + makeMockTokenResponse({ + available_models, + expires_at: Math.floor(Date.now() / 1000) + 3600, + session_token, + }) + ); + }); + } + + it('should filter out available_models that have no matching knownEndpoint before sending to router', async () => { + enableRouter(); + const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI'); + let capturedBody: string | undefined; + (mockCAPIClientService.makeRequest as ReturnType).mockImplementation((req: any, opts: any) => { + if (opts?.type === RequestType.ModelRouter) { + capturedBody = req.body; + return Promise.resolve({ + ok: true, + status: 200, + headers: createMockHeaders(), + text: vi.fn().mockResolvedValue(JSON.stringify({ + predicted_label: 'no_reasoning', + confidence: 0.96, + latency_ms: 23, + chosen_model: 'gpt-4o', + candidate_models: ['gpt-4o'], + scores: { needs_reasoning: 0.04, no_reasoning: 0.96 }, + sticky_override: false + })) + }); + } + return Promise.resolve( + makeMockTokenResponse({ + available_models: ['claude-haiku-4.5', 'gpt-4o', 'claude-sonnet-4.6'], + expires_at: Math.floor(Date.now() / 1000) + 3600, + session_token: 'test-token', + }) + ); + }); + + automodeService = createService(); + const chatRequest: Partial = { + location: ChatLocation.Panel, + prompt: 'what day is today', + sessionId: 'session-filter-models' + }; + + await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [gpt4oEndpoint]); + + expect(capturedBody).toBeDefined(); + const parsed = JSON.parse(capturedBody!); + expect(parsed.available_models).toEqual(['gpt-4o']); + expect(parsed.available_models).not.toContain('claude-haiku-4.5'); + expect(parsed.available_models).not.toContain('claude-sonnet-4.6'); + expect(mockLogService.info).toHaveBeenCalledWith( + expect.stringContaining('Filtered 2 unresolvable model(s)') + ); + }); + + it('should iterate all candidate_models when first candidate has no endpoint', async () => { + enableRouter(); + const gpt41Endpoint = createEndpoint('gpt-4.1', 'OpenAI'); + + mockRouterResponse( + ['gpt-4.1'], + { chosen_model: 'gpt-4.1', candidate_models: ['unknown-new-model', 'gpt-4.1'] } + ); + + automodeService = createService(); + const chatRequest: Partial = { + location: ChatLocation.Panel, + prompt: 'what day is today', + sessionId: 'session-iterate-candidates' + }; + + const result = await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [gpt41Endpoint]); + expect(result.model).toBe('gpt-4.1'); + }); + + it('should fall back to default when all available_models are unknown to knownEndpoints', async () => { + enableRouter(); + const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI'); + + (mockCAPIClientService.makeRequest as ReturnType).mockImplementation((_body: any, opts: any) => { + if (opts?.type === RequestType.ModelRouter) { + throw new Error('Router should not be called when no models are routable'); + } + return Promise.resolve( + makeMockTokenResponse({ + available_models: ['unknown-model-a', 'unknown-model-b'], + expires_at: Math.floor(Date.now() / 1000) + 3600, + session_token: 'test-token', + }) + ); + }); + + automodeService = createService(); + const chatRequest: Partial = { + location: ChatLocation.Panel, + prompt: 'test prompt', + sessionId: 'session-all-unknown' + }; + + const result = await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [gpt4oEndpoint]); + expect(result.model).toBe('gpt-4o'); + expect(mockLogService.warn).toHaveBeenCalledWith( + expect.stringContaining('No available_models matched knownEndpoints') + ); + }); + }); }); From 1696c1957d27eb9417c14eb35a965d5ef5f8775c Mon Sep 17 00:00:00 2001 From: Aashna Garg Date: Thu, 16 Apr 2026 10:33:49 -0700 Subject: [PATCH 11/56] Add automode.routerModelSelection telemetry with actualModel Emits a new telemetry event after all client-side model overrides are applied. Properties: candidateModel, actualModel, overrideReason (none/clientOverride), conversationId. Tests cover: match, vision fallback override, router failure. --- .../platform/endpoint/node/automodeService.ts | 27 +++- .../node/test/automodeService.spec.ts | 126 +++++++++++++++++- 2 files changed, 149 insertions(+), 4 deletions(-) diff --git a/extensions/copilot/src/platform/endpoint/node/automodeService.ts b/extensions/copilot/src/platform/endpoint/node/automodeService.ts index eb4c97318ee9f..c717f59871cdd 100644 --- a/extensions/copilot/src/platform/endpoint/node/automodeService.ts +++ b/extensions/copilot/src/platform/endpoint/node/automodeService.ts @@ -215,6 +215,29 @@ export class AutomodeService extends Disposable implements IAutomodeService { selectedModel = this._applyVisionFallback(chatRequest, selectedModel, token.available_models, knownEndpoints); + // Emit the final model selection alongside the router's recommendation + // so analysts can detect overrides without fragile telemetry joins + if (!skipRouter && routerResult.candidateModel) { + /* __GDPR__ + "automode.routerModelSelection" : { + "owner": "aashnagarg", + "comment": "Reports the router's recommended model vs the actual model used after all client-side overrides", + "conversationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The conversation ID" }, + "candidateModel": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The router's top candidate model (candidate_models[0])" }, + "actualModel": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model actually selected after all client-side overrides" }, + "overrideReason": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Why the actual model differs from the candidate: none or clientOverride" } + } + */ + const candidateModel = routerResult.candidateModel; + const overrideReason = candidateModel === selectedModel.model ? 'none' : 'clientOverride'; + this._telemetryService.sendMSFTTelemetryEvent('automode.routerModelSelection', { + conversationId: conversationId ?? '', + candidateModel, + actualModel: selectedModel.model, + overrideReason, + }); + } + // Reuse the cached endpoint if the session token and model haven't changed const autoEndpoint = (entry?.endpoint && entry.lastSessionToken === token.session_token && entry.endpoint.model === selectedModel.model) ? entry.endpoint @@ -250,7 +273,7 @@ export class AutomodeService extends Disposable implements IAutomodeService { entry: AutoModelCacheEntry | undefined, token: AutoModeAPIResponse, knownEndpoints: IChatEndpoint[], - ): Promise<{ selectedModel?: IChatEndpoint; lastRoutedPrompt?: string; fallbackReason?: string }> { + ): Promise<{ selectedModel?: IChatEndpoint; lastRoutedPrompt?: string; fallbackReason?: string; candidateModel?: string }> { const prompt = chatRequest?.prompt?.trim(); const lastRoutedPrompt = entry?.lastRoutedPrompt ?? prompt; @@ -298,7 +321,7 @@ export class AutomodeService extends Disposable implements IAutomodeService { if (result.sticky_override) { this._logService.trace(`[AutomodeService] Sticky routing override: confidence=${(result.confidence * 100).toFixed(1)}%, label=${result.predicted_label}, router_model=${result.candidate_models[0]}, actual_model=${selectedModel.model}`); } - return { selectedModel, lastRoutedPrompt: prompt }; + return { selectedModel, lastRoutedPrompt: prompt, candidateModel: result.candidate_models[0] }; } catch (e) { const isTimeout = isAbortError(e); let fallbackReason: string; diff --git a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts index 0da8ebc9828b2..e8cfe7ec506f7 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts @@ -17,7 +17,7 @@ import { ILogService } from '../../../log/common/logService'; import { IChatEndpoint } from '../../../networking/common/networking'; import { NullRequestLogger } from '../../../requestLogger/node/nullRequestLogger'; import { IExperimentationService, NullExperimentationService } from '../../../telemetry/common/nullExperimentationService'; -import { NullTelemetryService } from '../../../telemetry/common/nullTelemetryService'; +import { ITelemetryService } from '../../../telemetry/common/telemetry'; import { ICAPIClientService } from '../../common/capiClient'; import { AutomodeService } from '../automodeService'; @@ -60,6 +60,7 @@ describe('AutomodeService', () => { let configurationService: IConfigurationService; let mockChatEndpoint: IChatEndpoint; let envService: NullEnvService; + let mockTelemetryService: ITelemetryService & { sendMSFTTelemetryEvent: ReturnType }; function createEndpoint(model: string, provider: string, overrides?: Partial): IChatEndpoint { return { @@ -87,7 +88,7 @@ describe('AutomodeService', () => { mockExpService, configurationService, envService, - new NullTelemetryService(), + mockTelemetryService, new NullRequestLogger() ); } @@ -145,6 +146,13 @@ describe('AutomodeService', () => { configurationService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService()); envService = new NullEnvService(); + mockTelemetryService = { + sendTelemetryEvent: vi.fn(), + sendMSFTTelemetryEvent: vi.fn(), + sendTelemetryErrorEvent: vi.fn(), + sendMSFTTelemetryErrorEvent: vi.fn(), + sendSharedTelemetryEvent: vi.fn(), + } as unknown as ITelemetryService & { sendMSFTTelemetryEvent: ReturnType }; }); afterEach(() => { @@ -1018,4 +1026,118 @@ describe('AutomodeService', () => { ); }); }); + + describe('routerModelSelection telemetry', () => { + function mockRouterResponse(available_models: string[], routerResult: { chosen_model: string; candidate_models: string[] }, session_token = 'test-token'): void { + (mockCAPIClientService.makeRequest as ReturnType).mockImplementation((_body: any, opts: any) => { + if (opts?.type === RequestType.ModelRouter) { + return Promise.resolve({ + ok: true, + status: 200, + headers: createMockHeaders(), + text: vi.fn().mockResolvedValue(JSON.stringify({ + predicted_label: 'needs_reasoning', + confidence: 0.9, + latency_ms: 30, + chosen_model: routerResult.chosen_model, + candidate_models: routerResult.candidate_models, + scores: { needs_reasoning: 0.9, no_reasoning: 0.1 }, + sticky_override: false + })) + }); + } + return Promise.resolve( + makeMockTokenResponse({ + available_models, + expires_at: Math.floor(Date.now() / 1000) + 3600, + session_token, + }) + ); + }); + } + + it('should emit routerModelSelection with candidateModel and actualModel when router is used', async () => { + enableRouter(); + const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI'); + const claudeEndpoint = createEndpoint('claude-sonnet', 'Anthropic'); + + mockRouterResponse( + ['gpt-4o', 'claude-sonnet'], + { chosen_model: 'gpt-4o', candidate_models: ['gpt-4o', 'claude-sonnet'] } + ); + + automodeService = createService(); + const chatRequest: Partial = { + location: ChatLocation.Panel, + prompt: 'test prompt', + sessionId: 'session-telemetry-test' + }; + + await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [gpt4oEndpoint, claudeEndpoint]); + + const telemetryCalls = mockTelemetryService.sendMSFTTelemetryEvent.mock.calls; + const selectionEvent = telemetryCalls.find((call: unknown[]) => call[0] === 'automode.routerModelSelection'); + expect(selectionEvent).toBeDefined(); + expect(selectionEvent![1]).toMatchObject({ + candidateModel: 'gpt-4o', + actualModel: 'gpt-4o', + overrideReason: 'none', + }); + }); + + it('should emit overrideReason=clientOverride when vision fallback changes the model', async () => { + enableRouter(); + const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI', { supportsVision: true }); + const claudeEndpoint = createEndpoint('claude-sonnet', 'Anthropic', { supportsVision: false }); + + // Router picks claude-sonnet (no vision), vision fallback should override to gpt-4o + mockRouterResponse( + ['claude-sonnet', 'gpt-4o'], + { chosen_model: 'claude-sonnet', candidate_models: ['claude-sonnet', 'gpt-4o'] } + ); + + automodeService = createService(); + const chatRequest: Partial = { + location: ChatLocation.Panel, + prompt: 'describe this image', + sessionId: 'session-telemetry-vision', + references: [{ id: 'img', value: { mimeType: 'image/png', data: new Uint8Array() } }] as any + }; + + await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [gpt4oEndpoint, claudeEndpoint]); + + const telemetryCalls = mockTelemetryService.sendMSFTTelemetryEvent.mock.calls; + const selectionEvent = telemetryCalls.find((call: unknown[]) => call[0] === 'automode.routerModelSelection'); + expect(selectionEvent).toBeDefined(); + expect(selectionEvent![1]).toMatchObject({ + candidateModel: 'claude-sonnet', + actualModel: 'gpt-4o', + overrideReason: 'clientOverride', + }); + }); + + it('should not emit routerModelSelection when router fails', async () => { + enableRouter(); + const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI'); + + mockRouterResponse( + ['gpt-4o'], + { chosen_model: 'unknown-model', candidate_models: ['unknown-model'] } + ); + + automodeService = createService(); + const chatRequest: Partial = { + location: ChatLocation.Panel, + prompt: 'test prompt', + sessionId: 'session-telemetry-no-emit' + }; + + await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [gpt4oEndpoint]); + + const telemetryCalls = mockTelemetryService.sendMSFTTelemetryEvent.mock.calls; + const selectionEvent = telemetryCalls.find((call: unknown[]) => call[0] === 'automode.routerModelSelection'); + // candidateModel is not set when router returns unknown model, so event should not emit + expect(selectionEvent).toBeUndefined(); + }); + }); }); From 8bae860e0685720a5dbb0e52e73fc15b9b3b6a9e Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega <48293249+osortega@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:35:05 -0700 Subject: [PATCH 12/56] Agent web: Build system for agent sessions and fixes (#310202) * Build system for agent sessions * Discovery fixes * better npe fix. --------- Co-authored-by: Peng Lyu --- build/buildfile.ts | 3 ++ build/gulpfile.vscode.web.ts | 1 + build/lib/mangle/index.ts | 1 + build/next/index.ts | 7 ++++ .../browser/tunnelAgentHost.contribution.ts | 40 +++++++++++++++++++ .../common/customizationHarnessService.ts | 17 ++++++++ 6 files changed, 69 insertions(+) diff --git a/build/buildfile.ts b/build/buildfile.ts index 4641a2b195419..8920d12387d77 100644 --- a/build/buildfile.ts +++ b/build/buildfile.ts @@ -33,6 +33,8 @@ export const workbenchDesktop = [ export const workbenchWeb = createModuleDescription('vs/workbench/workbench.web.main.internal'); +export const sessionsWeb = createModuleDescription('vs/sessions/sessions.web.main.internal'); + export const keyboardMaps = [ createModuleDescription('vs/workbench/services/keybinding/browser/keyboardLayouts/layout.contribution.linux'), createModuleDescription('vs/workbench/services/keybinding/browser/keyboardLayouts/layout.contribution.darwin'), @@ -73,6 +75,7 @@ const buildfile = { workerBackgroundTokenization, workbenchDesktop, workbenchWeb, + sessionsWeb, keyboardMaps, code, codeWeb, diff --git a/build/gulpfile.vscode.web.ts b/build/gulpfile.vscode.web.ts index 9af2afecb38ba..5e0fb6da48cbc 100644 --- a/build/gulpfile.vscode.web.ts +++ b/build/gulpfile.vscode.web.ts @@ -116,6 +116,7 @@ const vscodeWebEntryPoints = [ buildfile.workerBackgroundTokenization, buildfile.keyboardMaps, buildfile.workbenchWeb, + buildfile.sessionsWeb, ].flat(); /** diff --git a/build/lib/mangle/index.ts b/build/lib/mangle/index.ts index e53c58d32ebc7..b4f4f83a05b6d 100644 --- a/build/lib/mangle/index.ts +++ b/build/lib/mangle/index.ts @@ -321,6 +321,7 @@ const skippedExportMangledFiles = [ buildfile.workerBackgroundTokenization, buildfile.workbenchDesktop, buildfile.workbenchWeb, + buildfile.sessionsWeb, buildfile.code, buildfile.codeWeb ].flat().map(x => x.name), diff --git a/build/next/index.ts b/build/next/index.ts index 3b530ea1e5184..1993dbf6d6f12 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -119,6 +119,11 @@ const webEntryPoints = [ 'vs/code/browser/workbench/workbench', ]; +// Additional web-only entry points (CDN build only, not in server-web) +const webOnlyEntryPoints = [ + 'vs/sessions/sessions.web.main.internal', +]; + const keyboardMapEntryPoints = [ 'vs/workbench/services/keybinding/browser/keyboardLayouts/layout.contribution.linux', 'vs/workbench/services/keybinding/browser/keyboardLayouts/layout.contribution.darwin', @@ -173,6 +178,7 @@ function getEntryPointsForTarget(target: BuildTarget): string[] { case 'web': return [ ...workerEntryPoints, + ...webOnlyEntryPoints, 'vs/workbench/workbench.web.main.internal', // web workbench only (no browser shell) ...keyboardMapEntryPoints, ]; @@ -220,6 +226,7 @@ function getCssBundleEntryPointsForTarget(target: BuildTarget): Set { case 'web': return new Set([ 'vs/workbench/workbench.web.main.internal', + 'vs/sessions/sessions.web.main.internal', ]); default: throw new Error(`Unknown target: ${target}`); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts index 6e82bb9ee6064..34a3195f9c371 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts @@ -4,13 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { isWeb } from '../../../../base/common/platform.js'; import * as nls from '../../../../nls.js'; import { IRemoteAgentHostService, 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'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js'; @@ -33,6 +36,8 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc @IConfigurationService private readonly _configurationService: IConfigurationService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @INotificationService private readonly _notificationService: INotificationService, + @ILogService private readonly _logService: ILogService, + @IAuthenticationService private readonly _authenticationService: IAuthenticationService, ) { super(); @@ -50,6 +55,15 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc this._reconcileProviders(); })); + // Re-run discovery when a GitHub session becomes available + // (e.g. after the walkthrough completes sign-in). + this._register(this._authenticationService.onDidChangeSessions(e => { + if (e.providerId === 'github') { + this._logService.info('[TunnelAgentHost] GitHub sessions changed, retrying discovery...'); + this._silentStatusCheck(); + } + })); + // Silently check status of cached tunnels on startup this._silentStatusCheck(); } @@ -219,6 +233,15 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc } } + // Auto-cache online tunnels that aren't cached yet so they + // appear in the UI on first discovery (e.g. fresh web session). + const cachedIds = new Set(cached.map(t => t.tunnelId)); + for (const tunnel of onlineTunnels) { + if (!cachedIds.has(tunnel.tunnelId) && tunnel.hostConnectionCount > 0) { + this._tunnelService.cacheTunnel(tunnel); + } + } + // 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 @@ -241,6 +264,23 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Disconnected); } } + + // 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) { + for (const tunnel of onlineTunnels) { + if (tunnel.hostConnectionCount > 0) { + const address = `${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`; + const alreadyConnected = this._remoteAgentHostService.connections.some( + c => c.address === address && c.status === RemoteAgentHostConnectionStatus.Connected + ); + if (!alreadyConnected) { + this._connectTunnel(address); + } + } + } + } } } } diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index 1a5c8d53b3ffb..90a9ec187f2e8 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -269,6 +269,17 @@ const EMPTY_FILTER: IStorageSourceFilter = { sources: [], }; +/** + * Empty descriptor returned when no harness is registered yet. + */ +const EMPTY_DESCRIPTOR: IHarnessDescriptor = { + id: '', + label: '', + icon: Codicon.sparkle, + getStorageSourceFilter: () => ({ sources: [] }), +}; + + /** * Hooks filter โ€” local, user, and plugin sources. */ @@ -502,6 +513,9 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer getStorageSourceFilter(type: PromptsType): IStorageSourceFilter { const activeId = this._activeHarness.get(); const all = this._getAllHarnesses(); + if (all.length === 0) { + return EMPTY_FILTER; + } const descriptor = all.find(h => h.id === activeId) ?? all[0]; return descriptor?.getStorageSourceFilter(type) ?? EMPTY_FILTER; } @@ -509,6 +523,9 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer getActiveDescriptor(): IHarnessDescriptor { const activeId = this._activeHarness.get(); const all = this._getAllHarnesses(); + if (all.length === 0) { + return EMPTY_DESCRIPTOR; + } return all.find(h => h.id === activeId) ?? all[0]; } } From d60ffdc8024d908e34f989420d212df1744efaf1 Mon Sep 17 00:00:00 2001 From: Ulugbek Abdullaev Date: Thu, 16 Apr 2026 22:52:48 +0500 Subject: [PATCH 13/56] fix: set isNESForAnotherDoc correctly for cross-file NES (#310794) Previously isNESForAnotherDoc was only set inside addNotebookTelemetry, so non-notebook cross-file NES always reported false. - Set the flag on the main edit path for all cases (notebook and non-notebook) - Set the flag on the jump-to-position path when targetDocumentId differs - Set the flag and status 'noEdit:crossFileTargetNotFound' when cross-file target document is not found in the workspace - Remove redundant setIsNESForOtherEditor call from addNotebookTelemetry --- .../inlineEdits/vscode-node/inlineCompletionProvider.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts b/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts index 3337e2a7d28ed..f59c7a000a97d 100644 --- a/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts +++ b/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts @@ -354,6 +354,7 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo const positionToJumpOneBased = suggestionInfo.suggestion.result.jumpToPosition; const jumpToPosition = new Position(positionToJumpOneBased.lineNumber - 1, positionToJumpOneBased.column - 1); const targetDocumentId = suggestionInfo.suggestion.result.targetDocumentId; + telemetryBuilder.setIsNESForOtherEditor(!!targetDocumentId && targetDocumentId !== doc.id); const jumpToPositionCompletionItem: NesCompletionItem = { insertText: undefined as unknown as string, info: suggestionInfo, @@ -388,6 +389,8 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo resolveDoc = targetObsDoc; } else { logger.trace('no next edit suggestion: cross-file target document not found in workspace'); + telemetryBuilder.setIsNESForOtherEditor(true); + telemetryBuilder.setStatus('noEdit:crossFileTargetNotFound'); this.telemetrySender.scheduleSendingEnhancedTelemetry(suggestionInfo.suggestion, telemetryBuilder); return emptyList; } @@ -396,6 +399,7 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo const [targetDocument, range] = documents.length ? documents[0] : [undefined, undefined]; addNotebookTelemetry(document, position, result.edit.newText, documents, telemetryBuilder); + telemetryBuilder.setIsNESForOtherEditor(targetDocument !== undefined && targetDocument !== document); telemetryBuilder.setIsActiveDocument(window.activeTextEditor?.document === targetDocument); if (!targetDocument) { @@ -769,6 +773,5 @@ function addNotebookTelemetry(document: TextDocument, position: Position, newTex .setIsNextEditorVisible(!!nextEditor) .setIsNextEditorRangeVisible(!!isNextEditorRangeVisible) .setNotebookCellLines(lineCounts) - .setNotebookId(notebookId) - .setIsNESForOtherEditor(documents[0][0] !== document); + .setNotebookId(notebookId); } From fb010060088fcec3ac9388bc4661215b67115a5e Mon Sep 17 00:00:00 2001 From: Robo Date: Fri, 17 Apr 2026 02:57:39 +0900 Subject: [PATCH 14/56] fix: prevent update server from bogus 'unknown' commit in overwrite checks (#310800) * fix: prevent update server from bogus 'unknown' commit in overwrite checks When update metadata is missing (e.g. interrupted install recovery via _applySpecificUpdate), the fallback IUpdate uses version: 'unknown'. This value flows into checkForOverwriteUpdates, which polls every 5 minutes and passes it as the commit to buildUpdateFeedUrl, producing requests to /api/update/{platform}/{quality}/unknown. Skip the overwrite check when pendingUpdateCommit is missing or 'unknown' to break the cycle. * fix: construct commit for install recovery --- .../platform/update/electron-main/abstractUpdateService.ts | 4 ++++ src/vs/platform/update/electron-main/updateService.win32.ts | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/update/electron-main/abstractUpdateService.ts b/src/vs/platform/update/electron-main/abstractUpdateService.ts index fb5142e88787d..1652c62e690c9 100644 --- a/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -392,6 +392,10 @@ export abstract class AbstractUpdateService implements IUpdateService { const pendingUpdateCommit = this._state.update.version; + if (!pendingUpdateCommit || pendingUpdateCommit === 'unknown') { + return false; + } + let isLatest: boolean | undefined; try { diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index 222db559f1240..63327397766f1 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -147,7 +147,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun this.logService.info(`update#doCheckForUpdates - application was updating to version ${updatingVersion}`); const updatePackagePath = await this.getUpdatePackagePath(updatingVersion); if (await pfs.Promises.exists(updatePackagePath)) { - await this._applySpecificUpdate(updatePackagePath); + await this._applySpecificUpdate(updatePackagePath, updatingVersion); this.logService.info(`update#doCheckForUpdates - successfully applied update to version ${updatingVersion}`); } } catch (e) { @@ -532,13 +532,13 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun return getUpdateType(); } - override async _applySpecificUpdate(packagePath: string): Promise { + override async _applySpecificUpdate(packagePath: string, commit?: string): Promise { if (this.state.type !== StateType.Idle) { return; } const fastUpdatesEnabled = this.configurationService.getValue('update.enableWindowsBackgroundUpdates'); - const update: IUpdate = await this.loadUpdateMetadata() ?? { version: 'unknown', productVersion: 'unknown' }; + const update: IUpdate = await this.loadUpdateMetadata() ?? { version: commit ?? 'unknown', productVersion: 'unknown' }; this.setState(State.Downloading(update, true, false)); this.availableUpdate = { packagePath }; From ce9d8b33ccb9e4c516f1abc75d9bfcf1b7d56604 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Thu, 16 Apr 2026 19:57:49 +0200 Subject: [PATCH 15/56] remove 'included' property from input latency telemetry configuration --- .../contrib/performance/browser/performance.contribution.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/vs/workbench/contrib/performance/browser/performance.contribution.ts b/src/vs/workbench/contrib/performance/browser/performance.contribution.ts index 3363d36e1bdad..37bb9c7cdb740 100644 --- a/src/vs/workbench/contrib/performance/browser/performance.contribution.ts +++ b/src/vs/workbench/contrib/performance/browser/performance.contribution.ts @@ -153,7 +153,6 @@ Registry.as(ConfigExt.Configuration).registerConfigurati minimum: 0, maximum: 1, tags: ['experimental'], - included: false, markdownDescription: localize('telemetry.performance.inputLatencySamplingProbability', "Probability (0 to 1) that input latency telemetry is reported for this session. Set to 0 to disable, 1 to always report."), experiment: { mode: 'auto' From e2a6c9c2cd097c3098d583c62a505ee1684ed8d8 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 16 Apr 2026 14:00:38 -0400 Subject: [PATCH 16/56] Fix Agents window accessibility: picker aria labels and keybindings (#310805) fix 3rd round of agents app issues --- .../contrib/chat/browser/repoPicker.ts | 2 ++ .../contrib/chat/browser/sessionTypePicker.ts | 2 ++ .../browser/branchPicker.ts | 2 ++ .../browser/isolationPicker.ts | 2 ++ .../copilotChatSessions/browser/modePicker.ts | 3 ++- .../copilotChatSessions/browser/modelPicker.ts | 2 ++ .../browser/permissionPicker.ts | 2 ++ .../agentSessions/agentSessionsActions.ts | 18 ++++++++++++++---- .../widgetHosts/viewPane/chatViewPane.ts | 3 +++ 9 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/repoPicker.ts b/src/vs/sessions/contrib/chat/browser/repoPicker.ts index 0ac9de839be2a..6bf3e892ab876 100644 --- a/src/vs/sessions/contrib/chat/browser/repoPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/repoPicker.ts @@ -249,6 +249,8 @@ export class RepoPicker extends Disposable { const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); labelSpan.textContent = label; dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); + + this._triggerElement.ariaLabel = localize('repoPicker.triggerAriaLabel', "Pick Repository, {0}", label); } } diff --git a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts index 482d8ea760a4f..281eaec54bc91 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts @@ -166,5 +166,7 @@ export class SessionTypePicker extends Disposable { labelSpan.textContent = modeLabel; dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); + + this._triggerElement.ariaLabel = localize('sessionTypePicker.triggerAriaLabel', "Pick Session Type, {0}", modeLabel); } } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts index a4e9bebda4355..61bd15c3bf54d 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts @@ -147,6 +147,8 @@ export class BranchPicker extends Disposable { labelSpan.textContent = label; dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); + this._triggerElement.ariaLabel = localize('branchPicker.triggerAriaLabel', "Pick Branch, {0}", label); + this._slotElement?.classList.toggle('disabled', isLoading || isDisabled); this._triggerElement.setAttribute('aria-disabled', String(isLoading || isDisabled)); this._triggerElement.tabIndex = (isLoading || isDisabled) ? -1 : 0; diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts index 814ceaabf547a..11b60551d1a2f 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts @@ -200,6 +200,8 @@ export class IsolationPicker extends Disposable { labelSpan.textContent = modeLabel; dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); + this._triggerElement.ariaLabel = localize('isolationPicker.triggerAriaLabel', "Pick Isolation Mode, {0}", modeLabel); + const isDisabled = !this._hasGitRepo; this._slotElement?.classList.toggle('disabled', isDisabled); this._triggerElement.setAttribute('aria-disabled', String(isDisabled)); diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts index dfd734d150413..438ab2251c02b 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts @@ -90,7 +90,6 @@ export class ModePicker extends Disposable { const trigger = dom.append(slot, dom.$('a.action-label')); trigger.tabIndex = 0; trigger.role = 'button'; - trigger.setAttribute('aria-label', localize('sessions.modePicker.ariaLabel', "Select chat mode")); this._triggerElement = trigger; this._updateTriggerLabel(); @@ -239,5 +238,7 @@ export class ModePicker extends Disposable { const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); labelSpan.textContent = this._selectedMode.label.get(); dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); + + this._triggerElement.ariaLabel = localize('modePicker.triggerAriaLabel', "Pick Mode, {0}", this._selectedMode.label.get()); } } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/modelPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/modelPicker.ts index ab7650c6d5d48..e62b24fe7eca2 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/modelPicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/modelPicker.ts @@ -210,6 +210,8 @@ export class CloudModelPicker extends Disposable { labelSpan.textContent = label; dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); + this._triggerElement.ariaLabel = localize('modelPicker.triggerAriaLabel', "Pick Model, {0}", label); + this._slotElement?.classList.toggle('disabled', this._models.length === 0); this._triggerElement.setAttribute('aria-disabled', String(this._models.length === 0)); } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts index 105597a8614dd..8c20dcdefd8a1 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts @@ -290,6 +290,8 @@ export class PermissionPicker extends Disposable { labelSpan.textContent = label; dom.append(trigger, renderIcon(Codicon.chevronDown)); + trigger.ariaLabel = localize('permissionPicker.triggerAriaLabel', "Pick Permission Level, {0}", label); + trigger.classList.toggle('warning', this._currentLevel === ChatPermissionLevel.Autopilot); trigger.classList.toggle('info', this._currentLevel === ChatPermissionLevel.AutoApprove); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts index 4b019ac5effe1..57edb5acf1384 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts @@ -891,8 +891,13 @@ export class RefreshAgentSessionsViewerAction extends Action2 { }); } - override run(accessor: ServicesAccessor, agentSessionsControl: IAgentSessionsControl) { - agentSessionsControl.refresh(); + override run(accessor: ServicesAccessor, agentSessionsControl?: IAgentSessionsControl) { + const control = agentSessionsControl ?? accessor.get(IViewsService).getActiveViewWithId(ChatViewId)?.agentSessionsControl; + if (control) { + control.refresh(); + } else { + accessor.get(ICommandService).executeCommand('sessionsViewPane.refresh'); + } } } @@ -911,8 +916,13 @@ export class FindAgentSessionInViewerAction extends Action2 { }); } - override run(accessor: ServicesAccessor, agentSessionsControl: IAgentSessionsControl) { - return agentSessionsControl.openFind(); + override run(accessor: ServicesAccessor, agentSessionsControl?: IAgentSessionsControl) { + const control = agentSessionsControl ?? accessor.get(IViewsService).getActiveViewWithId(ChatViewId)?.agentSessionsControl; + if (control) { + return control.openFind(); + } else { + return accessor.get(ICommandService).executeCommand('sessionsViewPane.find'); + } } } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index f2a7a77ec9d42..1ccad289c114f 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -351,6 +351,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private sessionsNewButtonContainer: HTMLElement | undefined; private sessionsControlContainer: HTMLElement | undefined; private sessionsControl: AgentSessionsControl | undefined; + + get agentSessionsControl(): AgentSessionsControl | undefined { return this.sessionsControl; } + private sessionsViewerVisible: boolean; private sessionsViewerOrientation = AgentSessionsViewerOrientation.Stacked; private sessionsViewerOrientationConfiguration: 'stacked' | 'sideBySide' = 'sideBySide'; From aadd0921d92a199f9d68eaf4b8872c55646c7387 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 16 Apr 2026 14:13:56 -0400 Subject: [PATCH 17/56] agentHost: connect status and update diffs occasionally in the sessions app (#310812) I realized after implementing this that we avoid showing diffs for in-progress sessions https://github.com/microsoft/vscode/blob/3267a57dc5ace468c668880fca8bb5cb73467e63/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts#L262-L264 But if that ever was to change this'd work nicely. And I think it should change :) --- .../agentHost/node/agentSideEffects.ts | 40 ++++++++++++-- src/vs/sessions/common/agentHostDiffs.ts | 19 +++++++ .../browser/localAgentHostSessionsProvider.ts | 54 +++++++++++++++++-- .../remoteAgentHostSessionsProvider.ts | 53 ++++++++++++++++-- 4 files changed, 152 insertions(+), 14 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 4d5102c187997..ab867c5393592 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -3,16 +3,16 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { SequencerByKey } from '../../../base/common/async.js'; +import { disposableTimeout, SequencerByKey } from '../../../base/common/async.js'; import { match as globMatch } from '../../../base/common/glob.js'; -import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { autorun, IObservable } from '../../../base/common/observable.js'; import { extUriBiasedIgnorePathCase, normalizePath } from '../../../base/common/resources.js'; import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { ILogService } from '../../log/common/log.js'; -import { IAgent, IAgentAttachment, IAgentProgressEvent, type IAgentToolReadyEvent } from '../common/agentService.js'; +import { IAgent, IAgentAttachment, IAgentProgressEvent, type IAgentToolCompleteEvent, type IAgentToolReadyEvent } from '../common/agentService.js'; import { IDiffComputeService } from '../common/diffComputeService.js'; import { ISessionDataService } from '../common/sessionDataService.js'; import { ActionType, ISessionAction } from '../common/state/sessionActions.js'; @@ -24,6 +24,7 @@ import { ToolCallStatus, ToolResultContentType, buildSubagentSessionUri, + getToolFileEdits, type ISessionCustomization, type ISessionModelInfo, type ISessionState, @@ -70,6 +71,9 @@ export class AgentSideEffects extends Disposable { private readonly _diffComputeService: IDiffComputeService; /** Serializes per-session diff computations to avoid races with stale previousDiffs. */ private readonly _diffComputationSequencer = new SequencerByKey(); + /** Per-session debounce timers for mid-turn diff computation. */ + private readonly _debouncedDiffTimers = this._register(new DisposableMap()); + private static readonly _DIFF_DEBOUNCE_MS = 5000; /** * Maps `parentSession:toolCallId` โ†’ subagent session URI. @@ -332,12 +336,16 @@ export class AgentSideEffects extends Disposable { // When a parent tool call completes, complete any associated subagent session if (e.type === 'tool_complete') { this.completeSubagentSession(sessionKey, e.toolCallId); + if (getToolFileEdits((e as IAgentToolCompleteEvent).result).length > 0) { + this._scheduleDebouncedDiffComputation(sessionKey, turnId); + } } } - // After a turn completes (idle event), compute session diffs and - // try to consume the next queued message + // After a turn completes (idle event), flush any pending debounced + // diff computation and compute final diffs immediately. if (e.type === 'idle') { + this._cancelDebouncedDiffComputation(sessionKey); this._computeSessionDiffs(sessionKey, turnId); this._tryConsumeNextQueuedMessage(sessionKey); } @@ -849,6 +857,28 @@ export class AgentSideEffects extends Disposable { // ---- Session diff computation ---------------------------------------------- + /** + * Schedules a debounced diff computation for a session. If a timer is + * already pending for this session, it is replaced (restarting the delay). + * The computation fires after {@link _DIFF_DEBOUNCE_MS} unless cancelled + * or flushed by the turn-complete handler. + */ + private _scheduleDebouncedDiffComputation(session: ProtocolURI, turnId: string): void { + // DisposableMap.set() auto-disposes any previous timer for this session + this._debouncedDiffTimers.set(session, disposableTimeout(() => { + this._debouncedDiffTimers.deleteAndDispose(session); + this._computeSessionDiffs(session, turnId); + }, AgentSideEffects._DIFF_DEBOUNCE_MS)); + } + + /** + * Cancels any pending debounced diff computation for a session. + * Called at turn end before the final (non-debounced) computation. + */ + private _cancelDebouncedDiffComputation(session: ProtocolURI): void { + this._debouncedDiffTimers.deleteAndDispose(session); + } + /** * Asynchronously (re)computes aggregated diff statistics for a session * and dispatches {@link ActionType.SessionDiffsChanged} to update the diff --git a/src/vs/sessions/common/agentHostDiffs.ts b/src/vs/sessions/common/agentHostDiffs.ts index aa1b1f4642469..2194a1f5e57ad 100644 --- a/src/vs/sessions/common/agentHostDiffs.ts +++ b/src/vs/sessions/common/agentHostDiffs.ts @@ -4,6 +4,25 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../base/common/uri.js'; +import { SessionStatus as ProtocolSessionStatus } from '../../platform/agentHost/common/state/protocol/state.js'; +import { SessionStatus } from '../services/sessions/common/session.js'; + +/** + * Maps the protocol-layer session status bitset to the UI-layer + * {@link SessionStatus} enum used by session adapters. + */ +export function mapProtocolStatus(protocol: ProtocolSessionStatus): SessionStatus { + if ((protocol & ProtocolSessionStatus.InputNeeded) === ProtocolSessionStatus.InputNeeded) { + return SessionStatus.NeedsInput; + } + if (protocol & ProtocolSessionStatus.InProgress) { + return SessionStatus.InProgress; + } + if (protocol & ProtocolSessionStatus.Error) { + return SessionStatus.Error; + } + return SessionStatus.Completed; +} export interface IFileChange { readonly modifiedUri: URI; diff --git a/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts index 4c40f4342f08a..da3d57639801c 100644 --- a/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts @@ -16,7 +16,7 @@ import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { AgentSession, IAgentHostService, type IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js'; -import type { IRootState, ISessionFileDiff } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import type { IRootState, ISessionFileDiff, ISessionSummary as IProtocolSessionSummary } from '../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; import type { IResolveSessionConfigResult, ISessionConfigValueItem } from '../../../../platform/agentHost/common/state/protocol/commands.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; @@ -27,7 +27,8 @@ import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/c import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; import { agentHostSessionWorkspaceKey, buildAgentHostSessionWorkspace } from '../../../common/agentHostSessionWorkspace.js'; import { isSessionConfigComplete } from '../../../common/sessionConfig.js'; -import { diffsToChanges, diffsEqual } from '../../../common/agentHostDiffs.js'; +import { diffsToChanges, diffsEqual, mapProtocolStatus } from '../../../common/agentHostDiffs.js'; +import { NotificationType } from '../../../../platform/agentHost/common/state/protocol/notifications.js'; import { ISendRequestOptions, ISessionChangeEvent } from '../../../services/sessions/common/sessionsProvider.js'; import { IAgentHostSessionsProvider } from '../../../common/agentHostSessionsProvider.js'; import { IChat, ISession, ISessionWorkspace, ISessionWorkspaceBrowseAction, SessionStatus, type IGitHubInfo, ISessionType } from '../../../services/sessions/common/session.js'; @@ -83,7 +84,7 @@ class LocalSessionAdapter implements ISession { readonly workspace: ISettableObservable; readonly title: ISettableObservable; readonly updatedAt: ISettableObservable; - readonly status = observableValue('status', SessionStatus.Completed); + readonly status: ISettableObservable; readonly changes = observableValue('changes', []); readonly modelId: ISettableObservable; readonly mode = observableValue<{ readonly id: string; readonly kind: string } | undefined>('mode', undefined); @@ -114,6 +115,7 @@ class LocalSessionAdapter implements ISession { this.createdAt = new Date(metadata.startTime); this.title = observableValue('title', metadata.summary || `Session ${rawId.substring(0, 8)}`); this.updatedAt = observableValue('updatedAt', new Date(metadata.modifiedTime)); + this.status = observableValue('status', metadata.status !== undefined ? mapProtocolStatus(metadata.status) : SessionStatus.Completed); this.modelId = observableValue('modelId', metadata.model ? `${logicalSessionType}:${metadata.model}` : undefined); this.lastTurnEnd = observableValue('lastTurnEnd', metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined); this.description = observableValue('description', new MarkdownString().appendText(localize('localAgentHostDescription', "Local"))); @@ -155,6 +157,14 @@ class LocalSessionAdapter implements ISession { didChange = true; } + if (metadata.status !== undefined) { + const uiStatus = mapProtocolStatus(metadata.status); + if (uiStatus !== this.status.get()) { + this.status.set(uiStatus, undefined); + didChange = true; + } + } + const modifiedTime = metadata.modifiedTime; if (this.updatedAt.get().getTime() !== modifiedTime) { this.updatedAt.set(new Date(modifiedTime), undefined); @@ -285,10 +295,12 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent // Listen for notifications from the agent host to update the session list this._register(this._agentHostService.onDidNotification(n => { - if (n.type === 'notify/sessionAdded') { + if (n.type === NotificationType.SessionAdded) { this._handleSessionAdded(n.summary); - } else if (n.type === 'notify/sessionRemoved') { + } else if (n.type === NotificationType.SessionRemoved) { this._handleSessionRemoved(n.session); + } else if (n.type === NotificationType.SessionSummaryChanged) { + this._handleSessionSummaryChanged(n.session, n.changes); } })); @@ -952,6 +964,38 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent } } + private _handleSessionSummaryChanged(session: string, changes: Partial): void { + const rawId = AgentSession.id(session); + const cached = this._sessionCache.get(rawId); + if (!cached) { + return; + } + + let didChange = false; + + if (changes.status !== undefined) { + const uiStatus = mapProtocolStatus(changes.status); + if (uiStatus !== cached.status.get()) { + cached.status.set(uiStatus, undefined); + didChange = true; + } + } + + if (changes.title !== undefined && changes.title !== cached.title.get()) { + cached.title.set(changes.title, undefined); + didChange = true; + } + + if (changes.diffs !== undefined && !diffsEqual(cached.changes.get(), changes.diffs)) { + cached.changes.set(diffsToChanges(changes.diffs), undefined); + didChange = true; + } + + if (didChange) { + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); + } + } + private _handleConfigChanged(session: string, config: Record): void { const rawId = AgentSession.id(session); const cached = this._sessionCache.get(rawId); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 2983bca3276b9..b12c8019c1958 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -21,6 +21,7 @@ import { AGENT_HOST_SCHEME, agentHostAuthority, toAgentHostUri } from '../../../ import { AgentSession, type IAgentConnection, type IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js'; import { RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; +import { NotificationType } from '../../../../platform/agentHost/common/state/protocol/notifications.js'; import type { IResolveSessionConfigResult, ISessionConfigValueItem } from '../../../../platform/agentHost/common/state/protocol/commands.js'; import type { IRootState, ISessionFileDiff, ISessionSummary } from '../../../../platform/agentHost/common/state/protocol/state.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; @@ -32,7 +33,7 @@ import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/c import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; import { agentHostSessionWorkspaceKey, buildAgentHostSessionWorkspace } from '../../../common/agentHostSessionWorkspace.js'; import { isSessionConfigComplete } from '../../../common/sessionConfig.js'; -import { diffsToChanges, diffsEqual } from '../../../common/agentHostDiffs.js'; +import { diffsToChanges, diffsEqual, mapProtocolStatus } from '../../../common/agentHostDiffs.js'; import { ISessionChangeEvent, ISendRequestOptions } from '../../../services/sessions/common/sessionsProvider.js'; import { IAgentHostSessionsProvider } from '../../../common/agentHostSessionsProvider.js'; import { ISession, IChat, IGitHubInfo, ISessionWorkspace, ISessionWorkspaceBrowseAction, SessionStatus, ISessionType, COPILOT_CLI_SESSION_TYPE } from '../../../services/sessions/common/session.js'; @@ -166,7 +167,7 @@ class RemoteSessionAdapter implements IChatData { readonly workspace: ISettableObservable; readonly title: ISettableObservable; readonly updatedAt: ISettableObservable; - readonly status = observableValue('status', SessionStatus.Completed); + readonly status: ISettableObservable; readonly changes = observableValue('changes', []); readonly modelId: ISettableObservable; readonly mode = observableValue<{ readonly id: string; readonly kind: string } | undefined>('mode', undefined); @@ -197,6 +198,7 @@ class RemoteSessionAdapter implements IChatData { this.createdAt = new Date(metadata.startTime); this.title = observableValue('title', metadata.summary || `Session ${rawId.substring(0, 8)}`); this.updatedAt = observableValue('updatedAt', new Date(metadata.modifiedTime)); + this.status = observableValue('status', metadata.status !== undefined ? mapProtocolStatus(metadata.status) : SessionStatus.Completed); this.modelId = observableValue('modelId', metadata.model ? `${resourceScheme}:${metadata.model}` : undefined); this.lastTurnEnd = observableValue('lastTurnEnd', metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined); this.description = observableValue('description', new MarkdownString().appendText(this._providerLabel)); @@ -217,6 +219,12 @@ class RemoteSessionAdapter implements IChatData { this.title.set(metadata.summary || this.title.get(), undefined); this.updatedAt.set(new Date(metadata.modifiedTime), undefined); this.lastTurnEnd.set(metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined, undefined); + if (metadata.status !== undefined) { + const uiStatus = mapProtocolStatus(metadata.status); + if (uiStatus !== this.status.get()) { + this.status.set(uiStatus, undefined); + } + } if (metadata.isRead !== undefined) { this.isRead.set(metadata.isRead, undefined); } @@ -402,10 +410,12 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements IAgen })); this._connectionListeners.add(connection.onDidNotification(n => { - if (n.type === 'notify/sessionAdded') { + if (n.type === NotificationType.SessionAdded) { this._handleSessionAdded(n.summary); - } else if (n.type === 'notify/sessionRemoved') { + } else if (n.type === NotificationType.SessionRemoved) { this._handleSessionRemoved(n.session); + } else if (n.type === NotificationType.SessionSummaryChanged) { + this._handleSessionSummaryChanged(n.session, n.changes); } })); @@ -1164,6 +1174,41 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements IAgen } } + private _handleSessionSummaryChanged(session: string, changes: Partial): void { + const rawId = AgentSession.id(session); + const cached = this._sessionCache.get(rawId); + if (!cached) { + return; + } + + let didChange = false; + + if (changes.status !== undefined) { + const uiStatus = mapProtocolStatus(changes.status); + if (uiStatus !== cached.status.get()) { + cached.status.set(uiStatus, undefined); + didChange = true; + } + } + + if (changes.title !== undefined && changes.title !== cached.title.get()) { + cached.title.set(changes.title, undefined); + didChange = true; + } + + if (changes.diffs !== undefined) { + const mapUri = toLocalDiffUri(this._connectionAuthority); + if (!diffsEqual(cached.changes.get(), changes.diffs, mapUri)) { + cached.changes.set(diffsToChanges(changes.diffs, mapUri), undefined); + didChange = true; + } + } + + if (didChange) { + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] }); + } + } + private _handleConfigChanged(session: string, config: Record): void { const rawId = AgentSession.id(session); const cached = this._sessionCache.get(rawId); From 2c6e38b236fb5271c610426765f622bb766c7d4a Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 16 Apr 2026 14:14:46 -0400 Subject: [PATCH 18/56] agentHost: make file paths in tool invocation messages nicer (#310813) Does proper markdown linking --- .../platform/agentHost/common/agentService.ts | 2 +- .../node/copilot/copilotAgentSession.ts | 124 +------------ .../node/copilot/copilotToolDisplay.ts | 166 +++++++++++++++++- 3 files changed, 164 insertions(+), 128 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 55910dbaa02d5..1050188a6676b 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -226,7 +226,7 @@ export interface IAgentToolStartEvent extends IAgentProgressEventBase { /** Human-readable display name for this tool. */ readonly displayName: string; /** Message describing the tool invocation in progress (e.g., "Running `echo hello`"). */ - readonly invocationMessage: string; + readonly invocationMessage: StringOrMarkdown; /** A representative input string for display in the UI (e.g., the shell command). */ readonly toolInput?: string; /** Hint for the renderer about how to display this tool (e.g., 'terminal' for shell commands, 'subagent' for subagent-spawning tools). */ diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 15843d1005def..a4e0ba6bcf816 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { PermissionRequest, PermissionRequestResult, Tool, ToolResultObject } from '@github/copilot-sdk'; +import type { PermissionRequestResult, Tool, ToolResultObject } from '@github/copilot-sdk'; import { DeferredPromise } from '../../../../base/common/async.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -11,12 +11,11 @@ import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; -import { localize } from '../../../../nls.js'; -import { IAgentAttachment, IAgentMessageEvent, IAgentProgressEvent, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolReadyEvent, IAgentToolStartEvent } from '../../common/agentService.js'; +import { IAgentAttachment, IAgentMessageEvent, IAgentProgressEvent, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType, type ISessionInputAnswer, type ISessionInputRequest, type IPendingMessage, type IToolCallResult, type IToolResultContent } from '../../common/state/sessionState.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; -import { getEditFilePath, getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool } from './copilotToolDisplay.js'; +import { getEditFilePath, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; import { FileEditTracker } from './fileEditTracker.js'; import { mapSessionEvents } from './mapSessionEvents.js'; import type { ShellManager } from './copilotShellTools.js'; @@ -66,119 +65,6 @@ interface IUserInputResponse { wasFreeform: boolean; } -/** - * Extends the SDK's {@link PermissionRequest} with the known extra properties - * that arrive on the index-signature. The SDK defines these as `[key: string]: unknown` - * so this interface adds proper types for the fields we actually use. - */ -interface ITypedPermissionRequest extends PermissionRequest { - /** File path โ€” set for `read` permission requests. */ - path?: string; - /** File path โ€” set for `write` permission requests. */ - fileName?: string; - /** Full shell command text โ€” set for `shell` permission requests. */ - fullCommandText?: string; - /** Human-readable intention describing the operation. */ - intention?: string; - /** MCP server name โ€” set for `mcp` permission requests. */ - serverName?: string; - /** Tool name โ€” set for `mcp` and `custom-tool` permission requests. */ - toolName?: string; - /** Tool arguments โ€” set for `custom-tool` permission requests. */ - args?: Record; -} - -function tryStringify(value: unknown): string | undefined { - try { - return JSON.stringify(value); - } catch { - return undefined; - } -} - -/** - * Derives display fields from a permission request for the tool confirmation UI. - */ -/** Safely extract a string value from an SDK field that may be `unknown` at runtime. */ -function str(value: unknown): string | undefined { - return typeof value === 'string' ? value : undefined; -} - -function getPermissionDisplay(request: ITypedPermissionRequest): { - confirmationTitle: string; - invocationMessage: string; - toolInput?: string; - /** Normalized permission kind for auto-approval routing. */ - permissionKind: IAgentToolReadyEvent['permissionKind']; -} { - const path = str(request.path) ?? str(request.fileName); - const fullCommandText = str(request.fullCommandText); - const intention = str(request.intention); - const serverName = str(request.serverName); - const toolName = str(request.toolName); - - switch (request.kind) { - case 'shell': - return { - confirmationTitle: localize('copilot.permission.shell.title', "Run in terminal"), - invocationMessage: intention ?? localize('copilot.permission.shell.message', "Run command"), - toolInput: fullCommandText, - permissionKind: 'shell', - }; - case 'custom-tool': { - // Custom tool overrides (e.g. our shell tool). Extract the actual - // tool args from the SDK's wrapper envelope. - const args = typeof request.args === 'object' && request.args !== null ? request.args as Record : undefined; - const command = typeof args?.command === 'string' ? args.command : undefined; - const sdkToolName = str(request.toolName); - if (command && sdkToolName && isShellTool(sdkToolName)) { - return { - confirmationTitle: localize('copilot.permission.shell.title', "Run in terminal"), - invocationMessage: localize('copilot.permission.shell.message', "Run command"), - toolInput: command, - permissionKind: 'shell', - }; - } - return { - confirmationTitle: toolName ?? localize('copilot.permission.default.title', "Permission request"), - invocationMessage: localize('copilot.permission.default.message', "Permission request"), - toolInput: args ? tryStringify(args) : tryStringify(request), - permissionKind: request.kind, - }; - } - case 'write': - return { - confirmationTitle: localize('copilot.permission.write.title', "Write file"), - invocationMessage: path ? localize('copilot.permission.write.message', "Edit {0}", path) : localize('copilot.permission.write.messageGeneric', "Edit file"), - toolInput: tryStringify(path ? { path } : request) ?? undefined, - permissionKind: 'write', - }; - case 'mcp': { - const title = toolName ?? localize('copilot.permission.mcp.defaultTool', "MCP Tool"); - return { - confirmationTitle: serverName ? `${serverName}: ${title}` : title, - invocationMessage: serverName ? `${serverName}: ${title}` : title, - toolInput: tryStringify({ serverName, toolName }) ?? undefined, - permissionKind: 'mcp', - }; - } - case 'read': - return { - confirmationTitle: localize('copilot.permission.read.title', "Read file"), - invocationMessage: intention ?? localize('copilot.permission.read.message', "Read file"), - toolInput: tryStringify(path ? { path, intention } : request) ?? undefined, - permissionKind: 'read', - }; - default: - return { - confirmationTitle: localize('copilot.permission.default.title', "Permission request"), - invocationMessage: localize('copilot.permission.default.message', "Permission request"), - toolInput: tryStringify(request) ?? undefined, - permissionKind: request.kind, - }; - } -} - /** * Options for constructing a {@link CopilotAgentSession}. */ @@ -481,7 +367,7 @@ export class CopilotAgentSession extends Disposable { this._pendingPermissions.set(toolCallId, deferred); // Derive display information from the permission request kind - const { confirmationTitle, invocationMessage, toolInput, permissionKind } = getPermissionDisplay(request); + const { confirmationTitle, invocationMessage, toolInput, permissionKind, permissionPath } = getPermissionDisplay(request); // Fire a tool_ready event to transition the tool to PendingConfirmation this._onDidSessionProgress.fire({ @@ -492,7 +378,7 @@ export class CopilotAgentSession extends Disposable { toolInput, confirmationTitle, permissionKind, - permissionPath: str(request.path) ?? str(request.fileName), + permissionPath, }); const approved = await deferred.p; diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts index 8cd317a371be0..6ca04a9daf839 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -3,8 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import type { PermissionRequest } from '@github/copilot-sdk'; import { hasKey } from '../../../../base/common/types.js'; +import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; +import type { IAgentToolReadyEvent } from '../../common/agentService.js'; +import { StringOrMarkdown } from '../../common/state/protocol/state.js'; // ============================================================================= // Copilot CLI built-in tool interfaces @@ -151,6 +155,22 @@ function truncate(text: string, maxLength: number): string { return text.length > maxLength ? text.substring(0, maxLength - 3) + '...' : text; } +/** + * Formats a file path as a markdown link `[](file-uri)` so it renders + * as a clickable file widget in the chat UI. + */ +function formatPathAsMarkdownLink(path: string): string { + return `[](${URI.file(path).toString()})`; +} + +/** + * Wraps a localized message containing a markdown file link into a + * `StringOrMarkdown` object so the renderer treats it as markdown. + */ +function md(value: string): StringOrMarkdown { + return { markdown: value }; +} + export function getToolDisplayName(toolName: string): string { switch (toolName) { case CopilotToolName.Bash: return localize('toolName.bash', "Bash"); @@ -176,7 +196,7 @@ export function getToolDisplayName(toolName: string): string { } } -export function getInvocationMessage(toolName: string, displayName: string, parameters: Record | undefined): string { +export function getInvocationMessage(toolName: string, displayName: string, parameters: Record | undefined): StringOrMarkdown { if (SHELL_TOOL_NAMES.has(toolName)) { const args = parameters as ICopilotShellToolArgs | undefined; if (args?.command) { @@ -190,21 +210,21 @@ export function getInvocationMessage(toolName: string, displayName: string, para case CopilotToolName.View: { const args = parameters as ICopilotFileToolArgs | undefined; if (args?.path) { - return localize('toolInvoke.viewFile', "Reading {0}", args.path); + return md(localize('toolInvoke.viewFile', "Reading {0}", formatPathAsMarkdownLink(args.path))); } return localize('toolInvoke.view', "Reading file"); } case CopilotToolName.Edit: { const args = parameters as ICopilotFileToolArgs | undefined; if (args?.path) { - return localize('toolInvoke.editFile', "Editing {0}", args.path); + return md(localize('toolInvoke.editFile', "Editing {0}", formatPathAsMarkdownLink(args.path))); } return localize('toolInvoke.edit', "Editing file"); } case CopilotToolName.Create: { const args = parameters as ICopilotFileToolArgs | undefined; if (args?.path) { - return localize('toolInvoke.createFile', "Creating {0}", args.path); + return md(localize('toolInvoke.createFile', "Creating {0}", formatPathAsMarkdownLink(args.path))); } return localize('toolInvoke.create', "Creating file"); } @@ -227,7 +247,7 @@ export function getInvocationMessage(toolName: string, displayName: string, para } } -export function getPastTenseMessage(toolName: string, displayName: string, parameters: Record | undefined, success: boolean): string { +export function getPastTenseMessage(toolName: string, displayName: string, parameters: Record | undefined, success: boolean): StringOrMarkdown { if (!success) { return localize('toolComplete.failed', "\"{0}\" failed", displayName); } @@ -245,21 +265,21 @@ export function getPastTenseMessage(toolName: string, displayName: string, param case CopilotToolName.View: { const args = parameters as ICopilotFileToolArgs | undefined; if (args?.path) { - return localize('toolComplete.viewFile', "Read {0}", args.path); + return md(localize('toolComplete.viewFile', "Read {0}", formatPathAsMarkdownLink(args.path))); } return localize('toolComplete.view', "Read file"); } case CopilotToolName.Edit: { const args = parameters as ICopilotFileToolArgs | undefined; if (args?.path) { - return localize('toolComplete.editFile', "Edited {0}", args.path); + return md(localize('toolComplete.editFile', "Edited {0}", formatPathAsMarkdownLink(args.path))); } return localize('toolComplete.edit', "Edited file"); } case CopilotToolName.Create: { const args = parameters as ICopilotFileToolArgs | undefined; if (args?.path) { - return localize('toolComplete.createFile', "Created {0}", args.path); + return md(localize('toolComplete.createFile', "Created {0}", formatPathAsMarkdownLink(args.path))); } return localize('toolComplete.create', "Created file"); } @@ -343,3 +363,133 @@ export function getShellLanguage(toolName: string): string { default: return 'shellscript'; } } + +// ============================================================================= +// Permission display +// +// Derives display fields from SDK permission requests for the tool +// confirmation UI. Colocated with the tool-start display helpers above so +// that formatting utilities (formatPathAsMarkdownLink, md, etc.) are shared. +// ============================================================================= + +export function tryStringify(value: unknown): string | undefined { + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} + +/** + * Extends the SDK's {@link PermissionRequest} with the known extra properties + * that arrive on the index-signature. The SDK defines these as `[key: string]: unknown` + * so this interface adds proper types for the fields we actually use. + */ +export interface ITypedPermissionRequest extends PermissionRequest { + /** File path โ€” set for `read` permission requests. */ + path?: string; + /** File path โ€” set for `write` permission requests. */ + fileName?: string; + /** Full shell command text โ€” set for `shell` permission requests. */ + fullCommandText?: string; + /** Human-readable intention describing the operation. */ + intention?: string; + /** MCP server name โ€” set for `mcp` permission requests. */ + serverName?: string; + /** Tool name โ€” set for `mcp` and `custom-tool` permission requests. */ + toolName?: string; + /** Tool arguments โ€” set for `custom-tool` permission requests. */ + args?: Record; +} + +/** Safely extract a string value from an SDK field that may be `unknown` at runtime. */ +function str(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +/** + * Derives display fields from a permission request for the tool confirmation UI. + */ +export function getPermissionDisplay(request: ITypedPermissionRequest): { + confirmationTitle: string; + invocationMessage: StringOrMarkdown; + toolInput?: string; + /** Normalized permission kind for auto-approval routing. */ + permissionKind: IAgentToolReadyEvent['permissionKind']; + /** File path extracted from the request. */ + permissionPath?: string; +} { + const path = str(request.path) ?? str(request.fileName); + const fullCommandText = str(request.fullCommandText); + const intention = str(request.intention); + const serverName = str(request.serverName); + const toolName = str(request.toolName); + + switch (request.kind) { + case 'shell': + return { + confirmationTitle: localize('copilot.permission.shell.title', "Run in terminal"), + invocationMessage: intention ?? getInvocationMessage(CopilotToolName.Bash, getToolDisplayName(CopilotToolName.Bash), fullCommandText ? { command: fullCommandText } : undefined), + toolInput: fullCommandText, + permissionKind: 'shell', + permissionPath: path, + }; + case 'custom-tool': { + // Custom tool overrides (e.g. our shell tool). Extract the actual + // tool args from the SDK's wrapper envelope. + const args = typeof request.args === 'object' && request.args !== null ? request.args as Record : undefined; + const command = typeof args?.command === 'string' ? args.command : undefined; + const sdkToolName = str(request.toolName); + if (command && sdkToolName && isShellTool(sdkToolName)) { + return { + confirmationTitle: localize('copilot.permission.shell.title', "Run in terminal"), + invocationMessage: getInvocationMessage(sdkToolName, getToolDisplayName(sdkToolName), { command }), + toolInput: command, + permissionKind: 'shell', + permissionPath: path, + }; + } + return { + confirmationTitle: toolName ?? localize('copilot.permission.default.title', "Permission request"), + invocationMessage: localize('copilot.permission.default.message', "Permission request"), + toolInput: args ? tryStringify(args) : tryStringify(request), + permissionKind: request.kind, + permissionPath: path, + }; + } + case 'write': + return { + confirmationTitle: localize('copilot.permission.write.title', "Write file"), + invocationMessage: getInvocationMessage(CopilotToolName.Edit, getToolDisplayName(CopilotToolName.Edit), path ? { path } : undefined), + toolInput: tryStringify(path ? { path } : request) ?? undefined, + permissionKind: 'write', + permissionPath: path, + }; + case 'mcp': { + const title = toolName ?? localize('copilot.permission.mcp.defaultTool', "MCP Tool"); + return { + confirmationTitle: serverName ? `${serverName}: ${title}` : title, + invocationMessage: serverName ? `${serverName}: ${title}` : title, + toolInput: tryStringify({ serverName, toolName }) ?? undefined, + permissionKind: 'mcp', + permissionPath: path, + }; + } + case 'read': + return { + confirmationTitle: localize('copilot.permission.read.title', "Read file"), + invocationMessage: intention ?? getInvocationMessage(CopilotToolName.View, getToolDisplayName(CopilotToolName.View), path ? { path } : undefined), + toolInput: tryStringify(path ? { path, intention } : request) ?? undefined, + permissionKind: 'read', + permissionPath: path, + }; + default: + return { + confirmationTitle: localize('copilot.permission.default.title', "Permission request"), + invocationMessage: localize('copilot.permission.default.message', "Permission request"), + toolInput: tryStringify(request) ?? undefined, + permissionKind: request.kind, + permissionPath: path, + }; + } +} From 8ce3152c1d4921dbf19ade1d21c983166fdc0633 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Thu, 16 Apr 2026 11:26:27 -0700 Subject: [PATCH 19/56] Log agent host catch failures (#310815) (Written by Copilot) --- src/vs/platform/agentHost/node/agentSideEffects.ts | 6 ++++-- .../platform/agentHost/node/copilot/copilotAgent.ts | 13 ++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index ab867c5393592..99316915b74ec 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -111,7 +111,8 @@ export class AgentSideEffects extends Disposable { maxContextWindow: m.maxContextWindow, supportsVision: m.supportsVision, policyState: m.policyState, })); - } catch { + } catch (err) { + this._logService.error(err, `[AgentSideEffects] Failed to list models for agent '${a.id}'`); models = []; } const protectedResources = a.getProtectedResources(); @@ -895,7 +896,8 @@ export class AgentSideEffects extends Disposable { let ref: ReturnType; try { ref = this._options.sessionDataService.openDatabase(URI.parse(session)); - } catch { + } catch (err) { + this._logService.warn(`[AgentSideEffects] Failed to open session database for diff computation: ${session}`, err); return; } try { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 4652cd895e678..e570a5d2029a7 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -467,7 +467,10 @@ export class CopilotAgent extends Disposable implements IAgent { async getSessionMessages(session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent | IAgentSubagentStartedEvent)[]> { const sessionId = AgentSession.id(session); - const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId).catch(() => undefined); + const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId).catch(err => { + this._logService.warn(`[Copilot:${sessionId}] Failed to resume session for message lookup`, err); + return undefined; + }); if (!entry) { return []; } @@ -846,7 +849,9 @@ export class CopilotAgent extends Disposable implements IAgent { } override dispose(): void { - this.shutdown().catch(() => { /* best-effort */ }).finally(() => super.dispose()); + this.shutdown().catch(err => { + this._logService.warn('[Copilot] Shutdown failed during dispose', err); + }).finally(() => super.dispose()); } } @@ -874,7 +879,9 @@ class PluginController { public sync(clientId: string, customizations: ICustomizationRef[], progress?: (results: ISyncedCustomization[]) => void) { const prev = this._lastSynced; - const promise = this._lastSynced = prev.catch(() => []).then(async () => { + const promise = this._lastSynced = prev.catch(err => { + this._logService.warn('[Copilot:PluginController] Previous customization sync failed', err); + }).then(async () => { const result = await this._pluginManager.syncCustomizations(clientId, customizations, status => { progress?.(status.map(c => ({ customization: c }))); }); From 98fff9f761fa9dab7bf67cbb8e52778db190b549 Mon Sep 17 00:00:00 2001 From: Aashna Garg Date: Thu, 16 Apr 2026 11:36:21 -0700 Subject: [PATCH 20/56] Address review: single partition pass, fix failing test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use single for-loop to partition available_models into routableModels and droppedModels (bhavyaus review) - Fix test: when ALL available_models are unknown, _selectDefaultModel throws since none match knownEndpoints โ€” test now expects throw instead of gpt-4o --- .../src/platform/endpoint/node/automodeService.ts | 10 +++++++--- .../endpoint/node/test/automodeService.spec.ts | 9 ++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/extensions/copilot/src/platform/endpoint/node/automodeService.ts b/extensions/copilot/src/platform/endpoint/node/automodeService.ts index d55fbab8570c6..86238a78a8af3 100644 --- a/extensions/copilot/src/platform/endpoint/node/automodeService.ts +++ b/extensions/copilot/src/platform/endpoint/node/automodeService.ts @@ -283,13 +283,17 @@ export class AutomodeService extends Disposable implements IAutomodeService { // Models API returns it). Sending unresolvable models to the router // causes it to recommend models the client must silently discard. const knownModelIds = new Set(knownEndpoints.map(e => e.model)); - const routableModels = token.available_models.filter(m => knownModelIds.has(m)); + const routableModels: string[] = []; + const droppedModels: string[] = []; + for (const m of token.available_models) { + (knownModelIds.has(m) ? routableModels : droppedModels).push(m); + } if (!routableModels.length) { this._logService.warn(`[AutomodeService] No available_models matched knownEndpoints. available_models=[${token.available_models.join(', ')}], knownEndpoints=[${knownEndpoints.map(e => e.model).join(', ')}]`); return { lastRoutedPrompt: prompt, fallbackReason: 'noMatchingEndpoint' }; } - if (routableModels.length < token.available_models.length) { - this._logService.info(`[AutomodeService] Filtered ${token.available_models.length - routableModels.length} unresolvable model(s) before routing: [${token.available_models.filter(m => !knownModelIds.has(m)).join(', ')}]`); + if (droppedModels.length) { + this._logService.info(`[AutomodeService] Filtered ${droppedModels.length} unresolvable model(s) before routing: [${droppedModels.join(', ')}]`); } const result = await this._routerDecisionFetcher.getRouterDecision(prompt, token.session_token, routableModels, undefined, contextSignals, conversationId, chatRequest?.id, routingMethod, hasImage(chatRequest)); diff --git a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts index fa867cf10f726..8fbdd9b56c578 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts @@ -1118,7 +1118,7 @@ describe('AutomodeService', () => { expect(result.model).toBe('gpt-4.1'); }); - it('should fall back to default when all available_models are unknown to knownEndpoints', async () => { + it('should throw when all available_models are unknown to knownEndpoints', async () => { enableRouter(); const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI'); @@ -1142,8 +1142,11 @@ describe('AutomodeService', () => { sessionId: 'session-all-unknown' }; - const result = await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [gpt4oEndpoint]); - expect(result.model).toBe('gpt-4o'); + // When all available_models are unknown, the router is skipped (no routable models), + // and _selectDefaultModel also fails since none of the available_models match knownEndpoints + await expect( + automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [gpt4oEndpoint]) + ).rejects.toThrow('no available model found'); expect(mockLogService.warn).toHaveBeenCalledWith( expect.stringContaining('No available_models matched knownEndpoints') ); From e8eefeb29c85ebfb66812c7c07eeeefe70e7a230 Mon Sep 17 00:00:00 2001 From: Peng Lyu Date: Thu, 16 Apr 2026 11:54:04 -0700 Subject: [PATCH 21/56] mobile friendly agent sessions view (#310552) * mobile friendly agent sessions view * no need for auto hide. * :lipstick: --- src/vs/sessions/browser/workbench.ts | 44 +++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index 0d3fd0e4a753f..cea02a1ec17fb 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -12,7 +12,7 @@ import { DeferredPromise, RunOnceScheduler } from '../../base/common/async.js'; import { isFullscreen, onDidChangeFullscreen, isChrome, isFirefox, isSafari } from '../../base/browser/browser.js'; import { mark } from '../../base/common/performance.js'; import { onUnexpectedError, setUnexpectedErrorHandler } from '../../base/common/errors.js'; -import { isWindows, isLinux, isWeb, isNative, isMacintosh } from '../../base/common/platform.js'; +import { isWindows, isLinux, isWeb, isNative, isMacintosh, isMobile } from '../../base/common/platform.js'; import { Parts, Position, PanelAlignment, IWorkbenchLayoutService, SINGLE_WINDOW_PARTS, MULTI_WINDOW_PARTS, IPartVisibilityChangeEvent, positionToString } from '../../workbench/services/layout/browser/layoutService.js'; import { ILayoutOffsetInfo } from '../../platform/layout/browser/layoutService.js'; import { Part } from '../../workbench/browser/part.js'; @@ -694,6 +694,16 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { // Initialize layout state (must be done before createWorkbenchLayout) this._mainContainerDimension = getClientArea(this.parent, new Dimension(800, 600)); + + // Default to list-detail on mobile web only. Desktop behavior stays unchanged, + // regardless of how narrow the window is resized. + if (isWeb && isMobile) { + this.partVisibility.sidebar = false; + } + } + + private isMobileWebLayout(): boolean { + return isWeb && isMobile; } private areAllGroupsEmpty(): boolean { @@ -904,11 +914,41 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { // Layout the grid widget this.workbenchGrid.layout(this._mainContainerDimension.width, this._mainContainerDimension.height); + this.layoutMobileSidebar(); // Emit as event this.handleContainerDidLayout(this.mainContainer, this._mainContainerDimension); } + private layoutMobileSidebar(): void { + const sidebarContainer = this.getContainer(mainWindow, Parts.SIDEBAR_PART); + const sidebarPart = this.getPart(Parts.SIDEBAR_PART); + if (!sidebarContainer) { + return; + } + + if (!this.isMobileWebLayout() || !this.partVisibility.sidebar) { + sidebarContainer.style.position = ''; + sidebarContainer.style.top = ''; + sidebarContainer.style.left = ''; + sidebarContainer.style.width = ''; + sidebarContainer.style.height = ''; + sidebarContainer.style.zIndex = ''; + return; + } + + const titleBarHeight = this.workbenchGrid.getViewSize(this.titleBarPartView).height; + const mobileWidth = this._mainContainerDimension.width; + const mobileHeight = Math.max(0, this._mainContainerDimension.height - titleBarHeight); + sidebarContainer.style.position = 'fixed'; + sidebarContainer.style.top = `${titleBarHeight}px`; + sidebarContainer.style.left = '0'; + sidebarContainer.style.width = `${mobileWidth}px`; + sidebarContainer.style.height = `${mobileHeight}px`; + sidebarContainer.style.zIndex = '30'; + sidebarPart.layout(mobileWidth, mobileHeight, titleBarHeight, 0); + } + private handleContainerDidLayout(container: HTMLElement, dimension: IDimension): void { this._onDidLayoutContainer.fire({ container, dimension }); if (container === this.mainContainer) { @@ -1103,6 +1143,8 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { this.paneCompositeService.openPaneComposite(viewletToOpen, ViewContainerLocation.Sidebar); } } + + this.layoutMobileSidebar(); } private setAuxiliaryBarHidden(hidden: boolean): void { From 347ce1ce39854ca987dbb385645fc8c63934c230 Mon Sep 17 00:00:00 2001 From: Robo Date: Fri, 17 Apr 2026 03:54:37 +0900 Subject: [PATCH 22/56] feat: support launching agents/vscode app without protocol handler (#310773) --- build/gulpfile.vscode.ts | 23 +++-- build/lib/embeddedType.ts | 1 + src/vs/base/common/product.ts | 2 + src/vs/code/node/cli.ts | 39 +++----- src/vs/platform/native/common/native.ts | 8 ++ .../electron-main/nativeHostMainService.ts | 10 ++ src/vs/platform/native/node/siblingApp.ts | 95 +++++++++++++++++++ .../openInVSCode.contribution.ts | 67 +++++++++++++ src/vs/sessions/sessions.desktop.main.ts | 1 + .../agentSessions/agentSessionsActions.ts | 12 +-- .../electron-browser/workbenchTestServices.ts | 2 + 11 files changed, 217 insertions(+), 43 deletions(-) create mode 100644 src/vs/platform/native/node/siblingApp.ts create mode 100644 src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index 1e216b7f5ad92..31188a4ce9ebc 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -372,6 +372,10 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d const name = product.nameShort; const packageJsonUpdates: Record = { name, version }; + const isInsiderOrExploration = quality === 'insider' || quality === 'exploration'; + const embedded = isInsiderOrExploration + ? (product as typeof product & { embedded?: EmbeddedProductInfo }).embedded + : undefined; if (platform === 'linux') { packageJsonUpdates.desktopName = `${product.applicationName}.desktop`; @@ -387,18 +391,23 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d let productJsonContents: string; const productJsonStream = gulp.src(['product.json'], { base: '.' }) - .pipe(jsonEditor({ commit, date: readISODate(out), checksums, version })) + .pipe(jsonEditor((json: Record) => { + json.commit = commit; + json.date = readISODate(out); + json.checksums = checksums; + json.version = version; + if (embedded) { + json['darwinSiblingBundleIdentifier'] = embedded.darwinBundleIdentifier; + const embeddedObj = json['embedded'] as EmbeddedProductInfo; + embeddedObj['darwinSiblingBundleIdentifier'] = json['darwinBundleIdentifier'] as string; + } + return json; + })) .pipe(es.through(function (file) { productJsonContents = file.contents.toString(); this.emit('data', file); })); - - const isInsiderOrExploration = quality === 'insider' || quality === 'exploration'; - const embedded = isInsiderOrExploration - ? (product as typeof product & { embedded?: EmbeddedProductInfo }).embedded - : undefined; - const packageSubJsonStream = embedded ? gulp.src(['package.json'], { base: '.' }) .pipe(jsonEditor((json: Record) => { diff --git a/build/lib/embeddedType.ts b/build/lib/embeddedType.ts index acfe9dfdddd5f..b0b3ad7d833ec 100644 --- a/build/lib/embeddedType.ts +++ b/build/lib/embeddedType.ts @@ -9,6 +9,7 @@ export type EmbeddedProductInfo = { applicationName: string; dataFolderName: string; darwinBundleIdentifier: string; + darwinSiblingBundleIdentifier?: string; urlProtocol: string; win32AppUserModelId: string; win32MutexName: string; diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index 81161d894d55a..5bfc2c65617dc 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -222,6 +222,7 @@ export interface IProductConfiguration { readonly 'editSessions.store'?: Omit; readonly darwinUniversalAssetId?: string; readonly darwinBundleIdentifier?: string; + readonly darwinSiblingBundleIdentifier?: string; readonly profileTemplatesUrl?: string; readonly commonlyUsedSettings?: string[]; @@ -282,6 +283,7 @@ export type IEmbeddedProductConfiguration = Pick { // Figure out the app to launch: with --agents we try to launch the embedded app on Windows let execToLaunch = process.execPath; - if (isWindows && args.agents && product.win32SiblingExeBasename) { - const siblingExe = join(dirname(process.execPath), `${product.win32SiblingExeBasename}.exe`); - try { - if (statSync(siblingExe).isFile()) { - execToLaunch = siblingExe; - argv = argv.filter(arg => arg !== '--agents'); - } - } catch (error) { - /* may not exist on disk */ + if (isWindows && args.agents) { + const siblingExe = resolveSiblingWindowsExePath(product); + if (siblingExe) { + execToLaunch = siblingExe; + argv = argv.filter(arg => arg !== '--agents'); } } @@ -516,24 +513,12 @@ export async function main(argv: string[]): Promise { const spawnArgs = ['-n', '-g']; // Figure out the app to launch: with --agents we try to launch the embedded app - let appToLaunch = process.execPath; - if (args.agents) { - // process.execPath is e.g. /Applications/Code.app/Contents/MacOS/Electron - // Embedded app is at /Applications/Code.app/Contents/Applications/.app - const contentsPath = dirname(dirname(process.execPath)); - const applicationsPath = join(contentsPath, 'Applications'); - try { - const files = await promises.readdir(applicationsPath); - const embeddedApp = files.find(file => file.endsWith('.app')); - if (embeddedApp) { - appToLaunch = join(applicationsPath, embeddedApp); - argv = argv.filter(arg => arg !== '--agents'); - } - } catch (error) { - /* may not exist on disk */ - } + if (args.agents && product.darwinSiblingBundleIdentifier) { + spawnArgs.push('-b', product.darwinSiblingBundleIdentifier); + argv = argv.filter(arg => arg !== '--agents'); + } else { + spawnArgs.push('-a', process.execPath); // -a opens the given application. } - spawnArgs.push('-a', appToLaunch); // -a opens the given application. if (args.verbose || args.status) { spawnArgs.push('--wait-apps'); // `open --wait-apps`: blocks until the launched app is closed (even if they were already running) diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index 95d4d3a45c4ce..4c5d318a6b0b4 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -131,6 +131,14 @@ export interface ICommonNativeHostService { openAgentsWindow(options?: { readonly forceNewWindow?: boolean }): Promise; + /** + * Launches the sibling application (host โ†” embedded). + * The launched process is detached with its own process group. + * + * @param args CLI arguments to pass to the sibling application. + */ + launchSiblingApp(args?: string[]): Promise; + isFullScreen(options?: INativeHostOptions): Promise; toggleFullScreen(options?: INativeHostOptions): Promise; diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index 8a4f25b4d7be9..0382c419a83a0 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -18,6 +18,7 @@ import { AddFirstParameterToFunctions } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { virtualMachineHint } from '../../../base/node/id.js'; import { Promises, SymlinkSupport } from '../../../base/node/pfs.js'; +import { launchSiblingApp } from '../node/siblingApp.js'; import { findFreePort, isPortFree } from '../../../base/node/ports.js'; import { localize } from '../../../nls.js'; import { ISerializableCommandAction } from '../../action/common/action.js'; @@ -313,6 +314,15 @@ export class NativeHostMainService extends Disposable implements INativeHostMain }); } + async launchSiblingApp(_windowId: number | undefined, args?: string[]): Promise { + const result = launchSiblingApp(this.productService, args, err => { + this.logService.error('[launchSiblingApp] Failed to spawn sibling app:', err.message); + }); + if (!result) { + this.logService.warn('[launchSiblingApp] Could not resolve sibling app on this platform'); + } + } + async isFullScreen(windowId: number | undefined, options?: INativeHostOptions): Promise { const window = this.windowById(options?.targetWindowId, windowId); return window?.isFullScreen ?? false; diff --git a/src/vs/platform/native/node/siblingApp.ts b/src/vs/platform/native/node/siblingApp.ts new file mode 100644 index 0000000000000..78228bf9f3e92 --- /dev/null +++ b/src/vs/platform/native/node/siblingApp.ts @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ChildProcess, spawn } from 'child_process'; +import { statSync } from 'fs'; +import { dirname, join } from '../../../base/common/path.js'; +import { isMacintosh, isWindows, INodeProcess } from '../../../base/common/platform.js'; +import { IProductConfiguration } from '../../../base/common/product.js'; + +export interface ISiblingAppLaunchResult { + readonly child: ChildProcess; +} + +/** + * Launches the sibling application (host โ†” embedded) using a detached + * child process with its own process group. + * + * @param product The product configuration of the **current** process. + * @param args CLI arguments to forward to the sibling app. + * @param onError Optional callback invoked when the spawned process emits an error. + * @returns The spawned detached child process, or `undefined` if the + * sibling could not be resolved on the current platform. + */ +export function launchSiblingApp(product: IProductConfiguration, args: string[] = [], onError?: (err: Error) => void): ISiblingAppLaunchResult | undefined { + if (isMacintosh) { + const bundleId = resolveSiblingDarwinBundleIdentifier(product); + if (!bundleId) { + return undefined; + } + const spawnArgs = ['-n', '-g', '-b', bundleId]; + if (args.length > 0) { + spawnArgs.push('--args', ...args); + } + const child = spawn('open', spawnArgs, { + detached: true, + stdio: 'ignore', + }); + child.on('error', err => onError?.(err)); + child.unref(); + return { child }; + } + + if (isWindows) { + const exePath = resolveSiblingWindowsExePath(product); + if (!exePath) { + return undefined; + } + const child = spawn(exePath, args, { + detached: true, + stdio: 'ignore', + }); + child.on('error', err => onError?.(err)); + child.unref(); + return { child }; + } + + return undefined; +} + +/** + * Returns the macOS bundle identifier for the sibling app. + */ +function resolveSiblingDarwinBundleIdentifier(product: IProductConfiguration): string | undefined { + const isEmbedded = !!(process as INodeProcess).isEmbeddedApp; + return isEmbedded + ? product.embedded?.darwinSiblingBundleIdentifier + : product.darwinSiblingBundleIdentifier; +} + +/** + * Resolves the sibling app's Windows executable path. + */ +export function resolveSiblingWindowsExePath(product: IProductConfiguration): string | undefined { + const isEmbedded = !!(process as INodeProcess).isEmbeddedApp; + const siblingBasename = isEmbedded + ? product.embedded?.win32SiblingExeBasename + : product.win32SiblingExeBasename; + + if (!siblingBasename) { + return undefined; + } + + const siblingExe = join(dirname(process.execPath), `${siblingBasename}.exe`); + try { + if (statSync(siblingExe).isFile()) { + return siblingExe; + } + } catch { + // may not exist on disk + } + + return undefined; +} diff --git a/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts new file mode 100644 index 0000000000000..89367f4a69f87 --- /dev/null +++ b/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { URI } from '../../../../base/common/uri.js'; +import { registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { logSessionsInteraction } from '../../../common/sessionsTelemetry.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { INativeHostService } from '../../../../platform/native/common/native.js'; +import { CopilotCLISessionType } from '../../../services/sessions/common/session.js'; +import { AGENT_HOST_SCHEME, fromAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js'; +import { OpenSessionWorktreeInVSCodeAction, resolveRemoteAuthority } from '../browser/chat.contribution.js'; + +/** + * Desktop override for {@link OpenSessionWorktreeInVSCodeAction}. + * + * Launches the host VS Code app via {@link INativeHostService.launchSiblingApp} + */ +registerAction2(class extends OpenSessionWorktreeInVSCodeAction { + + override async run(accessor: ServicesAccessor): Promise { + const telemetryService = accessor.get(ITelemetryService); + logSessionsInteraction(telemetryService, 'openInVSCode'); + + const nativeHostService = accessor.get(INativeHostService); + const productService = accessor.get(IProductService); + const sessionsManagementService = accessor.get(ISessionsManagementService); + const sessionsProvidersService = accessor.get(ISessionsProvidersService); + const remoteAgentHostService = accessor.get(IRemoteAgentHostService); + + const activeSession = sessionsManagementService.activeSession.get(); + const workspace = activeSession?.workspace.get(); + const repo = workspace?.repositories[0]; + const rawFolderUri = activeSession?.sessionType === CopilotCLISessionType.id ? repo?.workingDirectory ?? repo?.uri : undefined; + const folderUri = rawFolderUri?.scheme === AGENT_HOST_SCHEME ? fromAgentHostUri(rawFolderUri) : rawFolderUri; + const remoteAuthority = activeSession + ? resolveRemoteAuthority(activeSession.providerId, sessionsProvidersService, remoteAgentHostService) + : undefined; + + const args: string[] = ['--new-window']; + + if (folderUri) { + if (remoteAuthority) { + args.push('--folder-uri', URI.from({ scheme: Schemas.vscodeRemote, authority: remoteAuthority, path: folderUri.path }).toString()); + } else { + args.push('--folder-uri', folderUri.toString()); + } + } + + if (activeSession) { + const scheme = productService.parentPolicyConfig?.urlProtocol ?? productService.urlProtocol; + const params = new URLSearchParams(); + params.set('windowId', '_blank'); + params.set('session', activeSession.resource.toString()); + args.push('--open-url', URI.from({ scheme, query: params.toString() }).toString()); + } + + await nativeHostService.launchSiblingApp(args); + } +}); diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index 85a8ef449efc1..9dc4b5ac3a657 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -178,6 +178,7 @@ import '../workbench/contrib/remoteTunnel/electron-browser/remoteTunnel.contribu // Chat import '../workbench/contrib/chat/electron-browser/chat.contribution.js'; import './contrib/agentFeedback/browser/agentFeedback.contribution.js'; +import './contrib/chat/electron-browser/openInVSCode.contribution.js'; // Encryption import '../workbench/contrib/encryption/electron-browser/encryption.contribution.js'; diff --git a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts index 4f9434ccd8dbf..5dbfa3278e529 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts @@ -10,12 +10,8 @@ import { INativeHostService } from '../../../../../platform/native/common/native import { ChatEntitlementContextKeys } from '../../../../services/chat/common/chatEntitlementService.js'; import { CHAT_CATEGORY } from '../../browser/actions/chatActions.js'; import { IsSessionsWindowContext } from '../../../../common/contextkeys.js'; -import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; -import { IProductService } from '../../../../../platform/product/common/productService.js'; -import { URI } from '../../../../../base/common/uri.js'; import { isMacintosh, isWindows } from '../../../../../base/common/platform.js'; import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; -import { Schemas } from '../../../../../base/common/network.js'; import { ProductQualityContext } from '../../../../../platform/contextkey/common/contextkeys.js'; export class OpenAgentsWindowAction extends Action2 { @@ -36,14 +32,12 @@ export class OpenAgentsWindowAction extends Action2 { } async run(accessor: ServicesAccessor, options?: { forceNewWindow?: boolean }) { - const openerService = accessor.get(IOpenerService); - const productService = accessor.get(IProductService); const environmentService = accessor.get(IWorkbenchEnvironmentService); + const nativeHostService = accessor.get(INativeHostService); - if (environmentService.isBuilt && (isMacintosh || isWindows) && productService.embedded?.urlProtocol) { - await openerService.open(URI.from({ scheme: productService.embedded.urlProtocol, authority: Schemas.file }), { openExternal: true }); + if (environmentService.isBuilt && (isMacintosh || isWindows)) { + await nativeHostService.launchSiblingApp(); } else { - const nativeHostService = accessor.get(INativeHostService); await nativeHostService.openAgentsWindow({ forceNewWindow: options?.forceNewWindow ?? true }); } } diff --git a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts index 5be86b5e630e0..95048d5ca8fe5 100644 --- a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts @@ -105,6 +105,8 @@ export class TestNativeHostService implements INativeHostService { async openAgentsWindow(_options?: { readonly forceNewWindow?: boolean }): Promise { } + async launchSiblingApp(_args?: string[]): Promise { } + async toggleFullScreen(): Promise { } async isMaximized(): Promise { return true; } async isFullScreen(): Promise { return true; } From a494136c58694c45095a2fe11586ab123ac9904c Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:19:16 -0400 Subject: [PATCH 23/56] Fix z-index for webviews with modals We only want to set the zindex if we're inside a modal --- .../contrib/webview/browser/overlayWebview.ts | 1 - .../webviewPanel/browser/webviewEditor.ts | 18 ++++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/contrib/webview/browser/overlayWebview.ts b/src/vs/workbench/contrib/webview/browser/overlayWebview.ts index ec5034cde21ba..683604c1cc727 100644 --- a/src/vs/workbench/contrib/webview/browser/overlayWebview.ts +++ b/src/vs/workbench/contrib/webview/browser/overlayWebview.ts @@ -112,7 +112,6 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { // // Mount them to a high level node to avoid this depending on the active container. const root = this._layoutService.getContainer(this.window); root.appendChild(this._overlayLayout.root); - this._overlayLayout.root.style.zIndex = '2541'; // One level above the modals } return this._overlayLayout.content; diff --git a/src/vs/workbench/contrib/webviewPanel/browser/webviewEditor.ts b/src/vs/workbench/contrib/webviewPanel/browser/webviewEditor.ts index 592701e1e910f..f06d57edf11c8 100644 --- a/src/vs/workbench/contrib/webviewPanel/browser/webviewEditor.ts +++ b/src/vs/workbench/contrib/webviewPanel/browser/webviewEditor.ts @@ -43,6 +43,7 @@ export class WebviewEditor extends EditorPane { private _dimension?: DOM.Dimension; private _visible = false; private _isDisposed = false; + private _clippingContainer?: HTMLElement; private readonly _webviewVisibleDisposables = this._register(new DisposableStore()); private readonly _onFocusWindowHandler = this._register(new MutableDisposable()); @@ -179,6 +180,14 @@ export class WebviewEditor extends EditorPane { DOM.setParentFlowTo(input.webview.container, this._element); } + // Check if this editor is inside a modal editor + const modalEditorContainer = this._editorGroupsService.activeModalEditorPart?.modalElement; + const isModal = isHTMLElement(modalEditorContainer) && this._element && modalEditorContainer.contains(this._element); + this._clippingContainer = isModal ? undefined : this._workbenchLayoutService.getContainer(this.window, Parts.EDITOR_PART); + + // When shown in a modal editor, the webview overlay must sit above the modal layer + input.webview.container.style.zIndex = isModal ? '2541' : ''; // One over the modal z-index + this._webviewVisibleDisposables.clear(); // Webviews are not part of the normal editor dom, so we have to register our own drag and drop handler on them. @@ -197,14 +206,7 @@ export class WebviewEditor extends EditorPane { return; } - const modalEditorContainer = this._editorGroupsService.activeModalEditorPart?.modalElement; - let clippingContainer: HTMLElement | undefined; - if (isHTMLElement(modalEditorContainer)) { - clippingContainer = undefined; - } else { - clippingContainer = this._workbenchLayoutService.getContainer(this.window, Parts.EDITOR_PART); - } - webview.layoutWebviewOverElement(this._element.parentElement!, dimension, clippingContainer); + webview.layoutWebviewOverElement(this._element.parentElement!, dimension, this._clippingContainer); } private trackFocus(webview: IOverlayWebview): IDisposable { From b47efc0ac5698e7e50fe475144d0dbca46e8b474 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 16 Apr 2026 16:02:24 -0400 Subject: [PATCH 24/56] Add `Sort by Created/Updated` filters to sessions view (#310835) fixes #308566 --- .../agentSessions/agentSessionsFilter.ts | 68 ++++++++++++++++++- .../agentSessions/agentSessionsViewer.ts | 20 +++--- .../agentSessionsDataSource.test.ts | 13 +++- 3 files changed, 87 insertions(+), 14 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts index 5f0e8a7454562..f7bd5dc148d19 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts @@ -62,16 +62,18 @@ const DEFAULT_EXCLUDES: IAgentSessionsFilterExcludes = Object.freeze({ export class AgentSessionsFilter extends Disposable implements Required { private readonly STORAGE_KEY = `agentSessions.filterExcludes.agentsessionsviewerfiltersubmenu`; + private readonly SORTING_STORAGE_KEY = `agentSessions.sorting`; private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange = this._onDidChange.event; readonly limitResults = () => this.options.limitResults?.(); readonly groupResults = () => this.options.groupResults?.(); - readonly sortResults = () => this.options.sortResults?.(); + readonly sortResults = (): AgentSessionsSorting | undefined => this.options.sortResults?.() ?? this.currentSorting; private excludes = DEFAULT_EXCLUDES; private isStoringExcludes = false; + private currentSorting: AgentSessionsSorting = AgentSessionsSorting.Created; private readonly actionDisposables = this._register(new DisposableStore()); @@ -82,6 +84,7 @@ export class AgentSessionsFilter extends Disposable implements Required { @@ -311,7 +372,7 @@ export class AgentSessionsFilter extends Disposable implements Required { // Sort by time const sortBy = this.getSortBy(); - const timeA = prioritizeActiveSessions - ? sessionA.timing.lastRequestStarted ?? sessionA.timing.created - : sortBy === AgentSessionsSorting.Updated - ? sessionA.timing.lastRequestEnded ?? sessionA.timing.created - : sessionA.timing.created; - const timeB = prioritizeActiveSessions - ? sessionB.timing.lastRequestStarted ?? sessionB.timing.created - : sortBy === AgentSessionsSorting.Updated - ? sessionB.timing.lastRequestEnded ?? sessionB.timing.created - : sessionB.timing.created; + const timeA = sortBy === AgentSessionsSorting.Updated + ? (prioritizeActiveSessions + ? sessionA.timing.lastRequestStarted ?? sessionA.timing.created + : sessionA.timing.lastRequestEnded ?? sessionA.timing.created) + : sessionA.timing.created; + const timeB = sortBy === AgentSessionsSorting.Updated + ? (prioritizeActiveSessions + ? sessionB.timing.lastRequestStarted ?? sessionB.timing.created + : sessionB.timing.lastRequestEnded ?? sessionB.timing.created) + : sessionB.timing.created; return timeB - timeA; } } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index b4169caa93c17..b9d98566520c1 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -1283,8 +1283,8 @@ suite('AgentSessionsSorter', () => { assert.deepStrictEqual(sorted.map(s => s.label), ['Session active', 'Session archived']); }); - test('prioritizeActive: uses lastRequestStarted for time sorting', () => { - const sorter = new AgentSessionsSorter(); + test('prioritizeActive: uses lastRequestStarted for time sorting when sorted by updated', () => { + const sorter = new AgentSessionsSorter(() => AgentSessionsSorting.Updated); const recentlyActive = createSession({ id: 'recent-active', created: 1000, lastRequestStarted: 5000 }); const recentlyCreated = createSession({ id: 'recent-created', created: 3000 }); @@ -1292,6 +1292,15 @@ suite('AgentSessionsSorter', () => { assert.deepStrictEqual(sorted.map(s => s.label), ['Session recent-active', 'Session recent-created']); }); + test('prioritizeActive: uses created time when sorted by created', () => { + const sorter = new AgentSessionsSorter(() => AgentSessionsSorting.Created); + const recentlyActive = createSession({ id: 'recent-active', created: 1000, lastRequestStarted: 5000 }); + const recentlyCreated = createSession({ id: 'recent-created', created: 3000 }); + + const sorted = [recentlyCreated, recentlyActive].sort((a, b) => sorter.compare(a, b, true)); + assert.deepStrictEqual(sorted.map(s => s.label), ['Session recent-created', 'Session recent-active']); + }); + test('pinned sessions come before non-pinned sessions', () => { const sorter = new AgentSessionsSorter(); const pinned = createSession({ id: 'pinned', isPinned: true, created: 1000 }); From 88fc27106aea5521707486c23bd7101ad35e9993 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 17 Apr 2026 06:14:30 +1000 Subject: [PATCH 25/56] Add GitHub Copilot upgrader skill and CLI integration documentation (#310830) * feat: add GitHub Copilot upgrader skill and CLI integration documentation * Updates * Update extensions/copilot/src/extension/chatSessions/copilotcli/AGENTS.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../skills/github-copilot-upgrader/SKILL.md} | 39 +- .../chatSessions/copilotcli/AGENTS.md | 360 ++++++++++++++++++ 2 files changed, 366 insertions(+), 33 deletions(-) rename extensions/copilot/{.github/prompts/updateGithubCopilotSDK.prompt.md => .agents/skills/github-copilot-upgrader/SKILL.md} (62%) create mode 100644 extensions/copilot/src/extension/chatSessions/copilotcli/AGENTS.md diff --git a/extensions/copilot/.github/prompts/updateGithubCopilotSDK.prompt.md b/extensions/copilot/.agents/skills/github-copilot-upgrader/SKILL.md similarity index 62% rename from extensions/copilot/.github/prompts/updateGithubCopilotSDK.prompt.md rename to extensions/copilot/.agents/skills/github-copilot-upgrader/SKILL.md index 0d234b8d32b8f..c329e8dd68c72 100644 --- a/extensions/copilot/.github/prompts/updateGithubCopilotSDK.prompt.md +++ b/extensions/copilot/.agents/skills/github-copilot-upgrader/SKILL.md @@ -1,5 +1,5 @@ --- -name: updateGithubCopilotSDK +name: github-copilot-upgrader description: Use this to update the Github Copilot CLI/SDK model: Claude Opus 4.6 --- @@ -10,7 +10,6 @@ You are an expert at upgrading the @github/copilot npm package in the vscode-cop You must create a TODO list of all items that are to be completed. You MUST create a TODO markdown file before commencing any of the work. Update this file after each step is completed. -You must also use the update_todo tool on each step. Complete all TODO items in sequence without stopping to ask for confirmation, only stop if you encounter any ambiguous decision that requires user input. The TODO is your primary tracking mechanism. Before each step you MUST read the TODO to determine what to do next. @@ -23,9 +22,12 @@ At a minimum your TODO must contain the following: 6. test 7. Repease steps Compile, fix and tests until all tests are passing 8. Run integration tests -9. Repeate Compile, Fix, Test, Test integration tests until all integration tests are passing +9. Repeate Compile, Fix, Test, until all tests are passing 11. Create a summary +Note: +* Do not run any integration test. + Follow these steps exactly: ### 1. Snapshot of old type definitions @@ -48,6 +50,7 @@ After this you MSUT run `npm run postinstall` - You must perform a deep analysis of the compilation errors before attempting to resolve them. - Ensure there are no compilation errors before proceeding to run the tests. ```bash +npm run postinstall npm run compile npx tsc --noEmit --project tsconfig.json ``` @@ -61,36 +64,6 @@ npm run test:unit If the upgrade causes a test to fail, you must analyze the failure and determine if it is due to a legitimate issue caused by the upgrade or if it is a problem with the test itself. - Ensure all tests are passing before proceeding to the next step. -### 5. Running integration tests - -- The tests are located in test/e2e/cli.stest.ts. -- The tests in this file are all skipped by default using `suite.skip`, so you must remove the `.skip` to enable them before running the tests. -- Run the tests using the following command: -```bash -npm run simulate -- --grep=@cli --verbose -n=1 -p=1@cli -``` - -These tests are very slow, you might have to wait for around 5 minutes for them to complete. -- As earlier, fix the test failures without changing the behavior of the code, and ensure that all tests are passing. - -NOTE: -Tests are considered passing only if you get a score of 100% -Here's a sample output. As you can see below the score needs to be 100/100 for the tests to be considered passing. -``` -Suite Summary by Language: -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ (index) โ”‚ Suite โ”‚ Language โ”‚ Model โ”‚ # of tests โ”‚ Score(%) โ”‚ -โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค -โ”‚ 0 โ”‚ '@cli [external]' โ”‚ '-' โ”‚ '-' โ”‚ 16 โ”‚ 100 โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - -Approximate Summary (due to using --n=1 instead of --n=10): -Overall Approximate Score: 100.00 / 100 - -``` - -#### 6. Re-introduce `stest.skip` changes in cli.stest.ts - #### 5. Summarize the changes - After successfully upgrading the @github/copilot package and ensuring that all tests are passing, you must create a summary of the changes that were made during the upgrade process. diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/AGENTS.md b/extensions/copilot/src/extension/chatSessions/copilotcli/AGENTS.md new file mode 100644 index 0000000000000..eb343ccf15bdb --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/AGENTS.md @@ -0,0 +1,360 @@ +# Copilot CLI Integration + +This folder contains the Copilot CLI integration for VS Code Chat. It enables users to open a new Chat window and interact with a Copilot CLI agent instance directly within VS Code. **VS Code provides the UI, Copilot CLI SDK provides the smarts.** + +> **Important:** The Copilot CLI agent functionality is powered by the `@github/copilot/sdk` package. See the SDK package for full type definitions. + +## Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ VS Code Chat UI โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ CopilotCLISessionService โ”‚ +โ”‚ (node/copilotcliSessionService.ts) โ”‚ +โ”‚ - Manages SDK LocalSessionManager lifecycle โ”‚ +โ”‚ - Creates, retrieves, and caches CopilotCLISession instances โ”‚ +โ”‚ - Handles session persistence, discovery, and forking โ”‚ +โ”‚ - Monitors session files on disk for external changes โ”‚ +โ”‚ - Installs OTel bridge span processor for debug panel โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ CopilotCLISession โ”‚ +โ”‚ (node/copilotcliSession.ts) โ”‚ +โ”‚ - Wraps a single SDK Session for one conversation โ”‚ +โ”‚ - Processes SDK events (messages, tools, permissions, errors) โ”‚ +โ”‚ - Handles tool confirmation and permission requests โ”‚ +โ”‚ - Supports steering (injecting messages into running sessions) โ”‚ +โ”‚ - Manages model switching and reasoning effort โ”‚ +โ”‚ - Tracks OTel spans for observability โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Copilot CLI SDK (@github/copilot/sdk) โ”‚ +โ”‚ - Manages the agentic conversation loop โ”‚ +โ”‚ - Executes tools and reports results via events โ”‚ +โ”‚ - Handles permissions (read, write, shell, MCP) โ”‚ +โ”‚ - Provides session persistence as events.jsonl files โ”‚ +โ”‚ - Supports fleet mode and plan mode โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ MCP Server (In-Process) โ”‚ +โ”‚ (vscode-node/contribution.ts, vscode-node/inProcHttpServer.ts) โ”‚ +โ”‚ - Provides VS Code-specific tools to the SDK via MCP protocol โ”‚ +โ”‚ - Runs as an in-process HTTP server (InProcHttpServer) โ”‚ +โ”‚ - Exposes diff, diagnostics, selection, and session tools โ”‚ +โ”‚ - Discoverable by CLI via lock files in ~/.copilot/ide/ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## Folder Structure + +The integration follows VS Code's platform layering pattern with three layers: + +``` +copilotcli/ +โ”œโ”€โ”€ common/ # Platform-agnostic (NO Node.js or VS Code API imports) +โ”‚ โ”œโ”€โ”€ copilotCLITools.ts # Tool type definitions and processing helpers +โ”‚ โ”œโ”€โ”€ copilotCLIPrompt.ts # Prompt reference extraction and parsing +โ”‚ โ”œโ”€โ”€ customSessionTitleService.ts +โ”‚ โ”œโ”€โ”€ delegationSummaryService.ts +โ”‚ โ”œโ”€โ”€ utils.ts # SessionIdForCLI namespace (URI scheme: 'copilotcli') +โ”‚ โ””โ”€โ”€ test/ +โ”‚ +โ”œโ”€โ”€ node/ # Node.js-specific (SDK integration, filesystem, permissions) +โ”‚ โ”œโ”€โ”€ copilotCli.ts # ICopilotCLISDK, CopilotCLIModels, CopilotCLIAgents +โ”‚ โ”œโ”€โ”€ copilotcliSession.ts # CopilotCLISession โ€” main session wrapper +โ”‚ โ”œโ”€โ”€ copilotcliSessionService.ts # Session lifecycle management +โ”‚ โ”œโ”€โ”€ permissionHelpers.ts # Permission request handlers +โ”‚ โ”œโ”€โ”€ copilotcliPromptResolver.ts # Resolves prompts with variables and attachments +โ”‚ โ”œโ”€โ”€ copilotCLISkills.ts # Skills location resolution +โ”‚ โ”œโ”€โ”€ copilotCLIImageSupport.ts # Image attachment handling +โ”‚ โ”œโ”€โ”€ mcpHandler.ts # MCP server configuration for SDK sessions +โ”‚ โ”œโ”€โ”€ nodePtyShim.ts # Copies VS Code's node-pty for SDK use +โ”‚ โ”œโ”€โ”€ userInputHelpers.ts # User question/input handling interface +โ”‚ โ”œโ”€โ”€ exitPlanModeHandler.ts # Plan mode exit flow with user choice +โ”‚ โ”œโ”€โ”€ ripgrepShim.ts # Copies VS Code's ripgrep for SDK use +โ”‚ โ””โ”€โ”€ test/ +โ”‚ +โ””โ”€โ”€ vscode-node/ # VS Code API-dependent (commands, MCP tools, UI) + โ”œโ”€โ”€ copilotCLIFolderMru.ts # Folder MRU (most-recently-used) service + โ””โ”€โ”€ test/ +``` + +## Layering Rules + +Strict import dependency rules โ€” violations will cause build failures: + +| Layer | Can import from | Cannot import from | +|-------|----------------|--------------------| +| `common/` | `src/util/common/`, `src/platform/`, sibling `../common/` | `node/`, `vscode-node/`, `vscode` module | +| `node/` | `common/`, `src/util/`, `src/platform/`, Node.js builtins | `vscode-node/`, `vscode` module | +| `vscode-node/` | `common/`, `node/`, `src/util/`, `src/platform/`, `vscode` module | (top layer โ€” no restrictions) | + + +## Key Components +### `node/copilotCli.ts` + +**ICopilotCLISDK / CopilotCLISDK** +- Service interface wrapping the dynamic `import('@github/copilot/sdk')` for dependency injection and testability + +**ICopilotCLIModels / CopilotCLIModels** +- Fetches and caches available AI models from the SDK via `getAvailableModels()` +- Registers a `LanguageModelChatProvider` with `targetChatSessionType: 'copilotcli'` so VS Code's model picker shows CLI models +- Exposes model capabilities: vision support, reasoning effort levels, token limits, billing multiplier +- Rebuilds model list on authentication changes +- Builds configuration schema for reasoning effort per model (low/medium/high/xhigh) + +**ICopilotCLIAgents / CopilotCLIAgents** +- Discovers custom agents + +### `node/copilotcliSession.ts` + +**CopilotCLISession** +- Wraps a single `Session` object from the `@github/copilot/sdk` +- Entry point for every chat request via `handleRequest()` +- Listens to SDK events and translates them to VS Code chat UI parts +- Manages permission flow +- Tracks external edits via `ExternalEditTracker` for proper diff display +- Supports CLI commands: `compact`, `plan`, `fleet` +- Built-in slash commands: `/commit`, `/sync`, `/merge`, `/create-pr`, `/create-draft-pr`, `/update-pr` +- Captures pull request URLs from `create_pull_request` tool results + +### `node/copilotcliSessionService.ts` + +**ICopilotCLISessionService / CopilotCLISessionService** +- Central service managing the lifecycle of all Copilot CLI sessions + +### `common/copilotCLITools.ts` + +Defines all tool type interfaces used by the Copilot CLI agent: + +* File Operations +* Shell Operations +* Search Operations +* Agent & Task Operations +* User Interaction +* Code Review & Git +* Data, Memory & MCP +* Security + + +### `common/copilotCLIPrompt.ts` + +Parses raw user prompts and extracts structured chat prompt references (files, locations, diagnostics) + +### `node/copilotcliPromptResolver.ts` + +**CopilotCLIPromptResolver** +- Resolves chat request prompts by processing variable references and building attachments +- Extracts prompt variables from `ChatVariablesCollection` (files, locations, diagnostics, custom instructions) +- Converts image attachments +- Generates the final user prompt +- Handles workspace folder path translation for multi-folder isolation + +### `node/permissionHelpers.ts` + +Handles permission requests from the SDK. Each permission kind has a dedicated handler: + +* handleReadPermission +* handleWritePermission +* handleShellPermission +* handleMcpPermission +* showInteractivePermissionPrompt + +### `node/mcpHandler.ts` + +**ICopilotCLIMCPHandler / CopilotCLIMCPHandler** +- Loads MCP server configuration for SDK sessions +- Proxies all VS Code-configured MCP servers through a gateway URL with `type: 'http'` config per server + +### `node/copilotCLIImageSupport.ts` + +**ICopilotCLIImageSupport / CopilotCLIImageSupport** +- Stores image data as files in extension global storage (`copilot-cli-images/`) +- Tracks trusted image URIs to auto-approve read permissions +- Supports PNG, JPEG, GIF, WebP, and BMP formats via `isImageMimeType()` + +### `node/exitPlanModeHandler.ts` + +**`handleExitPlanMode()`** +- Presents exit options when the SDK finishes plan generation: Autopilot, Interactive, Exit Only, Autopilot Fleet +- Syncs saved plan changes back to the SDK session + +### `node/cliHelpers.ts` + +Path helpers for Copilot CLI directories. + +## Message Flow + +1. **User sends message** in VS Code Chat +2. **CopilotCLISessionService** creates or retrieves an existing session wrapper +3. **CopilotCLISession.handleRequest()** is called: + - If session is idle โ†’ normal request via `send()` + - If session is busy โ†’ steering request via `send({ mode: 'immediate' })` +4. **SDK Session** processes the request and emits events: + - `assistant.message_delta` โ†’ streamed markdown to chat UI + - `tool.execution_start` / `tool.execution_complete` โ†’ tool invocation UI parts + - `permission.requested` โ†’ routed to permission handler (auto-approve or interactive) + - `user_input.requested` โ†’ question carousel shown to user + - `exit_plan_mode.requested` โ†’ plan mode exit choices + - `session.title_changed` โ†’ session title updated + - `subagent.started/completed/failed` โ†’ subagent metadata enriches tool invocations + - `hook.start/end` โ†’ forwarded to OTel bridge for debug panel +5. **Session completes** โ€” status set to `Completed`, usage reported + +## Permission System + +The SDK emits `permission.requested` events with a `kind` field. + +When `autopilot` / `autoApprove` permission level is set, all permissions are auto-approved without user interaction. + +Tool invocation messages are intentionally held in a queue (`toolCallWaitingForPermissions`) until the permission resolves, preventing a flash of "Running..." immediately followed by "Permission requested...". + +## Session Persistence + +Copilot CLI sessions are persisted to `~/.copilot/session-state//` directories containing: +- `events.jsonl` โ€” Ordered event stream (messages, tool calls, results) +- `workspace.yaml` โ€” Workspace configuration + +### `IWorkspaceInfo` (`../common/workspaceInfo.ts`) + +Central type representing all workspace/repository/worktree state for a session: + +### `IChatSessionMetadataStore` (`../common/chatSessionMetadataStore.ts`) + +Persists VS Code-specific metadata that sits alongside the SDK's own session data. This metadata is **not part of the SDK's `events.jsonl`** โ€” it tracks VS Code concepts like worktree properties, request-to-tool mappings, mode instructions, and checkpoint refs. + +**Key Types:** + +**`ChatSessionMetadataFile`** โ€” The full metadata shape per session: + +**`RequestDetails`** โ€” Per-request metadata: + +**`RepositoryProperties`** โ€” Git repository metadata: + +### `IChatSessionWorktreeService` (`../common/chatSessionWorktreeService.ts`) + +Manages Git worktree lifecycle for session isolation. When isolation is enabled, each session gets its own Git worktree so the agent can make changes without affecting the user's working copy. + +### `IChatSessionWorktreeCheckpointService` (`../common/chatSessionWorktreeCheckpointService.ts`) + +Creates Git checkpoints (lightweight commits or refs) at the start and end of each request turn. These checkpoints enable the **undo/revert** feature โ€” users can roll back to any previous turn's state. + +### `IChatSessionWorkspaceFolderService` (`../common/chatSessionWorkspaceFolderService.ts`) + +Handles workspace folder tracking for sessions **without** Git worktree isolation โ€” i.e., when the agent works directly in the user's workspace. Used in multi-root workspaces where some folders may not have Git repositories. + +### `IFolderRepositoryManager` (`../common/folderRepositoryManager.ts`) + +Orchestrates the full folder/repository initialization flow for a session. This is the high-level coordinator that brings together worktree creation, trust verification, uncommitted change handling, and folder tracking. + +### `ISessionRequestLifecycle` (`../vscode-node/sessionRequestLifecycle.ts`) + +Orchestrates the start and end of each chat request turn, coordinating worktree commits, checkpoint creation, PR detection, and metadata updates. Handles the complexity of **steering** โ€” where multiple requests can be in-flight for the same session simultaneously. + +## Architecture Diagram: Shared Services + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ SessionRequestLifecycle โ”‚ +โ”‚ Orchestrates start/end of each request turn โ”‚ +โ”‚ Handles steering (multiple concurrent requests per session) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ โ”‚ โ”‚ + โ–ผ โ–ผ โ–ผ โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Worktree โ”‚ โ”‚ Workspace โ”‚ โ”‚ Checkpoint โ”‚ โ”‚ MetadataStore โ”‚ +โ”‚ Service โ”‚ โ”‚ Folder โ”‚ โ”‚ Service โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ Service โ”‚ โ”‚ โ”‚ โ”‚ - Request details โ”‚ +โ”‚ - Create โ”‚ โ”‚ โ”‚ โ”‚ - Baseline โ”‚ โ”‚ - Worktree props โ”‚ +โ”‚ - Commit โ”‚ โ”‚ - Track โ”‚ โ”‚ checkpts โ”‚ โ”‚ - Workspace folder โ”‚ +โ”‚ - Cleanup โ”‚ โ”‚ - Stage โ”‚ โ”‚ - Post-turn โ”‚ โ”‚ - Repo properties โ”‚ +โ”‚ - Archive โ”‚ โ”‚ - Changes โ”‚ โ”‚ checkpts โ”‚ โ”‚ - Mode instructions โ”‚ +โ”‚ - Unarchiveโ”‚ โ”‚ - Clear โ”‚ โ”‚ - Multi-rootโ”‚ โ”‚ - Checkpoint refs โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ โ”‚ โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ FolderRepositoryMgr โ”‚ + โ”‚ โ”‚ + โ”‚ - Init flow โ”‚ + โ”‚ - Trust verification โ”‚ + โ”‚ - Multi-root batch โ”‚ + โ”‚ - MRU tracking โ”‚ + โ”‚ - Isolation mode โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + + +## How to Add New Features + +### Adding a new permission handler + +1. Add `handlePermission()` in `node/permissionHelpers.ts` following the existing pattern +2. Add a `case '':` in the permission switch in `node/copilotcliSession.ts` (~line 468) +3. Handler should return a `PermissionRequestResult` with `kind: 'approved' | 'denied-interactively-by-user' | ...` + +### Handling a new SDK event + +1. Add a listener in `node/copilotcliSession.ts` using `this._sdkSession.on(eventName, handler)` +2. Wrap with `toDisposable()` and add to the `DisposableStore` for proper cleanup +3. Use `this._stream?.markdown()` / `this._stream?.push()` to output to the chat UI + +## Critical Pitfalls + +- **Shims before SDK import**: `ensureNodePtyShim()` and `ensureRipgrepShim()` in `node/nodePtyShim.ts` / `node/ripgrepShim.ts` MUST be called before any `import('@github/copilot/sdk')`. They copy VS Code's bundled native binaries to the SDK's expected locations. See `node/copilotCli.ts` for the initialization order. + +- **Delayed permission UI**: Tool invocation messages are held in `toolCallWaitingForPermissions` until permission resolves. `flushPendingInvocationMessageForToolCallId()` flushes only the specific approved tool, not all pending tools. This is intentional โ€” don't bypass it. + +- **Steering mode**: When a session is already busy (`InProgress` or `NeedsInput`), use `send({ mode: 'immediate' })` to inject messages into the running conversation instead of starting a new request. + +## Commands & Slash Commands + +**Copilot CLI commands** (user-facing, sent programmatically): +- `compact` โ€” compress conversation history to reduce tokens +- `plan` โ€” enter plan mode (SDK generates plan before executing) +- `fleet` โ€” start fleet mode for multi-agent parallel execution + +**Built-in custom slash commands** (user-facing): +`/commit`, `/sync`, `/merge`, `/create-pr`, `/create-draft-pr`, `/update-pr` + +**VS Code Session commands** (registered via `registerCLIChatCommands` in `vscode-node/copilotCLIChatSessions.ts`): + +## Configuration + +The integration respects these VS Code settings (all under `github.copilot.chat.cli.*`): + +| Setting | Default | Description | +|---------|---------|-------------| +| `mcp.enabled` | `true` | Enable MCP server proxying for CLI sessions | +| `branchSupport.enabled` | `false` | Enable Git branch support features | +| `showExternalSessions` | `false` | Show sessions created outside VS Code (e.g., terminal CLI) | +| `planExitMode.enabled` | `true` | Show plan exit mode choices (Autopilot/Interactive/Exit) | +| `planCommand.enabled` | `true` | Enable the `/plan` command | +| `aiGenerateBranchNames.enabled` | `true` | AI-generated branch names for worktrees | +| `forkSessions.enabled` | `true` | Allow forking sessions into new conversations | +| `isolationOption.enabled` | `true` | Show worktree isolation option in session UI | +| `autoCommit.enabled` | `true` | Auto-commit worktree changes at end of each turn | +| `sessionController.enabled` | `false` | Use session controller API (V2) | +| `thinkingEffort.enabled` | `true` | Show thinking effort control per model | +| `sessionControllerForSessionsApp.enabled` | `false` | Use session controller for Sessions window | +| `terminalLinks.enabled` | `true` | Enable terminal link detection | + +## Dependencies + +- `@github/copilot/sdk`: Official Copilot CLI SDK (session management, tools, permissions, events) + +## Deprecated Code + +V1 registration in `../vscode-node/copilotCLIChatSessionsContribution.ts` and `registerCopilotCLIServicesV1` are deprecated. All new development should use `CopilotCLISessionService` and the controller-based V2 API. From 17c5eed5e78f0891582c2bdf7951c4fabd0ff65d Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 16 Apr 2026 22:42:27 +0200 Subject: [PATCH 26/56] fix #310143 (#310840) Co-authored-by: Copilot --- .../chatSetup/chatSetupContributions.ts | 10 +++- .../extensions/browser/extensionsActions.ts | 56 ++++++++++++++++--- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts index 3fa75da3a9070..2ea65ba8917c5 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts @@ -20,7 +20,7 @@ import { localize, localize2 } from '../../../../../nls.js'; import { IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IsWebContext } from '../../../../../platform/contextkey/common/contextkeys.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; @@ -759,7 +759,13 @@ export class ChatTeardownContribution extends Disposable implements IWorkbenchCo const defaultChatExtension = this.extensionsWorkbenchService.local.find(value => ExtensionIdentifier.equals(value.identifier.id, defaultChat.chatExtensionId)); if (defaultChatExtension?.local && this.extensionEnablementService.isEnabled(defaultChatExtension.local)) { - this.configurationService.updateValue(ChatConfiguration.AIDisabled, false); + if (defaultChatExtension.enablementState === EnablementState.EnabledWorkspace) { + if (this.configurationService.inspect(ChatConfiguration.AIDisabled).workspaceValue === true) { + this.configurationService.updateValue(ChatConfiguration.AIDisabled, false, ConfigurationTarget.WORKSPACE); + } + } else { + this.configurationService.updateValue(ChatConfiguration.AIDisabled, false); + } } })); } diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts index 75896f1c81cf7..a7ef50800086a 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts @@ -26,7 +26,7 @@ import { IHostService } from '../../../services/host/browser/host.js'; import { IExtensionService, toExtension, toExtensionDescription } from '../../../services/extensions/common/extensions.js'; import { URI } from '../../../../base/common/uri.js'; import { CommandsRegistry, ICommandService } from '../../../../platform/commands/common/commands.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { registerThemingParticipant, IColorTheme, ICssStyleCollector } from '../../../../platform/theme/common/themeService.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { buttonBackground, buttonForeground, buttonHoverBackground, buttonSecondaryBackground, buttonSecondaryForeground, buttonSecondaryHoverBackground, registerColor, editorWarningForeground, editorInfoForeground, editorErrorForeground, buttonSeparator, buttonBorder, contrastBorder } from '../../../../platform/theme/common/colorRegistry.js'; @@ -1816,10 +1816,23 @@ class EnableAIFeaturesGloballyAction extends ExtensionAction { update(): void { this.enabled = false; - if (this.extension && ExtensionIdentifier.equals(this.extension.identifier.id, this.productService.defaultChatAgent?.chatExtensionId)) { - this.enabled = this.configurationService.getValue(CHAT_AI_DISABLED_SETTING) === true - && this.extension.enablementState !== EnablementState.DisabledWorkspace; + if (!this.extension?.local) { + return; + } + if (!ExtensionIdentifier.equals(this.extension.identifier.id, this.productService.defaultChatAgent?.chatExtensionId)) { + return; + } + if (this.extension.enablementState === EnablementState.DisabledWorkspace) { + return; + } + if (this.extension.enablementState === EnablementState.EnabledWorkspace) { + return; + } + const inspect = this.configurationService.inspect(CHAT_AI_DISABLED_SETTING); + if (inspect?.workspaceValue === true) { + return; } + this.enabled = inspect.value === true; } override async run(): Promise { @@ -1835,24 +1848,53 @@ class EnableAIFeaturesInWorkspaceAction extends ExtensionAction { constructor( @IProductService private readonly productService: IProductService, @IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IWorkbenchExtensionEnablementService private readonly extensionEnablementService: IWorkbenchExtensionEnablementService, ) { super(EnableAIFeaturesInWorkspaceAction.ID, EnableAIFeaturesInWorkspaceAction.LABEL, ExtensionAction.LABEL_ACTION_CLASS); this.tooltip = localize('enableAIInWorkspaceActionToolTip', "Enable AI features in this workspace"); this.update(); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(CHAT_AI_DISABLED_SETTING)) { + this.update(); + } + })); } update(): void { this.enabled = false; - if (this.extension && this.extension.local && ExtensionIdentifier.equals(this.extension.identifier.id, this.productService.defaultChatAgent?.chatExtensionId)) { - this.enabled = this.extension.enablementState === EnablementState.DisabledWorkspace; + if (!this.extension?.local) { + return; + } + if (!ExtensionIdentifier.equals(this.extension.identifier.id, this.productService.defaultChatAgent?.chatExtensionId)) { + return; + } + if (!this.extensionEnablementService.canChangeWorkspaceEnablement(this.extension.local)) { + return; + } + const inspect = this.configurationService.inspect(CHAT_AI_DISABLED_SETTING); + if (inspect.value === false) { + return; } + if (inspect?.workspaceValue === true) { + this.enabled = true; + return; + } + if (this.extension.enablementState === EnablementState.EnabledWorkspace) { + return; + } + this.enabled = true; + return; } override async run(): Promise { if (!this.extension) { return; } - return this.extensionsWorkbenchService.setEnablement(this.extension, EnablementState.EnabledWorkspace); + await this.extensionsWorkbenchService.setEnablement(this.extension, EnablementState.EnabledWorkspace); + if (this.configurationService.inspect(CHAT_AI_DISABLED_SETTING).workspaceValue === true) { + await this.configurationService.updateValue(CHAT_AI_DISABLED_SETTING, false, ConfigurationTarget.WORKSPACE); + } } } From a6d1c315ff67fe5d1d172cbcc4630d4d5e509116 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 16 Apr 2026 16:42:56 -0400 Subject: [PATCH 27/56] add `Edit` action to queued message context menu (#310837) fixes #308552 --- .../chat/browser/actions/chatQueueActions.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts index 93fdf1513d180..5756d74f37422 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts @@ -201,6 +201,43 @@ export class ChatRemovePendingRequestAction extends Action2 { } } +export class ChatEditPendingRequestAction extends Action2 { + static readonly ID = 'workbench.action.chat.editPendingRequest'; + + constructor() { + super({ + id: ChatEditPendingRequestAction.ID, + title: localize2('chat.editPendingRequest', "Edit"), + icon: Codicon.edit, + f1: false, + category: CHAT_CATEGORY, + menu: [{ + id: MenuId.ChatMessageTitle, + group: 'navigation', + order: 2, + when: ContextKeyExpr.and( + ChatContextKeys.isRequest, + ChatContextKeys.isPendingRequest, + ContextKeyExpr.notEquals(`config.${ChatConfiguration.EditRequests}`, 'hover'), + ContextKeyExpr.notEquals(`config.${ChatConfiguration.EditRequests}`, 'input') + ) + }] + }); + } + + override run(accessor: ServicesAccessor, ...args: unknown[]): void { + const widgetService = accessor.get(IChatWidgetService); + const [context] = args; + + if (!isRequestVM(context) || !context.pendingKind) { + return; + } + + const widget = widgetService.getWidgetBySessionResource(context.sessionResource); + widget?.startEditing(context.id); + } +} + export class ChatSendPendingImmediatelyAction extends Action2 { static readonly ID = 'workbench.action.chat.sendPendingImmediately'; @@ -299,6 +336,7 @@ export function registerChatQueueActions(): void { registerAction2(ChatQueueMessageAction); registerAction2(ChatSteerWithMessageAction); registerAction2(ChatRemovePendingRequestAction); + registerAction2(ChatEditPendingRequestAction); registerAction2(ChatSendPendingImmediatelyAction); registerAction2(ChatRemoveAllPendingRequestsAction); From 3eaee995846a7f782c848498726ce8ebd0c4c455 Mon Sep 17 00:00:00 2001 From: Peng Lyu Date: Thu, 16 Apr 2026 14:04:35 -0700 Subject: [PATCH 28/56] Agents app web: terminal integration with ahp (#310553) * Agents app web: terminal integration with ahp * resolve comments. * :lipstick: * refactor: simplify session opening logic in SessionsView * fix integration test. --- .../agentHost/browser/nullAgentHostService.ts | 53 +++++++++++++++++++ .../browser/remoteAgentHostServiceImpl.ts | 2 +- .../remoteAgentHostTerminal.contribution.ts | 17 +++++- .../sessions/browser/views/sessionsView.ts | 13 ++++- src/vs/sessions/sessions.web.main.ts | 6 +++ .../browser/terminalProfileService.ts | 8 +++ .../terminal/browser/terminalService.ts | 25 +++++++++ 7 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 src/vs/platform/agentHost/browser/nullAgentHostService.ts diff --git a/src/vs/platform/agentHost/browser/nullAgentHostService.ts b/src/vs/platform/agentHost/browser/nullAgentHostService.ts new file mode 100644 index 0000000000000..41b421dee514b --- /dev/null +++ b/src/vs/platform/agentHost/browser/nullAgentHostService.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../base/common/event.js'; +import { IReference } from '../../../base/common/lifecycle.js'; +import { URI } from '../../../base/common/uri.js'; +import type { IAgentCreateSessionConfig, IAgentHostService, IAgentHostSocketInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult } from '../common/agentService.js'; +import type { IAgentSubscription } from '../common/state/agentSubscription.js'; +import type { ICreateTerminalParams, IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; +import type { IActionEnvelope, INotification, ISessionAction, ITerminalAction } from '../common/state/sessionActions.js'; +import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult } from '../common/state/sessionProtocol.js'; +import type { ComponentToState, IRootState, StateComponents } from '../common/state/sessionState.js'; + +const notSupported = () => { throw new Error('Local agent host is not supported in the browser.'); }; + +/** + * Null implementation of {@link IAgentHostService} for browser contexts + * where a local agent host process is not available. + */ +export class NullAgentHostService implements IAgentHostService { + declare readonly _serviceBrand: undefined; + + readonly clientId = ''; + readonly onAgentHostExit = Event.None; + readonly onAgentHostStart = Event.None; + readonly onDidNotification: Event = Event.None; + readonly onDidAction: Event = Event.None; + + get rootState(): IAgentSubscription { return notSupported(); } + + getSubscription(_kind: T, _resource: URI): IReference> { return notSupported(); } + getSubscriptionUnmanaged(_kind: T, _resource: URI): IAgentSubscription | undefined { return undefined; } + dispatch(_action: ISessionAction | ITerminalAction): void { notSupported(); } + + async restartAgentHost(): Promise { notSupported(); } + async authenticate(_params: IAuthenticateParams): Promise { return notSupported(); } + async listSessions(): Promise { return []; } + async createSession(_config?: IAgentCreateSessionConfig): Promise { return notSupported(); } + async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise { return notSupported(); } + async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { return notSupported(); } + async startWebSocketServer(): Promise { return notSupported(); } + async disposeSession(_session: URI): Promise { } + async createTerminal(_params: ICreateTerminalParams): Promise { notSupported(); } + async disposeTerminal(_terminal: URI): Promise { } + async resourceList(_uri: URI): Promise { return notSupported(); } + async resourceRead(_uri: URI): Promise { return notSupported(); } + async resourceWrite(_params: IResourceWriteParams): Promise { return notSupported(); } + async resourceCopy(_params: IResourceCopyParams): Promise { return notSupported(); } + async resourceDelete(_params: IResourceDeleteParams): Promise { return notSupported(); } + async resourceMove(_params: IResourceMoveParams): Promise { return notSupported(); } +} diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts index 9d8231f3895fe..f14d1b42b602c 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts @@ -382,7 +382,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo this._reconnectAttempts.delete(address); this._resolvePendingConnectionWait(address); this._onDidChangeConnections.fire(); - }).catch(err => { + }).catch((err: unknown) => { if (!isCurrentEntry()) { return; } diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostTerminal.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostTerminal.contribution.ts index c8fded421f868..2b9fed2f6fc1b 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostTerminal.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostTerminal.contribution.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from '../../../../nls.js'; +import { isWeb } from '../../../../base/common/platform.js'; import { IAgentHostService } from '../../../../platform/agentHost/common/agentService.js'; import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -33,11 +34,23 @@ export class RemoteAgentHostTerminalContribution extends AgentHostTerminalContri // React to connection changes - this._register(this._remoteAgentHostService.onDidChangeConnections(() => this._reconcile())); + this._register(this._remoteAgentHostService.onDidChangeConnections(() => { + this._reconcile(); + })); + + // The base-class constructor already called _reconcile(), but at that + // point _remoteAgentHostService was not yet assigned (guard returned + // early). Re-reconcile now to pick up any existing connections. + this._reconcile(); } protected override _collectEntries(): IAgentHostEntry[] { const entries: IAgentHostEntry[] = []; + // Guard: _remoteAgentHostService may not be assigned yet when the + // base-class constructor calls _reconcile() before super() returns. + if (!this._remoteAgentHostService) { + return isWeb ? entries : super._collectEntries(); + } // Remote connections for (const info of this._remoteAgentHostService.connections) { if (info.status !== RemoteAgentHostConnectionStatus.Connected) { @@ -60,7 +73,7 @@ export class RemoteAgentHostTerminalContribution extends AgentHostTerminalContri }); } - return [...entries, ...super._collectEntries()]; + return isWeb ? entries : [...entries, ...super._collectEntries()]; } } registerWorkbenchContribution2(AgentHostTerminalContribution.ID, RemoteAgentHostTerminalContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts index b60b804148c3f..9ee4fa2ce3554 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts @@ -5,10 +5,11 @@ import '../media/sessionsViewPane.css'; import * as DOM from '../../../../../base/browser/dom.js'; +import { onUnexpectedError } from '../../../../../base/common/errors.js'; import { KeybindingLabel } from '../../../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; import { Event } from '../../../../../base/common/event.js'; import { autorun } from '../../../../../base/common/observable.js'; -import { OS } from '../../../../../base/common/platform.js'; +import { isMobile, isWeb, OS } from '../../../../../base/common/platform.js'; import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -32,6 +33,7 @@ import { agentsNewSessionButtonBackground, agentsNewSessionButtonBorder, agentsN import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IHostService } from '../../../../../workbench/services/host/browser/host.js'; +import { IWorkbenchLayoutService, Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { logSessionsInteraction } from '../../../../common/sessionsTelemetry.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; @@ -80,6 +82,7 @@ export class SessionsView extends ViewPane { @IHoverService hoverService: IHoverService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @IHostService private readonly hostService: IHostService, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IStorageService private readonly storageService: IStorageService, @ITelemetryService private readonly telemetryService: ITelemetryService, ) { @@ -228,7 +231,13 @@ export class SessionsView extends ViewPane { grouping: () => this.currentGrouping, sorting: () => this.currentSorting, findWidgetContainer, - onSessionOpen: (resource, preserveFocus) => this.sessionsManagementService.openSession(resource, { preserveFocus }), + onSessionOpen: (resource, preserveFocus) => { + this.sessionsManagementService.openSession(resource, { preserveFocus }).then(() => { + if (isWeb && isMobile) { + this.layoutService.setPartHidden(true, Parts.SIDEBAR_PART); + } + }).catch(onUnexpectedError); + }, })); this._register(this.onDidChangeBodyVisibility(visible => sessionsControl.setVisible(visible))); diff --git a/src/vs/sessions/sessions.web.main.ts b/src/vs/sessions/sessions.web.main.ts index 4d72a64d17267..0887e25338ab6 100644 --- a/src/vs/sessions/sessions.web.main.ts +++ b/src/vs/sessions/sessions.web.main.ts @@ -103,6 +103,8 @@ import { IRemoteAgentHostService } from '../platform/agentHost/common/remoteAgen import { RemoteAgentHostService } from '../platform/agentHost/browser/remoteAgentHostServiceImpl.js'; import { ISSHRemoteAgentHostService } from '../platform/agentHost/common/sshRemoteAgentHost.js'; import { NullSSHRemoteAgentHostService } from '../platform/agentHost/browser/nullSshRemoteAgentHostService.js'; +import { IAgentHostService } from '../platform/agentHost/common/agentService.js'; +import { NullAgentHostService } from '../platform/agentHost/browser/nullAgentHostService.js'; registerSingleton(IWorkbenchExtensionManagementService, ExtensionManagementService, InstantiationType.Delayed); registerSingleton(IAccessibilityService, AccessibilityService, InstantiationType.Delayed); @@ -124,6 +126,7 @@ registerSingleton(ISharedWebContentExtractorService, NullSharedWebContentExtract registerSingleton(IMcpGalleryManifestService, WorkbenchMcpGalleryManifestService, InstantiationType.Delayed); registerSingleton(IRemoteAgentHostService, RemoteAgentHostService, InstantiationType.Delayed); registerSingleton(ISSHRemoteAgentHostService, NullSSHRemoteAgentHostService, InstantiationType.Delayed); +registerSingleton(IAgentHostService, NullAgentHostService, InstantiationType.Delayed); //#endregion @@ -143,6 +146,9 @@ import './contrib/remoteAgentHost/browser/webTunnelAgentHostService.contribution // Tunnel agent host โ€” reconciles discovered tunnels into session providers import './contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.js'; +// Remote agent host terminal profiles โ€” registers terminal profiles for connected agent hosts +import './contrib/remoteAgentHost/browser/remoteAgentHostTerminal.contribution.js'; + // Remote agent host session provider โ€” discovers agents and registers sessions import './contrib/remoteAgentHost/browser/remoteAgentHost.contribution.js'; import './contrib/remoteAgentHost/browser/remoteAgentHostActions.js'; diff --git a/src/vs/workbench/contrib/terminal/browser/terminalProfileService.ts b/src/vs/workbench/contrib/terminal/browser/terminalProfileService.ts index eb1f657204bb7..d8a91110c34db 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalProfileService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalProfileService.ts @@ -191,6 +191,14 @@ export class TerminalProfileService extends Disposable implements ITerminalProfi } private async _detectProfiles(includeDetectedProfiles?: boolean): Promise { + // On web without a pty host, getBackend() waits forever for a backend + // that will never register. Check synchronously first to avoid hanging. + if (isWeb && !this._environmentService.remoteAuthority) { + const hasAnyBackend = [...this._terminalInstanceService.getRegisteredBackends()].length > 0; + if (!hasAnyBackend) { + return this._availableProfiles || []; + } + } const primaryBackend = await this._terminalInstanceService.getBackend(this._environmentService.remoteAuthority); if (!primaryBackend) { return this._availableProfiles || []; diff --git a/src/vs/workbench/contrib/terminal/browser/terminalService.ts b/src/vs/workbench/contrib/terminal/browser/terminalService.ts index c6b1bca471d06..e3caacf1ffa91 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalService.ts @@ -1041,6 +1041,31 @@ export class TerminalService extends Disposable implements ITerminalService { } if (!shellLaunchConfig.customPtyImplementation && !this.isProcessSupportRegistered) { + const resolvedLocation = await this.resolveLocation(options?.location); + let location: TerminalLocation | { viewColumn: number; preserveState?: boolean } | { splitActiveTerminal: boolean } | undefined; + if (splitActiveTerminal) { + location = resolvedLocation === TerminalLocation.Editor ? { viewColumn: SIDE_GROUP } : { splitActiveTerminal: true }; + } else { + location = typeof options?.location === 'object' && hasKey(options.location, { viewColumn: true }) ? options.location : resolvedLocation; + } + const instanceHost = resolvedLocation === TerminalLocation.Editor ? this._terminalEditorService : this._terminalGroupService; + for (const fallbackProfile of this._terminalProfileService.contributedProfiles) { + const instanceCount = instanceHost.instances.length; + await this.createContributedTerminalProfile(fallbackProfile.extensionIdentifier, fallbackProfile.id, { + icon: fallbackProfile.icon, + color: fallbackProfile.color, + location, + cwd: shellLaunchConfig.cwd, + titleTemplate: fallbackProfile.titleTemplate, + }); + const instance = instanceHost.instances[instanceCount]; + if (!instance) { + continue; + } + await instance.focusWhenReady(); + this._terminalHasBeenCreated.set(true); + return instance; + } throw new Error('Could not create terminal when process support is not registered'); } From c58f1a7bd0b1d897dc674c024b46f13656bc5dac Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:17:13 -0700 Subject: [PATCH 29/56] Close customizations editor and auto-send chat on troubleshoot (#310845) When using the Troubleshoot action from the Agent Customizations UI: - Close any open Agent Customizations editors before opening chat - Send the troubleshoot chat automatically (isPartialQuery: false) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aiCustomizationManagement.contribution.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index e9a0fe52a9b48..e99c916b65a80 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -27,7 +27,7 @@ import { Registry } from '../../../../../platform/registry/common/platform.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../../browser/editor.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; -import { EditorExtensions, IEditorFactoryRegistry, IEditorSerializer } from '../../../../common/editor.js'; +import { EditorExtensions, EditorsOrder, IEditorFactoryRegistry, IEditorSerializer } from '../../../../common/editor.js'; import { EditorInput } from '../../../../common/editor/editorInput.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; @@ -272,14 +272,23 @@ registerAction2(class extends Action2 { } async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { const commandService = accessor.get(ICommandService); + const editorService = accessor.get(IEditorService); const rawName = extractName(context); const displayName = rawName?.replace(/\.md$/i, ''); const query = displayName - ? `/troubleshoot ${displayName} ` - : '/troubleshoot '; + ? `/troubleshoot ${displayName}` + : '/troubleshoot'; + + // Close any open Agent Customizations editors before sending the chat. + const customizationEditors = editorService.getEditors(EditorsOrder.SEQUENTIAL) + .filter(({ editor }) => editor instanceof AICustomizationManagementEditorInput); + if (customizationEditors.length) { + await editorService.closeEditors(customizationEditors); + } + await commandService.executeCommand('workbench.action.chat.open', { query, - isPartialQuery: true, + isPartialQuery: false, }); } }); From b0adc5d2b29b304e59173cf91f16e0354cd05dec Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Thu, 16 Apr 2026 17:17:26 -0400 Subject: [PATCH 30/56] Handle showing weekly and session rate limit data (#310836) * Handle showing weekly and session rate limit data Co-authored-by: Copilot * Fix error Co-authored-by: Copilot * Update extensions/copilot/src/extension/conversation/vscode-node/chatParticipants.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix casing * Update verbiage + clear stale Co-authored-by: Copilot --------- Co-authored-by: Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../vscode-node/chatParticipants.ts | 23 ++++ .../platform/chat/common/chatQuotaService.ts | 9 +- .../chat/common/chatQuotaServiceImpl.ts | 102 ++++++++++++++---- .../src/platform/chat/common/commonTypes.ts | 4 +- 4 files changed, 116 insertions(+), 22 deletions(-) diff --git a/extensions/copilot/src/extension/conversation/vscode-node/chatParticipants.ts b/extensions/copilot/src/extension/conversation/vscode-node/chatParticipants.ts index 144ed9a8e1afb..54c0a52ac01da 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/chatParticipants.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/chatParticipants.ts @@ -269,6 +269,29 @@ Learn more about [GitHub Copilot](https://docs.github.com/copilot/using-github-c return result; } finally { + const rateLimitWarning = this._chatQuotaService.consumeRateLimitWarning(); + if (rateLimitWarning) { + const resetDate = rateLimitWarning.resetDate; + const now = new Date(); + const includeYear = resetDate.getFullYear() !== now.getFullYear(); + const dateStr = new Intl.DateTimeFormat(undefined, includeYear + ? { month: 'long', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' } + : { month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit' } + ).format(resetDate); + stream.warning(new vscode.MarkdownString( + rateLimitWarning.type === 'session' + ? vscode.l10n.t({ + message: "You've used {0}% of your session rate limit. Your session rate limit will reset on {1}. [Learn More]({2})", + args: [rateLimitWarning.percentUsed, dateStr, 'https://aka.ms/github-copilot-rate-limit-error'], + comment: [`{Locked=']({'}`] + }) + : vscode.l10n.t({ + message: "You've used {0}% of your weekly rate limit. Your weekly rate limit will reset on {1}. [Learn More]({2})", + args: [rateLimitWarning.percentUsed, dateStr, 'https://aka.ms/github-copilot-rate-limit-error'], + comment: [`{Locked=']({'}`] + }) + )); + } markChatExt(request.sessionId, ChatExtPerfMark.DidHandleParticipant); clearChatExtMarks(request.sessionId); } diff --git a/extensions/copilot/src/platform/chat/common/chatQuotaService.ts b/extensions/copilot/src/platform/chat/common/chatQuotaService.ts index 8d3dc14956c1b..372e9b1a88067 100644 --- a/extensions/copilot/src/platform/chat/common/chatQuotaService.ts +++ b/extensions/copilot/src/platform/chat/common/chatQuotaService.ts @@ -45,7 +45,7 @@ export interface CopilotUserQuotaInfo { export interface IChatQuota { quota: number; - used: number; + percentRemaining: number; unlimited: boolean; overageUsed: number; overageEnabled: boolean; @@ -67,6 +67,12 @@ export interface QuotaSnapshot { export type QuotaSnapshots = Record; +export interface IRateLimitWarning { + percentUsed: number; + type: 'session' | 'weekly'; + resetDate: Date; +} + export interface IChatQuotaService { readonly _serviceBrand: undefined; quotaExhausted: boolean; @@ -74,6 +80,7 @@ export interface IChatQuotaService { processQuotaHeaders(headers: IHeaders): void; processQuotaSnapshots(snapshots: QuotaSnapshots): void; clearQuota(): void; + consumeRateLimitWarning(): IRateLimitWarning | undefined; } export const IChatQuotaService = createServiceIdentifier('IChatQuotaService'); diff --git a/extensions/copilot/src/platform/chat/common/chatQuotaServiceImpl.ts b/extensions/copilot/src/platform/chat/common/chatQuotaServiceImpl.ts index 860b78e4211db..52fbead0e3608 100644 --- a/extensions/copilot/src/platform/chat/common/chatQuotaServiceImpl.ts +++ b/extensions/copilot/src/platform/chat/common/chatQuotaServiceImpl.ts @@ -6,14 +6,20 @@ import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { IAuthenticationService } from '../../authentication/common/authentication'; import { IHeaders } from '../../networking/common/fetcherService'; -import { CopilotUserQuotaInfo, IChatQuota, IChatQuotaService, QuotaSnapshots } from './chatQuotaService'; +import { CopilotUserQuotaInfo, IChatQuota, IChatQuotaService, IRateLimitWarning, QuotaSnapshots } from './chatQuotaService'; export class ChatQuotaService extends Disposable implements IChatQuotaService { declare readonly _serviceBrand: undefined; + private static readonly _RATE_LIMIT_THRESHOLDS = [50, 75, 90, 95]; private _quotaInfo: IChatQuota | undefined; + private _rateLimitInfo: { session: IChatQuota | undefined; weekly: IChatQuota | undefined }; + private readonly _shownSessionThresholds = new Set(); + private readonly _shownWeeklyThresholds = new Set(); + private _pendingRateLimitWarning: IRateLimitWarning | undefined; constructor(@IAuthenticationService private readonly _authService: IAuthenticationService) { super(); + this._rateLimitInfo = { session: undefined, weekly: undefined }; this._register(this._authService.onDidAuthenticationChange(() => { this.processUserInfoQuotaSnapshot(this._authService.copilotToken?.quotaInfo); })); @@ -23,7 +29,7 @@ export class ChatQuotaService extends Disposable implements IChatQuotaService { if (!this._quotaInfo) { return false; } - return this._quotaInfo.used >= this._quotaInfo.quota && !this._quotaInfo.overageEnabled && !this._quotaInfo.unlimited; + return this._quotaInfo.percentRemaining <= 0 && !this._quotaInfo.overageEnabled && !this._quotaInfo.unlimited; } get overagesEnabled(): boolean { @@ -37,15 +43,10 @@ export class ChatQuotaService extends Disposable implements IChatQuotaService { this._quotaInfo = undefined; } - processQuotaHeaders(headers: IHeaders): void { - const quotaHeader = this._authService.copilotToken?.isFreeUser ? headers.get('x-quota-snapshot-chat') : headers.get('x-quota-snapshot-premium_models') || headers.get('x-quota-snapshot-premium_interactions'); - if (!quotaHeader) { - return; - } - + private _processHeaderValue(header: string): IChatQuota | undefined { try { // Parse URL encoded string into key-value pairs - const params = new URLSearchParams(quotaHeader); + const params = new URLSearchParams(header); // Extract values with fallbacks to ensure type safety const entitlement = parseInt(params.get('ent') || '0', 10); @@ -63,21 +64,38 @@ export class ChatQuotaService extends Disposable implements IChatQuotaService { resetDate.setMonth(resetDate.getMonth() + 1); } - // Calculate used based on entitlement and remaining - const used = Math.max(0, entitlement * (1 - percentRemaining / 100)); - - // Update quota info - this._quotaInfo = { + return { quota: entitlement, unlimited: entitlement === -1, - used, + percentRemaining, overageUsed, overageEnabled, resetDate }; } catch (error) { console.error('Failed to parse quota header', error); + return undefined; + } + } + + + processQuotaHeaders(headers: IHeaders): void { + const quotaHeader = this._authService.copilotToken?.isFreeUser ? headers.get('x-quota-snapshot-chat') : headers.get('x-quota-snapshot-premium_models') || headers.get('x-quota-snapshot-premium_interactions'); + if (!quotaHeader) { + return; + } + const quotaInfo = this._processHeaderValue(quotaHeader); + if (!quotaInfo) { + return; } + this._quotaInfo = quotaInfo; + const sessionRateLimitHeader = headers.get('x-usage-ratelimit-session'); + const weeklyRateLimitHeader = headers.get('x-usage-ratelimit-weekly'); + this._rateLimitInfo.session = sessionRateLimitHeader ? this._processHeaderValue(sessionRateLimitHeader) : undefined; + this._rateLimitInfo.weekly = weeklyRateLimitHeader ? this._processHeaderValue(weeklyRateLimitHeader) : undefined; + this._clearStaleThresholds(this._rateLimitInfo.session, this._shownSessionThresholds); + this._clearStaleThresholds(this._rateLimitInfo.weekly, this._shownWeeklyThresholds); + this._pendingRateLimitWarning = this._computeRateLimitWarning() ?? this._pendingRateLimitWarning; } processQuotaSnapshots(snapshots: QuotaSnapshots): void { @@ -91,12 +109,11 @@ export class ChatQuotaService extends Disposable implements IChatQuotaService { try { const entitlement = parseInt(snapshot.entitlement, 10); const resetDate = snapshot.reset_date ? new Date(snapshot.reset_date) : (() => { const d = new Date(); d.setMonth(d.getMonth() + 1); return d; })(); - const used = Math.max(0, entitlement * (1 - snapshot.percent_remaining / 100)); this._quotaInfo = { quota: entitlement, unlimited: entitlement === -1, - used, + percentRemaining: snapshot.percent_remaining, overageUsed: snapshot.overage_count, overageEnabled: snapshot.overage_permitted, resetDate @@ -106,6 +123,53 @@ export class ChatQuotaService extends Disposable implements IChatQuotaService { } } + consumeRateLimitWarning(): IRateLimitWarning | undefined { + const warning = this._pendingRateLimitWarning; + this._pendingRateLimitWarning = undefined; + return warning; + } + + private _computeRateLimitWarning(): IRateLimitWarning | undefined { + // Session rate limit takes priority over weekly + const sessionWarning = this._checkThreshold(this._rateLimitInfo.session, this._shownSessionThresholds, 'session'); + if (sessionWarning) { + return sessionWarning; + } + return this._checkThreshold(this._rateLimitInfo.weekly, this._shownWeeklyThresholds, 'weekly'); + } + + private _clearStaleThresholds(info: IChatQuota | undefined, shownThresholds: Set): void { + if (!info) { + shownThresholds.clear(); + return; + } + const percentUsed = 100 - info.percentRemaining; + for (const threshold of shownThresholds) { + if (percentUsed < threshold) { + shownThresholds.delete(threshold); + } + } + } + + private _checkThreshold(info: IChatQuota | undefined, shownThresholds: Set, type: 'session' | 'weekly'): IRateLimitWarning | undefined { + if (!info || info.unlimited) { + return undefined; + } + const percentUsed = 100 - info.percentRemaining; + // Walk thresholds highest-first so we report the most severe crossed threshold + for (let i = ChatQuotaService._RATE_LIMIT_THRESHOLDS.length - 1; i >= 0; i--) { + const threshold = ChatQuotaService._RATE_LIMIT_THRESHOLDS[i]; + if (percentUsed >= threshold && !shownThresholds.has(threshold)) { + // Mark this and all lower thresholds as shown + for (let j = 0; j <= i; j++) { + shownThresholds.add(ChatQuotaService._RATE_LIMIT_THRESHOLDS[j]); + } + return { percentUsed: Math.round(percentUsed), type, resetDate: info.resetDate }; + } + } + return undefined; + } + private processUserInfoQuotaSnapshot(quotaInfo: CopilotUserQuotaInfo | undefined) { if (!quotaInfo || !quotaInfo.quota_snapshots || !quotaInfo.quota_reset_date) { return; @@ -116,7 +180,7 @@ export class ChatQuotaService extends Disposable implements IChatQuotaService { overageUsed: quotaInfo.quota_snapshots.premium_interactions.overage_count, quota: quotaInfo.quota_snapshots.premium_interactions.entitlement, resetDate: new Date(quotaInfo.quota_reset_date), - used: Math.max(0, quotaInfo.quota_snapshots.premium_interactions.entitlement * (1 - quotaInfo.quota_snapshots.premium_interactions.percent_remaining / 100)), + percentRemaining: quotaInfo.quota_snapshots.premium_interactions.percent_remaining, }; } -} \ No newline at end of file +} diff --git a/extensions/copilot/src/platform/chat/common/commonTypes.ts b/extensions/copilot/src/platform/chat/common/commonTypes.ts index fad49df32483f..73fe349229dd5 100644 --- a/extensions/copilot/src/platform/chat/common/commonTypes.ts +++ b/extensions/copilot/src/platform/chat/common/commonTypes.ts @@ -227,14 +227,14 @@ function getRateLimitMessage(fetchResult: ChatFetchError, copilotPlan: string | if (fetchResult.capiError?.code?.startsWith('user_global_rate_limited')) { if (copilotPlan === 'free' || copilotPlan === 'individual' || copilotPlan === 'individual_pro') { return l10n.t({ - message: 'You\'ve hit your global rate limit. Please upgrade your plan or wait {0} for your limit to reset. [Learn More]({1})', + message: 'You\'ve hit your session rate limit. Please upgrade your plan or wait {0} for your limit to reset. [Learn More]({1})', args: [retryAfterString, 'https://aka.ms/github-copilot-rate-limit-error'], comment: [`{Locked=']({'}`] }); } return l10n.t({ - message: 'You\'ve hit your global rate limit. Please wait {0} for your limit to reset. [Learn More]({1})', + message: 'You\'ve hit your session rate limit. Please wait {0} for your limit to reset. [Learn More]({1})', args: [retryAfterString, 'https://aka.ms/github-copilot-rate-limit-error'], comment: [`{Locked=']({'}`] }); From ea03e197c3e6657616de01035ffa6faccde2168f Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega <48293249+osortega@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:26:27 -0700 Subject: [PATCH 31/56] Agents web: Changes view support (#310843) * Agents web: Changes view support * Update src/vs/sessions/contrib/agentFeedback/browser/nullAgentFeedbackService.contribution.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../nullAgentFeedbackService.contribution.ts | 52 +++++++++++++++++++ .../contrib/changes/browser/changesView.ts | 22 +++++--- .../changes/browser/changesViewModel.ts | 3 +- src/vs/sessions/sessions.web.main.ts | 3 ++ 4 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 src/vs/sessions/contrib/agentFeedback/browser/nullAgentFeedbackService.contribution.ts diff --git a/src/vs/sessions/contrib/agentFeedback/browser/nullAgentFeedbackService.contribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/nullAgentFeedbackService.contribution.ts new file mode 100644 index 0000000000000..e2207f1825adc --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/browser/nullAgentFeedbackService.contribution.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IRange } from '../../../../editor/common/core/range.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { IAgentFeedback, IAgentFeedbackChangeEvent, IAgentFeedbackNavigationBearing, IAgentFeedbackService, INavigableSessionComment } from './agentFeedbackService.js'; +import { IAgentFeedbackContext } from './agentFeedbackEditorUtils.js'; +import { ICodeReviewSuggestion } from '../../codeReview/browser/codeReviewService.js'; + +/** + * No-op implementation of {@link IAgentFeedbackService} used on web, + * where the full agent feedback UI (editor overlay, hover, attachments) + * is not wired up. The changes view model still depends on the service + * being registered, so we expose a service that reports no feedback. + */ +class NullAgentFeedbackService extends Disposable implements IAgentFeedbackService { + + declare readonly _serviceBrand: undefined; + + readonly onDidChangeFeedback = this._register(new Emitter()).event; + readonly onDidChangeNavigation = this._register(new Emitter()).event; + + addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, _suggestion?: ICodeReviewSuggestion, _context?: IAgentFeedbackContext, _sourcePRReviewCommentId?: string): IAgentFeedback { + return { + id: '', + text, + resourceUri, + range, + sessionResource, + }; + } + + removeFeedback(_sessionResource: URI, _feedbackId: string): void { } + updateFeedback(_sessionResource: URI, _feedbackId: string, _text: string): void { } + getFeedback(_sessionResource: URI): readonly IAgentFeedback[] { return []; } + getMostRecentSessionForResource(_resourceUri: URI): URI | undefined { return undefined; } + async revealFeedback(_sessionResource: URI, _feedbackId: string): Promise { } + async revealSessionComment(): Promise { } + getNextFeedback(): IAgentFeedback | undefined { return undefined; } + getNextNavigableItem(): T | undefined { return undefined; } + setNavigationAnchor(): void { } + getNavigationBearing(_sessionResource: URI): IAgentFeedbackNavigationBearing { return { activeIdx: -1, totalCount: 0 }; } + clearFeedback(): void { } + async addFeedbackAndSubmit(): Promise { } +} + +registerSingleton(IAgentFeedbackService, NullAgentFeedbackService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 881475659fbc8..1073c8c24222a 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -6,6 +6,7 @@ import './media/changesView.css'; import * as dom from '../../../../base/browser/dom.js'; import { Schemas } from '../../../../base/common/network.js'; +import { isWeb } from '../../../../base/common/platform.js'; import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; import { IObjectTreeElement, ITreeSorter } from '../../../../base/browser/ui/tree/tree.js'; @@ -1219,7 +1220,7 @@ class ChangesPickerActionItem extends ActionWidgetDropdownActionViewItem { const branchName = state?.branchName; const baseBranchName = state?.baseBranchName; - return [ + const actions = [ { ...action, id: 'chatEditing.versionsBranchChanges', @@ -1237,7 +1238,10 @@ class ChangesPickerActionItem extends ActionWidgetDropdownActionViewItem { } }, }, - { + ]; + + if (!isWeb) { + actions.push({ ...action, id: 'chatEditing.versionsUncommittedChanges', label: localize('chatEditing.versionsUncommittedChanges', 'Uncommitted Changes'), @@ -1252,8 +1256,8 @@ class ChangesPickerActionItem extends ActionWidgetDropdownActionViewItem { this.renderLabel(this.element); } }, - }, - { + }); + actions.push({ ...action, id: 'chatEditing.versionsAllChanges', label: localize('chatEditing.versionsAllChanges', 'All Changes'), @@ -1270,8 +1274,8 @@ class ChangesPickerActionItem extends ActionWidgetDropdownActionViewItem { this.renderLabel(this.element); } }, - }, - { + }); + actions.push({ ...action, id: 'chatEditing.versionsLastTurnChanges', label: localize('chatEditing.versionsLastTurnChanges', "Last Turn's Changes"), @@ -1288,8 +1292,10 @@ class ChangesPickerActionItem extends ActionWidgetDropdownActionViewItem { this.renderLabel(this.element); } }, - }, - ]; + }); + } + + return actions; }, }; diff --git a/src/vs/sessions/contrib/changes/browser/changesViewModel.ts b/src/vs/sessions/contrib/changes/browser/changesViewModel.ts index 9b348cff419e5..bc3919926b9ab 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewModel.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewModel.ts @@ -8,6 +8,7 @@ import { arrayEqualsC, structuralEquals } from '../../../../base/common/equals.j import { Iterable } from '../../../../base/common/iterator.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { derived, derivedOpts, IObservable, IObservableWithChange, ISettableObservable, runOnChange, observableValue, observableSignalFromEvent, constObservable, ObservablePromise, derivedObservableWithCache } from '../../../../base/common/observable.js'; +import { isWeb } from '../../../../base/common/platform.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -320,7 +321,7 @@ export class ChangesViewModel extends Disposable { equalsFn: arrayEqualsC() }, reader => { const hasGitRepository = this.activeSessionHasGitRepositoryObs.read(reader); - if (!hasGitRepository) { + if (!hasGitRepository && !isWeb) { return []; } diff --git a/src/vs/sessions/sessions.web.main.ts b/src/vs/sessions/sessions.web.main.ts index 0887e25338ab6..5a5056d6bc0b7 100644 --- a/src/vs/sessions/sessions.web.main.ts +++ b/src/vs/sessions/sessions.web.main.ts @@ -152,6 +152,9 @@ import './contrib/remoteAgentHost/browser/remoteAgentHostTerminal.contribution.j // Remote agent host session provider โ€” discovers agents and registers sessions import './contrib/remoteAgentHost/browser/remoteAgentHost.contribution.js'; import './contrib/remoteAgentHost/browser/remoteAgentHostActions.js'; + +// TODO: support agent feedback in web +import './contrib/agentFeedback/browser/nullAgentFeedbackService.contribution.js'; import '../workbench/contrib/webview/browser/webview.web.contribution.js'; import '../workbench/contrib/extensions/browser/extensions.web.contribution.js'; import '../workbench/contrib/terminal/browser/terminal.web.contribution.js'; From afe454b6b3d162046f6b6aa9c29414222fcbd95f Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 16 Apr 2026 17:30:10 -0400 Subject: [PATCH 32/56] Use `System notification` vs `Steering` for system steering messages (#310848) fixes #308549 --- .../contrib/chat/browser/widget/chatListRenderer.ts | 9 +++++++-- .../workbench/contrib/chat/common/model/chatViewModel.ts | 4 +++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index da01c2f6bff3f..4d95908f411c1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -907,8 +907,13 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer 0) { - items.push({ kind: 'pendingDivider', id: 'pending-divider-steering', sessionResource: this._model.sessionResource, isComplete: true, dividerKind: ChatRequestQueueKind.Steering, currentRenderedHeight: undefined }); + const isSystemInitiated = steeringRequests.every(p => p.request.isSystemInitiated); + items.push({ kind: 'pendingDivider', id: 'pending-divider-steering', sessionResource: this._model.sessionResource, isComplete: true, dividerKind: ChatRequestQueueKind.Steering, isSystemInitiated, currentRenderedHeight: undefined }); for (const pending of steeringRequests) { const requestVM = this.instantiationService.createInstance(ChatRequestViewModel, pending.request, pending.kind); items.push(requestVM); From 96c09fc27d3f3f38abe68782b4ed494894d06298 Mon Sep 17 00:00:00 2001 From: Peng Lyu Date: Thu, 16 Apr 2026 14:41:54 -0700 Subject: [PATCH 33/56] fix bg for agent sidebar mobile (#310849) --- src/vs/sessions/browser/media/style.css | 4 ++++ src/vs/sessions/browser/workbench.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 93eb22aa74e24..82b59a09fd47b 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -100,6 +100,10 @@ background: transparent !important; } +.agent-sessions-workbench .part.sidebar.mobile-overlay-sidebar { + background: var(--vscode-agents-background) !important; +} + .agent-sessions-workbench .part.chatbar { margin: 0 10px 0px 10px; background: var(--part-background); diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index cea02a1ec17fb..6e587d89bbfa7 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -928,6 +928,7 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { } if (!this.isMobileWebLayout() || !this.partVisibility.sidebar) { + sidebarContainer.classList.remove('mobile-overlay-sidebar'); sidebarContainer.style.position = ''; sidebarContainer.style.top = ''; sidebarContainer.style.left = ''; @@ -940,6 +941,7 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { const titleBarHeight = this.workbenchGrid.getViewSize(this.titleBarPartView).height; const mobileWidth = this._mainContainerDimension.width; const mobileHeight = Math.max(0, this._mainContainerDimension.height - titleBarHeight); + sidebarContainer.classList.add('mobile-overlay-sidebar'); sidebarContainer.style.position = 'fixed'; sidebarContainer.style.top = `${titleBarHeight}px`; sidebarContainer.style.left = '0'; From 437414d411eb21edd00caa6c0a45891b53add90d Mon Sep 17 00:00:00 2001 From: Maruthan G <113752568+maruthang@users.noreply.github.com> Date: Fri, 17 Apr 2026 03:18:22 +0530 Subject: [PATCH 34/56] fix(chat): guard renderWelcomeViewContentIfNeeded against undisposed input part (#310356) (#310822) --- src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 0c1fea3afa6d7..719d50a08e8d0 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -995,6 +995,13 @@ export class ChatWidget extends Disposable implements IChatWidget { return; } + // The input part may not be rendered yet (or may have been disposed) when this is + // called from async flows such as `lockToCodingAgent` / `unlockFromCodingAgent` that + // run after `showModel` resolves. Bail out to avoid dereferencing an undefined input. + if (!this.inputPartDisposable.value) { + return; + } + this._isRenderingWelcome = true; try { if (this.viewOptions.renderStyle === 'compact' || this.viewOptions.renderStyle === 'minimal' || this.lifecycleService.willShutdown) { From ec963bb72be6a7f5b9c1c08bc216301fde0a35fa Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:00:46 -0700 Subject: [PATCH 35/56] agents: restore built-in skills and merge them into the provider-based customizations UI (#310824) * agents: restore built-in skills and merge into provider-based customizations UI Built-in skills bundled under `vs/sessions/skills/` stopped appearing in the Agents window's AI customization UI after PR #310000 deleted AgenticPromptsService. The CLI extension's itemProvider was also re-discovering the bundled copies from `out/vs/sessions/skills/` and tagging them as user-storage, which placed them in the wrong group. Fix in two parts: 1. Re-introduce a minimal AgenticPromptsService (sessions layer) that discovers SKILL.md files under FileAccess.asFileUri('vs/sessions/skills'), parses each via the public parseNew(), validates name === folderName, and exposes them via findAgentSkills(), listPromptFiles(), and listPromptFilesForStorage(skill, BUILTIN_STORAGE). Registered as IPromptsService for the Agents window. 2. In ProviderCustomizationItemSource.fetchItems(skill), merge the authoritative built-in entries with the harness's itemProvider output: - dedup provider items by URI against built-ins (drops the out/-tree duplicates) - append built-ins with groupKey: BUILTIN_STORAGE so the UI renders them under the Built-in group - apply the existing 'UI Integration' badge via IAICustomizationWorkspaceService.getSkillUIIntegrations() Safe for regular workbench: base PromptsService throws on BUILTIN_STORAGE and the merge step catches and returns items unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agents: preserve BUILTIN_STORAGE on built-in skills so edit/save-as-copy flow activates The management editor's 'edit built-in and save as user/workspace copy' flow is gated on item.storage === BUILTIN_STORAGE. The normalizer was unconditionally rewriting that to PromptsStorage.extension when groupKey === BUILTIN_STORAGE (historical: built-ins came from the extension contribution pipeline). For provider-supplied built-ins, preserve BUILTIN_STORAGE on the normalized item so the editor becomes editable and the save handler prompts for a workspace/user target on save. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agents: hide built-in skill when a workspace/user override with the same name exists Move the built-in skill merge to run on normalized list items so we can filter out built-ins whose name is already represented by a PromptsStorage.local or PromptsStorage.user entry. The built-in row disappears as soon as the user saves a workspace/user copy via the save ' flow.as Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agents: address Copilot review feedback - Respect getDisabledPromptFiles when merging built-in skills into the UI so a user-disabled built-in shows disabled (not enabled). - Restrict the 'existing names' override set in findAgentSkills to user/workspace skills only, so an extension-contributed skill with the same name no longer suppresses the built-in. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/chat.contribution.ts | 3 + .../contrib/chat/browser/promptsService.ts | 164 ++++++++++++++++++ .../aiCustomizationItemSource.ts | 97 ++++++++++- 3 files changed, 260 insertions(+), 4 deletions(-) create mode 100644 src/vs/sessions/contrib/chat/browser/promptsService.ts diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index d6641122c790d..3f68a9c0ce273 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -28,6 +28,8 @@ import './nullChatTipService.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ISessionsConfigurationService, SessionsConfigurationService } from './sessionsConfigurationService.js'; +import { AgenticPromptsService } from './promptsService.js'; +import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { ICustomizationHarnessService } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { SessionsAICustomizationWorkspaceService } from './aiCustomizationWorkspaceService.js'; @@ -308,6 +310,7 @@ registerWorkbenchContribution2(RegisterChatViewContainerContribution.ID, Registe registerWorkbenchContribution2(RunScriptContribution.ID, RunScriptContribution, WorkbenchPhase.AfterRestored); // register services +registerSingleton(IPromptsService, AgenticPromptsService, InstantiationType.Delayed); registerSingleton(ISessionsConfigurationService, SessionsConfigurationService, InstantiationType.Delayed); registerSingleton(IAICustomizationWorkspaceService, SessionsAICustomizationWorkspaceService, InstantiationType.Delayed); registerSingleton(ICustomizationHarnessService, SessionsCustomizationHarnessService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/chat/browser/promptsService.ts b/src/vs/sessions/contrib/chat/browser/promptsService.ts new file mode 100644 index 0000000000000..89c92e6a6163e --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/promptsService.ts @@ -0,0 +1,164 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { FileAccess } from '../../../../base/common/network.js'; +import { basename, dirname, joinPath } from '../../../../base/common/resources.js'; +import { SKILL_FILENAME } from '../../../../workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { IAgentSkill, IPromptPath, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { PromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.js'; +import { BUILTIN_STORAGE, IBuiltinPromptPath } from '../common/builtinPromptsStorage.js'; + +/** URI root for built-in skills bundled with the Agents app. */ +export const BUILTIN_SKILLS_URI = FileAccess.asFileUri('vs/sessions/skills'); + +/** + * Sessions-specific PromptsService that additionally discovers built-in skills + * bundled at `vs/sessions/skills/{folder}/SKILL.md`. + * + * Built-in skills are merged into `findAgentSkills()` and exposed via + * `listPromptFilesForStorage(skill, BUILTIN_STORAGE)` so that the existing + * AI customization UI (groups, badges, overrides) picks them up naturally. + * + * User/workspace skills with the same folder name take precedence (built-ins + * are appended last and filtered when overridden). + */ +export class AgenticPromptsService extends PromptsService { + + private _builtinSkillsCache: Promise | undefined; + + private async getBuiltinSkills(): Promise { + if (!this._builtinSkillsCache) { + this._builtinSkillsCache = this.discoverBuiltinSkills(); + } + return this._builtinSkillsCache; + } + + private async discoverBuiltinSkills(): Promise { + try { + const stat = await this.fileService.resolve(BUILTIN_SKILLS_URI); + if (!stat.children) { + return []; + } + + const skills: IAgentSkill[] = []; + for (const child of stat.children) { + if (!child.isDirectory) { + continue; + } + const skillFileUri = joinPath(child.resource, SKILL_FILENAME); + try { + const parsed = await this.parseNew(skillFileUri, CancellationToken.None); + const rawName = parsed.header?.name; + const rawDescription = parsed.header?.description; + if (!rawName || !rawDescription) { + continue; + } + const name = sanitizeSkillText(rawName, 64); + const description = sanitizeSkillText(rawDescription, 1024); + const folderName = basename(child.resource); + if (name !== folderName) { + continue; + } + skills.push({ + uri: skillFileUri, + storage: BUILTIN_STORAGE as PromptsStorage, + name, + description, + disableModelInvocation: parsed.header?.disableModelInvocation === true, + userInvocable: parsed.header?.userInvocable !== false, + }); + } catch (e) { + this.logger.warn(`[AgenticPromptsService] Failed to parse built-in skill: ${skillFileUri}`, e instanceof Error ? e.message : String(e)); + } + } + return skills; + } catch { + return []; + } + } + + private async getBuiltinSkillPaths(): Promise { + const skills = await this.getBuiltinSkills(); + return skills.map(s => ({ + uri: s.uri, + storage: BUILTIN_STORAGE, + type: PromptsType.skill, + name: s.name, + description: s.description, + })); + } + + public override async findAgentSkills(token: CancellationToken): Promise { + const baseResult = await super.findAgentSkills(token); + if (baseResult === undefined) { + return undefined; + } + + const builtinSkills = await this.getBuiltinSkills(); + if (builtinSkills.length === 0) { + return baseResult; + } + + const existingNames = new Set( + baseResult + .filter(s => s.storage === PromptsStorage.local || s.storage === PromptsStorage.user) + .map(s => s.name) + ); + const disabledSkills = this.getDisabledPromptFiles(PromptsType.skill); + const nonOverridden = builtinSkills.filter(s => !existingNames.has(s.name) && !disabledSkills.has(s.uri)); + if (nonOverridden.length === 0) { + return baseResult; + } + + return [...baseResult, ...nonOverridden]; + } + + public override async listPromptFiles(type: PromptsType, token: CancellationToken): Promise { + const baseResults = await super.listPromptFiles(type, token); + + if (type !== PromptsType.skill) { + return baseResults; + } + + const builtinItems = await this.getBuiltinSkillPaths(); + if (builtinItems.length === 0) { + return baseResults; + } + + // Filter out built-ins overridden by user/workspace skills of the same folder name. + const overriddenNames = new Set(); + for (const p of baseResults) { + if (p.storage === PromptsStorage.local || p.storage === PromptsStorage.user) { + overriddenNames.add(basename(dirname(p.uri))); + } + } + const nonOverridden = builtinItems.filter(p => !overriddenNames.has(basename(dirname(p.uri)))); + + // Built-in items use BUILTIN_STORAGE ('builtin') which is not in the core + // IPromptPath union but is handled by the sessions UI layer. + return [...baseResults, ...nonOverridden] as readonly IPromptPath[]; + } + + public override async listPromptFilesForStorage(type: PromptsType, storage: PromptsStorage, token: CancellationToken): Promise { + if ((storage as PromptsStorage | typeof BUILTIN_STORAGE) === BUILTIN_STORAGE) { + if (type === PromptsType.skill) { + return this.getBuiltinSkillPaths() as Promise; + } + return []; + } + return super.listPromptFilesForStorage(type, storage, token); + } +} + +/** + * Strips XML tags and truncates to the given max length. + * Matches the sanitization applied by PromptsService for other skill sources. + */ +function sanitizeSkillText(text: string, maxLength: number): string { + const sanitized = text.replace(/<[^>]+>/g, ''); + return sanitized.length > maxLength ? sanitized.substring(0, maxLength) : sanitized; +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index e5cf69296a66b..f394aa0176355 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -10,7 +10,7 @@ import { parse as parseJSONC } from '../../../../../base/common/json.js'; import { ResourceMap } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; import { OS } from '../../../../../base/common/platform.js'; -import { basename, isEqualOrParent } from '../../../../../base/common/resources.js'; +import { basename, dirname, isEqualOrParent } from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; @@ -239,8 +239,16 @@ export class AICustomizationItemNormalizer { } switch (item.groupKey) { - case BUILTIN_STORAGE: - return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true, extensionLabel }; + case BUILTIN_STORAGE: { + // Preserve a provider-supplied BUILTIN_STORAGE so the management + // editor's "edit built-in and save as user/workspace copy" flow + // activates. Otherwise fall back to extension storage (the + // historical source of built-in items). + const builtinStorage = (item.storage as PromptsStorage | typeof BUILTIN_STORAGE | undefined) === BUILTIN_STORAGE + ? (BUILTIN_STORAGE as unknown as PromptsStorage) + : PromptsStorage.extension; + return { storage: builtinStorage, groupKey: BUILTIN_STORAGE, isBuiltin: true, extensionLabel }; + } default: return { storage, groupKey: item.groupKey, extensionLabel }; } @@ -364,7 +372,88 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour providerItems = await this.addSkillDescriptionFallbacks(providerItems); } - return this.itemNormalizer.normalizeItems(providerItems, promptType); + const normalized = this.itemNormalizer.normalizeItems(providerItems, promptType); + if (promptType === PromptsType.skill) { + return this.mergeBuiltinSkills(normalized, promptType); + } + return normalized; + } + + /** + * Merges built-in skills (bundled with the app under `vs/sessions/skills/`) + * into the provider's items. The provider may re-discover the bundled + * copies when scanning disk โ€” those duplicates are dropped (deduped by + * URI) and replaced with the authoritative built-in entry tagged + * `groupKey: BUILTIN_STORAGE` so the UI renders them in the "Built-in" + * group. User-authored overrides (different URI, same name) are preserved. + * + * A workbench that uses the base `PromptsService` will throw on + * `BUILTIN_STORAGE` โ€” we catch and return the items unchanged in that case. + */ + private async mergeBuiltinSkills(items: readonly IAICustomizationListItem[], promptType: PromptsType): Promise { + let builtinPaths: readonly { uri: URI; name?: string; description?: string }[] = []; + try { + builtinPaths = await this.promptsService.listPromptFilesForStorage(PromptsType.skill, BUILTIN_STORAGE as unknown as PromptsStorage, CancellationToken.None); + } catch { + return [...items]; + } + if (builtinPaths.length === 0) { + return [...items]; + } + + const builtinUris = new ResourceMap(); + for (const p of builtinPaths) { + builtinUris.set(p.uri, p); + } + + // Drop provider items that are the same URI as a built-in (the provider + // re-discovered the bundled copy by scanning disk). + const deduped = items.filter(item => !builtinUris.has(item.uri)); + + const uiIntegrations = this.workspaceService.getSkillUIIntegrations(); + const uiIntegrationBadge = localize('uiIntegrationBadge', "UI Integration"); + + // Collect names of user/workspace skills so we can hide the built-in + // copy once the user has added an override at either level. + const overriddenNames = new Set(); + for (const item of deduped) { + if (item.storage === PromptsStorage.local || item.storage === PromptsStorage.user) { + if (item.name) { + overriddenNames.add(item.name); + } + } + } + + // Append authoritative built-in entries (excluding any that have been + // overridden by a workspace or user copy with the same name). + const uriUseCounts = new ResourceMap(); + for (const item of deduped) { + uriUseCounts.set(item.uri, (uriUseCounts.get(item.uri) ?? 0) + 1); + } + const appended: IAICustomizationListItem[] = []; + const disabledPromptFiles = this.promptsService.getDisabledPromptFiles(PromptsType.skill); + for (const p of builtinPaths) { + const name = p.name ?? basename(p.uri); + if (overriddenNames.has(name)) { + continue; + } + const folderName = basename(dirname(p.uri)); + const uiTooltip = uiIntegrations.get(folderName); + const builtinItem: ICustomizationItem = { + uri: p.uri, + type: PromptsType.skill, + name, + description: p.description, + storage: BUILTIN_STORAGE as unknown as PromptsStorage, + groupKey: BUILTIN_STORAGE, + enabled: !disabledPromptFiles.has(p.uri), + badge: uiTooltip ? uiIntegrationBadge : undefined, + badgeTooltip: uiTooltip, + }; + appended.push(this.itemNormalizer.normalizeItem(builtinItem, promptType, uriUseCounts)); + } + + return [...deduped, ...appended]; } private async addSkillDescriptionFallbacks(items: readonly ICustomizationItem[]): Promise { From 9319b956ab34992d39de71f7a3b7aaaf7e3887f7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:03:36 +0000 Subject: [PATCH 36/56] Bump dompurify from 3.3.2 to 3.4.0 in /extensions/mermaid-chat-features (#310377) Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.3.2 to 3.4.0. - [Release notes](https://github.com/cure53/DOMPurify/releases) - [Commits](https://github.com/cure53/DOMPurify/compare/3.3.2...3.4.0) --- updated-dependencies: - dependency-name: dompurify dependency-version: 3.4.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- extensions/mermaid-chat-features/package-lock.json | 11 ++++------- extensions/mermaid-chat-features/package.json | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/extensions/mermaid-chat-features/package-lock.json b/extensions/mermaid-chat-features/package-lock.json index 26443f16e1a21..d49d385f93a37 100644 --- a/extensions/mermaid-chat-features/package-lock.json +++ b/extensions/mermaid-chat-features/package-lock.json @@ -9,7 +9,7 @@ "version": "10.0.0", "license": "MIT", "dependencies": { - "dompurify": "^3.3.2", + "dompurify": "^3.4.0", "mermaid": "^11.12.3" }, "devDependencies": { @@ -974,13 +974,10 @@ } }, "node_modules/dompurify": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", - "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz", + "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==", "license": "(MPL-2.0 OR Apache-2.0)", - "engines": { - "node": ">=20" - }, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } diff --git a/extensions/mermaid-chat-features/package.json b/extensions/mermaid-chat-features/package.json index 551dc478460d1..3cd9b04b73e64 100644 --- a/extensions/mermaid-chat-features/package.json +++ b/extensions/mermaid-chat-features/package.json @@ -130,7 +130,7 @@ "@vscode/codicons": "^0.0.36" }, "dependencies": { - "dompurify": "^3.3.2", + "dompurify": "^3.4.0", "mermaid": "^11.12.3" }, "overrides": { From f4c8a0c8872bf1d8541d8cf1c87d559003a36525 Mon Sep 17 00:00:00 2001 From: Robo Date: Fri, 17 Apr 2026 07:04:46 +0900 Subject: [PATCH 37/56] fix: duplicate open in vscode command registration (#310852) --- .../contrib/chat/browser/chat.contribution.ts | 163 +----------------- .../chat/browser/openInVSCode.contribution.ts | 108 ++++++++++++ .../contrib/chat/browser/openInVSCodeUtils.ts | 68 ++++++++ .../openInVSCode.contribution.ts | 42 ++++- .../browser/resolveRemoteAuthority.test.ts | 2 +- src/vs/sessions/sessions.web.main.ts | 3 + 6 files changed, 214 insertions(+), 172 deletions(-) create mode 100644 src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts create mode 100644 src/vs/sessions/contrib/chat/browser/openInVSCodeUtils.ts diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index 3f68a9c0ce273..e3eb8f2a2535a 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -8,19 +8,12 @@ import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { Schemas } from '../../../../base/common/network.js'; -import { URI } from '../../../../base/common/uri.js'; -import { IOpenerService } from '../../../../platform/opener/common/opener.js'; -import { IProductService } from '../../../../platform/product/common/productService.js'; -import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { logSessionsInteraction } from '../../../common/sessionsTelemetry.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IViewContainersRegistry, IViewsRegistry, ViewContainerLocation, Extensions as ViewExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; -import { IsNewChatInSessionContext, IsNewChatSessionContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; -import { Menus } from '../../../browser/menus.js'; +import { IsNewChatInSessionContext, IsNewChatSessionContext } from '../../../common/contextkeys.js'; import { BranchChatSessionAction } from './branchChatSessionAction.js'; import { RunScriptContribution } from './runScriptAction.js'; import './nullInlineChatSessionService.js'; @@ -41,164 +34,10 @@ import { NewChatInSessionViewPane, NewChatInSessionViewId } from './newChatInSes import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { ChatViewPane } from '../../../../workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.js'; -import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; -import { CopilotCLISessionType } from '../../../services/sessions/common/session.js'; import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { SessionsChatAccessibilityHelp } from './sessionsChatAccessibilityHelp.js'; -import { AGENT_HOST_SCHEME, fromAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js'; -import { IRemoteAgentHostService, IRemoteAgentHostSSHConnection, RemoteAgentHostEntryType } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; -import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; -import { isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; -import { encodeHex, VSBuffer } from '../../../../base/common/buffer.js'; -export class OpenSessionWorktreeInVSCodeAction extends Action2 { - static readonly ID = 'chat.openSessionWorktreeInVSCode'; - - constructor() { - super({ - id: OpenSessionWorktreeInVSCodeAction.ID, - title: localize2('openInVSCode', 'Open in VS Code'), - icon: Codicon.vscodeInsiders, - precondition: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), - menu: [{ - id: Menus.TitleBarSessionMenu, - group: 'navigation', - order: 9, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), - }] - }); - } - - override async run(accessor: ServicesAccessor): Promise { - const telemetryService = accessor.get(ITelemetryService); - logSessionsInteraction(telemetryService, 'openInVSCode'); - - const openerService = accessor.get(IOpenerService); - const productService = accessor.get(IProductService); - const sessionsManagementService = accessor.get(ISessionsManagementService); - const sessionsProvidersService = accessor.get(ISessionsProvidersService); - const remoteAgentHostService = accessor.get(IRemoteAgentHostService); - - const scheme = productService.quality === 'stable' - ? 'vscode' - : productService.quality === 'exploration' - ? 'vscode-exploration' - : productService.quality === 'insider' - ? 'vscode-insiders' - : productService.urlProtocol; - - const params = new URLSearchParams(); - params.set('windowId', '_blank'); - - const activeSession = sessionsManagementService.activeSession.get(); - if (!activeSession) { - await openerService.open(URI.from({ scheme, query: params.toString() }), { openExternal: true }); // Open VS Code without a specific path - return; - } - - const workspace = activeSession.workspace.get(); - const repo = workspace?.repositories[0]; - const rawFolderUri = activeSession.sessionType === CopilotCLISessionType.id ? repo?.workingDirectory ?? repo?.uri : undefined; - - if (!rawFolderUri) { - await openerService.open(URI.from({ scheme, query: params.toString() }), { openExternal: true }); // Open VS Code without a specific path - return; - } - - // Unwrap agent-host URIs to get the original file path on the remote - const folderUri = rawFolderUri.scheme === AGENT_HOST_SCHEME ? fromAgentHostUri(rawFolderUri) : rawFolderUri; - - // Resolve VS Code remote authority from the session's provider - const remoteAuthority = resolveRemoteAuthority( - activeSession.providerId, sessionsProvidersService, remoteAgentHostService); - - params.set('session', activeSession.resource.toString()); - - if (remoteAuthority) { - // Open as remote: vscode://vscode-remote/ - // The main process converts this to vscode-remote:// - await openerService.open(URI.from({ - scheme, - authority: Schemas.vscodeRemote, - path: `/${remoteAuthority}${folderUri.path}`, - query: params.toString(), - }), { openExternal: true }); - } else { - // Open as local file - await openerService.open(URI.from({ - scheme, - authority: Schemas.file, - path: folderUri.path, - query: params.toString(), - }), { openExternal: true }); - } - } -} -registerAction2(OpenSessionWorktreeInVSCodeAction); - -/** - * Resolves the VS Code remote authority for the given session provider, - * e.g. `ssh-remote+myhost` or `tunnel+myTunnel`. - * - * Returns `undefined` for local or WebSocket-only providers where no - * VS Code remote extension can handle the connection. - */ -export function resolveRemoteAuthority( - providerId: string, - sessionsProvidersService: ISessionsProvidersService, - remoteAgentHostService: IRemoteAgentHostService, -): string | undefined { - const provider = sessionsProvidersService.getProvider(providerId); - if (!provider || !isAgentHostProvider(provider) || !provider.remoteAddress) { - return undefined; - } - - const entry = remoteAgentHostService.getEntryByAddress(provider.remoteAddress); - if (!entry) { - return undefined; - } - - switch (entry.connection.type) { - case RemoteAgentHostEntryType.SSH: - if (entry.connection.sshConfigHost) { - return `ssh-remote+${entry.connection.sshConfigHost}`; - } - return `ssh-remote+${sshAuthorityString(entry.connection)}`; - case RemoteAgentHostEntryType.Tunnel: - return `tunnel+${entry.connection.label ?? `${entry.connection.tunnelId}.${entry.connection.clusterId}`}`; - default: - return undefined; - } -} - -/** - * Encodes an SSH connection into the authority string format expected by - * the Remote SSH extension. - * - * Simple hostnames (lowercase alphanumeric) are used verbatim. - * Complex hosts (with user, port, uppercase, or special characters) - * are encoded as a hex-encoded JSON object `{"hostName":...,"user":...,"port":...}`. - */ -export function sshAuthorityString(connection: IRemoteAgentHostSSHConnection): string { - const hostName = connection.hostName; - const needsEncoding = connection.user || connection.port - || /[A-Z/\\+]/.test(hostName) || !/^[a-zA-Z0-9.:\-]+$/.test(hostName); - if (!needsEncoding) { - return hostName; - } - - const obj: Record = { hostName }; - if (connection.user) { - obj.user = connection.user; - } - if (connection.port) { - obj.port = connection.port; - } - - const json = JSON.stringify(obj); - return encodeHex(VSBuffer.fromString(json)); -} class NewChatInSessionsWindowAction extends Action2 { diff --git a/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts b/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts new file mode 100644 index 0000000000000..022b52b7c64d8 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; +import { localize2 } from '../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { AGENT_HOST_SCHEME, fromAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js'; +import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { logSessionsInteraction } from '../../../common/sessionsTelemetry.js'; +import { Menus } from '../../../browser/menus.js'; +import { CopilotCLISessionType } from '../../../services/sessions/common/session.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { resolveRemoteAuthority } from './openInVSCodeUtils.js'; + +/** + * Opens the host VS Code app from the Agents window via protocol handler. + * On desktop this action is replaced by the electron-browser override that + * uses {@link INativeHostService.launchSiblingApp} instead. + */ +registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { + static readonly ID = 'chat.openSessionWorktreeInVSCode'; + + constructor() { + super({ + id: OpenSessionWorktreeInVSCodeAction.ID, + title: localize2('openInVSCode', 'Open in VS Code'), + icon: Codicon.vscodeInsiders, + precondition: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), + menu: [{ + id: Menus.TitleBarSessionMenu, + group: 'navigation', + order: 9, + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), + }] + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const telemetryService = accessor.get(ITelemetryService); + logSessionsInteraction(telemetryService, 'openInVSCode'); + + const openerService = accessor.get(IOpenerService); + const productService = accessor.get(IProductService); + const sessionsManagementService = accessor.get(ISessionsManagementService); + const sessionsProvidersService = accessor.get(ISessionsProvidersService); + const remoteAgentHostService = accessor.get(IRemoteAgentHostService); + + const scheme = productService.quality === 'stable' + ? 'vscode' + : productService.quality === 'exploration' + ? 'vscode-exploration' + : productService.quality === 'insider' + ? 'vscode-insiders' + : productService.urlProtocol; + + const params = new URLSearchParams(); + params.set('windowId', '_blank'); + + const activeSession = sessionsManagementService.activeSession.get(); + if (!activeSession) { + await openerService.open(URI.from({ scheme, query: params.toString() }), { openExternal: true }); + return; + } + + const workspace = activeSession.workspace.get(); + const repo = workspace?.repositories[0]; + const rawFolderUri = activeSession.sessionType === CopilotCLISessionType.id ? repo?.workingDirectory ?? repo?.uri : undefined; + + if (!rawFolderUri) { + await openerService.open(URI.from({ scheme, query: params.toString() }), { openExternal: true }); + return; + } + + const folderUri = rawFolderUri.scheme === AGENT_HOST_SCHEME ? fromAgentHostUri(rawFolderUri) : rawFolderUri; + const remoteAuthority = resolveRemoteAuthority( + activeSession.providerId, sessionsProvidersService, remoteAgentHostService); + + params.set('session', activeSession.resource.toString()); + + if (remoteAuthority) { + await openerService.open(URI.from({ + scheme, + authority: Schemas.vscodeRemote, + path: `/${remoteAuthority}${folderUri.path}`, + query: params.toString(), + }), { openExternal: true }); + } else { + await openerService.open(URI.from({ + scheme, + authority: Schemas.file, + path: folderUri.path, + query: params.toString(), + }), { openExternal: true }); + } + } +}); diff --git a/src/vs/sessions/contrib/chat/browser/openInVSCodeUtils.ts b/src/vs/sessions/contrib/chat/browser/openInVSCodeUtils.ts new file mode 100644 index 0000000000000..5c3dcc760c249 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/openInVSCodeUtils.ts @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IRemoteAgentHostService, IRemoteAgentHostSSHConnection, RemoteAgentHostEntryType } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; +import { encodeHex, VSBuffer } from '../../../../base/common/buffer.js'; + +/** + * Resolves the VS Code remote authority for the given session provider, + * e.g. `ssh-remote+myhost` or `tunnel+myTunnel`. + * + * Returns `undefined` for local or WebSocket-only providers where no + * VS Code remote extension can handle the connection. + */ +export function resolveRemoteAuthority( + providerId: string, + sessionsProvidersService: ISessionsProvidersService, + remoteAgentHostService: IRemoteAgentHostService, +): string | undefined { + const provider = sessionsProvidersService.getProvider(providerId); + if (!provider || !isAgentHostProvider(provider) || !provider.remoteAddress) { + return undefined; + } + + const entry = remoteAgentHostService.getEntryByAddress(provider.remoteAddress); + if (!entry) { + return undefined; + } + + switch (entry.connection.type) { + case RemoteAgentHostEntryType.SSH: + if (entry.connection.sshConfigHost) { + return `ssh-remote+${entry.connection.sshConfigHost}`; + } + return `ssh-remote+${sshAuthorityString(entry.connection)}`; + case RemoteAgentHostEntryType.Tunnel: + return `tunnel+${entry.connection.label ?? `${entry.connection.tunnelId}.${entry.connection.clusterId}`}`; + default: + return undefined; + } +} + +/** + * Encodes an SSH connection into the authority string format expected by + * the Remote SSH extension. + */ +export function sshAuthorityString(connection: IRemoteAgentHostSSHConnection): string { + const hostName = connection.hostName; + const needsEncoding = connection.user || connection.port + || /[A-Z/\\+]/.test(hostName) || !/^[a-zA-Z0-9.:\-]+$/.test(hostName); + if (!needsEncoding) { + return hostName; + } + + const obj: Record = { hostName }; + if (connection.user) { + obj.user = connection.user; + } + if (connection.port) { + obj.port = connection.port; + } + + const json = JSON.stringify(obj); + return encodeHex(VSBuffer.fromString(json)); +} diff --git a/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts index 89367f4a69f87..067188f31e1c8 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts @@ -3,27 +3,51 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; +import { Codicon } from '../../../../base/common/codicons.js'; import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; -import { registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; +import { localize2 } from '../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { AGENT_HOST_SCHEME, fromAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js'; +import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { INativeHostService } from '../../../../platform/native/common/native.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { logSessionsInteraction } from '../../../common/sessionsTelemetry.js'; +import { Menus } from '../../../browser/menus.js'; +import { CopilotCLISessionType } from '../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; -import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; -import { INativeHostService } from '../../../../platform/native/common/native.js'; -import { CopilotCLISessionType } from '../../../services/sessions/common/session.js'; -import { AGENT_HOST_SCHEME, fromAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js'; -import { OpenSessionWorktreeInVSCodeAction, resolveRemoteAuthority } from '../browser/chat.contribution.js'; +import { resolveRemoteAuthority } from '../browser/openInVSCodeUtils.js'; /** - * Desktop override for {@link OpenSessionWorktreeInVSCodeAction}. + * Desktop version of the "Open in VS Code" action. * * Launches the host VS Code app via {@link INativeHostService.launchSiblingApp} + * (child_process.spawn) with direct CLI arguments, bypassing protocol handlers + * and their OS security prompts. */ -registerAction2(class extends OpenSessionWorktreeInVSCodeAction { +registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { + static readonly ID = 'chat.openSessionWorktreeInVSCode'; + + constructor() { + super({ + id: OpenSessionWorktreeInVSCodeAction.ID, + title: localize2('openInVSCode', 'Open in VS Code'), + icon: Codicon.vscodeInsiders, + precondition: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), + menu: [{ + id: Menus.TitleBarSessionMenu, + group: 'navigation', + order: 9, + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), + }] + }); + } override async run(accessor: ServicesAccessor): Promise { const telemetryService = accessor.get(ITelemetryService); diff --git a/src/vs/sessions/contrib/chat/test/browser/resolveRemoteAuthority.test.ts b/src/vs/sessions/contrib/chat/test/browser/resolveRemoteAuthority.test.ts index fb1e03d1b65de..1484eb6e4c4f7 100644 --- a/src/vs/sessions/contrib/chat/test/browser/resolveRemoteAuthority.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/resolveRemoteAuthority.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { getEntryAddress, IRemoteAgentHostEntry, IRemoteAgentHostService, RemoteAgentHostEntryType } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; -import { resolveRemoteAuthority, sshAuthorityString } from '../../browser/chat.contribution.js'; +import { resolveRemoteAuthority, sshAuthorityString } from '../../browser/openInVSCodeUtils.js'; import { decodeHex } from '../../../../../base/common/buffer.js'; suite('resolveRemoteAuthority', () => { diff --git a/src/vs/sessions/sessions.web.main.ts b/src/vs/sessions/sessions.web.main.ts index 5a5056d6bc0b7..464cc81b196b3 100644 --- a/src/vs/sessions/sessions.web.main.ts +++ b/src/vs/sessions/sessions.web.main.ts @@ -143,6 +143,9 @@ import '../workbench/contrib/welcomeBanner/browser/welcomeBanner.contribution.js // Web tunnel agent host โ€” discovers tunnels via Dev Tunnels REST API and connects via relay import './contrib/remoteAgentHost/browser/webTunnelAgentHostService.contribution.js'; +// Open in VS Code โ€” web uses protocol handler; desktop overrides in electron-browser +import './contrib/chat/browser/openInVSCode.contribution.js'; + // Tunnel agent host โ€” reconciles discovered tunnels into session providers import './contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.js'; From f80f392bf501f885e1045dcb2207d1ddab37bd6e Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 17 Apr 2026 00:18:34 +0200 Subject: [PATCH 38/56] enable multi chat sessions by default (#310856) --- src/vs/sessions/SESSIONS_PROVIDER.md | 22 +- .../browser/sessionWorkspacePicker.test.ts | 2 - .../sessionsConfigurationService.test.ts | 1 + .../COPILOT_CHAT_SESSIONS_PROVIDER.md | 2 +- .../copilotChatSessions.contribution.ts | 2 +- .../browser/copilotChatSessionsProvider.ts | 40 +- .../copilotChatSessionsProvider.test.ts | 422 +++++++++--------- .../browser/localAgentHostSessionsProvider.ts | 4 +- .../remoteAgentHostSessionsProvider.ts | 3 +- .../test/browser/sessionsList.test.ts | 1 + .../browser/sessionsListModelService.test.ts | 1 + .../sessionsTerminalContribution.test.ts | 2 + .../browser/sessionsManagementService.ts | 3 +- .../services/sessions/common/session.ts | 11 + .../sessions/common/sessionsManagement.ts | 2 +- .../sessions/common/sessionsProvider.ts | 18 - 16 files changed, 264 insertions(+), 272 deletions(-) diff --git a/src/vs/sessions/SESSIONS_PROVIDER.md b/src/vs/sessions/SESSIONS_PROVIDER.md index 347ba9eb2a5a9..dcc1f640213d7 100644 --- a/src/vs/sessions/SESSIONS_PROVIDER.md +++ b/src/vs/sessions/SESSIONS_PROVIDER.md @@ -124,7 +124,6 @@ A sessions provider encapsulates a compute environment. It owns workspace discov | `icon` | `ThemeIcon` | Provider icon | | `sessionTypes` | `readonly ISessionType[]` | Session types this provider supports | | `onDidChangeSessionTypes?` | `Event` | Optional; fires when session types change dynamically (e.g., a remote host advertises a new agent) | -| `capabilities` | `ISessionsProviderCapabilities` | Provider capabilities (e.g., `multipleChatsPerSession`) | #### Workspace Discovery @@ -182,9 +181,6 @@ A sessions provider encapsulates a compute environment. It owns workspace discov - `query: string` โ€” Query text - `attachedContext?: IChatRequestVariableEntry[]` โ€” Optional attached context entries -**`ISessionsProviderCapabilities`** โ€” Provider capabilities: -- `multipleChatsPerSession: boolean` โ€” Whether the provider supports multiple chats within a single session - --- ### `ISessionsProvidersService` โ€” Central Registry @@ -258,13 +254,13 @@ The management service binds and updates these context keys: | `activeSessionType` | `string` | Session type of the active session | | `isActiveSessionBackgroundProvider` | `boolean` | Whether the active session uses the background agent provider | | `isActiveSessionArchived` | `boolean` | Whether the active session is archived | -| `activeSessionSupportsMultiChat` | `boolean` | Whether the active session's provider supports multiple chats | +| `activeSessionSupportsMultiChat` | `boolean` | Whether the active session supports multiple chats | --- ## Multi-Chat Sessions -A session can contain **multiple chats** (conversations), controlled by the provider's `capabilities.multipleChatsPerSession` flag. When enabled, users can start additional conversations within the same session, sharing its workspace context. +A session can contain **multiple chats** (conversations), controlled by the session's `capabilities.supportsMultipleChats` property. When enabled, users can start additional conversations within the same session, sharing its workspace context. ### Sessionโ€“Chat Relationship @@ -274,6 +270,8 @@ A session can contain **multiple chats** (conversations), controlled by the prov ISession โ”œโ”€โ”€ mainChat: IChat โ† primary (first) chat โ”œโ”€โ”€ chats: IObservable โ† all chats in creation order +โ”œโ”€โ”€ capabilities โ† session capabilities +โ”‚ โ””โ”€โ”€ supportsMultipleChats โ† whether this session supports multi-chat โ””โ”€โ”€ session-level properties โ† derived from chats ``` @@ -284,17 +282,9 @@ ISession - `isRead` โ€” `true` only when **all** chats are read - `lastTurnEnd` โ€” latest `lastTurnEnd` across all chats -### Capabilities & Context Keys - -Providers declare multi-chat support via `ISessionsProviderCapabilities`: - -```typescript -interface ISessionsProviderCapabilities { - readonly multipleChatsPerSession: boolean; -} -``` +### Context Keys -When the active session's provider supports multi-chat, the context key `activeSessionSupportsMultiChat` is set to `true`, enabling multi-chat UI elements (e.g., "New Chat" button). +When the active session supports multi-chat (`capabilities.supportsMultipleChats` is `true`), the context key `activeSessionSupportsMultiChat` is set to `true`, enabling multi-chat UI elements (e.g., "New Chat" button). ### Active Chat Tracking diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index 129aa19fedd31..93b7e37e9dbf7 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -62,8 +62,6 @@ function createMockProvider(id: string, opts?: { sendAndCreateChat: async () => { throw new Error('Not implemented'); }, addChat: () => { throw new Error('Not implemented'); }, sendRequest: async () => { throw new Error('Not implemented'); }, - capabilities: { multipleChatsPerSession: false }, - onDidChangeCapabilities: Event.None, }; if (opts?.connectionStatus) { return { diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts index 2e569f642637b..83e627c025ec4 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts @@ -72,6 +72,7 @@ function makeSession(opts: { repository?: URI; worktree?: URI } = {}): ISession gitHubInfo: observableValue('gitHubInfo', undefined), chats: observableValue('chats', [chat]), mainChat: chat, + capabilities: { supportsMultipleChats: false }, }; return session; } diff --git a/src/vs/sessions/contrib/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md index f75b44994a6b7..b94223b688f6a 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md @@ -81,6 +81,6 @@ The provider maintains a `Map` cache keyed by resou e. Add temp session to cache and fire `onDidChangeSessions` f. Wait for session commit (untitled โ†’ real URI) g. Replace via `onDidReplaceSession` event with the committed session -3. For subsequent chats (if `multipleChatsPerSession` enabled), call `_sendSubsequentChat()` +3. For subsequent chats (if `capabilities.supportsMultipleChats` enabled on the session), call `_sendSubsequentChat()` 4. Wrap the new agent session as `AgentSessionAdapter` and return it 5. Clear the current new session reference diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts index 8c1aeef7cd897..b5ce2480e972b 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts @@ -20,7 +20,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis properties: { [COPILOT_MULTI_CHAT_SETTING]: { type: 'boolean', - default: false, + default: true, tags: ['preview'], description: localize('sessions.github.copilot.multiChatSessions', "Whether to enable multiple chats within a single session in the Copilot Chat sessions provider."), }, diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 6af453562a371..42309455361ff 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -1049,13 +1049,6 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions readonly sessionTypes: readonly ISessionType[] = [CopilotCLISessionType, CopilotCloudSessionType]; readonly onDidChangeSessionTypes = Event.None; - get capabilities() { - return { - multipleChatsPerSession: this._isMultiChatEnabled(), - }; - } - readonly onDidChangeCapabilities = Event.None; - private readonly _onDidChangeSessions = this._register(new Emitter()); readonly onDidChangeSessions: Event = this._onDidChangeSessions.event; @@ -1071,6 +1064,8 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions /** Group model tracking which chats belong to which session. */ private readonly _groupModel: SessionsGroupModel; + private readonly _multiChatEnabled: boolean; + readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; constructor( @@ -1085,13 +1080,14 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService, @IStorageService storageService: IStorageService, - @IConfigurationService private readonly configurationService: IConfigurationService, + @IConfigurationService configurationService: IConfigurationService, @ILogService private readonly logService: ILogService, @IGitHubService private readonly gitHubService: IGitHubService, ) { super(); this._groupModel = this._register(new SessionsGroupModel(storageService)); + this._multiChatEnabled = configurationService.getValue(COPILOT_MULTI_CHAT_SETTING) ?? true; this.browseActions = [ { @@ -1225,10 +1221,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } async deleteSession(sessionId: string): Promise { - // Collect all chat IDs in this session group - const chatIds = this._isMultiChatEnabled() - ? this._groupModel.getChatIds(sessionId) - : []; + const chatIds = this._groupModel.getChatIds(sessionId); // Collect all agent sessions to delete (primary + group members) const allChatIds = new Set([sessionId, ...chatIds]); @@ -1260,12 +1253,8 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions await this._deleteAgentSessions(agentSessions); - // Clean up group model - if (this._isMultiChatEnabled()) { - this._groupModel.deleteSession(sessionId); - this._sessionGroupCache.delete(sessionId); - } - + this._groupModel.deleteSession(sessionId); + this._sessionGroupCache.delete(sessionId); this._refreshSessionCache(); } @@ -1279,7 +1268,9 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } async deleteChat(sessionId: string, chatUri: URI): Promise { - if (!this._isMultiChatEnabled()) { + const session = this._findSession(sessionId); + + if (!session?.capabilities.supportsMultipleChats) { throw new Error('Deleting individual chats is not supported when multi-chat is disabled'); } @@ -1369,7 +1360,8 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } addChat(sessionId: string): IChat { - if (!this._isMultiChatEnabled()) { + const session = this._findSession(sessionId); + if (!session?.capabilities.supportsMultipleChats) { throw new Error('Multiple chats per session is not supported'); } @@ -2070,6 +2062,10 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return this.agentSessionsService.getSession(adapter.resource); } + private _findSession(sessionId: string): ISession | undefined { + return this._sessionGroupCache.get(sessionId); + } + private _localIdFromchatId(chatId: string): string { const prefix = `${this.id}:`; return chatId.startsWith(prefix) ? chatId.substring(prefix.length) : chatId; @@ -2144,6 +2140,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions gitHubInfo: primaryChat.gitHubInfo, chats: chatsObs, mainChat, + capabilities: { supportsMultipleChats: primaryChat.sessionType === CopilotCLISessionType.id && this._isMultiChatEnabled() }, }; this._sessionGroupCache.set(sessionId, session); return session; @@ -2173,6 +2170,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions gitHubInfo: chat.gitHubInfo, chats: constObservable([mainChat]), mainChat, + capabilities: { supportsMultipleChats: false }, }; } @@ -2219,6 +2217,6 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } private _isMultiChatEnabled(): boolean { - return this.configurationService.getValue(COPILOT_MULTI_CHAT_SETTING) ?? false; + return this._multiChatEnabled; } } diff --git a/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index 19ea7835f2b2a..e8c903a794ce5 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -111,7 +111,7 @@ function createProvider( const instantiationService = disposables.add(new TestInstantiationService()); const configService = new TestConfigurationService(); - configService.setUserConfiguration('sessions.github.copilot.multiChatSessions', opts?.multiChatEnabled ?? false); + configService.setUserConfiguration('sessions.github.copilot.multiChatSessions', opts?.multiChatEnabled ?? true); instantiationService.stub(IConfigurationService, configService); instantiationService.stub(IStorageService, disposables.add(new TestStorageService())); @@ -192,7 +192,7 @@ function createProviderForSendTests( const instantiationService = disposables.add(new TestInstantiationService()); const configService = new TestConfigurationService(); - configService.setUserConfiguration('sessions.github.copilot.multiChatSessions', false); + configService.setUserConfiguration('sessions.github.copilot.multiChatSessions', true); instantiationService.stub(ILogService, NullLogService); instantiationService.stub(IConfigurationService, configService); @@ -262,18 +262,6 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(provider.sessionTypes.length, 2); }); - // ---- Capabilities ------- - - test('capabilities.multipleChatsPerSession is false by default', () => { - const provider = createProvider(disposables, model); - assert.strictEqual(provider.capabilities.multipleChatsPerSession, false); - }); - - test('capabilities.multipleChatsPerSession is true when setting is enabled', () => { - const provider = createProvider(disposables, model, { multiChatEnabled: true }); - assert.strictEqual(provider.capabilities.multipleChatsPerSession, true); - }); - // ---- Session listing ------- test('getSessions returns empty array initially', () => { @@ -354,270 +342,292 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(agentSession.isArchived(), false); }); - // ---- Single-chat mode (multi-chat disabled) ------- + // ---- Session capabilities ------- + + test('copilot CLI sessions have supportsMultipleChats capability', () => { + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + model.addSession(createMockAgentSession(resource)); + + const provider = createProvider(disposables, model); + const sessions = provider.getSessions(); + + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].capabilities.supportsMultipleChats, true); + }); + + test('copilot cloud sessions do not have supportsMultipleChats capability', () => { + const resource = URI.from({ scheme: AgentSessionProviders.Cloud, path: '/session-1' }); + model.addSession(createMockAgentSession(resource, { providerType: AgentSessionProviders.Cloud })); + + const provider = createProvider(disposables, model); + const sessions = provider.getSessions(); + + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].capabilities.supportsMultipleChats, false); + }); - test('single-chat mode: each session has exactly one chat', () => { + test('copilot CLI sessions do not have supportsMultipleChats when setting is disabled', () => { const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); model.addSession(createMockAgentSession(resource)); const provider = createProvider(disposables, model, { multiChatEnabled: false }); const sessions = provider.getSessions(); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].capabilities.supportsMultipleChats, false); + }); + + // ---- Session listing & grouping ------- + + test('each session has exactly one chat initially', () => { + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + model.addSession(createMockAgentSession(resource)); + + const provider = createProvider(disposables, model); + const sessions = provider.getSessions(); + assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].chats.get().length, 1); assert.strictEqual(sessions[0].mainChat.resource.toString(), resource.toString()); }); - test('single-chat mode: sendAndCreateChat throws for unknown session', async () => { - const provider = createProvider(disposables, model, { multiChatEnabled: false }); + test('sendAndCreateChat throws for unknown session', async () => { + const provider = createProvider(disposables, model); await assert.rejects( () => provider.sendAndCreateChat('nonexistent', { query: 'test' }), - /not found or not a new session/, + /not found/, ); }); - // ---- Multi-chat mode ------- + test('getSessions groups chats by session group', () => { + const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' }); + model.addSession(createMockAgentSession(resource1, { title: 'Chat 1' })); + model.addSession(createMockAgentSession(resource2, { title: 'Chat 2' })); - suite('multi-chat (setting enabled)', () => { + const provider = createProvider(disposables, model); + const sessions = provider.getSessions(); - test('getSessions groups chats by session group', () => { - const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); - const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' }); - model.addSession(createMockAgentSession(resource1, { title: 'Chat 1' })); - model.addSession(createMockAgentSession(resource2, { title: 'Chat 2' })); + // Without explicit grouping, each chat is its own session + assert.strictEqual(sessions.length, 2); + }); - const provider = createProvider(disposables, model, { multiChatEnabled: true }); - const sessions = provider.getSessions(); + test('session title comes from primary (first) chat', () => { + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + model.addSession(createMockAgentSession(resource, { title: 'Primary Title' })); - // Without explicit grouping, each chat is its own session - assert.strictEqual(sessions.length, 2); - }); + const provider = createProvider(disposables, model); + const sessions = provider.getSessions(); - test('session title comes from primary (first) chat', () => { - const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); - model.addSession(createMockAgentSession(resource, { title: 'Primary Title' })); + assert.strictEqual(sessions[0].title.get(), 'Primary Title'); + }); - const provider = createProvider(disposables, model, { multiChatEnabled: true }); - const sessions = provider.getSessions(); + test('session has mainChat set to the first chat', () => { + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + model.addSession(createMockAgentSession(resource)); - assert.strictEqual(sessions[0].title.get(), 'Primary Title'); - }); + const provider = createProvider(disposables, model); + const sessions = provider.getSessions(); - test('session has mainChat set to the first chat', () => { - const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); - model.addSession(createMockAgentSession(resource)); + assert.ok(sessions[0].mainChat); + assert.strictEqual(sessions[0].mainChat.resource.toString(), resource.toString()); + }); - const provider = createProvider(disposables, model, { multiChatEnabled: true }); - const sessions = provider.getSessions(); + test('deleteSession removes session from model and list', async () => { + const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' }); + model.addSession(createMockAgentSession(resource1, { title: 'Session 1' })); + model.addSession(createMockAgentSession(resource2, { title: 'Session 2' })); - assert.ok(sessions[0].mainChat); - assert.strictEqual(sessions[0].mainChat.resource.toString(), resource.toString()); - }); + const provider = createProvider(disposables, model); + const sessions = provider.getSessions(); + assert.strictEqual(sessions.length, 2); - test('sendAndCreateChat throws for unknown session when no untitled session exists', async () => { - const provider = createProvider(disposables, model, { multiChatEnabled: true }); - await assert.rejects( - () => provider.sendAndCreateChat('nonexistent', { query: 'test' }), - /not found/, - ); - }); + await provider.deleteSession(sessions[0].sessionId); - test('deleteSession removes session from model and list', async () => { - const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); - const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' }); - model.addSession(createMockAgentSession(resource1, { title: 'Session 1' })); - model.addSession(createMockAgentSession(resource2, { title: 'Session 2' })); + const remainingSessions = provider.getSessions(); + assert.strictEqual(remainingSessions.length, 1); + assert.strictEqual(remainingSessions[0].title.get(), 'Session 2'); + }); - const provider = createProvider(disposables, model, { multiChatEnabled: true }); - const sessions = provider.getSessions(); - assert.strictEqual(sessions.length, 2); + test('deleteChat with single chat delegates to deleteSession', async () => { + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + model.addSession(createMockAgentSession(resource)); - await provider.deleteSession(sessions[0].sessionId); + const provider = createProvider(disposables, model); + const sessions = provider.getSessions(); + const session = sessions[0]; - const remainingSessions = provider.getSessions(); - assert.strictEqual(remainingSessions.length, 1); - assert.strictEqual(remainingSessions[0].title.get(), 'Session 2'); - }); + await provider.deleteChat(session.sessionId, resource); - test('deleteChat with single chat delegates to deleteSession', async () => { - const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); - model.addSession(createMockAgentSession(resource)); + // Model should no longer have the session + assert.strictEqual(model.sessions.length, 0); + }); - const provider = createProvider(disposables, model, { multiChatEnabled: true }); - const sessions = provider.getSessions(); - const session = sessions[0]; + test('deleteChat throws when session does not support multi-chat', async () => { + const resource = URI.from({ scheme: AgentSessionProviders.Cloud, path: '/session-1' }); + model.addSession(createMockAgentSession(resource, { providerType: AgentSessionProviders.Cloud })); - await provider.deleteChat(session.sessionId, resource); + const provider = createProvider(disposables, model); + const sessions = provider.getSessions(); + const session = sessions[0]; - // Model should no longer have the session - assert.strictEqual(model.sessions.length, 0); - }); + await assert.rejects( + () => provider.deleteChat(session.sessionId, resource), + /not supported when multi-chat is disabled/, + ); + }); - test('deleteChat throws when multi-chat is disabled', async () => { - const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); - model.addSession(createMockAgentSession(resource)); + test('session group cache is invalidated on session removal', () => { + const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' }); + model.addSession(createMockAgentSession(resource1, { title: 'Session 1' })); + model.addSession(createMockAgentSession(resource2, { title: 'Session 2' })); - const provider = createProvider(disposables, model, { multiChatEnabled: false }); - const sessions = provider.getSessions(); - const session = sessions[0]; + const provider = createProvider(disposables, model); - await assert.rejects( - () => provider.deleteChat(session.sessionId, resource), - /not supported when multi-chat is disabled/, - ); - }); + // Initialize sessions + let sessions = provider.getSessions(); + assert.strictEqual(sessions.length, 2); - test('session group cache is invalidated on session removal', () => { - const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); - const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' }); - model.addSession(createMockAgentSession(resource1, { title: 'Session 1' })); - model.addSession(createMockAgentSession(resource2, { title: 'Session 2' })); + // Remove one from the model + model.removeSession(resource1); - const provider = createProvider(disposables, model, { multiChatEnabled: true }); + // Re-fetch + sessions = provider.getSessions(); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].title.get(), 'Session 2'); + }); - // Initialize sessions - let sessions = provider.getSessions(); - assert.strictEqual(sessions.length, 2); + test('chats observable updates when group model changes', () => { + const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' }); + model.addSession(createMockAgentSession(resource1, { title: 'Chat 1' })); + model.addSession(createMockAgentSession(resource2, { title: 'Chat 2' })); - // Remove one from the model - model.removeSession(resource1); + const provider = createProvider(disposables, model); + const sessions = provider.getSessions(); + assert.strictEqual(sessions.length, 2); - // Re-fetch - sessions = provider.getSessions(); - assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].title.get(), 'Session 2'); - }); + // Both are separate sessions initially + const session1 = sessions[0]; + assert.strictEqual(session1.chats.get().length, 1); + }); - test('resolveWorkspace creates proper workspace structure', () => { - const provider = createProvider(disposables, model, { multiChatEnabled: true }); - const uri = URI.file('/test/project'); + test('session status aggregates across chats', () => { + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + model.addSession(createMockAgentSession(resource)); - const workspace = provider.resolveWorkspace(uri); + const provider = createProvider(disposables, model); + const sessions = provider.getSessions(); - assert.strictEqual(workspace.label, 'project'); - assert.strictEqual(workspace.repositories.length, 1); - assert.strictEqual(workspace.repositories[0].uri.toString(), uri.toString()); - assert.strictEqual(workspace.requiresWorkspaceTrust, true); - }); + // With a single chat, session status should match the chat status + assert.ok(sessions[0].status.get() !== undefined); + }); - test('chats observable updates when group model changes', () => { - const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); - const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' }); - model.addSession(createMockAgentSession(resource1, { title: 'Chat 1' })); - model.addSession(createMockAgentSession(resource2, { title: 'Chat 2' })); + test('session isRead aggregates across all chats', () => { + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + model.addSession(createMockAgentSession(resource, { read: true })); - const provider = createProvider(disposables, model, { multiChatEnabled: true }); - const sessions = provider.getSessions(); - assert.strictEqual(sessions.length, 2); + const provider = createProvider(disposables, model); + const sessions = provider.getSessions(); - // Both are separate sessions initially - const session1 = sessions[0]; - assert.strictEqual(session1.chats.get().length, 1); - }); + assert.strictEqual(sessions[0].isRead.get(), true); + }); - test('session status aggregates across chats', () => { - const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); - model.addSession(createMockAgentSession(resource)); + test('session isRead is false when any chat is unread', () => { + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + model.addSession(createMockAgentSession(resource, { read: false })); - const provider = createProvider(disposables, model, { multiChatEnabled: true }); - const sessions = provider.getSessions(); + const provider = createProvider(disposables, model); + const sessions = provider.getSessions(); - // With a single chat, session status should match the chat status - assert.ok(sessions[0].status.get() !== undefined); - }); + assert.strictEqual(sessions[0].isRead.get(), false); + }); - test('session isRead aggregates across all chats', () => { - const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); - model.addSession(createMockAgentSession(resource, { read: true })); + test('removing a chat from a group fires changed (not removed) with correct sessionId', async () => { + const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' }); + model.addSession(createMockAgentSession(resource1, { title: 'Chat 1' })); + model.addSession(createMockAgentSession(resource2, { title: 'Chat 2' })); - const provider = createProvider(disposables, model, { multiChatEnabled: true }); - const sessions = provider.getSessions(); + const provider = createProvider(disposables, model); + const sessions = provider.getSessions(); + assert.strictEqual(sessions.length, 2); - assert.strictEqual(sessions[0].isRead.get(), true); - }); + // Manually group both chats under the first session + const chat2Id = sessions[1].sessionId; + // Access the group model indirectly by deleting the second session's group + // and re-adding its chat to the first group via deleteChat flow + // Instead, simulate by removing the second chat from the model + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => changes.push(e))); - test('session isRead is false when any chat is unread', () => { - const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); - model.addSession(createMockAgentSession(resource, { read: false })); + model.removeSession(resource2); - const provider = createProvider(disposables, model, { multiChatEnabled: true }); - const sessions = provider.getSessions(); + // The removed chat was standalone, so it should fire a removed event + assert.ok(changes.length > 0); + const lastChange = changes[changes.length - 1]; + assert.strictEqual(lastChange.removed.length, 1); + assert.strictEqual(lastChange.removed[0].sessionId, chat2Id); + }); - assert.strictEqual(sessions[0].isRead.get(), false); - }); + test('getSessions does not create duplicate groups on repeated calls', () => { + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + model.addSession(createMockAgentSession(resource)); - test('removing a chat from a group fires changed (not removed) with correct sessionId', async () => { - const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); - const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' }); - model.addSession(createMockAgentSession(resource1, { title: 'Chat 1' })); - model.addSession(createMockAgentSession(resource2, { title: 'Chat 2' })); - - const provider = createProvider(disposables, model, { multiChatEnabled: true }); - const sessions = provider.getSessions(); - assert.strictEqual(sessions.length, 2); - - // Manually group both chats under the first session - const chat2Id = sessions[1].sessionId; - // Access the group model indirectly by deleting the second session's group - // and re-adding its chat to the first group via deleteChat flow - // Instead, simulate by removing the second chat from the model - const changes: ISessionChangeEvent[] = []; - disposables.add(provider.onDidChangeSessions(e => changes.push(e))); + const provider = createProvider(disposables, model); - model.removeSession(resource2); + // Call getSessions multiple times + const sessions1 = provider.getSessions(); + const sessions2 = provider.getSessions(); - // The removed chat was standalone, so it should fire a removed event - assert.ok(changes.length > 0); - const lastChange = changes[changes.length - 1]; - assert.strictEqual(lastChange.removed.length, 1); - assert.strictEqual(lastChange.removed[0].sessionId, chat2Id); - }); + assert.strictEqual(sessions1.length, 1); + assert.strictEqual(sessions2.length, 1); + // Should return the same cached session object + assert.strictEqual(sessions1[0], sessions2[0]); + }); - test('getSessions does not create duplicate groups on repeated calls', () => { - const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); - model.addSession(createMockAgentSession(resource)); + test('changed events are not duplicated when multiple chats update', () => { + const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' }); + model.addSession(createMockAgentSession(resource1, { title: 'Session 1' })); + model.addSession(createMockAgentSession(resource2, { title: 'Session 2' })); - const provider = createProvider(disposables, model, { multiChatEnabled: true }); + const provider = createProvider(disposables, model); + provider.getSessions(); // Initialize - // Call getSessions multiple times - const sessions1 = provider.getSessions(); - const sessions2 = provider.getSessions(); + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => changes.push(e))); - assert.strictEqual(sessions1.length, 1); - assert.strictEqual(sessions2.length, 1); - // Should return the same cached session object - assert.strictEqual(sessions1[0], sessions2[0]); - }); + // Trigger a refresh that updates both sessions + model.addSession(createMockAgentSession( + URI.from({ scheme: AgentSessionProviders.Background, path: '/session-3' }), + { title: 'Session 3' } + )); + + // Each event should not have duplicates in the changed array + for (const change of changes) { + const changedIds = change.changed.map(s => s.sessionId); + const uniqueIds = new Set(changedIds); + assert.strictEqual(changedIds.length, uniqueIds.size, 'Changed events should not have duplicates'); + } + }); - test('changed events are not duplicated when multiple chats update', () => { - const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); - const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' }); - model.addSession(createMockAgentSession(resource1, { title: 'Session 1' })); - model.addSession(createMockAgentSession(resource2, { title: 'Session 2' })); + // ---- Browse actions ------- - const provider = createProvider(disposables, model, { multiChatEnabled: true }); - provider.getSessions(); // Initialize + test('resolveWorkspace creates proper workspace structure', () => { + const provider = createProvider(disposables, model); + const uri = URI.file('/test/project'); - const changes: ISessionChangeEvent[] = []; - disposables.add(provider.onDidChangeSessions(e => changes.push(e))); + const workspace = provider.resolveWorkspace(uri); - // Trigger a refresh that updates both sessions - model.addSession(createMockAgentSession( - URI.from({ scheme: AgentSessionProviders.Background, path: '/session-3' }), - { title: 'Session 3' } - )); - - // Each event should not have duplicates in the changed array - for (const change of changes) { - const changedIds = change.changed.map(s => s.sessionId); - const uniqueIds = new Set(changedIds); - assert.strictEqual(changedIds.length, uniqueIds.size, 'Changed events should not have duplicates'); - } - }); + assert.strictEqual(workspace.label, 'project'); + assert.strictEqual(workspace.repositories.length, 1); + assert.strictEqual(workspace.repositories[0].uri.toString(), uri.toString()); + assert.strictEqual(workspace.requiresWorkspaceTrust, true); }); - // ---- Browse actions ------- - test('has folder and repo browse actions', () => { const provider = createProvider(disposables, model); assert.strictEqual(provider.browseActions.length, 2); diff --git a/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts index da3d57639801c..46d4b771cfbb0 100644 --- a/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts @@ -97,6 +97,7 @@ class LocalSessionAdapter implements ISession { readonly mainChat: IChat; readonly chats: IObservable; + readonly capabilities = { supportsMultipleChats: false }; readonly agentProvider: string; @@ -232,8 +233,6 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent readonly id = LOCAL_PROVIDER_ID; readonly label: string; readonly icon: ThemeIcon = Codicon.vm; - readonly onDidChangeCapabilities = Event.None; - readonly capabilities = { multipleChatsPerSession: false }; private readonly _localLabel = localize('localAgentHostSessionTypeLocation', "Local"); private _hasRootStateSnapshot = false; private _sessionTypes: ISessionType[] = []; @@ -486,6 +485,7 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent gitHubInfo: observableValue(this, undefined), mainChat, chats: constObservable([mainChat]), + capabilities: { supportsMultipleChats: false }, }; this._currentNewSession = session; this._currentNewSessionStatus = status; diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index b12c8019c1958..1e6ea04c0e53c 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -268,8 +268,6 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements IAgen readonly id: string; readonly label: string; readonly icon: ThemeIcon = Codicon.remote; - readonly onDidChangeCapabilities = Event.None; - readonly capabilities = { multipleChatsPerSession: false }; readonly remoteAddress: string; private _outputChannelId: string | undefined; get outputChannelId(): string | undefined { return this._outputChannelId; } @@ -1326,6 +1324,7 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements IAgen gitHubInfo: chat.gitHubInfo, chats: constObservable([mainChat]), mainChat, + capabilities: { supportsMultipleChats: false }, }; return session; } diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index 2b81bbdca6939..abe2301a9ae47 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -46,6 +46,7 @@ function createSession(id: string, opts: { gitHubInfo: observableValue(`gitHubInfo-${id}`, undefined), chats: observableValue(`chats-${id}`, []), mainChat: undefined!, + capabilities: { supportsMultipleChats: false }, }; } diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsListModelService.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsListModelService.test.ts index 46b5cfd01577e..1f1210595bbbd 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsListModelService.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsListModelService.test.ts @@ -39,6 +39,7 @@ function createSession(id: string): ISession { gitHubInfo: observableValue(`gitHubInfo-${id}`, undefined), chats: observableValue(`chats-${id}`, []), mainChat: undefined!, + capabilities: { supportsMultipleChats: false }, }; } diff --git a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts index 27987ce65919a..2ad1eda7cdabd 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts @@ -92,6 +92,7 @@ function makeAgentSession(opts: { chats: observableValue('test.chats', [chat]), activeChat: observableValue('test.activeChat', chat), mainChat: chat, + capabilities: { supportsMultipleChats: false }, }; return session; } @@ -140,6 +141,7 @@ function makeNonAgentSession(opts: { repository?: URI; worktree?: URI; providerT gitHubInfo: observableValue('test.gitHubInfo', undefined), chats: observableValue('test.chats', [chat]), mainChat: chat, + capabilities: { supportsMultipleChats: false }, }; return session; } diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 5fd2643c8e45d..41615e5a2fbce 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -374,8 +374,7 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen this._activeSessionType.set(session?.sessionType ?? ''); this._isBackgroundProvider.set(session?.sessionType === COPILOT_CLI_SESSION_TYPE); this._isActiveSessionArchived.set(session?.isArchived.get() ?? false); - const provider = session ? this.sessionsProvidersService.getProviders().find(p => p.id === session.providerId) : undefined; - this._supportsMultiChat.set(provider?.capabilities.multipleChatsPerSession ?? false); + this._supportsMultiChat.set(session?.capabilities.supportsMultipleChats ?? false); if (session && session.status.get() !== SessionStatus.Untitled) { this.lastSelectedSession = session.resource; diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 530df1bf1c279..2d26f435abf04 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -190,6 +190,17 @@ export interface ISession { readonly chats: IObservable; /** The main (first) chat of this session. */ readonly mainChat: IChat; + /** Capabilities of this session. */ + readonly capabilities: ISessionCapabilities; +} + +/** + * Capabilities declared per session. + * Consumers check these before surfacing session-specific features in the UI. + */ +export interface ISessionCapabilities { + /** Whether this session supports multiple chats. */ + readonly supportsMultipleChats: boolean; } export interface ISessionWorkspaceBrowseAction { diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index b0e8131426424..ae3525813c12a 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -12,7 +12,7 @@ import { RawContextKey } from '../../../../platform/contextkey/common/contextkey import { IChat, ISession, ISessionType } from './session.js'; import { ISendRequestOptions } from './sessionsProvider.js'; -export const ActiveSessionSupportsMultiChatContext = new RawContextKey('activeSessionSupportsMultiChat', false, localize('activeSessionSupportsMultiChat', "Whether the active session's provider supports multiple chats per session")); +export const ActiveSessionSupportsMultiChatContext = new RawContextKey('activeSessionSupportsMultiChat', false, localize('activeSessionSupportsMultiChat', "Whether the active session supports multiple chats")); /** * Event fired when sessions change within a provider. diff --git a/src/vs/sessions/services/sessions/common/sessionsProvider.ts b/src/vs/sessions/services/sessions/common/sessionsProvider.ts index 1e2177eab9b55..8677b33ab4700 100644 --- a/src/vs/sessions/services/sessions/common/sessionsProvider.ts +++ b/src/vs/sessions/services/sessions/common/sessionsProvider.ts @@ -28,15 +28,6 @@ export interface ISendRequestOptions { readonly attachedContext?: IChatRequestVariableEntry[]; } -/** - * Capabilities declared by a sessions provider. - * Consumers check these before surfacing provider-specific features in the UI. - */ -export interface ISessionsProviderCapabilities { - /** Whether the provider supports multiple chats within a single session. */ - readonly multipleChatsPerSession: boolean; -} - /** * A sessions provider encapsulates a compute environment. * It owns workspace discovery, session creation, session listing, and picker contributions. @@ -69,15 +60,6 @@ export interface ISessionsProvider { */ readonly onDidChangeSessionTypes: Event; - /** - * Capabilities of the provider, which may affect how sessions from this provider are surfaced in the UI. The provider is expected to update capabilities and fire `onDidChangeCapabilities` when they change. - */ - readonly capabilities: ISessionsProviderCapabilities; - /** - * Event that fires when capabilities change. Consumers should refresh any UI affected by capabilities when this occurs. - */ - readonly onDidChangeCapabilities: Event; - /** * List of all sessions currently known to the provider. Consumers should not cache this list, but should listen to `onDidChangeSessions` and update their cached list accordingly. */ From b3b683119a2c9c470c506e89e10c4762def2c4a2 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:18:41 -0700 Subject: [PATCH 39/56] Add `Developer: Sync Account Policy` command (#310855) * Add Developer: Sync Account Policy command Adds a manual command to force-resync account policy data, bypassing the 1-hour staleness cache used by the background poller. Plumbs forceRefresh through getTokenEntitlements and getMcpRegistryProvider so a forced sync truly hits the network for all three sub-fetches. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review: await dialog calls and use detail param for error --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/actions/developerActions.ts | 31 +++++++++++++++++++ .../accounts/browser/defaultAccount.ts | 12 +++---- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/browser/actions/developerActions.ts b/src/vs/workbench/browser/actions/developerActions.ts index 91f52f5d2c5c3..ae37d27ad8b6f 100644 --- a/src/vs/workbench/browser/actions/developerActions.ts +++ b/src/vs/workbench/browser/actions/developerActions.ts @@ -922,6 +922,36 @@ class PolicyDiagnosticsAction extends Action2 { } } +class SyncAccountPolicyAction extends Action2 { + + constructor() { + super({ + id: 'workbench.action.syncAccountPolicy', + title: localize2('syncAccountPolicy', 'Sync Account Policy'), + category: Categories.Developer, + f1: true + }); + } + + async run(accessor: ServicesAccessor): Promise { + const defaultAccountService = accessor.get(IDefaultAccountService); + const dialogService = accessor.get(IDialogService); + const logService = accessor.get(ILogService); + + try { + logService.info('[DefaultAccount] Manually syncing account policy'); + await defaultAccountService.refresh({ forceRefresh: true }); + await dialogService.info(localize('syncAccountPolicy.success', "Account policy has been synced.")); + } catch (error) { + logService.error('[DefaultAccount] Failed to sync account policy', error); + await dialogService.error( + localize('syncAccountPolicy.error', "Failed to sync account policy."), + error instanceof Error ? error.message : String(error) + ); + } + } +} + // --- Actions Registration registerAction2(InspectContextKeysAction); registerAction2(ToggleScreencastModeAction); @@ -929,6 +959,7 @@ registerAction2(LogStorageAction); registerAction2(LogWorkingCopiesAction); registerAction2(RemoveLargeStorageEntriesAction); registerAction2(PolicyDiagnosticsAction); +registerAction2(SyncAccountPolicyAction); if (!product.commit) { registerAction2(StartTrackDisposables); registerAction2(SnapshotTrackedDisposables); diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index ab423fdf32f68..20eb5b46b5c37 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -536,7 +536,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid const entitlementsResult = await this.getEntitlements(sessions, accountPolicyData, options); const entitlementsData = entitlementsResult?.data; const entitlementsFetchedAt = entitlementsResult?.fetchedAt; - const tokenEntitlementsResult = entitlementsData?.chat_enabled ? await this.getTokenEntitlements(sessions, accountPolicyData) : undefined; + const tokenEntitlementsResult = entitlementsData?.chat_enabled ? await this.getTokenEntitlements(sessions, accountPolicyData, options) : undefined; const tokenEntitlementsFetchedAt: number | undefined = tokenEntitlementsResult?.fetchedAt; let mcpRegistryDataFetchedAt: number | undefined; @@ -548,7 +548,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid policyData.chat_preview_features_enabled = tokenEntitlementsData.policyData.chat_preview_features_enabled; policyData.mcp = tokenEntitlementsData.policyData.mcp; if (policyData.mcp) { - const mcpRegistryResult = await this.getMcpRegistryProvider(sessions, accountPolicyData); + const mcpRegistryResult = await this.getMcpRegistryProvider(sessions, accountPolicyData, options); mcpRegistryDataFetchedAt = mcpRegistryResult?.fetchedAt; policyData.mcpRegistryUrl = mcpRegistryResult?.data?.url; policyData.mcpAccess = mcpRegistryResult?.data?.registry_access; @@ -629,8 +629,8 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return expectedScopes.every(scope => scopes.includes(scope)); } - private async getTokenEntitlements(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined): Promise<{ data: { policyData: Partial; copilotTokenInfo: ICopilotTokenInfo } | undefined; fetchedAt: number }> { - if (accountPolicyData?.tokenEntitlementsFetchedAt && !this.isDataStale(accountPolicyData.tokenEntitlementsFetchedAt)) { + private async getTokenEntitlements(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined, options?: { forceRefresh?: boolean }): Promise<{ data: { policyData: Partial; copilotTokenInfo: ICopilotTokenInfo } | undefined; fetchedAt: number }> { + if (!options?.forceRefresh && accountPolicyData?.tokenEntitlementsFetchedAt && !this.isDataStale(accountPolicyData.tokenEntitlementsFetchedAt)) { this.logService.debug('[DefaultAccount] Using last fetched token entitlements data'); return { data: { policyData: accountPolicyData.policyData, copilotTokenInfo: this._copilotTokenInfo ?? {} }, fetchedAt: accountPolicyData.tokenEntitlementsFetchedAt }; } @@ -723,8 +723,8 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return { data: undefined, fetchedAt: Date.now() }; } - private async getMcpRegistryProvider(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined): Promise<{ data: IMcpRegistryProvider | null; fetchedAt: number } | undefined> { - if (accountPolicyData?.mcpRegistryDataFetchedAt && !this.isDataStale(accountPolicyData.mcpRegistryDataFetchedAt)) { + private async getMcpRegistryProvider(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined, options?: { forceRefresh?: boolean }): Promise<{ data: IMcpRegistryProvider | null; fetchedAt: number } | undefined> { + if (!options?.forceRefresh && accountPolicyData?.mcpRegistryDataFetchedAt && !this.isDataStale(accountPolicyData.mcpRegistryDataFetchedAt)) { this.logService.debug('[DefaultAccount] Using last fetched MCP registry data'); const data = accountPolicyData.policyData.mcpRegistryUrl && accountPolicyData.policyData.mcpAccess ? { url: accountPolicyData.policyData.mcpRegistryUrl, registry_access: accountPolicyData.policyData.mcpAccess } : null; return { data, fetchedAt: accountPolicyData.mcpRegistryDataFetchedAt }; From 5289257589967e4b9b361eb000a4be551610deee Mon Sep 17 00:00:00 2001 From: Anthony Kim <62267334+anthonykim1@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:11:42 -0700 Subject: [PATCH 40/56] Fix terminal auto-approve splitting commands at `--flag=value` in powershell (#310839) * Fix to put fix in treeSitterCommandParser * be better with comments --- .../browser/treeSitterCommandParser.ts | 26 +++++++++++++++++++ .../treeSitterCommandParser.test.ts | 19 ++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/treeSitterCommandParser.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/treeSitterCommandParser.ts index b6bea01c0f2f1..6e4d115f68f64 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/treeSitterCommandParser.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/treeSitterCommandParser.ts @@ -17,6 +17,23 @@ export const enum TreeSitterCommandParserLanguage { PowerShell = 'powershell', } +/** + * Matches a PowerShell command token of the form `-flag=` or `--flag=` at the + * start of input or following whitespace. Used to work around a tree-sitter + * PowerShell grammar limitation where POSIX-style `--flag=value` arguments + * (e.g. `git log --format="a|b"`) are parsed as assignment expressions and + * truncate the surrounding command. + * + * See https://github.com/microsoft/vscode/issues/294010 + * TODO: Remove once upstream tree-sitter PowerShell grammer is updated. + */ +const pwshFlagEqualsRegex = /(^|\s)(-{1,2}[\w-]+)=/g; + +// TODO: Remove once upstream tree-sitter PowerShell grammer is updated. +function maskPwshFlagEquals(commandLine: string): string { + return commandLine.replace(pwshFlagEqualsRegex, (_, pre, flag) => `${pre}${flag} `); +} + export class TreeSitterCommandParser extends Disposable { private readonly _parser: Lazy>; private readonly _treeCache = this._register(new TreeCache()); @@ -32,6 +49,15 @@ export class TreeSitterCommandParser extends Disposable { } async extractSubCommands(languageId: TreeSitterCommandParserLanguage, commandLine: string): Promise { + if (languageId === TreeSitterCommandParserLanguage.PowerShell) { + const masked = maskPwshFlagEquals(commandLine); + if (masked !== commandLine) { + const captures = await this._queryTree(languageId, masked, '(command) @command'); + // Masked command line has identical character positions, so slice the original + // to preserve the user-visible text (including the `=` characters). + return captures.map(e => commandLine.substring(e.node.startIndex, e.node.endIndex)); + } + } const captures = await this._queryTree(languageId, commandLine, '(command) @command'); return captures.map(e => e.node.text); } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/treeSitterCommandParser.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/treeSitterCommandParser.test.ts index 4b3c50bc0729b..1e676c46a9bc8 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/treeSitterCommandParser.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/treeSitterCommandParser.test.ts @@ -179,6 +179,25 @@ suite('TreeSitterCommandParser', () => { test('nested try-catch-finally', () => t('try { try { Get-Content "file" } catch { throw } } catch { Write-Error "outer" } finally { Write-Host "cleanup" }', ['Get-Content "file"', 'Write-Error "outer"', 'Write-Host "cleanup"'])); test('parallel processing', () => t('1..10 | ForEach-Object -Parallel { Start-Sleep 1; Write-Host $_ } ; Get-Date', ['1..10 ', 'ForEach-Object -Parallel { Start-Sleep 1; Write-Host $_ }', 'Start-Sleep 1', 'Write-Host $_', 'Get-Date'])); }); + + // https://github.com/microsoft/vscode/issues/294010 + // The upstream tree-sitter-powershell grammar parses POSIX-style + // `--flag=value` arguments as assignment expressions and truncates + // the surrounding command. The parser masks the `=` before parsing + // so these arguments are preserved as part of the sub-command. + suite('POSIX-style `--flag=value` arguments', () => { + test('double-dash flag with quoted value', () => t('git log --format="abc"', ['git log --format="abc"'])); + test('double-dash flag with value containing pipe', () => t('git log --format="a|b"', ['git log --format="a|b"'])); + test('double-dash flag with single-quoted value', () => t(`git log --format='%h|%s'`, [`git log --format='%h|%s'`])); + test('multiple flag=value arguments', () => t('git log --format="%h" --date=short HEAD -1', ['git log --format="%h" --date=short HEAD -1'])); + test('chained git log with format containing pipes', () => t( + 'git log --format="%h|%s|%an|%ad" --date=short dff523fc450 -1; git log --format="%h|%s|%an|%ad" --date=short 0a541d056d3 -1', + [ + 'git log --format="%h|%s|%an|%ad" --date=short dff523fc450 -1', + 'git log --format="%h|%s|%an|%ad" --date=short 0a541d056d3 -1', + ] + )); + }); }); suite('all shells', () => { From a03c093fd8aa7671df5fe288c39555ce9e3a5900 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Thu, 16 Apr 2026 16:20:51 -0700 Subject: [PATCH 41/56] agentHost: Fix issue with auth/models (#310866) * Log agent host catch failures (Written by Copilot) * Use observable models for agent host providers (Written by Copilot) * Fix root agents changed test narrowing (Written by Copilot) --------- Co-authored-by: Copilot --- .../platform/agentHost/common/agentService.ts | 5 +- .../agentHost/node/agentSideEffects.ts | 41 +++---- .../agentHost/node/copilot/copilotAgent.ts | 70 ++++++++++-- .../test/node/agentSideEffects.test.ts | 64 +++++++++-- .../agentHost/test/node/copilotAgent.test.ts | 102 +++++++++++++++++- .../platform/agentHost/test/node/mockAgent.ts | 14 +-- 6 files changed, 249 insertions(+), 47 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 1050188a6676b..77e09cfd1ee59 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -6,6 +6,7 @@ import { Event } from '../../../base/common/event.js'; import { IReference } from '../../../base/common/lifecycle.js'; import { IAuthorizationProtectedResourceMetadata } from '../../../base/common/oauth.js'; +import type { IObservable } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { ISyncedCustomization } from './agentPluginManager.js'; @@ -438,8 +439,8 @@ export interface IAgent { /** Return the descriptor for this agent. */ getDescriptor(): IAgentDescriptor; - /** List available models from this provider. */ - listModels(): Promise; + /** Available models from this provider. */ + readonly models: IObservable; /** List persisted sessions from this provider. */ listSessions(): Promise; diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 99316915b74ec..a73982dd41aed 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -6,7 +6,8 @@ import { disposableTimeout, SequencerByKey } from '../../../base/common/async.js'; import { match as globMatch } from '../../../base/common/glob.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; -import { autorun, IObservable } from '../../../base/common/observable.js'; +import { equals } from '../../../base/common/objects.js'; +import { autorun, IObservable, IReader } from '../../../base/common/observable.js'; import { extUriBiasedIgnorePathCase, normalizePath } from '../../../base/common/resources.js'; import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; @@ -15,6 +16,7 @@ import { ILogService } from '../../log/common/log.js'; import { IAgent, IAgentAttachment, IAgentProgressEvent, type IAgentToolCompleteEvent, type IAgentToolReadyEvent } from '../common/agentService.js'; import { IDiffComputeService } from '../common/diffComputeService.js'; import { ISessionDataService } from '../common/sessionDataService.js'; +import type { IAgentInfo } from '../common/state/protocol/state.js'; import { ActionType, ISessionAction } from '../common/state/sessionActions.js'; import { CustomizationStatus, @@ -26,7 +28,6 @@ import { buildSubagentSessionUri, getToolFileEdits, type ISessionCustomization, - type ISessionModelInfo, type ISessionState, type IToolResultContent, type URI as ProtocolURI, @@ -71,6 +72,7 @@ export class AgentSideEffects extends Disposable { private readonly _diffComputeService: IDiffComputeService; /** Serializes per-session diff computations to avoid races with stale previousDiffs. */ private readonly _diffComputationSequencer = new SequencerByKey(); + private _lastAgentInfos: readonly IAgentInfo[] = []; /** Per-session debounce timers for mid-turn diff computation. */ private readonly _debouncedDiffTimers = this._register(new DisposableMap()); private static readonly _DIFF_DEBOUNCE_MS = 5000; @@ -93,34 +95,33 @@ export class AgentSideEffects extends Disposable { // Whenever the agents observable changes, publish to root state. this._register(autorun(reader => { const agents = this._options.agents.read(reader); - this._publishAgentInfos(agents); + this._publishAgentInfos(agents, reader); })); } /** - * Fetches models from all agents and dispatches `root/agentsChanged`. + * Publishes agent descriptors using the last known model lists. */ - private async _publishAgentInfos(agents: readonly IAgent[]): Promise { - const infos = await Promise.all(agents.map(async a => { + private _publishAgentInfos(agents: readonly IAgent[], reader: IReader): void { + const infos: IAgentInfo[] = agents.map(a => { const d = a.getDescriptor(); - let models: ISessionModelInfo[]; - try { - const rawModels = await a.listModels(); - models = rawModels.map(m => ({ - id: m.id, provider: m.provider, name: m.name, - maxContextWindow: m.maxContextWindow, supportsVision: m.supportsVision, - policyState: m.policyState, - })); - } catch (err) { - this._logService.error(err, `[AgentSideEffects] Failed to list models for agent '${a.id}'`); - models = []; - } const protectedResources = a.getProtectedResources(); return { - provider: d.provider, displayName: d.displayName, description: d.description, models, + provider: d.provider, displayName: d.displayName, description: d.description, models: a.models.read(reader).map(m => ({ + id: m.id, + provider: m.provider, + name: m.name, + maxContextWindow: m.maxContextWindow, + supportsVision: m.supportsVision, + policyState: m.policyState, + })), protectedResources: protectedResources.length > 0 ? protectedResources : undefined, }; - })); + }); + if (equals(this._lastAgentInfos, infos)) { + return; + } + this._lastAgentInfos = infos; this._stateManager.dispatchServerAction({ type: ActionType.RootAgentsChanged, agents: infos }); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index e570a5d2029a7..d419df9579823 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -11,6 +11,7 @@ import { Emitter } from '../../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { FileAccess } from '../../../../base/common/network.js'; +import { observableValue } from '../../../../base/common/observable.js'; import { equals } from '../../../../base/common/objects.js'; import { basename, delimiter, dirname } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; @@ -25,6 +26,7 @@ import { AgentHostSessionConfigBranchNameHintKey, AgentSession, IAgent, IAgentAt import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js'; import type { IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { IProtectedResourceMetadata, type IToolDefinition } from '../../common/state/protocol/state.js'; +import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; import { CustomizationStatus, ICustomizationRef, SessionInputResponseKind, type IPendingMessage, type ISessionInputAnswer, type IToolCallResult, type PolicyState } from '../../common/state/sessionState.js'; import { IAgentHostGitService } from '../agentHostGitService.js'; import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; @@ -60,6 +62,8 @@ export class CopilotAgent extends Disposable implements IAgent { private readonly _onDidSessionProgress = this._register(new Emitter()); readonly onDidSessionProgress = this._onDidSessionProgress.event; + private readonly _models = observableValue(this, []); + readonly models = this._models; private _client: CopilotClient | undefined; private _clientStarting: Promise | undefined; @@ -113,25 +117,55 @@ export class CopilotAgent extends Disposable implements IAgent { this._logService.info(`[Copilot] Auth token ${tokenChanged ? 'updated' : 'unchanged'}`); if (tokenChanged && this._client && this._sessions.size === 0) { this._logService.info('[Copilot] Restarting CopilotClient with new token'); - const client = this._client; - this._client = undefined; - this._clientStarting = undefined; - await client.stop(); + await this._stopClient(); + } + if (tokenChanged) { + void this._refreshModels(); } return true; } + private async _refreshModels(): Promise { + const tokenAtRefreshStart = this._githubToken; + if (!tokenAtRefreshStart) { + this._models.set([], undefined); + return; + } + try { + const models = await this._listModels(); + if (this._githubToken === tokenAtRefreshStart) { + this._models.set(models, undefined); + } + } catch (err) { + this._logService.error(err, '[Copilot] Failed to refresh models'); + if (this._githubToken === tokenAtRefreshStart) { + this._models.set([], undefined); + } + } + } + + private async _stopClient(): Promise { + const client = this._client; + this._client = undefined; + this._clientStarting = undefined; + await client?.stop(); + } + // ---- client lifecycle --------------------------------------------------- private async _ensureClient(): Promise { + const tokenAtStartup = this._githubToken; + if (!tokenAtStartup) { + throw new ProtocolError(AHP_AUTH_REQUIRED, 'Authentication is required to use Copilot'); + } if (this._client) { return this._client; } if (this._clientStarting) { return this._clientStarting; } - this._clientStarting = (async () => { - this._logService.info(`[Copilot] Starting CopilotClient... ${this._githubToken ? '(with token)' : '(no token)'}`); + const clientStarting = (async () => { + this._logService.info('[Copilot] Starting CopilotClient... (with token)'); // Build a clean env for the CLI subprocess, stripping Electron/VS Code vars // that can interfere with the Node.js process the SDK spawns. @@ -168,26 +202,38 @@ export class CopilotAgent extends Disposable implements IAgent { this._logService.info(`[Copilot] Resolved CLI path: ${cliPath}`); const client = new CopilotClient({ - githubToken: this._githubToken, - useLoggedInUser: !this._githubToken, + githubToken: tokenAtStartup, + useLoggedInUser: false, useStdio: true, autoStart: true, env, cliPath, }); await client.start(); + if (this._githubToken !== tokenAtStartup) { + await client.stop(); + throw new Error('Copilot authentication changed while the client was starting'); + } this._logService.info('[Copilot] CopilotClient started successfully'); this._client = client; this._clientStarting = undefined; return client; })(); - return this._clientStarting; + this._clientStarting = clientStarting; + void clientStarting.catch(() => { + this._clientStarting = undefined; + }); + return clientStarting; } // ---- session management ------------------------------------------------- async listSessions(): Promise { this._logService.info('[Copilot] Listing sessions...'); + if (!this._githubToken) { + this._logService.trace('[Copilot] No auth token; returning no sessions'); + return []; + } const client = await this._ensureClient(); const sessions = await client.listSessions(); const projectLimiter = new Limiter(4); @@ -214,8 +260,12 @@ export class CopilotAgent extends Disposable implements IAgent { return result; } - async listModels(): Promise { + private async _listModels(): Promise { this._logService.info('[Copilot] Listing models...'); + if (!this._githubToken) { + this._logService.trace('[Copilot] No auth token; returning no models'); + return []; + } const client = await this._ensureClient(); const models = await client.listModels(); const result = models.map(m => ({ diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index f66b5605dd2e4..3becd8fd502f2 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { VSBuffer } from '../../../../base/common/buffer.js'; +import { Event } from '../../../../base/common/event.js'; import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { observableValue } from '../../../../base/common/observable.js'; @@ -334,17 +335,66 @@ suite('AgentSideEffects', () => { suite('agents observable', () => { - test('dispatches root/agentsChanged when observable changes', async () => { - const envelopes: IActionEnvelope[] = []; - disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - + test('dispatches root/agentsChanged without fetching models when observable changes', async () => { + agentList.set([], undefined); + const envelope = Event.toPromise(Event.filter(stateManager.onDidEmitEnvelope, e => { + if (e.action.type !== ActionType.RootAgentsChanged) { + return false; + } + return e.action.agents.length === 1; + })); agentList.set([agent], undefined); + const { action } = await envelope; + assert.strictEqual(action.type, ActionType.RootAgentsChanged); - // Model fetch is async โ€” wait for it - await new Promise(r => setTimeout(r, 50)); + assert.deepStrictEqual(action.agents[0].models, []); + }); + + test('model observable update publishes models', async () => { + const envelopes: IActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - const action = envelopes.find(e => e.action.type === ActionType.RootAgentsChanged); + const envelope = Event.toPromise(Event.filter(stateManager.onDidEmitEnvelope, e => { + if (e.action.type !== ActionType.RootAgentsChanged) { + return false; + } + return e.action.agents[0]?.models.length === 1; + })); + agent.setModels([{ provider: 'mock', id: 'mock-model', name: 'mock Model', maxContextWindow: 128000, supportsVision: false, supportsReasoningEffort: false }]); + await envelope; + + const actions = envelopes.map(e => e.action).filter(action => action.type === ActionType.RootAgentsChanged); + const action = actions[actions.length - 1]; assert.ok(action, 'should dispatch root/agentsChanged'); + assert.deepStrictEqual(action.agents[0].models, [{ + id: 'mock-model', + provider: 'mock', + name: 'mock Model', + maxContextWindow: 128000, + supportsVision: false, + policyState: undefined, + }]); + }); + + test('unchanged model observable update does not dispatch unchanged agent infos', async () => { + const envelopes: IActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + const models = [{ provider: 'mock' as const, id: 'mock-model', name: 'mock Model', maxContextWindow: 128000, supportsVision: false, supportsReasoningEffort: false }]; + + const envelope = Event.toPromise(Event.filter(stateManager.onDidEmitEnvelope, e => { + if (e.action.type !== ActionType.RootAgentsChanged) { + return false; + } + return e.action.agents[0]?.models.length === 1; + })); + agent.setModels(models); + await envelope; + envelopes.length = 0; + agent.setModels([...models]); + await Promise.resolve(); + await Promise.resolve(); + + assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.RootAgentsChanged).length, 0); }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 87e6587704606..7dbb088fe6c05 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -4,9 +4,83 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { getCopilotWorktreeBranchName, getCopilotWorktreeName, getCopilotWorktreesRoot } from '../../node/copilot/copilotAgent.js'; +import { FileService } from '../../../files/common/fileService.js'; +import { IFileService } from '../../../files/common/files.js'; +import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; +import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; +import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; +import { ILogService, NullLogService } from '../../../log/common/log.js'; +import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; +import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; +import { ISessionCustomization, ICustomizationRef } from '../../common/state/sessionState.js'; +import { IAgentHostGitService } from '../../node/agentHostGitService.js'; +import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; +import { CopilotAgent, getCopilotWorktreeBranchName, getCopilotWorktreeName, getCopilotWorktreesRoot } from '../../node/copilot/copilotAgent.js'; +import { createNullSessionDataService } from '../common/sessionTestHelpers.js'; + +class TestAgentPluginManager implements IAgentPluginManager { + declare readonly _serviceBrand: undefined; + + async syncCustomizations(_clientId: string, _customizations: ICustomizationRef[], _progress?: (status: ISessionCustomization[]) => void): Promise { + return []; + } +} + +class TestAgentHostGitService implements IAgentHostGitService { + declare readonly _serviceBrand: undefined; + + async isInsideWorkTree(): Promise { return false; } + async getCurrentBranch(): Promise { return undefined; } + async getDefaultBranch(): Promise { return undefined; } + async getBranches(): Promise { return []; } + async getRepositoryRoot(): Promise { return undefined; } + async getWorktreeRoots(): Promise { return []; } + async addWorktree(): Promise { } + async removeWorktree(): Promise { } +} + +class TestAgentHostTerminalManager implements IAgentHostTerminalManager { + declare readonly _serviceBrand: undefined; + + async createTerminal(): Promise { } + writeInput(): void { } + onData(): IDisposable { return Disposable.None; } + onExit(): IDisposable { return Disposable.None; } + onClaimChanged(): IDisposable { return Disposable.None; } + onCommandFinished(): IDisposable { return Disposable.None; } + getContent(): string | undefined { return undefined; } + getClaim(): undefined { return undefined; } + hasTerminal(): boolean { return false; } + getExitCode(): number | undefined { return undefined; } + supportsCommandDetection(): boolean { return false; } + disposeTerminal(): void { } + getTerminalInfos(): [] { return []; } + getTerminalState(): undefined { return undefined; } +} + +function createTestAgent(disposables: DisposableStore): CopilotAgent { + const services = new ServiceCollection(); + const logService = new NullLogService(); + const fileService = disposables.add(new FileService(logService)); + services.set(ILogService, logService); + services.set(IFileService, fileService); + services.set(ISessionDataService, createNullSessionDataService()); + services.set(IAgentPluginManager, new TestAgentPluginManager()); + services.set(IAgentHostGitService, new TestAgentHostGitService()); + services.set(IAgentHostTerminalManager, new TestAgentHostTerminalManager()); + const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); + services.set(IInstantiationService, instantiationService); + return instantiationService.createInstance(CopilotAgent); +} + +async function disposeAgent(agent: CopilotAgent): Promise { + agent.dispose(); + await Promise.resolve(); +} suite('CopilotAgent', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -30,4 +104,30 @@ suite('CopilotAgent', () => { test('keeps hinted branch names short', () => { assert.strictEqual(getCopilotWorktreeBranchName('12345678-aaaa-bbbb-cccc-123456789abc', 'a'.repeat(48)).length, 'agents/'.length + 48 + '-12345678'.length); }); + + test('returns empty models and sessions before authentication', async () => { + const disposables = new DisposableStore(); + const agent = createTestAgent(disposables); + try { + assert.deepStrictEqual(agent.models.get(), []); + assert.deepStrictEqual(await agent.listSessions(), []); + } finally { + await disposeAgent(agent); + disposables.dispose(); + } + }); + + test('requires authentication before creating a session', async () => { + const disposables = new DisposableStore(); + const agent = createTestAgent(disposables); + try { + await assert.rejects( + () => agent.createSession({ workingDirectory: URI.file('/workspace') }), + (error: Error) => error instanceof ProtocolError && error.code === AHP_AUTH_REQUIRED, + ); + } finally { + await disposeAgent(agent); + disposables.dispose(); + } + }); }); diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index aaaa5058be8fc..651ed179f1fa4 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -5,6 +5,7 @@ import { timeout } from '../../../../base/common/async.js'; import { Emitter } from '../../../../base/common/event.js'; +import { observableValue } from '../../../../base/common/observable.js'; import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; import { URI } from '../../../../base/common/uri.js'; import { type ISyncedCustomization } from '../../common/agentPluginManager.js'; @@ -27,6 +28,8 @@ function mockProject(provider: AgentProvider) { export class MockAgent implements IAgent { private readonly _onDidSessionProgress = new Emitter(); readonly onDidSessionProgress = this._onDidSessionProgress.event; + private readonly _models = observableValue(this, []); + readonly models = this._models; private readonly _sessions = new Map(); private _nextId = 1; @@ -41,7 +44,6 @@ export class MockAgent implements IAgent { readonly authenticateCalls: { resource: string; token: string }[] = []; readonly setClientCustomizationsCalls: { clientId: string; customizations: ICustomizationRef[] }[] = []; readonly setCustomizationEnabledCalls: { uri: string; enabled: boolean }[] = []; - /** Configurable return value for getCustomizations. */ customizations: ICustomizationRef[] = []; @@ -64,8 +66,8 @@ export class MockAgent implements IAgent { return []; } - async listModels(): Promise { - return [{ provider: this.id, id: `${this.id}-model`, name: `${this.id} Model`, maxContextWindow: 128000, supportsVision: false, supportsReasoningEffort: false }]; + setModels(models: readonly IAgentModelInfo[]): void { + this._models.set(models, undefined); } async listSessions(): Promise { @@ -178,6 +180,8 @@ export class ScriptedMockAgent implements IAgent { private readonly _onDidSessionProgress = new Emitter(); readonly onDidSessionProgress = this._onDidSessionProgress.event; + private readonly _models = observableValue(this, [{ provider: 'mock', id: 'mock-model', name: 'Mock Model', maxContextWindow: 128000, supportsVision: false, supportsReasoningEffort: false }]); + readonly models = this._models; private readonly _sessions = new Map(); private _nextId = 1; @@ -211,10 +215,6 @@ export class ScriptedMockAgent implements IAgent { return []; } - async listModels(): Promise { - return [{ provider: 'mock', id: 'mock-model', name: 'Mock Model', maxContextWindow: 128000, supportsVision: false, supportsReasoningEffort: false }]; - } - async listSessions(): Promise { return [...this._sessions.values()].map(s => ({ session: s, From 5f42e17cf9b4592796da0381ad927c395ff2934e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 23:21:15 +0000 Subject: [PATCH 42/56] Bump hono from 4.12.12 to 4.12.14 in /extensions/copilot (#310373) Bumps [hono](https://github.com/honojs/hono) from 4.12.12 to 4.12.14. - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.12...v4.12.14) --- updated-dependencies: - dependency-name: hono dependency-version: 4.12.14 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- extensions/copilot/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index 7561623678093..8711d9f602efd 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -13356,9 +13356,9 @@ } }, "node_modules/hono": { - "version": "4.12.12", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", - "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", + "version": "4.12.14", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", + "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", "license": "MIT", "engines": { "node": ">=16.9.0" From 717d2a30e12553beb300dc7483aa7b21450ad5fc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 23:21:27 +0000 Subject: [PATCH 43/56] Bump hono from 4.12.12 to 4.12.14 in /test/mcp (#310374) Bumps [hono](https://github.com/honojs/hono) from 4.12.12 to 4.12.14. - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.12...v4.12.14) --- updated-dependencies: - dependency-name: hono dependency-version: 4.12.14 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- test/mcp/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/mcp/package-lock.json b/test/mcp/package-lock.json index 70da676d9204c..bae3bfa5d9462 100644 --- a/test/mcp/package-lock.json +++ b/test/mcp/package-lock.json @@ -702,9 +702,9 @@ } }, "node_modules/hono": { - "version": "4.12.12", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", - "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", + "version": "4.12.14", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", + "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", "license": "MIT", "engines": { "node": ">=16.9.0" From dccd12b0a110c8cc4182080b1ada398305a63675 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:18:22 -0700 Subject: [PATCH 44/56] Restrict autoImportSpecifierExcludeRegexes to trusted workspaces --- extensions/typescript-language-features/package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index 879be480493bb..522bacf051598 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -32,7 +32,10 @@ "typescript.tsserver.nodePath", "js/ts.tsserver.node.path", "js/ts.tsserver.diagnosticDir", - "js/ts.tsserver.heapProfile" + "js/ts.tsserver.heapProfile", + "js/ts.preferences.autoImportSpecifierExcludeRegexes", + "typescript.preferences.autoImportSpecifierExcludeRegexes", + "javascript.preferences.autoImportSpecifierExcludeRegexes" ] } }, From 4cd72fd26a058518a07de8940ae1adc468093a33 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:12:55 -0700 Subject: [PATCH 45/56] Always open Chat Customizations overview from sidebar (#310871) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/aiCustomizationOverviewView.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationOverviewView.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationOverviewView.ts index 805b479828c0d..279c2d94a5b32 100644 --- a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationOverviewView.ts +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationOverviewView.ts @@ -24,7 +24,6 @@ import { IPromptsService } from '../../../../workbench/contrib/chat/common/promp import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; import { AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; import { AICustomizationManagementEditorInput } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; -import { AICustomizationManagementEditor } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; import { agentIcon, instructionsIcon, mcpServerIcon, pluginIcon, skillIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; @@ -132,22 +131,22 @@ export class AICustomizationOverviewView extends ViewPane { countElement.textContent = `${section.count}`; this.countElements.set(section.id, countElement); - // Click handler to open management editor at section + // Click handler to open the management editor overview this._register(DOM.addDisposableListener(sectionElement, 'click', () => { - this.openSection(section.id); + this.openOverview(); })); // Keyboard support this._register(DOM.addDisposableListener(sectionElement, 'keydown', (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - this.openSection(section.id); + this.openOverview(); } })); // Hover tooltip this._register(this.hoverService.setupDelayedHoverAtMouse(sectionElement, () => ({ - content: localize('openSection', "Open {0} in Chat Customizations editor", section.label), + content: localize('openOverview', "Open Chat Customizations editor"), appearance: { compact: true, skipFadeInAnimation: true } }))); } @@ -221,14 +220,9 @@ export class AICustomizationOverviewView extends ViewPane { } } - private async openSection(sectionId: AICustomizationManagementSection): Promise { + private async openOverview(): Promise { const input = AICustomizationManagementEditorInput.getOrCreate(); - const editor = await this.editorService.openEditor(input, { pinned: true }); - - // Deep-link to the section - if (editor instanceof AICustomizationManagementEditor) { - editor.selectSectionById(sectionId); - } + await this.editorService.openEditor(input, { pinned: true }); } protected override layoutBody(height: number, width: number): void { From 51adf6f186fe360324de0d158b316729ab8c7186 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:21:01 -0400 Subject: [PATCH 46/56] Make sure we clear notebook styles Fixes #310070 --- .../workbench/contrib/notebook/browser/notebookEditorWidget.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts index c0a9ead9104cc..2c6b557617850 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts @@ -1952,6 +1952,8 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD clippingContainer = this.layoutService.getContainer(DOM.getWindow(this.getDomNode()), Parts.EDITOR_PART); } + this._overlayContainer.style.visibility = 'visible'; + this._overlayContainer.style.left = ''; // Clear hide offset this._overlayLayout.layoutOverAnchorElement(shadowElement, { clippingContainer, fallbackDimension: dimension, fallbackPosition: position }); } From 88cd054ab254349e045ea546b064d663c9fe072e Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:32:19 -0700 Subject: [PATCH 47/56] Add agentName to chronicle telemetry (#310880) * add agentName to telemetry Co-authored-by: Copilot * comment update --------- Co-authored-by: Copilot --- .../vscode-node/remoteSessionExporter.ts | 14 +++++++++++++- .../chronicle/vscode-node/sessionStoreTracker.ts | 16 ++++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts b/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts index a4552679e52c9..c90b5dbc958da 100644 --- a/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts +++ b/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts @@ -83,6 +83,9 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr private _isFlushing = false; private _firstCloudWriteLogged = false; + /** The session source of the first initialized session (for firstWrite telemetry). */ + private _firstCloudWriteSessionSource: string | undefined; + /** Resolved lazily on first use. */ private _repository: GitHubRepository | undefined; private _repositoryResolved = false; @@ -245,10 +248,16 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr // โ”€โ”€ Lazy session initialization โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - private async _initializeSession(sessionId: string, _triggerSpan: ICompletedSpanData): Promise { + private async _initializeSession(sessionId: string, triggerSpan: ICompletedSpanData): Promise { this._initializingSessions.add(sessionId); try { + const sessionSource = (triggerSpan.attributes[GenAiAttr.AGENT_NAME] as string | undefined) ?? 'unknown'; + + // Track the source of the very first session for firstWrite telemetry + if (!this._firstCloudWriteSessionSource) { + this._firstCloudWriteSessionSource = sessionSource; + } const repo = await this._resolveRepository(); if (!repo) { this._disabledSessions.add(sessionId); @@ -268,6 +277,7 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr "owner": "vijayu", "comment": "Tracks cloud sync operations (session init, creation, flush, errors)", "operation": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The operation performed." }, +"sessionSource": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The agent name/source for the session, or unknown if unavailable." }, "success": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether the operation succeeded." }, "error": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Truncated error message if failed." }, "indexingLevel": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The indexing level for the session." }, @@ -277,6 +287,7 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr this._telemetryService.sendMSFTTelemetryEvent('chronicle.cloudSync', { operation: 'sessionInit', success: 'true', + sessionSource, }); } catch (err) { @@ -539,6 +550,7 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr this._telemetryService.sendMSFTTelemetryEvent('chronicle.cloudSync', { operation: 'firstWrite', + sessionSource: this._firstCloudWriteSessionSource ?? 'unknown', }, {}); } } else if (!allSuccess) { diff --git a/extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts b/extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts index 995890c25c130..1dee652e3cc3f 100644 --- a/extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts +++ b/extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts @@ -97,6 +97,7 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib "owner": "vijayu", "comment": "Tracks local session store operations (init, write, flush errors)", "operation": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The operation performed." }, +"sessionSource": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The agent name/source for the session, or unknown if unavailable." }, "success": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether the operation succeeded." }, "error": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Truncated error message if failed." }, "opsCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Number of buffered operations in a failed flush." } @@ -154,7 +155,7 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib if (operationName !== GenAiOperationName.INVOKE_AGENT) { return; } - this._initSession(sessionId); + this._initSession(sessionId, span); } // Extract metadata from any span that carries workspace/user info @@ -183,13 +184,20 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib ?? (span.attributes[CopilotChatAttr.SESSION_ID] as string | undefined); } - private _initSession(sessionId: string): void { + private _initSession(sessionId: string, span: ICompletedSpanData): void { this._initializedSessions.add(sessionId); this._bufferSessionUpsert({ id: sessionId, host_type: 'vscode' }); + const sessionSource = (span.attributes[GenAiAttr.AGENT_NAME] as string | undefined) ?? 'unknown'; + + // Track the source of the very first session for firstWrite telemetry + if (!this._firstWriteSessionSource) { + this._firstWriteSessionSource = sessionSource; + } this._telemetryService.sendMSFTTelemetryEvent('chronicle.localStore', { operation: 'sessionInit', + sessionSource, }, {}); } @@ -362,6 +370,9 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib /** Whether we've already sent a successful-write telemetry event. */ private _firstWriteLogged = false; + /** The session source of the first initialized session (for firstWrite telemetry). */ + private _firstWriteSessionSource: string | undefined; + // โ”€โ”€ Flush: batch all buffered writes into one transaction โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ private _flush(): void { @@ -402,6 +413,7 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib this._telemetryService.sendMSFTTelemetryEvent('chronicle.localStore', { operation: 'firstWrite', + sessionSource: this._firstWriteSessionSource ?? 'unknown', }, {}); } } catch (err) { From 6bc5ae80de9caffb21e9eb58e18b5ca24fa2d6e8 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:59:44 -0400 Subject: [PATCH 48/56] Fix chat sessions not preserved Partially revert 96a69e3d03d6aaaeabb60fce9ba43ab8418a6d13 This re-introduces a issue with sessions not being cleaned up but fixes the more important regression --- .../contrib/chat/common/chatService/chatServiceImpl.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 5e0a11076d805..c10495b475101 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -894,7 +894,7 @@ export class ChatService extends Disposable implements IChatService { this.chatSessionService.updateSessionOptions(model.sessionResource, initialSessionOptions); } - this.chatSessionService.fireSessionCommitted(sessionResource, newItem.resource); + // this.chatSessionService.fireSessionCommitted(sessionResource, newItem.resource); sessionResource = newItem.resource; newSessionResource = newItem.resource; @@ -946,7 +946,7 @@ export class ChatService extends Disposable implements IChatService { }, }; } finally { - tempRef?.dispose(); + // tempRef?.dispose(); } } From 37d6ea174001bc429d076588a7ebf1fd946d1bdd Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Thu, 16 Apr 2026 19:18:02 -0700 Subject: [PATCH 49/56] Simplify compaction fallback: use Simple mode on budget exceeded (#310889) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify compaction fallback: use Simple mode on budget exceeded, skip Full When the main prompt render exceeds the token budget and falls back to foreground summarization (renderWithSummarization), skip Full mode and go straight to Simple mode via forceSimpleSummary. This saves a wasted LLM request since Full would likely also fail under the same budget pressure, and Simple is guaranteed to fit via hard tool-result truncation. - Add forceSimpleSummary prop to AgentPromptProps and SummarizedAgentHistoryProps - Honor forceSimpleSummary in getSummaryWithFallback (unless config forces Full) - Remove summarizationSource prop and source telemetry field (no longer needed) - Clean up stale GDPR annotations for removed source field - /compact and background standard-mode paths keep existing Full โ†’ Simple chain --- .../src/extension/intents/node/agentIntent.ts | 6 +----- .../src/extension/prompts/node/agent/agentPrompt.tsx | 9 ++++++--- .../node/agent/summarizedConversationHistory.tsx | 12 +++++++----- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/extensions/copilot/src/extension/intents/node/agentIntent.ts b/extensions/copilot/src/extension/intents/node/agentIntent.ts index 9e309f4c2deca..6fc9b72573250 100644 --- a/extensions/copilot/src/extension/intents/node/agentIntent.ts +++ b/extensions/copilot/src/extension/intents/node/agentIntent.ts @@ -539,6 +539,7 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I endpoint: this.endpoint, promptContext: renderProps.promptContext, triggerSummarize: true, + forceSimpleSummary: true, }); return await renderer.render(progress, token); } catch (e) { @@ -862,7 +863,6 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I "outcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The success state." }, "model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model ID." }, "summarizationMode": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The summarization mode." }, - "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether background or foreground." }, "conversationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Session id." }, "chatRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The chat request ID." }, "lastUsedTool": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The last tool used before summarization." }, @@ -881,7 +881,6 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I outcome: 'success', model: this.endpoint.model, summarizationMode: 'inline', - source: 'background', conversationId, chatRequestId: associatedRequestId, lastUsedTool, @@ -925,7 +924,6 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I endpoint: this.endpoint, promptContext: snapshotProps.promptContext, triggerSummarize: true, - summarizationSource: 'background', }); const bgProgress: vscode.Progress = { report: () => { } }; const bgRenderResult = await bgRenderer.render(bgProgress, bgToken); @@ -960,7 +958,6 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I "detailedOutcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Detailed failure reason." }, "model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model ID." }, "summarizationMode": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The summarization mode." }, - "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether background or foreground." }, "conversationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Session id." }, "chatRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The chat request ID." }, "duration": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Duration in ms." } @@ -971,7 +968,6 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I detailedOutcome: err instanceof Error ? err.message : String(err), model: this.endpoint.model, summarizationMode: 'inline', - source: 'background', conversationId, chatRequestId: associatedRequestId, }, { diff --git a/extensions/copilot/src/extension/prompts/node/agent/agentPrompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/agentPrompt.tsx index e80f70291fbcd..502ca65ad021c 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/agentPrompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/agentPrompt.tsx @@ -71,8 +71,11 @@ export interface AgentPromptProps extends GenericBasePromptElementProps { */ readonly customizations?: AgentPromptCustomizations; - /** Whether this summarization was triggered as a background or foreground operation. */ - readonly summarizationSource?: 'background' | 'foreground'; + /** + * Prefer Simple mode for summarization, typically for the budget-exceeded recovery path. + * An explicit summarization mode configuration can still force Full mode. + */ + readonly forceSimpleSummary?: boolean; } /** Proportion of the prompt token budget any singular textual tool result is allowed to use. */ @@ -143,6 +146,7 @@ export class AgentPrompt extends PromptElement { { endpoint={this.props.endpoint} tools={this.props.promptContext.tools?.availableTools} enableCacheBreakpoints={this.props.enableCacheBreakpoints} - summarizationSource={this.props.summarizationSource} userQueryTagName={userQueryTagName} ReminderInstructionsClass={ReminderInstructionsClass} ToolReferencesHintClass={ToolReferencesHintClass} diff --git a/extensions/copilot/src/extension/prompts/node/agent/summarizedConversationHistory.tsx b/extensions/copilot/src/extension/prompts/node/agent/summarizedConversationHistory.tsx index de2474528a7a3..f99392b102dd6 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/summarizedConversationHistory.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/summarizedConversationHistory.tsx @@ -405,8 +405,8 @@ export interface SummarizedAgentHistoryProps extends BasePromptElementProps, Age readonly maxSummaryTokens?: number; /** Optional custom instructions to include in the summarization prompt */ readonly summarizationInstructions?: string; - /** Whether this summarization was triggered as a background or foreground operation. Defaults to 'foreground'. */ - readonly summarizationSource?: 'background' | 'foreground'; + /** Skip Full mode and go straight to Simple mode for foreground budget-exceeded recovery. */ + readonly forceSimpleSummary?: boolean; } /** @@ -604,6 +604,10 @@ class ConversationHistorySummarizer { private async getSummaryWithFallback(propsInfo: ISummarizedConversationHistoryInfo): Promise { const forceMode = this.configurationService.getConfig(ConfigKey.Advanced.AgentHistorySummarizationMode); + if (this.props.forceSimpleSummary && forceMode !== SummaryMode.Full) { + // Foreground budget-exceeded recovery โ€” go straight to Simple. + return await this.getSummary(SummaryMode.Simple, propsInfo); + } if (forceMode === SummaryMode.Simple) { return await this.getSummary(SummaryMode.Simple, propsInfo); } else { @@ -874,8 +878,7 @@ class ConversationHistorySummarizer { "duration": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The duration of the summarization attempt in ms." }, "promptTokenCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of prompt tokens, server side counted", "isMeasurement": true }, "promptCacheTokenCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of prompt tokens hitting cache as reported by server", "isMeasurement": true }, - "responseTokenCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of generated tokens", "isMeasurement": true }, - "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the summarization was triggered as a background or foreground operation." } + "responseTokenCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of generated tokens", "isMeasurement": true } } */ this.telemetryService.sendMSFTTelemetryEvent('summarizedConversationHistory', { @@ -889,7 +892,6 @@ class ConversationHistorySummarizer { conversationId, mode, summarizationMode: mode, // Try to unstick GDPR - source: this.props.summarizationSource ?? 'foreground', }, { numRounds, numRoundsSinceLastSummarization, From 4f9f0ea1a999cfd0ab0522dd59de50c870da009a Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst <39639992+hawkticehurst@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:25:20 -0400 Subject: [PATCH 50/56] Tone down sessions shell gradient (#310873) --- src/vs/sessions/LAYOUT.md | 3 ++- src/vs/sessions/browser/media/style.css | 18 +++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 3116f5887c661..13ea6570c2e62 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -425,7 +425,7 @@ Each agent session part uses separate storage keys to avoid conflicts with regul ### 9.5 Part Borders and Card Appearance -Parts manage their own border and background styling via the `updateStyles()` method. In the default light theme, the sessions workbench surface uses the off-white workbench/sidebar background while the card-like chat, auxiliary bar, and panel surfaces use the brighter editor background. Light themes also override the chat, auxiliary bar, and panel card border color in CSS to use `editorWidget.border`, giving those cards a darker outline. Dark and high-contrast mappings continue to use the existing part border tokens. The optional shell gradient treatment is gated behind the application setting `sessions.experimental.shellGradientBackground`. When that setting is disabled, the sessions shell uses the same solid sidebar/grid backgrounds and sidebar view styling as the upstream default experience. When enabled, the sessions shell adds a single root-level background layer in `browser/media/style.css` (`.agent-sessions-workbench.experimental-shell-gradient-background::before`) that sits behind the workbench parts and falls back to the normal solid shell background when `color-mix(...)` is unavailable. When supported, the layer derives its tint from the theme's primary accent signal in `button.background`. The gradient runs from the base shell color at the top-left toward a gentle accent tint in the bottom-right; light themes use a transparentized accent overlay to preserve a bit more of the original accent hue, dark themes use direct mixes into the shell background, and high-contrast themes disable the gradient entirely for accessibility. Titlebar/sidebar wrappers are made transparent so that one shared layer reads continuously across the whole window chrome without clipping at part boundaries. These surfaces use a **card appearance** with CSS variables for background and border: +Parts manage their own border and background styling via the `updateStyles()` method. In the default light theme, the sessions workbench surface uses the off-white workbench/sidebar background while the card-like chat, auxiliary bar, and panel surfaces use the brighter editor background. Light themes also override the chat, auxiliary bar, and panel card border color in CSS to use `editorWidget.border`, giving those cards a darker outline. Dark and high-contrast mappings continue to use the existing part border tokens. The optional shell gradient treatment is gated behind the application setting `sessions.experimental.shellGradientBackground`. When that setting is disabled, the sessions shell uses the same solid sidebar/grid backgrounds and sidebar view styling as the upstream default experience. When enabled, the sessions shell adds a single root-level background layer in `browser/media/style.css` (`.agent-sessions-workbench.experimental-shell-gradient-background::before`) that sits behind the workbench parts and falls back to the normal solid shell background when `color-mix(...)` is unavailable. When supported, the layer derives its tint from the theme's primary accent signal in `button.background`. The gradient runs from the base shell color at the top-left toward a gentle, deliberately low-contrast accent tint in the bottom-right; light themes use a transparentized accent overlay to preserve a bit more of the original accent hue without letting it dominate the shell, dark themes use shallower direct mixes into the shell background, and high-contrast themes disable the gradient entirely for accessibility. Titlebar/sidebar wrappers are made transparent so that one shared layer reads continuously across the whole window chrome without clipping at part boundaries. These surfaces use a **card appearance** with CSS variables for background and border: | Part | Styling | Notes | |------|---------|-------| @@ -657,6 +657,7 @@ interface IPartVisibilityState { | Date | Change | |------|--------| +| 2026-04-16 | Softened the experimental sessions shell gradient by reducing the accent tint mix strength across the shared default, light-theme, and dark-theme variants so the primary color reads more subtly behind the workbench chrome. | | 2026-04-16 | Updated the layout visual representation to show the editor part in the top-right row and mark it as hidden by default. | | 2026-04-16 | Fixed the sessions workbench so modal editor opens no longer hide an already visible main editor part, and documented that the main editor stays hidden by default but can be revealed by explicit non-modal editor flows. | | 2026-04-15 | Updated the Sessions sidebar so pinned chats render in their own "Pinned" section header using the standard uppercase section styling, and that header only exposes the "Mark All as Done" section action. | diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 82b59a09fd47b..413bbfb130fb3 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -34,9 +34,9 @@ linear-gradient( to bottom right, transparent 0%, - color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 7%, transparent) 58%, - color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 10%, transparent) 82%, - color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 12%, transparent) 100% + color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 5%, transparent) 58%, + color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 7%, transparent) 82%, + color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 9%, transparent) 100% ), var(--vscode-agents-background); } @@ -46,9 +46,9 @@ linear-gradient( to bottom right, transparent 0%, - color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 2%, transparent) 45%, - color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 10%, transparent) 75%, - color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 20%, transparent) 100% + color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 1%, transparent) 45%, + color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 6%, transparent) 75%, + color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 12%, transparent) 100% ), var(--vscode-agents-background); } @@ -57,9 +57,9 @@ background: linear-gradient( to bottom right, var(--vscode-agents-background) 0%, - color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 10%, var(--vscode-agents-background)) 56%, - color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 14%, var(--vscode-agents-background)) 82%, - color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 18%, var(--vscode-agents-background)) 100% + color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 7%, var(--vscode-agents-background)) 56%, + color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 10%, var(--vscode-agents-background)) 82%, + color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 13%, var(--vscode-agents-background)) 100% ); } } From 4ce5aceb414f0a4e2f87bed8636ca0f9e1370d46 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Thu, 16 Apr 2026 20:10:38 -0700 Subject: [PATCH 51/56] Plumb agent host model configuration through sessions (#310465) * Plumb agent host model configuration through sessions (Written by Copilot) * Remove deprecated stuff * tweak * sync protocol * Resolve merge fallout for model config branch Restore the shared agent host diff helper to match main and adapt protocol file-edit diffs locally in the agent host session providers. (Written by Copilot) * Fix agent host session diff typings Restore the session diff compatibility export and allow the agent host session list to display both live protocol file edits and compact metadata diffs. (Written by Copilot) * Update session diffs for protocol file edit shape Return aggregated session diffs using the current IFileEdit protocol shape and update the focused tests accordingly. (Written by Copilot) --------- Co-authored-by: Copilot --- .../platform/agentHost/common/agentService.ts | 13 +- .../common/state/protocol/.ahp-version | 2 +- .../common/state/protocol/actions.ts | 14 ++- .../common/state/protocol/commands.ts | 8 +- .../common/state/protocol/reducers.ts | 4 +- .../agentHost/common/state/protocol/state.ts | 102 ++++++++++++---- .../agentHost/common/state/sessionState.ts | 5 +- .../platform/agentHost/node/agentService.ts | 2 +- .../agentHost/node/agentSideEffects.ts | 1 + .../agentHost/node/copilot/copilotAgent.ts | 114 +++++++++++++++--- .../node/copilot/copilotAgentSession.ts | 6 +- .../agentHost/node/sessionDiffAggregator.ts | 23 ++-- .../test/node/agentSideEffects.test.ts | 9 +- .../platform/agentHost/test/node/mockAgent.ts | 10 +- .../sessionFeatures.integrationTest.ts | 16 +-- .../test/node/sessionDiffAggregator.test.ts | 65 +++++----- .../browser/localAgentHostSessionsProvider.ts | 47 ++++++-- .../localAgentHostSessionsProvider.test.ts | 28 ++++- .../remoteAgentHostSessionsProvider.ts | 39 ++++-- .../remoteAgentHostSessionsProvider.test.ts | 28 ++++- .../agentHostLanguageModelProvider.ts | 31 ++++- .../agentHost/agentHostSessionHandler.ts | 51 ++++++-- .../agentHostSessionListController.ts | 37 ++++-- .../agentHostChatContribution.test.ts | 63 +++++++++- 24 files changed, 533 insertions(+), 185 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 77e09cfd1ee59..e45595c30187a 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -10,7 +10,7 @@ import type { IObservable } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { ISyncedCustomization } from './agentPluginManager.js'; -import { IProtectedResourceMetadata, type IToolDefinition } from './state/protocol/state.js'; +import { IProtectedResourceMetadata, type IConfigSchema, type IModelSelection, type IToolDefinition } from './state/protocol/state.js'; import type { IActionEnvelope, INotification, ISessionAction, ITerminalAction } from './state/sessionActions.js'; import type { IAgentSubscription } from './state/agentSubscription.js'; import type { ICreateTerminalParams, IResolveSessionConfigResult, ISessionConfigCompletionsResult } from './state/protocol/commands.js'; @@ -66,7 +66,7 @@ export interface IAgentSessionMetadata { readonly project?: IAgentSessionProjectInfo; readonly summary?: string; readonly status?: SessionStatus; - readonly model?: string; + readonly model?: IModelSelection; readonly workingDirectory?: URI; readonly isRead?: boolean; readonly isDone?: boolean; @@ -121,7 +121,7 @@ export interface IAuthenticateResult { export interface IAgentCreateSessionConfig { readonly provider?: AgentProvider; - readonly model?: string; + readonly model?: IModelSelection; readonly session?: URI; readonly workingDirectory?: URI; readonly config?: Record; @@ -174,11 +174,8 @@ export interface IAgentModelInfo { readonly name: string; readonly maxContextWindow: number; readonly supportsVision: boolean; - readonly supportsReasoningEffort: boolean; - readonly supportedReasoningEfforts?: readonly string[]; - readonly defaultReasoningEffort?: string; + readonly configSchema?: IConfigSchema; readonly policyState?: PolicyState; - readonly billingMultiplier?: number; } // ---- Progress events (discriminated union by `type`) ------------------------ @@ -428,7 +425,7 @@ export interface IAgent { abortSession(session: URI): Promise; /** Change the model for an existing session. */ - changeModel(session: URI, model: string): Promise; + changeModel(session: URI, model: IModelSelection): Promise; /** Respond to a pending permission request from the SDK. */ respondToPermissionRequest(requestId: string, approved: boolean): void; diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 53a39acc2b309..b51478e7d28dc 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -7a902e4 +13c3475 diff --git a/src/vs/platform/agentHost/common/state/protocol/actions.ts b/src/vs/platform/agentHost/common/state/protocol/actions.ts index f9e8285263488..52beb0e9811cd 100644 --- a/src/vs/platform/agentHost/common/state/protocol/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/actions.ts @@ -6,7 +6,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type URI, type StringOrMarkdown, type IAgentInfo, type IErrorInfo, type IUserMessage, type IResponsePart, type IToolCallResult, type IToolResultContent, type IToolDefinition, type ISessionActiveClient, type IUsageInfo, type ISessionCustomization, type ISessionFileDiff, type ISessionInputAnswer, type ISessionInputRequest, type ITerminalInfo, type ITerminalClaim, type SessionInputResponseKind } from './state.js'; +import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type URI, type StringOrMarkdown, type IAgentInfo, type IErrorInfo, type IModelSelection, type IUserMessage, type IResponsePart, type IToolCallResult, type IToolResultContent, type IToolDefinition, type ISessionActiveClient, type IUsageInfo, type ISessionCustomization, type IFileEdit, type ISessionInputAnswer, type ISessionInputRequest, type ITerminalInfo, type ITerminalClaim, type SessionInputResponseKind } from './state.js'; // โ”€โ”€โ”€ Action Type Enum โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -300,6 +300,10 @@ export interface ISessionToolCallReadyAction extends IToolCallActionBase { toolInput?: string; /** Short title for the confirmation prompt (e.g. `"Run in terminal"`, `"Write file"`) */ confirmationTitle?: StringOrMarkdown; + /** File edits that this tool call will perform, for preview before confirmation */ + edits?: { items: IFileEdit[] }; + /** Whether the agent host allows the client to edit the tool's input parameters before confirming */ + editable?: boolean; /** If set, the tool was auto-confirmed and transitions directly to `running` */ confirmed?: ToolCallConfirmationReason; } @@ -317,6 +321,8 @@ export interface ISessionToolCallApprovedAction extends IToolCallActionBase { approved: true; /** How the tool was confirmed */ confirmed: ToolCallConfirmationReason; + /** Edited tool input parameters, if the client modified them before confirming */ + editedToolInput?: string; } /** @@ -522,8 +528,8 @@ export interface ISessionModelChangedAction { type: ActionType.SessionModelChanged; /** Session URI */ session: URI; - /** New model ID */ - model: string; + /** New model selection */ + model: IModelSelection; } /** @@ -576,7 +582,7 @@ export interface ISessionDiffsChangedAction { /** Session URI */ session: URI; /** Updated file diffs for the session */ - diffs: ISessionFileDiff[]; + diffs: IFileEdit[]; } /** diff --git a/src/vs/platform/agentHost/common/state/protocol/commands.ts b/src/vs/platform/agentHost/common/state/protocol/commands.ts index 6b276646c84ca..f868bd271ae8d 100644 --- a/src/vs/platform/agentHost/common/state/protocol/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/commands.ts @@ -6,10 +6,10 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import type { URI, ISnapshot, ISessionConfigSchema, ISessionSummary, ITurn, ITerminalClaim } from './state.js'; +import type { URI, ISnapshot, ISessionConfigSchema, ISessionSummary, IModelSelection, ITurn, ITerminalClaim } from './state.js'; import type { IActionEnvelope, IStateAction } from './actions.js'; -export type { ISessionConfigPropertySchema, ISessionConfigSchema } from './state.js'; +export type { IConfigPropertySchema, IConfigSchema, ISessionConfigPropertySchema, ISessionConfigSchema } from './state.js'; // โ”€โ”€โ”€ initialize โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -184,8 +184,8 @@ export interface ICreateSessionParams { session: URI; /** Agent provider ID */ provider?: string; - /** Model ID to use */ - model?: string; + /** Model selection (ID and optional model-specific configuration) */ + model?: IModelSelection; /** Working directory for the session */ workingDirectory?: URI; /** diff --git a/src/vs/platform/agentHost/common/state/protocol/reducers.ts b/src/vs/platform/agentHost/common/state/protocol/reducers.ts index 0670a1ed5238b..f308599642b4e 100644 --- a/src/vs/platform/agentHost/common/state/protocol/reducers.ts +++ b/src/vs/platform/agentHost/common/state/protocol/reducers.ts @@ -394,6 +394,8 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log invocationMessage: action.invocationMessage, toolInput: action.toolInput, confirmationTitle: action.confirmationTitle, + edits: action.edits, + editable: action.editable, }; })); @@ -408,7 +410,7 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log status: ToolCallStatus.Running, ...base, invocationMessage: tc.invocationMessage, - toolInput: tc.toolInput, + toolInput: action.editedToolInput ?? tc.toolInput, confirmed: action.confirmed, }; } diff --git a/src/vs/platform/agentHost/common/state/protocol/state.ts b/src/vs/platform/agentHost/common/state/protocol/state.ts index fa8fb84b882c2..26c7b08227fc5 100644 --- a/src/vs/platform/agentHost/common/state/protocol/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/state.ts @@ -205,6 +205,26 @@ export interface ISessionModelInfo { supportsVision?: boolean; /** Policy configuration state */ policyState?: PolicyState; + /** + * Configuration schema describing model-specific options (e.g. thinking + * level). Clients present this as a form and pass the resolved values in + * {@link IModelSelection.config} when creating or changing sessions. + */ + configSchema?: IConfigSchema; +} + +/** + * A model selection: the chosen model ID together with any model-specific + * configuration values whose keys correspond to the model's + * {@link ISessionModelInfo.configSchema}. + * + * @category Root State + */ +export interface IModelSelection { + /** Model identifier */ + id: string; + /** Model-specific configuration values */ + config?: Record; } // โ”€โ”€โ”€ Pending Message Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -322,20 +342,6 @@ export interface ISessionActiveClient { customizations?: ICustomizationRef[]; } -/** - * A summary of changes to a single file within a session. - * - * @category Session State - */ -export interface ISessionFileDiff { - /** URI of the affected file */ - uri: URI; - /** Number of items added (e.g., lines for text files, cells for notebooks) */ - added?: number; - /** Number of items removed (e.g., lines for text files, cells for notebooks) */ - removed?: number; -} - /** * Server-owned project metadata for a session. * @@ -367,7 +373,7 @@ export interface ISessionSummary { /** Server-owned project for this session */ project?: IProjectInfo; /** Currently selected model */ - model?: string; + model?: IModelSelection; /** The working directory URI for this session */ workingDirectory?: URI; /** Whether the client has viewed this session since its last modification */ @@ -375,21 +381,25 @@ export interface ISessionSummary { /** Whether the session has been marked as done by the client */ isDone?: boolean; /** Files changed during this session with diff statistics */ - diffs?: ISessionFileDiff[]; + diffs?: IFileEdit[]; } -// โ”€โ”€โ”€ Session Config Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// โ”€โ”€โ”€ Config Schema Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ /** * A JSON Schema-compatible string enum property descriptor with display extensions. * * Standard JSON Schema fields (`type`, `title`, `description`, `default`, * `enum`) allow validators to process the schema. Display extensions - * (`enumLabels`, `enumDescriptions`) are parallel arrays that provide UI metadata for each `enum` value. + * (`enumLabels`, `enumDescriptions`) are parallel arrays that provide UI + * metadata for each `enum` value. * - * @category Session Config Types + * This is the generic base type. See {@link ISessionConfigPropertySchema} for + * session-specific extensions. + * + * @category Config Schema Types */ -export interface ISessionConfigPropertySchema { +export interface IConfigPropertySchema { /** JSON Schema: property type. Only string enum properties are currently supported. */ type: 'string'; /** JSON Schema: human-readable label for the property */ @@ -404,6 +414,38 @@ export interface ISessionConfigPropertySchema { enumLabels?: string[]; /** Display extension: description per enum value (parallel array) */ enumDescriptions?: string[]; + /** JSON Schema: when `true`, the property is displayed but cannot be modified by the user */ + readOnly?: boolean; +} + +/** + * A JSON Schema object describing available configuration properties. + * + * This is the generic base type. See {@link ISessionConfigSchema} for + * session-specific usage. + * + * @category Config Schema Types + */ +export interface IConfigSchema { + /** JSON Schema: always `'object'` */ + type: 'object'; + /** JSON Schema: property descriptors keyed by property id */ + properties: Record; + /** JSON Schema: list of required property ids */ + required?: string[]; +} + +// โ”€โ”€โ”€ Session Config Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * A session configuration property descriptor. + * + * Extends the generic {@link IConfigPropertySchema} with session-specific + * display extensions. + * + * @category Session Config Types + */ +export interface ISessionConfigPropertySchema extends IConfigPropertySchema { /** * Display extension: when `true`, the full set of allowed values is too large * to enumerate statically. The client SHOULD use `sessionConfigCompletions` @@ -411,8 +453,6 @@ export interface ISessionConfigPropertySchema { * seed/recent values for initial display. */ enumDynamic?: boolean; - /** JSON Schema: when `true`, the property is displayed but cannot be modified by the user */ - readOnly?: boolean; /** When `true`, the user may change this property after session creation */ sessionMutable?: boolean; } @@ -984,6 +1024,10 @@ export interface IToolCallPendingConfirmationState extends IToolCallBase, IToolC status: ToolCallStatus.PendingConfirmation; /** Short title for the confirmation prompt (e.g. `"Run in terminal"`, `"Write file"`) */ confirmationTitle?: StringOrMarkdown; + /** File edits that this tool call will perform, for preview before confirmation */ + edits?: { items: IFileEdit[] }; + /** Whether the agent host allows the client to edit the tool's input parameters before confirming */ + editable?: boolean; } /** @@ -1179,15 +1223,14 @@ export interface IToolResultResourceContent extends IContentRef { } /** - * Describes a file modification performed by a tool. + * Describes a file modification with before/after state and diff metadata. * * Supports creates (only `after`), deletes (only `before`), renames/moves * (different `uri` in `before` and `after`), and edits (same `uri`, different content). * * @category Tool Result Content */ -export interface IToolResultFileEditContent { - type: ToolResultContentType.FileEdit; +export interface IFileEdit { /** The file state before the edit. Absent for file creations or for in-place file edits. */ before?: { /** URI of the file before the edit */ @@ -1211,6 +1254,15 @@ export interface IToolResultFileEditContent { }; } +/** + * Describes a file modification performed by a tool. + * + * @category Tool Result Content + */ +export interface IToolResultFileEditContent extends IFileEdit { + type: ToolResultContentType.FileEdit; +} + /** * A reference to a terminal whose output is relevant to this tool result. * diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 682bb4e3f9f62..ae0aa043d66c3 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -34,6 +34,8 @@ import { export { type IActiveTurn, type IAgentInfo, + type IConfigPropertySchema, + type IConfigSchema, type IContentRef, type IErrorInfo, type IProjectInfo, @@ -44,7 +46,8 @@ export { type IRootState, type ISessionActiveClient, type ISessionConfigState, - type ISessionFileDiff, + type IFileEdit as ISessionFileDiff, + type IModelSelection, type ISessionModelInfo, type ISessionState, type ISessionSummary, diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 9a8bf2ef1d21b..fc09b435bdd24 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -235,7 +235,7 @@ export class AgentService extends Disposable implements IAgentService { const session = created.session; this._logService.trace(`[AgentService] createSession: initialization complete`); - this._logService.trace(`[AgentService] createSession: provider=${provider.id} model=${config?.model ?? '(default)'}`); + this._logService.trace(`[AgentService] createSession: provider=${provider.id} model=${config?.model?.id ?? '(default)'}`); this._sessionToProvider.set(session.toString(), provider.id); this._logService.trace(`[AgentService] createSession returned: ${session.toString()}`); diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index a73982dd41aed..945e6d863fcf0 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -114,6 +114,7 @@ export class AgentSideEffects extends Disposable { maxContextWindow: m.maxContextWindow, supportsVision: m.supportsVision, policyState: m.policyState, + configSchema: m.configSchema, })), protectedResources: protectedResources.length > 0 ? protectedResources : undefined, }; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index d419df9579823..d5d0b5e9e5c7a 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CopilotClient } from '@github/copilot-sdk'; +import { CopilotClient, type SessionConfig } from '@github/copilot-sdk'; import { rgPath } from '@vscode/ripgrep'; import * as fs from 'fs/promises'; import { Limiter, SequencerByKey } from '../../../../base/common/async.js'; @@ -25,7 +25,7 @@ import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPlu import { AgentHostSessionConfigBranchNameHintKey, AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js'; import type { IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; -import { IProtectedResourceMetadata, type IToolDefinition } from '../../common/state/protocol/state.js'; +import { IProtectedResourceMetadata, type IConfigSchema, type IModelSelection, type IToolDefinition } from '../../common/state/protocol/state.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; import { CustomizationStatus, ICustomizationRef, SessionInputResponseKind, type IPendingMessage, type ISessionInputAnswer, type IToolCallResult, type PolicyState } from '../../common/state/sessionState.js'; import { IAgentHostGitService } from '../agentHostGitService.js'; @@ -41,6 +41,19 @@ interface ICreatedWorktree { readonly worktree: URI; } +const ThinkingLevelConfigKey = 'thinkingLevel'; +const ReasoningEfforts = ['low', 'medium', 'high', 'xhigh'] as const; +type ReasoningEffort = NonNullable; + +interface ISerializedModelSelection { + id?: unknown; + config?: unknown; +} + +function isReasoningEffort(value: string | undefined): value is ReasoningEffort { + return ReasoningEfforts.some(reasoningEffort => reasoningEffort === value); +} + export function getCopilotWorktreesRoot(repositoryRoot: URI): URI { return URI.joinPath(repositoryRoot, '..', `${basename(repositoryRoot.fsPath)}.worktrees`); } @@ -228,6 +241,74 @@ export class CopilotAgent extends Disposable implements IAgent { // ---- session management ------------------------------------------------- + private _createThinkingLevelConfigSchema(supportedReasoningEfforts: readonly string[] | undefined, defaultReasoningEffort: string | undefined): IConfigSchema | undefined { + if (!supportedReasoningEfforts?.length) { + return undefined; + } + + const enumLabels = supportedReasoningEfforts.map(value => { + switch (value) { + case 'low': return localize('copilot.modelThinkingLevel.low', "Low"); + case 'medium': return localize('copilot.modelThinkingLevel.medium', "Medium"); + case 'high': return localize('copilot.modelThinkingLevel.high', "High"); + case 'xhigh': return localize('copilot.modelThinkingLevel.xhigh', "Extra High"); + default: return value; + } + }); + + return { + type: 'object', + properties: { + [ThinkingLevelConfigKey]: { + type: 'string', + title: localize('copilot.modelThinkingLevel.title', "Thinking Level"), + description: localize('copilot.modelThinkingLevel.description', "Controls how much reasoning effort the model uses."), + default: defaultReasoningEffort, + enum: [...supportedReasoningEfforts], + enumLabels, + }, + }, + }; + } + + private _getReasoningEffort(model: IModelSelection | undefined): SessionConfig['reasoningEffort'] { + const thinkingLevel = model?.config?.[ThinkingLevelConfigKey]; + return isReasoningEffort(thinkingLevel) ? thinkingLevel : undefined; + } + + private _serializeModelSelection(model: IModelSelection): string { + return JSON.stringify(model); + } + + private _parseModelSelection(raw: string | undefined): IModelSelection | undefined { + if (!raw) { + return undefined; + } + + try { + const value: ISerializedModelSelection | string | number | boolean | null = JSON.parse(raw); + if (value && typeof value === 'object' && typeof value.id === 'string') { + const modelSelection: IModelSelection = { id: value.id }; + if (value.config && typeof value.config === 'object') { + const config: Record = {}; + for (const [key, configValue] of Object.entries(value.config)) { + if (typeof configValue === 'string') { + config[key] = configValue; + } + } + if (Object.keys(config).length > 0) { + modelSelection.config = config; + } + } + return modelSelection; + } + } catch { + // Older session metadata stored the raw model id as a plain string. + } + + return { id: raw }; + } + async listSessions(): Promise { this._logService.info('[Copilot] Listing sessions...'); if (!this._githubToken) { @@ -274,11 +355,8 @@ export class CopilotAgent extends Disposable implements IAgent { name: m.name, maxContextWindow: m.capabilities.limits.max_context_window_tokens, supportsVision: m.capabilities.supports.vision, - supportsReasoningEffort: m.capabilities.supports.reasoningEffort, - supportedReasoningEfforts: m.supportedReasoningEfforts, - defaultReasoningEffort: m.defaultReasoningEffort, + configSchema: this._createThinkingLevelConfigSchema(m.supportedReasoningEfforts, m.defaultReasoningEffort), policyState: m.policy?.state as PolicyState | undefined, - billingMultiplier: m.billing?.multiplier, })); this._logService.info(`[Copilot] Found ${result.length} models`); return result; @@ -289,7 +367,7 @@ export class CopilotAgent extends Disposable implements IAgent { throw new Error('workingDirectory is required to create a Copilot session'); } - this._logService.info(`[Copilot] Creating session... ${config?.model ? `model=${config.model}` : ''}`); + this._logService.info(`[Copilot] Creating session... ${config?.model ? `model=${config.model.id}` : ''}`); const client = await this._ensureClient(); // When forking, use the SDK's sessions.fork RPC. @@ -354,7 +432,8 @@ export class CopilotAgent extends Disposable implements IAgent { const factory: SessionWrapperFactory = async callbacks => { const raw = await client.createSession({ - model: config?.model, + model: config?.model?.id, + reasoningEffort: this._getReasoningEffort(config?.model), sessionId, streaming: true, workingDirectory: workingDirectory?.fsPath, @@ -574,11 +653,11 @@ export class CopilotAgent extends Disposable implements IAgent { }); } - async changeModel(session: URI, model: string): Promise { + async changeModel(session: URI, model: IModelSelection): Promise { const sessionId = AgentSession.id(session); const entry = this._sessions.get(sessionId); if (entry) { - await entry.setModel(model); + await entry.setModel(model.id, this._getReasoningEffort(model)); } await this._storeSessionMetadata(session, model, undefined, undefined); } @@ -734,7 +813,8 @@ export class CopilotAgent extends Disposable implements IAgent { ...config, sessionId, streaming: true, - model: storedMetadata.model, + model: storedMetadata.model?.id, + reasoningEffort: this._getReasoningEffort(storedMetadata.model), workingDirectory: workingDirectory?.fsPath, }); @@ -804,13 +884,13 @@ export class CopilotAgent extends Disposable implements IAgent { private static readonly _META_PROJECT_URI = 'copilot.project.uri'; private static readonly _META_PROJECT_DISPLAY_NAME = 'copilot.project.displayName'; - private async _storeSessionMetadata(session: URI, model: string | undefined, workingDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined): Promise { + private async _storeSessionMetadata(session: URI, model: IModelSelection | undefined, workingDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined): Promise { const dbRef = this._sessionDataService.openDatabase(session); const db = dbRef.object; try { const work: Promise[] = []; if (model) { - work.push(db.setMetadata(CopilotAgent._META_MODEL, model)); + work.push(db.setMetadata(CopilotAgent._META_MODEL, this._serializeModelSelection(model))); } if (workingDirectory) { work.push(db.setMetadata(CopilotAgent._META_CWD, workingDirectory.toString())); @@ -828,7 +908,7 @@ export class CopilotAgent extends Disposable implements IAgent { } } - private async _readSessionMetadata(session: URI): Promise<{ model?: string; workingDirectory?: URI }> { + private async _readSessionMetadata(session: URI): Promise<{ model?: IModelSelection; workingDirectory?: URI }> { const ref = await this._sessionDataService.tryOpenDatabase(session); if (!ref) { return {}; @@ -839,7 +919,7 @@ export class CopilotAgent extends Disposable implements IAgent { ref.object.getMetadata(CopilotAgent._META_CWD), ]); return { - model, + model: this._parseModelSelection(model), workingDirectory: cwd ? URI.parse(cwd) : undefined, }; } finally { @@ -847,7 +927,7 @@ export class CopilotAgent extends Disposable implements IAgent { } } - private async _readStoredSessionMetadata(session: URI): Promise<{ model?: string; workingDirectory?: URI; project?: IAgentSessionProjectInfo; resolved: boolean }> { + private async _readStoredSessionMetadata(session: URI): Promise<{ model?: IModelSelection; workingDirectory?: URI; project?: IAgentSessionProjectInfo; resolved: boolean }> { const ref = await this._sessionDataService.tryOpenDatabase(session); if (!ref) { return { resolved: false }; @@ -861,7 +941,7 @@ export class CopilotAgent extends Disposable implements IAgent { ref.object.getMetadata(CopilotAgent._META_PROJECT_DISPLAY_NAME), ]); const project = uri && displayName ? { uri: URI.parse(uri), displayName } : undefined; - return { model, workingDirectory: cwd ? URI.parse(cwd) : undefined, project, resolved: resolved === 'true' || project !== undefined }; + return { model: this._parseModelSelection(model), workingDirectory: cwd ? URI.parse(cwd) : undefined, project, resolved: resolved === 'true' || project !== undefined }; } finally { ref.dispose(); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index a4e0ba6bcf816..f2e6653fb0baf 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { PermissionRequestResult, Tool, ToolResultObject } from '@github/copilot-sdk'; +import type { PermissionRequestResult, SessionConfig, Tool, ToolResultObject } from '@github/copilot-sdk'; import { DeferredPromise } from '../../../../base/common/async.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -337,9 +337,9 @@ export class CopilotAgentSession extends Disposable { await this._wrapper.session.destroy(); } - async setModel(model: string): Promise { + async setModel(model: string, reasoningEffort?: SessionConfig['reasoningEffort']): Promise { this._logService.info(`[Copilot:${this.sessionId}] Changing model to: ${model}`); - await this._wrapper.session.setModel(model); + await this._wrapper.session.setModel(model, { reasoningEffort }); } // ---- permission handling ------------------------------------------------ diff --git a/src/vs/platform/agentHost/node/sessionDiffAggregator.ts b/src/vs/platform/agentHost/node/sessionDiffAggregator.ts index 8fa751e4062db..dfcc9a2fdec2f 100644 --- a/src/vs/platform/agentHost/node/sessionDiffAggregator.ts +++ b/src/vs/platform/agentHost/node/sessionDiffAggregator.ts @@ -8,6 +8,19 @@ import type { IFileEditRecord, ISessionDatabase } from '../common/sessionDataSer import type { IDiffComputeService } from '../common/diffComputeService.js'; import { FileEditKind, type ISessionFileDiff } from '../common/state/sessionState.js'; +function getFileEditUri(diff: ISessionFileDiff): string | undefined { + return diff.after?.uri ?? diff.before?.uri; +} + +function createSessionFileDiff(identity: IFileIdentity, added: number, removed: number): ISessionFileDiff { + const uri = URI.file(identity.terminalPath).toString(); + const content = { uri }; + return { + ...(identity.lastKind === FileEditKind.Delete ? { before: { uri, content } } : { after: { uri, content } }), + diff: { added, removed }, + }; +} + /** * Represents a file's identity across renames, tracking its first and last * snapshots in the session for diff computation. @@ -72,7 +85,7 @@ export async function computeSessionDiffs( return [...incremental.previousDiffs]; } - const previousDiffsUris = new Set(incremental.previousDiffs.map(d => d.uri)); + const previousDiffsUris = new Set(incremental.previousDiffs.map(getFileEditUri)); const needsFullHistory = turnEdits.some(e => e.kind === FileEditKind.Rename || previousDiffsUris.has(URI.file(e.filePath).toString()) @@ -148,7 +161,7 @@ export async function computeSessionDiffs( // In incremental slow-path mode, build a lookup map from URI string โ†’ // previous diff so untouched identities can carry over their previous results. const previousDiffsMap = (incremental && !fastPath) - ? new Map(incremental.previousDiffs.map(d => [d.uri, d])) + ? new Map(incremental.previousDiffs.map(d => [getFileEditUri(d), d])) : undefined; // Compute diffs for each file identity @@ -192,11 +205,7 @@ export async function computeSessionDiffs( } const counts = await diffService.computeDiffCounts(beforeText, afterText); - results.push({ - uri: URI.file(identity.terminalPath).toString(), - added: counts.added, - removed: counts.removed, - }); + results.push(createSessionFileDiff(identity, counts.added, counts.removed)); })()); } diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 3becd8fd502f2..f27250fec0c5d 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -287,12 +287,12 @@ suite('AgentSideEffects', () => { sideEffects.handleAction({ type: ActionType.SessionModelChanged, session: sessionUri.toString(), - model: 'gpt-5', + model: { id: 'gpt-5' }, }); await new Promise(r => setTimeout(r, 10)); - assert.deepStrictEqual(agent.changeModelCalls, [{ session: URI.parse(sessionUri.toString()), model: 'gpt-5' }]); + assert.deepStrictEqual(agent.changeModelCalls, [{ session: URI.parse(sessionUri.toString()), model: { id: 'gpt-5' } }]); }); }); @@ -360,7 +360,7 @@ suite('AgentSideEffects', () => { } return e.action.agents[0]?.models.length === 1; })); - agent.setModels([{ provider: 'mock', id: 'mock-model', name: 'mock Model', maxContextWindow: 128000, supportsVision: false, supportsReasoningEffort: false }]); + agent.setModels([{ provider: 'mock', id: 'mock-model', name: 'mock Model', maxContextWindow: 128000, supportsVision: false }]); await envelope; const actions = envelopes.map(e => e.action).filter(action => action.type === ActionType.RootAgentsChanged); @@ -373,13 +373,14 @@ suite('AgentSideEffects', () => { maxContextWindow: 128000, supportsVision: false, policyState: undefined, + configSchema: undefined, }]); }); test('unchanged model observable update does not dispatch unchanged agent infos', async () => { const envelopes: IActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - const models = [{ provider: 'mock' as const, id: 'mock-model', name: 'mock Model', maxContextWindow: 128000, supportsVision: false, supportsReasoningEffort: false }]; + const models = [{ provider: 'mock' as const, id: 'mock-model', name: 'mock Model', maxContextWindow: 128000, supportsVision: false }]; const envelope = Event.toPromise(Event.filter(stateManager.onDidEmitEnvelope, e => { if (e.action.type !== ActionType.RootAgentsChanged) { diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 651ed179f1fa4..c9ee746d5696c 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -10,7 +10,7 @@ import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/c import { URI } from '../../../../base/common/uri.js'; import { type ISyncedCustomization } from '../../common/agentPluginManager.js'; import { AgentSession, type AgentProvider, type IAgent, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentCreateSessionResult, type IAgentDescriptor, type IAgentMessageEvent, type IAgentModelInfo, type IAgentProgressEvent, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type IAgentSubagentStartedEvent, type IAgentToolCompleteEvent, type IAgentToolStartEvent } from '../../common/agentService.js'; -import { IProtectedResourceMetadata } from '../../common/state/protocol/state.js'; +import { IProtectedResourceMetadata, type IModelSelection } from '../../common/state/protocol/state.js'; import type { IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { CustomizationStatus, ToolResultContentType, type ICustomizationRef, type IPendingMessage, type IToolCallResult } from '../../common/state/sessionState.js'; @@ -40,7 +40,7 @@ export class MockAgent implements IAgent { readonly disposeSessionCalls: URI[] = []; readonly abortSessionCalls: URI[] = []; readonly respondToPermissionCalls: { requestId: string; approved: boolean }[] = []; - readonly changeModelCalls: { session: URI; model: string }[] = []; + readonly changeModelCalls: { session: URI; model: IModelSelection }[] = []; readonly authenticateCalls: { resource: string; token: string }[] = []; readonly setClientCustomizationsCalls: { clientId: string; customizations: ICustomizationRef[] }[] = []; readonly setCustomizationEnabledCalls: { uri: string; enabled: boolean }[] = []; @@ -121,7 +121,7 @@ export class MockAgent implements IAgent { // no-op for tests } - async changeModel(session: URI, model: string): Promise { + async changeModel(session: URI, model: IModelSelection): Promise { this.changeModelCalls.push({ session, model }); } @@ -180,7 +180,7 @@ export class ScriptedMockAgent implements IAgent { private readonly _onDidSessionProgress = new Emitter(); readonly onDidSessionProgress = this._onDidSessionProgress.event; - private readonly _models = observableValue(this, [{ provider: 'mock', id: 'mock-model', name: 'Mock Model', maxContextWindow: 128000, supportsVision: false, supportsReasoningEffort: false }]); + private readonly _models = observableValue(this, [{ provider: 'mock', id: 'mock-model', name: 'Mock Model', maxContextWindow: 128000, supportsVision: false }]); readonly models = this._models; private readonly _sessions = new Map(); @@ -489,7 +489,7 @@ export class ScriptedMockAgent implements IAgent { } } - async changeModel(_session: URI, _model: string): Promise { + async changeModel(_session: URI, _model: IModelSelection): Promise { // Mock agent doesn't track model state } diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts index cd6766fcf074a..a5c502c6243eb 100644 --- a/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts @@ -159,41 +159,41 @@ suite('Protocol WebSocket โ€” Session Features', function () { await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-model-summary' }); const sessionUri = nextSessionUri(); - await client.call('createSession', { session: sessionUri, provider: 'mock', model: 'mock-model' }); + await client.call('createSession', { session: sessionUri, provider: 'mock', model: { id: 'mock-model' } }); const addedNotif = await client.waitForNotification(n => n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' ); const addedSession = (addedNotif.params as INotificationBroadcastParams).notification as ISessionAddedNotification; - assert.strictEqual(addedSession.summary.model, 'mock-model'); + assert.deepStrictEqual(addedSession.summary.model, { id: 'mock-model' }); const createdSessionUri = addedSession.summary.resource; const initialSnapshot = await client.call('subscribe', { resource: createdSessionUri }); const initialState = initialSnapshot.snapshot.state as ISessionState; - assert.strictEqual(initialState.summary.model, 'mock-model'); + assert.deepStrictEqual(initialState.summary.model, { id: 'mock-model' }); const initialList = await client.call('listSessions'); - assert.strictEqual(initialList.items.find(s => s.resource === createdSessionUri)?.model, 'mock-model'); + assert.deepStrictEqual(initialList.items.find(s => s.resource === createdSessionUri)?.model, { id: 'mock-model' }); client.notify('dispatchAction', { clientSeq: 1, action: { type: 'session/modelChanged', session: createdSessionUri, - model: 'mock-model-2', + model: { id: 'mock-model-2' }, }, }); const modelNotif = await client.waitForNotification(n => isActionNotification(n, 'session/modelChanged')); const modelAction = getActionEnvelope(modelNotif).action as IModelChangedAction; - assert.strictEqual(modelAction.model, 'mock-model-2'); + assert.deepStrictEqual(modelAction.model, { id: 'mock-model-2' }); const updatedSnapshot = await client.call('subscribe', { resource: createdSessionUri }); const updatedState = updatedSnapshot.snapshot.state as ISessionState; - assert.strictEqual(updatedState.summary.model, 'mock-model-2'); + assert.deepStrictEqual(updatedState.summary.model, { id: 'mock-model-2' }); const updatedList = await client.call('listSessions'); - assert.strictEqual(updatedList.items.find(s => s.resource === createdSessionUri)?.model, 'mock-model-2'); + assert.deepStrictEqual(updatedList.items.find(s => s.resource === createdSessionUri)?.model, { id: 'mock-model-2' }); }); // ---- Reasoning events ------------------------------------------------------ diff --git a/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts b/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts index b591f91d1297f..d5432b5dc558e 100644 --- a/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts @@ -12,6 +12,15 @@ import { computeSessionDiffs } from '../../node/sessionDiffAggregator.js'; const createTestDiffService = () => new TestDiffComputeService(); +function fileDiff(path: string, added: number, removed: number): ISessionFileDiff { + const uri = URI.file(path).toString(); + return { after: { uri, content: { uri } }, diff: { added, removed } }; +} + +function getDiffUri(diff: ISessionFileDiff): string | undefined { + return diff.after?.uri ?? diff.before?.uri; +} + suite('computeSessionDiffs', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -36,11 +45,7 @@ suite('computeSessionDiffs', () => { const diffService = createTestDiffService(); const result = await computeSessionDiffs(db, diffService); - assert.deepStrictEqual(result, [{ - uri: URI.file('/a.txt').toString(), - added: 1, - removed: 0, - }]); + assert.deepStrictEqual(result, [fileDiff('/a.txt', 1, 0)]); assert.strictEqual(diffService.callCount, 1); }); @@ -82,7 +87,7 @@ suite('computeSessionDiffs', () => { const result = await computeSessionDiffs(db, diffService); assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].uri, URI.file('/b.txt').toString(), 'uses terminal path after rename'); + assert.strictEqual(getDiffUri(result[0]), URI.file('/b.txt').toString(), 'uses terminal path after rename'); }); // ---- Incremental-mode tests --------------------------------------------- @@ -103,7 +108,7 @@ suite('computeSessionDiffs', () => { }); const previousDiffs: ISessionFileDiff[] = [ - { uri: URI.file('/a.txt').toString(), added: 42, removed: 7 }, + fileDiff('/a.txt', 42, 7), ]; const diffService = createTestDiffService(); @@ -114,11 +119,11 @@ suite('computeSessionDiffs', () => { ); // Sort to ensure stable comparison - result.sort((a, b) => a.uri.localeCompare(b.uri)); + result.sort((a, b) => (getDiffUri(a) ?? '').localeCompare(getDiffUri(b) ?? '')); assert.deepStrictEqual(result, [ - { uri: URI.file('/a.txt').toString(), added: 42, removed: 7 }, // carried over - { uri: URI.file('/b.txt').toString(), added: 1, removed: 0 }, // recomputed + fileDiff('/a.txt', 42, 7), // carried over + fileDiff('/b.txt', 1, 0), // recomputed ]); // Only file B should have triggered a diff computation assert.strictEqual(diffService.callCount, 1, 'only touched file should be diffed'); @@ -139,7 +144,7 @@ suite('computeSessionDiffs', () => { }); const previousDiffs: ISessionFileDiff[] = [ - { uri: URI.file('/a.txt').toString(), added: 100, removed: 100 }, // stale + fileDiff('/a.txt', 100, 100), // stale ]; const diffService = createTestDiffService(); @@ -150,11 +155,7 @@ suite('computeSessionDiffs', () => { ); // Should compare tc1.before='original' vs tc2.after='after-turn2\nextra' - assert.deepStrictEqual(result, [{ - uri: URI.file('/a.txt').toString(), - added: 1, - removed: 0, - }]); + assert.deepStrictEqual(result, [fileDiff('/a.txt', 1, 0)]); assert.strictEqual(diffService.callCount, 1); }); @@ -175,7 +176,7 @@ suite('computeSessionDiffs', () => { }); const previousDiffs: ISessionFileDiff[] = [ - { uri: URI.file('/old.txt').toString(), added: 5, removed: 0 }, + fileDiff('/old.txt', 5, 0), ]; const diffService = createTestDiffService(); @@ -187,7 +188,7 @@ suite('computeSessionDiffs', () => { // Create โ†’ Rename with same content: before='' (create), after='content' (rename) assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].uri, URI.file('/new.txt').toString(), 'uses new URI after rename'); + assert.strictEqual(getDiffUri(result[0]), URI.file('/new.txt').toString(), 'uses new URI after rename'); }); test('incremental: file with zero net change in current turn is excluded even if in previousDiffs', async () => { @@ -205,7 +206,7 @@ suite('computeSessionDiffs', () => { }); const previousDiffs: ISessionFileDiff[] = [ - { uri: URI.file('/a.txt').toString(), added: 10, removed: 5 }, + fileDiff('/a.txt', 10, 5), ]; const diffService = createTestDiffService(); @@ -235,8 +236,8 @@ suite('computeSessionDiffs', () => { }); const previousDiffs: ISessionFileDiff[] = [ - { uri: URI.file('/a.txt').toString(), added: 1, removed: 0 }, - { uri: URI.file('/orphan.txt').toString(), added: 99, removed: 99 }, // no longer in DB + fileDiff('/a.txt', 1, 0), + fileDiff('/orphan.txt', 99, 99), // no longer in DB ]; const diffService = createTestDiffService(); @@ -248,7 +249,7 @@ suite('computeSessionDiffs', () => { // Slow path: orphan is dropped because it has no identity in the full graph assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].uri, URI.file('/a.txt').toString()); + assert.strictEqual(getDiffUri(result[0]), URI.file('/a.txt').toString()); }); test('full mode recomputes all files (no incremental options)', async () => { @@ -289,7 +290,7 @@ suite('computeSessionDiffs', () => { }); const previousDiffs: ISessionFileDiff[] = [ - { uri: URI.file('/old.txt').toString(), added: 3, removed: 1 }, + fileDiff('/old.txt', 3, 1), ]; const diffService = createTestDiffService(); @@ -303,10 +304,10 @@ suite('computeSessionDiffs', () => { assert.strictEqual(db.getFileEditsByTurnCalls, 1); assert.strictEqual(db.getAllFileEditsCalls, 0, 'fast path should not call getAllFileEdits'); - result.sort((a, b) => a.uri.localeCompare(b.uri)); + result.sort((a, b) => (getDiffUri(a) ?? '').localeCompare(getDiffUri(b) ?? '')); assert.deepStrictEqual(result, [ - { uri: URI.file('/new.txt').toString(), added: 1, removed: 0 }, - { uri: URI.file('/old.txt').toString(), added: 3, removed: 1 }, // carried over + fileDiff('/new.txt', 1, 0), + fileDiff('/old.txt', 3, 1), // carried over ]); }); @@ -326,7 +327,7 @@ suite('computeSessionDiffs', () => { }); const previousDiffs: ISessionFileDiff[] = [ - { uri: URI.file('/a.txt').toString(), added: 5, removed: 0 }, + fileDiff('/a.txt', 5, 0), ]; const diffService = createTestDiffService(); @@ -341,11 +342,7 @@ suite('computeSessionDiffs', () => { assert.strictEqual(db.getAllFileEditsCalls, 1, 'should fall back to getAllFileEdits'); // Cumulative diff: original โ†’ turn2\nextra - assert.deepStrictEqual(result, [{ - uri: URI.file('/a.txt').toString(), - added: 1, - removed: 0, - }]); + assert.deepStrictEqual(result, [fileDiff('/a.txt', 1, 0)]); }); test('incremental slow path: rename in current turn falls back to getAllFileEdits', async () => { @@ -363,7 +360,7 @@ suite('computeSessionDiffs', () => { }); const previousDiffs: ISessionFileDiff[] = [ - { uri: URI.file('/a.txt').toString(), added: 1, removed: 0 }, + fileDiff('/a.txt', 1, 0), ]; const diffService = createTestDiffService(); @@ -385,7 +382,7 @@ suite('computeSessionDiffs', () => { }); const previousDiffs: ISessionFileDiff[] = [ - { uri: URI.file('/a.txt').toString(), added: 5, removed: 2 }, + fileDiff('/a.txt', 5, 2), ]; const diffService = createTestDiffService(); diff --git a/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts index 46d4b771cfbb0..af919f0afb171 100644 --- a/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts @@ -16,7 +16,7 @@ import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { AgentSession, IAgentHostService, type IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js'; -import type { IRootState, ISessionFileDiff, ISessionSummary as IProtocolSessionSummary } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import type { IFileEdit, IModelSelection, IRootState, ISessionSummary } from '../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; import type { IResolveSessionConfigResult, ISessionConfigValueItem } from '../../../../platform/agentHost/common/state/protocol/commands.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; @@ -59,6 +59,17 @@ function buildMutableConfigSchema(config: Record): Record; readonly changes = observableValue('changes', []); readonly modelId: ISettableObservable; + modelSelection: IModelSelection | undefined; readonly mode = observableValue<{ readonly id: string; readonly kind: string } | undefined>('mode', undefined); readonly loading = observableValue(this, false); readonly isArchived = observableValue('isArchived', false); @@ -116,8 +128,9 @@ class LocalSessionAdapter implements ISession { this.createdAt = new Date(metadata.startTime); this.title = observableValue('title', metadata.summary || `Session ${rawId.substring(0, 8)}`); this.updatedAt = observableValue('updatedAt', new Date(metadata.modifiedTime)); + this.modelSelection = metadata.model; this.status = observableValue('status', metadata.status !== undefined ? mapProtocolStatus(metadata.status) : SessionStatus.Completed); - this.modelId = observableValue('modelId', metadata.model ? `${logicalSessionType}:${metadata.model}` : undefined); + this.modelId = observableValue('modelId', metadata.model ? `${logicalSessionType}:${metadata.model.id}` : undefined); this.lastTurnEnd = observableValue('lastTurnEnd', metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined); this.description = observableValue('description', new MarkdownString().appendText(localize('localAgentHostDescription', "Local"))); this.workspace = observableValue('workspace', LocalAgentHostSessionsProvider.buildWorkspace(metadata.project, metadata.workingDirectory)); @@ -195,7 +208,8 @@ class LocalSessionAdapter implements ISession { didChange = true; } - const modelId = metadata.model ? `${this.sessionType}:${metadata.model}` : undefined; + this.modelSelection = metadata.model; + const modelId = metadata.model ? `${this.sessionType}:${metadata.model.id}` : undefined; if (modelId !== this.modelId.get()) { this.modelId.set(modelId, undefined); didChange = true; @@ -578,7 +592,8 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent cached.modelId.set(modelId, undefined); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); const rawModelId = modelId.startsWith(`${cached.sessionType}:`) ? modelId.substring(cached.sessionType.length + 1) : modelId; - const action = { type: ActionType.SessionModelChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), model: rawModelId }; + const model = cached.modelSelection?.id === rawModelId ? cached.modelSelection : { id: rawModelId }; + const action = { type: ActionType.SessionModelChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), model }; this._agentHostService.dispatch(action); } } @@ -876,7 +891,7 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent } } - private _handleSessionAdded(summary: { resource: string; provider: string; title: string; createdAt: number; modifiedAt: number; project?: { uri: string; displayName: string }; model?: string; workingDirectory?: string; isRead?: boolean; isDone?: boolean }): void { + private _handleSessionAdded(summary: ISessionSummary): void { const sessionUri = URI.parse(summary.resource); const rawId = AgentSession.id(sessionUri); if (this._sessionCache.has(rawId)) { @@ -927,10 +942,13 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent } } - private _handleModelChanged(session: string, model: string): void { + private _handleModelChanged(session: string, model: IModelSelection): void { const rawId = AgentSession.id(session); const cached = this._sessionCache.get(rawId); - const modelId = cached ? `${cached.sessionType}:${model}` : undefined; + if (cached) { + cached.modelSelection = model; + } + const modelId = cached ? `${cached.sessionType}:${model.id}` : undefined; if (cached && cached.modelId.get() !== modelId) { cached.modelId.set(modelId, undefined); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); @@ -955,16 +973,16 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent } } - private _handleDiffsChanged(session: string, diffs: ISessionFileDiff[]): void { + private _handleDiffsChanged(session: string, diffs: IFileEdit[]): void { const rawId = AgentSession.id(session); const cached = this._sessionCache.get(rawId); if (cached) { - cached.changes.set(diffsToChanges(diffs), undefined); + cached.changes.set(diffsToChanges(toSessionFileDiffs(diffs)), undefined); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); } } - private _handleSessionSummaryChanged(session: string, changes: Partial): void { + private _handleSessionSummaryChanged(session: string, changes: Partial): void { const rawId = AgentSession.id(session); const cached = this._sessionCache.get(rawId); if (!cached) { @@ -986,9 +1004,12 @@ export class LocalAgentHostSessionsProvider extends Disposable implements IAgent didChange = true; } - if (changes.diffs !== undefined && !diffsEqual(cached.changes.get(), changes.diffs)) { - cached.changes.set(diffsToChanges(changes.diffs), undefined); - didChange = true; + if (changes.diffs !== undefined) { + const diffs = toSessionFileDiffs(changes.diffs); + if (!diffsEqual(cached.changes.get(), diffs)) { + cached.changes.set(diffsToChanges(diffs), undefined); + didChange = true; + } } if (didChange) { diff --git a/src/vs/sessions/contrib/localAgentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/localAgentHost/test/browser/localAgentHostSessionsProvider.test.ts index 5107d2f4a1dab..f1ecf566f1ed2 100644 --- a/src/vs/sessions/contrib/localAgentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/localAgentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -16,7 +16,7 @@ import type { IAgentSubscription } from '../../../../../platform/agentHost/commo import type { ISessionAction, ITerminalAction } from '../../../../../platform/agentHost/common/state/protocol/action-origin.generated.js'; import type { IResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { NotificationType } from '../../../../../platform/agentHost/common/state/protocol/notifications.js'; -import type { IAgentInfo, IRootState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import type { IAgentInfo, IModelSelection, IRootState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { SessionStatus as ProtocolSessionStatus } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { ActionType, type IActionEnvelope, type INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; @@ -134,7 +134,7 @@ function createSession(id: string, opts?: { provider?: string; summary?: string; startTime: opts?.startTime ?? 1000, modifiedTime: opts?.modifiedTime ?? 2000, summary: opts?.summary, - model: opts?.model, + model: opts?.model ? { id: opts.model } : undefined, project: opts?.project, workingDirectory: opts?.workingDirectory, }; @@ -181,7 +181,7 @@ async function waitForSessionConfig(provider: LocalAgentHostSessionsProvider, se }); } -function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: { provider?: string; title?: string; model?: string; project?: { uri: string; displayName: string }; workingDirectory?: string }): void { +function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: { provider?: string; title?: string; model?: string; modelConfig?: Record; project?: { uri: string; displayName: string }; workingDirectory?: string }): void { const provider = opts?.provider ?? 'copilot'; const sessionUri = AgentSession.uri(provider, rawId); agentHost.fireNotification({ @@ -193,7 +193,7 @@ function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: status: ProtocolSessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), - model: opts?.model, + model: opts?.model ? { id: opts.model, ...(opts.modelConfig ? { config: opts.modelConfig } : {}) } : undefined, project: opts?.project, workingDirectory: opts?.workingDirectory, }, @@ -448,7 +448,23 @@ suite('LocalAgentHostSessionsProvider', () => { assert.deepStrictEqual(agentHost.dispatchedActions.at(-1)?.action, { type: ActionType.SessionModelChanged, session: AgentSession.uri('copilot', 'set-model').toString(), - model: 'new-model', + model: { id: 'new-model' }, + }); + }); + + test('setModel preserves current model config when model id is unchanged', () => { + const provider = createProvider(disposables, agentHost); + fireSessionAdded(agentHost, 'set-model-config', { title: 'Set Model Config Session', model: 'configured-model', modelConfig: { thinkingLevel: 'high' } }); + + const session = provider.getSessions().find(s => s.title.get() === 'Set Model Config Session'); + assert.ok(session); + + provider.setModel(session!.sessionId, 'agent-host-copilot:configured-model'); + + assert.deepStrictEqual(agentHost.dispatchedActions.at(-1)?.action, { + type: ActionType.SessionModelChanged, + session: AgentSession.uri('copilot', 'set-model-config').toString(), + model: { id: 'configured-model', config: { thinkingLevel: 'high' } }, }); }); @@ -595,7 +611,7 @@ suite('LocalAgentHostSessionsProvider', () => { action: { type: ActionType.SessionModelChanged, session: AgentSession.uri('copilot', 'model-change').toString(), - model: 'new-model', + model: { id: 'new-model' } satisfies IModelSelection, }, serverSeq: 1, origin: undefined, diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 1e6ea04c0e53c..708657dde59c7 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -23,7 +23,7 @@ import { RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/ import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; import { NotificationType } from '../../../../platform/agentHost/common/state/protocol/notifications.js'; import type { IResolveSessionConfigResult, ISessionConfigValueItem } from '../../../../platform/agentHost/common/state/protocol/commands.js'; -import type { IRootState, ISessionFileDiff, ISessionSummary } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import type { IFileEdit, IModelSelection, IRootState, ISessionSummary } from '../../../../platform/agentHost/common/state/protocol/state.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; @@ -94,6 +94,17 @@ function buildMutableConfigSchema(config: Record): Record; readonly changes = observableValue('changes', []); readonly modelId: ISettableObservable; + modelSelection: IModelSelection | undefined; readonly mode = observableValue<{ readonly id: string; readonly kind: string } | undefined>('mode', undefined); readonly loading = observableValue('loading', false); readonly isArchived = observableValue('isArchived', false); @@ -198,8 +210,9 @@ class RemoteSessionAdapter implements IChatData { this.createdAt = new Date(metadata.startTime); this.title = observableValue('title', metadata.summary || `Session ${rawId.substring(0, 8)}`); this.updatedAt = observableValue('updatedAt', new Date(metadata.modifiedTime)); + this.modelSelection = metadata.model; this.status = observableValue('status', metadata.status !== undefined ? mapProtocolStatus(metadata.status) : SessionStatus.Completed); - this.modelId = observableValue('modelId', metadata.model ? `${resourceScheme}:${metadata.model}` : undefined); + this.modelId = observableValue('modelId', metadata.model ? `${resourceScheme}:${metadata.model.id}` : undefined); this.lastTurnEnd = observableValue('lastTurnEnd', metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined); this.description = observableValue('description', new MarkdownString().appendText(this._providerLabel)); this.workspace = observableValue('workspace', RemoteAgentHostSessionsProvider.buildWorkspace(metadata.project, metadata.workingDirectory, this._providerLabel)); @@ -231,7 +244,8 @@ class RemoteSessionAdapter implements IChatData { if (metadata.isDone !== undefined) { this.isArchived.set(metadata.isDone, undefined); } - this.modelId.set(metadata.model ? `${this.resource.scheme}:${metadata.model}` : undefined, undefined); + this.modelSelection = metadata.model; + this.modelId.set(metadata.model ? `${this.resource.scheme}:${metadata.model.id}` : undefined, undefined); const workspace = RemoteAgentHostSessionsProvider.buildWorkspace(metadata.project, metadata.workingDirectory, this._providerLabel); if (agentHostSessionWorkspaceKey(workspace) !== agentHostSessionWorkspaceKey(this.workspace.get())) { this.workspace.set(workspace, undefined); @@ -764,7 +778,8 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements IAgen this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] }); const resourceScheme = cached.resource.scheme; const rawModelId = modelId.startsWith(`${resourceScheme}:`) ? modelId.substring(resourceScheme.length + 1) : modelId; - const action = { type: ActionType.SessionModelChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), model: rawModelId }; + const model = cached.modelSelection?.id === rawModelId ? cached.modelSelection : { id: rawModelId }; + const action = { type: ActionType.SessionModelChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), model }; this._connection.dispatch(action); } } @@ -1134,10 +1149,13 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements IAgen } } - private _handleModelChanged(session: string, model: string): void { + private _handleModelChanged(session: string, model: IModelSelection): void { const rawId = AgentSession.id(session); const cached = this._sessionCache.get(rawId); - const modelId = cached ? `${cached.resource.scheme}:${model}` : undefined; + if (cached) { + cached.modelSelection = model; + } + const modelId = cached ? `${cached.resource.scheme}:${model.id}` : undefined; if (cached && cached.modelId.get() !== modelId) { cached.modelId.set(modelId, undefined); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] }); @@ -1162,12 +1180,12 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements IAgen } } - private _handleDiffsChanged(session: string, diffs: ISessionFileDiff[]): void { + private _handleDiffsChanged(session: string, diffs: IFileEdit[]): void { const rawId = AgentSession.id(session); const cached = this._sessionCache.get(rawId); if (cached) { const mapUri = toLocalDiffUri(this._connectionAuthority); - cached.changes.set(diffsToChanges(diffs, mapUri), undefined); + cached.changes.set(diffsToChanges(toSessionFileDiffs(diffs), mapUri), undefined); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] }); } } @@ -1196,8 +1214,9 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements IAgen if (changes.diffs !== undefined) { const mapUri = toLocalDiffUri(this._connectionAuthority); - if (!diffsEqual(cached.changes.get(), changes.diffs, mapUri)) { - cached.changes.set(diffsToChanges(changes.diffs, mapUri), undefined); + const diffs = toSessionFileDiffs(changes.diffs); + if (!diffsEqual(cached.changes.get(), diffs, mapUri)) { + cached.changes.set(diffsToChanges(diffs, mapUri), undefined); didChange = true; } } 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 4c10a77c6f051..055b84605021e 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -15,7 +15,7 @@ import { AgentSession, type IAgentConnection, type IAgentSessionMetadata } from import type { ISessionAction, ITerminalAction } from '../../../../../platform/agentHost/common/state/protocol/action-origin.generated.js'; import type { IResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { NotificationType } from '../../../../../platform/agentHost/common/state/protocol/notifications.js'; -import type { IAgentInfo, IRootState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import type { IAgentInfo, IModelSelection, IRootState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, type IActionEnvelope, type INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionStatus as ProtocolSessionStatus } from '../../../../../platform/agentHost/common/state/sessionState.js'; import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; @@ -130,7 +130,7 @@ function createSession(id: string, opts?: { provider?: string; summary?: string; startTime: opts?.startTime ?? 1000, modifiedTime: opts?.modifiedTime ?? 2000, summary: opts?.summary, - model: opts?.model, + model: opts?.model ? { id: opts.model } : undefined, project: opts?.project, workingDirectory: opts?.workingDirectory, }; @@ -182,7 +182,7 @@ async function waitForSessionConfig(provider: RemoteAgentHostSessionsProvider, s }); } -function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?: { provider?: string; title?: string; model?: string; project?: { uri: string; displayName: string }; workingDirectory?: string }): void { +function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?: { provider?: string; title?: string; model?: string; modelConfig?: Record; project?: { uri: string; displayName: string }; workingDirectory?: string }): void { const provider = opts?.provider ?? 'copilot'; const sessionUri = AgentSession.uri(provider, rawId); connection.fireNotification({ @@ -194,7 +194,7 @@ function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?: status: ProtocolSessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), - model: opts?.model, + model: opts?.model ? { id: opts.model, ...(opts.modelConfig ? { config: opts.modelConfig } : {}) } : undefined, project: opts?.project, workingDirectory: opts?.workingDirectory, }, @@ -451,7 +451,23 @@ suite('RemoteAgentHostSessionsProvider', () => { assert.deepStrictEqual(connection.dispatchedActions.at(-1)?.action, { type: ActionType.SessionModelChanged, session: AgentSession.uri('copilot', 'set-model').toString(), - model: 'new-model', + model: { id: 'new-model' }, + }); + }); + + test('setModel preserves current model config when model id is unchanged', () => { + const provider = createProvider(disposables, connection); + fireSessionAdded(connection, 'set-model-config', { title: 'Set Model Config Session', model: 'configured-model', modelConfig: { thinkingLevel: 'high' } }); + + const session = provider.getSessions().find(s => s.title.get() === 'Set Model Config Session'); + assert.ok(session); + + provider.setModel(session!.sessionId, 'remote-localhost__4321-copilot:configured-model'); + + assert.deepStrictEqual(connection.dispatchedActions.at(-1)?.action, { + type: ActionType.SessionModelChanged, + session: AgentSession.uri('copilot', 'set-model-config').toString(), + model: { id: 'configured-model', config: { thinkingLevel: 'high' } }, }); }); @@ -628,7 +644,7 @@ suite('RemoteAgentHostSessionsProvider', () => { action: { type: ActionType.SessionModelChanged, session: AgentSession.uri('copilot', 'model-change').toString(), - model: 'new-model', + model: { id: 'new-model' } satisfies IModelSelection, }, serverSeq: 1, origin: undefined, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts index 546dd68513b18..d201fafa2c8ce 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts @@ -6,9 +6,9 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; -import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; -import { ISessionModelInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; -import { ILanguageModelChatProvider, ILanguageModelChatMetadataAndIdentifier } from '../../../common/languageModels.js'; +import { IConfigSchema, ISessionModelInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { nullExtensionDescription } from '../../../../../services/extensions/common/extensions.js'; +import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelConfigurationSchema } from '../../../common/languageModels.js'; /** * Exposes models available from the agent host process as selectable @@ -42,7 +42,7 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu .map(m => ({ identifier: `${this._vendor}:${m.id}`, metadata: { - extension: new ExtensionIdentifier('vscode.agent-host'), + extension: nullExtensionDescription.identifier, name: m.name, id: m.id, vendor: this._vendor, @@ -59,10 +59,33 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu toolCalling: true, agentMode: true, }, + configurationSchema: this._toLanguageModelConfigurationSchema(m.configSchema), }, })); } + private _toLanguageModelConfigurationSchema(schema: IConfigSchema | undefined): ILanguageModelConfigurationSchema | undefined { + if (!schema) { + return undefined; + } + + return { + type: schema.type, + required: schema.required, + properties: Object.fromEntries(Object.entries(schema.properties).map(([key, property]) => [key, { + type: property.type, + title: property.title, + description: property.description, + default: property.default, + enum: property.enum, + enumItemLabels: property.enumLabels, + enumDescriptions: property.enumDescriptions, + readOnly: property.readOnly, + group: key === 'thinkingLevel' ? 'navigation' : undefined, + }])), + }; + } + async sendChatRequest(): Promise { throw new Error('Agent-host models do not support direct chat requests'); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index daf255af4f09f..f38cffbce596a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -22,7 +22,7 @@ import { ISessionTruncatedAction } from '../../../../../../platform/agentHost/co import { ICustomizationRef, TerminalClaimKind, ToolResultContentType, type IProtectedResourceMetadata, type IToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, ISessionTurnStartedAction, type ISessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { AttachmentType, buildSubagentSessionUri, getToolFileEdits, getToolSubagentContent, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ICompletedToolCall, type IMessageAttachment, type IRootState, type IResponsePart, type ISessionInputAnswer, type ISessionInputRequest, type ISessionState, type IToolCallRunningState, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { AttachmentType, buildSubagentSessionUri, getToolFileEdits, getToolSubagentContent, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ICompletedToolCall, type IMessageAttachment, type IModelSelection, type IRootState, type IResponsePart, type ISessionInputAnswer, type ISessionInputRequest, type ISessionState, type IToolCallRunningState, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -401,7 +401,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } const sessionState = this._getSessionState(resolvedSession.toString()); if (sessionState) { - const modelId = this._toLanguageModelId(sessionResource, sessionState.summary.model); + const modelId = this._toLanguageModelId(sessionResource, sessionState.summary.model?.id); history.push(...turnsToHistory(resolvedSession, sessionState.turns, this._config.agentId, modelId)); // Enrich history with inner tool calls from subagent @@ -541,7 +541,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // Resolve or create backend session let resolvedSession = this._sessionToBackend.get(request.sessionResource); if (!resolvedSession) { - resolvedSession = await this._createAndSubscribe(request.sessionResource, request.userSelectedModelId, undefined, request.agentHostSessionConfig, getAgentHostBranchNameHint(request.message)); + resolvedSession = await this._createAndSubscribe(request.sessionResource, this._createModelSelection(request.userSelectedModelId, request.modelConfiguration), undefined, request.agentHostSessionConfig, getAgentHostBranchNameHint(request.message)); this._sessionToBackend.set(request.sessionResource, resolvedSession); } @@ -856,14 +856,14 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // If the user selected a different model since the session was created // (or since the last turn), dispatch a model change action first so the // agent backend picks up the new model before processing the turn. - const rawModelId = this._extractRawModelId(request.userSelectedModelId); - if (rawModelId) { + const selectedModel = this._createModelSelection(request.userSelectedModelId, request.modelConfiguration); + if (selectedModel) { const currentModel = this._getSessionState(session.toString())?.summary.model; - if (currentModel !== rawModelId) { + if (!this._modelSelectionsEqual(currentModel, selectedModel)) { this._config.connection.dispatch({ type: ActionType.SessionModelChanged, session: session.toString(), - model: rawModelId, + model: selectedModel, }); } } @@ -1946,14 +1946,13 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } /** Creates a new backend session and subscribes to its state. */ - private async _createAndSubscribe(sessionResource: URI, modelId?: string, fork?: { session: URI; turnIndex: number; turnId: string }, sessionConfig?: Record, branchNameHint?: string): Promise { - const rawModelId = this._extractRawModelId(modelId); + private async _createAndSubscribe(sessionResource: URI, model: IModelSelection | undefined, fork?: { session: URI; turnIndex: number; turnId: string }, sessionConfig?: Record, branchNameHint?: string): Promise { const config = branchNameHint ? { ...sessionConfig, [AgentHostSessionConfigBranchNameHintKey]: branchNameHint } : sessionConfig; const workingDirectory = this._config.resolveWorkingDirectory?.(sessionResource) ?? this._workingDirectoryResolver.resolve(sessionResource) ?? this._workspaceContextService.getWorkspace().folders[0]?.uri; - this._logService.trace(`[AgentHost] Creating new session, model=${rawModelId ?? '(default)'}, provider=${this._config.provider}${fork ? `, fork from ${fork.session.toString()} at index ${fork.turnIndex}` : ''}`); + this._logService.trace(`[AgentHost] Creating new session, model=${model?.id ?? '(default)'}, provider=${this._config.provider}${fork ? `, fork from ${fork.session.toString()} at index ${fork.turnIndex}` : ''}`); // Eagerly authenticate before creating the session if the agent // declares required protected resources. This avoids a wasted @@ -1971,7 +1970,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC let session: URI; try { session = await this._config.connection.createSession({ - model: rawModelId, + model, provider: this._config.provider, workingDirectory, fork, @@ -1984,7 +1983,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const authenticated = await this._config.resolveAuthentication(protectedResources); if (authenticated) { session = await this._config.connection.createSession({ - model: rawModelId, + model, provider: this._config.provider, workingDirectory, fork, @@ -2054,6 +2053,34 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return false; } + private _createModelSelection(languageModelIdentifier: string | undefined, modelConfiguration: Record | undefined): IModelSelection | undefined { + const rawModelId = this._extractRawModelId(languageModelIdentifier); + if (!rawModelId) { + return undefined; + } + + const config: Record = {}; + for (const [key, value] of Object.entries(modelConfiguration ?? {})) { + if (typeof value === 'string') { + config[key] = value; + } + } + + return Object.keys(config).length > 0 ? { id: rawModelId, config } : { id: rawModelId }; + } + + private _modelSelectionsEqual(a: IModelSelection | undefined, b: IModelSelection | undefined): boolean { + if (a?.id !== b?.id) { + return false; + } + + const aConfig = a?.config ?? {}; + const bConfig = b?.config ?? {}; + const aKeys = Object.keys(aConfig); + const bKeys = Object.keys(bConfig); + return aKeys.length === bKeys.length && aKeys.every(key => aConfig[key] === bConfig[key]); + } + /** * Extracts the raw model id from a language-model service identifier. * E.g. "agent-host-copilot:claude-sonnet-4-20250514" โ†’ "claude-sonnet-4-20250514". diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts index f3411eb837080..f1f14e024a5eb 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts @@ -6,6 +6,7 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { hasKey } from '../../../../../../base/common/types.js'; import { URI } from '../../../../../../base/common/uri.js'; import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { AgentSession, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; @@ -14,15 +15,36 @@ import { IProductService } from '../../../../../../platform/product/common/produ import { ChatSessionStatus, IChatSessionFileChange2, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta } from '../../../common/chatSessionsService.js'; import { getAgentHostIcon } from '../agentSessions.js'; -function mapDiffsToChanges(diffs: readonly ISessionFileDiff[] | readonly { readonly uri: string; readonly added?: number; readonly removed?: number }[] | undefined, connectionAuthority: string): readonly IChatSessionFileChange2[] | undefined { +type ICompactSessionFileDiff = { readonly uri: string; readonly added?: number; readonly removed?: number }; + +function mapDiffsToChanges(diffs: readonly ISessionFileDiff[] | readonly ICompactSessionFileDiff[] | undefined, connectionAuthority: string): readonly IChatSessionFileChange2[] | undefined { if (!diffs || diffs.length === 0) { return undefined; } - return diffs.map(d => ({ - uri: toAgentHostUri(URI.parse(d.uri), connectionAuthority), - insertions: d.added ?? 0, - deletions: d.removed ?? 0, - })); + const changes: IChatSessionFileChange2[] = []; + for (const diff of diffs) { + const uri = getDiffUri(diff); + if (uri) { + changes.push({ + uri: toAgentHostUri(URI.parse(uri), connectionAuthority), + insertions: getDiffAdded(diff) ?? 0, + deletions: getDiffRemoved(diff) ?? 0, + }); + } + } + return changes.length > 0 ? changes : undefined; +} + +function getDiffUri(diff: ISessionFileDiff | ICompactSessionFileDiff): string | undefined { + return hasKey(diff, { uri: true }) ? diff.uri : diff.after?.uri ?? diff.before?.uri; +} + +function getDiffAdded(diff: ISessionFileDiff | ICompactSessionFileDiff): number | undefined { + return hasKey(diff, { uri: true }) ? diff.added : diff.diff?.added; +} + +function getDiffRemoved(diff: ISessionFileDiff | ICompactSessionFileDiff): number | undefined { + return hasKey(diff, { uri: true }) ? diff.removed : diff.diff?.removed; } function mapSessionStatus(status: SessionStatus | undefined): ChatSessionStatus { @@ -131,7 +153,6 @@ export class AgentHostSessionListController extends Disposable implements IChatS workingDirectory: s.workingDirectory?.toString(), isRead: s.isRead, isDone: s.isDone, - diffs: s.diffs?.map(d => ({ uri: d.uri, added: d.added, removed: d.removed })), }); return this._makeItem(rawId, { title: s.summary, @@ -167,7 +188,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS workingDirectory?: URI; createdAt: number; modifiedAt: number; - diffs?: readonly ISessionFileDiff[]; + diffs?: readonly ISessionFileDiff[] | readonly ICompactSessionFileDiff[]; }): IChatSessionItem { return { resource: URI.from({ scheme: this._sessionType, path: `/${rawId}` }), diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 5e606a81222d8..d3c22b32b2543 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -351,7 +351,7 @@ function createContribution(disposables: DisposableStore) { return { contribution, listController, sessionHandler, agentHostService, chatAgentService }; } -function makeRequest(overrides: Partial<{ message: string; sessionResource: URI; variables: IChatAgentRequest['variables']; userSelectedModelId: string; agentHostSessionConfig: Record; agentId: string }> = {}): IChatAgentRequest { +function makeRequest(overrides: Partial<{ message: string; sessionResource: URI; variables: IChatAgentRequest['variables']; userSelectedModelId: string; modelConfiguration: Record; agentHostSessionConfig: Record; agentId: string }> = {}): IChatAgentRequest { return upcastPartial({ sessionResource: overrides.sessionResource ?? URI.from({ scheme: 'untitled', path: '/chat-1' }), requestId: 'req-1', @@ -360,6 +360,7 @@ function makeRequest(overrides: Partial<{ message: string; sessionResource: URI; variables: overrides.variables ?? { variables: [] }, location: ChatAgentLocation.Chat, userSelectedModelId: overrides.userSelectedModelId, + modelConfiguration: overrides.modelConfiguration, agentHostSessionConfig: overrides.agentHostSessionConfig, }); } @@ -387,6 +388,7 @@ async function startTurn( sessionResource: URI; variables: IChatAgentRequest['variables']; userSelectedModelId: string; + modelConfiguration: Record; agentHostSessionConfig: Record; cancellationToken: CancellationToken; agentId: string; @@ -413,6 +415,7 @@ async function startTurn( sessionResource, variables: overrides?.variables, userSelectedModelId: overrides?.userSelectedModelId, + modelConfiguration: overrides?.modelConfiguration, agentHostSessionConfig: overrides?.agentHostSessionConfig, agentId, }), @@ -660,7 +663,22 @@ suite('AgentHostChatContribution', () => { await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); - assert.strictEqual(agentHostService.createSessionCalls[0].model, 'claude-sonnet-4-20250514'); + assert.deepStrictEqual(agentHostService.createSessionCalls[0].model, { id: 'claude-sonnet-4-20250514' }); + })); + + test('passes selected model configuration through create session', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'Hi', + userSelectedModelId: 'agent-host-copilot:claude-sonnet-4-20250514', + modelConfiguration: { thinkingLevel: 'high', ignored: 1 }, + }); + fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); + await turnPromise; + + assert.strictEqual(agentHostService.createSessionCalls.length, 1); + assert.deepStrictEqual(agentHostService.createSessionCalls[0].model, { id: 'claude-sonnet-4-20250514', config: { thinkingLevel: 'high' } }); })); test('passes model id as-is when no vendor prefix', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -674,7 +692,7 @@ suite('AgentHostChatContribution', () => { await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); - assert.strictEqual(agentHostService.createSessionCalls[0].model, 'gpt-4o'); + assert.deepStrictEqual(agentHostService.createSessionCalls[0].model, { id: 'gpt-4o' }); })); test('does not create backend session eagerly for untitled sessions', async () => { @@ -1445,6 +1463,45 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(models[0].metadata.name, 'GPT-4o'); }); + test('maps model config schema to picker configuration schema', async () => { + const provider = disposables.add(new AgentHostLanguageModelProvider('agent-host-copilot', 'agent-host-copilot')); + provider.updateModels([ + { + provider: 'copilot', + id: 'claude-sonnet-4.5', + name: 'Claude Sonnet 4.5', + maxContextWindow: 128000, + supportsVision: false, + configSchema: { + type: 'object', + properties: { + thinkingLevel: { + type: 'string', + title: 'Thinking Level', + default: 'medium', + enum: ['low', 'medium', 'high'], + enumLabels: ['Low', 'Medium', 'High'], + }, + }, + }, + }, + ]); + + const models = await provider.provideLanguageModelChatInfo({}, CancellationToken.None); + + assert.deepStrictEqual(models[0].metadata.configurationSchema?.properties?.thinkingLevel, { + type: 'string', + title: 'Thinking Level', + description: undefined, + default: 'medium', + enum: ['low', 'medium', 'high'], + enumItemLabels: ['Low', 'Medium', 'High'], + enumDescriptions: undefined, + readOnly: undefined, + group: 'navigation', + }); + }); + test('returns empty when no models set', async () => { const provider = disposables.add(new AgentHostLanguageModelProvider('agent-host-copilot', 'agent-host-copilot')); From d4f80cba491c098cf7faee5da528baeaca669b83 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Thu, 16 Apr 2026 20:19:52 -0700 Subject: [PATCH 52/56] Enable remote agent hosts by default in Insiders (#310883) (Written by Copilot) --- .../remoteAgentHost/browser/remoteAgentHost.contribution.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index d9d8349104829..810ae6246725d 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -19,6 +19,7 @@ import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurati import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import product from '../../../../platform/product/common/product.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; @@ -577,7 +578,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis [RemoteAgentHostsEnabledSettingId]: { type: 'boolean', description: nls.localize('chat.remoteAgentHosts.enabled', "Enable connecting to remote agent hosts."), - default: false, + default: product.quality === 'insider', scope: ConfigurationScope.APPLICATION, tags: ['experimental', 'advanced'], }, From b6035ba5c220b8c52b9c062de4a3b3b11903978c Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Thu, 16 Apr 2026 20:26:43 -0700 Subject: [PATCH 53/56] Fix agent host worktree terminal cwd (#310893) * Fix agent host worktree terminal cwd (Written by Copilot) * Fix worktree terminal cwd test on Windows (Written by Copilot) --- .../agentHost/node/copilot/copilotAgent.ts | 12 ++-- .../node/copilot/copilotShellTools.ts | 3 +- .../test/node/copilotShellTools.test.ts | 68 +++++++++++++++++++ .../toolApprovalRealSdk.integrationTest.ts | 60 +++++++++++++++- 4 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 src/vs/platform/agentHost/test/node/copilotShellTools.test.ts diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index d5d0b5e9e5c7a..92d0ba6095b9a 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -424,11 +424,11 @@ export class CopilotAgent extends Disposable implements IAgent { const sessionId = config?.session ? AgentSession.id(config.session) : generateUuid(); const sessionUri = AgentSession.uri(this.id, sessionId); - const shellManager = this._instantiationService.createInstance(ShellManager, sessionUri); const activeClient = this._activeClients.get(sessionUri); const snapshot = activeClient ? await activeClient.snapshot() : undefined; - const sessionConfig = this._buildSessionConfig(snapshot, shellManager); const workingDirectory = await this._resolveSessionWorkingDirectory(config, sessionId); + const shellManager = this._instantiationService.createInstance(ShellManager, sessionUri, workingDirectory); + const sessionConfig = this._buildSessionConfig(snapshot, shellManager); const factory: SessionWrapperFactory = async callbacks => { const raw = await client.createSession({ @@ -444,7 +444,7 @@ export class CopilotAgent extends Disposable implements IAgent { let agentSession: CopilotAgentSession; try { - agentSession = this._createAgentSession(factory, workingDirectory, sessionId, shellManager, snapshot); + agentSession = this._createAgentSession(factory, sessionId, shellManager, snapshot); await agentSession.initializeSession(); } catch (error) { await this._removeCreatedWorktree(sessionId); @@ -714,7 +714,7 @@ export class CopilotAgent extends Disposable implements IAgent { * and returns it. The caller must call {@link CopilotAgentSession.initializeSession} * to wire up the SDK session. */ - private _createAgentSession(wrapperFactory: SessionWrapperFactory, _workingDirectory: URI | undefined, sessionId: string, shellManager: ShellManager, snapshot?: IActiveClientSnapshot): CopilotAgentSession { + private _createAgentSession(wrapperFactory: SessionWrapperFactory, sessionId: string, shellManager: ShellManager, snapshot?: IActiveClientSnapshot): CopilotAgentSession { const sessionUri = AgentSession.uri(this.id, sessionId); const agentSession = this._instantiationService.createInstance( @@ -789,7 +789,7 @@ export class CopilotAgent extends Disposable implements IAgent { throw new Error(`workingDirectory is required to resume Copilot session '${sessionId}'`); } - const shellManager = this._instantiationService.createInstance(ShellManager, sessionUri); + const shellManager = this._instantiationService.createInstance(ShellManager, sessionUri, workingDirectory); const sessionConfig = this._buildSessionConfig(snapshot, shellManager); const factory: SessionWrapperFactory = async callbacks => { @@ -822,7 +822,7 @@ export class CopilotAgent extends Disposable implements IAgent { } }; - const agentSession = this._createAgentSession(factory, workingDirectory, sessionId, shellManager, snapshot); + const agentSession = this._createAgentSession(factory, sessionId, shellManager, snapshot); await agentSession.initializeSession(); return agentSession; diff --git a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts index 920213fa8b7d6..a7de0fb17fa2a 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts @@ -70,6 +70,7 @@ export class ShellManager { constructor( private readonly _sessionUri: URI, + private readonly _workingDirectory: URI | undefined, @IAgentHostTerminalManager private readonly _terminalManager: IAgentHostTerminalManager, @ILogService private readonly _logService: ILogService, ) { } @@ -107,7 +108,7 @@ export class ShellManager { terminal: terminalUri, claim, name: shellDisplayName, - cwd, + cwd: cwd ?? this._workingDirectory?.fsPath, }, { shell: getShellExecutable(shellType) }); const shell: IManagedShell = { id, terminalUri, shellType }; diff --git a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts new file mode 100644 index 0000000000000..c53fb2bcbb13c --- /dev/null +++ b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../base/common/uri.js'; +import { Disposable, DisposableStore, type IDisposable } from '../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; +import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; +import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; +import { ILogService, NullLogService } from '../../../log/common/log.js'; +import type { ICreateTerminalParams } from '../../common/state/protocol/commands.js'; +import type { ITerminalClaim, ITerminalInfo } from '../../common/state/protocol/state.js'; +import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; +import { ShellManager } from '../../node/copilot/copilotShellTools.js'; + +class TestAgentHostTerminalManager implements IAgentHostTerminalManager { + declare readonly _serviceBrand: undefined; + + readonly created: ICreateTerminalParams[] = []; + + async createTerminal(params: ICreateTerminalParams): Promise { + this.created.push(params); + } + writeInput(): void { } + onData(): IDisposable { return Disposable.None; } + onExit(): IDisposable { return Disposable.None; } + onClaimChanged(): IDisposable { return Disposable.None; } + onCommandFinished(): IDisposable { return Disposable.None; } + getContent(): string | undefined { return undefined; } + getClaim(): ITerminalClaim | undefined { return undefined; } + hasTerminal(): boolean { return false; } + getExitCode(): number | undefined { return undefined; } + supportsCommandDetection(): boolean { return false; } + disposeTerminal(): void { } + getTerminalInfos(): ITerminalInfo[] { return []; } + getTerminalState(): undefined { return undefined; } +} + +suite('CopilotShellTools', () => { + + const disposables = new DisposableStore(); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('uses session working directory for created shells', async () => { + const terminalManager = new TestAgentHostTerminalManager(); + const services = new ServiceCollection(); + services.set(ILogService, new NullLogService()); + services.set(IAgentHostTerminalManager, terminalManager); + const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); + services.set(IInstantiationService, instantiationService); + const worktreePath = URI.file('/workspace/worktree').fsPath; + const explicitCwd = URI.file('/explicit/cwd').fsPath; + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), URI.file(worktreePath))); + + await shellManager.getOrCreateShell('bash', 'turn-1', 'tool-1'); + await shellManager.getOrCreateShell('bash', 'turn-2', 'tool-2', explicitCwd); + + assert.deepStrictEqual(terminalManager.created.map(c => c.cwd), [ + worktreePath, + explicitCwd, + ]); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts index 621b9d006ebba..22f14edd1b264 100644 --- a/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts @@ -25,12 +25,13 @@ import assert from 'assert'; import { execSync } from 'child_process'; import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; +import { removeAnsiEscapeCodes } from '../../../../../base/common/strings.js'; import { URI } from '../../../../../base/common/uri.js'; import { ISubscribeResult } from '../../../common/state/protocol/commands.js'; import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; import type { ISessionAddedNotification } from '../../../common/state/sessionActions.js'; import type { INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js'; -import type { ISessionState } from '../../../common/state/sessionState.js'; +import { ToolResultContentType, type ISessionState, type ITerminalState, type IToolResultContent } from '../../../common/state/sessionState.js'; import { getActionEnvelope, isActionNotification, @@ -103,6 +104,15 @@ function dispatchTurn(c: TestProtocolClient, session: string, turnId: string, te }); } +function terminalResourceFromContent(content: readonly IToolResultContent[]): string | undefined { + const terminalContent = content.find(c => c.type === ToolResultContentType.Terminal); + return terminalContent?.resource; +} + +function terminalText(state: ITerminalState): string { + return removeAnsiEscapeCodes(state.content.map(part => part.type === 'command' ? `${part.commandLine}\n${part.output}` : part.value).join('')); +} + (REAL_SDK_ENABLED ? suite : suite.skip)('Protocol WebSocket โ€” Real Copilot SDK', function () { let server: IServerHandle; @@ -321,6 +331,7 @@ function dispatchTurn(c: TestProtocolClient, session: string, turnId: string, te addedSummary.workingDirectory!.includes('.worktrees'), `workingDirectory should be under the .worktrees folder, got: ${addedSummary.workingDirectory}`, ); + const resolvedWorkingDirectoryPath = URI.parse(addedSummary.workingDirectory!).fsPath; // Set the active client with tools (matching real VS Code flow where // activeClientChanged is dispatched AFTER createSession). When the next @@ -373,5 +384,52 @@ function dispatchTurn(c: TestProtocolClient, session: string, turnId: string, te // Verify the turn got a response (the session resumed successfully) const responseParts = client.receivedNotifications(n => isActionNotification(n, 'session/responsePart')); assert.ok(responseParts.length > 0, 'should have received at least one response part after session refresh'); + + client.clearReceived(); + dispatchTurn(client, addedSummary.resource, 'turn-wt-terminal', 'Run the shell command: pwd', 3); + + const toolStartNotif = await client.waitForNotification( + n => isActionNotification(n, 'session/toolCallStart'), + 60_000, + ); + const toolStartAction = getActionEnvelope(toolStartNotif).action as { toolCallId: string }; + + const toolReadyNotif = await client.waitForNotification( + n => isActionNotification(n, 'session/toolCallReady'), + 30_000, + ); + const toolReadyAction = getActionEnvelope(toolReadyNotif).action as { confirmed?: string }; + if (!toolReadyAction.confirmed) { + client.notify('dispatchAction', { + clientSeq: 4, + action: { + type: 'session/toolCallConfirmed', + session: addedSummary.resource, + turnId: 'turn-wt-terminal', + toolCallId: toolStartAction.toolCallId, + approved: true, + }, + }); + } + + const terminalContentNotif = await client.waitForNotification(n => { + if (!isActionNotification(n, 'session/toolCallContentChanged')) { + return false; + } + const action = getActionEnvelope(n).action as { toolCallId: string; content: readonly IToolResultContent[] }; + return action.toolCallId === toolStartAction.toolCallId && terminalResourceFromContent(action.content) !== undefined; + }, 30_000); + const terminalContentAction = getActionEnvelope(terminalContentNotif).action as { content: readonly IToolResultContent[] }; + const terminalUri = terminalResourceFromContent(terminalContentAction.content); + assert.ok(terminalUri, 'shell tool should expose its terminal resource'); + + const terminalSubscribeResult = await client.call('subscribe', { resource: terminalUri }); + const initialTerminalState = terminalSubscribeResult.snapshot.state as ITerminalState; + assert.strictEqual(initialTerminalState.cwd, resolvedWorkingDirectoryPath, 'terminal should be created in the resolved worktree directory'); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'), 90_000); + const terminalSnapshot = await client.call('subscribe', { resource: terminalUri }); + const terminalState = terminalSnapshot.snapshot.state as ITerminalState; + assert.ok(terminalText(terminalState).includes(resolvedWorkingDirectoryPath), `pwd output should include the resolved worktree path ${resolvedWorkingDirectoryPath}`); }); }); From f97c287567a7a6b280e5fa1c94e1e3519a99f26f Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Thu, 16 Apr 2026 20:27:14 -0700 Subject: [PATCH 54/56] Don't add agent-host worktree as workspace folder (#310888) The AGENT_HOST_SCHEME guard in getActiveSessionFolderData only covered the repository fallback branch. When a remote agent host session provides a workingDirectory (worktree), it was added as a workspace folder unconditionally. This caused PromptFilesLocator to probe all customization sub-paths (.claude/agents, .claude/rules, .agents, etc.) over RPC, producing noisy warnings for directories that don't exist. Hoist the guard above both branches so any agent-host URI is excluded. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workspace/browser/workspaceFolderManagement.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts index 79de07de7d963..83652b1ea7ae9 100644 --- a/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts +++ b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts @@ -72,6 +72,12 @@ export class WorkspaceFolderManagementContribution extends Disposable implements const worktree = repo?.workingDirectory; const branchName = repo?.detail; + // Remote agent host sessions use a read-only FS provider that + // should not be added as a workspace folder. + if (worktree?.scheme === AGENT_HOST_SCHEME || repository?.scheme === AGENT_HOST_SCHEME) { + return undefined; + } + if (worktree) { return { uri: worktree, @@ -80,11 +86,6 @@ export class WorkspaceFolderManagementContribution extends Disposable implements } if (repository) { - // Remote agent host sessions use a read-only FS provider that - // should not be added as a workspace folder. - if (repository.scheme === AGENT_HOST_SCHEME) { - return undefined; - } return { uri: repository, name: workspace?.label, From 104e9ec48031bc95ce7f4132ff09f26da72b53c8 Mon Sep 17 00:00:00 2001 From: Paul Date: Thu, 16 Apr 2026 20:38:56 -0700 Subject: [PATCH 55/56] Fix an issue with updateReadonly perf (#310262) --- .../service/promptsServiceImpl.ts | 46 ++++++--- .../common/filesConfigurationService.ts | 20 +++- .../browser/filesConfigurationService.test.ts | 97 +++++++++++++++++++ .../test/common/workbenchTestServices.ts | 2 +- 4 files changed, 149 insertions(+), 16 deletions(-) create mode 100644 src/vs/workbench/services/filesConfiguration/test/browser/filesConfigurationService.test.ts diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 1aa966316e9c8..3a75fb8f6b9f8 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -158,6 +158,15 @@ export class PromptsService extends Disposable implements IPromptsService { private readonly _onDidPluginHooksChange = this._register(new Emitter()); private _pluginPromptFilesByType = new Map(); + /** + * Pending URIs to mark as readonly, flushed on the next microtask. + * This batches multiple `registerContributedFile` calls (which happen + * synchronously in the extension point handler) into a single + * `updateReadonly` call to avoid firing `onDidChangeReadonly` per file. + */ + private _pendingReadonlyUris: URI[] = []; + private _pendingReadonlyFlush = false; + constructor( @ILogService public readonly logger: ILogService, @ILabelService private readonly labelService: ILabelService, @@ -449,6 +458,7 @@ export class PromptsService extends Disposable implements IPromptsService { */ private async listFromProviders(type: PromptsType, activationEvent: string, token: CancellationToken): Promise { const result: IExtensionPromptPath[] = []; + const readonlyUris: URI[] = []; // Activate extensions that might provide files for this type await this.extensionService.activateByEvent(activationEvent); @@ -467,12 +477,7 @@ export class PromptsService extends Disposable implements IPromptsService { } for (const file of files) { - try { - await this.filesConfigService.updateReadonly(file.uri, true); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - this.logger.error(`[listFromProviders] Failed to make file readonly: ${file.uri}`, msg); - } + readonlyUris.push(file.uri); result.push({ uri: file.uri, storage: PromptsStorage.extension, @@ -489,6 +494,11 @@ export class PromptsService extends Disposable implements IPromptsService { } } + // Mark all collected files as readonly in a single batch to avoid + // firing onDidChangeReadonly once per file (which causes a cascade + // of event handlers and can freeze the renderer). + void this.filesConfigService.updateReadonly(readonlyUris, true); + return result; } @@ -890,15 +900,14 @@ export class PromptsService extends Disposable implements IPromptsService { } } - try { - await this.filesConfigService.updateReadonly(uri, true); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - this.logger.error(`[registerContributedFile] Failed to make prompt file readonly: ${uri}`, msg); - } return { uri, name, description, when, sessionTypes, storage: PromptsStorage.extension, type, extension, source: PromptFileSource.ExtensionContribution } satisfies IExtensionPromptPath; })(); bucket.set(uri, entryPromise); + + // Enqueue the URI for a batched readonly update instead of calling + // updateReadonly per file, which would fire onDidChangeReadonly each time. + this._enqueueReadonlyUpdate(uri); + if (when) { this._contributedWhenClauses.set(`${type}/${uri.toString()}`, when); } @@ -929,6 +938,19 @@ export class PromptsService extends Disposable implements IPromptsService { }; } + private _enqueueReadonlyUpdate(uri: URI): void { + this._pendingReadonlyUris.push(uri); + if (!this._pendingReadonlyFlush) { + this._pendingReadonlyFlush = true; + queueMicrotask(() => { + const uris = this._pendingReadonlyUris; + this._pendingReadonlyUris = []; + this._pendingReadonlyFlush = false; + void this.filesConfigService.updateReadonly(uris, true); + }); + } + } + private _updateContributedWhenKeys(): void { this._contributedWhenKeys.clear(); for (const whenClause of this._contributedWhenClauses.values()) { diff --git a/src/vs/workbench/services/filesConfiguration/common/filesConfigurationService.ts b/src/vs/workbench/services/filesConfiguration/common/filesConfigurationService.ts index a7e2a589c3bae..ab7f8d98f4516 100644 --- a/src/vs/workbench/services/filesConfiguration/common/filesConfigurationService.ts +++ b/src/vs/workbench/services/filesConfiguration/common/filesConfigurationService.ts @@ -104,6 +104,7 @@ export interface IFilesConfigurationService { isReadonly(resource: URI, stat?: IBaseFileStat): boolean | IMarkdownString; updateReadonly(resource: URI, readonly: true | false | 'toggle' | 'reset'): Promise; + updateReadonly(resource: URI[], readonly: true | false | 'reset'): Promise; //#endregion @@ -239,7 +240,17 @@ export class FilesConfigurationService extends Disposable implements IFilesConfi return false; } - async updateReadonly(resource: URI, readonly: true | false | 'toggle' | 'reset'): Promise { + async updateReadonly(resource: URI | URI[], readonly: true | false | 'toggle' | 'reset'): Promise { + if (Array.isArray(resource)) { + for (const r of resource) { + this.applyReadonly(r, readonly as true | false | 'reset'); + } + if (resource.length > 0) { + this._onDidChangeReadonly.fire(); + } + return; + } + if (readonly === 'toggle') { let stat: IFileStatWithMetadata | undefined = undefined; try { @@ -251,13 +262,16 @@ export class FilesConfigurationService extends Disposable implements IFilesConfi readonly = !this.isReadonly(resource, stat); } + this.applyReadonly(resource, readonly); + this._onDidChangeReadonly.fire(); + } + + private applyReadonly(resource: URI, readonly: true | false | 'reset'): void { if (readonly === 'reset') { this.sessionReadonlyOverrides.delete(resource); } else { this.sessionReadonlyOverrides.set(resource, readonly); } - - this._onDidChangeReadonly.fire(); } private registerListeners(): void { diff --git a/src/vs/workbench/services/filesConfiguration/test/browser/filesConfigurationService.test.ts b/src/vs/workbench/services/filesConfiguration/test/browser/filesConfigurationService.test.ts new file mode 100644 index 0000000000000..e9d82815c07be --- /dev/null +++ b/src/vs/workbench/services/filesConfiguration/test/browser/filesConfigurationService.test.ts @@ -0,0 +1,97 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestFilesConfigurationService, TestServiceAccessor, workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; + +suite('FilesConfigurationService', () => { + + const disposables = new DisposableStore(); + let service: TestFilesConfigurationService; + + setup(() => { + const instantiationService = workbenchInstantiationService(undefined, disposables); + service = instantiationService.createInstance(TestServiceAccessor).filesConfigurationService; + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('updateReadonly with single resource fires onDidChangeReadonly once', async () => { + const resource = URI.file('/test/file.txt'); + let eventCount = 0; + disposables.add(service.onDidChangeReadonly(() => eventCount++)); + + await service.updateReadonly(resource, true); + + assert.strictEqual(eventCount, 1); + assert.strictEqual(!!service.isReadonly(resource), true); + }); + + test('updateReadonly with array of resources fires onDidChangeReadonly once', async () => { + const resources = [ + URI.file('/test/file1.txt'), + URI.file('/test/file2.txt'), + URI.file('/test/file3.txt'), + ]; + let eventCount = 0; + disposables.add(service.onDidChangeReadonly(() => eventCount++)); + + await service.updateReadonly(resources, true); + + assert.strictEqual(eventCount, 1); + for (const resource of resources) { + assert.strictEqual(!!service.isReadonly(resource), true); + } + }); + + test('updateReadonly with empty array does not fire onDidChangeReadonly', async () => { + let eventCount = 0; + disposables.add(service.onDidChangeReadonly(() => eventCount++)); + + await service.updateReadonly([], true); + + assert.strictEqual(eventCount, 0); + }); + + test('updateReadonly with array supports reset', async () => { + const resources = [ + URI.file('/test/file1.txt'), + URI.file('/test/file2.txt'), + ]; + + await service.updateReadonly(resources, true); + for (const resource of resources) { + assert.strictEqual(!!service.isReadonly(resource), true); + } + + await service.updateReadonly(resources, 'reset'); + for (const resource of resources) { + assert.strictEqual(service.isReadonly(resource), false); + } + }); + + test('multiple single updateReadonly calls fire onDidChangeReadonly multiple times', async () => { + const resources = [ + URI.file('/test/file1.txt'), + URI.file('/test/file2.txt'), + URI.file('/test/file3.txt'), + ]; + let eventCount = 0; + disposables.add(service.onDidChangeReadonly(() => eventCount++)); + + for (const resource of resources) { + await service.updateReadonly(resource, true); + } + + assert.strictEqual(eventCount, 3); + }); +}); diff --git a/src/vs/workbench/test/common/workbenchTestServices.ts b/src/vs/workbench/test/common/workbenchTestServices.ts index c18d8310477d5..c5ee2c49eeb8f 100644 --- a/src/vs/workbench/test/common/workbenchTestServices.ts +++ b/src/vs/workbench/test/common/workbenchTestServices.ts @@ -352,7 +352,7 @@ export const NullFilesConfigurationService = new class implements IFilesConfigur enableAutoSaveAfterShortDelay(resourceOrEditor: URI | EditorInput): IDisposable { throw new Error('Method not implemented.'); } disableAutoSave(resourceOrEditor: URI | EditorInput): IDisposable { throw new Error('Method not implemented.'); } isReadonly(resource: URI, stat?: IBaseFileStat | undefined): boolean { return false; } - async updateReadonly(resource: URI, readonly: boolean | 'toggle' | 'reset'): Promise { } + async updateReadonly(_resource: URI | URI[], _readonly: boolean | 'toggle' | 'reset'): Promise { } preventSaveConflicts(resource: URI, language?: string | undefined): boolean { throw new Error('Method not implemented.'); } }; From 6cd94ddc6f13b64c6de30895e563edd35a67ef69 Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Thu, 16 Apr 2026 20:55:32 -0700 Subject: [PATCH 56/56] revert: remove anthropic-beta header merge in Claude streaming endpoint (#310908) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Callers can now opt into Anthropic features (context editing, tool search, etc.) by setting modelCapabilities on the request itself, as introduced in #308387. The base endpoint no longer needs to inject beta headers โ€” the Claude agent controls its own headers. Reverts the merge logic from microsoft/vscode-copilot-chat#4945. --- .../claude/node/claudeLanguageModelServer.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts index b1fe2b3e17e81..617c0b55f830d 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts @@ -452,17 +452,7 @@ class ClaudeStreamingPassThroughEndpoint implements IChatEndpoint { if (typeof this.requestHeaders['anthropic-beta'] === 'string') { const filtered = filterSupportedBetas(this.requestHeaders['anthropic-beta']); if (filtered) { - if (headers['anthropic-beta']) { - // Merge SDK's filtered betas with base endpoint's betas (e.g. config-driven - // context-management) instead of overwriting, deduplicating exact matches. - const allBetas = new Set([ - ...headers['anthropic-beta'].split(',').map(b => b.trim()), - ...filtered.split(',').map(b => b.trim()), - ]); - headers['anthropic-beta'] = [...allBetas].join(','); - } else { - headers['anthropic-beta'] = filtered; - } + headers['anthropic-beta'] = filtered; } } return headers;