From 97aa46f6c8e3476ca3d0936f33cbc04e36c418fe Mon Sep 17 00:00:00 2001 From: Bikesh Date: Fri, 19 Jun 2026 19:15:29 +0000 Subject: [PATCH 01/25] Add tests for prefixedUuid --- src/vs/base/common/uuid.ts | 2 +- src/vs/base/test/common/uuid.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/vs/base/common/uuid.ts b/src/vs/base/common/uuid.ts index f63fc232872..6174efbc99b 100644 --- a/src/vs/base/common/uuid.ts +++ b/src/vs/base/common/uuid.ts @@ -64,7 +64,7 @@ export const generateUuid = (function (): () => string { }; })(); -/** Namespace should be 3 letter. */ +/** Namespace should be 3 letters, e.g. `abc-`. */ export function prefixedUuid(namespace: string): string { return `${namespace}-${generateUuid()}`; } diff --git a/src/vs/base/test/common/uuid.test.ts b/src/vs/base/test/common/uuid.test.ts index d1787f2d028..13985946aa9 100644 --- a/src/vs/base/test/common/uuid.test.ts +++ b/src/vs/base/test/common/uuid.test.ts @@ -23,4 +23,17 @@ suite('UUID', () => { assert.ok(uuid.isUUID(value)); } }); + + test('prefixedUuid', () => { + const namespace = 'abc'; + const result = uuid.prefixedUuid(namespace); + + assert.ok(result.startsWith(`${namespace}-`), `Expected "${result}" to start with "${namespace}-"`); + + const expectedLength = namespace.length + 1 + 36; + assert.strictEqual(result.length, expectedLength); + + const uuidPart = result.slice(namespace.length + 1); + assert.ok(uuid.isUUID(uuidPart), `Expected "${uuidPart}" to be a valid UUID`); + }); }); From c0c59ca93cdb723ffd75223249f1190ce4ffa1e5 Mon Sep 17 00:00:00 2001 From: Paul Date: Fri, 19 Jun 2026 14:21:14 -0700 Subject: [PATCH 02/25] Reduce noise from quota exceeded input notification (#322005) --- .../chat/browser/chatQuotaNotification.ts | 73 ++++++++++++++-- .../browser/chatQuotaNotification.test.ts | 83 ++++++++++++++++++- 2 files changed, 145 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index 57d4bcd91da..995ec1fa9a6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -7,7 +7,7 @@ import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.j import { safeIntl } from '../../../../base/common/date.js'; import { localize } from '../../../../nls.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { ChatEntitlement, IChatEntitlementService, IQuotaSnapshot, IRateLimitSnapshot } from '../../../services/chat/common/chatEntitlementService.js'; import { isSelectedModelCopilot, SELECTED_MODEL_STORAGE_KEY_PREFIX } from '../common/chatSelectedModel.js'; @@ -17,6 +17,14 @@ import { ChatInputNotificationSeverity, IChatInputNotification, IChatInputNotifi const QUOTA_NOTIFICATION_ID = 'copilot.quotaStatus'; const THRESHOLDS = [50, 75, 90, 95]; +/** + * Persisted flag remembering that the user dismissed the quota-exceeded + * notification. Kept until quota recovers (credit becomes available again) so + * the banner does not re-appear on every window reload while quota is still + * exhausted. + */ +const QUOTA_EXHAUSTED_DISMISSED_STORAGE_KEY = 'chat.quotaNotification.exhaustedDismissed'; + /** * Core-side workbench contribution that shows chat input notifications for * quota exhaustion and quota-approaching thresholds. @@ -69,6 +77,15 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo } })); + // Remember when the user dismisses the quota-exceeded notification so it + // does not re-appear on the next window reload while quota is still + // exhausted. The flag is cleared from `_update` once quota recovers. + this._register(this._chatInputNotificationService.onDidDismiss(id => { + if (id === QUOTA_NOTIFICATION_ID && this._showingExhausted) { + this._setExhaustedDismissed(); + } + })); + // Check initial state in case quota is already exhausted at startup this._update(); } @@ -101,6 +118,16 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo const entitlement = this._chatEntitlementService.entitlement; const isCopilot = this._isCopilotModelSelected(); + // Once quota recovers (credit is positively available again) drop any + // persisted dismissal so the quota-exceeded notification can show the next + // time quota runs out. Done before the Copilot/BYOK gate so a recovery is + // always observed, even while a BYOK model is selected. Guarded on a + // present snapshot so the transient "no quota data yet" state at + // startup/reload does not wipe the flag. + if (this._isQuotaKnownAvailable()) { + this._clearExhaustedDismissed(); + } + // Defer new notifications when a BYOK model is selected or the model // selection hasn't loaded yet — quota only applies to Copilot models. // Already-shown notifications stay visible. @@ -115,7 +142,9 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo // authoritative signal that the org has exceeded its budget, regardless of // overages or remaining quota. if (this._isManagedPlan(entitlement) && this._isManagedPlanBlocked()) { - this._showManagedPlanBlockedNotification(); + if (!this._isExhaustedDismissed()) { + this._showManagedPlanBlockedNotification(); + } return; } @@ -126,14 +155,16 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo const wasAdditionalUsageEnabled = this._prevAdditionalUsageEnabled; this._prevAdditionalUsageEnabled = additionalUsageEnabled; - if (additionalUsageEnabled) { - // Show overage notification on a live transition to 100%, - // or when overages are enabled while already at 100%. - if (this._prevQuotaPercentUsed !== undefined || wasAdditionalUsageEnabled === false) { - this._showOverageActivationNotification(); + if (!this._isExhaustedDismissed()) { + if (additionalUsageEnabled) { + // Show overage notification on a live transition to 100%, + // or when overages are enabled while already at 100%. + if (this._prevQuotaPercentUsed !== undefined || wasAdditionalUsageEnabled === false) { + this._showOverageActivationNotification(); + } + } else { + this._showExhaustedNotification(); } - } else { - this._showExhaustedNotification(); } // Keep the baseline up-to-date so that recovery from exhaustion @@ -410,4 +441,28 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo this._showingExhausted = false; this._chatInputNotificationService.deleteNotification(QUOTA_NOTIFICATION_ID); } + + // --- Exhausted dismissal persistence ------------------------------------ + + /** + * Returns `true` only when there is an actual quota snapshot indicating that + * credit is available (i.e. quota is not used up). Returns `false` when no + * snapshot has loaded yet, so the transient "no data" state at startup/reload + * is not mistaken for recovery. + */ + private _isQuotaKnownAvailable(): boolean { + return !!this._getRelevantSnapshot() && !this._isQuotaUsedUp(); + } + + private _isExhaustedDismissed(): boolean { + return this._storageService.getBoolean(QUOTA_EXHAUSTED_DISMISSED_STORAGE_KEY, StorageScope.APPLICATION, false); + } + + private _setExhaustedDismissed(): void { + this._storageService.store(QUOTA_EXHAUSTED_DISMISSED_STORAGE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + } + + private _clearExhaustedDismissed(): void { + this._storageService.remove(QUOTA_EXHAUSTED_DISMISSED_STORAGE_KEY, StorageScope.APPLICATION); + } } diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts index 81108bd53e2..99f9050e671 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -127,6 +127,7 @@ function createMockNotificationService() { getNotification(): IChatInputNotification | undefined { return deleted || dismissed ? undefined : lastNotification; }, get wasDeleted() { return deleted; }, get setCount() { return setCount; }, + dismiss(id: string) { service.dismissNotification(id); }, reset() { lastNotification = undefined; deleted = false; dismissed = false; setCount = 0; }, }; } @@ -156,11 +157,11 @@ suite('ChatQuotaNotificationContribution', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - function createContribution(entitlementOpts?: Parameters[0], modelOpts?: { vendor?: string }) { + function createContribution(entitlementOpts?: Parameters[0], modelOpts?: { vendor?: string }, sharedStorageService?: InMemoryStorageService) { const entitlementMock = createMockEntitlementService(entitlementOpts); const notificationMock = createMockNotificationService(); const contextKeyService = store.add(new MockContextKeyService()); - const storageService = store.add(new InMemoryStorageService()); + const storageService = sharedStorageService ?? store.add(new InMemoryStorageService()); const vendor = modelOpts?.vendor ?? 'copilot'; const isBYOK = vendor !== 'copilot'; // Persist model selection in storage (used by getSelectedModelVendor) @@ -273,6 +274,84 @@ suite('ChatQuotaNotificationContribution', () => { }); }); + // --- Exhausted dismissal persistence ------------------------------------ + + suite('exhausted dismissal persistence', () => { + test('does not re-show exhausted notification after reload when previously dismissed', () => { + const storageService = store.add(new InMemoryStorageService()); + + // First window: exhausted notification shown, then dismissed by the user. + const first = createContribution( + { quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) } }, + undefined, + storageService, + ); + const notification = first.notificationMock.getNotification(); + assert.ok(notification); + first.notificationMock.dismiss(notification!.id); + first.contribution.dispose(); + + // Reload: new contribution with the same (persisted) storage and still-exhausted quota. + const second = createContribution( + { quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) } }, + undefined, + storageService, + ); + assert.strictEqual(second.notificationMock.getNotification(), undefined); + }); + + test('re-shows exhausted notification after quota recovers and is exhausted again', () => { + const storageService = store.add(new InMemoryStorageService()); + + // Exhausted and dismissed. + const first = createContribution( + { quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) } }, + undefined, + storageService, + ); + first.notificationMock.dismiss(first.notificationMock.getNotification()!.id); + + // Quota recovers — persisted dismissal is cleared. + updateQuotas(first.entitlementMock, { premiumChat: makeQuotaSnapshot(50) }); + first.contribution.dispose(); + + // Reload while exhausted again — notification shows because the flag was cleared. + const second = createContribution( + { quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) } }, + undefined, + storageService, + ); + assert.ok(second.notificationMock.getNotification()); + assert.strictEqual(second.notificationMock.getNotification()!.message, 'Credit Limit Reached'); + }); + + test('keeps dismissal across reload when quota data is not loaded yet at startup', () => { + const storageService = store.add(new InMemoryStorageService()); + + // First window: exhausted notification shown, then dismissed by the user. + const first = createContribution( + { quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) } }, + undefined, + storageService, + ); + first.notificationMock.dismiss(first.notificationMock.getNotification()!.id); + first.contribution.dispose(); + + // Reload: quota snapshots have not been fetched yet (no relevant snapshot), + // so the dismissal must NOT be cleared by the transient "no data" state. + const second = createContribution( + { quotas: { usageBasedBilling: true, premiumChat: undefined } }, + undefined, + storageService, + ); + assert.strictEqual(second.notificationMock.getNotification(), undefined); + + // Quota data arrives showing it is still exhausted — banner stays suppressed. + updateQuotas(second.entitlementMock, { premiumChat: makeQuotaSnapshot(0) }); + assert.strictEqual(second.notificationMock.getNotification(), undefined); + }); + }); + // --- Exhausted notification descriptions -------------------------------- suite('exhausted notification descriptions', () => { From 3d575eec6f41a57554710fa005bd4db951aee6d3 Mon Sep 17 00:00:00 2001 From: "vs-code-engineering[bot]" <122617954+vs-code-engineering[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:32:24 +0000 Subject: [PATCH 03/25] fix: re-check cancellation after postToolUse hook to avoid closed-stream write (fixes #321951) (#321959) fix: re-check cancellation after postToolUse hook to avoid closed stream write (fixes #321951) The postToolUse hook can run for a long time because it spawns external, user-configured commands. If the chat request is cancelled while the hook runs, the response stream is closed by the time the hook resolves, and writing hook progress to it throws an unhandled "Response stream has been closed" error. The prior fix (cdf17eb2, #319011) only checked cancellation before invoking the hook, leaving the cancellation-during-hook window open. Re-check the cancellation token in executePostToolUseHook after the await and skip result processing when cancelled, since a cancelled turn never consumes the result. Co-authored-by: vs-code-engineering[bot] <122617954+vs-code-engineering[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/extension/chat/vscode-node/chatHookService.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/extensions/copilot/src/extension/chat/vscode-node/chatHookService.ts b/extensions/copilot/src/extension/chat/vscode-node/chatHookService.ts index a35158b37d2..b6b9501a871 100644 --- a/extensions/copilot/src/extension/chat/vscode-node/chatHookService.ts +++ b/extensions/copilot/src/extension/chat/vscode-node/chatHookService.ts @@ -501,6 +501,16 @@ export class ChatHookService implements IChatHookService { token ); + // Running the hook can take a long time because it spawns external, user-configured + // commands. If the request was cancelled while the hook ran, the response stream is + // already closed and writing hook progress to it throws "Response stream has been + // closed". The caller only checks cancellation before invoking the hook, so re-check + // here after the await and skip result processing - a cancelled turn never consumes + // the result anyway. + if (token?.isCancellationRequested) { + return undefined; + } + if (results.length === 0) { return undefined; } From ceb6be17cfcd7268101510e7e0d54b65a2fe0c34 Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:33:32 -0700 Subject: [PATCH 04/25] Fix overlay positioning of quick input in browser URL bar (#322143) * Fix overlay positioning of quick input in browser URL bar * feedback --- .../quickinput/browser/quickInputController.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/vs/platform/quickinput/browser/quickInputController.ts b/src/vs/platform/quickinput/browser/quickInputController.ts index ff35a90d0a7..2d3710f5dee 100644 --- a/src/vs/platform/quickinput/browser/quickInputController.ts +++ b/src/vs/platform/quickinput/browser/quickInputController.ts @@ -906,28 +906,34 @@ export class QuickInputController extends Disposable { const isElement = dom.isHTMLElement(target); const anchorWindow = isElement ? dom.getWindow(target) : dom.getActiveWindow(); const container = this.layoutService.getContainer(anchorWindow).getBoundingClientRect(); - let anchor = getAnchorRect(target); + const verticalPadding = 6 + 26 + 16; // Accounts for input box and padding + let anchor = getAnchorRect(target); + let preferredAnchorPosition = AnchorPosition.ABOVE; let listHeightRatio = 0.2; + let maxListHeight = 200; + if (this.controller.anchorPosition === 'overlay') { width = anchor.width + 12; listHeightRatio = 0.4; anchor = { - ...anchor, - top: anchor.top - 7 - anchor.height, + top: anchor.top - 7, left: anchor.left - 7, + width: anchor.width, + height: 0 }; + maxListHeight = Math.min(400, container.bottom - anchor.top - verticalPadding); + preferredAnchorPosition = AnchorPosition.BELOW; } else { width = 380; } - const maxListHeight = listHeightRatio * 1000; listHeight = this.dimension ? Math.min(this.dimension.height * listHeightRatio, maxListHeight) : maxListHeight; // Beware: // We need to add some extra pixels to the height to account for the input and padding. - const containerHeight = Math.floor(listHeight) + 6 + 26 + 16; - const { top, left, right, bottom, anchorAlignment, anchorPosition } = layout2d(container, { width, height: containerHeight }, anchor, { anchorPosition: AnchorPosition.ABOVE }); + const containerHeight = Math.floor(listHeight) + verticalPadding; + const { top, left, right, bottom, anchorAlignment, anchorPosition } = layout2d(container, { width, height: containerHeight }, anchor, { anchorPosition: preferredAnchorPosition }); if (anchorAlignment === AnchorAlignment.RIGHT) { style.right = `${right}px`; From 044134364a85d72c5d998427c5d06d4d127bc9e3 Mon Sep 17 00:00:00 2001 From: Alexandru Dima Date: Fri, 19 Jun 2026 23:40:20 +0200 Subject: [PATCH 05/25] ci: split Electron PR test jobs into unit/integration and smoke (#322145) * ci: split Electron test jobs into unit/integration and smoke The Linux, Windows and macOS Electron PR test jobs are the slowest in CI, dominated by the smoke test run. Split each into two parallel jobs - one running unit + integration tests, the other running smoke tests - to cut wall-clock time. Done via two new parameters on the reusable workflows (unit_and_integration_tests and smoke_tests, both defaulting to true) so Browser and Remote jobs are unchanged. Artifact names get a -smoke suffix on the smoke-only job to avoid upload collisions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: gate build and diagnostics to correct Electron test phase Follow-up to the Electron job split. Ensure each half only does the work it needs: - Gate "Build integration tests" on unit_and_integration_tests so the smoke-only job skips it. - Scope the before/after diagnostics steps to their phase (combined with always()) so they don't run in the wrong job. - Move the Copilot extension build into the smoke phase (gated on smoke_tests) instead of compiling it unconditionally; align Linux, Windows and macOS on the same ordering. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: drop space and parens from Electron-Smoke job name The Windows 1ES runner builds its JobId label from job_name, producing "windows-test-Electron (Smoke)-...". The space and parentheses prevented the runner from picking up the job. Rename the smoke job to Electron-Smoke on all three platforms so the JobId is a plain slug. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fixes --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-darwin-test.yml | 39 +++++++++------- .github/workflows/pr-linux-test.yml | 43 ++++++++++-------- .github/workflows/pr-win32-test.yml | 67 +++++++++++++++------------- .github/workflows/pr.yml | 27 +++++++++++ 4 files changed, 110 insertions(+), 66 deletions(-) diff --git a/.github/workflows/pr-darwin-test.yml b/.github/workflows/pr-darwin-test.yml index c36da018996..971fda8191f 100644 --- a/.github/workflows/pr-darwin-test.yml +++ b/.github/workflows/pr-darwin-test.yml @@ -13,13 +13,19 @@ on: remote_tests: type: boolean default: false + unit_and_integration_tests: + type: boolean + default: true + smoke_tests: + type: boolean + default: true jobs: macOS-test: name: ${{ inputs.job_name }} runs-on: macos-14-xlarge env: - ARTIFACT_NAME: ${{ (inputs.electron_tests && 'electron') || (inputs.browser_tests && 'browser') || (inputs.remote_tests && 'remote') || 'unknown' }} + ARTIFACT_NAME: ${{ (inputs.electron_tests && 'electron') || (inputs.browser_tests && 'browser') || (inputs.remote_tests && 'remote') || 'unknown' }}${{ (!inputs.unit_and_integration_tests && inputs.smoke_tests) && '-smoke' || '' }} NPM_ARCH: arm64 VSCODE_ARCH: arm64 steps: @@ -113,23 +119,23 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: 🧪 Run unit tests (Electron) - if: ${{ inputs.electron_tests }} + if: ${{ inputs.electron_tests && inputs.unit_and_integration_tests }} timeout-minutes: 15 run: ./scripts/test.sh --tfs "Unit Tests" - name: 🧪 Run unit tests (node.js) - if: ${{ inputs.electron_tests }} + if: ${{ inputs.electron_tests && inputs.unit_and_integration_tests }} timeout-minutes: 15 run: npm run test-node - name: 🧪 Run unit tests (Browser, Webkit) - if: ${{ inputs.browser_tests }} + if: ${{ inputs.browser_tests && inputs.unit_and_integration_tests }} timeout-minutes: 30 run: npm run test-browser-no-install -- --browser webkit --tfs "Browser Unit Tests" env: DEBUG: "*browser*" - - name: Build integration tests + - name: Compile extensions for integration tests & smoke tests run: | set -e npm run gulp \ @@ -152,55 +158,54 @@ jobs: compile-extension:vscode-test-resolver - name: 🧪 Run integration tests (Electron) - if: ${{ inputs.electron_tests }} + if: ${{ inputs.electron_tests && inputs.unit_and_integration_tests }} timeout-minutes: 20 run: ./scripts/test-integration.sh --tfs "Integration Tests" - name: 🧪 Run integration tests (Browser, Webkit) - if: ${{ inputs.browser_tests }} + if: ${{ inputs.browser_tests && inputs.unit_and_integration_tests }} timeout-minutes: 20 run: ./scripts/test-web-integration.sh --browser webkit - name: 🧪 Run integration tests (Remote) - if: ${{ inputs.remote_tests }} + if: ${{ inputs.remote_tests && inputs.unit_and_integration_tests }} timeout-minutes: 20 run: ./scripts/test-remote-integration.sh - name: Compile smoke tests + if: ${{ inputs.smoke_tests }} working-directory: test/smoke run: npm run compile - - name: Compile extensions for smoke tests - run: npm run gulp compile-extension-media - - - name: Build Copilot Chat extension for smoke tests + - name: Compile Copilot Chat extension for smoke tests + if: ${{ inputs.smoke_tests }} working-directory: extensions/copilot run: npm run compile - name: Diagnostics before smoke test run + if: ${{ inputs.smoke_tests && always() }} run: ps -ef continue-on-error: true - if: always() - name: 🧪 Run smoke tests (Electron) - if: ${{ inputs.electron_tests }} + if: ${{ inputs.electron_tests && inputs.smoke_tests }} timeout-minutes: 20 run: npm run smoketest-no-compile -- --tracing - name: 🧪 Run smoke tests (Browser, Chromium) - if: ${{ inputs.browser_tests }} + if: ${{ inputs.browser_tests && inputs.smoke_tests }} timeout-minutes: 20 run: npm run smoketest-no-compile -- --web --tracing --headless - name: 🧪 Run smoke tests (Remote) - if: ${{ inputs.remote_tests }} + if: ${{ inputs.remote_tests && inputs.smoke_tests }} timeout-minutes: 20 run: npm run smoketest-no-compile -- --remote --tracing - name: Diagnostics after smoke test run + if: ${{ inputs.smoke_tests && always() }} run: ps -ef continue-on-error: true - if: always() - name: Publish Crash Reports uses: actions/upload-artifact@v7 diff --git a/.github/workflows/pr-linux-test.yml b/.github/workflows/pr-linux-test.yml index 452f974f802..113d41cdda3 100644 --- a/.github/workflows/pr-linux-test.yml +++ b/.github/workflows/pr-linux-test.yml @@ -13,13 +13,19 @@ on: remote_tests: type: boolean default: false + unit_and_integration_tests: + type: boolean + default: true + smoke_tests: + type: boolean + default: true jobs: linux-test: name: ${{ inputs.job_name }} runs-on: ubuntu-24.04 env: - ARTIFACT_NAME: ${{ (inputs.electron_tests && 'electron') || (inputs.browser_tests && 'browser') || (inputs.remote_tests && 'remote') || 'unknown' }} + ARTIFACT_NAME: ${{ (inputs.electron_tests && 'electron') || (inputs.browser_tests && 'browser') || (inputs.remote_tests && 'remote') || 'unknown' }}${{ (!inputs.unit_and_integration_tests && inputs.smoke_tests) && '-smoke' || '' }} NPM_ARCH: x64 VSCODE_ARCH: x64 steps: @@ -265,25 +271,25 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: 🧪 Run unit tests (Electron) - if: ${{ inputs.electron_tests }} + if: ${{ inputs.electron_tests && inputs.unit_and_integration_tests }} timeout-minutes: 15 run: ./scripts/test.sh --tfs "Unit Tests" env: DISPLAY: ":10" - name: 🧪 Run unit tests (node.js) - if: ${{ inputs.electron_tests }} + if: ${{ inputs.electron_tests && inputs.unit_and_integration_tests }} timeout-minutes: 15 run: npm run test-node - name: 🧪 Run unit tests (Browser, Chromium) - if: ${{ inputs.browser_tests }} + if: ${{ inputs.browser_tests && inputs.unit_and_integration_tests }} timeout-minutes: 30 run: npm run test-browser-no-install -- --browser chromium --tfs "Browser Unit Tests" env: DEBUG: "*browser*" - - name: Build integration tests + - name: Compile extensions for integration tests & smoke tests run: | set -e npm run gulp \ @@ -305,34 +311,34 @@ jobs: compile-extension:vscode-colorize-perf-tests \ compile-extension:vscode-test-resolver - - name: Compile Copilot extension - run: npm --prefix extensions/copilot run compile - - name: 🧪 Run integration tests (Electron) - if: ${{ inputs.electron_tests }} + if: ${{ inputs.electron_tests && inputs.unit_and_integration_tests }} timeout-minutes: 20 run: ./scripts/test-integration.sh --tfs "Integration Tests" env: DISPLAY: ":10" - name: 🧪 Run integration tests (Browser, Chromium) - if: ${{ inputs.browser_tests }} + if: ${{ inputs.browser_tests && inputs.unit_and_integration_tests }} timeout-minutes: 20 run: ./scripts/test-web-integration.sh --browser chromium - name: 🧪 Run integration tests (Remote) - if: ${{ inputs.remote_tests }} + if: ${{ inputs.remote_tests && inputs.unit_and_integration_tests }} timeout-minutes: 20 run: ./scripts/test-remote-integration.sh env: DISPLAY: ":10" - name: Compile smoke tests + if: ${{ inputs.smoke_tests }} working-directory: test/smoke run: npm run compile - - name: Compile extensions for smoke tests - run: npm run gulp compile-extension-media + - name: Compile Copilot Chat extension for smoke tests + if: ${{ inputs.smoke_tests }} + working-directory: extensions/copilot + run: npm run compile # Remove the musl-libc Claude Code native binary so the bundled # @anthropic-ai/claude-agent-sdk in extensions/copilot falls through to the @@ -347,6 +353,7 @@ jobs: # https://github.com/anthropics/claude-agent-sdk-typescript for the SDK # resolution order. - name: Remove musl Claude binary on glibc Linux + if: ${{ inputs.smoke_tests }} run: rm -rf node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl - name: Diagnostics before smoke test run (processes, max_user_watches, number of opened file handles) @@ -356,35 +363,35 @@ jobs: cat /proc/sys/fs/inotify/max_user_watches lsof | wc -l continue-on-error: true - if: always() + if: ${{ inputs.smoke_tests && always() }} - name: 🧪 Run smoke tests (Electron) - if: ${{ inputs.electron_tests }} + if: ${{ inputs.electron_tests && inputs.smoke_tests }} timeout-minutes: 20 run: npm run smoketest-no-compile -- --tracing env: DISPLAY: ":10" - name: 🧪 Run smoke tests (Browser, Chromium) - if: ${{ inputs.browser_tests }} + if: ${{ inputs.browser_tests && inputs.smoke_tests }} timeout-minutes: 20 run: npm run smoketest-no-compile -- --web --tracing --headless - name: 🧪 Run smoke tests (Remote) - if: ${{ inputs.remote_tests }} + if: ${{ inputs.remote_tests && inputs.smoke_tests }} timeout-minutes: 20 run: npm run smoketest-no-compile -- --remote --tracing env: DISPLAY: ":10" - name: Diagnostics after smoke test run (processes, max_user_watches, number of opened file handles) + if: ${{ inputs.smoke_tests && always() }} run: | set -e ps -ef cat /proc/sys/fs/inotify/max_user_watches lsof | wc -l continue-on-error: true - if: always() - name: Publish Crash Reports uses: actions/upload-artifact@v7 diff --git a/.github/workflows/pr-win32-test.yml b/.github/workflows/pr-win32-test.yml index a1255348f21..45681aff707 100644 --- a/.github/workflows/pr-win32-test.yml +++ b/.github/workflows/pr-win32-test.yml @@ -13,13 +13,19 @@ on: remote_tests: type: boolean default: false + unit_and_integration_tests: + type: boolean + default: true + smoke_tests: + type: boolean + default: true jobs: windows-test: name: ${{ inputs.job_name }} runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-windows-2022-x64, "JobId=windows-test-${{ inputs.job_name }}-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] env: - ARTIFACT_NAME: ${{ (inputs.electron_tests && 'electron') || (inputs.browser_tests && 'browser') || (inputs.remote_tests && 'remote') || 'unknown' }} + ARTIFACT_NAME: ${{ (inputs.electron_tests && 'electron') || (inputs.browser_tests && 'browser') || (inputs.remote_tests && 'remote') || 'unknown' }}${{ (!inputs.unit_and_integration_tests && inputs.smoke_tests) && '-smoke' || '' }} NPM_ARCH: x64 VSCODE_ARCH: x64 steps: @@ -120,26 +126,26 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: 🧪 Run unit tests (Electron) - if: ${{ inputs.electron_tests }} + if: ${{ inputs.electron_tests && inputs.unit_and_integration_tests }} + timeout-minutes: 15 shell: pwsh run: .\scripts\test.bat --tfs "Unit Tests" - timeout-minutes: 15 - name: 🧪 Run unit tests (node.js) - if: ${{ inputs.electron_tests }} + if: ${{ inputs.electron_tests && inputs.unit_and_integration_tests }} + timeout-minutes: 15 shell: pwsh run: npm run test-node - timeout-minutes: 15 - name: 🧪 Run unit tests (Browser, Chromium) - if: ${{ inputs.browser_tests }} + if: ${{ inputs.browser_tests && inputs.unit_and_integration_tests }} + timeout-minutes: 20 shell: pwsh run: node test/unit/browser/index.js --browser chromium --tfs "Browser Unit Tests" env: DEBUG: "*browser*" - timeout-minutes: 20 - - name: Build integration tests + - name: Compile extensions for integration tests & smoke tests shell: pwsh run: | . build/azure-pipelines/win32/exec.ps1 @@ -164,78 +170,77 @@ jobs: compile-extension:vscode-test-resolver ` } - - name: Compile Copilot extension - shell: pwsh - run: npm --prefix extensions/copilot run compile - - name: Diagnostics before integration test runs + if: ${{ inputs.unit_and_integration_tests && always() }} shell: pwsh run: .\build\azure-pipelines\win32\listprocesses.bat continue-on-error: true - if: always() - name: 🧪 Run integration tests (Electron) - if: ${{ inputs.electron_tests }} + if: ${{ inputs.electron_tests && inputs.unit_and_integration_tests }} + timeout-minutes: 20 shell: pwsh run: .\scripts\test-integration.bat --tfs "Integration Tests" - timeout-minutes: 20 - name: 🧪 Run integration tests (Browser, Chromium) - if: ${{ inputs.browser_tests }} + if: ${{ inputs.browser_tests && inputs.unit_and_integration_tests }} + timeout-minutes: 20 shell: pwsh run: .\scripts\test-web-integration.bat --browser chromium - timeout-minutes: 20 - name: 🧪 Run integration tests (Remote) - if: ${{ inputs.remote_tests }} + if: ${{ inputs.remote_tests && inputs.unit_and_integration_tests }} + timeout-minutes: 20 shell: pwsh run: .\scripts\test-remote-integration.bat - timeout-minutes: 20 - name: Diagnostics after integration test runs + if: ${{ inputs.unit_and_integration_tests && always() }} shell: pwsh run: .\build\azure-pipelines\win32\listprocesses.bat continue-on-error: true - if: always() - - name: Diagnostics before smoke test run - shell: pwsh - run: .\build\azure-pipelines\win32\listprocesses.bat - continue-on-error: true - if: always() - name: Compile smoke tests + if: ${{ inputs.smoke_tests }} working-directory: test/smoke shell: pwsh run: npm run compile - - name: Compile extensions for smoke tests + - name: Compile Copilot Chat extension for smoke tests + if: ${{ inputs.smoke_tests }} + working-directory: extensions/copilot + run: npm run compile + + - name: Diagnostics before smoke test run + if: ${{ inputs.smoke_tests && always() }} shell: pwsh - run: npm run gulp compile-extension-media + run: .\build\azure-pipelines\win32\listprocesses.bat + continue-on-error: true - name: 🧪 Run smoke tests (Electron) - if: ${{ inputs.electron_tests }} + if: ${{ inputs.electron_tests && inputs.smoke_tests }} timeout-minutes: 20 shell: pwsh run: npm run smoketest-no-compile -- --tracing - name: 🧪 Run smoke tests (Browser, Chromium) - if: ${{ inputs.browser_tests }} + if: ${{ inputs.browser_tests && inputs.smoke_tests }} timeout-minutes: 20 shell: pwsh run: npm run smoketest-no-compile -- --web --tracing --headless - name: 🧪 Run smoke tests (Remote) - if: ${{ inputs.remote_tests }} + if: ${{ inputs.remote_tests && inputs.smoke_tests }} timeout-minutes: 20 shell: pwsh run: npm run smoketest-no-compile -- --remote --tracing - name: Diagnostics after smoke test run + if: ${{ inputs.smoke_tests && always() }} shell: pwsh run: .\build\azure-pipelines\win32\listprocesses.bat continue-on-error: true - if: always() - name: Publish Crash Reports uses: actions/upload-artifact@v7 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 96fb9a707d1..1d8675086a6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -103,6 +103,15 @@ jobs: with: job_name: Electron electron_tests: true + smoke_tests: false + + linux-electron-smoke-tests: + name: Linux + uses: ./.github/workflows/pr-linux-test.yml + with: + job_name: Electron-Smoke + electron_tests: true + unit_and_integration_tests: false linux-browser-tests: name: Linux @@ -124,6 +133,15 @@ jobs: with: job_name: Electron electron_tests: true + smoke_tests: false + + macos-electron-smoke-tests: + name: macOS + uses: ./.github/workflows/pr-darwin-test.yml + with: + job_name: Electron-Smoke + electron_tests: true + unit_and_integration_tests: false macos-browser-tests: name: macOS @@ -145,6 +163,15 @@ jobs: with: job_name: Electron electron_tests: true + smoke_tests: false + + windows-electron-smoke-tests: + name: Windows + uses: ./.github/workflows/pr-win32-test.yml + with: + job_name: Electron-Smoke + electron_tests: true + unit_and_integration_tests: false windows-browser-tests: name: Windows From 6e05dd0b075ccdb7106565aa5727dd7891b9eb7e Mon Sep 17 00:00:00 2001 From: Anthony Kim <62267334+anthonykim1@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:42:51 -0700 Subject: [PATCH 06/25] Telemetry for hidden terminal interaction (#322154) * Telemetry for hidden terminal interaction * address copilot feedback --- .../terminal/browser/terminalTabsChatEntry.ts | 18 ++++++++++++ .../chat/browser/terminalChatActions.ts | 29 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTabsChatEntry.ts b/src/vs/workbench/contrib/terminal/browser/terminalTabsChatEntry.ts index 7e6e39b2f31..3628fada486 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTabsChatEntry.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabsChatEntry.ts @@ -9,6 +9,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { $ } from '../../../../base/browser/dom.js'; import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { ITerminalChatService, ITerminalService } from './terminal.js'; import * as dom from '../../../../base/browser/dom.js'; @@ -31,6 +32,7 @@ export class TerminalTabsChatEntry extends Disposable { @ICommandService private readonly _commandService: ICommandService, @ITerminalChatService private readonly _terminalChatService: ITerminalChatService, @ITerminalService private readonly _terminalService: ITerminalService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); @@ -90,6 +92,22 @@ export class TerminalTabsChatEntry extends Disposable { private async _deleteAllHiddenTerminals(): Promise { const hiddenTerminals = this._terminalChatService.getToolSessionTerminalInstances(true); + if (hiddenTerminals.length === 0) { + return; + } + + type DeleteHiddenChatTerminalsEvent = { + count: number; + }; + type DeleteHiddenChatTerminalsClassification = { + owner: 'anthonykim1'; + comment: 'Tracks when the user deletes all hidden chat terminals from the terminal tabs entry.'; + count: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of hidden chat terminals that were deleted.' }; + }; + this._telemetryService.publicLog2('terminal.chatDeleteHiddenTerminals', { + count: hiddenTerminals.length, + }); + await Promise.all(hiddenTerminals.map(terminal => this._terminalService.safeDisposeTerminal(terminal))); } diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts index 9a378d9ff82..fb1da4e5d6b 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts @@ -28,6 +28,7 @@ import { TerminalChatController } from './terminalChatController.js'; import { TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; import { isString } from '../../../../../base/common/types.js'; import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IPreferencesService, IOpenSettingsOptions } from '../../../../services/preferences/common/preferences.js'; import { ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js'; import { TerminalChatAgentToolsSettingId } from '../../chatAgentTools/common/terminalChatAgentToolsConfiguration.js'; @@ -317,6 +318,15 @@ registerActiveXtermAction({ } }); +type ViewHiddenChatTerminalsEvent = { + hiddenCount: number; +}; +type ViewHiddenChatTerminalsClassification = { + owner: 'anthonykim1'; + comment: 'Tracks when the user opens the hidden chat terminals UI to understand how often users need to reach into agent-owned terminals.'; + hiddenCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of hidden chat terminals that existed when the action was invoked. A value of 1 reveals the terminal directly, while more than 1 shows a quick pick.' }; +}; + registerAction2(class ShowChatTerminalsAction extends Action2 { constructor() { super({ @@ -343,6 +353,7 @@ registerAction2(class ShowChatTerminalsAction extends Action2 { const quickInputService = accessor.get(IQuickInputService); const instantiationService = accessor.get(IInstantiationService); const chatService = accessor.get(IChatService); + const telemetryService = accessor.get(ITelemetryService); const visible = new Set([...groupService.instances, ...editorService.instances]); const toolInstances = terminalChatService.getToolSessionTerminalInstances(); @@ -364,12 +375,17 @@ registerAction2(class ShowChatTerminalsAction extends Action2 { return; } + telemetryService.publicLog2('terminal.chatViewHiddenTerminals', { + hiddenCount: all.size, + }); + // If there's only one hidden terminal, show it directly without the quick pick if (all.size === 1) { const instance = Array.from(all.values())[0]; terminalService.setActiveInstance(instance); await terminalService.revealTerminal(instance); await terminalService.focusInstance(instance); + this._logRevealHiddenTerminal(telemetryService, 'single'); return; } @@ -457,6 +473,7 @@ registerAction2(class ShowChatTerminalsAction extends Action2 { await terminalService.revealTerminal(instance); qp.hide(); await terminalService.focusInstance(instance); + this._logRevealHiddenTerminal(telemetryService, 'quickPick'); } else { qp.hide(); } @@ -470,6 +487,18 @@ registerAction2(class ShowChatTerminalsAction extends Action2 { })); qp.show(); } + + private _logRevealHiddenTerminal(telemetryService: ITelemetryService, via: 'single' | 'quickPick'): void { + type RevealHiddenChatTerminalEvent = { + via: 'single' | 'quickPick'; + }; + type RevealHiddenChatTerminalClassification = { + owner: 'anthonykim1'; + comment: 'Tracks when the user reveals and focuses a specific hidden chat terminal, indicating they needed to interact directly with an agent-owned terminal.'; + via: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How the terminal was revealed: single (only one hidden terminal) or quickPick (selected from the list).' }; + }; + telemetryService.publicLog2('terminal.chatRevealHiddenTerminal', { via }); + } }); From ec1c07431b6141af315fbb23fbbe9aee9e5245eb Mon Sep 17 00:00:00 2001 From: "vs-code-engineering[bot]" <122617954+vs-code-engineering[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:54:38 +0000 Subject: [PATCH 07/25] fix: prevent unhandled rejection of language model result promise (fixes #322110) (#322114) Co-authored-by: vs-code-engineering[bot] <122617954+vs-code-engineering[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/workbench/api/browser/mainThreadLanguageModels.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/vs/workbench/api/browser/mainThreadLanguageModels.ts b/src/vs/workbench/api/browser/mainThreadLanguageModels.ts index 2e600244cec..fc615a11915 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageModels.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageModels.ts @@ -86,6 +86,12 @@ export class MainThreadLanguageModels implements MainThreadLanguageModelsShape { sendChatRequest: async (modelId, messages, from, options, token) => { const requestId = (Math.random() * 1e6) | 0; const defer = new DeferredPromise(); + // `result` mirrors the stream's terminal status and is rejected together with the + // stream on error (see `$reportResponseDone`). Consumers that read the stream let the + // for-await throw and never reach `await response.result`, leaving its rejection (e.g. + // an expected `ChatQuotaExceeded`) unobserved. Attach a no-op handler so it cannot + // surface as an unhandled rejection; real awaiters of `result` still see the error. + defer.p.catch(() => { }); const stream = new AsyncIterableSource(); try { From c8940a8eb5275230d1284bff582ff0703a1b7a82 Mon Sep 17 00:00:00 2001 From: Paul Date: Fri, 19 Jun 2026 15:14:17 -0700 Subject: [PATCH 08/25] Propagate quota snapshots from copilot SDK (#322162) --- .../copilotcli/node/copilotcliSession.ts | 56 +++++++++- .../node/test/copilotcliSession.spec.ts | 104 +++++++++++++++++- .../agentHost/common/state/sessionState.ts | 18 +++ .../node/copilot/copilotAgentSession.ts | 44 +++++++- .../test/node/copilotAgentSession.test.ts | 43 ++++++++ .../agentHost/agentHostSessionHandler.ts | 28 ++++- .../agentHost/stateToProgressAdapter.ts | 91 ++++++++++++++- .../stateToProgressAdapter.test.ts | 95 +++++++++++++++- 8 files changed, 473 insertions(+), 6 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index 57ae4040537..6e0634f28de 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -10,7 +10,7 @@ import * as crypto from 'crypto'; import type * as vscode from 'vscode'; import type { ChatParticipantToolToken } from 'vscode'; import { IAuthenticationService } from '../../../../platform/authentication/common/authentication'; -import { IChatQuotaService } from '../../../../platform/chat/common/chatQuotaService'; +import { IChatQuotaService, QuotaSnapshot, QuotaSnapshots } from '../../../../platform/chat/common/chatQuotaService'; import { getQuotaMessageForPlan } from '../../../../platform/chat/common/commonTypes'; import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService'; import { IGitService } from '../../../../platform/git/common/gitService'; @@ -1444,6 +1444,12 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes this._chatQuotaService.setLastCopilotUsage(totalNanoAiu, request.id); } } + // Sync the live per-category quota state the SDK reports (internal-only field) so the + // quota UI stays current without a separate `copilot_internal/user` fetch. This mirrors + // the extension-host chat path, which processes `copilot_quota_snapshots` from CAPI. + if (event.data.quotaSnapshots) { + this._chatQuotaService.processQuotaSnapshots(toChatQuotaSnapshots(event.data.quotaSnapshots)); + } // Record this model turn so we can synthesize a `chat` span for it at request completion. modelTurnUsages.push({ model: event.data.model, @@ -3150,6 +3156,54 @@ interface IModelTurnUsage { readonly parentToolCallId?: string; } +/** + * Shape of a single quota snapshot on the SDK's `assistant.usage` event (`quotaSnapshots`). The + * field is marked internal-only by the SDK, so although the published types say `entitlementRequests` + * is a number and `resetDate` is a `Date`, the runtime shape can drift (e.g. a sibling SDK delivers + * `resetDate` as an ISO string). Mark the fields optional and validate at runtime below. + */ +interface ISdkQuotaSnapshot { + readonly isUnlimitedEntitlement?: boolean; + readonly entitlementRequests?: number; + readonly overage?: number; + readonly overageAllowedWithExhaustedQuota?: boolean; + readonly remainingPercentage?: number; + readonly resetDate?: Date | string; +} + +/** Maps the SDK `assistant.usage` quota snapshots to the shared {@link QuotaSnapshots} shape. */ +function toChatQuotaSnapshots(snapshots: Record): QuotaSnapshots { + const result: Record = {}; + for (const [key, snapshot] of Object.entries(snapshots)) { + if (!snapshot || typeof snapshot !== 'object') { + continue; + } + const unlimited = snapshot.isUnlimitedEntitlement === true; + const entitlement = unlimited + ? '-1' + : typeof snapshot.entitlementRequests === 'number' ? String(snapshot.entitlementRequests) : undefined; + if (entitlement === undefined || typeof snapshot.remainingPercentage !== 'number') { + continue; + } + result[key] = { + entitlement, + percent_remaining: snapshot.remainingPercentage, + overage_permitted: snapshot.overageAllowedWithExhaustedQuota ?? false, + overage_count: typeof snapshot.overage === 'number' ? snapshot.overage : 0, + reset_date: toResetDateIsoString(snapshot.resetDate), + }; + } + return result; +} + +/** Coerces an SDK `resetDate` (a `Date` per the published type, but possibly an ISO string at runtime) to an ISO string. */ +function toResetDateIsoString(resetDate: Date | string | undefined): string | undefined { + if (resetDate instanceof Date) { + return resetDate.toISOString(); + } + return typeof resetDate === 'string' ? resetDate : undefined; +} + function buildPromptTokenDetails(usageInfo: UsageInfoData | undefined): { category: string; label: string; percentageOfPrompt: number }[] | undefined { if (!usageInfo || usageInfo.currentTokens <= 0) { return undefined; diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts index 28f8e4ebc6f..27de7188f4c 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts @@ -7,6 +7,7 @@ import type { SessionOptions } from '@github/copilot/sdk'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ChatParticipantToolToken, ChatResponseStream } from 'vscode'; import { ConfigKey, IConfigurationService } from '../../../../../platform/configuration/common/configurationService'; +import { QuotaSnapshots } from '../../../../../platform/chat/common/chatQuotaService'; import { MockGitService } from '../../../../../platform/ignore/node/test/mockGitService'; import { ILogService } from '../../../../../platform/log/common/logService'; import { GenAiAttr, IOTelService, NoopOTelService, resolveOTelConfig, SpanKind } from '../../../../../platform/otel/common/index'; @@ -225,6 +226,7 @@ describe('CopilotCLISession', () => { let authInfo: NonNullable; let userQuestionAnswer: IQuestionAnswer | undefined; let telemetryService: ITelemetryService; + let processedQuotaSnapshots: QuotaSnapshots[]; beforeEach(async () => { const services = disposables.add(createExtensionUnitTestingServices()); const accessor = services.createTestingAccessor(); @@ -246,6 +248,7 @@ describe('CopilotCLISession', () => { toolsService = new FakeToolsService(); userQuestionAnswer = undefined; telemetryService = new NullTelemetryService(); + processedQuotaSnapshots = []; }); afterEach(() => { @@ -283,7 +286,7 @@ describe('CopilotCLISession', () => { otelService, new MockGitService(), { _serviceBrand: undefined } as any, - { _serviceBrand: undefined, resetTurnCredits() { }, getCreditsForTurn() { return undefined; }, setLastCopilotUsage() { } } as any, + { _serviceBrand: undefined, resetTurnCredits() { }, getCreditsForTurn() { return undefined; }, setLastCopilotUsage() { }, processQuotaSnapshots(snapshots: QuotaSnapshots) { processedQuotaSnapshots.push(snapshots); } } as any, telemetryService )); } @@ -2511,6 +2514,105 @@ describe('CopilotCLISession', () => { expect(session.getLastResponseModelId()).toBe('claude-opus-4.7'); }); + it('syncs quota snapshots from assistant.usage event into the quota service', async () => { + sdkSession.send = async (options: any) => { + sdkSession.emit('user.message', { content: options.prompt }); + sdkSession.emit('assistant.usage', { + model: 'claude-opus-4.7', + inputTokens: 200, + outputTokens: 80, + quotaSnapshots: { + premium_interactions: { + isUnlimitedEntitlement: false, + entitlementRequests: 300, + usedRequests: 75, + usageAllowedWithExhaustedQuota: true, + overage: 1.5, + overageAllowedWithExhaustedQuota: true, + remainingPercentage: 75, + resetDate: new Date('2026-07-01T00:00:00.000Z'), + }, + chat: { + isUnlimitedEntitlement: true, + entitlementRequests: -1, + usedRequests: 10, + usageAllowedWithExhaustedQuota: false, + overage: 0, + overageAllowedWithExhaustedQuota: false, + remainingPercentage: 100, + }, + }, + }); + sdkSession.emit('assistant.turn_end', {}); + }; + + const session = await createSession(); + session.attachStream(new UsageCapturingStream()); + + await session.handleRequest({ id: 'req-1', toolInvocationToken: undefined as never }, { prompt: 'Hello' }, [], undefined, authInfo, CancellationToken.None); + + expect(processedQuotaSnapshots).toEqual([{ + premium_interactions: { + entitlement: '300', + percent_remaining: 75, + overage_permitted: true, + overage_count: 1.5, + reset_date: '2026-07-01T00:00:00.000Z', + }, + chat: { + entitlement: '-1', + percent_remaining: 100, + overage_permitted: false, + overage_count: 0, + reset_date: undefined, + }, + }]); + }); + + it('tolerates a string resetDate and skips malformed snapshots from assistant.usage', async () => { + sdkSession.send = async (options: any) => { + sdkSession.emit('user.message', { content: options.prompt }); + sdkSession.emit('assistant.usage', { + model: 'claude-opus-4.7', + inputTokens: 200, + outputTokens: 80, + quotaSnapshots: { + // The internal field can drift from the published type: `resetDate` may arrive as an + // ISO string and a snapshot may be missing `remainingPercentage` entirely. + premium_interactions: { + isUnlimitedEntitlement: false, + entitlementRequests: 300, + overage: 1.5, + overageAllowedWithExhaustedQuota: true, + remainingPercentage: 75, + resetDate: '2026-07-01T00:00:00.000Z', + }, + completions: { + isUnlimitedEntitlement: false, + entitlementRequests: 50, + // remainingPercentage absent — snapshot must be skipped rather than producing "undefined". + }, + }, + }); + sdkSession.emit('assistant.turn_end', {}); + }; + + const session = await createSession(); + session.attachStream(new UsageCapturingStream()); + + await session.handleRequest({ id: 'req-1', toolInvocationToken: undefined as never }, { prompt: 'Hello' }, [], undefined, authInfo, CancellationToken.None); + + expect(processedQuotaSnapshots).toEqual([{ + premium_interactions: { + entitlement: '300', + percent_remaining: 75, + overage_permitted: true, + overage_count: 1.5, + reset_date: '2026-07-01T00:00:00.000Z', + }, + }]); + }); + it('reports usage from session.usage_info event immediately', async () => { sdkSession.send = async (options: any) => { sdkSession.emit('user.message', { content: options.prompt }); diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 2d8e4acfcef..2470806750f 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -101,6 +101,24 @@ export interface UsageInfoMeta { totalNanoAiu?: number; [key: string]: unknown; }; + /** + * Per-category account quota snapshots reported by the backend on the + * model-call usage event, keyed by quota type (e.g. `chat`, + * `premium_interactions`). Clients MAY use these to keep the account quota + * UI current without a separate quota fetch. + */ + quotaSnapshots?: { + [quotaType: string]: { + readonly isUnlimitedEntitlement?: boolean; + readonly entitlementRequests?: number; + readonly usedRequests?: number; + readonly remainingPercentage?: number; + readonly overage?: number; + readonly overageAllowedWithExhaustedQuota?: boolean; + /** ISO 8601 date when the quota resets, if applicable. */ + readonly resetDate?: string; + } | undefined; + }; [key: string]: unknown; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 0f45446f659..6ba95c5e249 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -2418,7 +2418,13 @@ export class CopilotAgentSession extends Disposable { totalNanoAiu: this._turnCopilotUsageTotalNanoAiu, }; } - this._logService.trace(`[Copilot:${sessionId}] Usage: model=${e.data.model}, in=${e.data.inputTokens ?? '?'}, out=${e.data.outputTokens ?? '?'}, cacheRead=${e.data.cacheReadTokens ?? '?'}, cost=${e.data.cost ?? '?'}, totalNanoAiu=${metadata.copilotUsage ? this._turnCopilotUsageTotalNanoAiu : '?'}`); + // `quotaSnapshots` is likewise `asInternal` in the SDK schema (not on the generated type) but is + // present at runtime. Forward the per-category snapshots on `_meta` so the client can keep the + // account quota UI current. Mirrors the extension-host CLI path, which feeds these into its quota service. + const quotaSnapshots = normalizeQuotaSnapshots((e.data as unknown as Record).quotaSnapshots); + if (quotaSnapshots) { + metadata.quotaSnapshots = quotaSnapshots; + } if (typeof e.data.model === 'string' && e.data.model) { this._lastSeenModelId = e.data.model; } @@ -2975,3 +2981,39 @@ function countUnifiedDiffLines(diff: string): { added: number; removed: number } } return { added, removed }; } + +/** + * Normalizes the SDK's internal `quotaSnapshots` field — present on the `assistant.usage` event at + * runtime but absent from the generated `AssistantUsageData` type — into the serializable shape + * carried on {@link UsageInfoMeta.quotaSnapshots}. Returns `undefined` when no usable snapshot is present. + */ +function normalizeQuotaSnapshots(raw: unknown): UsageInfoMeta['quotaSnapshots'] | undefined { + if (!raw || typeof raw !== 'object') { + return undefined; + } + const result: NonNullable = {}; + let hasAny = false; + for (const [quotaType, value] of Object.entries(raw as Record)) { + if (!value || typeof value !== 'object') { + continue; + } + const v = value as Record; + const resetDateRaw = v.resetDate; + const resetDate = typeof resetDateRaw === 'string' + ? resetDateRaw + : resetDateRaw instanceof Date + ? resetDateRaw.toISOString() + : undefined; + result[quotaType] = { + isUnlimitedEntitlement: typeof v.isUnlimitedEntitlement === 'boolean' ? v.isUnlimitedEntitlement : undefined, + entitlementRequests: typeof v.entitlementRequests === 'number' ? v.entitlementRequests : undefined, + usedRequests: typeof v.usedRequests === 'number' ? v.usedRequests : undefined, + remainingPercentage: typeof v.remainingPercentage === 'number' ? v.remainingPercentage : undefined, + overage: typeof v.overage === 'number' ? v.overage : undefined, + overageAllowedWithExhaustedQuota: typeof v.overageAllowedWithExhaustedQuota === 'boolean' ? v.overageAllowedWithExhaustedQuota : undefined, + resetDate, + }; + hasAny = true; + } + return hasAny ? result : undefined; +} diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index b1b3954ad21..bc3539a125f 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -901,6 +901,49 @@ suite('CopilotAgentSession', () => { ]); }); + test('forwards account quota snapshots on usage metadata', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + + session.resetTurnState('turn-quota'); + mockSession.fire('assistant.usage', { + model: 'claude-sonnet-4.6', + inputTokens: 10, + outputTokens: 20, + // `quotaSnapshots` is marked `asInternal` in the SDK schema so it is not on the public type, but is present at runtime. + quotaSnapshots: { + premium_interactions: { + isUnlimitedEntitlement: false, + entitlementRequests: 300, + usedRequests: 75, + usageAllowedWithExhaustedQuota: true, + remainingPercentage: 75, + overage: 1.5, + overageAllowedWithExhaustedQuota: true, + resetDate: '2026-07-01T00:00:00.000Z', + }, + }, + } as unknown as SessionEventPayload<'assistant.usage'>['data']); + + const usageActions = signals + .filter((s): s is IAgentActionSignal => s.kind === 'action') + .map(s => s.action) + .filter(a => a.type === ActionType.ChatUsage); + + assert.deepStrictEqual(usageActions.map(a => a.usage._meta?.quotaSnapshots), [ + { + premium_interactions: { + isUnlimitedEntitlement: false, + entitlementRequests: 300, + usedRequests: 75, + remainingPercentage: 75, + overage: 1.5, + overageAllowedWithExhaustedQuota: true, + resetDate: '2026-07-01T00:00:00.000Z', + }, + }, + ]); + }); + test('extracts selected text from file contents for different line endings and bounds', async () => { const testCases = [ { 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 428c24f9561..c45e402d223 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -67,7 +67,7 @@ import { IAgentHostNewSessionFolderService } from './agentHostNewSessionFolderSe import { AgentHostSnapshotController } from './agentHostSnapshotController.js'; import { toolDataToDefinition } from './agentHostToolUtils.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; -import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, getTerminalContentUri, isSubagentTool, makeAhpTerminalToolSessionId, messageToVariableData, parseAhpTerminalToolSessionId, rawMarkdownToString, stringOrMarkdownToString, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, usageInfoToChatUsage, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js'; +import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, getTerminalContentUri, isSubagentTool, makeAhpTerminalToolSessionId, messageToVariableData, parseAhpTerminalToolSessionId, rawMarkdownToString, stringOrMarkdownToString, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, usageInfoToChatUsage, usageInfoToQuotas, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js'; export { toolDataToDefinition }; // ============================================================================= @@ -1577,6 +1577,32 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC opts.sink([usage]); })); + // Surface the account quota snapshots the agent host reports on each model-call usage event + // into the entitlement service, keeping the quota UI current for agent-host sessions (mirrors + // the extension-host CLI path). `acceptQuotas` replaces state, so shallow-merge the top-level + // container and deep-merge each per-category snapshot to preserve fields the usage event + // doesn't carry (e.g. `hasQuota`, `usageBasedBilling` from a prior full entitlement fetch). + let lastQuotaSignature: string | undefined; + store.add(autorun(reader => { + const quotaUpdate = usageInfoToQuotas(usage$.read(reader)); + if (!quotaUpdate) { + return; + } + const signature = JSON.stringify(quotaUpdate); + if (signature === lastQuotaSignature) { + return; + } + lastQuotaSignature = signature; + const existing = this._chatEntitlementService.quotas; + this._chatEntitlementService.acceptQuotas({ + ...existing, + ...quotaUpdate, + chat: quotaUpdate.chat ? { ...existing.chat, ...quotaUpdate.chat } : existing.chat, + completions: quotaUpdate.completions ? { ...existing.completions, ...quotaUpdate.completions } : existing.completions, + premiumChat: quotaUpdate.premiumChat ? { ...existing.premiumChat, ...quotaUpdate.premiumChat } : existing.premiumChat, + }); + })); + store.add(autorunPerKeyedItem( inputRequests$, ir => ir.id, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 9f130e129aa..9a8babb193c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -17,13 +17,14 @@ import { isViewUnreviewedCommentsTool } from '../../../../../../platform/agentHo import { MessageAttachmentKind, type FileEdit, type MessageAttachment, type StringOrMarkdown, type TextRange } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { type ChatExternalEditKind, type ChatMcpAppData, type IChatAgentFeedbackReviewConfirmationData, type IChatExternalEdit, type IChatModifiedFilesConfirmationData, type IChatProgress, type IChatResponseErrorDetails, type IChatSearchToolInvocationData, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, type IChatUsage, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { type IChatSessionHistoryItem } from '../../../common/chatSessionsService.js'; +import { type IQuotaSnapshot } from '../../../../../services/chat/common/chatEntitlementService.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { type IChatRequestVariableData } from '../../../common/model/chatModel.js'; import { AgentHostCompletionReferenceKind, restorePasteVariableEntryFromAttachment, toAgentHostCompletionVariableEntryFromMetadata, type IAgentFeedbackVariableEntry, type IChatRequestVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { type IToolConfirmationMessages, type IToolData, type IToolResult, type IToolResultInputOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; import { MCP } from '../../../../mcp/common/modelContextProtocol.js'; import { basename, isEqual } from '../../../../../../base/common/resources.js'; -import { hasKey } from '../../../../../../base/common/types.js'; +import { hasKey, type Mutable } from '../../../../../../base/common/types.js'; import { localize } from '../../../../../../nls.js'; import type { IRange } from '../../../../../../editor/common/core/range.js'; @@ -193,6 +194,94 @@ function getCopilotCredits(usage: UsageInfo | undefined): number | undefined { : undefined; } +/** + * A partial quota update derived from a usage report's `_meta.quotaSnapshots`. Structurally a + * subset of the entitlement service's quota state, so callers merge it onto the existing quotas. + */ +export interface IAgentHostQuotaUpdate { + readonly chat?: IQuotaSnapshot; + readonly completions?: IQuotaSnapshot; + readonly premiumChat?: IQuotaSnapshot; + readonly additionalUsageEnabled?: boolean; + readonly additionalUsageCount?: number; + readonly resetDate?: string; +} + +type AccountQuotaSnapshot = NonNullable[string]>; + +function mapAccountQuotaSnapshot(snapshot: AccountQuotaSnapshot): IQuotaSnapshot | undefined { + const unlimited = snapshot.isUnlimitedEntitlement ?? false; + const entitlement = typeof snapshot.entitlementRequests === 'number' ? snapshot.entitlementRequests : undefined; + + // Skip categories with no allocated entitlement (e.g. free-tier premium with 0 credits), + // mirroring `parseQuotas` so we don't surface an empty premium bucket. + if (!unlimited && entitlement === 0) { + return undefined; + } + + // `remainingPercentage` is required to express a usable snapshot. Treat its absence as + // "no data" and skip the category rather than defaulting to 0, which would otherwise + // masquerade as an exhausted quota (matching `parseQuotas`, where `percent_remaining` is required). + if (typeof snapshot.remainingPercentage !== 'number') { + return undefined; + } + + const used = typeof snapshot.usedRequests === 'number' ? snapshot.usedRequests : undefined; + const resetAt = snapshot.resetDate ? Date.parse(snapshot.resetDate) : NaN; + return { + percentRemaining: Math.min(100, Math.max(0, snapshot.remainingPercentage)), + unlimited, + entitlement: !unlimited && entitlement !== undefined && entitlement >= 0 ? entitlement : undefined, + quotaRemaining: !unlimited && entitlement !== undefined && used !== undefined ? Math.max(0, entitlement - used) : undefined, + resetAt: Number.isFinite(resetAt) ? resetAt : undefined, + }; +} + +/** + * Maps the per-category quota snapshots carried on a usage report's `_meta.quotaSnapshots` + * (reported by the model-call usage event) into a partial quota update for the entitlement + * service. Returns `undefined` when no usable snapshot is present. + */ +export function usageInfoToQuotas(usage: UsageInfo | undefined): IAgentHostQuotaUpdate | undefined { + const meta = usage?._meta as UsageInfoMeta | undefined; + const snapshots = meta?.quotaSnapshots; + if (!snapshots) { + return undefined; + } + + const update: Mutable = {}; + let hasAny = false; + + const chat = snapshots['chat'] && mapAccountQuotaSnapshot(snapshots['chat']); + if (chat) { + update.chat = chat; + hasAny = true; + } + const completions = snapshots['completions'] && mapAccountQuotaSnapshot(snapshots['completions']); + if (completions) { + update.completions = completions; + hasAny = true; + } + const premiumRaw = snapshots['premium_interactions']; + const premiumChat = premiumRaw && mapAccountQuotaSnapshot(premiumRaw); + if (premiumChat) { + update.premiumChat = premiumChat; + hasAny = true; + } + if (premiumRaw) { + update.additionalUsageEnabled = premiumRaw.overageAllowedWithExhaustedQuota ?? false; + update.additionalUsageCount = typeof premiumRaw.overage === 'number' ? premiumRaw.overage : 0; + hasAny = true; + } + + const resetDate = premiumRaw?.resetDate ?? snapshots['chat']?.resetDate; + if (resetDate) { + update.resetDate = resetDate; + } + + return hasAny ? update : undefined; +} + /** * Converts completed turns from the protocol state into session history items. * diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 22d2b8ad060..54b16ff4787 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -10,7 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { MessageKind, ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, type ActiveTurn, type ICompletedToolCall, type ToolCallRunningState, type Turn, type ToolCallResponsePart, ToolCallCancellationReason, type Message } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IChatToolInvocation, IChatToolInvocationSerialized, type IChatMarkdownContent, type IChatProgressMessage, type IChatUsage } from '../../../common/chatService/chatService.js'; import { isToolResultInputOutputDetails, type IToolResultInputOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; -import { turnsToHistory as rawTurnsToHistory, activeTurnToProgress as rawActiveTurnToProgress, toolCallStateToInvocation as rawToolCallStateToInvocation, finalizeToolInvocation as rawFinalizeToolInvocation, updateRunningToolSpecificData as rawUpdateRunningToolSpecificData } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; +import { turnsToHistory as rawTurnsToHistory, activeTurnToProgress as rawActiveTurnToProgress, toolCallStateToInvocation as rawToolCallStateToInvocation, finalizeToolInvocation as rawFinalizeToolInvocation, updateRunningToolSpecificData as rawUpdateRunningToolSpecificData, usageInfoToQuotas } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; // ---- Helper factories ------------------------------------------------------- @@ -1481,4 +1481,97 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(termData.terminalCommandOutput?.text, 'hi\r\n'); }); }); + + suite('usageInfoToQuotas', () => { + + test('returns undefined when no quota snapshots present', () => { + assert.strictEqual(usageInfoToQuotas(undefined), undefined); + assert.strictEqual(usageInfoToQuotas({ inputTokens: 10 }), undefined); + assert.strictEqual(usageInfoToQuotas({ _meta: { cost: 1 } }), undefined); + }); + + test('maps premium and chat snapshots, deriving additional usage and reset date', () => { + const result = usageInfoToQuotas({ + _meta: { + quotaSnapshots: { + premium_interactions: { + isUnlimitedEntitlement: false, + entitlementRequests: 300, + usedRequests: 75, + remainingPercentage: 75, + overage: 1.5, + overageAllowedWithExhaustedQuota: true, + resetDate: '2026-07-01T00:00:00.000Z', + }, + chat: { + isUnlimitedEntitlement: true, + entitlementRequests: -1, + usedRequests: 10, + remainingPercentage: 100, + }, + }, + }, + }); + + assert.deepStrictEqual(result, { + premiumChat: { + percentRemaining: 75, + unlimited: false, + entitlement: 300, + quotaRemaining: 225, + resetAt: Date.parse('2026-07-01T00:00:00.000Z'), + }, + chat: { + percentRemaining: 100, + unlimited: true, + entitlement: undefined, + quotaRemaining: undefined, + resetAt: undefined, + }, + additionalUsageEnabled: true, + additionalUsageCount: 1.5, + resetDate: '2026-07-01T00:00:00.000Z', + }); + }); + + test('skips categories with no allocated entitlement', () => { + const result = usageInfoToQuotas({ + _meta: { + quotaSnapshots: { + premium_interactions: { + isUnlimitedEntitlement: false, + entitlementRequests: 0, + usedRequests: 0, + remainingPercentage: 0, + overage: 0, + overageAllowedWithExhaustedQuota: false, + }, + }, + }, + }); + + // The 0-entitlement premium snapshot is skipped, but additional-usage fields are still derived. + assert.deepStrictEqual(result, { + additionalUsageEnabled: false, + additionalUsageCount: 0, + }); + }); + + test('skips a category whose remainingPercentage is missing', () => { + const result = usageInfoToQuotas({ + _meta: { + quotaSnapshots: { + chat: { + isUnlimitedEntitlement: false, + entitlementRequests: 100, + usedRequests: 10, + // remainingPercentage intentionally absent — must not masquerade as exhausted (0%). + }, + }, + }, + }); + + assert.strictEqual(result, undefined); + }); + }); }); From 89b8d6d16adb48a014687b9ae9ea2032a207b017 Mon Sep 17 00:00:00 2001 From: Julia Kasper <42241691+jukasper@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:17:44 -0700 Subject: [PATCH 09/25] Enable persistent CoT for GPT-5.4 and GPT-5.5 (#322161) Add GPT-5.4 and GPT-5.5 to the Responses API persistent chain-of-thought model-family gate while preserving the existing supported-family condition. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/copilot/src/platform/endpoint/node/responsesApi.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts index 5660d746d55..fba5bbff3f7 100644 --- a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts @@ -27,7 +27,7 @@ import { IChatWebSocketManager } from '../../networking/node/chatWebSocketManage import { IExperimentationService } from '../../telemetry/common/nullExperimentationService'; import { ITelemetryService } from '../../telemetry/common/telemetry'; import { TelemetryData } from '../../telemetry/common/telemetryData'; -import { getVerbosityForModelSync, isHiddenModelM } from '../common/chatModelCapabilities'; +import { getVerbosityForModelSync, isGpt54, isGpt55, isHiddenModelM } from '../common/chatModelCapabilities'; import { rawPartAsCompactionData } from '../common/compactionDataContainer'; import { rawPartAsPhaseData } from '../common/phaseDataContainer'; import { getIndexOfStatefulMarker, getStatefulMarkerAndIndex } from '../common/statefulMarkerContainer'; @@ -164,7 +164,7 @@ export function createResponsesRequestBody(accessor: ServicesAccessor, options: : undefined; const summary = summaryConfig === 'off' || shouldDisableReasoningSummary ? undefined : summaryConfig; const persistentCoTEnabled = configService.getExperimentBasedConfig(ConfigKey.ResponsesApiPersistentCoTEnabled, expService) - && isHiddenModelM(endpoint); + && (isGpt54(endpoint) || isGpt55(endpoint) || isHiddenModelM(endpoint)); if (effort || summary || persistentCoTEnabled) { body.reasoning = { ...(effort ? { effort } : {}), From b5f8b27ece934e3bc4d3a369dd4caef2bb71d280 Mon Sep 17 00:00:00 2001 From: BryanLiang <31512600+Bryan2333@users.noreply.github.com> Date: Sat, 20 Jun 2026 06:33:28 +0800 Subject: [PATCH 10/25] fix issue 300307 (#322104) Co-authored-by: Dmitriy Vasyura --- .../contrib/terminal/common/scripts/shellIntegration.fish | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish index 6cff7487a71..f87fc7623ff 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish @@ -127,10 +127,7 @@ end # Backslashes are doubled and non-alphanumeric characters are hex encoded. function __vsc_escape_value # Escape backslashes and semi-colons - echo $argv \ - | string replace --all '\\' '\\\\' \ - | string replace --all ';' '\\x3b' \ - ; + echo $argv | string replace --all '\\' '\\\\' | string replace --all ';' '\\x3b' end # Sent right after an interactive command has finished executing. From 411a997c16fab0c93b99168996525c9159e7a751 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 19 Jun 2026 15:49:53 -0700 Subject: [PATCH 11/25] agent host: bring tool approval to parity with the extension (#322151) * agent host: bring tool approval to parity with the extension - The agent host auto-approved write tool calls using only working-directory and glob checks, while the extension's edit-confirmation pipeline also guards against unsafe paths, system/platform-config locations, and symlinks that redirect edits outside the workspace; this closes that gap so the agent host no longer silently approves edits the extension would prompt for. - Resolves symlinks (including not-yet-created ancestors) so a link cannot be used to escape the working directory, and treats permission-denied resolution as requiring confirmation. - Makes the approval path async rather than relying on synchronous filesystem calls, while keeping the shell-command approver synchronous so its existing behavior and tests are unaffected. - Adds unit tests covering the new path-safety, platform-restriction, and symlink-escape checks. (Commit message generated by Copilot) * agent host: address review feedback and fix async approval tests - Treat EACCES like EPERM when resolving symlinks so an execute-only directory cannot be used to bypass the working-directory check; permission errors now require confirmation rather than silently checking the literal path. - Fix mis-indented JSDoc in claudeCanUseTool. - Update AgentSideEffects tests to await the now-async tool-approval dispatch via a deterministic waitForState helper (re-checks on each state emit) instead of depending on a fixed timing delay. (Commit message generated by Copilot) * agent host: fix SessionPermissionManager test on Windows CI - The test built its working directory under `os.tmpdir()`, which on Windows CI is an 8.3 short path (`C:\Users\RUNNER~1\...`). `assertPathIsSafe` correctly rejects the `~1` segment, so every auto-approval returned "needs confirmation" and the suite failed. Base the temp dir on `RUNNER_TEMP` (a plain long path) when available so the working directory is free of 8.3 short names. (Commit message generated by Copilot) --- .../agentHost/node/agentSideEffects.ts | 12 +- .../agentHost/node/claude/claudeCanUseTool.ts | 2 +- .../agentHost/node/sessionPermissions.ts | 226 +++++++++++++++++- .../test/node/agentSideEffects.test.ts | 99 ++++++-- .../test/node/sessionPermissions.test.ts | 142 +++++++++++ 5 files changed, 447 insertions(+), 34 deletions(-) create mode 100644 src/vs/platform/agentHost/test/node/sessionPermissions.test.ts diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index b2005626cb7..ab004a86019 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -349,7 +349,9 @@ export class AgentSideEffects extends Disposable { if (subagentSession) { const subTurnId = this._stateManager.getActiveTurnId(subagentSession); if (subTurnId) { - this._handleToolReady(signal, subagentSession, subTurnId, agent); + void this._handleToolReady(signal, subagentSession, subTurnId, agent).catch(err => { + this._logService.error('[AgentSideEffects] _handleToolReady failed', err); + }); } return; } @@ -381,7 +383,9 @@ export class AgentSideEffects extends Disposable { private _dispatchActionForSession(signal: AgentSignal, sessionKey: ProtocolURI, turnId: string, agent?: IAgent): void { if (signal.kind === 'pending_confirmation') { if (agent) { - this._handleToolReady(signal, sessionKey, turnId, agent); + void this._handleToolReady(signal, sessionKey, turnId, agent).catch(err => { + this._logService.error('[AgentSideEffects] _handleToolReady failed', err); + }); } return; } @@ -734,7 +738,7 @@ export class AgentSideEffects extends Disposable { * dispatches the `ChatToolCallReady` action with confirmation options * for the client. */ - private _handleToolReady(e: IAgentToolPendingConfirmationSignal, sessionKey: ProtocolURI, turnId: string, agent: IAgent): void { + private async _handleToolReady(e: IAgentToolPendingConfirmationSignal, sessionKey: ProtocolURI, turnId: string, agent: IAgent): Promise { const approvalEvent = { toolCallId: e.state.toolCallId, session: e.session, @@ -742,7 +746,7 @@ export class AgentSideEffects extends Disposable { permissionPath: e.permissionPath, toolInput: e.state.toolInput, }; - const autoApproval = this._permissionManager.getAutoApproval(approvalEvent, sessionKey); + const autoApproval = await this._permissionManager.getAutoApproval(approvalEvent, sessionKey); const part = this._stateManager.getSessionState(sessionKey)?.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === e.state.toolCallId); const toolCall = part?.kind === ResponsePartKind.ToolCall ? part.toolCall : undefined; const contributor = e.state.contributor ?? toolCall?.contributor; diff --git a/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts b/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts index bdcad9e0345..2802007a05a 100644 --- a/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts +++ b/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts @@ -61,7 +61,7 @@ export interface IClaudeCanUseToolOptions { * * Note: protocol-level auto-approve for write tools lives in * `agentSideEffects.ts:_handleToolReady`, which subscribes to the - * `pending_confirmation` signal and synchronously calls + * `pending_confirmation` signal and calls * `respondToPermissionRequest`. The atomic register-then-fire * invariant lives inside {@link ClaudeAgentSession.requestPermission} * (via `PendingRequestRegistry.registerAndFire`). diff --git a/src/vs/platform/agentHost/node/sessionPermissions.ts b/src/vs/platform/agentHost/node/sessionPermissions.ts index 3172525d58b..ac205701f79 100644 --- a/src/vs/platform/agentHost/node/sessionPermissions.ts +++ b/src/vs/platform/agentHost/node/sessionPermissions.ts @@ -3,12 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { realpath } from 'fs/promises'; import { homedir } from 'os'; -import { match as globMatch } from '../../../base/common/glob.js'; +import { match as globMatch, parse as globParse, type ParsedPattern } from '../../../base/common/glob.js'; import { untildify } from '../../../base/common/labels.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import * as path from '../../../base/common/path.js'; +import { isMacintosh, isWindows } from '../../../base/common/platform.js'; import { extUriBiasedIgnorePathCase, normalizePath } from '../../../base/common/resources.js'; +import { isDefined } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { ILogService } from '../../log/common/log.js'; @@ -58,6 +61,124 @@ const DEFAULT_EDIT_AUTO_APPROVE_PATTERNS: Readonly> = { '**/*-lock.{yaml,json}': false, }; +/** + * Glob patterns matching dotfiles directly under the user's home directory + * (e.g. `~/.ssh`, `~/.aws`). Writes to these always require confirmation, + * even when the working directory sits inside the home directory. + */ +const HOME_DOTFILE_PATTERNS: readonly ParsedPattern[] = [ + globParse(homedir() + '/.*'), + globParse(homedir() + '/.*/**'), +]; + +/** + * Absolute directory prefixes whose contents are platform configuration data + * (e.g. `~/Library`, `%APPDATA%`). Writes under these require confirmation + * unless the working directory itself lives inside the restricted directory. + */ +const PLATFORM_RESTRICTED_DIRS: readonly string[] = ( + isWindows + ? [process.env.APPDATA, process.env.LOCALAPPDATA] + : isMacintosh + ? [homedir() + '/Library'] + : [] +).filter(isDefined); + +/** + * Validates that a path doesn't contain suspicious characters that could be + * used to bypass security checks on Windows (e.g. NTFS Alternate Data Streams, + * invalid characters, reserved device names). Throws if the path is suspicious. + */ +function assertPathIsSafe(fsPath: string, _isWindows = isWindows): void { + if (fsPath.includes('\0')) { + throw new Error(`Path contains null bytes: ${fsPath}`); + } + + if (!_isWindows) { + return; + } + + // Check for NTFS Alternate Data Streams (ADS) + const colonIndex = fsPath.indexOf(':', 2); + if (colonIndex !== -1) { + throw new Error(`Path contains invalid characters (alternate data stream): ${fsPath}`); + } + + // Check for invalid Windows filename characters + const invalidChars = /[<>"|?*]/; + const pathAfterDrive = fsPath.length > 2 ? fsPath.substring(2) : fsPath; + if (invalidChars.test(pathAfterDrive)) { + throw new Error(`Path contains invalid characters: ${fsPath}`); + } + + // Check for named pipes or device paths + if (fsPath.startsWith('\\\\.') || fsPath.startsWith('\\\\?')) { + throw new Error(`Path is a reserved device path: ${fsPath}`); + } + + const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i; + + // Check for trailing dots and spaces on path components (Windows quirk) + const parts = fsPath.split('\\'); + for (const part of parts) { + if (part.length === 0) { + continue; + } + + if (reserved.test(part)) { + throw new Error(`Reserved device name in path: ${fsPath}`); + } + + if (part.endsWith('.') || part.endsWith(' ')) { + throw new Error(`Path contains invalid trailing characters: ${fsPath}`); + } + + const tildeIndex = part.indexOf('~'); + if (tildeIndex !== -1) { + const afterTilde = part.substring(tildeIndex + 1); + if (afterTilde.length > 0 && /^\d/.test(afterTilde)) { + throw new Error(`Path appears to use short filename format (8.3 names): ${fsPath}. Please use the full path.`); + } + } + } +} + +/** + * Resolves the real path of `fsPath`, walking up the parent chain when the path + * (or its ancestors) does not yet exist on disk. This ensures a symlink at any + * ancestor is followed even for files that are about to be created. + */ +async function resolveRealPathForNonexistent(fsPath: string): Promise { + try { + return await realpath(fsPath); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { + throw e; + } + } + + const tail: string[] = [path.basename(fsPath)]; + let current = path.dirname(fsPath); + while (true) { + const parent = path.dirname(current); + if (parent === current) { + // Reached the filesystem root without finding an existing ancestor. + return fsPath; + } + try { + const resolved = await realpath(current); + return path.join(resolved, ...tail); + } catch (e) { + const code = (e as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + throw e; + } + } + tail.unshift(path.basename(current)); + current = parent; + } +} + /** * Single entry point for all tool-call approval logic in the agent host. * @@ -96,8 +217,8 @@ export class SessionPermissionManager extends Disposable { /** * Initializes async resources (tree-sitter WASM) used for shell command - * auto-approval. Await this before any session events can arrive to - * guarantee that {@link getAutoApproval} is fully synchronous. + * auto-approval. Await this before any session events can arrive so that + * shell command parsing within {@link getAutoApproval} is synchronous. */ initialize(): Promise { return this._commandAutoApprover.initialize(); @@ -106,10 +227,10 @@ export class SessionPermissionManager extends Disposable { // ---- Auto-approval (analogous to getPreConfirmAction) ------------------- /** - * Synchronously checks whether a `tool_ready` event should be - * auto-approved. Returns a {@link ToolCallConfirmationReason} when the - * tool call should proceed without user interaction, or `undefined` - * when user confirmation is required. + * Checks whether a `tool_ready` event should be auto-approved. Returns a + * {@link ToolCallConfirmationReason} when the tool call should proceed + * without user interaction, or `undefined` when user confirmation is + * required. * * Checks are evaluated in order: * 1. Session-level bypass (`autoApprove` config) @@ -118,7 +239,7 @@ export class SessionPermissionManager extends Disposable { * 4. Write path rules (within working directory + glob patterns) * 5. Shell command rules (tree-sitter parsed, default allow/deny) */ - getAutoApproval(e: IToolApprovalEvent, sessionKey: ProtocolURI): ToolCallConfirmationReason | undefined { + async getAutoApproval(e: IToolApprovalEvent, sessionKey: ProtocolURI): Promise { const workDir = this._configService.getEffectiveWorkingDirectory(sessionKey); // 1. Session-level auto-approve @@ -142,7 +263,7 @@ export class SessionPermissionManager extends Disposable { // 4. Write auto-approval if (e.permissionKind === 'write' && e.permissionPath) { - if (this._isPathInWorkingDirectory(e.permissionPath, workDir) && this._isEditAutoApproved(e.permissionPath)) { + if (await this._isEditAutoApproved(e.permissionPath, workDir)) { this._logService.trace(`[SessionPermissionManager] Auto-approving write to ${e.permissionPath}`); return ToolCallConfirmationReason.NotNeeded; } @@ -247,7 +368,7 @@ export class SessionPermissionManager extends Disposable { if (!resolved) { return false; } - return this._isPathInWorkingDirectory(resolved, workDir) && this._isEditAutoApproved(resolved); + return this._checkWritePath(resolved, workDir); } /** @@ -271,7 +392,90 @@ export class SessionPermissionManager extends Disposable { return path.resolve(URI.parse(workDir).fsPath, trimmed); } - private _isEditAutoApproved(filePath: string): boolean { + /** + * Determines whether a write to `filePath` can be auto-approved. Mirrors the + * checks performed by the workbench edit-confirmation pipeline: + * + * 1. The path is resolved through any symlinks (following ancestors that do + * not yet exist) so a link can't redirect an edit outside the working + * directory. Both the literal and resolved paths must pass every check. + * 2. The path must be free of suspicious characters (see {@link assertPathIsSafe}). + * 3. The path must live inside the working directory. + * 4. The path must not target a platform-restricted location (home dotfiles, + * `~/Library`, `%APPDATA%`, ...). + * 5. The path must match the edit auto-approve glob rules. + */ + private async _isEditAutoApproved(filePath: string, workDir: string | undefined): Promise { + const pathsToCheck = await this._resolveWritePaths(filePath); + return pathsToCheck !== undefined && pathsToCheck.every(p => this._checkWritePath(p, workDir)); + } + + /** + * Returns the set of paths that must each pass the write checks: the literal + * path plus, for absolute paths, the symlink-resolved real path. Returns + * `undefined` when the path cannot be resolved due to missing permissions, + * signalling that confirmation is required. + */ + private async _resolveWritePaths(filePath: string): Promise { + const pathsToCheck = [filePath]; + if (path.isAbsolute(filePath)) { + try { + const linked = await resolveRealPathForNonexistent(filePath); + if (linked !== filePath) { + pathsToCheck.push(linked); + } + } catch (e) { + const code = (e as NodeJS.ErrnoException).code; + if (code === 'EPERM' || code === 'EACCES') { + // No permission to resolve the path — require confirmation. + return undefined; + } + // Otherwise fall back to checking the literal path only. + } + } + return pathsToCheck; + } + + /** Runs the per-path write checks for a single (already symlink-resolved) path. */ + private _checkWritePath(filePath: string, workDir: string | undefined): boolean { + try { + assertPathIsSafe(filePath); + } catch { + return false; + } + if (!this._isPathInWorkingDirectory(filePath, workDir)) { + return false; + } + if (this._isPlatformRestrictedPath(filePath, workDir)) { + return false; + } + return this._matchesEditAutoApprovePatterns(filePath); + } + + /** + * Returns whether `filePath` targets a platform-restricted location that + * should always require confirmation. Edits within home-directory dotfiles + * are never auto-approved. Edits within platform config directories are + * allowed only when the working directory itself lives inside them. + */ + private _isPlatformRestrictedPath(filePath: string, workDir: string | undefined): boolean { + if (HOME_DOTFILE_PATTERNS.some(pattern => pattern(filePath))) { + return true; + } + + const uri = URI.file(filePath); + const workspaceFolder = workDir ? URI.parse(workDir) : undefined; + for (const restricted of PLATFORM_RESTRICTED_DIRS) { + const parentURI = URI.file(restricted); + if (extUriBiasedIgnorePathCase.isEqualOrParent(uri, parentURI)) { + // Allow edits when the working directory is opened inside the restricted area. + return !(workspaceFolder && extUriBiasedIgnorePathCase.isEqualOrParent(workspaceFolder, parentURI)); + } + } + return false; + } + + private _matchesEditAutoApprovePatterns(filePath: string): boolean { let approved = true; for (const [pattern, isApproved] of Object.entries(DEFAULT_EDIT_AUTO_APPROVE_PATTERNS)) { if (isApproved !== approved && globMatch(pattern, filePath)) { diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 8c5b02cf535..5359c4a0c72 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -164,6 +164,36 @@ suite('AgentSideEffects', () => { ); } + /** + * Resolves with the first non-`undefined` value returned by `match`, + * re-evaluating it immediately and after every envelope emitted by the + * state manager. Used to await the async tool-approval pipeline + * (`_handleToolReady` -> `getAutoApproval` -> `realpath`) deterministically + * instead of depending on a fixed settle delay. + */ + function waitForState(manager: AgentHostStateManager, match: () => T | undefined): Promise { + return new Promise((resolve, reject) => { + const initial = match(); + if (initial !== undefined) { + resolve(initial); + return; + } + const store = new DisposableStore(); + const timer = setTimeout(() => { + store.dispose(); + reject(new Error('waitForState: condition was not met')); + }, 5000); + store.add(toDisposable(() => clearTimeout(timer))); + store.add(manager.onDidEmitEnvelope(() => { + const value = match(); + if (value !== undefined) { + store.dispose(); + resolve(value); + } + })); + }); + } + setup(async () => { fileService = disposables.add(new FileService(new NullLogService())); const memFs = disposables.add(new InMemoryFileSystemProvider()); @@ -1389,7 +1419,7 @@ suite('AgentSideEffects', () => { suite('tool_ready dispatches progress actions to advance tool call state', () => { - test('tool_ready for a non-permission tool dispatches ChatToolCallReady and advances state from Streaming to Running', () => { + test('tool_ready for a non-permission tool dispatches ChatToolCallReady and advances state from Streaming to Running', async () => { setupSession(); startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); @@ -1422,14 +1452,18 @@ suite('AgentSideEffects', () => { permissionKind: undefined, permissionPath: undefined, }); - const stateAfterReady = stateManager.getSessionState(sessionUri.toString()); + const stateAfterReady = await waitForState(stateManager, () => { + const s = stateManager.getSessionState(sessionUri.toString()); + const p = s?.activeTurn?.responseParts[0]; + return p?.kind === ResponsePartKind.ToolCall && p.toolCall.status === ToolCallStatus.Running ? s : undefined; + }); const partAfterReady = stateAfterReady?.activeTurn?.responseParts[0]; assert.strictEqual(partAfterReady?.kind, ResponsePartKind.ToolCall); assert.strictEqual(partAfterReady?.kind === ResponsePartKind.ToolCall ? partAfterReady.toolCall.status : undefined, ToolCallStatus.Running, 'tool call should advance from Streaming to Running after tool_ready'); }); - test('tool_ready for a permission-gated tool dispatches ChatToolCallReady and advances state to PendingConfirmation', () => { + test('tool_ready for a permission-gated tool dispatches ChatToolCallReady and advances state to PendingConfirmation', async () => { setupSession(); startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); @@ -1456,14 +1490,18 @@ suite('AgentSideEffects', () => { permissionKind: undefined, permissionPath: undefined, }); - const state = stateManager.getSessionState(sessionUri.toString()); + const state = await waitForState(stateManager, () => { + const s = stateManager.getSessionState(sessionUri.toString()); + const p = s?.activeTurn?.responseParts[0]; + return p?.kind === ResponsePartKind.ToolCall && p.toolCall.status === ToolCallStatus.PendingConfirmation ? s : undefined; + }); const part = state?.activeTurn?.responseParts[0]; assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.PendingConfirmation, 'tool call should advance to PendingConfirmation for permission-gated tool_ready'); }); - test('pending_confirmation for a tool inside a subagent routes to the subagent session', () => { + test('pending_confirmation for a tool inside a subagent routes to the subagent session', async () => { // Regression: a `pending_confirmation` signal for a client tool // inside a subagent must dispatch ChatToolCallReady against // the subagent session, not the parent. Otherwise the parent @@ -1519,7 +1557,13 @@ suite('AgentSideEffects', () => { // The subagent session must contain the ChatToolCallReady. const subagentUri = buildSubagentSessionUri(sessionUri.toString(), 'tc-parent'); - const subState = stateManager.getSessionState(subagentUri); + const subState = await waitForState(stateManager, () => { + const s = stateManager.getSessionState(subagentUri); + const inner = s?.activeTurn?.responseParts.find( + rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-inner' + ); + return inner?.kind === ResponsePartKind.ToolCall && inner.toolCall.status === ToolCallStatus.Running ? s : undefined; + }); const innerPart = subState?.activeTurn?.responseParts.find( rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-inner' ); @@ -1565,7 +1609,7 @@ suite('AgentSideEffects', () => { }); } - test('auto-approves all writes when autoApprove is set to bypass', () => { + test('auto-approves all writes when autoApprove is set to bypass', async () => { setupSessionWithConfig('autoApprove'); startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); @@ -1598,13 +1642,14 @@ suite('AgentSideEffects', () => { permissionKind: 'write', permissionPath: '/workspace/.env', }); + await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined); // .env would normally be blocked, but session-level auto-approve overrides assert.deepStrictEqual(agent.respondToPermissionCalls, [ { requestId: 'tc-bypass-1', approved: true }, ]); }); - test('auto-approves shell commands when autoApprove is set to bypass', () => { + test('auto-approves shell commands when autoApprove is set to bypass', async () => { setupSessionWithConfig('autoApprove'); startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); @@ -1637,6 +1682,7 @@ suite('AgentSideEffects', () => { permissionKind: 'shell', permissionPath: undefined, }); + await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined); // Dangerous command would normally be blocked, but session-level // bypass auto-approve overrides. assert.deepStrictEqual(agent.respondToPermissionCalls, [ @@ -1644,7 +1690,7 @@ suite('AgentSideEffects', () => { ]); }); - test('marks pending client tool approval for client-side auto-approval in bypass mode', () => { + test('marks pending client tool approval for client-side auto-approval in bypass mode', async () => { setupSessionWithConfig('autoApprove'); startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); @@ -1669,7 +1715,11 @@ suite('AgentSideEffects', () => { permissionKind: 'custom-tool', permissionPath: undefined, }); - const state = stateManager.getSessionState(sessionUri.toString()); + const state = await waitForState(stateManager, () => { + const s = stateManager.getSessionState(sessionUri.toString()); + const p = s?.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === 'tc-client-approve-1'); + return p?.kind === ResponsePartKind.ToolCall && p.toolCall.status === ToolCallStatus.PendingConfirmation ? s : undefined; + }); const part = state?.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === 'tc-client-approve-1'); assert.ok(part?.kind === ResponsePartKind.ToolCall); assert.deepStrictEqual({ @@ -1732,7 +1782,7 @@ suite('AgentSideEffects', () => { assert.strictEqual(agent.respondToPermissionCalls.length, 0); }); - test('respects mid-session config change via SessionConfigChanged', () => { + test('respects mid-session config change via SessionConfigChanged', async () => { setupSessionWithConfig('default'); startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); @@ -1771,6 +1821,7 @@ suite('AgentSideEffects', () => { permissionKind: 'write', permissionPath: '/workspace/.env', }); + await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined); // Should now be auto-approved after config change assert.deepStrictEqual(agent.respondToPermissionCalls, [ { requestId: 'tc-mid-1', approved: true }, @@ -1815,6 +1866,7 @@ suite('AgentSideEffects', () => { permissionKind: 'write', permissionPath: '/workspace/src/app.ts', }); + await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined); // Auto-approved writes call respondToPermissionRequest directly assert.deepStrictEqual(agent.respondToPermissionCalls, [ { requestId: 'tc-auto-1', approved: true }, @@ -1978,7 +2030,7 @@ suite('AgentSideEffects', () => { suite('read auto-approve', () => { - test('auto-approves reads inside working directory', () => { + test('auto-approves reads inside working directory', async () => { setupSession(URI.file('/workspace').toString()); startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); @@ -2011,6 +2063,7 @@ suite('AgentSideEffects', () => { permissionKind: 'read', permissionPath: '/workspace/src/app.ts', }); + await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined); assert.deepStrictEqual(agent.respondToPermissionCalls, [ { requestId: 'tc-read-1', approved: true }, ]); @@ -2547,7 +2600,7 @@ suite('AgentSideEffects', () => { assert.strictEqual(parentInnerTool, undefined, 'inner tool must not leak into parent session'); }); - test('reads inside parent working directory are auto-approved for tools in subagent sessions', () => { + test('reads inside parent working directory are auto-approved for tools in subagent sessions', async () => { // Subagent sessions don't carry their own workingDirectory or // autoApprove config. Without inheritance from the parent, every // tool call inside a subagent (even a read in the workspace) would @@ -2590,12 +2643,13 @@ suite('AgentSideEffects', () => { permissionKind: 'read', permissionPath: '/workspace/src/app.ts', }); + await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined); assert.deepStrictEqual(agent.respondToPermissionCalls, [ { requestId: 'inner-read-1', approved: true }, ]); }); - test('session-level autoApprove on the parent is inherited by tools in subagent sessions', () => { + test('session-level autoApprove on the parent is inherited by tools in subagent sessions', async () => { setupSession(URI.file('/workspace').toString()); startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); @@ -2650,6 +2704,7 @@ suite('AgentSideEffects', () => { permissionKind: 'write', permissionPath: '/tmp/foo', }); + await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined); assert.deepStrictEqual(agent.respondToPermissionCalls, [ { requestId: 'inner-write-1', approved: true }, ]); @@ -2660,7 +2715,7 @@ suite('AgentSideEffects', () => { suite('session permissions', () => { - test('tool_ready action includes confirmation options when confirmation is needed', () => { + test('tool_ready action includes confirmation options when confirmation is needed', async () => { setupSession(); startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); @@ -2693,7 +2748,13 @@ suite('AgentSideEffects', () => { permissionKind: 'custom-tool', permissionPath: undefined, }); - const state = stateManager.getSessionState(sessionUri.toString()); + const state = await waitForState(stateManager, () => { + const s = stateManager.getSessionState(sessionUri.toString()); + const found = s?.activeTurn?.responseParts.find( + rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-perm-1' + ); + return found?.kind === ResponsePartKind.ToolCall && found.toolCall.status === ToolCallStatus.PendingConfirmation ? s : undefined; + }); const tc = state!.activeTurn!.responseParts.find( rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-perm-1' ); @@ -2756,7 +2817,7 @@ suite('AgentSideEffects', () => { ); }); - test('subsequent tool_ready for same tool is auto-approved after allow-session permission', () => { + test('subsequent tool_ready for same tool is auto-approved after allow-session permission', async () => { setupSession(); stateManager.setSessionConfig(sessionUri.toString(), { schema: { type: 'object', properties: {} }, @@ -2793,12 +2854,13 @@ suite('AgentSideEffects', () => { permissionKind: 'custom-tool', permissionPath: undefined, }); + await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined); assert.deepStrictEqual(agent.respondToPermissionCalls, [ { requestId: 'tc-perm-3', approved: true }, ]); }); - test('subagent tool calls inherit parent session permissions', () => { + test('subagent tool calls inherit parent session permissions', async () => { setupSession(); stateManager.setSessionConfig(sessionUri.toString(), { schema: { type: 'object', properties: {} }, @@ -2859,6 +2921,7 @@ suite('AgentSideEffects', () => { permissionKind: 'custom-tool', permissionPath: undefined, }); + await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined); assert.deepStrictEqual(agent.respondToPermissionCalls, [ { requestId: 'inner-perm-1', approved: true }, ]); diff --git a/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts b/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts new file mode 100644 index 00000000000..7c4b3d0ff82 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'fs'; +import { homedir, tmpdir } from 'os'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { join } from '../../../../base/common/path.js'; +import { isWindows } from '../../../../base/common/platform.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { platformSessionSchema } from '../../common/agentHostSchema.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +import { SessionStatus, ToolCallConfirmationReason, type SessionSummary } from '../../common/state/sessionState.js'; +import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { SessionPermissionManager, type IToolApprovalEvent } from '../../node/sessionPermissions.js'; + +suite('SessionPermissionManager', () => { + + const disposables = new DisposableStore(); + let manager: AgentHostStateManager; + let permissions: SessionPermissionManager; + + // Real (symlink-resolved) temp directories so that the symlink-resolution + // checks compare like-for-like (e.g. macOS `/var` -> `/private/var`). + let workDir: string; + let outsideDir: string; + + const sessionUri = URI.from({ scheme: 'copilot', path: '/s' }).toString(); + + function makeSummary(resource: string, workingDirectory?: string): SessionSummary { + return { + resource, + provider: 'copilot', + title: 't', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + project: { uri: 'file:///project', displayName: 'Project' }, + workingDirectory, + }; + } + + function writeEvent(permissionPath: string): IToolApprovalEvent { + return { toolCallId: 'tc-1', session: URI.parse(sessionUri), permissionKind: 'write', permissionPath }; + } + + setup(async () => { + // Prefer the CI runner temp dir (a plain long path) over `os.tmpdir()`, + // which on Windows CI is an 8.3 short path (`C:\Users\RUNNER~1\...`) that + // `assertPathIsSafe` rejects for its `~1` segment — which would make every + // auto-approval fail. `realpathSync` keeps macOS `/var` -> `/private/var` + // consistent so the symlink-resolution checks compare like-for-like. + const baseTmp = process.env.RUNNER_TEMP || tmpdir(); + workDir = realpathSync(mkdtempSync(join(baseTmp, 'sesperm-work-'))); + outsideDir = realpathSync(mkdtempSync(join(baseTmp, 'sesperm-out-'))); + + manager = disposables.add(new AgentHostStateManager(new NullLogService())); + const configService = disposables.add(new AgentConfigurationService(manager, new NullLogService())); + permissions = disposables.add(new SessionPermissionManager(manager, configService, new NullLogService())); + await permissions.initialize(); + + manager.createSession(makeSummary(sessionUri, URI.file(workDir).toString())); + }); + + teardown(() => { + disposables.clear(); + rmSync(workDir, { recursive: true, force: true }); + rmSync(outsideDir, { recursive: true, force: true }); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('auto-approves a normal file inside the working directory', async () => { + const result = await permissions.getAutoApproval(writeEvent(join(workDir, 'src', 'app.ts')), sessionUri); + assert.strictEqual(result, ToolCallConfirmationReason.NotNeeded); + }); + + test('requires confirmation for writes outside the working directory', async () => { + const result = await permissions.getAutoApproval(writeEvent(join(outsideDir, 'app.ts')), sessionUri); + assert.strictEqual(result, undefined); + }); + + test('requires confirmation for protected files inside the working directory', async () => { + const files = ['.env', 'package.json', join('.git', 'config'), 'deps.lock', join('.vscode', 'settings.json')]; + const results: (ToolCallConfirmationReason | undefined)[] = []; + for (const file of files) { + results.push(await permissions.getAutoApproval(writeEvent(join(workDir, file)), sessionUri)); + } + assert.deepStrictEqual(results, files.map(() => undefined)); + }); + + test('requires confirmation for paths containing null bytes', async () => { + const result = await permissions.getAutoApproval(writeEvent(join(workDir, 'a\u0000b.txt')), sessionUri); + assert.strictEqual(result, undefined); + }); + + (isWindows ? test.skip : test)('requires confirmation when a symlink redirects outside the working directory', async () => { + symlinkSync(outsideDir, join(workDir, 'link'), 'dir'); + const result = await permissions.getAutoApproval(writeEvent(join(workDir, 'link', 'secret.txt')), sessionUri); + assert.strictEqual(result, undefined); + }); + + (isWindows ? test.skip : test)('auto-approves when a symlink stays inside the working directory', async () => { + mkdirSync(join(workDir, 'real')); + symlinkSync(join(workDir, 'real'), join(workDir, 'link-in'), 'dir'); + const result = await permissions.getAutoApproval(writeEvent(join(workDir, 'link-in', 'note.txt')), sessionUri); + assert.strictEqual(result, ToolCallConfirmationReason.NotNeeded); + }); + + test('requires confirmation for home-directory dotfiles', async () => { + const homeSession = URI.from({ scheme: 'copilot', path: '/home' }).toString(); + manager.createSession(makeSummary(homeSession, URI.file(homedir()).toString())); + const result = await permissions.getAutoApproval(writeEvent(join(homedir(), '.sesperm-config-xyz')), homeSession); + assert.strictEqual(result, undefined); + }); + + test('auto-approves any write when session bypass is enabled', async () => { + manager.setSessionConfig(sessionUri, { + schema: platformSessionSchema.toProtocol(), + values: { [SessionConfigKey.AutoApprove]: 'autoApprove' }, + }); + const result = await permissions.getAutoApproval(writeEvent(join(outsideDir, 'anything.txt')), sessionUri); + assert.strictEqual(result, ToolCallConfirmationReason.Setting); + }); + + test('auto-approves reads inside but requires confirmation outside the working directory', async () => { + const inside = await permissions.getAutoApproval( + { toolCallId: 'r', session: URI.parse(sessionUri), permissionKind: 'read', permissionPath: join(workDir, 'a.txt') }, + sessionUri, + ); + const outside = await permissions.getAutoApproval( + { toolCallId: 'r', session: URI.parse(sessionUri), permissionKind: 'read', permissionPath: join(outsideDir, 'a.txt') }, + sessionUri, + ); + assert.deepStrictEqual([inside, outside], [ToolCallConfirmationReason.NotNeeded, undefined]); + }); +}); From 81d0c7de4b294114a8145f23275137f41f6cfdce Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 19 Jun 2026 15:50:28 -0700 Subject: [PATCH 12/25] Stop worktreeCreated tasks when a session is marked done (#322153) The WorktreeCreatedTaskDispatcher launched setup/build tasks but never stopped them, so their (potentially remote) host processes leaked when a session was archived ("Mark as Done"). See #321021. Task runners now return an IDisposable stop handle. The dispatcher tracks these per session and disposes them when the session is archived or removed, terminating the launched terminals/processes. Agent-host task terminals are disposed directly (prompt-free) so the AgentHostPty shuts down and the node-pty process on the host is killed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/sessionTaskRunner.ts | 8 ++- .../chat/browser/sessionsTasksService.ts | 14 +++-- .../browser/workbenchSessionTaskRunner.ts | 15 +++-- .../browser/worktreeCreatedTaskDispatcher.ts | 27 +++++++-- .../workbenchSessionTaskRunner.test.ts | 22 ++++++- .../worktreeCreatedTaskDispatcher.test.ts | 60 +++++++++++++++++-- .../browser/agentHostSessionTaskRunner.ts | 14 +++-- .../agentHostSessionTaskRunner.test.ts | 28 +++++++-- 8 files changed, 158 insertions(+), 30 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/sessionTaskRunner.ts b/src/vs/sessions/contrib/chat/browser/sessionTaskRunner.ts index 38e0c40e9a7..f456b6ddf49 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTaskRunner.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTaskRunner.ts @@ -30,8 +30,14 @@ export interface ISessionTaskRunner { /** * Executes the given task in the session's runtime. The returned promise * resolves once the task has been launched (not when it has finished). + * + * May resolve to an {@link IDisposable} that stops the launched task (e.g. + * kills its terminal/process). Callers that auto-dispatch tasks (such as + * {@link WorktreeCreatedTaskDispatcher}) use it to stop long-running setup + * processes when a session is marked done. Resolves to `undefined` when the + * runner has nothing to stop. */ - runTask(task: ITaskEntry, session: ISession): Promise; + runTask(task: ITaskEntry, session: ISession): Promise; } /** diff --git a/src/vs/sessions/contrib/chat/browser/sessionsTasksService.ts b/src/vs/sessions/contrib/chat/browser/sessionsTasksService.ts index 080b4c5a9e3..187f8b2596d 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsTasksService.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsTasksService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; import { joinPath, dirname, isEqual } from '../../../../base/common/resources.js'; import { parse } from '../../../../base/common/jsonc.js'; @@ -146,8 +146,11 @@ export interface ISessionsTasksService { /** * Runs a task via the task service, looking it up by label in the * workspace folder corresponding to the session worktree. + * + * May resolve to an {@link IDisposable} that stops the launched task; see + * {@link ISessionTaskRunner.runTask}. */ - runTask(task: ITaskEntry, session: ISession): Promise; + runTask(task: ITaskEntry, session: ISession): Promise; /** * Observable label of the pinned task for the given repository. @@ -385,13 +388,14 @@ export class SessionsTasksService extends Disposable implements ISessionsTasksSe } } - async runTask(task: ITaskEntry, session: ISession): Promise { + async runTask(task: ITaskEntry, session: ISession): Promise { const runner = this._taskRunnerRegistry.getRunner(session); if (!runner) { - return; + return undefined; } - await runner.runTask(task, session); + const handle = await runner.runTask(task, session); this._onDidRunTask.fire({ task, session }); + return handle; } getPinnedTaskLabel(repository: URI | undefined): IObservable { diff --git a/src/vs/sessions/contrib/chat/browser/workbenchSessionTaskRunner.ts b/src/vs/sessions/contrib/chat/browser/workbenchSessionTaskRunner.ts index 295d2acc8b9..59a3401701a 100644 --- a/src/vs/sessions/contrib/chat/browser/workbenchSessionTaskRunner.ts +++ b/src/vs/sessions/contrib/chat/browser/workbenchSessionTaskRunner.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Schemas } from '../../../../base/common/network.js'; +import { IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { TaskRunSource } from '../../../../workbench/contrib/tasks/common/tasks.js'; import { ITaskService } from '../../../../workbench/contrib/tasks/common/taskService.js'; @@ -40,20 +41,26 @@ export class WorkbenchSessionTaskRunner implements ISessionTaskRunner { return !!this._workspaceContextService.getWorkspaceFolder(cwd); } - async runTask(task: ITaskEntry, session: ISession): Promise { + async runTask(task: ITaskEntry, session: ISession): Promise { const cwd = this._getCwd(session); if (!cwd) { - return; + return undefined; } const workspaceFolder = this._workspaceContextService.getWorkspaceFolder(cwd); if (!workspaceFolder) { - return; + return undefined; } const resolved = await this._taskService.getTask(workspaceFolder, task.label); if (!resolved) { - return; + return undefined; } await this._taskService.run(resolved, undefined, TaskRunSource.User); + + // Hand back a stop handle so auto-dispatched setup/build tasks can be + // terminated when the session is marked done. See #321021. + return toDisposable(() => { + this._taskService.terminate(resolved); + }); } private _getCwd(session: ISession) { diff --git a/src/vs/sessions/contrib/chat/browser/worktreeCreatedTaskDispatcher.ts b/src/vs/sessions/contrib/chat/browser/worktreeCreatedTaskDispatcher.ts index 13a4903735c..475aabd946e 100644 --- a/src/vs/sessions/contrib/chat/browser/worktreeCreatedTaskDispatcher.ts +++ b/src/vs/sessions/contrib/chat/browser/worktreeCreatedTaskDispatcher.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js'; -import { registerAutorunSelfDisposable } from '../../../../base/common/observable.js'; +import { autorun, registerAutorunSelfDisposable } from '../../../../base/common/observable.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; @@ -31,6 +31,10 @@ export const AGENT_HOST_RUN_WORKTREE_CREATED_TASKS_SETTING = 'chat.agentHost.run * {@link ISessionCapabilities.runsWorktreeCreatedTasks}) are skipped to avoid * double-execution. * + * The stop handles returned by the dispatched tasks are tracked per session and + * disposed when the session is marked done (archived) or removed, so the + * long-running setup/build processes don't leak. See #321021. + * * We deliberately ignore sessions that predate this contribution so restored * sessions don't re-run setup tasks when the agents window opens. */ @@ -72,6 +76,8 @@ export class WorktreeCreatedTaskDispatcher extends Disposable implements IWorkbe const store = new DisposableStore(); this._sessionDisposables.set(session.sessionId, store); + const taskHandles = store.add(new DisposableStore()); + registerAutorunSelfDisposable(store, reader => { if (session.loading.read(reader)) { return; @@ -83,11 +89,17 @@ export class WorktreeCreatedTaskDispatcher extends Disposable implements IWorkbe return; } reader.dispose(); - this._dispatchWorktreeCreatedTasks(session); + this._dispatchWorktreeCreatedTasks(session, taskHandles); }); + + store.add(autorun(reader => { + if (session.isArchived.read(reader)) { + taskHandles.clear(); + } + })); } - private async _dispatchWorktreeCreatedTasks(session: ISession): Promise { + private async _dispatchWorktreeCreatedTasks(session: ISession, taskHandles: DisposableStore): Promise { if (isAgentHostProviderId(session.providerId) && !this._configurationService.getValue(AGENT_HOST_RUN_WORKTREE_CREATED_TASKS_SETTING)) { this._logService.trace(`${LOG_PREFIX} Skipping worktreeCreated tasks for agent host session '${session.sessionId}' — '${AGENT_HOST_RUN_WORKTREE_CREATED_TASKS_SETTING}' is disabled.`); return; @@ -107,7 +119,14 @@ export class WorktreeCreatedTaskDispatcher extends Disposable implements IWorkbe } this._logService.trace(`${LOG_PREFIX} Running worktreeCreated task '${task.label}' for session '${session.sessionId}'`); try { - await this._sessionsTasksService.runTask(task, session); + const handle = await this._sessionsTasksService.runTask(task, session); + if (handle) { + if (session.isArchived.get()) { + handle.dispose(); + } else { + taskHandles.add(handle); + } + } } catch (err) { this._logService.warn(`${LOG_PREFIX} Failed to run task '${task.label}' for session '${session.sessionId}': ${err}`); } diff --git a/src/vs/sessions/contrib/chat/test/browser/workbenchSessionTaskRunner.test.ts b/src/vs/sessions/contrib/chat/test/browser/workbenchSessionTaskRunner.test.ts index 92a48923f70..ccabedbfbb9 100644 --- a/src/vs/sessions/contrib/chat/test/browser/workbenchSessionTaskRunner.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/workbenchSessionTaskRunner.test.ts @@ -69,6 +69,7 @@ suite('WorkbenchSessionTaskRunner', () => { const store = new DisposableStore(); let runner: WorkbenchSessionTaskRunner; let ranTasks: { label: string }[]; + let terminatedTasks: { label: string }[]; let tasksByLabel: Map; let workspaceFoldersByUri: Map; @@ -77,6 +78,7 @@ suite('WorkbenchSessionTaskRunner', () => { setup(() => { ranTasks = []; + terminatedTasks = []; tasksByLabel = new Map(); workspaceFoldersByUri = new Map(); @@ -93,6 +95,10 @@ suite('WorkbenchSessionTaskRunner', () => { } return undefined; } + override async terminate(task: Task) { + terminatedTasks.push({ label: task._label }); + return { success: true, task }; + } }); instantiationService.stub(IWorkspaceContextService, new class extends mock() { @@ -137,11 +143,23 @@ suite('WorkbenchSessionTaskRunner', () => { registerMockTask('build', worktreeUri); const session = makeSession({ worktree: worktreeUri, repository: repoUri }); - await runner.runTask(makeTask('build'), session); + (await runner.runTask(makeTask('build'), session))?.dispose(); assert.deepStrictEqual(ranTasks, [{ label: 'build' }]); }); + test('returned handle terminates the task via ITaskService', async () => { + registerMockTask('build', worktreeUri); + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + + const handle = await runner.runTask(makeTask('build'), session); + assert.deepStrictEqual(terminatedTasks, []); + + handle?.dispose(); + + assert.deepStrictEqual(terminatedTasks, [{ label: 'build' }]); + }); + test('runTask is a no-op when task is not registered', async () => { workspaceFoldersByUri.set(worktreeUri.toString(), { uri: worktreeUri, name: 'folder', index: 0, toResource: () => worktreeUri } as IWorkspaceFolder); const session = makeSession({ worktree: worktreeUri, repository: repoUri }); @@ -155,7 +173,7 @@ suite('WorkbenchSessionTaskRunner', () => { registerMockTask('build', repoUri); const session = makeSession({ repository: repoUri }); - await runner.runTask(makeTask('build'), session); + (await runner.runTask(makeTask('build'), session))?.dispose(); assert.deepStrictEqual(ranTasks, [{ label: 'build' }]); }); diff --git a/src/vs/sessions/contrib/chat/test/browser/worktreeCreatedTaskDispatcher.test.ts b/src/vs/sessions/contrib/chat/test/browser/worktreeCreatedTaskDispatcher.test.ts index 88b88658a7c..30c4f850b5e 100644 --- a/src/vs/sessions/contrib/chat/test/browser/worktreeCreatedTaskDispatcher.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/worktreeCreatedTaskDispatcher.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { Emitter } from '../../../../../base/common/event.js'; -import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { constObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { Codicon } from '../../../../../base/common/codicons.js'; @@ -25,6 +25,7 @@ interface ITestSession { readonly loading: ReturnType>; readonly status: ReturnType>; readonly workspace: ReturnType>; + readonly isArchived: ReturnType>; } function makeWorkspace(hasWorktree: boolean): ISessionWorkspace { @@ -50,6 +51,7 @@ function makeSession(opts: { id?: string; providerId?: string; runsWorktreeCreat const loading = observableValue('loading', opts.loading ?? false); const status = observableValue('status', opts.status ?? SessionStatus.InProgress); const workspace = observableValue('workspace', makeWorkspace(opts.hasWorktree ?? true)); + const isArchived = observableValue('isArchived', false); const chat = { resource: URI.parse('file:///session') } as IChat; const session: ISession = { sessionId: opts.id ?? 'test:session', @@ -67,7 +69,7 @@ function makeSession(opts: { id?: string; providerId?: string; runsWorktreeCreat modelId: observableValue('modelId', undefined), mode: observableValue('mode', undefined), loading, - isArchived: observableValue('isArchived', false), + isArchived, isRead: observableValue('isRead', true), lastTurnEnd: observableValue('lastTurnEnd', undefined), description: observableValue('description', undefined), @@ -75,7 +77,7 @@ function makeSession(opts: { id?: string; providerId?: string; runsWorktreeCreat mainChat: constObservable(chat), capabilities: { supportsMultipleChats: false, runsWorktreeCreatedTasks: opts.runsWorktreeCreatedTasks }, }; - return { session, loading, status, workspace }; + return { session, loading, status, workspace, isArchived }; } function entry(label: string, runOn?: 'worktreeCreated' | 'folderOpen' | 'default'): ISessionTaskWithTarget { @@ -91,6 +93,7 @@ function entry(label: string, runOn?: 'worktreeCreated' | 'folderOpen' | 'defaul class FakeSessionsTasksService implements Partial { declare readonly _serviceBrand: undefined; readonly ranTasks: { label: string; sessionId: string }[] = []; + readonly stoppedTasks: { label: string; sessionId: string }[] = []; private readonly _tasks = new Map(); runTaskFails = false; @@ -102,11 +105,12 @@ class FakeSessionsTasksService implements Partial { return this._tasks.get(session.sessionId) ?? []; } - async runTask(task: ITaskEntry, session: ISession): Promise { + async runTask(task: ITaskEntry, session: ISession): Promise { this.ranTasks.push({ label: task.label, sessionId: session.sessionId }); if (this.runTaskFails) { throw new Error('simulated launch failure'); } + return toDisposable(() => this.stoppedTasks.push({ label: task.label, sessionId: session.sessionId })); } } @@ -292,4 +296,52 @@ suite('WorktreeCreatedTaskDispatcher', () => { assert.deepStrictEqual(tasks.ranTasks, [{ label: 'setup', sessionId: 'a' }]); }); + + test('stops dispatched tasks when the session is marked done (archived)', async () => { + createDispatcher(); + const { session, workspace, isArchived } = makeSession({ id: 'a', hasWorktree: false }); + tasks.setTasks(session.sessionId, [entry('setup', 'worktreeCreated')]); + + mgmt.sessionStartedEmitter.fire(session); + workspace.set(makeWorkspace(true), undefined); + await settle(); + assert.deepStrictEqual(tasks.stoppedTasks, []); + + isArchived.set(true, undefined); + await settle(); + + assert.deepStrictEqual(tasks.stoppedTasks, [{ label: 'setup', sessionId: 'a' }]); + }); + + test('stops dispatched tasks when a started session is removed', async () => { + createDispatcher(); + const { session, workspace } = makeSession({ id: 'a', hasWorktree: false }); + tasks.setTasks(session.sessionId, [entry('setup', 'worktreeCreated')]); + + mgmt.sessionStartedEmitter.fire(session); + workspace.set(makeWorkspace(true), undefined); + await settle(); + assert.deepStrictEqual(tasks.ranTasks, [{ label: 'setup', sessionId: 'a' }]); + + mgmt.sessionsChangedEmitter.fire({ added: [], removed: [session], changed: [] }); + await settle(); + + assert.deepStrictEqual(tasks.stoppedTasks, [{ label: 'setup', sessionId: 'a' }]); + }); + + test('stops a task that finishes launching after the session is archived', async () => { + createDispatcher(); + const { session, workspace, isArchived } = makeSession({ id: 'a', hasWorktree: false }); + tasks.setTasks(session.sessionId, [entry('setup', 'worktreeCreated')]); + + mgmt.sessionStartedEmitter.fire(session); + // Archive before the worktree appears so the task is launched against an + // already-archived session. + isArchived.set(true, undefined); + workspace.set(makeWorkspace(true), undefined); + await settle(); + + assert.deepStrictEqual(tasks.ranTasks, [{ label: 'setup', sessionId: 'a' }]); + assert.deepStrictEqual(tasks.stoppedTasks, [{ label: 'setup', sessionId: 'a' }]); + }); }); diff --git a/src/vs/sessions/contrib/terminal/browser/agentHostSessionTaskRunner.ts b/src/vs/sessions/contrib/terminal/browser/agentHostSessionTaskRunner.ts index f09179e8e8d..9c9f7d8afd8 100644 --- a/src/vs/sessions/contrib/terminal/browser/agentHostSessionTaskRunner.ts +++ b/src/vs/sessions/contrib/terminal/browser/agentHostSessionTaskRunner.ts @@ -5,9 +5,11 @@ import { localize } from '../../../../nls.js'; import { Schemas } from '../../../../base/common/network.js'; +import { IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { AGENT_HOST_SCHEME, fromAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js'; +import { TerminalExitReason } from '../../../../platform/terminal/common/terminal.js'; import { IAgentHostTerminalService } from '../../../../workbench/contrib/terminal/browser/agentHostTerminalService.js'; import { ITerminalGroupService, ITerminalService } from '../../../../workbench/contrib/terminal/browser/terminal.js'; import { isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; @@ -43,10 +45,10 @@ export class AgentHostSessionTaskRunner implements ISessionTaskRunner { return this._getAddress(session) !== undefined; } - async runTask(task: ITaskEntry, session: ISession): Promise { + async runTask(task: ITaskEntry, session: ISession): Promise { const address = this._getAddress(session); if (!address) { - return; + return undefined; } const allTasks = await this._sessionsTasksService.getAllTasks(session); @@ -58,7 +60,7 @@ export class AgentHostSessionTaskRunner implements ISessionTaskRunner { const command = resolveTaskCommand(task, { lookup: label => byLabel.get(label) }); if (!command) { this._logService.trace(`${LOG_PREFIX} Skipping task '${task.label}' — no command could be resolved.`); - return; + return undefined; } const cwd = this._getCwd(session); @@ -68,12 +70,16 @@ export class AgentHostSessionTaskRunner implements ISessionTaskRunner { }); if (!instance) { this._logService.warn(`${LOG_PREFIX} Failed to create terminal for task '${task.label}' on '${address}'.`); - return; + return undefined; } this._terminalService.setActiveInstance(instance); await this._terminalGroupService.showPanel(true); await instance.sendText(command, /*shouldExecute*/ true); + + return toDisposable(() => { + instance.dispose(TerminalExitReason.User); + }); } private _getAddress(session: ISession): string | undefined { diff --git a/src/vs/sessions/contrib/terminal/test/browser/agentHostSessionTaskRunner.test.ts b/src/vs/sessions/contrib/terminal/test/browser/agentHostSessionTaskRunner.test.ts index 2c1c17a3811..df23ac90b79 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/agentHostSessionTaskRunner.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/agentHostSessionTaskRunner.test.ts @@ -72,12 +72,17 @@ suite('AgentHostSessionTaskRunner', () => { let runner: AgentHostSessionTaskRunner; let createdTerminals: { address: string; options?: IAgentHostTerminalCreateOptions }[]; let sentText: { text: string; shouldExecute: boolean }[]; + let disposedTerminals: ITerminalInstance[]; let allTasks: ISessionTaskWithTarget[]; - const fakeInstance = { sendText: async (text: string, shouldExecute: boolean) => { sentText.push({ text, shouldExecute }); } } as ITerminalInstance; + const fakeInstance = { + sendText: async (text: string, shouldExecute: boolean) => { sentText.push({ text, shouldExecute }); }, + dispose: () => { disposedTerminals.push(fakeInstance); }, + } as unknown as ITerminalInstance; setup(() => { createdTerminals = []; sentText = []; + disposedTerminals = []; allTasks = []; const instantiationService = store.add(new TestInstantiationService()); @@ -146,7 +151,7 @@ suite('AgentHostSessionTaskRunner', () => { const cwd = URI.parse('file:///path/to/worktree'); const session = makeSession({ providerId: LOCAL_AGENT_HOST_PROVIDER_ID, cwd }); - await runner.runTask(shellTask(), session); + (await runner.runTask(shellTask(), session))?.dispose(); assert.strictEqual(createdTerminals.length, 1); assert.strictEqual(createdTerminals[0].address, '__local__'); @@ -154,13 +159,24 @@ suite('AgentHostSessionTaskRunner', () => { assert.deepStrictEqual(sentText, [{ text: 'echo hi', shouldExecute: true }]); }); + test('returned handle stops the task by disposing its terminal', async () => { + const session = makeSession({ providerId: LOCAL_AGENT_HOST_PROVIDER_ID, cwd: URI.parse('file:///x') }); + + const handle = await runner.runTask(shellTask(), session); + assert.deepStrictEqual(disposedTerminals, []); + + handle?.dispose(); + + assert.deepStrictEqual(disposedTerminals, [fakeInstance]); + }); + test('agent-host scheme cwds are unwrapped to their original URI', async () => { const innerCwd = URI.parse('file:///remote/path'); const wrapped = toAgentHostUri(innerCwd, 'remote'); assert.strictEqual(wrapped.scheme, AGENT_HOST_SCHEME, 'precondition: wrapped uri'); const session = makeSession({ providerId: 'agenthost-myhost', cwd: wrapped }); - await runner.runTask(shellTask(), session); + (await runner.runTask(shellTask(), session))?.dispose(); assert.strictEqual(createdTerminals.length, 1); assert.strictEqual(createdTerminals[0].options?.cwd?.toString(), innerCwd.toString()); @@ -169,7 +185,7 @@ suite('AgentHostSessionTaskRunner', () => { test('unknown scheme cwds are omitted (host uses default)', async () => { const session = makeSession({ providerId: 'agenthost-myhost', cwd: URI.parse('vscode-vfs://github/owner/repo') }); - await runner.runTask(shellTask(), session); + (await runner.runTask(shellTask(), session))?.dispose(); assert.strictEqual(createdTerminals.length, 1); assert.strictEqual(createdTerminals[0].options?.cwd, undefined); @@ -177,7 +193,7 @@ suite('AgentHostSessionTaskRunner', () => { test('skips when no command can be resolved from the task', async () => { const session = makeSession({ providerId: LOCAL_AGENT_HOST_PROVIDER_ID, cwd: URI.parse('file:///x') }); - await runner.runTask({ label: 'empty' }, session); + (await runner.runTask({ label: 'empty' }, session))?.dispose(); assert.deepStrictEqual(createdTerminals, []); }); @@ -197,7 +213,7 @@ suite('AgentHostSessionTaskRunner', () => { { task: top, target: 'workspace' }, ]; - await runner.runTask(top, session); + (await runner.runTask(top, session))?.dispose(); assert.deepStrictEqual(sentText, [{ text: 'npm run transpile && npm run dev', shouldExecute: true }]); }); From 68f213e0beca4dffa48af50b9d2a531adc0489a8 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 19 Jun 2026 18:53:00 -0400 Subject: [PATCH 13/25] Don't dispose user-revealed terminals on session switch (#322121) Fixes microsoft/vscode#321049 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/tools/runInTerminalTool.ts | 13 ++++++++++ .../runInTerminalTool.test.ts | 25 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 1f8a127c3c4..54268fecd56 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -2776,6 +2776,15 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { this._sessionTerminalInstances.delete(chatSessionResource); for (const terminal of terminalsToDispose) { + // Only dispose if the terminal is still hidden from the user. Once + // the user reveals it (via the terminal panel or the outputLocation + // setting), it joins foregroundInstances and should persist so they + // can inspect/interact with it. This prevents user-revealed + // terminals from being destroyed when switching between sessions. + if (this._terminalService.foregroundInstances.includes(terminal)) { + this._logService.debug(`RunInTerminalTool: Skipping disposal of user-revealed terminal ${terminal.instanceId} for session ${chatSessionResource}`); + continue; + } // Skip redundant map walks in onDidDispose since this session has already been removed. this._terminalsBeingDisposedBySessionCleanup.add(terminal); terminal.dispose(); @@ -2785,6 +2794,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const terminalToRemove: string[] = []; for (const [termId, execution] of RunInTerminalTool._activeExecutions.entries()) { if (terminalsToDispose.has(execution.instance)) { + // Skip active executions for terminals that were preserved above + if (this._terminalService.foregroundInstances.includes(execution.instance)) { + continue; + } execution.dispose(); terminalToRemove.push(termId); } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index d0db403c0a5..ba7a257091a 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -2483,6 +2483,31 @@ suite('RunInTerminalTool', () => { ok(runInTerminalTool.sessionTerminalAssociations.has(sessionResource2), 'Session 2 terminal association should remain'); }); + test('should not dispose user-revealed terminals when chat session is disposed', () => { + const sessionId = 'test-session-revealed'; + const mockTerminal1 = createMockTerminal(11111); + const mockTerminal2 = createMockTerminal(22222); + + let terminal1Disposed = false; + let terminal2Disposed = false; + mockTerminal1.dispose = () => { terminal1Disposed = true; }; + mockTerminal2.dispose = () => { terminal2Disposed = true; }; + + const sessionResource = LocalChatSessionUri.forSession(sessionId); + runInTerminalTool.sessionTerminalInstances.set(sessionResource, new Set([mockTerminal1, mockTerminal2])); + + // Simulate that terminal2 was revealed by the user (it's in foregroundInstances) + (instantiationService.get(ITerminalService).foregroundInstances as ITerminalInstance[]).push(mockTerminal2); + + chatServiceDisposeEmitter.fire({ sessionResources: [sessionResource], reason: 'cleared' }); + + strictEqual(terminal1Disposed, true, 'Hidden terminal should have been disposed'); + strictEqual(terminal2Disposed, false, 'User-revealed terminal should NOT have been disposed'); + + // Clean up + (instantiationService.get(ITerminalService).foregroundInstances as ITerminalInstance[]).length = 0; + }); + test('should handle disposal of non-existent session gracefully', () => { strictEqual(runInTerminalTool.sessionTerminalAssociations.size, 0, 'No associations should exist initially'); chatServiceDisposeEmitter.fire({ sessionResources: [LocalChatSessionUri.forSession('non-existent-session')], reason: 'cleared' }); From 16c0598ca1d55f4dc1bb447ccb9837b892593392 Mon Sep 17 00:00:00 2001 From: Alexandru Dima Date: Sat, 20 Jun 2026 01:15:46 +0200 Subject: [PATCH 14/25] smoke: fix flaky Agents Window Copilot CLI session activation (#322170) The "Agents Window > Test Copilot CLI session" smoke test flakily timed out at `activateSessionByLabel`, which located the just-completed session row by its title and expected it to equal the mocked reply `MOCKED_COPILOT_RESPONSE`. That title is set asynchronously by a utility model after the first turn and races the untitled->committed session swap, so it is non-deterministically either the user's prompt (the synchronous fallback) or the generated reply. The test passed on macOS (title-gen landed) and failed on Linux (it didn't) in the same run. Decouple row identification from response verification: `activateSessionByLabel` now accepts one or several row substrings and matches a row containing ANY of them, while a separate `responseLabel` is verified against the active session's response bubble. The smoke test passes both the first prompt and the reply, so activation is deterministic regardless of when title generation completes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/automation/src/agentsWindow.ts | 55 +++++++++++++------ .../areas/agentsWindow/agentsWindow.test.ts | 16 ++++-- 2 files changed, 50 insertions(+), 21 deletions(-) diff --git a/test/automation/src/agentsWindow.ts b/test/automation/src/agentsWindow.ts index 78ef507bc63..9070add0243 100644 --- a/test/automation/src/agentsWindow.ts +++ b/test/automation/src/agentsWindow.ts @@ -262,8 +262,13 @@ export class AgentsWindow { * would land in the untitled session and the follow-up never reaches * the intended conversation. When the check fails the active session is * re-activated and the prompt is re-typed before sending. + * + * `activeRowMatch` (defaulting to `expectedActiveLabel`) is forwarded to + * {@link activateSessionByLabel} to locate the row on re-activation; pass + * both the first prompt and the response so row matching is robust against + * the asynchronously generated session title (see that method's docs). */ - async sendFollowUpMessage(prompt: string, sendButtonRetryCount: number = 600, expectedActiveLabel?: string): Promise { + async sendFollowUpMessage(prompt: string, sendButtonRetryCount: number = 600, expectedActiveLabel?: string, activeRowMatch?: string | string[]): Promise { const typeAndSend = async () => { await this.code.waitForElement(ACTIVE_SESSION_INPUT_EDITOR); await this.code.waitAndClick(ACTIVE_SESSION_INPUT_EDITOR); @@ -278,7 +283,7 @@ export class AgentsWindow { if (!stillActive) { // The active slot swapped between activation and send. Re-bind // and re-type the prompt before sending. - await this.activateSessionByLabel(expectedActiveLabel); + await this.activateSessionByLabel(activeRowMatch ?? expectedActiveLabel, expectedActiveLabel); await typeAndSend(); } } @@ -302,11 +307,25 @@ export class AgentsWindow { * untitled session and spawn a brand new agent session instead of * continuing the existing conversation. * - * `label` should be a substring of the session row's text (typically the - * first response text from message 1, e.g. `MOCKED_COPILOT_RESPONSE`). - * We can't simply click the topmost row because the sessions list - * contains workspace folder group headers and historical sessions from - * prior runs. + * `rowMatch` is one (or several) substrings used to locate the row; a row + * matches when its text contains ANY of them. We can't simply click the + * topmost row because the sessions list contains workspace folder group + * headers and historical sessions from prior runs. + * + * Pass BOTH the user's first prompt and the expected response here. The + * row's text is the session title, which is auto-generated asynchronously + * by a utility model after the first turn: until that lands the title is + * the synchronous fallback (the user's prompt), and once it lands the + * title becomes the generated value (which, in the smoke mock, echoes the + * scenario reply because the title prompt embeds the tagged user message). + * Matching on the prompt alone is racy because the generated title can + * replace it; matching on the response alone is racy because the generated + * title may not have landed yet. Accepting either makes activation + * deterministic regardless of when the title generation completes. + * + * `responseLabel` (defaulting to the first `rowMatch` entry) is the text + * the just-completed conversation's response bubble must contain; it is + * verified in the active session view after the row is clicked. * * Returns once the active session has loaded and is ready for input. * @@ -330,37 +349,39 @@ export class AgentsWindow { * guarantees the chat widget has actually re-bound to the session we * intended to activate before the caller types a follow-up. */ - async activateSessionByLabel(label: string, timeoutMs: number = 30_000): Promise { + async activateSessionByLabel(rowMatch: string | string[], responseLabel?: string, timeoutMs: number = 30_000): Promise { const retryCount = Math.ceil(timeoutMs / 100); await this.code.waitForElement(SESSION_LIST_ROW, undefined, retryCount); const workingStatus = 'Working...'; const deadline = Date.now() + timeoutMs; - const needle = label.toLowerCase(); + const rowMatches = Array.isArray(rowMatch) ? rowMatch : [rowMatch]; + const rowNeedles = rowMatches.map(s => s.toLowerCase()); + const responseNeedle = (responseLabel ?? rowMatches[0]).toLowerCase(); const activeResponseSelector = `${ACTIVE_SESSION} .interactive-item-container.interactive-response .rendered-markdown`; let lastTexts: string[] = []; let lastActiveTexts: string[] = []; while (Date.now() < deadline) { const rows = await this.code.getElements(SESSION_LIST_ROW, /* recursive */ true); lastTexts = (rows ?? []).map(r => (r.textContent ?? '').trim()); - const matchIndex = lastTexts.findIndex(t => t.toLowerCase().includes(needle) && !t.includes(workingStatus)); + const matchIndex = lastTexts.findIndex(t => !t.includes(workingStatus) && rowNeedles.some(n => t.toLowerCase().includes(n))); if (matchIndex < 0) { await new Promise(r => setTimeout(r, 250)); continue; } const summary = lastTexts.map((t, i) => `[${i}] ${JSON.stringify(t.slice(0, 120))}`).join('\n'); - console.log(`[agentsWindow] activateSessionByLabel("${label}") clicking index ${matchIndex}; all rows:\n${summary}`); + console.log(`[agentsWindow] activateSessionByLabel(${JSON.stringify(rowMatches)}) clicking index ${matchIndex}; all rows:\n${summary}`); await this.code.waitAndClick(`${SESSION_LIST_ROW}[data-index="${matchIndex}"]`); await this.code.waitForElement(ACTIVE_SESSION_INPUT_EDITOR, undefined, retryCount); // Wait until the active session view's chat widget actually shows a - // response matching `label`. A bare `is-active` check is not enough - // because the workbench may auto-create a fresh untitled session - // and route it into the active slot between row-render and click. + // response matching `responseLabel`. A bare `is-active` check is not + // enough because the workbench may auto-create a fresh untitled + // session and route it into the active slot between row-render and click. while (Date.now() < deadline) { const responses = await this.code.getElements(activeResponseSelector, /* recursive */ true); lastActiveTexts = (responses ?? []).map(el => (el.textContent ?? '').trim()); - if (lastActiveTexts.some(t => t.toLowerCase().includes(needle))) { + if (lastActiveTexts.some(t => t.toLowerCase().includes(responseNeedle))) { return; } await new Promise(r => setTimeout(r, 250)); @@ -368,10 +389,10 @@ export class AgentsWindow { const activeSummary = lastActiveTexts.length ? lastActiveTexts.map((t, i) => ` [${i}] ${JSON.stringify(t.slice(0, 120))}`).join('\n') : ' (no response bubbles in active session view)'; - throw new Error(`Activated row index ${matchIndex} but the active session view never rendered a response containing "${label}". Active view responses:\n${activeSummary}`); + throw new Error(`Activated row index ${matchIndex} but the active session view never rendered a response containing "${responseLabel ?? rowMatches[0]}". Active view responses:\n${activeSummary}`); } const summary = lastTexts.map((t, i) => ` [${i}] ${JSON.stringify(t.slice(0, 120))}`).join('\n'); - throw new Error(`Timed out waiting for a settled session list row containing "${label}" (without "${workingStatus}"). Last-seen rows:\n${summary}`); + throw new Error(`Timed out waiting for a settled session list row containing any of ${JSON.stringify(rowMatches)} (without "${workingStatus}"). Last-seen rows:\n${summary}`); } /** diff --git a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts index a9aa87a33c1..33d43d8df5e 100644 --- a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts +++ b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts @@ -215,8 +215,9 @@ export function setup(logger: Logger) { await app.workbench.agentsWindow.selectSessionType(session.name); const requestsBefore = mockServer.requestCount(); + const firstPrompt = `hello world [scenario:${session.scenarioId}]`; logger.log(`[Agents Window/${session.name}] submitting prompt; requestCount=${requestsBefore}`); - await app.workbench.agentsWindow.submitNewSessionPrompt(`hello world [scenario:${session.scenarioId}]`); + await app.workbench.agentsWindow.submitNewSessionPrompt(firstPrompt); logger.log(`[Agents Window/${session.name}] prompt submitted; waiting for assistant text '${session.reply}'; requestCount=${mockServer.requestCount()}`); const text = await app.workbench.agentsWindow.waitForAssistantText(session.reply); @@ -229,10 +230,15 @@ export function setup(logger: Logger) { // than continuing the existing one. Click back into the // just-completed session before sending message 2 so the // follow-up lands in the same session. Identify the row by - // its msg1 reply text since the sessions list also contains - // workspace folder group headers and historical sessions. + // EITHER the first prompt or the msg1 reply: the row text is + // the session title, which starts as the prompt (synchronous + // fallback) and is asynchronously replaced by a generated + // title (the reply, in the mock). Matching either avoids a + // race on when title generation lands. The sessions list also + // contains workspace folder group headers and historical + // sessions, so we can't just click the topmost row. if (session.name === 'Copilot CLI') { - await app.workbench.agentsWindow.activateSessionByLabel(session.reply); + await app.workbench.agentsWindow.activateSessionByLabel([firstPrompt, session.reply], session.reply); } if (!session.skipReply2) { @@ -244,10 +250,12 @@ export function setup(logger: Logger) { // a fresh untitled session between `activateSessionByLabel` // returning and the send-button click). const expectedActiveLabel = session.name === 'Copilot CLI' ? session.reply : undefined; + const activeRowMatch = session.name === 'Copilot CLI' ? [firstPrompt, session.reply] : undefined; await app.workbench.agentsWindow.sendFollowUpMessage( `hello again [scenario:${session.scenarioId2}]`, undefined, expectedActiveLabel, + activeRowMatch, ); const secondTurnTimeout = session.name === 'Copilot CLI' ? 180_000 : 60_000; From 8b458a5d616f30f0f7b46a5d3c4f7b4dfac79400 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:17:11 +0000 Subject: [PATCH 15/25] build(deps): bump undici from 7.26.0 to 7.28.0 (#322102) * build(deps): bump undici from 7.26.0 to 7.28.0 Bumps [undici](https://github.com/nodejs/undici) from 7.26.0 to 7.28.0. - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v7.26.0...v7.28.0) --- updated-dependencies: - dependency-name: undici dependency-version: 7.28.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] * fix: restore cpu-features entry in package-lock.json Co-authored-by: rzhao271 <7199958+rzhao271@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Martin Aeschlimann Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rzhao271 <7199958+rzhao271@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 084ff5e1eb2..22a8c783058 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,7 +66,7 @@ "playwright-core": "1.61.0-alpha-2026-06-04", "ssh2": "^1.16.0", "tas-client": "0.3.1", - "undici": "^7.24.0", + "undici": "^7.28.0", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", "vscode-textmate": "^9.3.2", @@ -18989,9 +18989,9 @@ "dev": true }, "node_modules/undici": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", - "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/package.json b/package.json index 7fbdd2f74f8..02ced79b320 100644 --- a/package.json +++ b/package.json @@ -150,7 +150,7 @@ "playwright-core": "1.61.0-alpha-2026-06-04", "ssh2": "^1.16.0", "tas-client": "0.3.1", - "undici": "^7.24.0", + "undici": "^7.28.0", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", "vscode-textmate": "^9.3.2", From 75d61a59aebb63af94d18e6967f803973656e3d4 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 19 Jun 2026 16:30:32 -0700 Subject: [PATCH 16/25] Update CLI agent commands for AHP 0.4 (#322173) AHP 0.4 moves turns/active_turn off SessionState onto per-chat ChatState (SessionState now carries a `chats` catalog), and SessionStatus is now a newtype bitset instead of a repr enum. - agent_ps: use SessionStatus::bits()/from_bits().contains() instead of `as u32` casts - agent_stop: resolve in-progress chats from the session catalog, subscribe to each chat to find its active turn, and dispatch the renamed StateAction::ChatTurnCancelled to the chat URI - agent_logs: print the session's chats catalog instead of the removed turns/active_turn Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/Cargo.lock | 8 ++-- cli/Cargo.toml | 4 +- cli/src/commands/agent_logs.rs | 33 ++++++------- cli/src/commands/agent_ps.rs | 17 +++---- cli/src/commands/agent_stop.rs | 84 ++++++++++++++++++++++------------ 5 files changed, 88 insertions(+), 58 deletions(-) diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 0d90b4474f1..710abb9e76f 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -19,9 +19,9 @@ dependencies = [ [[package]] name = "ahp" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ff1c5fceb94cf9e8a852eb84dd7f835827a7a2a07b8c23916d192075345e0ea" +checksum = "8fa7af01c6ff90f8b54fa169a71de21cc26e5a98621249ad882a5b2e137f57c0" dependencies = [ "ahp-types", "serde", @@ -33,9 +33,9 @@ dependencies = [ [[package]] name = "ahp-types" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b556a5958c99e73aee87d8e638baf648bc902ff0dabc00393522f8e6e88830b2" +checksum = "1632209b0398b17c4a9928d71eaad0ced329d3617ffcbe32be789f59b1d65c70" dependencies = [ "serde", "serde_json", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 2fa40b9b4a6..e939680aae6 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -56,8 +56,8 @@ console = "0.15.7" bytes = "1.11.1" tar = "0.4.46" local-ip-address = "0.6" -ahp = "0.3" -ahp-types = "0.3" +ahp = "0.4" +ahp-types = "0.4" [build-dependencies] serde = { version="1.0.163", features = ["derive"] } diff --git a/cli/src/commands/agent_logs.rs b/cli/src/commands/agent_logs.rs index 2116ed7e175..528598e9e8c 100644 --- a/cli/src/commands/agent_logs.rs +++ b/cli/src/commands/agent_logs.rs @@ -6,7 +6,7 @@ use ahp::SubscriptionEvent; use ahp_types::actions::StateAction; use ahp_types::commands::{SubscribeParams, SubscribeResult}; -use ahp_types::state::{SnapshotState, TurnState}; +use ahp_types::state::{SessionStatus, SnapshotState}; use console::Style; use crate::tunnels::shutdown_signal::ShutdownRequest; @@ -100,23 +100,24 @@ fn print_initial_state(uri: &str, result: &SubscribeResult) { println!(" {} {}", label.apply_to("activity:"), activity); } } - println!(" {} {}", label.apply_to("turns:"), session.turns.len()); + println!(" {} {}", label.apply_to("chats:"), session.chats.len()); - // Print a brief summary of past turns. - for turn in &session.turns { - let state_str = match turn.state { - TurnState::Complete => Styles::success().apply_to("✓"), - TurnState::Cancelled => Styles::warning().apply_to("⊘"), - TurnState::Error => Styles::error().apply_to("✗"), + // Print a brief summary of the chats in this session. + for chat in &session.chats { + let status = SessionStatus::from_bits(chat.status); + let marker = if status.contains(SessionStatus::InProgress) { + Style::new().green().bold().apply_to("►") + } else if status.contains(SessionStatus::Error) { + Styles::error().apply_to("✗") + } else { + Styles::muted().apply_to("○") }; - let msg = truncate(&turn.message.text, 80); - println!(" {} {}", state_str, Styles::muted().apply_to(msg)); - } - - // Print active turn if any. - if let Some(ref active) = session.active_turn { - let msg = truncate(&active.message.text, 80); - println!(" {} {}", Style::new().green().bold().apply_to("►"), msg); + let title = if chat.title.is_empty() { + "(untitled)".to_string() + } else { + truncate(&chat.title, 80) + }; + println!(" {} {}", marker, Styles::muted().apply_to(title)); } } diff --git a/cli/src/commands/agent_ps.rs b/cli/src/commands/agent_ps.rs index 95a0e3157e6..32afc2be451 100644 --- a/cli/src/commands/agent_ps.rs +++ b/cli/src/commands/agent_ps.rs @@ -61,9 +61,9 @@ pub async fn agent_ps(ctx: CommandContext, args: AgentPsArgs) -> Result bool { - let dominated = SessionStatus::IsRead as u32 - | SessionStatus::IsArchived as u32 - | SessionStatus::Idle as u32; + let dominated = SessionStatus::IsRead.bits() + | SessionStatus::IsArchived.bits() + | SessionStatus::Idle.bits(); status & !dominated != 0 } @@ -118,15 +118,16 @@ fn format_sessions_list(sessions: &[&SessionSummary]) -> String { } fn status_styled(status: u32) -> console::StyledObject { - if status & (SessionStatus::InputNeeded as u32) == (SessionStatus::InputNeeded as u32) { + let status = SessionStatus::from_bits(status); + if status.contains(SessionStatus::InputNeeded) { Styles::warning().apply_to("● input needed".to_string()) - } else if status & (SessionStatus::InProgress as u32) != 0 { + } else if status.contains(SessionStatus::InProgress) { Styles::success().apply_to("● in progress".to_string()) - } else if status & (SessionStatus::Error as u32) != 0 { + } else if status.contains(SessionStatus::Error) { Styles::error().apply_to("● error".to_string()) - } else if status & (SessionStatus::Idle as u32) != 0 { + } else if status.contains(SessionStatus::Idle) { Styles::muted().apply_to("○ idle".to_string()) } else { - Styles::muted().apply_to(format!("? unknown ({status})")) + Styles::muted().apply_to(format!("? unknown ({})", status.bits())) } } diff --git a/cli/src/commands/agent_stop.rs b/cli/src/commands/agent_stop.rs index c18c3123269..58e5915ead3 100644 --- a/cli/src/commands/agent_stop.rs +++ b/cli/src/commands/agent_stop.rs @@ -3,9 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -use ahp_types::actions::{SessionTurnCancelledAction, StateAction}; +use ahp_types::actions::{ChatTurnCancelledAction, StateAction}; use ahp_types::commands::{SubscribeParams, SubscribeResult}; -use ahp_types::state::SnapshotState; +use ahp_types::state::{SessionStatus, SnapshotState}; use crate::log; use crate::util::errors::{wrap, AnyError}; @@ -14,11 +14,12 @@ use super::agent; use super::args::AgentStopArgs; use super::CommandContext; -/// Cancels the active turn of a session on a running agent host. +/// Cancels the active turn of every in-progress chat in a session on a running +/// agent host. pub async fn agent_stop(ctx: CommandContext, args: AgentStopArgs) -> Result { let client = agent::connect(&ctx, args.address.as_deref(), args.tunnel.as_deref()).await?; - // Subscribe to the session to get its current state. + // Subscribe to the session to get its catalog of chats. let result: SubscribeResult = agent::request_with_auth( &ctx, &client, @@ -29,34 +30,61 @@ pub async fn agent_stop(ctx: CommandContext, args: AgentStopArgs) -> Result session.active_turn.map(|t| t.id), - _ => None, + // Turns live on individual chats now, so collect the chats that look active + // from the session catalog before drilling into each one. + let chat_uris: Vec = match result.snapshot.map(|s| s.state) { + Some(SnapshotState::Session(session)) => session + .chats + .into_iter() + .filter(|c| SessionStatus::from_bits(c.status).contains(SessionStatus::InProgress)) + .map(|c| c.resource) + .collect(), + _ => Vec::new(), }; - let turn_id = match turn_id { - Some(id) => id, - None => { - ctx.log.result("No active turn to cancel."); - client.shutdown().await; - return Ok(0); - } - }; - - debug!(ctx.log, "Cancelling turn {} on {}", turn_id, args.session); - - client - .dispatch( - args.session.clone(), - StateAction::SessionTurnCancelled(SessionTurnCancelledAction { - turn_id: turn_id.clone(), - }), + let mut cancelled = 0; + for chat_uri in chat_uris { + // Subscribe to the chat to find its active turn, if any. + let chat_result: SubscribeResult = agent::request_with_auth( + &ctx, + &client, + "subscribe", + SubscribeParams { + channel: chat_uri.clone(), + }, ) - .await - .map_err(|e| wrap(e, "Failed to dispatch turn cancellation"))?; + .await?; - ctx.log - .result(format!("Cancelled turn {turn_id} on {}", args.session)); + let turn_id = match chat_result.snapshot.map(|s| s.state) { + Some(SnapshotState::Chat(chat)) => chat.active_turn.map(|t| t.id), + _ => None, + }; + + let Some(turn_id) = turn_id else { + continue; + }; + + debug!(ctx.log, "Cancelling turn {} on {}", turn_id, chat_uri); + + client + .dispatch( + chat_uri.clone(), + StateAction::ChatTurnCancelled(ChatTurnCancelledAction { + turn_id: turn_id.clone(), + meta: None, + }), + ) + .await + .map_err(|e| wrap(e, "Failed to dispatch turn cancellation"))?; + + ctx.log + .result(format!("Cancelled turn {turn_id} on {chat_uri}")); + cancelled += 1; + } + + if cancelled == 0 { + ctx.log.result("No active turn to cancel."); + } client.shutdown().await; Ok(0) From 4e116aaf70a79973eda3d5182306bc9c5192e8b7 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:47:03 -0700 Subject: [PATCH 17/25] Gemini: Fix tool-call validation for Gemini flattened argument keys (#322165) * Gemini: Fix tool-call validation for Gemini flattened argument keys * Feedback updates --- .../tools/common/test/toolService.spec.ts | 178 ++++++++++++++++++ .../extension/tools/common/toolsService.ts | 113 +++++++++++ 2 files changed, 291 insertions(+) diff --git a/extensions/copilot/src/extension/tools/common/test/toolService.spec.ts b/extensions/copilot/src/extension/tools/common/test/toolService.spec.ts index 844fb0b74cc..ad59dee1b60 100644 --- a/extensions/copilot/src/extension/tools/common/test/toolService.spec.ts +++ b/extensions/copilot/src/extension/tools/common/test/toolService.spec.ts @@ -230,5 +230,183 @@ describe('Tool Service', () => { error: expect.stringContaining('ERROR: Your input to the tool was invalid') }); }); + + test('should reconstruct flattened path keys', () => { + const askQuestionsTool: vscode.LanguageModelToolInformation = { + name: 'askQuestionsTool', + description: 'A tool that expects an array of nested question objects', + inputSchema: { + type: 'object', + properties: { + questions: { + type: 'array', + items: { + type: 'object', + properties: { + header: { type: 'string' }, + question: { type: 'string' }, + allowFreeformInput: { type: 'boolean' }, + options: { + type: 'array', + items: { + type: 'object', + properties: { + label: { type: 'string' }, + description: { type: 'string' }, + recommended: { type: 'boolean' } + }, + required: ['label'] + } + } + }, + required: ['header', 'question'] + } + } + }, + required: ['questions'] + }, + tags: [], + source: undefined + }; + + (toolsService.tools as vscode.LanguageModelToolInformation[]).push(askQuestionsTool); + + // Gemini-style flattened path keys instead of a nested object/array. + const flattenedInput = JSON.stringify({ + 'questions[0].allowFreeformInput': true, + 'questions[0].header': 'repro_question_1', + 'questions[0].options[0].description': 'First option description', + 'questions[0].options[0].label': 'Option A', + 'questions[0].options[0].recommended': true, + 'questions[0].options[1].description': 'Second option description', + 'questions[0].options[1].label': 'Option B', + 'questions[0].question': 'Which option do you prefer?', + 'questions[1].allowFreeformInput': false, + 'questions[1].header': 'repro_question_2', + 'questions[1].options[0].label': 'Yes', + 'questions[1].options[1].label': 'No', + 'questions[1].question': 'Do you want to continue?' + }); + + const result = toolsService.validateToolInput('askQuestionsTool', flattenedInput); + expect(result).toEqual({ + inputObj: { + questions: [ + { + allowFreeformInput: true, + header: 'repro_question_1', + question: 'Which option do you prefer?', + options: [ + { description: 'First option description', label: 'Option A', recommended: true }, + { description: 'Second option description', label: 'Option B' } + ] + }, + { + allowFreeformInput: false, + header: 'repro_question_2', + question: 'Do you want to continue?', + options: [ + { label: 'Yes' }, + { label: 'No' } + ] + } + ] + } + }); + }); + + test('should not pollute prototype when reconstructing flattened keys', () => { + const pollutionTool: vscode.LanguageModelToolInformation = { + name: 'pollutionTool', + description: 'A tool whose flattened input contains unsafe property names', + inputSchema: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { value: { type: 'string' } } + } + }, + required: ['data'] + }, + tags: [], + source: undefined + }; + + (toolsService.tools as vscode.LanguageModelToolInformation[]).push(pollutionTool); + + const malicious = JSON.stringify({ + '__proto__.polluted': 'yes', + 'data.value': 'ok' + }); + + const result = toolsService.validateToolInput('pollutionTool', malicious); + + // The unsafe key makes reconstruction bail out, so validation fails + // rather than mutating Object.prototype. + expect(result).toMatchObject({ + error: expect.stringContaining('ERROR: Your input to the tool was invalid') + }); + expect(({} as Record).polluted).toBeUndefined(); + }); + + test('should bail out on conflicting flattened keys', () => { + const conflictTool: vscode.LanguageModelToolInformation = { + name: 'conflictTool', + description: 'A tool whose flattened input has conflicting paths', + inputSchema: { + type: 'object', + properties: { + a: { type: 'object' } + }, + required: ['a'] + }, + tags: [], + source: undefined + }; + + (toolsService.tools as vscode.LanguageModelToolInformation[]).push(conflictTool); + + // `a` is both a primitive and a parent of `a.b` — unresolvable. + const conflicting = JSON.stringify({ + 'a': 'primitive', + 'a.b': 'nested' + }); + + const result = toolsService.validateToolInput('conflictTool', conflicting); + expect(result).toMatchObject({ + error: expect.stringContaining('ERROR: Your input to the tool was invalid') + }); + }); + + test('should reject out-of-range array indices in flattened keys', () => { + const indexTool: vscode.LanguageModelToolInformation = { + name: 'indexTool', + description: 'A tool whose flattened input has an enormous array index', + inputSchema: { + type: 'object', + properties: { + items: { + type: 'array', + items: { type: 'string' } + } + }, + required: ['items'] + }, + tags: [], + source: undefined + }; + + (toolsService.tools as vscode.LanguageModelToolInformation[]).push(indexTool); + + // A huge index would create a massive sparse array; reconstruction + // must bail rather than produce one. + const huge = JSON.stringify({ 'items[999999999999]': 'value' }); + + const result = toolsService.validateToolInput('indexTool', huge); + expect(result).toMatchObject({ + error: expect.stringContaining('ERROR: Your input to the tool was invalid') + }); + }); }); }); diff --git a/extensions/copilot/src/extension/tools/common/toolsService.ts b/extensions/copilot/src/extension/tools/common/toolsService.ts index 4e66b020e33..2eb611f9b5b 100644 --- a/extensions/copilot/src/extension/tools/common/toolsService.ts +++ b/extensions/copilot/src/extension/tools/common/toolsService.ts @@ -130,6 +130,109 @@ function getObjectPropertyByPath(obj: any, jsonPointerPath: string): { parent: a return null; } +/** + * Property names that must never be used as path segments when reconstructing + * objects from untrusted tool input, to avoid prototype pollution. + */ +const UNSAFE_PROPERTY_NAMES = new Set(['__proto__', 'constructor', 'prototype']); + +/** + * Upper bound for array indices accepted when reconstructing flattened tool + * input. Caps the reconstructed array length to avoid huge sparse arrays from + * untrusted input (e.g. `items[999999999999]`) that would make subsequent Ajv + * validation pathologically slow. + */ +const MAX_FLATTENED_ARRAY_INDEX = 1000; + +/** + * Parses a flattened path key (e.g. `questions[0].options[1].label`) into an + * ordered list of segments (`['questions', 0, 'options', 1, 'label']`). Object + * properties are returned as strings and array indices as numbers. Returns + * `undefined` if the key is not a well-formed, contiguous path expression, if + * it contains an unsafe property name (e.g. `__proto__`), or if an array index + * exceeds {@link MAX_FLATTENED_ARRAY_INDEX}. + */ +function parseFlattenedPath(key: string): (string | number)[] | undefined { + const segments: (string | number)[] = []; + const re = /\.?([^.[\]]+)|\[(\d+)\]/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = re.exec(key)) !== null) { + // Bail if there is an unexpected character between tokens (e.g. `a..b`). + if (match.index !== lastIndex) { + return undefined; + } + if (match[2] !== undefined) { + const index = Number(match[2]); + // Reject out-of-range indices to avoid huge sparse arrays. + if (!Number.isSafeInteger(index) || index > MAX_FLATTENED_ARRAY_INDEX) { + return undefined; + } + segments.push(index); + } else { + // Reject prototype-pollution keys from untrusted tool input. + if (UNSAFE_PROPERTY_NAMES.has(match[1])) { + return undefined; + } + segments.push(match[1]); + } + lastIndex = re.lastIndex; + } + if (lastIndex !== key.length || segments.length === 0) { + return undefined; + } + return segments; +} + +/** + * Reconstructs a nested object/array structure from an object whose keys are + * flattened path expressions. Some models (notably Gemini) serialize nested + * tool-call arguments as flat keys like `questions[0].header` instead of a + * proper nested object. Returns `undefined` when none of the keys use path + * notation (so normal inputs are left untouched), when a key is malformed, or + * when keys conflict (e.g. both `a` and `a.b`). + */ +function tryUnflattenObject(obj: Record): Record | undefined { + const keys = Object.keys(obj); + if (!keys.some(key => /\.|\[\d+\]/.test(key))) { + return undefined; + } + + // Use null-prototype containers so untrusted keys cannot reach Object.prototype. + const result: Record = Object.create(null); + for (const key of keys) { + const path = parseFlattenedPath(key); + if (!path) { + return undefined; + } + + let current: any = result; + for (let i = 0; i < path.length - 1; i++) { + const segment = path[i]; + const nextSegment = path[i + 1]; + const child = current[segment]; + if (child === undefined) { + current[segment] = typeof nextSegment === 'number' ? [] : Object.create(null); + } else if (typeof child !== 'object' || child === null) { + // Conflicting keys (e.g. both `a` and `a.b`) would require + // overwriting a primitive with a container; bail out instead. + return undefined; + } + current = current[segment]; + } + + const leaf = path[path.length - 1]; + if (typeof current[leaf] === 'object' && current[leaf] !== null) { + // A container already exists at this leaf (e.g. both `a` and `a.b` + // where `a` is assigned last); refuse to clobber it. + return undefined; + } + current[leaf] = obj[key]; + } + + return result; +} + function ajvValidateForTool(toolName: string, fn: ValidateFunction, inputObj: unknown): IToolValidationResult { // Empty output can be valid when the schema only has optional properties if (fn(inputObj ?? {})) { @@ -168,6 +271,16 @@ function ajvValidateForTool(toolName: string, fn: ValidateFunction, inputObj: un } } + // Recovery: some models (notably Gemini) serialize nested arguments as + // flattened path keys like `questions[0].header` instead of nested + // objects/arrays. Reconstruct the nested structure and re-validate. + if (typeof inputObj === 'object' && inputObj !== null && !Array.isArray(inputObj)) { + const unflattened = tryUnflattenObject(inputObj as Record); + if (unflattened) { + return ajvValidateForTool(toolName, fn, unflattened); + } + } + const errors = fn.errors!.map(e => e.message || `${e.instancePath} is invalid}`); return { error: `ERROR: Your input to the tool was invalid (${errors.join(', ')})` }; } From 8d8f1028df37be745a244f27fb300ed12272fa57 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 19 Jun 2026 20:07:44 -0400 Subject: [PATCH 18/25] Integrate voice mode panel with chat input and improve floating window (#321957) --- .../browser/agentsVoice.contribution.ts | 236 ++++++++++- .../browser/agentsVoiceSessionsPicker.ts | 101 +++++ .../agentsVoice/browser/agentsVoiceWidget.ts | 400 ++++++++++++++++-- .../browser/agentsVoiceWindowService.ts | 188 +++----- .../browser/components/headerComponent.ts | 29 +- .../components/sessionListComponent.ts | 4 - .../browser/components/transcriptComponent.ts | 24 +- .../contrib/agentsVoice/common/agentsVoice.ts | 4 +- .../browser/actions/chatExecuteActions.ts | 7 + .../browser/voiceClient/ttsPlaybackService.ts | 5 + .../voiceClient/voiceSessionController.ts | 197 ++++++++- .../voiceClient/voiceToolDispatchService.ts | 11 + .../chat/browser/widget/media/chat.css | 19 + .../widgetHosts/viewPane/chatViewPane.ts | 316 ++++++++------ .../viewPane/media/chatViewPane.css | 15 +- .../actions/voiceChatActions.ts | 4 +- 16 files changed, 1205 insertions(+), 355 deletions(-) create mode 100644 src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceSessionsPicker.ts diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts index 6d070f141f0..d3283240a1c 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts @@ -21,9 +21,11 @@ import '../common/voiceTranscriptStore.js'; import './transcriptsView/voiceTranscripts.contribution.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import * as nls from '../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; import { Extensions as ConfigurationExtensions, ConfigurationScope, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; @@ -31,6 +33,7 @@ import { KeybindingWeight } from '../../../../platform/keybinding/common/keybind import { Registry } from '../../../../platform/registry/common/platform.js'; import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; + import { IAgentsVoiceWindowService, AgentsVoiceStorageKeys } from '../common/agentsVoice.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; @@ -39,11 +42,18 @@ import { VoiceDisabledClassification, VoiceDisabledEvent, } from '../../chat/browser/voiceClient/voiceTelemetry.js'; import { mainWindow } from '../../../../base/browser/window.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; +import { ChatAgentLocation } from '../../chat/common/constants.js'; // --- Context Keys --- const AGENTS_VOICE_WINDOW_VISIBLE = new RawContextKey('agentsVoiceWindowVisible', false); export const AGENTS_VOICE_WIDGET_FOCUSED = new RawContextKey('agentsVoiceWidgetFocused', false); +const AGENTS_VOICE_CONNECTED = new RawContextKey('agentsVoiceConnected', false); +const AGENTS_VOICE_CONNECTING = new RawContextKey('agentsVoiceConnecting', false); +const AGENTS_VOICE_LISTENING = new RawContextKey('agentsVoiceListening', false); +const AGENTS_VOICE_ACTIVE = new RawContextKey('agentsVoiceActive', false); // --- Context Key Binding --- @@ -57,17 +67,45 @@ class AgentsVoiceContextKeyContribution extends Disposable implements IWorkbench ) { super(); - const contextKey = AGENTS_VOICE_WINDOW_VISIBLE.bindTo(contextKeyService); - contextKey.set(this.agentsVoiceWindowService.isOpen); + const windowKey = AGENTS_VOICE_WINDOW_VISIBLE.bindTo(contextKeyService); + windowKey.set(this.agentsVoiceWindowService.isOpen); this._register(this.agentsVoiceWindowService.onDidChangeOpen(isOpen => { - contextKey.set(isOpen); + windowKey.set(isOpen); })); } } registerWorkbenchContribution2(AgentsVoiceContextKeyContribution.ID, AgentsVoiceContextKeyContribution, WorkbenchPhase.AfterRestored); +// Separate contribution for voice connected state — runs later to avoid +// forcing IVoiceSessionController instantiation too early. +class AgentsVoiceConnectedKeyContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.agentsVoiceConnectedKey'; + + constructor( + @IVoiceSessionController voiceSessionController: IVoiceSessionController, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(); + + const connectedKey = AGENTS_VOICE_CONNECTED.bindTo(contextKeyService); + const connectingKey = AGENTS_VOICE_CONNECTING.bindTo(contextKeyService); + const listeningKey = AGENTS_VOICE_LISTENING.bindTo(contextKeyService); + const activeKey = AGENTS_VOICE_ACTIVE.bindTo(contextKeyService); + this._register(autorun(reader => { + connectedKey.set(voiceSessionController.isConnected.read(reader)); + connectingKey.set(voiceSessionController.isConnecting.read(reader)); + const state = voiceSessionController.voiceState.read(reader); + listeningKey.set(state === 'listening'); + activeKey.set(state === 'listening' || state === 'speaking'); + })); + } +} + +registerWorkbenchContribution2(AgentsVoiceConnectedKeyContribution.ID, AgentsVoiceConnectedKeyContribution, WorkbenchPhase.Eventually); + // --- Telemetry: track enable/disable --- class AgentsVoiceTelemetryContribution extends Disposable implements IWorkbenchContribution { @@ -108,12 +146,22 @@ registerAction2(class extends Action2 { super({ id: 'agentsVoice.toggleWindow', title: nls.localize2('toggleAgentsVoiceWindow', "Voice Mode"), - menu: { + icon: Codicon.openInWindow, + menu: [{ id: MenuId.MenubarViewMenu, group: '5_copilot', order: 1, when: ContextKeyExpr.equals('config.agents.voice.enabled', true), - }, + }, { + id: MenuId.ChatExecute, + when: ContextKeyExpr.and( + ContextKeyExpr.equals('config.agents.voice.enabled', true), + AGENTS_VOICE_CONNECTED.isEqualTo(true), + ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), + ), + group: 'navigation', + order: 6 + }], toggled: AGENTS_VOICE_WINDOW_VISIBLE.isEqualTo(true), }); } @@ -123,6 +171,172 @@ registerAction2(class extends Action2 { } }); +// Internal command: open the floating window without toggling (used by voice +// controller to surface responses for non-visible sessions). +CommandsRegistry.registerCommand('_agentsVoice.openWindow', async (accessor) => { + const service = accessor.get(IAgentsVoiceWindowService); + if (!service.isOpen) { + await service.openWindow(); + } +}); + +// --- Mic button in Chat toolbar --- +// Shows mic (unfilled) normally, mic-filled when actively listening. +// Click to connect if disconnected, or toggle PTT if connected. +// The disconnect button (shown when connected) indicates active voice mode. + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'agentsVoice.connecting', + title: nls.localize2('agentsVoice.connecting', "Connecting..."), + icon: Codicon.loading, + precondition: ContextKeyExpr.and( + ContextKeyExpr.equals('config.agents.voice.enabled', true), + AGENTS_VOICE_CONNECTING.isEqualTo(true), + ), + menu: { + id: MenuId.ChatExecute, + when: ContextKeyExpr.and( + ContextKeyExpr.equals('config.agents.voice.enabled', true), + ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), + AGENTS_VOICE_CONNECTING.isEqualTo(true), + ), + group: 'navigation', + order: 4 + } + }); + } + async run(): Promise { + // No-op — just a visual indicator + } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'agentsVoice.startVoiceInChat', + title: nls.localize2('agentsVoice.startVoiceInChat', "Voice Mode"), + icon: Codicon.mic, + precondition: ContextKeyExpr.equals('config.agents.voice.enabled', true), + menu: { + id: MenuId.ChatExecute, + when: ContextKeyExpr.and( + ContextKeyExpr.equals('config.agents.voice.enabled', true), + ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), + AGENTS_VOICE_ACTIVE.negate(), + AGENTS_VOICE_CONNECTING.negate(), + ), + group: 'navigation', + order: 4 + }, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Space, + when: ContextKeyExpr.and( + ContextKeyExpr.equals('config.agents.voice.enabled', true), + ChatContextKeys.inChatInput, + ), + }, + }); + } + async run(accessor: ServicesAccessor): Promise { + const voiceController = accessor.get(IVoiceSessionController); + if (!voiceController.isConnected.get()) { + await voiceController.connect(mainWindow); + } else { + voiceController.pttDown(); + voiceController.pttUp(); + } + } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'agentsVoice.pttStopInChat', + title: nls.localize2('agentsVoice.pttStopInChat', "Voice Mode: Stop Recording"), + icon: Codicon.micFilled, + precondition: ContextKeyExpr.and( + ContextKeyExpr.equals('config.agents.voice.enabled', true), + AGENTS_VOICE_ACTIVE.isEqualTo(true), + ), + menu: { + id: MenuId.ChatExecute, + when: ContextKeyExpr.and( + ContextKeyExpr.equals('config.agents.voice.enabled', true), + ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), + AGENTS_VOICE_ACTIVE.isEqualTo(true), + ), + group: 'navigation', + order: 4 + }, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Space, + when: ContextKeyExpr.and( + ContextKeyExpr.equals('config.agents.voice.enabled', true), + ChatContextKeys.inChatInput, + AGENTS_VOICE_ACTIVE.isEqualTo(true), + ), + }, + }); + } + async run(accessor: ServicesAccessor): Promise { + const voiceController = accessor.get(IVoiceSessionController); + // Stop recording and send + voiceController.pttDown(); + voiceController.pttUp(); + } +}); + +// --- Disconnect Voice (command palette + separate toolbar button when connected) --- + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'agentsVoice.disconnect', + title: nls.localize2('agentsVoice.disconnect', "Disconnect Voice Mode"), + icon: Codicon.debugDisconnect, + f1: true, + precondition: ContextKeyExpr.and( + ContextKeyExpr.equals('config.agents.voice.enabled', true), + AGENTS_VOICE_CONNECTED.isEqualTo(true), + ), + menu: { + id: MenuId.ChatExecute, + when: ContextKeyExpr.and( + ContextKeyExpr.equals('config.agents.voice.enabled', true), + AGENTS_VOICE_CONNECTED.isEqualTo(true), + ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), + ), + group: 'navigation', + order: 5 + }, + }); + } + async run(accessor: ServicesAccessor): Promise { + const voiceController = accessor.get(IVoiceSessionController); + voiceController.disconnect(); + } +}); + +// --- Simulate Voice Connection (dev utility, backend down) --- + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'agentsVoice.simulateConnection', + title: nls.localize2('agentsVoice.simulateConnection', "Voice: Simulate Connection (Dev)"), + f1: true, + }); + } + async run(accessor: ServicesAccessor): Promise { + const voiceController = accessor.get(IVoiceSessionController); + voiceController.simulateConnection(); + } +}); + // --- Reset Onboarding Command (dev utility) --- registerAction2(class extends Action2 { @@ -150,7 +364,7 @@ registerAction2(class extends Action2 { precondition: ContextKeyExpr.equals('config.agents.voice.enabled', true), keybinding: { weight: KeybindingWeight.WorkbenchContrib, - primary: KeyCode.Space, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Space, when: ContextKeyExpr.and( AGENTS_VOICE_WIDGET_FOCUSED, ContextKeyExpr.not('inputFocus'), @@ -208,5 +422,13 @@ configurationRegistry.registerConfiguration({ scope: ConfigurationScope.APPLICATION, included: false, }, + 'agents.voice.showTranscript': { + type: 'boolean', + description: nls.localize('agents.voice.showTranscript', "Show the voice transcript overlay in the chat input area while voice mode is active."), + default: true, + scope: ConfigurationScope.APPLICATION, + included: false, + tags: ['advanced'], + }, } }); diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceSessionsPicker.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceSessionsPicker.ts new file mode 100644 index 00000000000..4415a525765 --- /dev/null +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceSessionsPicker.ts @@ -0,0 +1,101 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize } from '../../../../nls.js'; +import { IQuickInputButton, IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; +import { IAgentSession } from '../../chat/browser/agentSessions/agentSessionsModel.js'; +import { IAgentSessionsService } from '../../chat/browser/agentSessions/agentSessionsService.js'; +import { AgentSessionsSorter, groupAgentSessionsByDate } from '../../chat/browser/agentSessions/agentSessionsViewer.js'; +import { getSessionDescription, shouldShowSessionInPicker } from '../../chat/browser/agentSessions/agentSessionsPicker.js'; +import { AgentSessionsFilter } from '../../chat/browser/agentSessions/agentSessionsFilter.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { URI } from '../../../../base/common/uri.js'; + +interface IVoiceSessionPickItem extends IQuickPickItem { + readonly session: IAgentSession; +} + +const setTargetButton: IQuickInputButton = { + iconClass: ThemeIcon.asClassName(Codicon.mic), + tooltip: localize('voiceSessions.setTarget', "Set as voice target") +}; + +/** + * A quickpick that lists agent sessions and allows the user to select one + * as the voice transcription target. Mirrors the pattern of AgentSessionsPicker + * but with a voice-specific action. + */ +export class AgentsVoiceSessionsPicker { + + private readonly sorter = new AgentSessionsSorter(); + + constructor( + private readonly onSelectTarget: (resource: URI) => void, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IQuickInputService private readonly quickInputService: IQuickInputService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { } + + async show(): Promise { + const disposables = new DisposableStore(); + const picker = disposables.add(this.quickInputService.createQuickPick({ useSeparators: true })); + const filter = disposables.add(this.instantiationService.createInstance(AgentSessionsFilter, {})); + + picker.items = this.createPickerItems(filter); + picker.placeholder = localize('voiceSessions.placeholder', "Select a session for voice input"); + + disposables.add(picker.onDidAccept(() => { + const pick = picker.selectedItems[0]; + if (pick) { + this.onSelectTarget(pick.session.resource); + } + picker.hide(); + })); + + disposables.add(picker.onDidTriggerItemButton(e => { + if (e.button === setTargetButton) { + this.onSelectTarget(e.item.session.resource); + picker.hide(); + } + })); + + disposables.add(picker.onDidHide(() => disposables.dispose())); + picker.show(); + } + + private createPickerItems(filter: AgentSessionsFilter): (IVoiceSessionPickItem | IQuickPickSeparator)[] { + const sessions = this.agentSessionsService.model.sessions + .filter(session => shouldShowSessionInPicker(session, filter)) + .sort(this.sorter.compare.bind(this.sorter)); + const items: (IVoiceSessionPickItem | IQuickPickSeparator)[] = []; + + const groupedSessions = groupAgentSessionsByDate(sessions); + for (const group of groupedSessions.values()) { + if (group.sessions.length > 0) { + items.push({ type: 'separator', label: group.label }); + items.push(...group.sessions.map(session => this.toPickItem(session))); + } + } + + return items; + } + + private toPickItem(session: IAgentSession): IVoiceSessionPickItem { + const description = getSessionDescription(session); + + return { + id: session.resource.toString(), + label: session.label, + tooltip: session.tooltip, + description, + iconClass: ThemeIcon.asClassName(session.icon), + buttons: [setTargetButton], + session + }; + } +} diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts index 48cbd17e8a9..6bb8ca63c1d 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts @@ -17,7 +17,7 @@ import { createSessionList, type SessionRowData, type SessionGroupData } from '. import { createFeedbackDialog, type FeedbackDialogState } from './components/feedbackDialog.js'; import { createOnboarding } from './components/onboardingComponent.js'; import { createVoiceBar } from './components/voiceBarComponent.js'; -import { FONT_SIZE } from './components/tokens.js'; +import { FONT_SIZE, addKeyboardActivation } from './components/tokens.js'; import type { VoiceState, IPendingToolConfirmation, ITranscriptTurn } from '../../chat/browser/voiceClient/voiceSessionController.js'; export interface VoiceWidgetCallbacks { @@ -44,6 +44,12 @@ export interface VoiceWidgetCallbacks { submitFeedback(feedbackText: string): Promise<{ ok: boolean; error?: string }>; /** Called when the user dismisses the onboarding card. */ onOnboardingCompleted?(): void; + /** + * Optional — when provided, the expand chevron opens this picker instead of + * the inline session list. Used by the floating window to show the agent + * sessions quickpick with a "set as voice target" action. + */ + showSessionsPicker?(): void; } /** @@ -92,6 +98,13 @@ export interface VoiceWidgetOptions { * (collapsed) to match the legacy floating aux-window behavior. */ readonly defaultExpanded?: boolean; + /** + * When true, renders the widget in a chat-input-box style layout: + * a rounded bordered container for transcript/placeholder text with a + * toolbar row below for action icons. Matches the chat panel input box + * appearance. + */ + readonly inputBoxLayout?: boolean; } const DEFAULT_OPTIONS: Required = { @@ -109,6 +122,7 @@ const DEFAULT_OPTIONS: Required = { showOnboarding: false, reshowOnboardingOnDisconnect: false, defaultExpanded: false, + inputBoxLayout: false, }; export class AgentsVoiceWidget extends Disposable { @@ -159,6 +173,17 @@ export class AgentsVoiceWidget extends Disposable { private readonly _chevronWrapper: HTMLElement; private readonly _chevronIcon: HTMLElement; + // --- Input box layout elements (created only when inputBoxLayout=true) --- + private readonly _inputBoxContainer: HTMLElement | undefined; + private readonly _inputBoxPlaceholder: HTMLElement | undefined; + private readonly _inputBoxToolbar: HTMLElement | undefined; + private readonly _inputBoxMicBtn: HTMLElement | undefined; + private readonly _inputBoxGearBtn: HTMLElement | undefined; + private readonly _inputBoxConnIndicator: HTMLElement | undefined; + private readonly _inputBoxFeedbackBtn: HTMLElement | undefined; + private readonly _inputBoxSessionsBtn: HTMLElement | undefined; + private readonly _inputBoxCloseBtn: HTMLElement | undefined; + private readonly _options: Required; constructor( @@ -176,10 +201,10 @@ export class AgentsVoiceWidget extends Disposable { const opts = this._options; const widthStyle = opts.width === 'auto' ? 'width:100%;position:relative;' - : `position:absolute;top:0;left:0;width:${opts.width}px;min-height:${AGENTS_VOICE_WINDOW_DEFAULT_HEIGHT}px;`; + : `position:absolute;top:0;left:0;width:${opts.width}px;${opts.inputBoxLayout ? '' : `min-height:${AGENTS_VOICE_WINDOW_DEFAULT_HEIGHT}px;`}`; this._rootDiv = dom.$('div'); - this._rootDiv.style.cssText = `${widthStyle}display:flex;flex-direction:column;user-select:none;font-family:inherit;font-size:${FONT_SIZE.base};color:var(--vscode-foreground);box-sizing:border-box;margin:0;`; + this._rootDiv.style.cssText = `${widthStyle}display:flex;flex-direction:column;user-select:none;font-family:inherit;font-size:${FONT_SIZE.base};color:var(--vscode-foreground);box-sizing:border-box;margin:0;${opts.inputBoxLayout && opts.draggable ? '-webkit-app-region:drag;' : ''}`; this._glowDiv = dom.$('div'); this._glowDiv.style.cssText = 'position:absolute;top:0;left:0;right:0;height:50px;pointer-events:none;z-index:0;'; @@ -206,7 +231,7 @@ export class AgentsVoiceWidget extends Disposable { this._statusTextDiv.style.cssText = `text-align:center;font-size:${FONT_SIZE.body};font-weight:500;color:var(--vscode-foreground);padding:2px 0;`; this._sessionListWrapper = dom.$('div'); - this._sessionListWrapper.style.cssText = 'display:flex;flex-direction:column;'; + this._sessionListWrapper.style.cssText = 'display:flex;flex-direction:column;-webkit-app-region:no-drag;overflow:hidden;'; this._sessionListWrapper.append(this._sessionListComponent.element); this._expandSpacer = dom.$('div'); @@ -218,27 +243,155 @@ export class AgentsVoiceWidget extends Disposable { this._chevronWrapper.style.cssText = 'display:flex;justify-content:center;cursor:pointer;-webkit-app-region:no-drag;'; this._chevronIcon = dom.$('span.codicon'); this._chevronIcon.style.cssText = `font-size:${FONT_SIZE.iconSm};color:var(--vscode-descriptionForeground);`; - this._chevronIcon.addEventListener('mouseenter', () => { this._chevronIcon.style.color = 'var(--vscode-foreground)'; }); - this._chevronIcon.addEventListener('mouseleave', () => { this._chevronIcon.style.color = 'var(--vscode-descriptionForeground)'; }); + this._register(dom.addDisposableListener(this._chevronIcon, 'mouseenter', () => { this._chevronIcon.style.color = 'var(--vscode-foreground)'; })); + this._register(dom.addDisposableListener(this._chevronIcon, 'mouseleave', () => { this._chevronIcon.style.color = 'var(--vscode-descriptionForeground)'; })); this._chevronWrapper.append(this._chevronIcon); - this._chevronWrapper.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this._expanded.set(!this._expanded.get(), undefined); }); - this._chevronWrapper.addEventListener('keydown', (e) => { + this._register(dom.addDisposableListener(this._chevronWrapper, 'click', (e) => { + e.preventDefault(); e.stopPropagation(); + if (this.callbacks.showSessionsPicker) { + this.callbacks.showSessionsPicker(); + } else { + this._expanded.set(!this._expanded.get(), undefined); + } + })); + this._register(dom.addDisposableListener(this._chevronWrapper, 'keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this._chevronWrapper.click(); } - }); + })); + + // --- Input box layout elements --- + if (opts.inputBoxLayout) { + // Inject processing animation CSS into the document head + // (@property must be at document level to work) + const styleEl = dom.$('style'); + styleEl.textContent = ` + @property --voice-processing-angle { syntax: ''; inherits: false; initial-value: 135deg; } + @keyframes voice-processing-spin { from { --voice-processing-angle: 135deg; } to { --voice-processing-angle: 495deg; } } + .processing { overflow: visible !important; } + .processing::before { + content: ''; position: absolute; inset: -1px; border-radius: inherit; padding: 1px; + background: conic-gradient(from var(--voice-processing-angle), + transparent 0deg, rgba(88,166,255,0.9) 20deg, rgba(88,166,255,1) 30deg, + rgba(88,166,255,0.6) 50deg, transparent 90deg, transparent 360deg); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; mask-composite: exclude; + animation: voice-processing-spin 3s linear infinite; + pointer-events: none; z-index: 2; + } + .processing::after { + content: ''; position: absolute; inset: -1px; border-radius: inherit; padding: 2px; + background: conic-gradient(from var(--voice-processing-angle), + transparent 0deg, rgba(88,166,255,0.5) 25deg, rgba(88,166,255,0.3) 50deg, transparent 90deg, transparent 360deg); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; mask-composite: exclude; + filter: blur(1.5px); animation: voice-processing-spin 3s linear infinite; + pointer-events: none; z-index: 1; + } + `; + getWindow(this.container).document.head.append(styleEl); + + // Rounded bordered container for transcript/placeholder (matches chat-input-container) + this._inputBoxContainer = dom.$('div'); + this._inputBoxContainer.style.cssText = 'box-sizing:border-box;background-color:var(--vscode-input-background);border:1px solid var(--vscode-input-border, transparent);border-radius:var(--vscode-cornerRadius-large, 8px);padding:10px 12px;width:100%;position:relative;min-height:32px;display:flex;align-items:center;-webkit-app-region:no-drag;'; + + this._inputBoxPlaceholder = dom.$('span'); + this._inputBoxPlaceholder.style.cssText = `font-size:${FONT_SIZE.body};color:var(--vscode-input-placeholderForeground, var(--vscode-descriptionForeground));user-select:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;`; + this._inputBoxContainer.append(this._inputBoxPlaceholder); + + // Toolbar row below the input box + this._inputBoxToolbar = dom.$('div'); + this._inputBoxToolbar.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 4px 2px;-webkit-app-region:no-drag;'; + + const toolbarBtn = (className: string, ariaLabel: string, title: string): HTMLElement => { + const el = dom.$(`span.codicon.${className}`); + el.role = 'button'; + el.tabIndex = 0; + el.ariaLabel = ariaLabel; + el.title = title; + el.style.cssText = `font-size:${FONT_SIZE.iconSm};color:var(--vscode-descriptionForeground);cursor:pointer;-webkit-app-region:no-drag;padding:2px;`; + this._register(dom.addDisposableListener(el, 'mouseenter', () => { el.style.color = 'var(--vscode-foreground)'; })); + this._register(dom.addDisposableListener(el, 'mouseleave', () => { el.style.color = 'var(--vscode-descriptionForeground)'; })); + addKeyboardActivation(el); + return el; + }; + + // Mic button + this._inputBoxMicBtn = dom.$('span.codicon.codicon-mic'); + this._inputBoxMicBtn.role = 'button'; + this._inputBoxMicBtn.tabIndex = 0; + this._inputBoxMicBtn.ariaLabel = localize('agentsVoice.pushToTalkSpace', "Push to talk (Space)"); + this._inputBoxMicBtn.title = localize('agentsVoice.pushToTalkSpace', "Push to talk (Space)"); + this._inputBoxMicBtn.style.cssText = `font-size:${FONT_SIZE.iconMd};cursor:pointer;-webkit-app-region:no-drag;border-radius:4px;padding:2px;`; + + // Connection indicator + this._inputBoxConnIndicator = toolbarBtn('codicon-debug-connected', + localize('agentsVoice.disconnect', "Disconnect"), + localize('agentsVoice.disconnect', "Disconnect")); + + // Gear button + this._inputBoxGearBtn = toolbarBtn('codicon-gear', + localize('agentsVoice.configureKeybinding', "Configure keybinding"), + localize('agentsVoice.configureKeybinding', "Configure keybinding")); + + // Feedback button + this._inputBoxFeedbackBtn = toolbarBtn('codicon-feedback', + localize('agentsVoice.sendFeedback', "Send feedback"), + localize('agentsVoice.sendFeedback', "Send feedback")); + + // Sessions dropdown button + this._inputBoxSessionsBtn = toolbarBtn('codicon-list-tree', + localize('agentsVoice.sessions', "Sessions"), + localize('agentsVoice.sessions', "Sessions")); + this._register(dom.addDisposableListener(this._inputBoxSessionsBtn, 'click', (e) => { + e.preventDefault(); e.stopPropagation(); + this._expanded.set(!this._expanded.get(), undefined); + })); + + // Close button + this._inputBoxCloseBtn = toolbarBtn('codicon-chrome-minimize', + localize('agentsVoice.minimize', "Minimize"), + localize('agentsVoice.minimize', "Minimize")); + + const toolbarSpacer = dom.$('span'); + toolbarSpacer.style.flex = '1'; + + this._inputBoxToolbar.append( + this._inputBoxMicBtn, + this._inputBoxConnIndicator, + this._inputBoxGearBtn, + toolbarSpacer, + this._inputBoxFeedbackBtn, + this._inputBoxSessionsBtn, + this._inputBoxCloseBtn + ); + } // Assemble: all children are in the DOM; visibility is toggled via display - this._contentDiv.append( - this._onboardingComponent.element, - this._headerComponent.element, - this._voiceBarComponent.element, - this._feedbackDialogComponent.element, - this._statusTextDiv, - this._transcriptComponent.element, - this._statusRowsComponent.element, - this._sessionListWrapper, - this._expandSpacer, - this._chevronWrapper - ); + if (opts.inputBoxLayout) { + this._contentDiv.append( + this._onboardingComponent.element, + this._feedbackDialogComponent.element, + this._inputBoxToolbar!, + this._transcriptComponent.element, + this._sessionListWrapper, + this._statusRowsComponent.element, + this._inputBoxContainer!, + ); + } else { + this._contentDiv.append( + this._onboardingComponent.element, + this._headerComponent.element, + this._voiceBarComponent.element, + this._feedbackDialogComponent.element, + this._statusTextDiv, + this._transcriptComponent.element, + this._statusRowsComponent.element, + this._sessionListWrapper, + this._expandSpacer, + this._chevronWrapper + ); + } this._rootDiv.append(this._glowDiv, this._titleRow, this._contentDiv); this.container.append(this._rootDiv); @@ -256,20 +409,20 @@ export class AgentsVoiceWidget extends Disposable { win.document.addEventListener('keydown', onDocKeydown, true); this._register(toDisposable(() => win.document.removeEventListener('keydown', onDocKeydown, true))); - this.container.addEventListener('keydown', (e: KeyboardEvent) => { + this._register(dom.addDisposableListener(this.container, 'keydown', (e: KeyboardEvent) => { if (!_isTextInput(e.target) && pttKeyCode && e.code === pttKeyCode) { // Prevent repeat keydowns from activating focused child // buttons (role="button" elements fire click on Space). e.preventDefault(); } - }); - this.container.addEventListener('keyup', (e: KeyboardEvent) => { + })); + this._register(dom.addDisposableListener(this.container, 'keyup', (e: KeyboardEvent) => { if (!_isTextInput(e.target) && pttKeyCode && e.code === pttKeyCode) { e.preventDefault(); pttKeyCode = undefined; this.callbacks.pttUp(); } - }); + })); // Hook into pttDown to snapshot which key started PTT. const origPttDown = this.callbacks.pttDown; @@ -369,6 +522,184 @@ export class AgentsVoiceWidget extends Disposable { } private _updateDOM(reader: IReader): void { + if (this._options.inputBoxLayout) { + this._updateDOMInputBoxLayout(reader); + } else { + this._updateDOMClassicLayout(reader); + } + } + + private _updateDOMInputBoxLayout(reader: IReader): void { + const onboarding = this._showOnboarding.read(reader); + const voiceState = this._voiceState.read(reader); + const isConnected = this._isConnected.read(reader); + const isConnecting = this._isConnecting.read(reader); + const isReconnecting = this._isReconnecting.read(reader); + const showConnected = isConnected || isReconnecting; + const opts = this._options; + const showExpanded = this._shouldShowExpanded.read(reader) && opts.showExpandChevron; + + // Adjust root width when sessions are expanded + const baseWidth = typeof opts.width === 'number' ? opts.width : AGENTS_VOICE_WINDOW_DEFAULT_WIDTH; + this._rootDiv.style.width = `${baseWidth}px`; + + // Title row: hidden during onboarding + this._titleRow.style.display = (onboarding || !opts.title) ? 'none' : 'flex'; + + if (onboarding) { + this._onboardingComponent.element.style.display = ''; + this._feedbackDialogComponent.element.style.display = 'none'; + this._inputBoxContainer!.style.display = 'none'; + this._transcriptComponent.element.style.display = 'none'; + this._statusRowsComponent.element.style.display = 'none'; + this._sessionListWrapper.style.display = 'none'; + this._inputBoxToolbar!.style.display = 'none'; + + this._onboardingComponent.update({ + pttKeyLabel: this._pttKeyLabel.read(reader), + isConnecting: this._onboardingPendingConnect.read(reader) || isConnecting, + onGetStarted: (e) => { e.preventDefault(); e.stopPropagation(); this._dismissOnboarding(true); }, + onOpenPttKeySettings: (e) => { e.preventDefault(); e.stopPropagation(); this.callbacks.openPttKeySettings(); }, + onOpenPopout: this.callbacks.openPopout ? (e) => { e.preventDefault(); e.stopPropagation(); this.callbacks.openPopout?.(); } : undefined, + }); + return; + } + + this._onboardingComponent.element.style.display = 'none'; + + const feedbackState = this._feedbackDialogState.read(reader); + if (feedbackState) { + this._feedbackDialogComponent.element.style.display = ''; + this._feedbackDialogComponent.update({ + onSubmit: (text) => this._submitFeedback(text), + onCancel: () => { this._feedbackDialogState.set(null, undefined); }, + }, feedbackState); + this._inputBoxContainer!.style.display = 'none'; + this._transcriptComponent.element.style.display = 'none'; + this._statusRowsComponent.element.style.display = 'none'; + this._sessionListWrapper.style.display = 'none'; + this._inputBoxToolbar!.style.display = 'none'; + return; + } + + this._feedbackDialogComponent.element.style.display = 'none'; + + // Input box container — show transcript inside or placeholder + this._inputBoxContainer!.style.display = 'flex'; + const transcriptTurns = this._transcriptTurns.read(reader); + const hasTranscript = transcriptTurns.some(t => t.text.length > 0 || (t.speaker === 'user' && t.isPartial)); + + // Toggle voice-active glow on the input container (base state; wave animation overrides dynamically) + if (!showConnected || (voiceState !== 'listening' && voiceState !== 'speaking')) { + this._inputBoxContainer!.style.borderColor = 'var(--vscode-input-border, transparent)'; + this._inputBoxContainer!.style.boxShadow = 'none'; + } + + // Toggle processing comet animation when agent is thinking + this._inputBoxContainer!.classList.toggle('processing', voiceState === 'processing'); + + if (hasTranscript) { + if (showExpanded) { + // When expanded, show full transcript component with chat-like styling + this._transcriptComponent.element.style.display = ''; + this._transcriptComponent.element.style.padding = '8px 12px'; + this._transcriptComponent.element.style.borderBottom = '1px solid var(--vscode-widget-border, var(--vscode-input-border, transparent))'; + this._transcriptComponent.update({ turns: transcriptTurns, chatStyle: true }); + // Hide the input box placeholder since transcript is shown above + this._inputBoxPlaceholder!.style.display = 'none'; + } else { + // Show transcript text inside the placeholder (no purple coloring) + this._inputBoxPlaceholder!.style.display = ''; + this._transcriptComponent.element.style.display = 'none'; + this._transcriptComponent.element.style.padding = ''; + this._transcriptComponent.element.style.borderBottom = ''; + const lastTurn = transcriptTurns[transcriptTurns.length - 1]; + this._inputBoxPlaceholder!.textContent = lastTurn?.text ?? ''; + } + } else { + // Show placeholder + this._inputBoxPlaceholder!.style.display = ''; + this._transcriptComponent.element.style.display = 'none'; + const keyLabel = this._pttKeyLabel.read(reader); + if (showConnected) { + this._inputBoxPlaceholder!.textContent = localize('agentsVoice.listening', "Listening"); + } else if (keyLabel) { + this._inputBoxPlaceholder!.textContent = localize('agentsVoice.holdToTalk', "Hold {0} to talk", keyLabel); + } else { + this._inputBoxPlaceholder!.textContent = localize('agentsVoice.clickMicToTalk', "Click mic to talk"); + } + } + + // Status rows — hide in inputBoxLayout (no "No active sessions" text needed) + if (!showExpanded) { + this._statusRowsComponent.element.style.display = 'none'; + this._sessionListWrapper.style.display = 'none'; + } else { + this._statusRowsComponent.element.style.display = 'none'; + this._sessionListWrapper.style.display = ''; + // Constrain session list height so toolbar and transcript always remain visible + this._sessionListWrapper.style.maxHeight = '200px'; + this._sessionListWrapper.style.overflowY = 'auto'; + this._sessionListWrapper.style.scrollbarWidth = 'none'; + this._sessionListComponent.update({ + sessions: this._sessions.read(reader), + groups: this._sessionGroups.read(reader), + selectedTarget: this._selectedTargetSession.read(reader), + onOpenSession: (r) => this.callbacks.openSession(r), + onStopSession: (r) => this.callbacks.stopSession(r), + onCancelSession: (r) => this.callbacks.cancelSession(r), + onSelectTarget: (r) => { this._selectedTargetSession.set(r, undefined); this.callbacks.selectTargetSession(r); }, + onNewSession: () => this.callbacks.newSessionAsTarget(), + }); + } + + // Toolbar — always visible + this._inputBoxToolbar!.style.display = 'flex'; + + // Mic button — always visible (primary action) + this._inputBoxMicBtn!.style.display = ''; + const keyLabel = this._pttKeyLabel.read(reader); + const micTooltip = keyLabel + ? localize('agentsVoice.pushToTalkKey', "Push to talk ({0})", keyLabel) + : localize('agentsVoice.pushToTalk', "Push to talk"); + this._inputBoxMicBtn!.title = micTooltip; + this._inputBoxMicBtn!.ariaLabel = micTooltip; + const micColor = voiceState === 'error' ? 'var(--vscode-editorError-foreground)' + : voiceState === 'listening' ? 'var(--vscode-editorInfo-foreground)' + : voiceState === 'speaking' ? 'var(--vscode-agentsVoice-speakingForeground)' + : 'var(--vscode-descriptionForeground)'; + this._inputBoxMicBtn!.style.color = micColor; + // Toggle filled state when actively listening or speaking + const micFilled = voiceState === 'listening' || voiceState === 'speaking'; + this._inputBoxMicBtn!.classList.toggle('codicon-mic', !micFilled); + this._inputBoxMicBtn!.classList.toggle('codicon-mic-filled', micFilled); + this._inputBoxMicBtn!.onmousedown = (e: MouseEvent) => { e.preventDefault(); this.callbacks.pttDown(); }; + this._inputBoxMicBtn!.onmouseup = () => { this.callbacks.pttUp(); }; + + // Connection indicator — visible when connected + this._inputBoxConnIndicator!.style.display = showConnected ? '' : 'none'; + this._inputBoxConnIndicator!.onclick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.disconnect(); }; + + // Gear button — always visible + this._inputBoxGearBtn!.style.display = ''; + this._inputBoxGearBtn!.onclick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.openPttKeySettings(); }; + + // Feedback button — always visible + this._inputBoxFeedbackBtn!.onclick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this._toggleFeedbackDialog(); }; + + // Sessions button — always visible, icon toggles with expanded state + this._inputBoxSessionsBtn!.style.display = ''; + this._inputBoxSessionsBtn!.className = `codicon codicon-${showExpanded ? 'chevron-up' : 'list-tree'}`; + this._inputBoxSessionsBtn!.title = showExpanded + ? localize('agentsVoice.collapseSessions', "Collapse sessions") + : localize('agentsVoice.sessions', "Sessions"); + + // Close button + this._inputBoxCloseBtn!.style.display = opts.showClose ? '' : 'none'; + this._inputBoxCloseBtn!.onclick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.closeWindow(); }; + } + + private _updateDOMClassicLayout(reader: IReader): void { const onboarding = this._showOnboarding.read(reader); const voiceState = this._voiceState.read(reader); const opts = this._options; @@ -645,16 +976,19 @@ export class AgentsVoiceWidget extends Disposable { if (!glowActive) { this._glowDiv.style.display = 'none'; + if (this._inputBoxContainer) { + this._inputBoxContainer.style.borderColor = 'var(--vscode-input-border, transparent)'; + this._inputBoxContainer.style.boxShadow = 'none'; + } return; } - this._glowDiv.style.display = ''; const analyser = this.callbacks.getAnalyserNode(); let intensity: number; if (onboarding) { intensity = 0.6; } else if (!analyser) { - intensity = 0; + intensity = 0.3; } else { const dataArray = new Uint8Array(analyser.frequencyBinCount); analyser.getByteFrequencyData(dataArray); @@ -665,6 +999,18 @@ export class AgentsVoiceWidget extends Disposable { intensity = Math.min(1, (sum / dataArray.length) / 80); } + // Animate input box container border/shadow (inputBoxLayout) + if (this._inputBoxContainer) { + const r = (voiceState === 'speaking') ? '163,113,247' : '88,166,255'; + const borderAlpha = 0.4 + intensity * 0.5; + const shadowSpread = 4 + intensity * 12; + const shadowAlpha = 0.15 + intensity * 0.35; + this._inputBoxContainer.style.borderColor = `rgba(${r},${borderAlpha})`; + this._inputBoxContainer.style.boxShadow = `0 0 ${shadowSpread}px rgba(${r},${shadowAlpha}), inset 0 0 ${shadowSpread * 0.4}px rgba(${r},${shadowAlpha * 0.3})`; + } + + // Classic layout glow div + this._glowDiv.style.display = ''; const baseOpacity = 0.15 + intensity * 0.4; const r = (onboarding || voiceState === 'speaking') ? '163,113,247' : '88,166,255'; this._glowDiv.style.background = `radial-gradient(ellipse 40% 70% at 50% 0%, rgba(${r},${baseOpacity}) 0%, transparent 100%), radial-gradient(ellipse 70% 100% at 50% 0%, rgba(${r},${baseOpacity * 0.4}) 0%, transparent 100%)`; diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts index ff464070b72..66d7cba2c13 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts @@ -7,7 +7,6 @@ import { Disposable, DisposableStore, MutableDisposable } from '../../../../base import { Emitter, Event } from '../../../../base/common/event.js'; import { mainWindow } from '../../../../base/browser/window.js'; import { disposableWindowInterval } from '../../../../base/browser/dom.js'; -import { getZoomFactor } from '../../../../base/browser/browser.js'; import { FileAccess } from '../../../../base/common/network.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -29,8 +28,11 @@ import { IWorkspaceContextService } from '../../../../platform/workspace/common/ import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { editorBackground } from '../../../../platform/theme/common/colorRegistry.js'; +import { inputBackground, inputBorder } from '../../../../platform/theme/common/colors/inputColors.js'; import { AgentsVoiceWidget } from './agentsVoiceWidget.js'; import { bindWidgetToController } from './agentsVoiceWidgetBinding.js'; +import { AgentsVoiceSessionsPicker } from './agentsVoiceSessionsPicker.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; export class AgentsVoiceWindowService extends Disposable implements IAgentsVoiceWindowService { @@ -43,6 +45,7 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice private _window: IAuxiliaryWindow | undefined; private readonly _windowDisposables = this._register(new DisposableStore()); private readonly _ownershipChannel: BroadcastChannel; + private _resizeTimeout: ReturnType | undefined; get isOpen(): boolean { return !!this._window; @@ -60,17 +63,6 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice } } - /** - * Minimizes a window via a registered command (Electron only). - */ - private async tryMinimizeWindow(targetWindowId: number): Promise { - try { - await this.commandService.executeCommand('_agentsVoice.minimizeWindow', targetWindowId); - } catch { - // Command not registered (e.g. web) — ignore - } - } - constructor( @IAuxiliaryWindowService private readonly auxiliaryWindowService: IAuxiliaryWindowService, @IStorageService private readonly storageService: IStorageService, @@ -88,6 +80,7 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, @IThemeService private readonly themeService: IThemeService, @IKeybindingService private readonly keybindingService: IKeybindingService, + @IInstantiationService private readonly instantiationService: IInstantiationService, ) { super(); @@ -144,39 +137,31 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice this._window = auxiliaryWindow; this._auxiliaryWindowRef.value = auxiliaryWindow; - // Minimize the main VS Code window so the floating aux window is the - // primary surface the user interacts with. The aux window stays visible - // because it lives in a separate OS window. We minimize at three points - // to defeat any focus-restore behavior from Electron when the aux is - // shown: immediately, after styles load, and again after a short delay. - const minimizeMain = async () => { - try { - const mainWindowId = mainWindow.vscodeWindowId; - await this.tryMinimizeWindow(mainWindowId); - } catch { - // nativeHostService may not be available (e.g. web); ignore. - } - }; - void minimizeMain(); - auxiliaryWindow.whenStylesHaveLoaded.then(() => { - void minimizeMain(); - setTimeout(() => { void minimizeMain(); }, 250); - }); - const workspace = this.workspaceContextService.getWorkspace(); const projectName = workspace.folders.length > 0 ? workspace.folders[0].name : ''; auxiliaryWindow.window.document.title = projectName ? `Agents Voice — ${projectName}` : 'Agents Voice'; auxiliaryWindow.container.style.overflow = 'hidden'; - auxiliaryWindow.container.style.setProperty('--vscode-agents-background', this.themeService.getColorTheme().getColor(editorBackground)?.toString() ?? '#1e1e1e'); auxiliaryWindow.window.document.body.style.setProperty('margin', '0', 'important'); + // Resolve theme colors so the aux window matches the chat input box + const theme = this.themeService.getColorTheme(); + const bgColor = theme.getColor(editorBackground)?.toString() ?? '#1e1e1e'; + const inputBg = theme.getColor(inputBackground)?.toString() ?? '#3C3C3C'; + const inputBd = theme.getColor(inputBorder)?.toString() ?? 'transparent'; + + auxiliaryWindow.container.style.setProperty('--vscode-agents-background', bgColor); + auxiliaryWindow.container.style.backgroundColor = inputBg; + auxiliaryWindow.container.style.border = `1px solid ${inputBd}`; + auxiliaryWindow.container.style.boxSizing = 'border-box'; + auxiliaryWindow.window.document.body.style.setProperty('background-color', inputBg, 'important'); + this._windowDisposables.clear(); // Create the widget — aux window uses the default options (draggable, fixed // width, close button, expand chevron, status rows, no status-text label, - // no popout button), but starts in the expanded view by default so the - // user immediately sees the session list when popping out. + // no popout button). Sessions are collapsed by default; the user can + // expand them via the chevron. const widget = new AgentsVoiceWidget(auxiliaryWindow.container, { copilotIconSrc: FileAccess.asBrowserUri('vs/sessions/browser/media/sessions-icon.svg').toString(true), connect: () => { @@ -220,6 +205,10 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice }, selectTargetSession: (resource) => { this.voiceSessionController.setTargetSession(resource); + // Reveal the selected session in the chat panel + if (resource) { + this.commandService.executeCommand('_chat.voice.switchToSession', resource.toString()).catch(() => { /* ignore */ }); + } }, newSessionAsTarget: () => { this.voiceSessionController.newSessionAsTarget(); @@ -233,8 +222,16 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice onResize: () => this._resizeWindow(auxiliaryWindow), openPttKeySettings: () => this.commandService.executeCommand('workbench.action.openGlobalKeybindings', 'agentsVoice.pushToTalk'), submitFeedback: (text) => this.voiceSessionController.submitFeedback(text), + showSessionsPicker: () => { + const picker = this.instantiationService.createInstance( + AgentsVoiceSessionsPicker, + (resource) => this.voiceSessionController.setTargetSession(resource), + ); + picker.show(); + }, }, { - defaultExpanded: true, + defaultExpanded: false, + inputBoxLayout: true, }); this._windowDisposables.add(widget); @@ -255,43 +252,12 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice chatService: this.chatService, })); - // Re-resize when zoom level changes - let lastDpr = auxiliaryWindow.window.devicePixelRatio; - let zoomDebounce: ReturnType | undefined; - const checkZoom = () => { - const currentDpr = auxiliaryWindow.window.devicePixelRatio; - if (Math.abs(currentDpr - lastDpr) > 0.01) { - lastDpr = currentDpr; - if (zoomDebounce) { clearTimeout(zoomDebounce); } - zoomDebounce = setTimeout(() => { - this._resizeWindow(auxiliaryWindow); - }, 200); - } - }; - this._windowDisposables.add(disposableWindowInterval(auxiliaryWindow.window, checkZoom, 500)); - this._windowDisposables.add({ dispose: () => { if (zoomDebounce) { clearTimeout(zoomDebounce); } } }); - // Poll for session updates this.agentSessionsService.model.resolve(undefined); this._windowDisposables.add(disposableWindowInterval(auxiliaryWindow.window, () => { this.agentSessionsService.model.resolve(undefined); }, 3000)); - // Periodically save window bounds - let lastBoundsJson = ''; - this._windowDisposables.add(disposableWindowInterval(auxiliaryWindow.window, () => { - if (!this._window) { return; } - try { - const state = this._window.createState(); - if (state.bounds) { - const posJson = JSON.stringify({ x: state.bounds.x, y: state.bounds.y }); - if (posJson !== lastBoundsJson) { - lastBoundsJson = posJson; - this.storageService.store(AgentsVoiceStorageKeys.WindowBounds, posJson, StorageScope.WORKSPACE, StorageTarget.MACHINE); - } - } - } catch { /* window may have been disposed */ } - }, 1000)); // Clean up when user closes window via OS controls Event.once(auxiliaryWindow.onUnload)(() => { @@ -337,6 +303,17 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice // --- Window sizing --- private _resizeWindow(auxiliaryWindow: IAuxiliaryWindow): void { + // Debounce resize to avoid fighting user drag operations + if (this._resizeTimeout) { + clearTimeout(this._resizeTimeout); + } + this._resizeTimeout = setTimeout(() => { + this._resizeTimeout = undefined; + this._doResizeWindow(auxiliaryWindow); + }, 100); + } + + private _doResizeWindow(auxiliaryWindow: IAuxiliaryWindow): void { // eslint-disable-next-line no-restricted-syntax const pill = auxiliaryWindow.container.querySelector('div') as HTMLElement | null; if (!pill) { return; } @@ -344,28 +321,17 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice const pillWidth = pill.offsetWidth; const pillHeight = pill.offsetHeight; if (pillWidth <= 0 || pillHeight <= 0) { return; } - const zoomFactor = getZoomFactor(auxiliaryWindow.window); - const targetWidth = Math.ceil(pillWidth * zoomFactor); - const targetHeight = Math.ceil(pillHeight * zoomFactor); const currentWidth = auxiliaryWindow.window.outerWidth; const currentHeight = auxiliaryWindow.window.outerHeight; - // Only resize width unconditionally; for height, only grow (never - // shrink) so that manual vertical resizing by the user is preserved. - const newWidth = targetWidth !== currentWidth ? targetWidth : currentWidth; - const newHeight = targetHeight > currentHeight ? targetHeight : currentHeight; - if (newWidth !== currentWidth || newHeight !== currentHeight) { - // Capture position before resize — resizeTo can shift the window - // on some platforms (macOS), causing accumulated drift. - const preX = auxiliaryWindow.window.screenX; - const preY = auxiliaryWindow.window.screenY; + if (pillWidth !== currentWidth || pillHeight !== currentHeight) { try { - auxiliaryWindow.window.resizeTo(newWidth, newHeight); - // Restore position if it drifted - const postX = auxiliaryWindow.window.screenX; - const postY = auxiliaryWindow.window.screenY; - if (postX !== preX || postY !== preY) { - auxiliaryWindow.window.moveTo(preX, preY); - } + // Clamp height so window doesn't exceed available screen space. + const screenBottom = auxiliaryWindow.window.screen.availHeight; + const maxHeight = screenBottom - auxiliaryWindow.window.screenY; + const clampedHeight = Math.min(pillHeight, Math.max(maxHeight, AGENTS_VOICE_WINDOW_DEFAULT_HEIGHT)); + // resizeTo only — no moveTo. On macOS this keeps top-left fixed, + // window grows/shrinks downward. No visible position change. + auxiliaryWindow.window.resizeTo(pillWidth, clampedHeight); } catch { /* resize may not be supported */ } } } @@ -373,57 +339,25 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice // --- Bounds persistence --- private _defaultBounds(): IRectangle { - const screenWidth = mainWindow.screen?.availWidth ?? 1920; + // Center horizontally within the main VS Code window, near bottom. + const x = Math.round(mainWindow.screenX + (mainWindow.outerWidth - AGENTS_VOICE_WINDOW_DEFAULT_WIDTH) / 2); + const y = mainWindow.screenY + mainWindow.outerHeight - AGENTS_VOICE_WINDOW_DEFAULT_HEIGHT - 100; return { - x: screenWidth - AGENTS_VOICE_WINDOW_DEFAULT_WIDTH - 20, - y: 20, + x, + y, width: AGENTS_VOICE_WINDOW_DEFAULT_WIDTH, height: AGENTS_VOICE_WINDOW_DEFAULT_HEIGHT, }; } - private _isOnScreen(bounds: IRectangle): boolean { - const screen = mainWindow.screen; - const screenLeft = (screen as unknown as { availLeft?: number }).availLeft ?? 0; - const screenTop = (screen as unknown as { availTop?: number }).availTop ?? 0; - const screenWidth = screen?.availWidth ?? 1920; - const screenHeight = screen?.availHeight ?? 1080; - - const minVisible = 50; - const visibleX = Math.min(bounds.x + bounds.width, screenLeft + screenWidth) - Math.max(bounds.x, screenLeft); - const visibleY = Math.min(bounds.y + bounds.height, screenTop + screenHeight) - Math.max(bounds.y, screenTop); - - return visibleX >= minVisible && visibleY >= minVisible; - } - private loadBounds(): IRectangle { - const defaults = this._defaultBounds(); - const raw = this.storageService.get(AgentsVoiceStorageKeys.WindowBounds, StorageScope.WORKSPACE); - if (raw) { - try { - const parsed = JSON.parse(raw); - if (typeof parsed.x === 'number' && typeof parsed.y === 'number') { - const bounds = { x: parsed.x, y: parsed.y, width: defaults.width, height: defaults.height }; - if (this._isOnScreen(bounds)) { - return bounds; - } - } - } catch { /* ignore invalid JSON */ } - } - - return defaults; + // Always compute fresh bounds from the current main window position. + // This ensures the aux window is always centered within VS Code. + return this._defaultBounds(); } - private saveBounds(window: IAuxiliaryWindow): void { - const state = window.createState(); - if (state.bounds) { - this.storageService.store( - AgentsVoiceStorageKeys.WindowBounds, - JSON.stringify({ x: state.bounds.x, y: state.bounds.y }), - StorageScope.WORKSPACE, - StorageTarget.MACHINE - ); - } + private saveBounds(_window: IAuxiliaryWindow): void { + // Bounds persistence disabled — always use fresh defaults for now. } } diff --git a/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts b/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts index c78a7be835e..4ab2aee2f2d 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts @@ -90,6 +90,15 @@ export function createHeader(): HeaderComponent { connIndicator.append(connDot, connDisc); addKeyboardActivation(connIndicator); + // Placeholder text — clickable, shows PTT keybinding + const placeholderText = dom.$('span.voice-placeholder-text'); + placeholderText.role = 'button'; + placeholderText.tabIndex = 0; + placeholderText.style.cssText = `font-size:${FONT_SIZE.body};color:var(--vscode-input-placeholderForeground, var(--vscode-descriptionForeground));cursor:pointer;-webkit-app-region:no-drag;user-select:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`; + placeholderText.addEventListener('mouseenter', () => { placeholderText.style.color = 'var(--vscode-foreground)'; }); + placeholderText.addEventListener('mouseleave', () => { placeholderText.style.color = 'var(--vscode-input-placeholderForeground, var(--vscode-descriptionForeground))'; }); + addKeyboardActivation(placeholderText); + // Spacer const spacer = dom.$('span'); spacer.style.flex = '1'; @@ -100,7 +109,7 @@ export function createHeader(): HeaderComponent { localize('agentsVoice.sendFeedback', "Send feedback")); // Popout button - const popoutBtn = hoverButton('codicon-link-external', + const popoutBtn = hoverButton('codicon-open-in-window', localize('agentsVoice.openMiniView', "Open mini-view"), localize('agentsVoice.openMiniView', "Open mini-view")); @@ -116,7 +125,7 @@ export function createHeader(): HeaderComponent { .voice-conn-indicator:hover .voice-conn-disconnect { display: inline-block !important; color: var(--vscode-errorForeground, #f44) !important; } `; - container.append(copilotIcon, micBtn, gearBtn, connIndicator, spacer, feedbackBtn, popoutBtn, closeBtn, connStyle); + container.append(copilotIcon, micBtn, placeholderText, gearBtn, connIndicator, spacer, popoutBtn, closeBtn, connStyle); return { element: container, @@ -127,7 +136,10 @@ export function createHeader(): HeaderComponent { copilotIcon.style.display = props.showCopilotIcon ? '' : 'none'; copilotIcon.src = props.copilotIconSrc; - // Mic color + const showConnected = props.isConnected || props.isReconnecting; + + // Mic button — shown only when connected + micBtn.style.display = showConnected ? '' : 'none'; const micColor = props.voiceState === 'error' ? 'var(--vscode-editorError-foreground)' : props.voiceState === 'listening' ? 'var(--vscode-editorInfo-foreground)' : props.voiceState === 'speaking' ? 'var(--vscode-agentsVoice-speakingForeground)' @@ -138,8 +150,17 @@ export function createHeader(): HeaderComponent { micBtn.onmousedown = props.onMicDown; micBtn.onmouseup = () => props.onMicUp(); + // Placeholder text — shown when not connected, displays PTT keybinding + placeholderText.style.display = showConnected ? 'none' : ''; + const keyLabel = props.pttKeyLabel; + const holdText = keyLabel + ? localize('agentsVoice.holdToTalk', "Hold {0} to talk", keyLabel) + : localize('agentsVoice.clickMicToTalk', "Click mic to talk"); + placeholderText.textContent = holdText; + placeholderText.ariaLabel = holdText; + placeholderText.onclick = props.onConnectClick; + // Gear - const showConnected = props.isConnected || props.isReconnecting; gearBtn.style.display = props.isConnected ? '' : 'none'; gearBtn.onclick = props.onPttKeyClick; diff --git a/src/vs/workbench/contrib/agentsVoice/browser/components/sessionListComponent.ts b/src/vs/workbench/contrib/agentsVoice/browser/components/sessionListComponent.ts index 0ef1f5831a1..4b5bb407685 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/components/sessionListComponent.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/components/sessionListComponent.ts @@ -145,10 +145,6 @@ function createSessionRow(session: SessionRowData, props: SessionListProps): HTM actions.setAttribute('data-role', 'actions'); actions.style.cssText = 'display:none;gap:4px;align-items:center;'; - const openBtn = hoverIcon('codicon-link-external', localize('agentsVoice.openSessionAction', "Open session")); - openBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); props.onOpenSession(session.resource); }); - actions.append(openBtn); - if (!session.isIdle) { const stopBtn = hoverIcon('codicon-debug-stop', localize('agentsVoice.stopSessionAction', "Stop session")); stopBtn.addEventListener('mouseenter', () => { stopBtn.style.color = 'var(--vscode-editorError-foreground)'; }); diff --git a/src/vs/workbench/contrib/agentsVoice/browser/components/transcriptComponent.ts b/src/vs/workbench/contrib/agentsVoice/browser/components/transcriptComponent.ts index e21232d1551..7e9ba6b89e1 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/components/transcriptComponent.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/components/transcriptComponent.ts @@ -37,28 +37,30 @@ const TRANSCRIPT_CSS = ` export interface TranscriptProps { readonly turns: readonly ITranscriptTurn[]; + readonly chatStyle?: boolean; } -function createUserTurn(turn: ITranscriptTurn): HTMLElement { +function createUserTurn(turn: ITranscriptTurn, chatStyle?: boolean): HTMLElement { const wrapper = dom.$('div.voice-user-transcript'); wrapper.style.cssText = USER_CONTAINER_STYLE; + const userColor = chatStyle ? 'var(--vscode-foreground)' : COLOR.userTranscript; const inner = dom.$('div'); if (!turn.isPartial) { const span = dom.$('span'); - span.style.color = COLOR.userTranscript; + span.style.color = userColor; span.textContent = turn.text; inner.append(span); } else { const unsure = turn.committed ? turn.text.slice(turn.committed.length) : turn.text; if (turn.committed) { const committedSpan = dom.$('span'); - committedSpan.style.color = COLOR.userTranscript; + committedSpan.style.color = userColor; committedSpan.textContent = turn.committed; inner.append(committedSpan); } const unsureSpan = dom.$('span'); - unsureSpan.style.cssText = `color:${COLOR.userTranscript};opacity:0.6;font-style:italic;animation:textPulse 1.5s ease-in-out infinite;`; + unsureSpan.style.cssText = `color:${userColor};opacity:0.6;font-style:italic;animation:textPulse 1.5s ease-in-out infinite;`; unsureSpan.textContent = unsure; const cursor = dom.$('span'); cursor.style.fontStyle = 'normal'; @@ -71,9 +73,13 @@ function createUserTurn(turn: ITranscriptTurn): HTMLElement { return wrapper; } -function createAssistantTurn(turn: ITranscriptTurn): HTMLElement { +function createAssistantTurn(turn: ITranscriptTurn, chatStyle?: boolean): HTMLElement { const el = dom.$('div'); - el.style.cssText = ASSISTANT_STYLE; + if (chatStyle) { + el.style.cssText = ASSISTANT_STYLE.replace(`color:${COLOR.assistantTranscript}`, 'color:var(--vscode-descriptionForeground)'); + } else { + el.style.cssText = ASSISTANT_STYLE; + } el.textContent = turn.text; return el; } @@ -101,6 +107,10 @@ export function createTranscript(): TranscriptComponent { visible[visible.length - 2].speaker === 'assistant') { visible = [visible[visible.length - 1]]; } + // In chat style, only show the most recent turn (matches collapsed behavior) + if (props.chatStyle && visible.length > 0) { + visible = [visible[visible.length - 1]]; + } dom.clearNode(container); if (visible.length === 0) { container.style.display = 'none'; @@ -108,7 +118,7 @@ export function createTranscript(): TranscriptComponent { } container.style.display = 'flex'; for (const turn of visible) { - container.append(turn.speaker === 'user' ? createUserTurn(turn) : createAssistantTurn(turn)); + container.append(turn.speaker === 'user' ? createUserTurn(turn, props.chatStyle) : createAssistantTurn(turn, props.chatStyle)); } } }; diff --git a/src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts b/src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts index 4b1fc76d3d1..6b1d6339366 100644 --- a/src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts +++ b/src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts @@ -11,8 +11,8 @@ import './agentsVoiceColors.js'; // Register custom voice theme colors /** * Default dimensions for the Agents Voice floating window. */ -export const AGENTS_VOICE_WINDOW_DEFAULT_WIDTH = 220; -export const AGENTS_VOICE_WINDOW_DEFAULT_HEIGHT = 100; +export const AGENTS_VOICE_WINDOW_DEFAULT_WIDTH = 400; +export const AGENTS_VOICE_WINDOW_DEFAULT_HEIGHT = 70; /** * Storage keys for persisting window state across restarts. diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index a3d4b57adcd..d48676bc038 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -38,6 +38,7 @@ import { getAgentSessionProvider, AgentSessionProviders, AgentSessionTarget } fr import { getEditingSessionContext } from '../chatEditing/chatEditingActions.js'; import { ctxHasEditorModification, ctxHasRequestInProgress, ctxIsGlobalEditingSession } from '../chatEditing/chatEditingEditorContextKeys.js'; import { ACTION_ID_NEW_CHAT, CHAT_CATEGORY, clearChatSessionPreservingType, handleCurrentEditingSession, handleModeSwitch } from './chatActions.js'; +import { IVoiceSessionController } from '../voiceClient/voiceSessionController.js'; import { CreateRemoteAgentJobAction } from './chatContinueInAction.js'; export interface IVoiceChatExecuteActionContext { @@ -979,6 +980,7 @@ export class CancelAction extends Action2 { const logService = accessor.get(ILogService); const telemetryService = accessor.get(ITelemetryService); const widget = context?.widget ?? widgetService.lastFocusedWidget; + const voiceController = accessor.get(IVoiceSessionController); if (!widget) { telemetryService.publicLog2(ChatStopCancellationNoopEventName, { source: 'cancelAction', @@ -1002,6 +1004,11 @@ export class CancelAction extends Action2 { }); logService.info('ChatCancelAction#run: Canceled chat widget has no view model'); } + // Also disconnect voice session if active + + if (voiceController.isConnected.get()) { + voiceController.disconnect(); + } } } diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/ttsPlaybackService.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/ttsPlaybackService.ts index b087eb29533..37c97273af3 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/ttsPlaybackService.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/ttsPlaybackService.ts @@ -86,6 +86,11 @@ export class TtsPlaybackService extends Disposable implements ITtsPlaybackServic if (!this._playbackCtx) { this._playbackCtx = new window.AudioContext({ sampleRate: PLAYBACK_SAMPLE_RATE }); } + // AudioContext may be suspended if no user gesture occurred on this window yet. + // Resume it to ensure playback works regardless of which window initiated the action. + if (this._playbackCtx.state === 'suspended') { + this._playbackCtx.resume().catch(() => { /* ignore - best effort */ }); + } return this._playbackCtx; } diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index af332deb36c..15a4084efeb 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts @@ -6,6 +6,7 @@ import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { IObservable, observableValue, autorun, transaction, observableSignalFromEvent } from '../../../../../base/common/observable.js'; import { disposableWindowInterval } from '../../../../../base/browser/dom.js'; +import { disposableTimeout } from '../../../../../base/common/async.js'; import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; @@ -103,6 +104,9 @@ export interface IVoiceSessionController { * client state, environment info). Returns success/failure. */ submitFeedback(feedbackText: string): Promise<{ ok: boolean; error?: string }>; + + /** DEV ONLY: Simulate a connected session with fake transcript for UI testing. */ + simulateConnection(): void; } export const IVoiceSessionController = createDecorator('voiceSessionController'); @@ -837,7 +841,13 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._isReconnecting.set(false, undefined); this._voiceState.set('idle', undefined); this._statusText.set('Tap to start', undefined); - } else if (!this._isConnecting.get()) { + } else if (this._isConnecting.get()) { + // Connection failed during initial handshake (e.g. fatal WS close). + // Clear isConnecting so callers awaiting the state settle properly. + this._isConnecting.set(false, undefined); + this._voiceState.set('idle', undefined); + this._statusText.set('Tap to start', undefined); + } else { this._voiceState.set('idle', undefined); } })); @@ -1017,6 +1027,43 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._sessionAudioCache.clear(); } + /** DEV ONLY: Simulate a connected session with fake transcript for UI testing. */ + simulateConnection(): void { + this._isConnected.set(true, undefined); + this._isConnecting.set(false, undefined); + this._voiceState.set('idle', undefined); + this._statusText.set('Hold to speak...', undefined); + + // Simulate a user speaking after 1s + this._voiceEventDisposables.add(disposableTimeout(() => { + if (!this._isConnected.get()) { return; } + this._voiceState.set('listening', undefined); + this._transcriptTurns.set([{ speaker: 'user', text: 'Create a', committed: '', isPartial: true }], undefined); + }, 1000)); + + // Partial grows + this._voiceEventDisposables.add(disposableTimeout(() => { + if (!this._isConnected.get()) { return; } + this._transcriptTurns.set([{ speaker: 'user', text: 'Create a new React component', committed: 'Create a ', isPartial: true }], undefined); + }, 2000)); + + // Final user turn + this._voiceEventDisposables.add(disposableTimeout(() => { + if (!this._isConnected.get()) { return; } + this._transcriptTurns.set([{ speaker: 'user', text: 'Create a new React component for the dashboard', committed: 'Create a new React component for the dashboard', isPartial: false }], undefined); + this._voiceState.set('idle', undefined); + }, 3000)); + + // Assistant response + this._voiceEventDisposables.add(disposableTimeout(() => { + if (!this._isConnected.get()) { return; } + this._transcriptTurns.set([ + { speaker: 'user', text: 'Create a new React component for the dashboard', committed: 'Create a new React component for the dashboard', isPartial: false }, + { speaker: 'assistant', text: 'I\'ll create a Dashboard component with some widgets...', committed: '', isPartial: false }, + ], undefined); + }, 4500)); + } + private _onConnectionLost(): void { this.logService.warn('[voice] connection lost, preserving state for reconnect'); // Don't stop the mic here — keep the MediaStream alive across the @@ -1161,26 +1208,62 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC private async _sendTranscriptionToChat(text: string): Promise { const target = this._targetSession.get(); if (target) { - // Try switching to the session via the workbench chat pane first - const switched = await this.commandService.executeCommand('_chat.voice.switchToSession', target.toString()).catch(() => false); - if (switched) { - // Small delay to let the widget load the session model - await new Promise(resolve => setTimeout(resolve, 200)); + // Check if target is the currently visible session + const currentSession = await this.commandService.executeCommand('_chat.voice.getCurrentSession').catch(() => undefined); + const isTargetVisible = currentSession === target.toString(); + + if (isTargetVisible) { + // Target is visible — send via the chat pane directly await this.commandService.executeCommand('_chat.voice.acceptInput', text).catch(err => { - this.logService.warn('[voice] acceptInput failed after switch:', err); + this.logService.warn('[voice] acceptInput failed for visible target:', err); }); } else { - // Not in workbench chat — try agents window openAndSend - const handled = await this.commandService.executeCommand('_sessions.voice.openAndSend', target.toString(), text).catch(() => false); - if (!handled) { - // Last resort: try sendRequest directly - this.chatService.sendRequest(target, text).then(result => { - if (result.kind === 'rejected') { - this.logService.warn('[voice] Failed to send transcription to target session:', result.reason); + // Target is NOT visible — ensure session is loaded, then send + const cts = new CancellationTokenSource(); + const ref = await this.chatService.acquireOrLoadSession(target, ChatAgentLocation.Chat, cts.token, 'voice-send').catch(err => { + this.logService.warn('[voice] Failed to load target session:', err); + return undefined; + }); + cts.dispose(); + if (!ref) { + this.logService.warn('[voice] Could not load target session, falling back to switch'); + // Fallback: switch to the session and send via the UI + const switched = await this.commandService.executeCommand('_chat.voice.switchToSession', target.toString()).catch(() => false); + if (switched) { + await new Promise(resolve => setTimeout(resolve, 200)); + await this.commandService.executeCommand('_chat.voice.acceptInput', text).catch(() => { }); + } + return; + } + const result = await this.chatService.sendRequest(target, text).catch(err => { + this.logService.warn('[voice] Error sending transcription to target session:', err); + return undefined; + }); + if (result && result.kind !== 'rejected') { + // Surface response in floating window + this._watchResponseForFloatingWindow(target); + // Open the floating window so user can see the response + this.commandService.executeCommand('_agentsVoice.openWindow').catch(() => { /* ignore */ }); + // Keep the session model loaded until the response completes + // so the autorun can observe state transitions and trigger narration. + const model = this.chatService.getSession(target); + if (model) { + const lastReq = model.getRequests().at(-1); + if (lastReq?.response && !lastReq.response.isComplete && !lastReq.response.isCanceled) { + const responseDisposable = lastReq.response.onDidChange(() => { + if (lastReq.response!.isComplete || lastReq.response!.isCanceled) { + responseDisposable.dispose(); + ref.dispose(); + } + }); + } else { + ref.dispose(); } - }).catch(err => { - this.logService.warn('[voice] Error sending transcription to target session:', err); - }); + } else { + ref.dispose(); + } + } else { + ref.dispose(); } } } else { @@ -1224,10 +1307,77 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC }); } } + + // Ensure the chat view is visible so the user sees/hears the response + this.commandService.executeCommand('workbench.panel.chat.view.copilot.focus').catch(() => { /* ignore */ }); + } + } + + /** + * Watch a session's latest response and surface it in the floating window + * transcript. Called when voice sends to a non-visible session so the user + * can see the reply without switching the chat panel. + */ + private _watchResponseForFloatingWindow(sessionResource: URI): void { + const model = this.chatService.getSession(sessionResource); + if (!model) { + return; } - // Ensure the chat view is visible so the user sees/hears the response - this.commandService.executeCommand('workbench.panel.chat.view.copilot.focus').catch(() => { /* ignore */ }); + // Seed the state cache so the delta mechanism sees thinking→idle as a transition + // and includes last_response_summary in the patch. + this._prevSessionStates.set(sessionResource.toString(), { state: 'thinking', detail: '' }); + this._sendContext(); + + const disposables = new DisposableStore(); + let lastText = ''; + + const updateFromResponse = () => { + const lastReq = model.lastRequest; + const response = lastReq?.response; + if (!response) { + return; + } + + const markdown = response.response.getMarkdown(); + // Only first ~200 chars for the floating window transcript preview + const previewText = markdown.length > 200 ? markdown.slice(0, 200) + '…' : markdown; + if (previewText && previewText !== lastText) { + const isFirst = lastText === ''; + lastText = previewText; + this._setAssistantTurn(previewText, { startNewTurn: isFirst }); + } + + if (response.isComplete || response.isCanceled) { + // Notify the voice backend of the state transition so it can + // narrate the response for this non-focused session. + this._prevSessionStates.set(sessionResource.toString(), { state: 'idle', detail: '' }); + this._sendContext(); + this.voiceClientService.flushSessionContext(); + disposables.dispose(); + } + }; + + // Listen for response changes + const checkResponse = () => { + const lastReq = model.lastRequest; + if (lastReq?.response) { + disposables.add(lastReq.response.onDidChange(() => updateFromResponse())); + updateFromResponse(); + } + }; + + // The response may not exist yet — listen for model changes + disposables.add(model.onDidChange(e => { + if (e.kind === 'addResponse') { + checkResponse(); + } + })); + checkResponse(); + + // Safety: dispose after 5 minutes in case the response never completes + const timeout = setTimeout(() => disposables.dispose(), 5 * 60 * 1000); + disposables.add({ dispose: () => clearTimeout(timeout) }); } // --- Transcript buffer helpers --- @@ -1758,8 +1908,11 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC return false; }); + const targetSessionId = this._targetSession.get()?.toString(); + const sessionList = sessions.map(s => { const model = this.chatService.getSession(s.resource); + const isActive = s.resource.toString() === targetSessionId; if (!model) { const fallbackState = s.status === AgentSessionStatus.InProgress ? 'thinking' : s.status === AgentSessionStatus.NeedsInput ? 'waiting_for_confirmation' @@ -1767,14 +1920,14 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC : 'unknown'; return { id: s.resource.toString(), - is_active: false, + is_active: isActive, agent_state: fallbackState, }; } const stateInfo = this._getAgentStateInfo(model); return { id: s.resource.toString(), - is_active: false, + is_active: isActive, agent_state: stateInfo.state, ...(stateInfo.detail ? { agent_state_detail: stateInfo.detail } : {}), ...(stateInfo.last_response_summary ? { last_response_summary: stateInfo.last_response_summary } : {}), @@ -1796,7 +1949,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } sessionList.push({ id: key, - is_active: false, + is_active: key === targetSessionId, agent_state: stateInfo.state, ...(stateInfo.detail ? { agent_state_detail: stateInfo.detail } : {}), ...(stateInfo.last_response_summary ? { last_response_summary: stateInfo.last_response_summary } : {}), diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceToolDispatchService.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceToolDispatchService.ts index 41b66c489e3..066e2186999 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceToolDispatchService.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceToolDispatchService.ts @@ -15,6 +15,7 @@ import { IChatModel } from '../../common/model/chatModel.js'; import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { IVoiceToolCall } from '../../common/voiceClient/voiceClientService.js'; +import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; /** * Callbacks that require access to the chat widget or view state. @@ -206,6 +207,16 @@ export class VoiceToolDispatchService implements IVoiceToolDispatchService { } } } + // Session not loaded — acquire it so we can confirm the tool invocation + if (!model && agentSession) { + const cts = new CancellationTokenSource(); + const ref = await this.chatService.acquireOrLoadSession(agentSession.resource, ChatAgentLocation.Chat, cts.token, 'voice-confirm').catch(() => undefined); + cts.dispose(); + if (ref) { + model = this.chatService.getSession(agentSession.resource); + ref.dispose(); + } + } } if (!model) { // Last resort: use the currently focused session diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index f9a119c1e21..5827c99bf97 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -4440,3 +4440,22 @@ have to be updated for changes to the rules above, or to support more deeply nes padding: 2px 8px; font-size: 11px; } + +.monaco-workbench .chat-model-hover-configurable > .monaco-button .codicon[class*='codicon-'] { + font-size: var(--vscode-codiconFontSize-compact); +} + +/* Voice mode: color the mic icon blue when listening, purple when speaking */ +/* Voice mode: color the mic icon when listening/speaking */ +.chat-input-container.voice-active.voice-listening .chat-input-toolbars .action-label.codicon-mic-filled { + color: var(--vscode-charts-blue, #58a6ff) !important; +} + +.chat-input-container.voice-active:not(.voice-listening) .chat-input-toolbars .action-label.codicon-mic-filled { + color: var(--vscode-charts-purple, #a371f7) !important; +} + +/* Voice mode: green disconnect button */ +.chat-input-container .chat-input-toolbars .codicon-debug-disconnect { + color: var(--vscode-charts-green, #3fb950) !important; +} 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 d31e014e736..6b4b5cb8b4e 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -67,17 +67,12 @@ import { IAgentSession } from '../../agentSessions/agentSessionsModel.js'; import { ChatEntitlementContextKeys, IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { toErrorMessage } from '../../../../../../base/common/errorMessage.js'; import { IHostService } from '../../../../../services/host/browser/host.js'; -import { FileAccess } from '../../../../../../base/common/network.js'; import { IMicCaptureService } from '../../voiceClient/micCaptureService.js'; import { ITtsPlaybackService } from '../../voiceClient/ttsPlaybackService.js'; import { IVoiceSessionController } from '../../voiceClient/voiceSessionController.js'; -import { IAgentsVoiceWindowService, AgentsVoiceStorageKeys } from '../../../../agentsVoice/common/agentsVoice.js'; -import { AgentsVoiceWidget } from '../../../../agentsVoice/browser/agentsVoiceWidget.js'; -import { AGENTS_VOICE_WIDGET_FOCUSED } from '../../../../agentsVoice/browser/agentsVoice.contribution.js'; -import { bindWidgetToController } from '../../../../agentsVoice/browser/agentsVoiceWidgetBinding.js'; +import { IAgentsVoiceWindowService } from '../../../../agentsVoice/common/agentsVoice.js'; import { IAgentTitleBarStatusService } from '../../agentSessions/experiments/agentTitleBarStatusService.js'; import { IVoicePlaybackService } from '../../../common/voicePlaybackService.js'; -import { VoiceOnboardingCompletedClassification, VoiceOnboardingCompletedEvent } from '../../voiceClient/voiceTelemetry.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; interface IChatViewPaneState extends Partial { @@ -116,7 +111,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { constructor( options: IViewPaneOptions, - @IKeybindingService private readonly keybindingService2: IKeybindingService, + @IKeybindingService keybindingService2: IKeybindingService, @IContextMenuService contextMenuService: IContextMenuService, @IConfigurationService configurationService: IConfigurationService, @IContextKeyService contextKeyService: IContextKeyService, @@ -144,9 +139,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { @ITtsPlaybackService private readonly ttsPlaybackService: ITtsPlaybackService, @IVoiceSessionController private readonly voiceSessionController: IVoiceSessionController, @IAgentsVoiceWindowService private readonly agentsVoiceWindowService: IAgentsVoiceWindowService, - @IAgentTitleBarStatusService private readonly agentTitleBarStatusService: IAgentTitleBarStatusService, - @IVoicePlaybackService private readonly voicePlaybackService: IVoicePlaybackService, - @IWorkbenchEnvironmentService private readonly workbenchEnvironmentService: IWorkbenchEnvironmentService, + @IAgentTitleBarStatusService _agentTitleBarStatusService: IAgentTitleBarStatusService, + @IVoicePlaybackService _voicePlaybackService: IVoicePlaybackService, + @IWorkbenchEnvironmentService _workbenchEnvironmentService: IWorkbenchEnvironmentService, ) { super(options, keybindingService2, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -332,28 +327,21 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { const controlsWrapper = append(parent, $('.voice-agent-controls-wrapper')); this.createControls(controlsWrapper); - // Bottom area for voice panel — always present, populated when enabled - const bottomArea = append(parent, $('.voice-bottom-area')); - this._voiceBottomArea = bottomArea; - this._updateVoiceBar(bottomArea); + // Voice bar — hidden by default, voice is activated via mic button in toolbar. + // The widget is still created for PTT keybinding support and session binding. + this._voiceBarContainer = $('.voice-agent-bar-host'); + this._voiceBarContainer.style.display = 'none'; + this._updateVoiceBar(this._voiceBarContainer); - // Watch for size changes so we relayout when content changes - // (e.g. onboarding → connected, confirmations added/removed) - const resizeObserver = new ResizeObserver(() => { - if (this.lastDimensions) { - this.layoutBody(this.lastDimensions.height, this.lastDimensions.width); - } - }); - resizeObserver.observe(bottomArea); - this._voiceBarResizeObserver = resizeObserver; - this._register({ dispose: () => resizeObserver.disconnect() }); + // Transcript overlay — shown inside the input container when voice is active + const inputContainerEl = this._widget.inputPart.inputContainerElement; + if (inputContainerEl) { + this._setupVoiceTranscriptOverlay(inputContainerEl); + } this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('agents.voice.enabled')) { - this._updateVoiceBar(bottomArea); - if (this.lastDimensions) { - this.layoutBody(this.lastDimensions.height, this.lastDimensions.width); - } + this._updateVoiceBar(this._voiceBarContainer!); } })); @@ -382,17 +370,19 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { //#region Voice Agent Bar - private _voiceBottomArea: HTMLElement | undefined; - private _voiceBarResizeObserver: ResizeObserver | undefined; + private _voiceBarContainer: HTMLElement | undefined; private readonly _voiceBarDisposables = this._register(new DisposableStore()); private _updateVoiceBar(container: HTMLElement): void { this._voiceBarDisposables.clear(); container.replaceChildren(); - if (this.configurationService.getValue('agents.voice.enabled')) { - container.style.display = ''; + // Always keep the container hidden — voice UI is now the mic toolbar + // button + transcript overlay. We still register the command bridges + // needed by VoiceSessionController. + container.style.display = 'none'; + if (this.configurationService.getValue('agents.voice.enabled')) { // Voice command bridge — lets the VoiceSessionController reach into the chat widget this._voiceBarDisposables.add(CommandsRegistry.registerCommand('_chat.voice.acceptInput', (_accessor, text: string) => { if (text && this._widget?.viewModel) { @@ -414,126 +404,172 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { this._voiceBarDisposables.add(CommandsRegistry.registerCommand('_chat.voice.getCurrentSession', (_accessor): string | undefined => { return this._widget?.viewModel?.sessionResource?.toString(); })); - - this.createVoiceAgentBar(container); - } else { - container.style.display = 'none'; } } - private createVoiceAgentBar(parent: HTMLElement): void { - const bar = append(parent, $('.voice-agent-bar')); - const win = getWindow(bar) as Window & typeof globalThis; + private _setupVoiceTranscriptOverlay(inputContainerEl: HTMLElement): void { + inputContainerEl.style.position = 'relative'; + const transcriptOverlay = $('.voice-transcript-overlay'); + // Leave bottom 36px for the toolbar (Agent, model picker, mic, send) + transcriptOverlay.style.cssText = 'display:none;position:absolute;top:0;left:0;right:0;bottom:36px;z-index:10;padding:8px 12px;font-size:13px;line-height:1.4;word-break:break-word;overflow:hidden;pointer-events:none;background:var(--vscode-input-background, transparent);border-radius:inherit;border-bottom-left-radius:0;border-bottom-right-radius:0;'; + inputContainerEl.append(transcriptOverlay); - // Also observe the inner bar — its content changes (onboarding → - // connected) before the outer wrapper resizes. - this._voiceBarResizeObserver?.observe(bar); + const style = document.createElement('style'); + style.textContent = ` + @keyframes voiceTextPulse { 0%,100%{opacity:0.9} 50%{opacity:0.4} } + .voice-transcript-overlay .committed { color: var(--vscode-foreground); } + .voice-transcript-overlay .partial { color: var(--vscode-foreground); opacity:0.6; font-style:italic; animation: voiceTextPulse 1.5s ease-in-out infinite; } + .voice-transcript-overlay .assistant-text { color: var(--vscode-descriptionForeground); display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; } + `; + transcriptOverlay.append(style); - const widget = new AgentsVoiceWidget(bar, { - copilotIconSrc: FileAccess.asBrowserUri('vs/sessions/browser/media/sessions-icon.svg').toString(true), - connect: () => this.voiceSessionController.connect(win), - disconnect: () => this.voiceSessionController.disconnect(), - pttDown: () => { - if (!this.voiceSessionController.isConnected.get() && !this.voiceSessionController.isConnecting.get()) { - this.voiceSessionController.connect(win).then(() => { - if (this.voiceSessionController.isConnected.get()) { - this.voiceSessionController.pttDown(); - } - }); + // Dynamic audio-reactive glow animation (matches aux window behavior) + let animFrameId: number | undefined; + let glowDataArray: Uint8Array | undefined; + const win = getWindow(inputContainerEl); + const startGlowAnimation = () => { + if (animFrameId !== undefined) { return; } + const animate = () => { + animFrameId = win.requestAnimationFrame(animate); + const connected = this.voiceSessionController.isConnected.get(); + const voiceState = this.voiceSessionController.voiceState.get(); + const glowActive = connected && (voiceState === 'listening' || voiceState === 'speaking'); + + if (!glowActive) { + inputContainerEl.style.borderColor = ''; + inputContainerEl.style.boxShadow = ''; + inputContainerEl.classList.remove('voice-active', 'voice-listening'); return; } - this.voiceSessionController.pttDown(); - }, - pttUp: () => this.voiceSessionController.pttUp(), - closeWindow: () => { /* no-op: chat pane has no close button */ }, - stopPlayback: () => this.ttsPlaybackService.stopPlayback(), - openSession: (resource) => { - this.viewState.sessionResource = resource; - this.applyModel(); - }, - stopSession: (resource) => { - const model = this.chatService.getSession(resource); - if (model) { - const lastReq = model.getRequests().at(-1); - if (lastReq) { - this.voiceSessionController.markUserCancelled(resource.toString()); - this.chatService.cancelCurrentRequestForSession(resource); - } - } - }, - cancelSession: (resource) => { - this.voiceSessionController.markUserCancelled(resource.toString()); - this.chatService.cancelCurrentRequestForSession(resource); - }, - selectTargetSession: (resource) => { - this.voiceSessionController.setTargetSession(resource); - }, - newSessionAsTarget: () => { - this.voiceSessionController.newSessionAsTarget(); - }, - getAnalyserNode: () => { - const state = this.voiceSessionController.voiceState.get(); - return this.ttsPlaybackService.analyserNode - ?? (state === 'listening' ? this.micCaptureService.analyserNode : null) + + // Get audio intensity from analyser + const analyser = this.ttsPlaybackService.analyserNode + ?? (voiceState === 'listening' ? this.micCaptureService.analyserNode : null) ?? null; - }, - onResize: () => { - if (this.lastDimensions) { - this.layoutBody(this.lastDimensions.height, this.lastDimensions.width); + let intensity: number; + if (!analyser) { + intensity = 0.3; + } else { + if (!glowDataArray || glowDataArray.length !== analyser.frequencyBinCount) { + glowDataArray = new Uint8Array(analyser.frequencyBinCount); + } + analyser.getByteFrequencyData(glowDataArray as Uint8Array); + let sum = 0; + for (let i = 0; i < glowDataArray.length; i++) { + sum += glowDataArray[i]; + } + intensity = Math.min(1, (sum / glowDataArray.length) / 80); } - }, - openPttKeySettings: () => this.commandService.executeCommand('workbench.action.openGlobalKeybindings', 'agentsVoice.pushToTalk'), - openPopout: () => this.commandService.executeCommand('agentsVoice.toggleWindow'), - submitFeedback: (text) => this.voiceSessionController.submitFeedback(text), - onOnboardingCompleted: () => { - this.storageService.store(AgentsVoiceStorageKeys.OnboardingCompleted, true, StorageScope.PROFILE, StorageTarget.USER); - this.telemetryService.publicLog2('voiceOnboardingCompleted', {}); - }, - }, { - width: 'auto', - draggable: false, - showClose: false, - showExpandChevron: false, - showStatusText: false, - showStatusCounters: false, - showCopilotIcon: false, - centerConnectButton: false, - title: localize('agentsVoice.voiceChatTitle', "Voice Mode"), - focusable: true, - reshowOnboardingOnDisconnect: false, - }); - this._voiceBarDisposables.add(widget); - // Set context key for voice widget focus (drives Space keybinding) - const widgetFocusedKey = AGENTS_VOICE_WIDGET_FOCUSED.bindTo(this.contextKeyService); - bar.addEventListener('focusin', () => widgetFocusedKey.set(true)); - bar.addEventListener('focusout', () => widgetFocusedKey.set(false)); - this._voiceBarDisposables.add({ dispose: () => widgetFocusedKey.reset() }); + // Blue when listening, purple when speaking + const rgb = voiceState === 'speaking' ? '163,113,247' : '88,166,255'; + const borderAlpha = 0.4 + intensity * 0.5; + const shadowSpread = 4 + intensity * 12; + const shadowAlpha = 0.15 + intensity * 0.35; + inputContainerEl.style.borderColor = `rgba(${rgb},${borderAlpha})`; + inputContainerEl.style.boxShadow = `0 0 ${shadowSpread}px rgba(${rgb},${shadowAlpha}), inset 0 0 ${shadowSpread * 0.4}px rgba(${rgb},${shadowAlpha * 0.3})`; + inputContainerEl.classList.add('voice-active'); + inputContainerEl.classList.toggle('voice-listening', voiceState === 'listening'); + }; + animFrameId = win.requestAnimationFrame(animate); + }; + const stopGlowAnimation = () => { + if (animFrameId !== undefined) { + win.cancelAnimationFrame(animFrameId); + animFrameId = undefined; + } + inputContainerEl.style.borderColor = ''; + inputContainerEl.style.boxShadow = ''; + inputContainerEl.classList.remove('voice-active', 'voice-listening'); + }; - // Hide the popout button when the floating window is already open. - widget.setPopoutAvailable(!this.agentsVoiceWindowService.isOpen); - this._voiceBarDisposables.add(this.agentsVoiceWindowService.onDidChangeOpen(isOpen => { - widget.setPopoutAvailable(!isOpen); + this._register(autorun(reader => { + const connected = this.voiceSessionController.isConnected.read(reader); + const voiceState = this.voiceSessionController.voiceState.read(reader); + if (connected && (voiceState === 'listening' || voiceState === 'speaking')) { + startGlowAnimation(); + } else { + stopGlowAnimation(); + } })); + this._register({ dispose: () => stopGlowAnimation() }); - // PTT key label from keybinding - const getPttLabel = () => this.keybindingService2.lookupKeybinding('agentsVoice.pushToTalk')?.getLabel() ?? undefined; - widget.setPttKeyLabel(getPttLabel()); - this._voiceBarDisposables.add(this.keybindingService2.onDidUpdateKeybindings(() => { - widget.setPttKeyLabel(getPttLabel()); + this._register(autorun(reader => { + const turns = this.voiceSessionController.transcriptTurns.read(reader); + const connected = this.voiceSessionController.isConnected.read(reader); + const voiceState = this.voiceSessionController.voiceState.read(reader); + const showTranscript = this.configurationService.getValue('agents.voice.showTranscript') !== false; + const visible = turns.filter(t => t.text.length > 0 || (t.speaker === 'user' && t.isPartial)); + + if (!connected) { + transcriptOverlay.style.display = 'none'; + return; + } + + // If aux window is open and voice is targeting a different session, + // don't show transcript in the chat input — it's shown in aux window instead. + const targetSession = this.voiceSessionController.targetSession.read(reader); + const currentSession = this._widget?.viewModel?.sessionResource; + if (this.agentsVoiceWindowService.isOpen && targetSession && currentSession && targetSession.toString() !== currentSession.toString()) { + transcriptOverlay.style.display = 'none'; + return; + } + + // Show hint when connected but no transcript yet + if (visible.length === 0 || !showTranscript) { + if (voiceState === 'idle' && visible.length === 0) { + transcriptOverlay.style.display = ''; + while (transcriptOverlay.childNodes.length > 1) { + transcriptOverlay.removeChild(transcriptOverlay.lastChild!); + } + const hint = $('span.partial'); + const kb = this.keybindingService.lookupKeybinding('agentsVoice.pushToTalk'); + const kbLabel = kb?.getLabel(); + hint.textContent = kbLabel + ? localize('voiceMode.pttHint', "Press {0} to talk", kbLabel) + : localize('voiceMode.clickMicHint', "Click mic to talk"); + transcriptOverlay.append(hint); + } else { + transcriptOverlay.style.display = 'none'; + } + return; + } + + transcriptOverlay.style.display = ''; + // Show only the latest turn: user question first, then assistant reply replaces it + const lastTurn = visible[visible.length - 1]; + const contentElements: HTMLElement[] = []; + if (lastTurn.speaker === 'user') { + const span = $('span'); + if (lastTurn.isPartial) { + const committedPart = lastTurn.committed || ''; + const unsurePart = lastTurn.text.slice(committedPart.length); + if (committedPart) { + const c = $('span.committed'); + c.textContent = committedPart; + span.append(c); + } + const u = $('span.partial'); + u.textContent = unsurePart + '\u2589'; + span.append(u); + } else { + span.className = 'committed'; + span.textContent = lastTurn.text; + } + contentElements.push(span); + } else { + const div = $('div.assistant-text'); + div.textContent = lastTurn.text; + contentElements.push(div); + } + // Keep the style element, replace content + while (transcriptOverlay.childNodes.length > 1) { + transcriptOverlay.removeChild(transcriptOverlay.lastChild!); + } + for (const el of contentElements) { + transcriptOverlay.append(el); + } })); - - // Shared controller→widget binding (also used by the floating window) - this._voiceBarDisposables.add(bindWidgetToController(widget, { - voiceSessionController: this.voiceSessionController, - agentSessionsService: this.agentSessionsService, - agentTitleBarStatusService: this.agentTitleBarStatusService, - voicePlaybackService: this.voicePlaybackService, - environmentService: this.workbenchEnvironmentService, - chatService: this.chatService, - })); - - this._voiceBarDisposables.add({ dispose: () => { this.voiceSessionController.disconnect(); } }); } //#endregion @@ -1203,9 +1239,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { let remainingHeight = height; const remainingWidth = width; - // Voice bottom area — read current height (ResizeObserver triggers - // relayout whenever the content changes size). - remainingHeight -= this._voiceBottomArea?.offsetHeight ?? 0; + // Voice bar is now inside the input container, no separate height deduction needed // Title Control const titleHeight = this.titleControl?.getHeight() ?? 0; diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css index edc68225a6a..c1415f8d2f0 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css @@ -3,12 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* Voice Agent Bar — wraps the shared AgentsVoiceWidget */ +/* Voice Agent Bar — wraps the shared AgentsVoiceWidget inside input container */ .voice-agent-bar { display: flex; flex-direction: column; flex-shrink: 0; - border-top: 1px solid var(--vscode-panel-border); + border-bottom: 1px solid var(--vscode-panel-border); position: relative; overflow: hidden; } @@ -18,7 +18,7 @@ display: flex; flex-direction: column; - /* wrapper that holds sessions + chat controls, voice bar sits below this */ + /* wrapper that holds sessions + chat controls */ > .voice-agent-controls-wrapper { display: flex; flex-direction: column; @@ -46,15 +46,6 @@ } } -/* Bottom area: tab bar + voice panel, pinned to bottom of viewpane */ -.chat-viewpane > .voice-bottom-area { - display: flex; - flex-direction: column; - flex-shrink: 0; - flex-grow: 0; - overflow: hidden; -} - /* Sessions control: either sidebar or stacked */ .chat-viewpane.has-sessions-control .agent-sessions-container { display: flex; diff --git a/src/vs/workbench/contrib/chat/electron-browser/actions/voiceChatActions.ts b/src/vs/workbench/contrib/chat/electron-browser/actions/voiceChatActions.ts index bf4af290a4b..1af6ac2b281 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/actions/voiceChatActions.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/actions/voiceChatActions.ts @@ -26,12 +26,12 @@ import { IKeybindingService } from '../../../../../platform/keybinding/common/ke import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { contrastBorder, focusBorder } from '../../../../../platform/theme/common/colorRegistry.js'; +import { editorInfoForeground } from '../../../../../platform/theme/common/colors/editorColors.js'; import { spinningLoading, syncing } from '../../../../../platform/theme/common/iconRegistry.js'; import { isHighContrast } from '../../../../../platform/theme/common/theme.js'; import { registerThemingParticipant } from '../../../../../platform/theme/common/themeService.js'; import { ActiveEditorContext } from '../../../../common/contextkeys.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; -import { ACTIVITY_BAR_FOREGROUND } from '../../../../common/theme.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IHostService } from '../../../../services/host/browser/host.js'; import { IWorkbenchLayoutService, Parts } from '../../../../services/layout/browser/layoutService.js'; @@ -1243,7 +1243,7 @@ registerThemingParticipant((theme, collector) => { let activeRecordingColor: Color | undefined; let activeRecordingDimmedColor: Color | undefined; if (!isHighContrast(theme.type)) { - activeRecordingColor = theme.getColor(ACTIVITY_BAR_FOREGROUND) ?? theme.getColor(focusBorder); + activeRecordingColor = theme.getColor(editorInfoForeground) ?? theme.getColor(focusBorder); activeRecordingDimmedColor = activeRecordingColor?.transparent(0.38); } else { activeRecordingColor = theme.getColor(contrastBorder); From 4a6e32fc1f008aceb40ce232b60b7272ef354892 Mon Sep 17 00:00:00 2001 From: Alexandru Dima Date: Sat, 20 Jun 2026 02:08:10 +0200 Subject: [PATCH 19/25] copilot: disable process.report in copilot extension (#322178) Disable process.report in copilot extension Override process.report.getReport to return undefined, loaded as the first import in the copilot extension entrypoint. Cherry-picked from #321998 and #322036. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../vscode-node/disableProcessReport.ts | 17 +++++++++++++++++ .../extension/vscode-node/extension.ts | 3 +++ 2 files changed, 20 insertions(+) create mode 100644 extensions/copilot/src/extension/extension/vscode-node/disableProcessReport.ts diff --git a/extensions/copilot/src/extension/extension/vscode-node/disableProcessReport.ts b/extensions/copilot/src/extension/extension/vscode-node/disableProcessReport.ts new file mode 100644 index 00000000000..4f5c5c48c81 --- /dev/null +++ b/extensions/copilot/src/extension/extension/vscode-node/disableProcessReport.ts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +if (process.report) { + try { + Object.defineProperty(process.report, 'getReport', { + value: () => undefined, + writable: true, + configurable: true, + enumerable: true + }); + } catch (err) { + + } +} diff --git a/extensions/copilot/src/extension/extension/vscode-node/extension.ts b/extensions/copilot/src/extension/extension/vscode-node/extension.ts index 053b2a4a4e8..8b802b6db7a 100644 --- a/extensions/copilot/src/extension/extension/vscode-node/extension.ts +++ b/extensions/copilot/src/extension/extension/vscode-node/extension.ts @@ -3,6 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +// Must be the first import to ensure it evaluates before other imports. +import './disableProcessReport'; + import { ExtensionContext } from 'vscode'; import { resolve } from '../../../util/vs/base/common/path'; import { baseActivate } from '../vscode/extension'; From 551477a8c6d15994c334547b463ba96742b26a08 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 19 Jun 2026 19:24:03 -0600 Subject: [PATCH 20/25] sessions: avoid classic agent host list in agents window (#322182) Move the local agent host chat-session item controller into an editor-window-only contribution so the Agents window keeps using its sessions provider list path without loading AgentHostSessionListController. Keep the shared agent-host contribution focused on chat content, models, auth, and customization wiring, and add focused tests for editor-window registration versus sessions-window suppression. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 3 +- .../agentHost/agentHostChatContribution.ts | 68 +------- .../agentHostSessionListContribution.ts | 149 ++++++++++++++++++ .../electron-browser/chat.contribution.ts | 2 + .../agentHostChatContribution.test.ts | 51 +++++- 5 files changed, 199 insertions(+), 74 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index a97fe663b56..33ca99e2cc3 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -31,6 +31,7 @@ Registered by `LocalAgentHostContribution` in `browser/localAgentHost.contributi - The same module also wires the heavy lifting from the workbench chat layer at `WorkbenchPhase.AfterRestored`: - `AgentHostContribution` — agent discovery, session-handler registration, language-model providers, customization harness (via `IChatSessionsService`). - `AgentHostTerminalContribution` — terminal integration for agent host sessions. + - The classic chat sidebar item controller is registered separately in the editor window only; the Agents window does not load or register `AgentHostSessionListController`. - Registers the experimental `chat.agentHost.defaultSessionsProvider` setting (`LocalAgentHostDefaultProviderSettingId`, default `false`, startup experiment). The Electron-only `electron-browser/agentHost.contribution.ts` adds desktop-only wiring on top. @@ -82,7 +83,7 @@ controller and the chat-content path are two unrelated APIs: | API | Responsibility | Used by the Agents window? | |-----|----------------|----------------------------| -| `IChatSessionItemController` (`registerChatSessionItemController`) | Enumerate session **items** (`.items`, `onDidChangeChatSessionItems`) for the **classic** chat sidebar list. | **No.** The agent host `ISessionsProvider` builds its own list via `getSessions()` straight from the connection (`listSessions()` / `notify/sessionAdded` / `rootState`). The workbench `AgentHostSessionListController` still implements this for the classic chat surfaces, but the Agents window never consumes it. | +| `IChatSessionItemController` (`registerChatSessionItemController`) | Enumerate session **items** (`.items`, `onDidChangeChatSessionItems`) for the **classic** chat sidebar list. | **No.** The agent host `ISessionsProvider` builds its own list via `getSessions()` straight from the connection (`listSessions()` / `notify/sessionAdded` / `rootState`). The workbench `AgentHostSessionListController` is registered only for classic chat surfaces in the editor window; the Agents window neither loads nor consumes it. | | `IChatSessionContentProvider` (`registerChatSessionContentProvider`) | Load a session's **chat content** (history/turns) for a resource, provide input completions, and handle the request stream. | **Yes — this is the only API on the chat path.** | The classic `ChatWidget` is generic: it renders whatever `IChatModel` it is diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index 02a0f33e649..c499f232459 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -7,9 +7,8 @@ import { Codicon } from '../../../../../../base/common/codicons.js'; import { Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; -import { type URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; -import { AgentHostEnabledSettingId, claudePreferAgentHostSettingId, IAgentHostService, shouldSurfaceLocalAgentHostProvider, type AgentProvider, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AgentHostEnabledSettingId, claudePreferAgentHostSettingId, IAgentHostService, shouldSurfaceLocalAgentHostProvider, type AgentProvider } from '../../../../../../platform/agentHost/common/agentService.js'; import { type ProtectedResourceMetadata } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { type AgentInfo, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -30,7 +29,6 @@ import { authenticateProtectedResources, AgentHostAuthTokenCache, resolveAuthent import { AgentHostLanguageModelProvider, agentHostProviderSupportsAutoModel } from './agentHostLanguageModelProvider.js'; import { AgentHostSessionHandler } from './agentHostSessionHandler.js'; import { IAgentHostActiveClientService } from './agentHostActiveClientService.js'; -import { AgentHostSessionListController, IAgentHostSessionListConnection } from './agentHostSessionListController.js'; import { AICustomizationSources } from '../../../common/aiCustomizationWorkspaceService.js'; const LOCAL_AGENT_HOST_SESSION_TYPE_PREFIX = 'agent-host-'; @@ -79,57 +77,12 @@ function getLocalAgentHostProviderForSessionType(sessionType: string): AgentProv return sessionType.slice(LOCAL_AGENT_HOST_SESSION_TYPE_PREFIX.length) || undefined; } -/** - * Shared session-list connection used by all local agent-host list controllers. - * - * The agent host exposes a single provider-agnostic `listSessions()` RPC, while - * the workbench registers one {@link AgentHostSessionListController} per agent - * provider. Those controllers can refresh at the same time during startup, - * reconnect, or workspace changes. This wrapper keeps the controller coupled - * only to the minimal list-session surface and joins concurrent refreshes onto - * one in-flight `listSessions()` request so the agent host does not repeat the - * same session enumeration work for every provider. - */ -export class CoalescingAgentHostSessionListConnection implements IAgentHostSessionListConnection { - - private _listSessionsInFlight: Promise | undefined; - - constructor( - private readonly _delegate: IAgentHostService, - ) { } - - get onDidNotification(): IAgentHostSessionListConnection['onDidNotification'] { - return this._delegate.onDidNotification; - } - - disposeSession(session: URI): Promise { - return this._delegate.disposeSession(session); - } - - listSessions(): Promise { - if (this._listSessionsInFlight) { - return this._listSessionsInFlight; - } - - const request = this._delegate.listSessions(); - this._listSessionsInFlight = request; - const clear = () => { - if (this._listSessionsInFlight === request) { - this._listSessionsInFlight = undefined; - } - }; - request.then(clear, clear); - return request; - } -} - export { AgentHostSessionHandler } from './agentHostSessionHandler.js'; -export { AgentHostSessionListController } from './agentHostSessionListController.js'; /** * Discovers available agents from the agent host process and dynamically * registers each one as a chat session type with its own session handler, - * list controller, and language model provider. + * customization harness, and language model provider. * * Gated on the `chat.agentHost.enabled` setting. */ @@ -140,9 +93,6 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr private readonly _agentRegistrations = this._register(new DisposableMap()); /** Model providers keyed by agent provider, for pushing model updates. */ private readonly _modelProviders = new Map(); - /** List controllers keyed by agent provider, for cache resets on reconnect. */ - private readonly _listControllers = new Map(); - private readonly _sessionListConnection: CoalescingAgentHostSessionListConnection; /** Dedupes redundant `authenticate` RPCs when the resolved token hasn't changed. */ private readonly _authTokenCache = new AgentHostAuthTokenCache(); @@ -165,10 +115,8 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr @IAgentHostActiveClientService private readonly _activeClientService: IAgentHostActiveClientService, ) { super(); - this._isSessionsWindow = environmentService.isSessionsWindow; this._enableSmokeTestDriver = !!environmentService.enableSmokeTestDriver; - this._sessionListConnection = new CoalescingAgentHostSessionListConnection(this._agentHostService); if (!this._configurationService.getValue(AgentHostEnabledSettingId)) { return; @@ -183,13 +131,8 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr // Clear the auth cache whenever the local agent host (re)starts so the // first post-restart authenticate RPC is never skipped as "unchanged". - // Also reset each list controller's session cache so the next refresh - // re-fetches via listSessions() rather than serving a stale in-memory list. this._register(this._agentHostService.onAgentHostStart(() => { this._authTokenCache.clear(); - for (const controller of this._listControllers.values()) { - controller.resetCache(); - } })); // Process initial root state if already available @@ -303,12 +246,6 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr }, })); - // Session list controller - const listController = store.add(this._instantiationService.createInstance(AgentHostSessionListController, sessionType, agent.provider, this._sessionListConnection, undefined, 'local')); - this._listControllers.set(agent.provider, listController); - store.add({ dispose: () => this._listControllers.delete(agent.provider) }); - store.add(this._chatSessionsService.registerChatSessionItemController(sessionType, listController)); - const agentRegistration = store.add(this._activeClientService.registerForAgent(sessionType)); const syncProvider = agentRegistration.syncProvider; @@ -334,7 +271,6 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr description: agent.description, connection: this._agentHostService, connectionAuthority: 'local', - isNewSession: sessionResource => listController.isNewSession(sessionResource), resolveAuthentication: (resources) => this._resolveAuthenticationInteractively(resources), })); store.add(this._chatSessionsService.registerChatSessionContentProvider(sessionType, sessionHandler)); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts new file mode 100644 index 00000000000..2f3265079d3 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { type URI } from '../../../../../../base/common/uri.js'; +import { AgentHostEnabledSettingId, claudePreferAgentHostSettingId, IAgentHostService, shouldSurfaceLocalAgentHostProvider, type AgentProvider, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agentService.js'; +import { type AgentInfo, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchContribution } from '../../../../../common/contributions.js'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; +import { IChatSessionsService } from '../../../common/chatSessionsService.js'; +import { IAgentHostSessionWorkingDirectoryResolver } from './agentHostSessionWorkingDirectoryResolver.js'; +import { AgentHostSessionListController, IAgentHostSessionListConnection } from './agentHostSessionListController.js'; + +/** + * Shared session-list connection used by all local agent-host list controllers. + * + * The agent host exposes a single provider-agnostic `listSessions()` RPC, while + * the workbench registers one {@link AgentHostSessionListController} per agent + * provider. Those controllers can refresh at the same time during startup, + * reconnect, or workspace changes. This wrapper keeps the controller coupled + * only to the minimal list-session surface and joins concurrent refreshes onto + * one in-flight `listSessions()` request so the agent host does not repeat the + * same session enumeration work for every provider. + */ +export class CoalescingAgentHostSessionListConnection implements IAgentHostSessionListConnection { + + private _listSessionsInFlight: Promise | undefined; + + constructor( + private readonly _delegate: IAgentHostService, + ) { } + + get onDidNotification(): IAgentHostSessionListConnection['onDidNotification'] { + return this._delegate.onDidNotification; + } + + disposeSession(session: URI): Promise { + return this._delegate.disposeSession(session); + } + + listSessions(): Promise { + if (this._listSessionsInFlight) { + return this._listSessionsInFlight; + } + + const request = this._delegate.listSessions(); + this._listSessionsInFlight = request; + const clear = () => { + if (this._listSessionsInFlight === request) { + this._listSessionsInFlight = undefined; + } + }; + request.then(clear, clear); + return request; + } +} + +export class AgentHostSessionListContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.agentHostSessionListContribution'; + + private readonly _agentRegistrations = this._register(new DisposableMap()); + private readonly _listControllers = new Map(); + private readonly _sessionListConnection: CoalescingAgentHostSessionListConnection; + + private readonly _isSessionsWindow: boolean; + + constructor( + @IAgentHostService private readonly _agentHostService: IAgentHostService, + @IChatSessionsService private readonly _chatSessionsService: IChatSessionsService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, + @IAgentHostSessionWorkingDirectoryResolver private readonly _workingDirectoryResolver: IAgentHostSessionWorkingDirectoryResolver, + ) { + super(); + + this._isSessionsWindow = environmentService.isSessionsWindow; + this._sessionListConnection = new CoalescingAgentHostSessionListConnection(this._agentHostService); + + if (this._isSessionsWindow || !this._configurationService.getValue(AgentHostEnabledSettingId)) { + return; + } + + this._register(this._agentHostService.rootState.onDidChange(rootState => { + this._handleRootStateChange(rootState); + })); + + this._register(this._agentHostService.onAgentHostStart(() => { + for (const controller of this._listControllers.values()) { + controller.resetCache(); + } + })); + + const initialRootState = this._agentHostService.rootState.value; + if (initialRootState && !(initialRootState instanceof Error)) { + this._handleRootStateChange(initialRootState); + } + + this._register(this._configurationService.onDidChangeConfiguration(e => { + const relevantSetting = claudePreferAgentHostSettingId(this._isSessionsWindow); + if (!e.affectsConfiguration(relevantSetting)) { + return; + } + const current = this._agentHostService.rootState.value; + if (current && !(current instanceof Error)) { + this._handleRootStateChange(current); + } + })); + } + + private _shouldRegisterAgent(provider: AgentProvider): boolean { + return shouldSurfaceLocalAgentHostProvider(provider, this._configurationService, this._isSessionsWindow); + } + + private _handleRootStateChange(rootState: RootState): void { + const allowed = rootState.agents.filter(agent => this._shouldRegisterAgent(agent.provider)); + const incoming = new Set(allowed.map(agent => agent.provider)); + + for (const [provider] of this._agentRegistrations) { + if (!incoming.has(provider)) { + this._agentRegistrations.deleteAndDispose(provider); + } + } + + for (const agent of allowed) { + if (!this._agentRegistrations.has(agent.provider)) { + this._registerAgent(agent); + } + } + } + + private _registerAgent(agent: AgentInfo): void { + const store = new DisposableStore(); + this._agentRegistrations.set(agent.provider, store); + + const sessionType = `agent-host-${agent.provider}`; + const listController = store.add(this._instantiationService.createInstance(AgentHostSessionListController, sessionType, agent.provider, this._sessionListConnection, undefined, 'local')); + this._listControllers.set(agent.provider, listController); + store.add(toDisposable(() => this._listControllers.delete(agent.provider))); + + store.add(this._chatSessionsService.registerChatSessionItemController(sessionType, listController)); + store.add(this._workingDirectoryResolver.registerResolver(sessionType, _sessionResource => undefined, sessionResource => listController.isNewSession(sessionResource))); + } +} diff --git a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts index 306afeb3e55..8383329504e 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts @@ -32,6 +32,7 @@ import { IWorkbenchLayoutService } from '../../../services/layout/browser/layout import { ILifecycleService, ShutdownReason } from '../../../services/lifecycle/common/lifecycle.js'; import { ACTION_ID_NEW_CHAT, CHAT_OPEN_ACTION_ID, IChatViewOpenOptions } from '../browser/actions/chatActions.js'; import { AgentHostContribution } from '../browser/agentSessions/agentHost/agentHostChatContribution.js'; +import { AgentHostSessionListContribution } from '../browser/agentSessions/agentHost/agentHostSessionListContribution.js'; import { AgentHostTerminalContribution } from '../browser/agentSessions/agentHost/agentHostTerminalContribution.js'; import { AgentSessionProviders, getAgentSessionProviderName } from '../browser/agentSessions/agentSessions.js'; import { IAgentSessionsService } from '../browser/agentSessions/agentSessionsService.js'; @@ -260,6 +261,7 @@ registerWorkbenchContribution2(ChatCommandLineHandler.ID, ChatCommandLineHandler registerWorkbenchContribution2(ChatSuspendThrottlingHandler.ID, ChatSuspendThrottlingHandler, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatLifecycleHandler.ID, ChatLifecycleHandler, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentHostContribution.ID, AgentHostContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(AgentHostSessionListContribution.ID, AgentHostSessionListContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentHostTerminalContribution.ID, AgentHostTerminalContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(OpenWorkspaceInAgentsContribution.ID, OpenWorkspaceInAgentsContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(AgentsHandoffInputTipContribution.ID, AgentsHandoffInputTipContribution, WorkbenchPhase.Eventually); 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 0a5172de400..21acfceaa0e 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 @@ -33,15 +33,17 @@ import { ChatAgentLocation } from '../../../common/constants.js'; import { ChatRequestQueueKind, ElicitationState, IChatService, IChatMarkdownContent, IChatProgress, IChatTerminalToolInvocationData, IChatToolInputInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, IChatUsage, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; import { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { IChatSessionsService, type IChatSessionRequestHistoryItem, type IChatSessionsExtensionPoint } from '../../../common/chatSessionsService.js'; +import { IChatSessionsService, type IChatSessionItemController, type IChatSessionRequestHistoryItem, type IChatSessionsExtensionPoint } from '../../../common/chatSessionsService.js'; import { ILanguageModelsService, type ILanguageModelChatMetadata } from '../../../common/languageModels.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IOutputService } from '../../../../../services/output/common/output.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; -import { AgentHostContribution, AgentHostSessionListController, AgentHostSessionHandler, CoalescingAgentHostSessionListConnection } from '../../../browser/agentSessions/agentHost/agentHostChatContribution.js'; +import { AgentHostContribution, AgentHostSessionHandler } from '../../../browser/agentSessions/agentHost/agentHostChatContribution.js'; import { AgentHostLanguageModelProvider } from '../../../browser/agentSessions/agentHost/agentHostLanguageModelProvider.js'; +import { AgentHostSessionListContribution, CoalescingAgentHostSessionListConnection } from '../../../browser/agentSessions/agentHost/agentHostSessionListContribution.js'; +import { AgentHostSessionListController } from '../../../browser/agentSessions/agentHost/agentHostSessionListController.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { TestFileService } from '../../../../../test/common/workbenchTestServices.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; @@ -456,7 +458,7 @@ class MockChatWidgetService extends mock() { // ---- Helpers ---------------------------------------------------------------- -function createTestServices(disposables: DisposableStore, workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }, authServiceOverride?: Partial, languageModels?: ReadonlyMap, provisionalServiceOverride?: Partial) { +function createTestServices(disposables: DisposableStore, workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }, authServiceOverride?: Partial, languageModels?: ReadonlyMap, provisionalServiceOverride?: Partial, isSessionsWindow = false) { const instantiationService = disposables.add(new TestInstantiationService()); const agentHostService = new MockAgentHostService(); @@ -465,6 +467,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv const chatAgentService = new MockChatAgentService(); const chatWidgetService = new MockChatWidgetService(); const chatSessionContributions: IChatSessionsExtensionPoint[] = []; + const chatSessionItemControllers: { type: string; controller: IChatSessionItemController }[] = []; const openerService: { openedUrls: (string | URI)[]; openShouldFail: boolean; openResult: boolean } & Partial = { openedUrls: [], openShouldFail: false, @@ -487,7 +490,16 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv instantiationService.stub(IFileService, TestFileService); instantiationService.stub(ILabelService, MockLabelService); instantiationService.stub(IChatSessionsService, { - registerChatSessionItemController: () => toDisposable(() => { }), + registerChatSessionItemController: (type, controller) => { + const entry = { type, controller }; + chatSessionItemControllers.push(entry); + return toDisposable(() => { + const index = chatSessionItemControllers.indexOf(entry); + if (index >= 0) { + chatSessionItemControllers.splice(index, 1); + } + }); + }, registerChatSessionContentProvider: () => toDisposable(() => { }), registerChatSessionContribution: contribution => { chatSessionContributions.push(contribution); @@ -570,7 +582,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv resolve: sessionResource => workingDirectoryResolver?.resolve(sessionResource), isNewSession: sessionResource => workingDirectoryResolver?.isNewSession?.(sessionResource) ?? sessionResource.path.substring(1).startsWith('new-'), }); - instantiationService.stub(IWorkbenchEnvironmentService, { isSessionsWindow: false } as Partial); + instantiationService.stub(IWorkbenchEnvironmentService, { isSessionsWindow } as Partial); instantiationService.stub(IAgentHostCustomizationService, new NullAgentHostCustomizationService()); instantiationService.stub(IAgentHostUntitledProvisionalSessionService, { onDidChange: Event.None, @@ -621,7 +633,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv instantiationService.stub(IAgentHostActiveClientService, activeClientService); instantiationService.stub(IOpenerService, openerService as IOpenerService); - return { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService, activeClientService, seedActiveClient, chatSessionContributions, newSessionFolderService }; + return { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService, activeClientService, seedActiveClient, chatSessionContributions, chatSessionItemControllers, newSessionFolderService }; } function createContribution(disposables: DisposableStore, opts?: { authServiceOverride?: Partial; workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }; languageModels?: ReadonlyMap; provisionalServiceOverride?: Partial }) { @@ -4217,7 +4229,7 @@ suite('AgentHostChatContribution', () => { })); test('local agent contribution advertises image attachments', () => { - const { instantiationService, agentHostService, chatSessionContributions } = createTestServices(disposables); + const { instantiationService, agentHostService, chatSessionContributions, chatSessionItemControllers } = createTestServices(disposables); disposables.add(instantiationService.createInstance(AgentHostContribution)); agentHostService.setRootState({ @@ -4228,6 +4240,31 @@ suite('AgentHostChatContribution', () => { assert.deepStrictEqual(chatSessionContributions.map(c => ({ type: c.type, supportsImageAttachments: c.capabilities?.supportsImageAttachments })), [ { type: 'agent-host-copilot', supportsImageAttachments: true }, ]); + assert.deepStrictEqual(chatSessionItemControllers.map(c => c.type), []); + }); + + test('session list contribution registers item controller in editor window', () => { + const { instantiationService, agentHostService, chatSessionItemControllers } = createTestServices(disposables); + disposables.add(instantiationService.createInstance(AgentHostSessionListContribution)); + + agentHostService.setRootState({ + agents: [{ provider: 'copilot' as const, displayName: 'Agent Host - Copilot', description: 'test', models: [] }], + activeSessions: 0, + }); + + assert.deepStrictEqual(chatSessionItemControllers.map(c => c.type), ['agent-host-copilot']); + }); + + test('session list contribution does not register item controller in sessions window', () => { + const { instantiationService, agentHostService, chatSessionItemControllers } = createTestServices(disposables, undefined, undefined, undefined, undefined, true); + disposables.add(instantiationService.createInstance(AgentHostSessionListContribution)); + + agentHostService.setRootState({ + agents: [{ provider: 'copilot' as const, displayName: 'Agent Host - Copilot', description: 'test', models: [] }], + activeSessions: 0, + }); + + assert.deepStrictEqual(chatSessionItemControllers.map(c => c.type), []); }); }); From 5389822279a0afbbc92fc2774c29f4cfaa8b7b03 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 19 Jun 2026 19:24:28 -0600 Subject: [PATCH 21/25] Add build-fast npm script for fast full builds (#322181) * Add build-fast npm script for fast full builds Adds a 'build-fast' script that fully builds the repo with as little typechecking as possible by composing existing tasks: esbuild transpile for the core (transpile-client), tsgo compile for extensions (compile-extensions + compile-extension-media), the copilot extension (compile-copilot), and the codicon.ttf copy (copy-codicons). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove transpile and transpile-extensions scripts in favor of build-fast build-fast replaces the transpile script. The transpile-extensions npm script is no longer needed; CI invokes the gulp transpile-extensions task directly via `npm run gulp`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 02ced79b320..0918965b084 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,8 @@ "compile": "npm-run-all2 -lp compile-client compile-copilot", "compile-client": "npm run gulp compile", "compile-copilot": "npm --prefix extensions/copilot run compile", - "transpile": "npm-run-all2 -lp transpile-client transpile-extensions compile-copilot", - "transpile-extensions": "npm run gulp transpile-extensions compile-extension-media", + "build-fast": "npm-run-all2 -lp transpile-client build-fast-extensions compile-copilot", + "build-fast-extensions": "npm run gulp copy-codicons compile-extensions compile-extension-media", "typecheck-client": "tsgo --project ./src/tsconfig.json --noEmit --skipLibCheck", "codex:gen-protocol": "node build/codex/generate-protocol.mjs", "watch": "npm-run-all2 -lp watch-client-transpile watch-client watch-extensions watch-copilot", From d6085d180c8e73b17d5a84337be1e5e545ba86b6 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 19 Jun 2026 23:18:08 -0600 Subject: [PATCH 22/25] Fix Agent Host chat picker disposal (#322195) Cancel pending initial config resolution before the picker render store is disposed, and ignore late render attempts after disposal.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostChatInputPicker.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts index dd11c1b5e2f..27551174d06 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts @@ -11,7 +11,7 @@ import { BaseActionViewItem } from '../../../../../../base/browser/ui/actionbar/ import { Delayer } from '../../../../../../base/common/async.js'; import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; @@ -220,13 +220,12 @@ export function resolveConfigChipValue(isUntitled: boolean, serverValue: unknown */ export class AgentHostChatInputPicker extends Disposable { + private _container: HTMLElement | undefined; + private _initialResolved: { readonly sessionResource: URI; readonly result: ResolveSessionConfigResult } | undefined; + private readonly _initialResolveCts = this._registerInitialResolveCts(); private readonly _renderDisposables = this._register(new DisposableStore()); private readonly _filterDelayer = this._register(new Delayer[]>(200)); private readonly _subRef = this._register(new MutableDisposable; readonly backendSession: URI }>()); - private _container: HTMLElement | undefined; - - private _initialResolved: { readonly sessionResource: URI; readonly result: ResolveSessionConfigResult } | undefined; - private readonly _initialResolveCts = this._register(new MutableDisposable()); constructor( private readonly _widget: IChatWidget, @@ -257,6 +256,15 @@ export class AgentHostChatInputPicker extends Disposable { this._reattach(); } + private _registerInitialResolveCts(): MutableDisposable { + const cts = new MutableDisposable(); + this._register(toDisposable(() => { + this._container = undefined; + this._cancelInitialResolve(); + })); + return this._register(cts); + } + render(container: HTMLElement): void { this._container = container; container.classList.add('agent-host-chat-input-picker-host'); @@ -346,7 +354,7 @@ export class AgentHostChatInputPicker extends Disposable { } private _renderChip(): void { - if (!this._container) { + if (!this._container || this._renderDisposables.isDisposed) { return; } this._renderDisposables.clear(); From 8c2e53656ecfecc81202dfb2fb601fb55a64547d Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 19 Jun 2026 23:18:29 -0600 Subject: [PATCH 23/25] chat: simplify Agent Host Copilot label (#322179) * chat: simplify Agent Host Copilot label Use the shorter Copilot label for the Agent Host Copilot session when the local editor agent is disabled, while keeping the Agent Host-disambiguated name when both implementations can appear. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: update Agent Host Copilot naming expectations (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: always simplify Agent Host Copilot label (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: rename Agent Host Copilot display name (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: use Agent Host advertised names (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: remove redundant Agent Host Copilot label cases (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/node/claude/CONTEXT.md | 2 +- .../platform/agentHost/node/copilot/copilotAgent.ts | 2 +- .../agentHost/test/node/copilotAgent.test.ts | 13 +++++++++++++ .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 2 +- .../browser/baseAgentHostSessionsProvider.ts | 2 +- .../browser/localAgentHostSessionsProvider.ts | 5 ----- .../agentHost/agentHostChatContribution.ts | 10 +--------- .../chat/browser/agentSessions/agentSessions.ts | 2 +- .../chatSessions/chatSessions.contribution.ts | 3 ++- .../agentSessions/agentHostChatContribution.test.ts | 12 ++++++++++++ .../agentSessions/agentSessionViewModel.test.ts | 5 +++++ 11 files changed, 38 insertions(+), 20 deletions(-) diff --git a/src/vs/platform/agentHost/node/claude/CONTEXT.md b/src/vs/platform/agentHost/node/claude/CONTEXT.md index c392fc26c9f..ceb6c8f54bc 100644 --- a/src/vs/platform/agentHost/node/claude/CONTEXT.md +++ b/src/vs/platform/agentHost/node/claude/CONTEXT.md @@ -1890,7 +1890,7 @@ platform-shared properties). | | Shape | |---|---| | Returns | `IAgentDescriptor { provider, displayName, description }` ([agentService.ts:160-165](../../common/agentService.ts#L160-L165)) | -| CopilotAgent | Hardcoded literal `{ provider: 'copilotcli', displayName: 'Copilot CLI', description: '…' }` ([copilotAgent.ts:256-262](../copilot/copilotAgent.ts#L256-L262)) | +| CopilotAgent | Hardcoded literal `{ provider: 'copilotcli', displayName: 'Copilot', description: '…' }` ([copilotAgent.ts:256-262](../copilot/copilotAgent.ts#L256-L262)) | | Claude provider | Hardcoded literal `{ provider: 'claude', displayName: 'Claude', description: '…' }` | `AgentProvider` is `type AgentProvider = string` ([agentService.ts:158](../../common/agentService.ts#L158)) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 34b6ea80046..72027fe9ee6 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -422,7 +422,7 @@ export class CopilotAgent extends Disposable implements IAgent { getDescriptor(): IAgentDescriptor { return { provider: 'copilotcli', - displayName: 'Copilot CLI', + displayName: 'Copilot', description: 'Copilot SDK agent running in a dedicated process', }; } diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index e705a90d54f..71e05384e82 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -520,6 +520,19 @@ async function disposeAgent(agent: CopilotAgent): Promise { suite('CopilotAgent', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + test('advertises Copilot as its display name', async () => { + const agent = createTestAgent(disposables); + try { + assert.deepStrictEqual(agent.getDescriptor(), { + provider: 'copilotcli', + displayName: 'Copilot', + description: 'Copilot SDK agent running in a dedicated process', + }); + } finally { + await disposeAgent(agent); + } + }); + test('uses the Copilot CLI sibling worktrees root convention', () => { assert.strictEqual( getCopilotWorktreesRoot(URI.file('/Users/me/src/vscode')).fsPath, diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index 33ca99e2cc3..5d4cc783353 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -2,7 +2,7 @@ **Folder:** `src/vs/sessions/contrib/providers/agentHost/` -The agent host provider family backs sessions run by an **agent host** — an out-of-process (or in-process) agent runtime that exposes one or more agents (Copilot CLI, Codex, Claude, …) over the agent host protocol (`platform/agentHost`). It is the largest provider in the Agents window and is shared between the local window and remote hosts: +The agent host provider family backs sessions run by an **agent host** — an out-of-process (or in-process) agent runtime that exposes one or more agents (Copilot, Codex, Claude, …) over the agent host protocol (`platform/agentHost`). It is the largest provider in the Agents window and is shared between the local window and remote hosts: | Class | File | Purpose | |-------|------|---------| diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index bfea62dee75..ca876e4ceba 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -1485,7 +1485,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement */ protected abstract resourceSchemeForProvider(provider: string): string; - /** Format the human-readable label for a session type entry (e.g. `Copilot CLI`). */ + /** Format the human-readable label for a session type entry (e.g. `Copilot`). */ protected abstract _formatSessionTypeLabel(agentLabel: string): string; /** diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index 8a9654ac644..a655d3f390f 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -198,11 +198,6 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide } protected _formatSessionTypeLabel(agentLabel: string): string { - // Use the unadorned agent label (e.g. "Copilot") rather than tagging it - // with `[Agent Host]`. The session type id is shared with the extension-host - // Copilot CLI provider, so the filter menu / new-session picker entry - // covers both sets of sessions; the `[Agent Host]` tag belongs on the - // per-session workspace label, not the type label. return agentLabel; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index c499f232459..b6c080030a2 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -216,14 +216,6 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr const agentId = sessionType; const vendor = sessionType; - // In the Agents app, the agent-host displayName is unambiguous because - // only agent-host sessions exist there. In VS Code, the same picker - // also lists the extension-host harness with the same displayName - // (e.g. "Copilot CLI"), so suffix with "- Agent Host" to disambiguate. - const displayName = this._isSessionsWindow - ? agent.displayName - : localize('agentHost.displayName', "{0} - Agent Host", agent.displayName); - // Chat session contribution. // Keep the delegation picker available for local agent host sessions in // both VS Code and the Agents app so users can hand off (continue) their @@ -231,7 +223,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr store.add(this._chatSessionsService.registerChatSessionContribution({ type: sessionType, name: agentId, - displayName, + displayName: agent.displayName, description: agent.description, customAgentTarget: this._isSessionsWindow ? undefined : Target.GitHubCopilot, canDelegate: true, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index 4ea1b6e841d..29ffd11e263 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -73,7 +73,7 @@ export function getAgentSessionProviderName(provider: AgentSessionTarget): strin case AgentSessionProviders.Growth: return 'Growth'; case AgentSessionProviders.AgentHostCopilot: - return localize('chat.session.providerLabel.agentHostCopilot', "Copilot CLI [Agent Host]"); + return localize('chat.session.providerLabel.agentHostCopilot', "Copilot"); default: return provider; } diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 230fbe451cf..d11a6054d45 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -376,7 +376,8 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ const knownProvider = getAgentSessionProvider(type); if (knownProvider) { // Well-known provider — use hardcoded name - reader.store.add(registerNewSessionInPlaceAction(type, getAgentSessionProviderName(knownProvider))); + const label = getAgentSessionProviderName(knownProvider); + reader.store.add(registerNewSessionInPlaceAction(type, label)); } else { // Extension-contributed — use contribution metadata const contrib = this._contributions.get(type); 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 21acfceaa0e..93aa3218ac4 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 @@ -4266,6 +4266,18 @@ suite('AgentHostChatContribution', () => { assert.deepStrictEqual(chatSessionItemControllers.map(c => c.type), []); }); + + test('local agent contribution uses advertised display name', () => { + const services = createTestServices(disposables); + disposables.add(services.instantiationService.createInstance(AgentHostContribution)); + + services.agentHostService.setRootState({ + agents: [{ provider: 'testagent', displayName: 'Test Agent', description: 'test', models: [] }], + activeSessions: 0, + }); + + assert.strictEqual(services.chatSessionContributions[0].displayName, 'Test Agent'); + }); }); // ---- IAgentConnection unification ------------------------------------- diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts index ffb22f957db..ff1ee861755 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts @@ -2174,6 +2174,11 @@ suite('AgentSessions', () => { assert.strictEqual(icon.id, Codicon.copilot.id); }); + test('should return simplified AgentHostCopilot name', () => { + const name = getAgentSessionProviderName(AgentSessionProviders.AgentHostCopilot); + assert.strictEqual(name, 'Copilot'); + }); + test('should return correct name for Growth provider', () => { const name = getAgentSessionProviderName(AgentSessionProviders.Growth); assert.strictEqual(name, 'Growth'); From 06d84f5a8c022822645a1b70dd381470e85007da Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 19 Jun 2026 23:26:42 -0600 Subject: [PATCH 24/25] chat: Suppress missing Agent Host file read logs (#322200) * chat: Suppress missing Agent Host file read logs Suppress expected NotFound logging for file resource reads on both protocol client and server handling paths, while preserving warnings for synthetic resources, non-read requests, and non-missing read failures. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: Address Agent Host file read review Share missing file-read log suppression logic across protocol client and server paths, and avoid assuming read failures are always Error instances. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/remoteAgentHostProtocolClient.ts | 22 ++++++- .../agentHost/common/resourceReadLogging.ts | 27 ++++++++ .../platform/agentHost/node/agentService.ts | 14 ++++- .../agentHost/node/protocolServerHandler.ts | 13 +++- .../remoteAgentHostProtocolClient.test.ts | 47 +++++++++++++- .../agentHost/test/node/agentService.test.ts | 34 ++++++++++ .../test/node/protocolServerHandler.test.ts | 63 +++++++++++++++++-- 7 files changed, 205 insertions(+), 15 deletions(-) create mode 100644 src/vs/platform/agentHost/common/resourceReadLogging.ts diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 73d6d8ba16b..f89891f13e3 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -42,6 +42,7 @@ import type { OtlpExportLogsParams } from '../common/state/protocol/channels-otl import type { TelemetryCapabilities } from '../common/state/protocol/channels-otlp/state.js'; import type { InitializeResult } from '../common/state/protocol/common/commands.js'; import { dirname } from '../../../base/common/resources.js'; +import { isFileResourceRead } from '../common/resourceReadLogging.js'; const AHP_CLIENT_CONNECTION_CLOSED = -32000; @@ -95,6 +96,12 @@ interface IRemoteAgentHostExtensionCommandMap { 'shutdown': { params: undefined; result: void }; } +interface IPendingRequest { + readonly deferred: DeferredPromise; + readonly suppressNotFoundWarning: boolean; + readonly sentAt: number; +} + /** * High-level connection state of a {@link RemoteAgentHostProtocolClient}. * Exposed via {@link RemoteAgentHostProtocolClient.onDidChangeConnectionState} @@ -215,7 +222,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC private _state: ClientState = { kind: AgentHostClientState.Connecting }; /** Pending JSON-RPC requests keyed by request id. */ - private readonly _pendingRequests = new Map; sentAt: number }>(); + private readonly _pendingRequests = new Map(); private _nextRequestId = 1; /** @@ -1029,7 +1036,9 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC if (pending) { this._pendingRequests.delete(msg.id); if (hasKey(msg, { error: true })) { - this._logService.warn(`[RemoteAgentHostProtocol] Request ${msg.id} failed:`, msg.error); + if (this._shouldLogFailedRequest(pending, msg.error)) { + this._logService.warn(`[RemoteAgentHostProtocol] Request ${msg.id} failed:`, msg.error); + } pending.deferred.error(this._toProtocolError(msg.error)); } else { pending.deferred.complete(msg.result); @@ -1326,12 +1335,19 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC const id = this._nextRequestId++; const deferred = new DeferredPromise(); - this._pendingRequests.set(id, { deferred, sentAt: Date.now() }); + this._pendingRequests.set(id, { deferred, suppressNotFoundWarning: isFileResourceRead(method, params), sentAt: Date.now() }); const request: JsonRpcRequest = { jsonrpc: '2.0', id, method, params }; this._transport.send(request); return deferred.p as Promise; } + private _shouldLogFailedRequest(request: IPendingRequest, error: JsonRpcErrorResponse['error']): boolean { + if (error.code === AhpErrorCodes.NotFound && request.suppressNotFoundWarning) { + return false; + } + return true; + } + private _toProtocolError(error: JsonRpcErrorResponse['error']): ProtocolError { return new ProtocolError(error.code, error.message, error.data); } diff --git a/src/vs/platform/agentHost/common/resourceReadLogging.ts b/src/vs/platform/agentHost/common/resourceReadLogging.ts new file mode 100644 index 00000000000..fc596806973 --- /dev/null +++ b/src/vs/platform/agentHost/common/resourceReadLogging.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Schemas } from '../../../base/common/network.js'; +import { hasKey } from '../../../base/common/types.js'; +import { URI } from '../../../base/common/uri.js'; + +export function isFileResourceRead(method: string, params: unknown): boolean { + if (method !== 'resourceRead' || !hasUriParam(params)) { + return false; + } + const uri = params.uri; + if (typeof uri !== 'string') { + return false; + } + try { + return URI.parse(uri).scheme === Schemas.file; + } catch { + return false; + } +} + +function hasUriParam(params: unknown): params is { readonly uri: unknown } { + return typeof params === 'object' && params !== null && hasKey(params, { uri: true }); +} diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index e6065a8e801..cddc187f5ed 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -16,7 +16,7 @@ import { extname as resourcesExtname, isEqual, isEqualOrParent, joinPath } from import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { localize } from '../../../nls.js'; -import { FileChangeType, FileOperationError, FileOperationResult, FileSystemProviderErrorCode, IFileChange, IFileService, toFileSystemProviderErrorCode, type FileChangesEvent } from '../../files/common/files.js'; +import { FileChangeType, FileOperationError, FileOperationResult, FileSystemProviderErrorCode, IFileChange, IFileService, toFileOperationResult, toFileSystemProviderErrorCode, type FileChangesEvent } from '../../files/common/files.js'; import { InstantiationService } from '../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; import { ILogService } from '../../log/common/log.js'; @@ -1696,8 +1696,16 @@ export class AgentService extends Disposable implements IAgentService { encoding: ContentEncoding.Utf8, contentType: 'text/plain', }; - } catch (_e) { - throw new ProtocolError(AhpErrorCodes.NotFound, `Content not found: ${uri.toString()}`); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + const result = toFileOperationResult(error); + if (result === FileOperationResult.FILE_NOT_FOUND) { + throw new ProtocolError(AhpErrorCodes.NotFound, `Content not found: ${uri.toString()}`); + } + if (result === FileOperationResult.FILE_PERMISSION_DENIED) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${uri.toString()}`); + } + throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Failed to read content: ${uri.toString()}: ${toErrorMessage(error)}`); } } diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 0ad18cbc11b..1ced2fb12c7 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -20,6 +20,7 @@ import { VSCODE_UPGRADE_METHOD, type UnsupportedProtocolVersionErrorDataEx } fro import { getAgentHostManagementSocketPath, requestAgentHostUpgrade } from './agentHostUpgradeChannel.js'; import { AHP_AUTH_REQUIRED, + AhpErrorCodes, AHP_PROVIDER_NOT_FOUND, AHP_SESSION_NOT_FOUND, AHP_UNSUPPORTED_PROTOCOL_VERSION, @@ -50,6 +51,7 @@ import { type IOtlpLogRecord, type OtlpLogLevelName, } from '../common/otlp/otlpLogEmitter.js'; +import { isFileResourceRead } from '../common/resourceReadLogging.js'; /** Default capacity of the server-side action replay buffer. */ const REPLAY_BUFFER_CAPACITY = 1000; @@ -82,6 +84,13 @@ function jsonRpcErrorFrom(id: number, err: unknown): JsonRpcResponse { return jsonRpcError(id, JSON_RPC_INTERNAL_ERROR, message); } +function shouldLogFailedRequest(method: string, params: unknown, err: unknown): boolean { + if (!(err instanceof ProtocolError) || err.code !== AhpErrorCodes.NotFound || !isFileResourceRead(method, params)) { + return true; + } + return false; +} + /** True when `value` is a non-null params object (as opposed to an array or primitive). */ function isParamsObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); @@ -1204,7 +1213,9 @@ export class ProtocolServerHandler extends Disposable { this._logService.trace(`[ProtocolServer] Request '${method}' id=${id} succeeded`); client.transport.send(jsonRpcSuccess(id, result ?? null)); }).catch(err => { - this._logService.error(`[ProtocolServer] Request '${method}' failed`, err); + if (shouldLogFailedRequest(method, params, err)) { + this._logService.error(`[ProtocolServer] Request '${method}' failed`, err); + } client.transport.send(jsonRpcErrorFrom(id, err)); }); return; diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts index 1dfd2442f0b..7dc3039be81 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts @@ -12,7 +12,7 @@ import { observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { NullLogService } from '../../../log/common/log.js'; +import { ILogService, NullLogService } from '../../../log/common/log.js'; import { AgentHostClientState, RemoteAgentHostProtocolClient } from '../../browser/remoteAgentHostProtocolClient.js'; import { AgentHostPermissionMode, AgentHostResourcePermissionError, IAgentHostResourceService } from '../../common/agentHostResourceService.js'; import { ContentEncoding, ReconnectResultType } from '../../common/state/protocol/commands.js'; @@ -71,6 +71,14 @@ class CloseOnDisposeProtocolTransport extends TestProtocolTransport { } } +class CountingLogService extends NullLogService { + warnCount = 0; + + override warn(_message: string, ..._args: unknown[]): void { + this.warnCount++; + } +} + suite('RemoteAgentHostProtocolClient', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -122,8 +130,8 @@ suite('RemoteAgentHostProtocolClient', () => { }; } - function createClient(transport = disposables.add(new TestProtocolTransport()), permissionService = createPermissionService(), loadEstimator?: { hasHighLoad(): boolean }): { client: RemoteAgentHostProtocolClient; transport: TestProtocolTransport } { - const client = disposables.add(new RemoteAgentHostProtocolClient('test.example:1234', transport, loadEstimator, new NullLogService(), permissionService, new TestConfigurationService())); + function createClient(transport = disposables.add(new TestProtocolTransport()), permissionService = createPermissionService(), loadEstimator?: { hasHighLoad(): boolean }, logService: ILogService = new NullLogService()): { client: RemoteAgentHostProtocolClient; transport: TestProtocolTransport } { + const client = disposables.add(new RemoteAgentHostProtocolClient('test.example:1234', transport, loadEstimator, logService, permissionService, new TestConfigurationService())); return { client, transport }; } @@ -169,6 +177,39 @@ suite('RemoteAgentHostProtocolClient', () => { await assertRemoteProtocolError(resultPromise, { code: AhpErrorCodes.NotFound, message: 'Missing resource', data }); }); + test('does not warn for missing file resource reads', async () => { + const logService = new CountingLogService(); + const { client, transport } = createClient(undefined, undefined, undefined, logService); + const resultPromise = client.resourceRead(URI.file('/workspace/src/missing.ts')); + + transport.fireMessage({ jsonrpc: '2.0', id: 1, error: { code: AhpErrorCodes.NotFound, message: 'Content not found' } }); + + await assertRemoteProtocolError(resultPromise, { code: AhpErrorCodes.NotFound, message: 'Content not found' }); + assert.strictEqual(logService.warnCount, 0); + }); + + test('warns for non-file resource read NotFound errors', async () => { + const logService = new CountingLogService(); + const { client, transport } = createClient(undefined, undefined, undefined, logService); + const resultPromise = client.resourceRead(URI.parse('session-db:/missing')); + + transport.fireMessage({ jsonrpc: '2.0', id: 1, error: { code: AhpErrorCodes.NotFound, message: 'Missing snapshot' } }); + + await assertRemoteProtocolError(resultPromise, { code: AhpErrorCodes.NotFound, message: 'Missing snapshot' }); + assert.strictEqual(logService.warnCount, 1); + }); + + test('warns for non-read NotFound errors', async () => { + const logService = new CountingLogService(); + const { client, transport } = createClient(undefined, undefined, undefined, logService); + const resultPromise = client.resourceResolve({ channel: ROOT_STATE_URI, uri: URI.file('/workspace/src/missing.ts').toString() }); + + transport.fireMessage({ jsonrpc: '2.0', id: 1, error: { code: AhpErrorCodes.NotFound, message: 'Missing resource' } }); + + await assertRemoteProtocolError(resultPromise, { code: AhpErrorCodes.NotFound, message: 'Missing resource' }); + assert.strictEqual(logService.warnCount, 1); + }); + test('ignores response for unknown request id', () => { const { transport } = createClient(); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index e5f34def2d9..b197cc5ba31 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -37,6 +37,7 @@ import { createNoopGitService, createSessionDataService, TestSessionDatabase } f import { NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; import { buildSessionChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js'; import { type ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest } from '../../node/shared/copilotApiService.js'; +import { AhpErrorCodes, JSON_RPC_INTERNAL_ERROR, ProtocolError } from '../../common/state/sessionProtocol.js'; /** * Loads a JSONL fixture of raw Copilot SDK events, runs them through @@ -164,6 +165,39 @@ suite('AgentService (node dispatcher)', () => { }); }); + suite('resourceRead', () => { + + test('maps missing files to NotFound', async () => { + const uri = URI.from({ scheme: Schemas.inMemory, path: '/missing.txt' }); + + await assert.rejects( + () => service.resourceRead(uri), + (error: unknown) => error instanceof ProtocolError + && error.code === AhpErrorCodes.NotFound + && error.message === `Content not found: ${uri.toString()}` + ); + }); + + test('does not map all read failures to NotFound', async () => { + const uri = URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' }); + const originalReadFile = fileService.readFile.bind(fileService); + fileService.readFile = async resource => { + if (resource.toString() === uri.toString()) { + return Promise.reject('Injected unknown read failure'); + } + return originalReadFile(resource); + }; + disposables.add(toDisposable(() => fileService.readFile = originalReadFile)); + + await assert.rejects( + () => service.resourceRead(uri), + (error: unknown) => error instanceof ProtocolError + && error.code === JSON_RPC_INTERNAL_ERROR + && error.message === `Failed to read content: ${uri.toString()}: Injected unknown read failure` + ); + }); + }); + // ---- createSession -------------------------------------------------- suite('dispatchAction', () => { diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index dcae0709357..41c52f5eaec 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -12,10 +12,10 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import { FileType } from '../../../files/common/files.js'; import { type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentService, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agentService.js'; -import { CompletionsParams, CompletionsResult, ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult, ResourceMkdirParams, ResourceMkdirResult, ResourceResolveParams, ResourceResolveResult, ResourceCopyParams, ResourceCopyResult } from '../../common/state/protocol/commands.js'; +import { CompletionsParams, CompletionsResult, ContentEncoding, ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult, ResourceMkdirParams, ResourceMkdirResult, ResourceResolveParams, ResourceResolveResult, ResourceCopyParams, ResourceCopyResult } from '../../common/state/protocol/commands.js'; import { ActionType, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction } from '../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; -import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, AHP_UNSUPPORTED_PROTOCOL_VERSION, AHP_SESSION_NOT_FOUND, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; +import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, AhpErrorCodes, AHP_UNSUPPORTED_PROTOCOL_VERSION, AHP_SESSION_NOT_FOUND, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; import { MessageKind, ResponsePartKind, SessionStatus, ChangesetStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, type SessionSummary } from '../../common/state/sessionState.js'; import type { SessionAddedParams } from '../../common/state/protocol/notifications.js'; import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js'; @@ -70,11 +70,20 @@ class MockProtocolServer implements IProtocolServer { } } +class CountingLogService extends NullLogService { + errorCount = 0; + + override error(_message: string, ..._args: unknown[]): void { + this.errorCount++; + } +} + class MockAgentService implements IAgentService { declare readonly _serviceBrand: undefined; readonly handledActions: (SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction)[] = []; readonly browsedUris: URI[] = []; readonly browseErrors = new Map(); + readonly readErrors = new Map(); readonly listedSessions: IAgentSessionMetadata[] = []; readonly createSessionConfigs: (IAgentCreateSessionConfig | undefined)[] = []; @@ -155,8 +164,12 @@ class MockAgentService implements IAgentService { ], }; } - async resourceRead(_uri: URI): Promise { - throw new Error('Not implemented'); + async resourceRead(uri: URI): Promise { + const error = this.readErrors.get(uri.toString()); + if (error) { + throw error; + } + return { data: '', encoding: ContentEncoding.Utf8 }; } async resourceCopy(_params: ResourceCopyParams): Promise { return {}; } async resourceDelete(): Promise<{}> { return {}; } @@ -222,6 +235,7 @@ suite('ProtocolServerHandler', () => { let agentService: MockAgentService; let handler: ProtocolServerHandler; let fileSystemProvider: AgentHostFileSystemProvider; + let logService: CountingLogService; const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString(); @@ -254,6 +268,7 @@ suite('ProtocolServerHandler', () => { server = disposables.add(new MockProtocolServer()); agentService = new MockAgentService(); agentService.setStateManager(stateManager); + logService = new CountingLogService(); disposables.add(agentService); disposables.add(handler = new ProtocolServerHandler( agentService, @@ -261,7 +276,7 @@ suite('ProtocolServerHandler', () => { server, { defaultDirectory: URI.file('/home/testuser').toString() }, disposables.add(fileSystemProvider = new AgentHostFileSystemProvider()), - new NullLogService(), + logService, )); }); @@ -1327,6 +1342,44 @@ suite('ProtocolServerHandler', () => { assert.match(resp.error!.message, /Directory not found/); }); + test('resourceRead does not log missing file reads', async () => { + const transport = connectClient('client-read-missing-file'); + transport.sent.length = 0; + + const fileUri = URI.file('/missing').toString(); + agentService.readErrors.set(fileUri, new ProtocolError(AhpErrorCodes.NotFound, `Content not found: ${fileUri}`)); + const responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'resourceRead', { uri: fileUri })); + const resp = await responsePromise as { error?: { code: number; message: string } }; + + assert.deepStrictEqual({ + errorCode: resp.error?.code, + errorCount: logService.errorCount, + }, { + errorCode: AhpErrorCodes.NotFound, + errorCount: 0, + }); + }); + + test('resourceRead logs missing non-file reads', async () => { + const transport = connectClient('client-read-missing-session-db'); + transport.sent.length = 0; + + const resource = 'session-db:/missing'; + agentService.readErrors.set(resource, new ProtocolError(AhpErrorCodes.NotFound, `Content not found: ${resource}`)); + const responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'resourceRead', { uri: resource })); + const resp = await responsePromise as { error?: { code: number; message: string } }; + + assert.deepStrictEqual({ + errorCode: resp.error?.code, + errorCount: logService.errorCount, + }, { + errorCode: AhpErrorCodes.NotFound, + errorCount: 1, + }); + }); + // ---- Extension methods: auth ---------------------------------------- test('authenticate returns result via typed request', async () => { From 43e100f3ff5432f4a34fcc900b8cec01c2682a3a Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sat, 20 Jun 2026 03:50:16 -0600 Subject: [PATCH 25/25] chat: hide Agent Host Copilot response identity (#322203) Hide the chat response username and avatar for Agent Host Copilot responses so they match the default Copilot presentation while keeping other Agent Host agents distinguishable. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/widget/chatListRenderer.ts | 23 ++++++++++++--- .../browser/widget/chatListRenderer.test.ts | 29 ++++++++++++++++++- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 31dd5d3dd32..f90f3a93a26 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -43,6 +43,7 @@ import { IMarkdownRenderer } from '../../../../../platform/markdown/browser/mark import { isDark } from '../../../../../platform/theme/common/theme.js'; import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; +import { parseRemoteAgentHostSessionTypeAuthority } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { CodiconActionViewItem } from '../../../notebook/browser/view/cellParts/cellActionView.js'; import { annotateSpecialMarkdownContent, extractSubAgentInvocationIdFromText, hasCodeblockUriTag, hasEditCodeblockUriTag } from '../../common/widget/annotations.js'; @@ -54,7 +55,7 @@ import { chatSubcommandLeader } from '../../common/requestParser/chatParserTypes import { ChatAgentVoteDirection, ChatErrorLevel, ChatRequestQueueKind, IChatConfirmation, IChatContentReference, IChatDisabledClaudeHooksPart, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatExternalEdit, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatPlanReview, IChatPlanReviewResult, IChatPullRequestContent, IChatQuestionAnswerValue, IChatQuestionAnswers, IChatQuestionCarousel, IChatService, IChatTask, IChatTaskSerialized, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, isChatFollowup } from '../../common/chatService/chatService.js'; import { ChatPlanReviewData } from '../../common/model/chatProgressTypes/chatPlanReviewData.js'; import { ChatQuestionCarouselData } from '../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; -import { localChatSessionType } from '../../common/chatSessionsService.js'; +import { localChatSessionType, SessionType } from '../../common/chatSessionsService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { getExplicitFileOrImageAttachmentSummary, IChatRequestVariableEntry, isExplicitFileOrImageVariableEntry, isPasteVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { IChatChangesSummaryPart, IChatCodeCitations, IChatErrorDetailsPart, IChatReferences, IChatRendererContent, IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, IChatWorkingProgress, isRequestVM, isResponseVM, IChatPendingDividerViewModel, isPendingDividerVM } from '../../common/model/chatViewModel.js'; @@ -109,7 +110,7 @@ import { HookType } from '../../common/promptSyntax/hookTypes.js'; import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; import { AccessibilityWorkbenchSettingId } from '../../../accessibility/browser/accessibilityConfiguration.js'; import { isMcpToolInvocation } from './chatContentParts/toolInvocationParts/chatToolPartUtilities.js'; -import { isAgentHostTarget } from '../agentSessions/agentSessions.js'; +import { AgentSessionProviders, isAgentHostTarget } from '../agentSessions/agentSessions.js'; const $ = dom.$; @@ -185,6 +186,19 @@ export interface IChatRendererDelegate { const mostRecentResponseClassName = 'chat-most-recent-response'; +export function shouldHideChatUserIdentity(username: string, sessionResource: URI, isResponse: boolean, isSessionsWindow: boolean, isSystemInitiatedRequest: boolean): boolean { + const sessionType = getChatSessionType(sessionResource); + return username === COPILOT_USERNAME || + (isResponse && isAgentHostCopilotSessionType(sessionType)) || + isSessionsWindow || + isSystemInitiatedRequest; +} + +function isAgentHostCopilotSessionType(sessionType: string): boolean { + return sessionType === AgentSessionProviders.AgentHostCopilot || + parseRemoteAgentHostSessionTypeAuthority(sessionType, SessionType.CopilotCLI) !== undefined; +} + function upvoteAnimationSettingToEnum(value: string | undefined): ClickAnimation | undefined { switch (value) { case 'confetti': return ClickAnimation.Confetti; @@ -808,8 +822,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { ensureNoDisposablesAreLeakedInTestSuite(); @@ -27,4 +28,30 @@ suite('ChatListRenderer', () => { ]); }); }); + + suite('shouldHideChatUserIdentity', () => { + test('hides local Copilot and Agent Host Copilot response identity', () => { + assert.deepStrictEqual([ + shouldHideChatUserIdentity('GitHub Copilot', URI.from({ scheme: 'vscode-chat-editor' }), true, false, false), + shouldHideChatUserIdentity('Copilot', URI.from({ scheme: 'agent-host-copilotcli' }), true, false, false), + shouldHideChatUserIdentity('Copilot', URI.from({ scheme: 'agent-host-copilotcli' }), false, false, false), + shouldHideChatUserIdentity('Copilot', URI.from({ scheme: 'remote-test-authority-copilotcli' }), true, false, false), + shouldHideChatUserIdentity('Copilot', URI.from({ scheme: 'remote-test-authority-copilotcli' }), false, false, false), + shouldHideChatUserIdentity('Claude', URI.from({ scheme: 'remote-test-authority-claude' }), true, false, false), + shouldHideChatUserIdentity('Claude', URI.from({ scheme: 'agent-host-claude' }), true, false, false), + shouldHideChatUserIdentity('Claude', URI.from({ scheme: 'agent-host-claude' }), true, true, false), + shouldHideChatUserIdentity('User', URI.from({ scheme: 'vscode-chat-editor' }), false, false, true), + ], [ + true, + true, + false, + true, + false, + false, + false, + true, + true, + ]); + }); + }); });