From 611bf74afd7d96cdccfe249d2e05905000c96e19 Mon Sep 17 00:00:00 2001 From: AshtonYoon Date: Sat, 4 Apr 2026 10:13:01 +0900 Subject: [PATCH 01/19] markdown: fix scroll sync regressions introduced in #287050 Two regressions from the merge of #287050: 1. preview.ts: The merge retained `this.#isScrolling = false` inside the early-return guard of `scrollTo()`, which was intentionally removed in the original PR. This resets the timer-based flag on the very first forward-sync call, allowing subsequent editor scroll events to re-trigger forward sync while reverse sync is in progress, causing the editor to jump back up. 2. index.ts: The PR converted `onUpdateView` from a decrement-counter to a timer-based approach but left initialization and resize handlers still using `scrollDisabledCount += 1` without a corresponding timer reset. The old scroll handler decremented the counter naturally; the new handler only returns early. As a result, after page load `scrollDisabledCount` stays at 1 indefinitely, blocking all preview-to-editor sync until the user scrolls the editor once. Fixes: - Remove the erroneous `this.#isScrolling = false` from scrollTo() - Apply the same timer-reset pattern (200ms) to initialization and resize handlers so scrollDisabledCount is always auto-cleared Fixes #307762 --- .../preview-src/index.ts | 16 ++++++++++++---- .../src/preview/preview.ts | 1 - 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/extensions/markdown-language-features/preview-src/index.ts b/extensions/markdown-language-features/preview-src/index.ts index d481bb24e5351..087736a6c0e38 100644 --- a/extensions/markdown-language-features/preview-src/index.ts +++ b/extensions/markdown-language-features/preview-src/index.ts @@ -90,7 +90,9 @@ onceDocumentLoaded(() => { addImageContexts(); if (typeof scrollProgress === 'number' && !settings.settings.fragment) { doAfterImagesLoaded(() => { - scrollDisabledCount += 1; + scrollDisabledCount = 1; + if (scrollDisabledTimer) { clearTimeout(scrollDisabledTimer); } + scrollDisabledTimer = window.setTimeout(() => { scrollDisabledCount = 0; }, 200); // Always set scroll of at least 1 to prevent VS Code's webview code from auto scrolling us const scrollToY = Math.max(1, scrollProgress * document.body.clientHeight); window.scrollTo(0, scrollToY); @@ -113,12 +115,16 @@ onceDocumentLoaded(() => { const element = getLineElementForFragment(fragment, documentVersion); if (element) { - scrollDisabledCount += 1; + scrollDisabledCount = 1; + if (scrollDisabledTimer) { clearTimeout(scrollDisabledTimer); } + scrollDisabledTimer = window.setTimeout(() => { scrollDisabledCount = 0; }, 200); scrollToRevealSourceLine(element.line, documentVersion, settings); } } else { if (!isNaN(settings.settings.line!)) { - scrollDisabledCount += 1; + scrollDisabledCount = 1; + if (scrollDisabledTimer) { clearTimeout(scrollDisabledTimer); } + scrollDisabledTimer = window.setTimeout(() => { scrollDisabledCount = 0; }, 200); scrollToRevealSourceLine(settings.settings.line!, documentVersion, settings); } } @@ -152,7 +158,9 @@ const onUpdateView = (() => { })(); window.addEventListener('resize', () => { - scrollDisabledCount += 1; + scrollDisabledCount = 1; + if (scrollDisabledTimer) { clearTimeout(scrollDisabledTimer); } + scrollDisabledTimer = window.setTimeout(() => { scrollDisabledCount = 0; }, 200); updateScrollProgress(); }, true); diff --git a/extensions/markdown-language-features/src/preview/preview.ts b/extensions/markdown-language-features/src/preview/preview.ts index 4dc949eae0585..ee90c05c53569 100644 --- a/extensions/markdown-language-features/src/preview/preview.ts +++ b/extensions/markdown-language-features/src/preview/preview.ts @@ -235,7 +235,6 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider { } if (this.#isScrolling) { - this.#isScrolling = false; return; } From ffa3fecdf812cc66d3bfafea3ff60a5d8c6b4dc3 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:22:57 -0700 Subject: [PATCH 02/19] Disable emmet commands when there is no active editor Fixes #313645 Co-authored-by: Copilot --- extensions/emmet/package.json | 44 +++++++++++++++++------------------ 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/extensions/emmet/package.json b/extensions/emmet/package.json index 67f834f913920..5ec0afb7ba53c 100644 --- a/extensions/emmet/package.json +++ b/extensions/emmet/package.json @@ -384,91 +384,91 @@ "commandPalette": [ { "command": "editor.emmet.action.wrapWithAbbreviation", - "when": "!activeEditorIsReadonly" + "when": "activeEditor && !activeEditorIsReadonly" }, { "command": "editor.emmet.action.removeTag", - "when": "!activeEditorIsReadonly" + "when": "activeEditor && !activeEditorIsReadonly" }, { "command": "editor.emmet.action.updateTag", - "when": "!activeEditorIsReadonly" + "when": "activeEditor && !activeEditorIsReadonly" }, { "command": "editor.emmet.action.matchTag", - "when": "!activeEditorIsReadonly" + "when": "activeEditor && !activeEditorIsReadonly" }, { "command": "editor.emmet.action.balanceIn", - "when": "!activeEditorIsReadonly" + "when": "activeEditor && !activeEditorIsReadonly" }, { "command": "editor.emmet.action.balanceOut", - "when": "!activeEditorIsReadonly" + "when": "activeEditor && !activeEditorIsReadonly" }, { "command": "editor.emmet.action.prevEditPoint", - "when": "!activeEditorIsReadonly" + "when": "activeEditor && !activeEditorIsReadonly" }, { "command": "editor.emmet.action.nextEditPoint", - "when": "!activeEditorIsReadonly" + "when": "activeEditor && !activeEditorIsReadonly" }, { "command": "editor.emmet.action.mergeLines", - "when": "!activeEditorIsReadonly" + "when": "activeEditor && !activeEditorIsReadonly" }, { "command": "editor.emmet.action.selectPrevItem", - "when": "!activeEditorIsReadonly" + "when": "activeEditor && !activeEditorIsReadonly" }, { "command": "editor.emmet.action.selectNextItem", - "when": "!activeEditorIsReadonly" + "when": "activeEditor && !activeEditorIsReadonly" }, { "command": "editor.emmet.action.splitJoinTag", - "when": "!activeEditorIsReadonly" + "when": "activeEditor && !activeEditorIsReadonly" }, { "command": "editor.emmet.action.toggleComment", - "when": "!activeEditorIsReadonly" + "when": "activeEditor && !activeEditorIsReadonly" }, { "command": "editor.emmet.action.evaluateMathExpression", - "when": "!activeEditorIsReadonly" + "when": "activeEditor && !activeEditorIsReadonly" }, { "command": "editor.emmet.action.updateImageSize", - "when": "!activeEditorIsReadonly" + "when": "activeEditor && !activeEditorIsReadonly" }, { "command": "editor.emmet.action.incrementNumberByOneTenth", - "when": "!activeEditorIsReadonly" + "when": "activeEditor && !activeEditorIsReadonly" }, { "command": "editor.emmet.action.incrementNumberByOne", - "when": "!activeEditorIsReadonly" + "when": "activeEditor && !activeEditorIsReadonly" }, { "command": "editor.emmet.action.incrementNumberByTen", - "when": "!activeEditorIsReadonly" + "when": "activeEditor && !activeEditorIsReadonly" }, { "command": "editor.emmet.action.decrementNumberByOneTenth", - "when": "!activeEditorIsReadonly" + "when": "activeEditor && !activeEditorIsReadonly" }, { "command": "editor.emmet.action.decrementNumberByOne", - "when": "!activeEditorIsReadonly" + "when": "activeEditor && !activeEditorIsReadonly" }, { "command": "editor.emmet.action.decrementNumberByTen", - "when": "!activeEditorIsReadonly" + "when": "activeEditor && !activeEditorIsReadonly" }, { "command": "editor.emmet.action.reflectCSSValue", - "when": "!activeEditorIsReadonly" + "when": "activeEditor && !activeEditorIsReadonly" } ] } From dd112d44b504be4d19946d071bf7551199eb6c63 Mon Sep 17 00:00:00 2001 From: Paul Date: Fri, 1 May 2026 11:36:02 -0700 Subject: [PATCH 03/19] Add usage hover and style tweaks (#313743) --- .../vscode-node/workspaceIndexingStatus.ts | 8 +- src/vs/base/common/defaultAccount.ts | 1 + .../browser/chatStatus/chatStatusDashboard.ts | 61 +++++++++++---- .../browser/chatStatus/media/chatStatus.css | 17 ++-- .../test/browser/chatStatusDashboard.test.ts | 78 +++++++++++++++++++ .../chat/common/chatEntitlementService.ts | 3 + 6 files changed, 145 insertions(+), 23 deletions(-) diff --git a/extensions/copilot/src/extension/workspaceChunkSearch/vscode-node/workspaceIndexingStatus.ts b/extensions/copilot/src/extension/workspaceChunkSearch/vscode-node/workspaceIndexingStatus.ts index c3e35962c25d3..a3d6d9080141d 100644 --- a/extensions/copilot/src/extension/workspaceChunkSearch/vscode-node/workspaceIndexingStatus.ts +++ b/extensions/copilot/src/extension/workspaceChunkSearch/vscode-node/workspaceIndexingStatus.ts @@ -154,11 +154,15 @@ export class ChatStatusWorkspaceIndexingStatus extends Disposable { if (readyRepos.length) { return this._writeStatusItem({ primary: { - message: t('{0} repos with indexes', readyRepos.length), + message: readyRepos.length === 1 + ? t('1 repo with index') + : t('{0} repos with indexes', readyRepos.length), icon: '$(warning)', }, details: { - message: t(`[Try re-authenticating for {0} additional repos](${commandUri(reauthenticateCommandId, [inaccessibleRepo])} "${t('Try signing in again to use the codebase index')}")`, errorRepos.length), + message: errorRepos.length === 1 + ? t(`[Try re-authenticating for 1 additional repo](${commandUri(reauthenticateCommandId, [inaccessibleRepo])} "${t('Try signing in again to use the codebase index')}")`) + : t(`[Try re-authenticating for {0} additional repos](${commandUri(reauthenticateCommandId, [inaccessibleRepo])} "${t('Try signing in again to use the codebase index')}")`, errorRepos.length), busy: false, }, }); diff --git a/src/vs/base/common/defaultAccount.ts b/src/vs/base/common/defaultAccount.ts index ed92b0514f9f4..83b98183bcf20 100644 --- a/src/vs/base/common/defaultAccount.ts +++ b/src/vs/base/common/defaultAccount.ts @@ -10,6 +10,7 @@ export interface IQuotaSnapshotData { readonly unlimited: boolean; readonly quota_reset_at?: number; readonly token_based_billing?: boolean; + readonly entitlement?: string; } export interface ILegacyQuotaSnapshotData { diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts index c9e8858ec6309..432871c889859 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts @@ -97,6 +97,7 @@ export class ChatStatusDashboard extends DomWidget { private readonly dateFormatter = safeIntl.DateTimeFormat(language, { month: 'short', day: 'numeric' }); private readonly timeFormatter = safeIntl.DateTimeFormat(language, { hour: 'numeric', minute: 'numeric' }); private readonly quotaPercentageFormatter = safeIntl.NumberFormat(undefined, { maximumFractionDigits: 0, minimumFractionDigits: 0 }); + private readonly quotaCreditsFormatter = safeIntl.NumberFormat(language, { maximumFractionDigits: 2, minimumFractionDigits: 0 }); constructor( private readonly options: IChatStatusDashboardOptions | undefined, @@ -313,11 +314,11 @@ export class ChatStatusDashboard extends DomWidget { } disclosureHeader.setAttribute('aria-expanded', String(!collapsed)); + disclosureHeader.appendChild($('span.collapsible-label', undefined, localize('inlineSuggestionsTab', "Inline Suggestions"))); + chevron = disclosureHeader.appendChild($('span.collapsible-chevron')); chevron.classList.add(...ThemeIcon.asClassNameArray(collapsed ? Codicon.chevronRight : Codicon.chevronDown)); - disclosureHeader.appendChild($('span.collapsible-label', undefined, localize('inlineSuggestionsTab', "Inline Suggestions"))); - statusEl = disclosureHeader.appendChild($('span.collapsible-status', undefined, getStatusText())); } @@ -369,14 +370,14 @@ export class ChatStatusDashboard extends DomWidget { : $('button.collapsible-header') ); let chevron: HTMLElement | undefined; + disclosureHeader.appendChild($('span.collapsible-label', undefined, headerLabel)); + if (!nonCollapsible) { disclosureHeader.setAttribute('aria-expanded', String(!collapsed)); chevron = disclosureHeader.appendChild($('span.collapsible-chevron')); chevron.classList.add(...ThemeIcon.asClassNameArray(collapsed ? Codicon.chevronRight : Codicon.chevronDown)); } - disclosureHeader.appendChild($('span.collapsible-label', undefined, headerLabel)); - // Use renderLabelWithIcons for header status (plain text + icons only, no links inside button) const statusEl = disclosureHeader.appendChild($('span.collapsible-status')); statusEl.append(...renderLabelWithIcons(item.description)); @@ -669,13 +670,16 @@ export class ChatStatusDashboard extends DomWidget { resetValue.textContent = resetLabel; } + const quotaPercentage = $('div.quota-percentage', undefined, + quotaValue, + quotaValueSuffix + ); + quotaPercentage.tabIndex = 0; + container.appendChild($('div.quota-indicator', undefined, $('div.quota-title', undefined, label), $('div.quota-details', undefined, - $('div.quota-percentage', undefined, - quotaValue, - quotaValueSuffix - ), + quotaPercentage, resetValue ), $('div.quota-bar', undefined, @@ -683,7 +687,39 @@ export class ChatStatusDashboard extends DomWidget { ) )); + let currentQuota: IQuotaSnapshot | string = quota; + let isHovered = false; + + const showPercentage = () => { + if (typeof currentQuota === 'string') { + quotaValue.textContent = currentQuota; + quotaValueSuffix.textContent = ''; + } else { + const usedPercentage = Math.max(0, 100 - currentQuota.percentRemaining); + quotaValue.textContent = localize('quotaDisplay', "{0}%", this.quotaPercentageFormatter.value.format(Math.floor(usedPercentage))); + quotaValueSuffix.textContent = ` ${localize('quotaUsed', "used")}`; + } + }; + + const showCredits = () => { + if (typeof currentQuota !== 'string' && currentQuota.entitlement !== undefined) { + const total = currentQuota.entitlement; + const used = total * (100 - currentQuota.percentRemaining) / 100; + const usedFormatted = this.quotaCreditsFormatter.value.format(used); + const totalFormatted = this.quotaCreditsFormatter.value.format(total); + quotaValue.textContent = localize('quotaCreditsDisplay', "{0} / {1}", usedFormatted, totalFormatted); + quotaValueSuffix.textContent = ` ${localize('quotaUsed', "used")}`; + } + }; + + this._store.add(addDisposableListener(quotaPercentage, EventType.MOUSE_ENTER, () => { isHovered = true; showCredits(); })); + this._store.add(addDisposableListener(quotaPercentage, EventType.MOUSE_LEAVE, () => { isHovered = false; showPercentage(); })); + this._store.add(addDisposableListener(quotaPercentage, EventType.FOCUS, () => { isHovered = true; showCredits(); })); + this._store.add(addDisposableListener(quotaPercentage, EventType.BLUR, () => { isHovered = false; showPercentage(); })); + const update = (quota: IQuotaSnapshot | string) => { + currentQuota = quota; + let usedPercentage: number; if (typeof quota === 'string') { usedPercentage = 0; @@ -691,14 +727,11 @@ export class ChatStatusDashboard extends DomWidget { usedPercentage = Math.max(0, 100 - quota.percentRemaining); } - if (typeof quota === 'string') { - quotaValue.textContent = quota; - quotaValueSuffix.textContent = ''; + if (isHovered) { + showCredits(); } else { - quotaValue.textContent = localize('quotaDisplay', "{0}%", this.quotaPercentageFormatter.value.format(Math.floor(usedPercentage))); - quotaValueSuffix.textContent = ` ${localize('quotaUsed', "used")}`; + showPercentage(); } - quotaBit.style.width = `${usedPercentage}%`; }; diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css b/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css index e36be613ce668..2963e7dfd886b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css @@ -6,7 +6,7 @@ /* Overall */ .chat-status-bar-entry-tooltip { - padding: 14px; + padding: 8px; min-width: 320px; max-width: 360px; } @@ -23,7 +23,6 @@ align-items: center; gap: 6px; width: 100%; - margin-top: 8px; padding: 6px 0 6px 0; border: none; border-top: 1px solid var(--vscode-editorWidget-border); @@ -31,7 +30,7 @@ cursor: pointer; font-size: 13px; font-family: inherit; - font-weight: 600; + font-weight: 400; color: var(--vscode-foreground); } @@ -53,6 +52,9 @@ font-size: 12px; display: flex; align-items: center; + flex-shrink: 0; + opacity: 0.7; + margin-top: 2px; } .chat-status-bar-entry-tooltip .collapsible-label { @@ -131,14 +133,15 @@ color: var(--vscode-foreground); font-size: 13px; line-height: 18px; - padding-bottom: 12px; - margin-bottom: 12px; + padding-bottom: 10px; + margin-bottom: 6px; border-bottom: 1px solid var(--vscode-editorWidget-border); font-weight: 400; } .chat-status-bar-entry-tooltip div.header .header-label { flex-grow: 1; + font-weight: 400; } .chat-status-bar-entry-tooltip div.header .monaco-action-bar { @@ -183,8 +186,8 @@ .chat-status-bar-entry-tooltip .quota-indicator .quota-title { font-size: 13px; line-height: 18px; - font-weight: 400; - color: var(--vscode-descriptionForeground); + font-weight: 600; + color: var(--vscode-foreground); margin-bottom: 4px; } diff --git a/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts index a10e57679f44e..67ae701cb15a6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts @@ -21,6 +21,7 @@ interface IQuotaConfig { unlimited: boolean; usageBasedBilling?: boolean; resetAt?: number; + entitlement?: number; } function createEntitlementService(opts: { @@ -298,4 +299,81 @@ suite('ChatStatusDashboard', () => { assert.deepStrictEqual(getQuotaLabels(dashboard.element), ['Monthly Limit']); }); + + // --- HOVER: CREDIT FRACTIONS --- + + test('Hover shows credit fractions when entitlement is available', () => { + const dashboard = createDashboard(createEntitlementService({ + chat: { percentRemaining: 80, unlimited: false, entitlement: 2000 }, + completions: { percentRemaining: 70, unlimited: false, entitlement: 5000 }, + entitlement: ChatEntitlement.Free, + })); + + const quotaPercentages = dashboard.element.querySelectorAll('.quota-indicator:not(.included) .quota-percentage'); + assert.strictEqual(quotaPercentages.length, 2); + + // Before hover: shows percentages + assert.deepStrictEqual(getQuotaValues(dashboard.element), ['20%', '30%']); + + // Hover: shows credit fractions + quotaPercentages[0].dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); + const chatValue = quotaPercentages[0].querySelector('.quota-value'); + assert.ok(chatValue?.textContent?.includes('/')); + + // Mouse leave: reverts to percentage + quotaPercentages[0].dispatchEvent(new MouseEvent('mouseleave', { bubbles: true })); + assert.deepStrictEqual(getQuotaValues(dashboard.element), ['20%', '30%']); + }); + + test('Hover is a no-op when entitlement is not available', () => { + const dashboard = createDashboard(createEntitlementService({ + chat: { percentRemaining: 80, unlimited: false }, + completions: { percentRemaining: 70, unlimited: false }, + entitlement: ChatEntitlement.Free, + })); + + const quotaPercentages = dashboard.element.querySelectorAll('.quota-indicator:not(.included) .quota-percentage'); + assert.strictEqual(quotaPercentages.length, 2); + + // Before hover: shows percentages + assert.deepStrictEqual(getQuotaValues(dashboard.element), ['20%', '30%']); + + // Hover: still shows percentages (no entitlement data) + quotaPercentages[0].dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); + assert.deepStrictEqual(getQuotaValues(dashboard.element), ['20%', '30%']); + }); + + test('Focus shows credit fractions (keyboard accessibility)', () => { + const dashboard = createDashboard(createEntitlementService({ + chat: { percentRemaining: 80, unlimited: false, entitlement: 2000 }, + completions: { percentRemaining: 70, unlimited: false, entitlement: 5000 }, + entitlement: ChatEntitlement.Free, + })); + + const quotaPercentages = dashboard.element.querySelectorAll('.quota-indicator:not(.included) .quota-percentage'); + assert.strictEqual(quotaPercentages.length, 2); + + // Before focus: shows percentages + assert.deepStrictEqual(getQuotaValues(dashboard.element), ['20%', '30%']); + + // Focus: shows credit fractions + quotaPercentages[0].dispatchEvent(new FocusEvent('focus', { bubbles: true })); + const chatValue = quotaPercentages[0].querySelector('.quota-value'); + assert.ok(chatValue?.textContent?.includes('/')); + + // Blur: reverts to percentage + quotaPercentages[0].dispatchEvent(new FocusEvent('blur', { bubbles: true })); + assert.deepStrictEqual(getQuotaValues(dashboard.element), ['20%', '30%']); + }); + + test('Quota percentage element is keyboard-focusable', () => { + const dashboard = createDashboard(createEntitlementService({ + chat: { percentRemaining: 80, unlimited: false, entitlement: 2000 }, + entitlement: ChatEntitlement.Free, + })); + + const quotaPercentage = dashboard.element.querySelector('.quota-indicator:not(.included) .quota-percentage') as HTMLElement; + assert.ok(quotaPercentage); + assert.strictEqual(quotaPercentage.tabIndex, 0); + }); }); diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index c223ae7fb6590..3ce93e672cba2 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -625,6 +625,7 @@ export interface IQuotaSnapshot { readonly unlimited: boolean; readonly resetAt?: number; readonly usageBasedBilling?: boolean; + readonly entitlement?: number; } interface IQuotas { @@ -798,11 +799,13 @@ export class ChatEntitlementRequests extends Disposable { if (!rawQuotaSnapshot) { continue; } + const parsedEntitlement = rawQuotaSnapshot.entitlement !== undefined ? Number(rawQuotaSnapshot.entitlement) : undefined; const quotaSnapshot: IQuotaSnapshot = { percentRemaining: Math.min(100, Math.max(0, rawQuotaSnapshot.percent_remaining)), unlimited: rawQuotaSnapshot.unlimited, usageBasedBilling: rawQuotaSnapshot.token_based_billing, resetAt: rawQuotaSnapshot.quota_reset_at || undefined, + entitlement: parsedEntitlement !== undefined && Number.isSafeInteger(parsedEntitlement) && parsedEntitlement >= 0 ? parsedEntitlement : undefined, }; switch (quotaType) { From 0b758f43d395270c1b3caddaf25d7a42ea8d2388 Mon Sep 17 00:00:00 2001 From: Paul Date: Fri, 1 May 2026 12:01:27 -0700 Subject: [PATCH 04/19] Make chat input notification text always inline (#313768) --- .../widget/input/media/chatInputNotificationWidget.css | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotificationWidget.css b/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotificationWidget.css index 40e88561850f6..b3bb4ccb58e0b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotificationWidget.css +++ b/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotificationWidget.css @@ -81,13 +81,12 @@ white-space: nowrap; } -/* Body row: description + actions inline, wraps at small widths */ +/* Body row: description + actions inline */ .chat-input-notification .chat-input-notification-body { display: flex; align-items: center; gap: 8px; min-width: 0; - flex-wrap: wrap; } /* Description */ @@ -96,7 +95,10 @@ line-height: 18px; color: var(--vscode-descriptionForeground); flex: 1 1 auto; - min-width: 150px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } /* Actions container */ From c1df4d90096ffecb39d64244c70f793e70b8833d Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Fri, 1 May 2026 12:09:55 -0700 Subject: [PATCH 05/19] Remove agentHistorySummarizationInline experiment (#313757) Make background inline summarization the default and only path. --- extensions/copilot/package.json | 10 - extensions/copilot/package.nls.json | 1 - .../src/extension/intents/node/agentIntent.ts | 375 ++++++++---------- .../node/agent/backgroundSummarizer.ts | 23 +- .../agent/summarizedConversationHistory.tsx | 25 +- .../agent/test/backgroundSummarizer.spec.ts | 39 +- .../node/agent/test/summarization.spec.tsx | 18 +- .../common/configurationService.ts | 1 - 8 files changed, 209 insertions(+), 283 deletions(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 0dcfc623a552c..711272c5417c4 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -4499,16 +4499,6 @@ "experimental" ] }, - "github.copilot.chat.agentHistorySummarizationInline": { - "type": "boolean", - "default": true, - "markdownDescription": "%github.copilot.config.agentHistorySummarizationInline%", - "tags": [ - "advanced", - "experimental", - "onExp" - ] - }, "github.copilot.chat.useResponsesApiTruncation": { "type": "boolean", "default": false, diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index abc75223e7547..6a71d1a03dc9e 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -398,7 +398,6 @@ "github.copilot.config.instantApply.shortContextLimit": "Token limit for short context instant apply.", "github.copilot.config.summarizeAgentConversationHistoryThreshold": "Threshold for compacting agent conversation history.", "github.copilot.config.agentHistorySummarizationMode": "Mode for agent history summarization.", - "github.copilot.config.agentHistorySummarizationInline": "Summarize conversation inline within the agent loop instead of a separate LLM call, maximizing prompt cache hits.", "github.copilot.config.useResponsesApiTruncation": "Use Responses API for truncation.", "github.copilot.config.enableReadFileV2": "Enable version 2 of the read file tool.", "github.copilot.config.enableAskAgent": "Enable the Ask agent for answering questions.", diff --git a/extensions/copilot/src/extension/intents/node/agentIntent.ts b/extensions/copilot/src/extension/intents/node/agentIntent.ts index dc32c17f87d57..4004004142df4 100644 --- a/extensions/copilot/src/extension/intents/node/agentIntent.ts +++ b/extensions/copilot/src/extension/intents/node/agentIntent.ts @@ -49,7 +49,7 @@ import { IBuildPromptResult, IIntent, IIntentInvocation } from '../../prompt/nod import { AgentPrompt, AgentPromptProps } from '../../prompts/node/agent/agentPrompt'; import { BackgroundSummarizationState, BackgroundSummarizer, IBackgroundSummarizationResult, shouldKickOffBackgroundSummarization } from '../../prompts/node/agent/backgroundSummarizer'; import { AgentPromptCustomizations, PromptRegistry } from '../../prompts/node/agent/promptRegistry'; -import { extractInlineSummary, InlineSummarizationUserMessage, SummarizedConversationHistory, SummarizedConversationHistoryMetadata, SummarizedConversationHistoryPropsBuilder, appendTranscriptHintToSummary, computeSummarizationRoundCounts } from '../../prompts/node/agent/summarizedConversationHistory'; +import { extractSummary, SummarizationUserMessage, SummarizedConversationHistory, SummarizedConversationHistoryMetadata, SummarizedConversationHistoryPropsBuilder, appendTranscriptHintToSummary, computeSummarizationRoundCounts } from '../../prompts/node/agent/summarizedConversationHistory'; import { PromptRenderer, renderPromptElement } from '../../prompts/node/base/promptRenderer'; import { ICodeMapperService } from '../../prompts/node/codeMapper/codeMapperService'; import { EditCodePrompt2 } from '../../prompts/node/panel/editCodePrompt2'; @@ -368,7 +368,7 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I private _lastModelCapabilities: { enableThinking: boolean; reasoningEffort: string | undefined; enableToolSearch: boolean; enableContextEditing: boolean } | undefined; /** - * RNG used to jitter the inline-summarization trigger threshold around 0.80. + * RNG used to jitter the background-summarization trigger threshold around 0.80. * Tests may overwrite this directly (e.g. `(invocation as any)._thresholdRng = () => 0.5`). */ private _thresholdRng: () => number = Math.random; @@ -436,7 +436,6 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I const useTruncation = this.endpoint.apiType === 'responses' && this.configurationService.getConfig(ConfigKey.Advanced.UseResponsesApiTruncation); const responsesCompactionContextManagementEnabled = isResponsesCompactionContextManagementEnabled(this.endpoint, this.configurationService, this.expService); const summarizationEnabled = this.configurationService.getConfig(ConfigKey.SummarizeAgentConversationHistory) && this.prompt === AgentPrompt && !responsesCompactionContextManagementEnabled; - const useInlineSummarization = summarizationEnabled && this.configurationService.getExperimentBasedConfig(ConfigKey.Advanced.AgentHistorySummarizationInline, this.expService); // When tools are present, apply a 10% safety margin on the message portion // to account for tokenizer discrepancies between our tool-token counter and @@ -477,8 +476,10 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I // BudgetExceeded: if bg is InProgress/Completed, wait/apply. // Otherwise fall back to foreground summarization. // - // Post-render (≥ 80% + Idle): kick off background compaction - // so it is ready for a future turn. + // Post-render: kick off background compaction if Idle and the + // jittered/emergency threshold is met (see + // shouldKickOffBackgroundSummarization) so it is + // ready for a future turn. // const backgroundSummarizer = summarizationEnabled ? this._getOrCreateBackgroundSummarizer(promptContext.conversation?.sessionId) : undefined; const contextRatio = backgroundSummarizer && baseBudget > 0 @@ -689,12 +690,10 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I } // Post-render: kick off background compaction if idle and over the - // threshold. For the inline-summarization path we care about prompt - // cache parity with the main agent fetch — so we gate kick-off on a - // completed tool call (cache has been warmed) and jitter the threshold - // around 0.80 to avoid firing at the same exact boundary every time. - // The non-inline path forks its own prompt and sees no cache benefit, - // so it keeps the simple >= 0.80 behavior. + // threshold. Prompt cache parity with the main agent fetch matters + // here — so we gate kick-off on a completed tool call (cache has been + // warmed) and jitter the threshold around 0.80 to avoid firing at the + // same exact boundary every time. if (summarizationEnabled && backgroundSummarizer && !didSummarizeThisIteration) { const postRenderRatio = baseBudget > 0 ? (result.tokenCount + toolTokens) / baseBudget @@ -705,28 +704,26 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I const cacheWarm = (promptContext.toolCallRounds?.length ?? 0) > 0; - const kickOff = shouldKickOffBackgroundSummarization(postRenderRatio, useInlineSummarization, cacheWarm, this._thresholdRng); + const kickOff = shouldKickOffBackgroundSummarization(postRenderRatio, cacheWarm, this._thresholdRng); if (kickOff && idleOrFailed) { - if (useInlineSummarization) { - // Compute and cache model capabilities from the current render's - // messages. These must match the main agent fetch for cache parity. - const strippedMessages = ToolCallingLoop.stripInternalToolCallIds(result.messages); - const rawEffort = this.request.modelConfiguration?.reasoningEffort; - const isSubagent = !!this.request.subAgentInvocationId; - // Must match the main agent's enableThinking logic in - // toolCallingLoop.ts runOne() — thinking is only disabled - // on continuation turns for Anthropic when no thinking - // blocks exist yet in the messages. - const shouldDisableThinking = !!promptContext.isContinuation && isAnthropicFamily(this.endpoint) && !ToolCallingLoop.messagesContainThinking(strippedMessages); - this._lastModelCapabilities = { - enableThinking: !shouldDisableThinking, - reasoningEffort: typeof rawEffort === 'string' ? rawEffort : undefined, - enableToolSearch: !isSubagent && !!this.endpoint.supportsToolSearch, - enableContextEditing: !isSubagent && isAnthropicContextEditingEnabled(this.endpoint, this.configurationService, this.expService), - }; - } - this._startBackgroundSummarization(backgroundSummarizer, result.messages, promptContext, props, token, postRenderRatio, useInlineSummarization); + // Compute and cache model capabilities from the current render's + // messages. These must match the main agent fetch for cache parity. + const strippedMessages = ToolCallingLoop.stripInternalToolCallIds(result.messages); + const rawEffort = this.request.modelConfiguration?.reasoningEffort; + const isSubagent = !!this.request.subAgentInvocationId; + // Must match the main agent's enableThinking logic in + // toolCallingLoop.ts runOne() — thinking is only disabled + // on continuation turns for Anthropic when no thinking + // blocks exist yet in the messages. + const shouldDisableThinking = !!promptContext.isContinuation && isAnthropicFamily(this.endpoint) && !ToolCallingLoop.messagesContainThinking(strippedMessages); + this._lastModelCapabilities = { + enableThinking: !shouldDisableThinking, + reasoningEffort: typeof rawEffort === 'string' ? rawEffort : undefined, + enableToolSearch: !isSubagent && !!this.endpoint.supportsToolSearch, + enableContextEditing: !isSubagent && isAnthropicContextEditingEnabled(this.endpoint, this.configurationService, this.expService), + }; + this._startBackgroundSummarization(backgroundSummarizer, result.messages, promptContext, props, token, postRenderRatio); } } @@ -800,9 +797,8 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I props: AgentPromptProps, token: vscode.CancellationToken, contextRatio: number, - useInlineSummarization: boolean, ): void { - this.logService.debug(`[ConversationHistorySummarizer] context at ${(contextRatio * 100).toFixed(0)}% — starting background compaction (inline=${useInlineSummarization})`); + this.logService.debug(`[ConversationHistorySummarizer] context at ${(contextRatio * 100).toFixed(0)}% — starting background compaction`); const bgStartTime = Date.now(); @@ -853,187 +849,146 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I backgroundSummarizer.start(async bgToken => { try { - if (useInlineSummarization) { - // Inline mode: fork the exact messages from the main render - // and append a summary user message. The prompt prefix is - // byte-identical to the main agent loop for cache hits. - const strippedMainMessages = ToolCallingLoop.stripInternalToolCallIds(mainRenderMessages); - const summaryMsgResult = await renderPromptElement( - this.instantiationService, - this.endpoint, - InlineSummarizationUserMessage, - { endpoint: this.endpoint }, - undefined, - bgToken, - ); - const messages = [ - ...strippedMainMessages, - ...summaryMsgResult.messages, - ]; - - const response = await this.endpoint.makeChatRequest2({ - debugName: 'summarizeConversationHistory-inline', - messages, - finishedCb: undefined, - location: ChatLocation.Agent, - conversationId, - requestOptions: { - temperature: 0, - stream: false, - ...toolOpts, - }, - modelCapabilities, - telemetryProperties: associatedRequestId ? { associatedRequestId } : undefined, - enableRetryOnFilter: true, - }, bgToken); - if (response.type !== ChatFetchResponseType.Success) { - throw new Error(`Background inline summarization request failed: ${response.type}`); - } - const rawSummaryText = extractInlineSummary(response.value); - if (!rawSummaryText) { - throw new Error('Background inline summarization: no tags found in response'); - } - if (!toolCallRoundId) { - throw new Error('Background inline summarization: no round ID to apply summary to'); - } - // Flush the transcript before snapshotting the line count so - // the baked "N lines" hint matches the on-disk file at this - // moment (mirrors the full/simple path in SummarizedConversationHistory.render). - if (conversationId && this.sessionTranscriptService.getTranscriptPath(conversationId)) { - await this.sessionTranscriptService.flush(conversationId); - } - const summaryText = conversationId - ? appendTranscriptHintToSummary(rawSummaryText, conversationId, this.sessionTranscriptService) - : rawSummaryText; - this.logService.debug(`[ConversationHistorySummarizer] background inline compaction completed (${summaryText.length} chars, roundId=${toolCallRoundId})`); - - // Send summarizedConversationHistory telemetry for parity - // with the standard ConversationHistorySummarizer path. - const { numRounds, numRoundsSinceLastSummarization } = computeSummarizationRoundCounts(history, rounds); - const numRoundsInCurrentTurn = rounds.length; - const lastUsedTool = rounds.at(-1)?.toolCalls?.at(-1)?.name - ?? history.at(-1)?.rounds.at(-1)?.toolCalls?.at(-1)?.name ?? 'none'; - const promptTypes = messages.map(msg => `${msg.role}${'name' in msg && msg.name ? `-${msg.name}` : ''}:${getTextPart(msg.content).length}`).join(','); - /* __GDPR__ - "summarizedConversationHistory" : { - "owner": "bhavyau", - "comment": "Tracks background inline summarization outcome", - "outcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The success state." }, - "model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model ID." }, - "summarizationMode": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The summarization mode." }, - "conversationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Session id." }, - "chatRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The chat request ID." }, - "lastUsedTool": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The last tool used before summarization." }, - "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request ID from the summarization call." }, - "promptTypes": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Role and character count of each prompt message in order, as a proxy for cache hit rate (e.g. system:1234,user:567)." }, - "numRounds": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Total tool call rounds." }, - "turnIndex": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The index of the current turn." }, - "curTurnRoundIndex": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The index of the current round within the current turn." }, - "isDuringToolCalling": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Whether this was triggered during tool calling." }, - "duration": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Duration in ms." }, - "promptTokenCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Prompt tokens." }, - "promptCacheTokenCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Cached prompt tokens." }, - "responseTokenCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Output tokens." } - } - */ - this.telemetryService.sendMSFTTelemetryEvent('summarizedConversationHistory', { - outcome: 'success', - model: this.endpoint.model, - summarizationMode: 'inline', - conversationId, - chatRequestId: associatedRequestId, - lastUsedTool, - requestId: response.requestId, - promptTypes, - }, { - numRounds, - turnIndex: history.length, - curTurnRoundIndex: numRoundsInCurrentTurn, - isDuringToolCalling: numRoundsInCurrentTurn > 0 ? 1 : 0, - duration: Date.now() - bgStartTime, - promptTokenCount: response.usage?.prompt_tokens, - promptCacheTokenCount: response.usage?.prompt_tokens_details?.cached_tokens, - responseTokenCount: response.usage?.completion_tokens, - }); - - return { - summary: summaryText, - toolCallRoundId, - promptTokens: response.usage?.prompt_tokens, - promptCacheTokens: response.usage?.prompt_tokens_details?.cached_tokens, - outputTokens: response.usage?.completion_tokens, - durationMs: Date.now() - bgStartTime, - model: this.endpoint.model, - summarizationMode: 'inline', - numRounds, - numRoundsSinceLastSummarization, - }; - } else { - // Standard mode: use triggerSummarize which makes a separate - // LLM call with a summarization-specific prompt during render. - const snapshotProps: AgentPromptProps = { - ...props, - promptContext: { - ...promptContext, - toolCallRounds: promptContext.toolCallRounds ? [...promptContext.toolCallRounds] : undefined, - toolCallResults: promptContext.toolCallResults ? { ...promptContext.toolCallResults } : undefined, - } - }; - const bgRenderer = PromptRenderer.create(this.instantiationService, this.endpoint, this.prompt, { - ...snapshotProps, - endpoint: this.endpoint, - promptContext: snapshotProps.promptContext, - triggerSummarize: true, - }); - const bgProgress: vscode.Progress = { report: () => { } }; - const bgRenderResult = await bgRenderer.render(bgProgress, bgToken); - const summaryMetadata = bgRenderResult.metadata.get(SummarizedConversationHistoryMetadata); - if (!summaryMetadata) { - throw new Error('Background compaction produced no summary metadata'); - } - this.logService.debug(`[ConversationHistorySummarizer] background compaction completed successfully (roundId=${summaryMetadata.toolCallRoundId})`); - return { - summary: summaryMetadata.text, - toolCallRoundId: summaryMetadata.toolCallRoundId, - promptTokens: summaryMetadata.usage?.prompt_tokens, - promptCacheTokens: summaryMetadata.usage?.prompt_tokens_details?.cached_tokens, - outputTokens: summaryMetadata.usage?.completion_tokens, - durationMs: Date.now() - bgStartTime, - model: summaryMetadata.model, - summarizationMode: summaryMetadata.summarizationMode, - numRounds: summaryMetadata.numRounds, - numRoundsSinceLastSummarization: summaryMetadata.numRoundsSinceLastSummarization, - }; + // Fork the exact messages from the main render and append a + // summary user message. The prompt prefix is byte-identical + // to the main agent loop for cache hits. + const strippedMainMessages = ToolCallingLoop.stripInternalToolCallIds(mainRenderMessages); + const summaryMsgResult = await renderPromptElement( + this.instantiationService, + this.endpoint, + SummarizationUserMessage, + { endpoint: this.endpoint }, + undefined, + bgToken, + ); + const messages = [ + ...strippedMainMessages, + ...summaryMsgResult.messages, + ]; + + const response = await this.endpoint.makeChatRequest2({ + debugName: 'summarizeConversationHistory', + messages, + finishedCb: undefined, + location: ChatLocation.Agent, + conversationId, + requestOptions: { + temperature: 0, + stream: false, + ...toolOpts, + }, + modelCapabilities, + telemetryProperties: associatedRequestId ? { associatedRequestId } : undefined, + enableRetryOnFilter: true, + }, bgToken); + if (response.type !== ChatFetchResponseType.Success) { + throw new Error(`Background summarization request failed: ${response.type}`); + } + const rawSummaryText = extractSummary(response.value); + if (rawSummaryText === undefined) { + throw new Error('Background summarization: no tags found in response'); } + if (!toolCallRoundId) { + throw new Error('Background summarization: no round ID to apply summary to'); + } + // Flush the transcript before snapshotting the line count so + // the baked "N lines" hint matches the on-disk file at this + // moment (mirrors the full/simple path in SummarizedConversationHistory.render). + if (conversationId && this.sessionTranscriptService.getTranscriptPath(conversationId)) { + await this.sessionTranscriptService.flush(conversationId); + } + const summaryText = conversationId + ? appendTranscriptHintToSummary(rawSummaryText, conversationId, this.sessionTranscriptService) + : rawSummaryText; + this.logService.debug(`[ConversationHistorySummarizer] background compaction completed (${summaryText.length} chars, roundId=${toolCallRoundId})`); + + // Send summarizedConversationHistory telemetry for parity + // with the standard ConversationHistorySummarizer path. + const { numRounds, numRoundsSinceLastSummarization } = computeSummarizationRoundCounts(history, rounds); + const numRoundsInCurrentTurn = rounds.length; + const lastUsedTool = rounds.at(-1)?.toolCalls?.at(-1)?.name + ?? history.at(-1)?.rounds.at(-1)?.toolCalls?.at(-1)?.name ?? 'none'; + const promptTypes = messages.map(msg => `${msg.role}${'name' in msg && msg.name ? `-${msg.name}` : ''}:${getTextPart(msg.content).length}`).join(','); + /* __GDPR__ + "summarizedConversationHistory" : { + "owner": "bhavyau", + "comment": "Tracks background summarization outcome", + "outcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The success state." }, + "model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model ID." }, + "summarizationMode": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The summarization mode." }, + "conversationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Session id." }, + "chatRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The chat request ID." }, + "lastUsedTool": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The last tool used before summarization." }, + "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The request ID from the summarization call." }, + "promptTypes": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Role and character count of each prompt message in order, as a proxy for cache hit rate (e.g. system:1234,user:567)." }, + "numRounds": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Total tool call rounds." }, + "turnIndex": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The index of the current turn." }, + "curTurnRoundIndex": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The index of the current round within the current turn." }, + "isDuringToolCalling": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Whether this was triggered during tool calling." }, + "duration": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Duration in ms." }, + "promptTokenCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Prompt tokens." }, + "promptCacheTokenCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Cached prompt tokens." }, + "responseTokenCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Output tokens." } + } + */ + this.telemetryService.sendMSFTTelemetryEvent('summarizedConversationHistory', { + outcome: 'success', + model: this.endpoint.model, + summarizationMode: 'full', + conversationId, + chatRequestId: associatedRequestId, + lastUsedTool, + requestId: response.requestId, + promptTypes, + }, { + numRounds, + turnIndex: history.length, + curTurnRoundIndex: numRoundsInCurrentTurn, + isDuringToolCalling: numRoundsInCurrentTurn > 0 ? 1 : 0, + duration: Date.now() - bgStartTime, + promptTokenCount: response.usage?.prompt_tokens, + promptCacheTokenCount: response.usage?.prompt_tokens_details?.cached_tokens, + responseTokenCount: response.usage?.completion_tokens, + }); + + return { + summary: summaryText, + toolCallRoundId, + promptTokens: response.usage?.prompt_tokens, + promptCacheTokens: response.usage?.prompt_tokens_details?.cached_tokens, + outputTokens: response.usage?.completion_tokens, + durationMs: Date.now() - bgStartTime, + model: this.endpoint.model, + summarizationMode: 'full', + numRounds, + numRoundsSinceLastSummarization, + }; } catch (err) { this.logService.error(err, `[ConversationHistorySummarizer] background compaction failed`); - // Send failure telemetry for inline background summarization - if (useInlineSummarization) { - /* __GDPR__ - "summarizedConversationHistory" : { - "owner": "bhavyau", - "comment": "Tracks background inline summarization failure", - "outcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The success state." }, - "detailedOutcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Detailed failure reason." }, - "model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model ID." }, - "summarizationMode": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The summarization mode." }, - "conversationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Session id." }, - "chatRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The chat request ID." }, - "duration": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Duration in ms." } - } - */ - this.telemetryService.sendMSFTTelemetryEvent('summarizedConversationHistory', { - outcome: 'failed', - detailedOutcome: err instanceof Error ? err.message : String(err), - model: this.endpoint.model, - summarizationMode: 'inline', - conversationId, - chatRequestId: associatedRequestId, - }, { - duration: Date.now() - bgStartTime, - }); - } + /* __GDPR__ + "summarizedConversationHistory" : { + "owner": "bhavyau", + "comment": "Tracks background summarization failure", + "outcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The success state." }, + "detailedOutcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Detailed failure reason." }, + "model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model ID." }, + "summarizationMode": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The summarization mode." }, + "conversationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Session id." }, + "chatRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The chat request ID." }, + "duration": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Duration in ms." } + } + */ + this.telemetryService.sendMSFTTelemetryEvent('summarizedConversationHistory', { + outcome: 'failed', + detailedOutcome: err instanceof Error ? err.message : String(err), + model: this.endpoint.model, + summarizationMode: 'full', + conversationId, + chatRequestId: associatedRequestId, + }, { + duration: Date.now() - bgStartTime, + }); throw err; } diff --git a/extensions/copilot/src/extension/prompts/node/agent/backgroundSummarizer.ts b/extensions/copilot/src/extension/prompts/node/agent/backgroundSummarizer.ts index ec4d171d5abe1..097ef3252a2d6 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/backgroundSummarizer.ts +++ b/extensions/copilot/src/extension/prompts/node/agent/backgroundSummarizer.ts @@ -44,17 +44,15 @@ export interface IBackgroundSummarizationResult { * tests can reference the same numbers without repeating them. */ export const BackgroundSummarizationThresholds = { - /** Trigger ratio for the non-inline path (no prompt-cache benefit). */ - base: 0.80, - /** Minimum of the jittered warm-cache range for the inline path. */ + /** Minimum of the jittered warm-cache range. */ warmJitterMin: 0.78, /** Width of the jittered warm-cache range; together with `warmJitterMin` yields [0.78, 0.82). */ warmJitterSpan: 0.04, /** - * Cold-cache emergency ratio for the inline path. Above this we kick off - * even without a warmed cache to avoid forcing a foreground sync compaction - * on the next render. Tuned low enough that long-running sessions stay - * ahead of the budget without relying on foreground compaction. + * Cold-cache emergency ratio. Above this we kick off even without a warmed + * cache to avoid forcing a foreground sync compaction on the next render. + * Tuned low enough that long-running sessions stay ahead of the budget + * without relying on foreground compaction. */ emergency: 0.90, } as const; @@ -62,7 +60,7 @@ export const BackgroundSummarizationThresholds = { /** * Decide whether to kick off post-render background compaction. * - * For the inline-summarization path prompt-cache parity matters, so we: + * Prompt-cache parity matters, so we: * - require a completed tool call in this turn ("warm" cache) before * firing at the normal, jittered ~0.80 threshold; * - allow an emergency kick-off at >= 0.90 even with a cold cache to @@ -72,20 +70,15 @@ export const BackgroundSummarizationThresholds = { * bar") — the goal is to avoid always firing at the exact same boundary, * not to kick off systematically earlier. * - * The non-inline path forks its own prompt (no cache benefit) and keeps the - * simple >= 0.80 behavior. `rng` is only consumed on the warm-cache inline - * branch, which keeps deterministic tests straightforward. + * `rng` is only consumed on the warm-cache branch, which keeps deterministic + * tests straightforward. */ export function shouldKickOffBackgroundSummarization( postRenderRatio: number, - useInlineSummarization: boolean, cacheWarm: boolean, rng: () => number, ): boolean { const t = BackgroundSummarizationThresholds; - if (!useInlineSummarization) { - return postRenderRatio >= t.base; - } if (!cacheWarm) { return postRenderRatio >= t.emergency; } diff --git a/extensions/copilot/src/extension/prompts/node/agent/summarizedConversationHistory.tsx b/extensions/copilot/src/extension/prompts/node/agent/summarizedConversationHistory.tsx index bb87c144f1e14..d64417d4c9d2e 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/summarizedConversationHistory.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/summarizedConversationHistory.tsx @@ -904,11 +904,10 @@ function replaceImageContentWithPlaceholders(messages: ChatMessage[]): void { * Bake a stable transcript pointer into a freshly-produced summary text. * * Shared by both the full/simple summarization path - * ({@link ConversationHistorySummarizer}) and the inline background - * summarization path in `agentIntent.ts`. The hint is appended exactly once, - * at summary creation time, so the resulting string is frozen from then on - * and replayed verbatim — preserving Anthropic prompt cache hits across - * subsequent renders. + * ({@link ConversationHistorySummarizer}) and the background summarization + * path in `agentIntent.ts`. The hint is appended exactly once, at summary + * creation time, so the resulting string is frozen from then on and replayed + * verbatim — preserving Anthropic prompt cache hits across subsequent renders. * * Returns the input unchanged when there is no transcript on disk for the * session. @@ -1101,17 +1100,17 @@ class SummaryMessageElement extends PromptElement { } } -export interface InlineSummarizationUserMessageProps extends BasePromptElementProps { +export interface SummarizationUserMessageProps extends BasePromptElementProps { readonly endpoint: IChatEndpoint; } /** - * User message appended to the agent prompt when inline summarization is triggered. - * Instructs the model to output ONLY a summary wrapped in `` tags, with - * no tool calls. The summary is extracted from the response and stored on the round - * for the next iteration. + * User message appended to the agent prompt when background summarization is + * triggered. Instructs the model to output ONLY a summary wrapped in + * `` tags, with no tool calls. The summary is extracted from the + * response and stored on the round for the next iteration. */ -export class InlineSummarizationUserMessage extends PromptElement { +export class SummarizationUserMessage extends PromptElement { override async render(state: void, sizing: PromptSizing) { const isOpus = this.props.endpoint.model.startsWith('claude-opus'); return @@ -1130,7 +1129,7 @@ export class InlineSummarizationUserMessage extends PromptElement...` tags → extracts content between them @@ -1139,7 +1138,7 @@ export class InlineSummarizationUserMessage extends PromptElement... extraction const openTag = ''; const closeTag = ''; diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/backgroundSummarizer.spec.ts b/extensions/copilot/src/extension/prompts/node/agent/test/backgroundSummarizer.spec.ts index bc55ad7500f71..379f2b5e0b2b8 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/backgroundSummarizer.spec.ts +++ b/extensions/copilot/src/extension/prompts/node/agent/test/backgroundSummarizer.spec.ts @@ -254,65 +254,56 @@ describe('BackgroundSummarizer', () => { }); }); -const { base, warmJitterMin, warmJitterSpan, emergency } = BackgroundSummarizationThresholds; +const { warmJitterMin, warmJitterSpan, emergency } = BackgroundSummarizationThresholds; // rng that always returns 0.5 -> threshold sits exactly at the center of the // jitter range. With [0.78, 0.82) that's 0.80. const midRng = () => 0.5; // rng that forces the maximum of the jitter range. const maxRng = () => 1 - Number.EPSILON; -// rng that should never be called on cold/non-inline branches. +// rng that should never be called on cold-cache branches. const unusedRng = () => { throw new Error('rng should not be consumed'); }; describe('shouldKickOffBackgroundSummarization', () => { - describe('inline + cold cache', () => { + describe('cold cache', () => { test('defers kick-off below the emergency threshold', () => { // Cold turn sitting in the old 0.80 trigger band — must not fire. - expect(shouldKickOffBackgroundSummarization(0.85, true, false, unusedRng)).toBe(false); + expect(shouldKickOffBackgroundSummarization(0.85, false, unusedRng)).toBe(false); }); test('kicks off at the emergency threshold', () => { - expect(shouldKickOffBackgroundSummarization(emergency, true, false, unusedRng)).toBe(true); - expect(shouldKickOffBackgroundSummarization(0.91, true, false, unusedRng)).toBe(true); + expect(shouldKickOffBackgroundSummarization(emergency, false, unusedRng)).toBe(true); + expect(shouldKickOffBackgroundSummarization(0.91, false, unusedRng)).toBe(true); }); test('does not consume the rng on the cold branch', () => { // The unusedRng would throw if consumed — asserting no throw is the check. - expect(() => shouldKickOffBackgroundSummarization(0.85, true, false, unusedRng)).not.toThrow(); - expect(() => shouldKickOffBackgroundSummarization(0.91, true, false, unusedRng)).not.toThrow(); + expect(() => shouldKickOffBackgroundSummarization(0.85, false, unusedRng)).not.toThrow(); + expect(() => shouldKickOffBackgroundSummarization(0.91, false, unusedRng)).not.toThrow(); }); }); - describe('inline + warm cache', () => { + describe('warm cache', () => { test('kicks off at the jittered midpoint (0.80) when ratio meets it', () => { - expect(shouldKickOffBackgroundSummarization(0.80, true, true, midRng)).toBe(true); - expect(shouldKickOffBackgroundSummarization(0.81, true, true, midRng)).toBe(true); + expect(shouldKickOffBackgroundSummarization(0.80, true, midRng)).toBe(true); + expect(shouldKickOffBackgroundSummarization(0.81, true, midRng)).toBe(true); }); test('defers when ratio is under the jittered threshold', () => { // midRng -> 0.80; 0.77 is below the entire jitter window. - expect(shouldKickOffBackgroundSummarization(0.77, true, true, midRng)).toBe(false); + expect(shouldKickOffBackgroundSummarization(0.77, true, midRng)).toBe(false); // Also below the minimum of the window regardless of rng. - expect(shouldKickOffBackgroundSummarization(warmJitterMin - 0.0001, true, true, () => 0)).toBe(false); + expect(shouldKickOffBackgroundSummarization(warmJitterMin - 0.0001, true, () => 0)).toBe(false); }); test('respects the top of the jitter range', () => { // With maxRng, threshold approaches warmJitterMin + warmJitterSpan = 0.82. // 0.81 lands below it, so we defer. - expect(shouldKickOffBackgroundSummarization(0.81, true, true, maxRng)).toBe(false); + expect(shouldKickOffBackgroundSummarization(0.81, true, maxRng)).toBe(false); // 0.82 meets it. - expect(shouldKickOffBackgroundSummarization(warmJitterMin + warmJitterSpan, true, true, maxRng)).toBe(true); - }); - }); - - describe('non-inline path', () => { - test('uses the fixed base threshold and ignores cache warmth', () => { - // Warm, cold — both behave the same on non-inline. - expect(shouldKickOffBackgroundSummarization(base, false, false, unusedRng)).toBe(true); - expect(shouldKickOffBackgroundSummarization(base, false, true, unusedRng)).toBe(true); - expect(shouldKickOffBackgroundSummarization(base - 0.0001, false, true, unusedRng)).toBe(false); + expect(shouldKickOffBackgroundSummarization(warmJitterMin + warmJitterSpan, true, maxRng)).toBe(true); }); }); }); diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/summarization.spec.tsx b/extensions/copilot/src/extension/prompts/node/agent/test/summarization.spec.tsx index 8987972c923e8..2fbb815ec0718 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/summarization.spec.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/test/summarization.spec.tsx @@ -31,7 +31,7 @@ import { PromptRenderer } from '../../base/promptRenderer'; import { AgentPrompt, AgentPromptProps } from '../agentPrompt'; import { PromptRegistry } from '../promptRegistry'; import { ISessionTranscriptService, NullSessionTranscriptService } from '../../../../../platform/chat/common/sessionTranscriptService'; -import { appendTranscriptHintToSummary, ConversationHistorySummarizationPrompt, extractInlineSummary, stripToolSearchMessages, SummarizedConversationHistory, SummarizedConversationHistoryMetadata, SummarizedConversationHistoryPropsBuilder } from '../summarizedConversationHistory'; +import { appendTranscriptHintToSummary, ConversationHistorySummarizationPrompt, extractSummary, stripToolSearchMessages, SummarizedConversationHistory, SummarizedConversationHistoryMetadata, SummarizedConversationHistoryPropsBuilder } from '../summarizedConversationHistory'; suite('Agent Summarization', () => { let accessor: ITestingServicesAccessor; @@ -538,47 +538,47 @@ suite('Agent Summarization', () => { }); }); -suite('extractInlineSummary', () => { +suite('extractSummary', () => { test('extracts clean summary tags', () => { const text = 'Some preamble\n\nThis is the summary content.\n\nSome trailing text'; - const result = extractInlineSummary(text); + const result = extractSummary(text); expect(result).toBe('This is the summary content.'); }); test('extracts summary with no closing tag', () => { const text = 'Preamble text\n\nThis is a partial summary that was cut off'; - const result = extractInlineSummary(text); + const result = extractSummary(text); expect(result).toBe('This is a partial summary that was cut off'); }); test('returns undefined when no tags found', () => { const text = 'This is just a normal response with no summary tags at all.'; - const result = extractInlineSummary(text); + const result = extractSummary(text); expect(result).toBeUndefined(); }); test('uses first complete summary when multiple blocks exist', () => { const text = 'First summary\nSecond summary'; - const result = extractInlineSummary(text); + const result = extractSummary(text); expect(result).toBe('First summary'); }); test('handles empty summary tags', () => { const text = ''; - const result = extractInlineSummary(text); + const result = extractSummary(text); expect(result).toBe(''); }); test('handles summary with analysis tags inside', () => { const text = '\nSome analysis\n\n1. Overview: test\n2. Details: test\n'; - const result = extractInlineSummary(text); + const result = extractSummary(text); expect(result).toContain('1. Overview: test'); expect(result).toContain('Some analysis'); }); test('trims whitespace from extracted summary', () => { const text = '\n\n Padded summary text \n\n'; - const result = extractInlineSummary(text); + const result = extractSummary(text); expect(result).toBe('Padded summary text'); }); }); diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index a0ad29c56ba4f..2643c9bd3d4cd 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -647,7 +647,6 @@ export namespace ConfigKey { export const InstantApplyShortModelName = defineAndMigrateExpSetting('chat.advanced.instantApply.shortContextModelName', 'chat.instantApply.shortContextModelName', CHAT_MODEL.SHORT_INSTANT_APPLY); export const InstantApplyShortContextLimit = defineAndMigrateExpSetting('chat.advanced.instantApply.shortContextLimit', 'chat.instantApply.shortContextLimit', 8000); - export const AgentHistorySummarizationInline = defineAndMigrateExpSetting('chat.advanced.agentHistorySummarizationInline', 'chat.agentHistorySummarizationInline', true); export const PromptFileContext = defineAndMigrateExpSetting('chat.advanced.promptFileContextProvider.enabled', 'chat.promptFileContextProvider.enabled', true); export const DefaultToolsGrouped = defineAndMigrateExpSetting('chat.advanced.tools.defaultToolsGrouped', 'chat.tools.defaultToolsGrouped', false); export const Gpt5AlternativePatch = defineAndMigrateExpSetting('chat.advanced.gpt5AlternativePatch', 'chat.gpt5AlternativePatch', false); From c25d7050c83347946068d6aa673e444e39e0022b Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Fri, 1 May 2026 21:11:15 +0200 Subject: [PATCH 06/19] Refactor IChatModes to support session-based modes (#313765) * IChatModes refactor * Updated IChatModeService to provide modes based on session type. Co-authored-by: Copilot * update --------- Co-authored-by: Copilot --- .../copilotChatSessions/browser/modePicker.ts | 11 +- .../chat/browser/actions/chatActions.ts | 5 +- .../browser/actions/chatExecuteActions.ts | 16 +- .../contrib/chat/browser/chat.contribution.ts | 10 +- .../chatSetup/chatSetupContributions.ts | 39 ++-- .../promptSyntax/promptFileContributions.ts | 3 +- .../contrib/chat/browser/widget/chatWidget.ts | 12 +- .../browser/widget/input/chatInputPart.ts | 33 ++-- .../widget/input/modePickerActionItem.ts | 21 +-- .../contrib/chat/common/chatModes.ts | 168 +++++++++++++----- .../PromptHeaderDefinitionProvider.ts | 3 +- .../promptHeaderAutocompletion.ts | 13 +- .../languageProviders/promptHovers.ts | 7 +- .../languageProviders/promptValidator.ts | 3 +- .../actions/chatExecuteActions.test.ts | 42 +++-- .../promptHeaderAutocompletion.test.ts | 9 +- .../chat/test/common/chatModeService.test.ts | 28 +-- .../chat/test/common/mockChatModeService.ts | 42 +++-- .../chat/chatInput.fixture.ts | 3 +- .../editor/inlineChatZoneWidget.fixture.ts | 7 +- 20 files changed, 298 insertions(+), 177 deletions(-) diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts index 4fd157dc52adf..5c78d98c653df 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts @@ -12,7 +12,7 @@ import { localize } from '../../../../nls.js'; import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { ChatMode, IChatMode, IChatModeService } from '../../../../workbench/contrib/chat/common/chatModes.js'; +import { ChatMode, IChatMode, IChatModes, IChatModeService } from '../../../../workbench/contrib/chat/common/chatModes.js'; import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { Target } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; @@ -53,6 +53,8 @@ export class ModePicker extends Disposable { return this._selectedMode; } + private readonly _chatModes: IChatModes; + constructor( @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, @IChatModeService private readonly chatModeService: IChatModeService, @@ -63,7 +65,9 @@ export class ModePicker extends Disposable { ) { super(); - this._register(this.chatModeService.onDidChangeChatModes(() => { + this._chatModes = this.chatModeService.getModes(CopilotCLISessionType.id); + + this._register(this._chatModes.onDidChange(() => { // Refresh the trigger label when available chat modes change if (this._triggerElement) { this._updateTriggerLabel(); @@ -116,13 +120,12 @@ export class ModePicker extends Disposable { private _getAvailableModes(): IChatMode[] { const customAgentTarget = this.chatSessionsService.getCustomAgentTargetForSessionType(CopilotCLISessionType.id); const effectiveTarget = customAgentTarget && customAgentTarget !== Target.Undefined ? customAgentTarget : Target.GitHubCopilot; - const modes = this.chatModeService.getModes(); // Always include the default Agent mode const result: IChatMode[] = [ChatMode.Agent]; // Add custom modes matching the target and visible to users - for (const mode of modes.custom) { + for (const mode of this._chatModes.custom) { const target = mode.target.get(); if (target === effectiveTarget || target === Target.Undefined) { const visibility = mode.visibility?.get(); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 66491e54474ff..dd2222584f47b 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -50,7 +50,7 @@ import { IChatAgentResult, IChatAgentService } from '../../common/participants/c import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; import { IChatModel, IChatResponseModel } from '../../common/model/chatModel.js'; -import { ChatMode, IChatMode, IChatModeService } from '../../common/chatModes.js'; +import { ChatMode, IChatMode } from '../../common/chatModes.js'; import { ElicitationState, IChatService, IChatToolInvocation } from '../../common/chatService/chatService.js'; import { ISCMHistoryItemChangeRangeVariableEntry, ISCMHistoryItemChangeVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { IChatRequestViewModel, IChatResponseViewModel, isRequestVM } from '../../common/model/chatViewModel.js'; @@ -220,7 +220,6 @@ abstract class OpenChatGlobalAction extends Action2 { const chatAgentService = accessor.get(IChatAgentService); const instaService = accessor.get(IInstantiationService); const commandService = accessor.get(ICommandService); - const chatModeService = accessor.get(IChatModeService); const fileService = accessor.get(IFileService); const languageModelService = accessor.get(ILanguageModelsService); const scmService = accessor.get(ISCMService); @@ -238,7 +237,7 @@ abstract class OpenChatGlobalAction extends Action2 { return; } - const switchToMode = (opts?.mode ? chatModeService.findModeByName(opts?.mode) : undefined) ?? this.mode; + const switchToMode = opts?.mode ? chatWidget.input.currentChatModesObs.get().findModeByName(opts.mode) : this.mode; if (switchToMode) { await this.handleSwitchToMode(switchToMode, chatWidget, instaService, commandService); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 0c3d168f16829..99dbe969dc672 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -25,7 +25,7 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { IsSessionsWindowContext } from '../../../../common/contextkeys.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { getModeNameForTelemetry, buildCustomAgentHandoffsInfo, getHandoffId, IChatMode, IChatModeService } from '../../common/chatModes.js'; +import { getModeNameForTelemetry, buildCustomAgentHandoffsInfo, getHandoffId, IChatMode, IChatModeService, IChatModes } from '../../common/chatModes.js'; import { chatVariableLeader } from '../../common/requestParser/chatParserTypes.js'; import { ChatStopCancellationNoopClassification, ChatStopCancellationNoopEvent, ChatStopCancellationNoopEventName, IChatService } from '../../common/chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js'; @@ -295,7 +295,6 @@ class ToggleChatModeAction extends Action2 { async run(accessor: ServicesAccessor, ...args: unknown[]) { const commandService = accessor.get(ICommandService); const instaService = accessor.get(IInstantiationService); - const modeService = accessor.get(IChatModeService); const telemetryService = accessor.get(ITelemetryService); const chatWidgetService = accessor.get(IChatWidgetService); @@ -313,7 +312,8 @@ class ToggleChatModeAction extends Action2 { const chatSession = widget.viewModel?.model; const requestCount = chatSession?.getRequests().length ?? 0; - const switchToMode = (arg && (modeService.findModeById(arg.modeId) || modeService.findModeByName(arg.modeId))) ?? this.getNextMode(widget, requestCount, modeService); + const modes = widget.input.currentChatModesObs.get(); + const switchToMode = (arg && (modes.findModeById(arg.modeId) || modes.findModeByName(arg.modeId))) ?? this.getNextMode(widget, requestCount, modes); const currentMode = widget.input.currentModeObs.get(); if (switchToMode.id === currentMode.id) { @@ -352,8 +352,7 @@ class ToggleChatModeAction extends Action2 { } } - private getNextMode(chatWidget: IChatWidget, requestCount: number, modeService: IChatModeService): IChatMode { - const modes = modeService.getModes(); + private getNextMode(chatWidget: IChatWidget, requestCount: number, modes: IChatModes): IChatMode { const flat = [ ...modes.builtin.filter(mode => { return mode.kind !== ChatModeKind.Edit || requestCount === 0; @@ -1032,6 +1031,8 @@ interface IGetHandoffsArgs { * handoffs from all agents and built-in modes are returned. */ sourceCustomAgent?: string; + + sessionType?: string; } /** @@ -1061,7 +1062,7 @@ class GetHandoffsAction extends Action2 { const modeService = accessor.get(IChatModeService); const arg = args.at(0) as IGetHandoffsArgs | undefined; - const { builtin, custom } = modeService.getModes(); + const { builtin, custom } = modeService.getModes(arg?.sessionType ?? localChatSessionType); let allModes: readonly IChatMode[] = [...builtin, ...custom]; if (arg?.sourceCustomAgent) { @@ -1118,7 +1119,6 @@ class ExecuteHandoffAction extends Action2 { async run(accessor: ServicesAccessor, ...args: unknown[]): Promise { const chatWidgetService = accessor.get(IChatWidgetService); - const modeService = accessor.get(IChatModeService); const arg = args.at(0) as IExecuteHandoffArgs | undefined; if (!arg?.id && !arg?.label) { @@ -1146,7 +1146,7 @@ class ExecuteHandoffAction extends Action2 { let sourceMode: IChatMode | undefined; if (arg.sourceCustomAgent) { const filterName = arg.sourceCustomAgent.toLowerCase(); - const { builtin, custom } = modeService.getModes(); + const { builtin, custom } = widget.input.currentChatModesObs.get(); sourceMode = [...builtin, ...custom].find(m => m.name.get().toLowerCase() === filterName || m.id.toLowerCase() === filterName); } if (!sourceMode) { diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 1e475f4a1d4ff..7603e8dbd5da2 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -48,7 +48,7 @@ import { ChatModeService, IChatMode, IChatModeService } from '../common/chatMode import { ChatResponseResourceFileSystemProvider, ChatResponseResourceWorkbenchContribution, IChatResponseResourceFileSystemProvider } from '../common/widget/chatResponseResourceFileSystemProvider.js'; import { IChatService } from '../common/chatService/chatService.js'; import { ChatService } from '../common/chatService/chatServiceImpl.js'; -import { IChatSessionsService } from '../common/chatSessionsService.js'; +import { IChatSessionsService, SessionType } from '../common/chatSessionsService.js'; import { ChatSlashCommandService, IChatSlashCommandService } from '../common/participants/chatSlashCommands.js'; import { ChatArtifactsService, IChatArtifactsService } from '../common/tools/chatArtifactsService.js'; import { ChatTodoListService, IChatTodoListService } from '../common/tools/chatTodoListService.js'; @@ -2021,8 +2021,10 @@ class ChatAgentActionsContribution extends Disposable implements IWorkbenchContr super(); this._store.add(this._modeActionDisposables); + const chatModes = this.chatModeService.getModes(SessionType.Local); + const { builtin, custom } = chatModes; + // Register actions for existing custom modes (avoiding name collisions) - const { builtin, custom } = this.chatModeService.getModes(); const currentModeIds = getCustomModesWithUniqueNames(builtin, custom); for (const mode of custom) { if (currentModeIds.has(mode.id)) { @@ -2031,8 +2033,8 @@ class ChatAgentActionsContribution extends Disposable implements IWorkbenchContr } // Listen for custom mode changes by tracking snapshots - this._register(this.chatModeService.onDidChangeChatModes(() => { - const { builtin, custom } = this.chatModeService.getModes(); + this._register(chatModes.onDidChange(() => { + const { builtin, custom } = chatModes; const currentModeIds = getCustomModesWithUniqueNames(builtin, custom); // Remove modes that no longer exist and those replaced by modes later in the list with same name diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts index ae39b7028a357..2d1b50bedce16 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts @@ -46,11 +46,10 @@ import { ILifecycleService } from '../../../../services/lifecycle/common/lifecyc import { IPreferencesService } from '../../../../services/preferences/common/preferences.js'; import { IExtension, IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { IChatModeService } from '../../common/chatModes.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js'; import { CHAT_CATEGORY, CHAT_SETUP_ACTION_ID, CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID } from '../actions/chatActions.js'; -import { ChatViewContainerId, IChatWidgetService } from '../chat.js'; +import { ChatViewContainerId, IChatWidget, IChatWidgetService } from '../chat.js'; import { chatViewsWelcomeRegistry } from '../viewsWelcome/chatViewsWelcome.js'; import { ChatSetupAnonymous, ChatSetupStrategy } from './chatSetup.js'; import { ChatSetupController } from './chatSetupController.js'; @@ -253,7 +252,12 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr if (mode) { const chatWidget = await widgetService.revealWidget(); - chatWidget?.input.setChatMode(mode); + if (chatWidget) { + const resolvedMode = this.resolveAgentId(mode, chatWidget); + if (resolvedMode) { + chatWidget.input.setChatMode(resolvedMode); + } + } } if (options?.inputValue) { @@ -278,6 +282,18 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr return Boolean(success); } + + private resolveAgentId(agentParam: string, chatWidget: IChatWidget): string | undefined { + const modes = chatWidget.input.currentChatModesObs.get(); + const foundAgent = modes.findModeById(agentParam); + if (foundAgent) { + return foundAgent.id; + } + const allAgents = [...modes.builtin, ...modes.custom]; + const nameLower = agentParam.toLowerCase(); + const agentByName = allAgents.find(agent => agent.name.get().toLowerCase() === nameLower); + return agentByName?.id; + } } class ChatSetupTriggerSupportAnonymousAction extends Action2 { @@ -668,7 +684,6 @@ class ChatSetupExtensionUrlHandler implements IExtensionUrlHandlerOverride { @IProductService private readonly productService: IProductService, @ICommandService private readonly commandService: ICommandService, @ITelemetryService private readonly telemetryService: ITelemetryService, - @IChatModeService private readonly chatModeService: IChatModeService, ) { } canHandleURL(url: URI): boolean { @@ -685,24 +700,10 @@ class ChatSetupExtensionUrlHandler implements IExtensionUrlHandlerOverride { return false; } - const agentId = agentParam ? this.resolveAgentId(agentParam) : undefined; - await this.commandService.executeCommand(CHAT_SETUP_ACTION_ID, agentId, inputParam ? { inputValue: inputParam } : undefined); + await this.commandService.executeCommand(CHAT_SETUP_ACTION_ID, agentParam, inputParam ? { inputValue: inputParam } : undefined); return true; } - private resolveAgentId(agentParam: string): string | undefined { - const agents = this.chatModeService.getModes(); - const allAgents = [...agents.builtin, ...agents.custom]; - - const foundAgent = allAgents.find(agent => agent.id === agentParam); - if (foundAgent) { - return foundAgent.id; - } - - const nameLower = agentParam.toLowerCase(); - const agentByName = allAgents.find(agent => agent.name.get().toLowerCase() === nameLower); - return agentByName?.id; - } } export class ChatTeardownContribution extends Disposable implements IWorkbenchContribution { diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileContributions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileContributions.ts index 04e1853ddd581..c04617c585702 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileContributions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileContributions.ts @@ -22,6 +22,7 @@ import { IMarkerData, IMarkerService } from '../../../../../platform/markers/com import { ILanguageModelsService } from '../../common/languageModels.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { IChatModeService } from '../../common/chatModes.js'; +import { localChatSessionType } from '../../common/chatSessionsService.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; import { Delayer } from '../../../../../base/common/async.js'; import { ITextModel } from '../../../../../editor/common/model.js'; @@ -151,7 +152,7 @@ class PromptValidatorContribution extends Disposable { const validateAll = (): void => trackers.forEach(tracker => tracker.validate()); this.localDisposables.add(this.languageModelToolsService.onDidChangeTools(() => validateAll())); - this.localDisposables.add(this.chatModeService.onDidChangeChatModes(() => validateAll())); + this.localDisposables.add(this.chatModeService.getModes(localChatSessionType).onDidChange(() => validateAll())); this.localDisposables.add(this.languageModelsService.onDidChangeLanguageModels(() => validateAll())); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 1274a1b6edcb5..776047a6e6b77 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -55,7 +55,7 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { applyingChatEditsFailedContextKey, decidedChatEditingResourceContextKey, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, IChatEditingSession, inChatEditingSessionContextKey, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; import { IChatLayoutService } from '../../common/widget/chatLayoutService.js'; import { IChatModel, IChatModelInputState, IChatResponseModel } from '../../common/model/chatModel.js'; -import { ChatMode, getModeNameForTelemetry, IChatMode, IChatModeService } from '../../common/chatModes.js'; +import { ChatMode, getModeNameForTelemetry, IChatMode } from '../../common/chatModes.js'; import { chatAgentLeader, ChatRequestAgentPart, ChatRequestDynamicVariablePart, ChatRequestSlashPromptPart, ChatRequestToolPart, ChatRequestToolSetPart, chatSubcommandLeader, formatChatQuestion, IParsedChatRequest } from '../../common/requestParser/chatParserTypes.js'; import { ChatRequestParser } from '../../common/requestParser/chatRequestParser.js'; import { getDynamicVariablesForWidget, getSelectedToolAndToolSetsForWidget } from '../attachments/chatVariables.js'; @@ -408,7 +408,6 @@ export class ChatWidget extends Disposable implements IChatWidget { @IPromptsService private readonly promptsService: IPromptsService, @ICustomizationHarnessService private readonly customizationHarnessService: ICustomizationHarnessService, @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService, - @IChatModeService private readonly chatModeService: IChatModeService, @IChatLayoutService private readonly chatLayoutService: IChatLayoutService, @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @@ -1270,10 +1269,11 @@ export class ChatWidget extends Disposable implements IChatWidget { // Fall back to the current mode picker for old sessions where modeInfo was not persisted. const modeInfo = lastItem.model.request?.modeInfo; let responseMode: IChatMode | undefined; + const modes = this.input.currentChatModesObs.get(); if (modeInfo?.modeInstructions?.name) { - responseMode = this.chatModeService.findModeByName(modeInfo.modeInstructions.name); + responseMode = modes.findModeByName(modeInfo.modeInstructions.name); } else if (modeInfo?.modeId) { - responseMode = this.chatModeService.findModeById(modeInfo.modeId); + responseMode = modes.findModeById(modeInfo.modeId); } else { responseMode = this.input.currentModeObs.get(); } @@ -1325,7 +1325,7 @@ export class ChatWidget extends Disposable implements IChatWidget { // Log telemetry const currentMode = this.input.currentModeObs.get(); - const toMode = handoff.agent ? this.chatModeService.findModeByName(handoff.agent) : undefined; + const toMode = handoff.agent ? this.input.currentChatModesObs.get().findModeByName(handoff.agent) : undefined; this.telemetryService.publicLog2('chat.handoffClicked', { fromAgent: getModeNameForTelemetry(currentMode), toAgent: agentId || (toMode ? getModeNameForTelemetry(toMode) : ''), @@ -2868,7 +2868,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } // Find the mode object to get its kind - const agent = this.chatModeService.findModeByName(agentName); + const agent = this.input.currentChatModesObs.get().findModeByName(agentName); if (!agent) { return false; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index bc92664449a3a..3aad8c6c0eb60 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -81,7 +81,7 @@ import { getSimpleCodeEditorWidgetOptions, getSimpleEditorOptions, setupSimpleEd import { IChatViewTitleActionContext } from '../../../common/actions/chatActions.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { ChatRequestVariableSet, getImageAttachmentLimit, IChatRequestVariableEntry, isBrowserViewVariableEntry, isElementVariableEntry, isImageVariableEntry, isNotebookOutputVariableEntry, isPasteVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry, isSCMHistoryItemChangeRangeVariableEntry, isSCMHistoryItemChangeVariableEntry, isSCMHistoryItemVariableEntry, isStringVariableEntry, OmittedState } from '../../../common/attachments/chatVariableEntries.js'; -import { ChatMode, getModeNameForTelemetry, IChatMode, IChatModeService } from '../../../common/chatModes.js'; +import { ChatMode, getModeNameForTelemetry, IChatMode, IChatModes, IChatModeService } from '../../../common/chatModes.js'; import { IChatFollowup, IChatPlanReview, IChatQuestionCarousel, IChatToolInvocation } from '../../../common/chatService/chatService.js'; import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService, isIChatSessionFileChange2, localChatSessionType } from '../../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../common/constants.js'; @@ -427,6 +427,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge readonly onDidChangeCurrentChatMode: Event = this._onDidChangeCurrentChatMode.event; private readonly _currentModeObservable: ISettableObservable; + private readonly _currentChatModesObservable: ISettableObservable; private readonly _currentPermissionLevel: ISettableObservable; private permissionLevelKey: IContextKey; @@ -441,6 +442,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return this._currentModeObservable; } + public get currentChatModesObs(): IObservable { + return this._currentChatModesObservable; + } + public get currentPermissionLevelObs(): IObservable { return this._currentPermissionLevel; } @@ -565,6 +570,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._contextResourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this._onDidChangeVisibility.event })); this._currentModeObservable = observableValue('currentMode', this.options.defaultMode ?? ChatMode.Agent); + this._currentChatModesObservable = observableValue('currentChatModes', this.chatModeService.getModes(localChatSessionType)); this._currentPermissionLevel = observableValue('permissionLevel', this.getDefaultPermissionLevel()); this._register(this.editorService.onDidActiveEditorChange(() => { this._indexOfLastOpenedContext = -1; @@ -696,7 +702,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } this._inputEditor?.updateOptions({ ariaLabel: this._getAriaLabel() }); })); - this._register(this.chatModeService.onDidChangeChatModes(() => this.validateCurrentChatMode())); + this._register(autorun(reader => { + const modes = this._currentChatModesObservable.read(reader); + reader.store.add(modes.onDidChange(() => this.validateCurrentChatMode())); + })); this._register(autorun(r => { const mode = this._currentModeObservable.read(r); this.chatModeKindKey.set(mode.kind); @@ -935,6 +944,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._inputModel = model; this._modelSyncDisposables.clear(); + this._currentChatModesObservable.set(this.chatModeService.getModes(getChatSessionType(forSessionResource)), undefined); this.selectedToolsModel.resetSessionEnablementState(); this._chatSessionIsEmpty = chatSessionIsEmpty; @@ -949,7 +959,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._setEmptyModelState(); } })); - this._modelSyncDisposables.add(this.chatModeService.onDidChangeChatModes(() => { + this._modelSyncDisposables.add(this._currentChatModesObservable.get().onDidChange(() => { if (this._chatSessionIsEmpty) { this._setEmptyModelState(); } @@ -985,9 +995,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const defaultMode = rawDefaultMode.trim(); if (defaultMode) { const defaultModeLower = defaultMode.toLowerCase(); - const resolved = this.chatModeService.findModeById(defaultMode) - ?? this.chatModeService.findModeByName(defaultMode) - ?? this.chatModeService.getModes().custom.find(m => m.name.get().toLowerCase() === defaultModeLower); + const modes = this._currentChatModesObservable.get(); + const resolved = modes.findModeById(defaultMode) + ?? modes.findModeByName(defaultMode) + ?? modes.custom.find(m => m.name.get().toLowerCase() === defaultModeLower); if (resolved) { this.logService.trace(`[ChatInputPart] Applying default mode from setting: ${defaultMode} -> ${resolved.id}`); this.setChatMode(resolved.id, false); @@ -1136,9 +1147,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return; } - const mode2 = this.chatModeService.findModeById(mode) ?? - this.chatModeService.findModeByName(mode) ?? - this.chatModeService.findModeById(ChatModeKind.Agent) ?? + const modes = this._currentChatModesObservable.get(); + const mode2 = modes.findModeById(mode) ?? + modes.findModeByName(mode) ?? + modes.findModeById(ChatModeKind.Agent) ?? ChatMode.Ask; this.setChatMode2(mode2, storeSelection); } @@ -1379,7 +1391,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private validateCurrentChatMode() { const currentMode = this._currentModeObservable.get(); - const validMode = this.chatModeService.findModeById(currentMode.id); + const validMode = this._currentChatModesObservable.get().findModeById(currentMode.id); const isAgentModeEnabled = this.configurationService.getValue(ChatConfiguration.AgentEnabled); if (!validMode) { this.setChatMode(isAgentModeEnabled ? ChatModeKind.Agent : ChatModeKind.Ask); @@ -2354,6 +2366,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } else if (action.id === OpenModePickerAction.ID && action instanceof MenuItemAction) { const delegate: IModePickerDelegate = { currentMode: this._currentModeObservable, + currentChatModes: this._currentChatModesObservable, sessionResource: () => this._widget?.viewModel?.sessionResource, customAgentTarget: () => { const sessionResource = this._widget?.viewModel?.model.sessionResource; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts index 47be09ec9af73..96737cda4a504 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts @@ -25,12 +25,11 @@ import { IKeybindingService } from '../../../../../../platform/keybinding/common import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { IChatAgentService } from '../../../common/participants/chatAgents.js'; -import { ChatMode, IChatMode, IChatModeService } from '../../../common/chatModes.js'; +import { ChatMode, IChatMode, IChatModes } from '../../../common/chatModes.js'; import { isOrganizationPromptFile } from '../../../common/promptSyntax/utils/promptsServiceUtils.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../common/constants.js'; -import { matchesSessionType, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { Target } from '../../../common/promptSyntax/promptTypes.js'; -import { getChatSessionType } from '../../../common/model/chatUri.js'; import { getOpenChatActionIdForMode } from '../../actions/chatActions.js'; import { IToggleChatModeArgs, ToggleAgentModeActionId } from '../../actions/chatExecuteActions.js'; import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from './chatInputPickerActionItem.js'; @@ -39,6 +38,7 @@ import { IWorkbenchAssignmentService } from '../../../../../services/assignment/ export interface IModePickerDelegate { readonly currentMode: IObservable; + readonly currentChatModes: IObservable; readonly sessionResource: () => URI | undefined; /** * When set, the mode picker will show custom agents whose target matches this value. @@ -67,7 +67,6 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { @IKeybindingService keybindingService: IKeybindingService, @IConfigurationService configurationService: IConfigurationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IChatModeService chatModeService: IChatModeService, @IMenuService private readonly menuService: IMenuService, @ICommandService commandService: ICommandService, @IProductService private readonly _productService: IProductService, @@ -162,18 +161,13 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { }; const getActionsForCustomAgentTarget = (currentTarget: Target): IActionWidgetDropdownAction[] => { - const modes = chatModeService.getModes(); + const modes = delegate.currentChatModes.get(); const currentMode = delegate.currentMode.get(); - const sessionResource = delegate.sessionResource(); - const currentSessionType = sessionResource ? getChatSessionType(sessionResource) : undefined; const filteredCustomModes = modes.custom.filter(mode => { const target = mode.target.get(); if (target !== currentTarget && target !== Target.Undefined) { return false; } - if (!matchesSessionType(mode.sessionTypes, currentSessionType)) { - return false; - } return true; }); const customModes = groupBy( @@ -195,19 +189,14 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { const actionProvider: IActionWidgetDropdownActionProvider = { getActions: () => { - const modes = chatModeService.getModes(); + const modes = delegate.currentChatModes.get(); const currentMode = delegate.currentMode.get(); const agentMode = modes.builtin.find(mode => mode.id === ChatMode.Agent.id); - const sessionResource = delegate.sessionResource(); - const currentSessionType = sessionResource ? getChatSessionType(sessionResource) : undefined; const otherBuiltinModes = modes.builtin.filter(mode => { return mode.id !== ChatMode.Agent.id && shouldShowBuiltInMode(mode, assignments.get(), agentModeDisabledViaPolicy); }); const filteredCustomModes = modes.custom.filter(mode => { - if (!matchesSessionType(mode.sessionTypes, currentSessionType)) { - return false; - } if (isModeConsideredBuiltIn(mode, this._productService)) { return shouldShowBuiltInMode(mode, assignments.get(), agentModeDisabledViaPolicy); } diff --git a/src/vs/workbench/contrib/chat/common/chatModes.ts b/src/vs/workbench/contrib/chat/common/chatModes.ts index dc55be08fb4c1..c2555358d51b5 100644 --- a/src/vs/workbench/contrib/chat/common/chatModes.ts +++ b/src/vs/workbench/contrib/chat/common/chatModes.ts @@ -5,7 +5,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; import { constObservable, IObservable, ISettableObservable, observableValue, transaction } from '../../../../base/common/observable.js'; import { isUriComponents, URI } from '../../../../base/common/uri.js'; import { IOffsetRange } from '../../../../editor/common/core/ranges/offsetRange.js'; @@ -13,14 +13,14 @@ import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IChatAgentService } from './participants/chatAgents.js'; import { ChatContextKeys } from './actions/chatContextKeys.js'; import { ChatConfiguration, ChatModeKind } from './constants.js'; import { IHandOff } from './promptSyntax/promptFileParser.js'; -import { IAgentSource, ICustomAgent, ICustomAgentVisibility, IPromptsService, isCustomAgentVisibility, PromptsStorage } from './promptSyntax/service/promptsService.js'; +import { IAgentSource, ICustomAgent, ICustomAgentVisibility, IPromptsService, isCustomAgentVisibility, matchesSessionType, PromptsStorage } from './promptSyntax/service/promptsService.js'; import { PromptFileSource, Target } from './promptSyntax/promptTypes.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { Codicon } from '../../../../base/common/codicons.js'; @@ -32,71 +32,117 @@ export const IChatModeService = createDecorator('chatModeServi export interface IChatModeService { readonly _serviceBrand: undefined; - // TODO expose an observable list of modes - readonly onDidChangeChatModes: Event; - getModes(): { builtin: readonly IChatMode[]; custom: readonly IChatMode[] }; + /** + * Returns the chat modes available for the given session type. Custom modes + * are filtered by their declared {@link ICustomAgent.sessionTypes}. + * + * Instances are cached by session type and live for the lifetime of the service. + */ + getModes(sessionType: string): IChatModes; + + /** + * Like {@link getModes}, but awaits the in-flight refresh of custom prompt + * modes so callers see an up-to-date snapshot. Use this when synchronous + * results are not required and stale data would be surprising. + */ + awaitModes(sessionType: string): Promise; +} + +/** + * The set of chat modes available for a particular session type, partitioned + * into builtin and custom modes, with helpers for lookup by id or name. + */ +export interface IChatModes { + readonly sessionType: string; + readonly onDidChange: Event; + readonly builtin: readonly IChatMode[]; + readonly custom: readonly IChatMode[]; findModeById(id: string): IChatMode | undefined; findModeByName(name: string): IChatMode | undefined; + + /** + * Awaits the most recently scheduled refresh of custom prompt modes. + * After this resolves, {@link custom} reflects the latest data from the + * prompts service. + */ + waitForRefresh(): Promise; } -export class ChatModeService extends Disposable implements IChatModeService { - declare readonly _serviceBrand: undefined; +class ChatModes extends Disposable implements IChatModes { - private static readonly CUSTOM_MODES_STORAGE_KEY = 'chat.customModes'; + private static readonly CUSTOM_MODES_STORAGE_KEY_PREFIX = 'chat.customModes.'; private readonly hasCustomModes: IContextKey; - private readonly agentModeDisabledByPolicy: IContextKey; private readonly _customModeInstances = new Map(); + private readonly _storageKey: string; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; - private readonly _onDidChangeChatModes = this._register(new Emitter()); - public readonly onDidChangeChatModes = this._onDidChangeChatModes.event; + /** Tracks the most recent refresh of custom prompt modes. */ + private _pendingRefresh: Promise = Promise.resolve(); constructor( + readonly sessionType: string, @IPromptsService private readonly promptsService: IPromptsService, @IChatAgentService private readonly chatAgentService: IChatAgentService, @IContextKeyService contextKeyService: IContextKeyService, @ILogService private readonly logService: ILogService, @IStorageService private readonly storageService: IStorageService, - @IConfigurationService private readonly configurationService: IConfigurationService + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); + this._storageKey = ChatModes.CUSTOM_MODES_STORAGE_KEY_PREFIX + sessionType; this.hasCustomModes = ChatContextKeys.Modes.hasCustomChatModes.bindTo(contextKeyService); - this.agentModeDisabledByPolicy = ChatContextKeys.Modes.agentModeDisabledByPolicy.bindTo(contextKeyService); - - // Initialize the policy context key - this.updateAgentModePolicyContextKey(); // Load cached modes from storage first this.loadCachedModes(); - void this.refreshCustomPromptModes(true); + this._pendingRefresh = this.refreshCustomPromptModes(true); this._register(this.promptsService.onDidChangeCustomAgents(() => { - void this.refreshCustomPromptModes(true); + this._pendingRefresh = this.refreshCustomPromptModes(true); })); this._register(this.storageService.onWillSaveState(() => this.saveCachedModes())); - // Listen for configuration changes that affect agent mode policy + // Builtin mode availability depends on configuration policy and tools-agent availability. this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(ChatConfiguration.AgentEnabled)) { - this.updateAgentModePolicyContextKey(); - this._onDidChangeChatModes.fire(); + this._onDidChange.fire(); } })); - - // Ideally we can get rid of the setting to disable agent mode? let didHaveToolsAgent = this.chatAgentService.hasToolsAgent; this._register(this.chatAgentService.onDidChangeAgents(() => { if (didHaveToolsAgent !== this.chatAgentService.hasToolsAgent) { didHaveToolsAgent = this.chatAgentService.hasToolsAgent; - this._onDidChangeChatModes.fire(); + this._onDidChange.fire(); } })); } + get builtin(): readonly IChatMode[] { + return this.getBuiltinModes(); + } + + get custom(): readonly IChatMode[] { + return this.getCustomModes(); + } + + findModeById(id: string | ChatModeKind): IChatMode | undefined { + return this.getBuiltinModes().find(mode => mode.id === id) ?? this._customModeInstances.get(id); + } + + findModeByName(name: string): IChatMode | undefined { + return this.getBuiltinModes().find(mode => mode.name.get() === name) ?? this.getCustomModes().find(mode => mode.name.get() === name); + } + + waitForRefresh(): Promise { + return this._pendingRefresh; + } + private loadCachedModes(): void { try { - const cachedCustomModes = this.storageService.getObject(ChatModeService.CUSTOM_MODES_STORAGE_KEY, StorageScope.WORKSPACE); + const cachedCustomModes = this.storageService.getObject(this._storageKey, StorageScope.WORKSPACE); if (cachedCustomModes) { this.deserializeCachedModes(cachedCustomModes); } @@ -149,7 +195,7 @@ export class ChatModeService extends Disposable implements IChatModeService { private saveCachedModes(): void { try { const modesToCache = Array.from(this._customModeInstances.values()); - this.storageService.store(ChatModeService.CUSTOM_MODES_STORAGE_KEY, modesToCache, StorageScope.WORKSPACE, StorageTarget.MACHINE); + this.storageService.store(this._storageKey, modesToCache, StorageScope.WORKSPACE, StorageTarget.MACHINE); } catch (error) { this.logService.warn('Failed to save cached custom agents', error); } @@ -163,7 +209,8 @@ export class ChatModeService extends Disposable implements IChatModeService { const seenUris = new Set(); for (const customMode of customModes) { - if (!customMode.visibility.userInvocable || !customMode.enabled) { + // Filter custom agents by the session type this instance was created for + if (!customMode.visibility.userInvocable || !customMode.enabled || !matchesSessionType(customMode.sessionTypes, this.sessionType)) { continue; } @@ -195,25 +242,10 @@ export class ChatModeService extends Disposable implements IChatModeService { this.hasCustomModes.set(false); } if (fireChangeEvent) { - this._onDidChangeChatModes.fire(); + this._onDidChange.fire(); } } - getModes(): { builtin: readonly IChatMode[]; custom: readonly IChatMode[] } { - return { - builtin: this.getBuiltinModes(), - custom: this.getCustomModes(), - }; - } - - findModeById(id: string | ChatModeKind): IChatMode | undefined { - return this.getBuiltinModes().find(mode => mode.id === id) ?? this._customModeInstances.get(id); - } - - findModeByName(name: string): IChatMode | undefined { - return this.getBuiltinModes().find(mode => mode.name.get() === name) ?? this.getCustomModes().find(mode => mode.name.get() === name); - } - private getBuiltinModes(): IChatMode[] { const builtinModes: IChatMode[] = [ ChatMode.Ask, @@ -235,6 +267,54 @@ export class ChatModeService extends Disposable implements IChatModeService { return this.chatAgentService.hasToolsAgent || this.isAgentModeDisabledByPolicy() ? Array.from(this._customModeInstances.values()) : []; } + private isAgentModeDisabledByPolicy(): boolean { + return this.configurationService.inspect(ChatConfiguration.AgentEnabled).policyValue === false; + } +} + +export class ChatModeService extends Disposable implements IChatModeService { + declare readonly _serviceBrand: undefined; + + private readonly agentModeDisabledByPolicy: IContextKey; + + /** Cached per-sessionType {@link ChatModes} instances. */ + private readonly _modesBySessionType = this._register(new DisposableMap()); + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IConfigurationService private readonly configurationService: IConfigurationService, + ) { + super(); + + this.agentModeDisabledByPolicy = ChatContextKeys.Modes.agentModeDisabledByPolicy.bindTo(contextKeyService); + + // Initialize the policy context key + this.updateAgentModePolicyContextKey(); + + // Listen for configuration changes that affect agent mode policy + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ChatConfiguration.AgentEnabled)) { + this.updateAgentModePolicyContextKey(); + } + })); + } + + getModes(sessionType: string): IChatModes { + let modes = this._modesBySessionType.get(sessionType); + if (!modes) { + modes = this.instantiationService.createInstance(ChatModes, sessionType); + this._modesBySessionType.set(sessionType, modes); + } + return modes; + } + + async awaitModes(sessionType: string): Promise { + const modes = this.getModes(sessionType); + await modes.waitForRefresh(); + return modes; + } + private updateAgentModePolicyContextKey(): void { this.agentModeDisabledByPolicy.set(this.isAgentModeDisabledByPolicy()); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts index b991d1d4db579..ea30e90756c3a 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts @@ -9,6 +9,7 @@ import { Range } from '../../../../../../editor/common/core/range.js'; import { Definition, DefinitionProvider } from '../../../../../../editor/common/languages.js'; import { ITextModel } from '../../../../../../editor/common/model.js'; import { IChatModeService } from '../../chatModes.js'; +import { localChatSessionType } from '../../chatSessionsService.js'; import { PromptHeaderAttributes } from '../promptFileParser.js'; import { getPromptsTypeForLanguageId } from '../promptTypes.js'; import { IPromptsService } from '../service/promptsService.js'; @@ -40,7 +41,7 @@ export class PromptHeaderDefinitionProvider implements DefinitionProvider { const agentAttr = header.getAttribute(PromptHeaderAttributes.agent) ?? header.getAttribute(PromptHeaderAttributes.mode); if (agentAttr && agentAttr.value.type === 'scalar' && agentAttr.range.containsPosition(position)) { - const agent = this.chatModeService.findModeByName(agentAttr.value.value); + const agent = (await this.chatModeService.awaitModes(localChatSessionType)).findModeByName(agentAttr.value.value); if (agent && agent.uri) { return { uri: agent.uri.get(), diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts index 537571f6bd631..440ce0916656b 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts @@ -12,6 +12,7 @@ import { ITextModel } from '../../../../../../editor/common/model.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; import { ILanguageModelToolsService } from '../../tools/languageModelToolsService.js'; import { IChatModeService } from '../../chatModes.js'; +import { localChatSessionType } from '../../chatSessionsService.js'; import { getPromptsTypeForLanguageId, PromptsType, Target } from '../promptTypes.js'; import { IPromptsService } from '../service/promptsService.js'; import { Iterable } from '../../../../../../base/common/iterator.js'; @@ -143,7 +144,7 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { for (const attr of header.attributes) { attributesToPropose.delete(attr.key); } - const getInsertText = (key: string): string => { + const getInsertText = async (key: string): Promise => { if (colonPosition) { return key; } @@ -152,7 +153,7 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { const hookNames = Object.keys(HOOKS_BY_TARGET[target] ?? HOOKS_BY_TARGET[Target.Undefined]); return `${key}:\n \${1|${hookNames.join(',')}|}:\n - type: command\n command: "$2"`; } - const valueSuggestions = this.getValueSuggestions(promptType, key, target); + const valueSuggestions = await this.getValueSuggestions(promptType, key, target); if (valueSuggestions.length > 0) { return `${key}: \${0:${valueSuggestions[0].name}}`; } else { @@ -166,7 +167,7 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { label: attribute, documentation: getAttributeDefinition(attribute, promptType, target)?.description, kind: CompletionItemKind.Property, - insertText: getInsertText(attribute), + insertText: await getInsertText(attribute), insertTextRules: CompletionItemInsertTextRule.InsertAsSnippet, range: new Range(position.lineNumber, 1, position.lineNumber, !colonPosition ? model.getLineMaxColumn(position.lineNumber) : colonPosition.column), }; @@ -251,7 +252,7 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { } const lineContent = model.getLineContent(attribute.range.startLineNumber); const whilespaceAfterColon = (lineContent.substring(colonPosition.column).match(/^\s*/)?.[0].length) ?? 0; - const entries = this.getValueSuggestions(promptType, attribute.key, target); + const entries = await this.getValueSuggestions(promptType, attribute.key, target); for (const entry of entries) { const item: CompletionItem = { label: entry.name, @@ -562,7 +563,7 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { return undefined; } - private getValueSuggestions(promptType: PromptsType, attribute: string, target: Target): readonly IValueEntry[] { + private async getValueSuggestions(promptType: PromptsType, attribute: string, target: Target): Promise { const attributeDesc = getAttributeDefinition(attribute, promptType, target); if (attributeDesc?.enums) { return attributeDesc.enums; @@ -575,7 +576,7 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { case PromptHeaderAttributes.mode: if (promptType === PromptsType.prompt) { // Get all available agents (builtin + custom) - const agents = this.chatModeService.getModes(); + const agents = await this.chatModeService.awaitModes(localChatSessionType); const suggestions: IValueEntry[] = []; for (const agent of Iterable.concat(agents.builtin, agents.custom)) { suggestions.push({ name: agent.name.get(), description: agent.label.get() }); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts index 00065d1b40c3a..e9e7c698d1ffc 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts @@ -13,6 +13,7 @@ import { localize } from '../../../../../../nls.js'; import { ILanguageModelsService } from '../../languageModels.js'; import { ILanguageModelToolsService, isToolSet, IToolSet } from '../../tools/languageModelToolsService.js'; import { IChatModeService, isBuiltinChatMode } from '../../chatModes.js'; +import { localChatSessionType } from '../../chatSessionsService.js'; import { getPromptsTypeForLanguageId, PromptsType, Target } from '../promptTypes.js'; import { IPromptsService } from '../service/promptsService.js'; import { IHeaderAttribute, ISequenceValue, parseCommaSeparatedList, PromptBody, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; @@ -203,17 +204,17 @@ export class PromptHoverProvider implements HoverProvider { return this.createHover(baseMessage, node.range); } - private getAgentHover(agentAttribute: IHeaderAttribute, position: Position, baseMessage: string): Hover | undefined { + private async getAgentHover(agentAttribute: IHeaderAttribute, position: Position, baseMessage: string): Promise { const lines: string[] = []; const value = agentAttribute.value; if (value.type === 'scalar' && value.range.containsPosition(position)) { - const agent = this.chatModeService.findModeByName(value.value); + const agent = (await this.chatModeService.awaitModes(localChatSessionType)).findModeByName(value.value); if (agent) { const description = agent.description.get() || (isBuiltinChatMode(agent) ? localize('promptHeader.prompt.agent.builtInDesc', 'Built-in agent') : localize('promptHeader.prompt.agent.customDesc', 'Custom agent')); lines.push(`\`${agent.name.get()}\`: ${description}`); } } else { - const agents = this.chatModeService.getModes(); + const agents = await this.chatModeService.awaitModes(localChatSessionType); lines.push(baseMessage); lines.push(''); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts index 92ef7c2b31ad8..7f0e8c25ca44d 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -9,6 +9,7 @@ import { Range } from '../../../../../../editor/common/core/range.js'; import { localize } from '../../../../../../nls.js'; import { IMarkerData, MarkerSeverity, MarkerTag } from '../../../../../../platform/markers/common/markers.js'; import { ChatMode, IChatMode, IChatModeService } from '../../chatModes.js'; +import { localChatSessionType } from '../../chatSessionsService.js'; import { ChatModeKind } from '../../constants.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; import { ILanguageModelToolsService, SpecedToolAliases } from '../../tools/languageModelToolsService.js'; @@ -469,7 +470,7 @@ export class PromptValidator { } private validateAgentValue(value: IScalarValue, report: (markers: IMarkerData) => void): IChatMode | undefined { - const agents = this.chatModeService.getModes(); + const agents = this.chatModeService.getModes(localChatSessionType); const availableAgents = []; // Check if agent exists in builtin or custom agents diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatExecuteActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatExecuteActions.test.ts index b8deb8f251094..0efed2f8dc751 100644 --- a/src/vs/workbench/contrib/chat/test/browser/actions/chatExecuteActions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatExecuteActions.test.ts @@ -12,12 +12,13 @@ import { CommandsRegistry } from '../../../../../../platform/commands/common/com import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; import { GetHandoffsActionId, ExecuteHandoffActionId, registerChatExecuteActions } from '../../../browser/actions/chatExecuteActions.js'; -import { IChatMode, IChatModeService, ICustomAgentInfo } from '../../../common/chatModes.js'; +import { IChatMode, IChatModes, IChatModeService, ICustomAgentInfo } from '../../../common/chatModes.js'; import { ChatModeKind } from '../../../common/constants.js'; import { IHandOff } from '../../../common/promptSyntax/promptFileParser.js'; import { Target } from '../../../common/promptSyntax/promptTypes.js'; import { MockChatWidgetService } from '../widget/mockChatWidget.js'; import { MockChatModeService } from '../../common/mockChatModeService.js'; +import { SessionType } from '../../../common/chatSessionsService.js'; interface IExecuteHandoffResult { success: boolean; @@ -161,11 +162,12 @@ suite('ExecuteHandoffAction', () => { handOffs: observableValue('handOffs', testHandoffs), }); - function createMockWidget(currentMode: IChatMode): { widget: Partial; executeHandoffCalls: IHandOff[] } { + function createMockWidget(currentMode: IChatMode, chatModes: IChatModes): { widget: Partial; executeHandoffCalls: IHandOff[] } { const executeHandoffCalls: IHandOff[] = []; const widget: Partial = { input: { currentModeObs: constObservable(currentMode), + currentChatModesObs: constObservable(chatModes), } as IChatWidget['input'], executeHandoff: async (handoff: IHandOff) => { executeHandoffCalls.push(handoff); @@ -195,14 +197,15 @@ suite('ExecuteHandoffAction', () => { }); test('should fall back to lastFocusedWidget when sessionResource is omitted', async () => { - const { widget, executeHandoffCalls } = createMockWidget(planMode); + const chatModeService = new MockChatModeService(); + const { widget, executeHandoffCalls } = createMockWidget(planMode, await chatModeService.awaitModes(SessionType.Local)); const mockWidgetService = new class extends MockChatWidgetService { override readonly lastFocusedWidget = widget as IChatWidget; }; instantiationService.set(IChatWidgetService, mockWidgetService); - instantiationService.set(IChatModeService, new MockChatModeService({ builtin: [], custom: [planMode] })); + instantiationService.set(IChatModeService, chatModeService); const handler = CommandsRegistry.getCommand(ExecuteHandoffActionId)?.handler; assert.ok(handler); @@ -214,7 +217,8 @@ suite('ExecuteHandoffAction', () => { }); test('should resolve widget by sessionResource', async () => { - const { widget, executeHandoffCalls } = createMockWidget(planMode); + const chatModeService = new MockChatModeService({ builtin: [], custom: [planMode] }); + const { widget, executeHandoffCalls } = createMockWidget(planMode, await chatModeService.awaitModes(SessionType.Local)); const sessionUri = URI.parse('test://session/1'); const mockWidgetService = new class extends MockChatWidgetService { @@ -224,7 +228,7 @@ suite('ExecuteHandoffAction', () => { }; instantiationService.set(IChatWidgetService, mockWidgetService); - instantiationService.set(IChatModeService, new MockChatModeService({ builtin: [], custom: [planMode] })); + instantiationService.set(IChatModeService, chatModeService); const handler = CommandsRegistry.getCommand(ExecuteHandoffActionId)?.handler; assert.ok(handler); @@ -238,14 +242,15 @@ suite('ExecuteHandoffAction', () => { }); test('should match by id (primary)', async () => { - const { widget, executeHandoffCalls } = createMockWidget(planMode); + const chatModeService = new MockChatModeService(); + const { widget, executeHandoffCalls } = createMockWidget(planMode, await chatModeService.awaitModes(SessionType.Local)); const mockWidgetService = new class extends MockChatWidgetService { override readonly lastFocusedWidget = widget as IChatWidget; }; instantiationService.set(IChatWidgetService, mockWidgetService); - instantiationService.set(IChatModeService, new MockChatModeService()); + instantiationService.set(IChatModeService, chatModeService); const handler = CommandsRegistry.getCommand(ExecuteHandoffActionId)?.handler; assert.ok(handler); @@ -256,14 +261,15 @@ suite('ExecuteHandoffAction', () => { }); test('should fall back to label match when id is not provided', async () => { - const { widget, executeHandoffCalls } = createMockWidget(planMode); + const chatModeService = new MockChatModeService(); + const { widget, executeHandoffCalls } = createMockWidget(planMode, await chatModeService.awaitModes(SessionType.Local)); const mockWidgetService = new class extends MockChatWidgetService { override readonly lastFocusedWidget = widget as IChatWidget; }; instantiationService.set(IChatWidgetService, mockWidgetService); - instantiationService.set(IChatModeService, new MockChatModeService()); + instantiationService.set(IChatModeService, chatModeService); const handler = CommandsRegistry.getCommand(ExecuteHandoffActionId)?.handler; assert.ok(handler); @@ -274,14 +280,15 @@ suite('ExecuteHandoffAction', () => { }); test('should return error for non-matching identifier', async () => { - const { widget } = createMockWidget(planMode); + const chatModeService = new MockChatModeService(); + const { widget } = createMockWidget(planMode, await chatModeService.awaitModes(SessionType.Local)); const mockWidgetService = new class extends MockChatWidgetService { override readonly lastFocusedWidget = widget as IChatWidget; }; instantiationService.set(IChatWidgetService, mockWidgetService); - instantiationService.set(IChatModeService, new MockChatModeService()); + instantiationService.set(IChatModeService, chatModeService); const handler = CommandsRegistry.getCommand(ExecuteHandoffActionId)?.handler; assert.ok(handler); @@ -293,7 +300,8 @@ suite('ExecuteHandoffAction', () => { test('should resolve sourceCustomAgent to look up handoffs from a different mode', async () => { const askMode = createMockMode({ id: 'ask', kind: ChatModeKind.Ask, isBuiltin: true }); - const { widget, executeHandoffCalls } = createMockWidget(askMode); // widget is in "ask" mode (no handoffs) + const modeService = new MockChatModeService({ builtin: [askMode], custom: [planMode] }); + const { widget, executeHandoffCalls } = createMockWidget(askMode, await modeService.awaitModes(SessionType.Local)); // widget is in "ask" mode (no handoffs) const mockWidgetService = new class extends MockChatWidgetService { override readonly lastFocusedWidget = widget as IChatWidget; @@ -301,7 +309,7 @@ suite('ExecuteHandoffAction', () => { // The plan mode has handoffs; sourceCustomAgent overrides the widget's current mode instantiationService.set(IChatWidgetService, mockWidgetService); - instantiationService.set(IChatModeService, new MockChatModeService({ builtin: [askMode], custom: [planMode] })); + instantiationService.set(IChatModeService, modeService); const handler = CommandsRegistry.getCommand(ExecuteHandoffActionId)?.handler; assert.ok(handler); @@ -315,15 +323,17 @@ suite('ExecuteHandoffAction', () => { }); test('should return error when source mode has no handoffs', async () => { + const askMode = createMockMode({ id: 'ask', kind: ChatModeKind.Ask, isBuiltin: true }); - const { widget } = createMockWidget(askMode); + const chatModeService = new MockChatModeService({ builtin: [askMode], custom: [] }); + const { widget } = createMockWidget(askMode, await chatModeService.awaitModes(SessionType.Local)); // widget is in "ask" mode (no handoffs) const mockWidgetService = new class extends MockChatWidgetService { override readonly lastFocusedWidget = widget as IChatWidget; }; instantiationService.set(IChatWidgetService, mockWidgetService); - instantiationService.set(IChatModeService, new MockChatModeService({ builtin: [askMode], custom: [] })); + instantiationService.set(IChatModeService, chatModeService); const handler = CommandsRegistry.getCommand(ExecuteHandoffActionId)?.handler; assert.ok(handler); diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts index 56be1f0a16aa4..93bd33b636fd6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts @@ -27,6 +27,7 @@ import { PromptFileParser } from '../../../../common/promptSyntax/promptFilePars import { ITextModel } from '../../../../../../../editor/common/model.js'; import { getPromptFileExtension } from '../../../../common/promptSyntax/config/promptFileLocations.js'; import { Range } from '../../../../../../../editor/common/core/range.js'; +import { MockChatModeService } from '../../../common/mockChatModeService.js'; suite('PromptHeaderAutocompletion', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -91,11 +92,7 @@ suite('PromptHeaderAutocompletion', () => { } }); - instaService.stub(IChatModeService, { - getModes() { - return { builtin: [], custom: [] }; - } - }); + instaService.stub(IChatModeService, new MockChatModeService()); completionProvider = instaService.createInstance(PromptHeaderAutocompletion); }); @@ -797,7 +794,7 @@ suite('PromptHeaderAutocompletion', () => { const actual = await getCompletions(content, PromptsType.prompt); assert.deepStrictEqual(actual.sort(sortByLabel), [ - { label: 'agent', result: 'agent: $0' }, + { label: 'agent', result: 'agent: ${0:ask}' }, { label: 'argument-hint', result: 'argument-hint: $0' }, { label: 'model', result: 'model: ${0:MAE 4 (olama)}' }, { label: 'name', result: 'name: $0' }, diff --git a/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts index ad8bd6c917f07..8c773de6aa36c 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts @@ -18,6 +18,7 @@ import { TestConfigurationService } from '../../../../../platform/configuration/ import { TestStorageService } from '../../../../test/common/workbenchTestServices.js'; import { IChatAgentService } from '../../common/participants/chatAgents.js'; import { ChatMode, ChatModeService } from '../../common/chatModes.js'; +import { localChatSessionType } from '../../common/chatSessionsService.js'; import { ChatModeKind } from '../../common/constants.js'; import { IAgentSource, ICustomAgent, IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { MockPromptsService } from './promptSyntax/service/mockPromptsService.js'; @@ -68,10 +69,13 @@ suite('ChatModeService', () => { instantiationService.stub(IConfigurationService, configurationService); chatModeService = testDisposables.add(instantiationService.createInstance(ChatModeService)); + // Eagerly create the ChatModes for the local session type and await + // its initial async refresh so tests can rely on a settled state. + await chatModeService.awaitModes(localChatSessionType); }); test('should return builtin modes', () => { - const modes = chatModeService.getModes(); + const modes = chatModeService.getModes(localChatSessionType); assert.strictEqual(modes.builtin.length, 3); assert.strictEqual(modes.custom.length, 0); @@ -87,12 +91,12 @@ suite('ChatModeService', () => { test('should adjust builtin modes based on tools agent availability', () => { // Agent mode should always be present regardless of tools agent availability chatAgentService.setHasToolsAgent(true); - let agents = chatModeService.getModes(); + let agents = chatModeService.getModes(localChatSessionType); assert.ok(agents.builtin.find(agent => agent.id === ChatModeKind.Agent)); // Without tools agent - Agent mode should not be present chatAgentService.setHasToolsAgent(false); - agents = chatModeService.getModes(); + agents = chatModeService.getModes(localChatSessionType); assert.strictEqual(agents.builtin.find(agent => agent.id === ChatModeKind.Agent), undefined); // Ask and Edit modes should always be present @@ -101,14 +105,14 @@ suite('ChatModeService', () => { }); test('should find builtin modes by id', () => { - const agentMode = chatModeService.findModeById(ChatModeKind.Agent); + const agentMode = chatModeService.getModes(localChatSessionType).findModeById(ChatModeKind.Agent); assert.ok(agentMode); assert.strictEqual(agentMode.id, ChatMode.Agent.id); assert.strictEqual(agentMode.kind, ChatModeKind.Agent); }); test('should return undefined for non-existent mode', () => { - const mode = chatModeService.findModeById('non-existent-mode'); + const mode = chatModeService.getModes(localChatSessionType).findModeById('non-existent-mode'); assert.strictEqual(mode, undefined); }); @@ -130,7 +134,7 @@ suite('ChatModeService', () => { // Wait for the service to refresh await timeout(0); - const modes = chatModeService.getModes(); + const modes = chatModeService.getModes(localChatSessionType); assert.strictEqual(modes.custom.length, 1); const testMode = modes.custom[0]; @@ -148,7 +152,7 @@ suite('ChatModeService', () => { test('should fire change event when custom modes are updated', async () => { let eventFired = false; - testDisposables.add(chatModeService.onDidChangeChatModes(() => { + testDisposables.add(chatModeService.getModes(localChatSessionType).onDidChange(() => { eventFired = true; })); @@ -190,7 +194,7 @@ suite('ChatModeService', () => { // Wait for the service to refresh await timeout(0); - const foundMode = chatModeService.findModeById(customMode.uri.toString()); + const foundMode = chatModeService.getModes(localChatSessionType).findModeById(customMode.uri.toString()); assert.ok(foundMode); assert.strictEqual(foundMode.id, customMode.uri.toString()); assert.strictEqual(foundMode.name.get(), customMode.name); @@ -215,7 +219,7 @@ suite('ChatModeService', () => { promptsService.setCustomModes([initialMode]); await timeout(0); - const initialModes = chatModeService.getModes(); + const initialModes = chatModeService.getModes(localChatSessionType); const initialCustomMode = initialModes.custom[0]; assert.strictEqual(initialCustomMode.description.get(), 'Initial description'); @@ -231,7 +235,7 @@ suite('ChatModeService', () => { promptsService.setCustomModes([updatedMode]); await timeout(0); - const updatedModes = chatModeService.getModes(); + const updatedModes = chatModeService.getModes(localChatSessionType); const updatedCustomMode = updatedModes.custom[0]; // The instance should be the same (reused) @@ -274,14 +278,14 @@ suite('ChatModeService', () => { promptsService.setCustomModes([mode1, mode2]); await timeout(0); - let modes = chatModeService.getModes(); + let modes = chatModeService.getModes(localChatSessionType); assert.strictEqual(modes.custom.length, 2); // Remove one mode promptsService.setCustomModes([mode1]); await timeout(0); - modes = chatModeService.getModes(); + modes = chatModeService.getModes(localChatSessionType); assert.strictEqual(modes.custom.length, 1); assert.strictEqual(modes.custom[0].id, mode1.uri.toString()); }); diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatModeService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatModeService.ts index 53e66f2b5e0a1..18a9fe6d9576c 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatModeService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatModeService.ts @@ -4,28 +4,48 @@ *--------------------------------------------------------------------------------------------*/ -import { Event } from '../../../../../base/common/event.js'; -import { ChatMode, IChatMode, IChatModeService } from '../../common/chatModes.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { ChatMode, IChatMode, IChatModes, IChatModeService } from '../../common/chatModes.js'; +import { localChatSessionType } from '../../common/chatSessionsService.js'; export class MockChatModeService implements IChatModeService { declare readonly _serviceBrand: undefined; - public readonly onDidChangeChatModes = Event.None; + private readonly _onDidChange = new Emitter(); + private readonly _modesView: IChatModes; constructor( private readonly _modes: { builtin: readonly IChatMode[]; custom: readonly IChatMode[] } = { builtin: [ChatMode.Ask], custom: [] } - ) { } - - getModes(): { builtin: readonly IChatMode[]; custom: readonly IChatMode[] } { - return this._modes; + ) { + const modes = this._modes; + const onDidChange = this._onDidChange.event; + this._modesView = { + sessionType: localChatSessionType, + onDidChange, + get builtin() { return modes.builtin; }, + get custom() { return modes.custom; }, + findModeById(id: string): IChatMode | undefined { + return modes.builtin.find(mode => mode.id === id) ?? modes.custom.find(mode => mode.id === id); + }, + findModeByName(name: string): IChatMode | undefined { + return modes.builtin.find(mode => mode.name.get() === name) ?? modes.custom.find(mode => mode.name.get() === name); + }, + waitForRefresh(): Promise { + return Promise.resolve(); + }, + }; } - findModeById(id: string): IChatMode | undefined { - return this._modes.builtin.find(mode => mode.id === id) ?? this._modes.custom.find(mode => mode.id === id); + getModes(_sessionType: string): IChatModes { + return this._modesView; } - findModeByName(name: string): IChatMode | undefined { - return this._modes.builtin.find(mode => mode.name.get() === name) ?? this._modes.custom.find(mode => mode.name.get() === name); + async awaitModes(_sessionType: string): Promise { + return this._modesView; } + /** Test helper to fire the change event for the cached modes view. */ + fireDidChange(): void { + this._onDidChange.fire(); + } } diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts index 9ae498902e5f2..30a8371b08ae3 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts @@ -57,6 +57,7 @@ import { ISCMService } from '../../../../contrib/scm/common/scm.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; import '../../../../contrib/chat/browser/widget/media/chat.css'; +import { MockChatModeService } from '../../../../contrib/chat/test/common/mockChatModeService.js'; class FixtureMenuService implements IMenuService { declare readonly _serviceBrand: undefined; @@ -118,7 +119,7 @@ async function renderChatInput(context: ComponentFixtureContext, fixtureOptions: reg.defineInstance(ISharedWebContentExtractorService, new class extends mock() { }()); reg.defineInstance(IWorkbenchAssignmentService, new class extends mock() { override async getCurrentExperiments() { return []; } override async getTreatment() { return undefined; } override onDidRefetchAssignments = Event.None; }()); reg.defineInstance(IChatEntitlementService, new class extends mock() { }()); - reg.defineInstance(IChatModeService, new class extends mock() { override readonly onDidChangeChatModes = Event.None; override getModes() { return { builtin: [], custom: [] }; } override findModeById() { return undefined; } }()); + reg.defineInstance(IChatModeService, new MockChatModeService()); reg.defineInstance(ILanguageModelToolsService, new class extends mock() { override onDidChangeTools = Event.None; override getTools() { return []; } }()); reg.defineInstance(IChatService, new class extends mock() { override onDidSubmitRequest = Event.None; }()); reg.defineInstance(IChatSessionsService, new class extends mock() { override getAllChatSessionContributions() { return []; } override readonly onDidChangeSessionOptions = Event.None; override readonly onDidChangeOptionGroups = Event.None; override readonly onDidChangeAvailability = Event.None; }()); diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts index a07d465f6843a..1a8478b3dd54f 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts @@ -78,6 +78,7 @@ import '../../../../contrib/inlineChat/browser/media/inlineChat.css'; import '../../../../contrib/chat/browser/widget/media/chat.css'; import '../../../../../editor/contrib/zoneWidget/browser/zoneWidget.css'; import '../../../../../base/browser/ui/codicons/codiconStyles.js'; +import { MockChatModeService } from '../../../../contrib/chat/test/common/mockChatModeService.js'; const SAMPLE_CODE = `import { useState, useEffect } from 'react'; @@ -223,11 +224,7 @@ function renderInlineChatZoneWidget({ container, disposableStore, theme }: Compo override readonly anonymousObs = observableValue('anonymous', false); override readonly onDidChangeAnonymous = Event.None; }()); - reg.defineInstance(IChatModeService, new class extends mock() { - override readonly onDidChangeChatModes = Event.None; - override getModes() { return { builtin: [], custom: [] }; } - override findModeById() { return undefined; } - }()); + reg.defineInstance(IChatModeService, new MockChatModeService()); reg.defineInstance(IChatSessionsService, new class extends mock() { override getAllChatSessionContributions() { return []; } override readonly onDidChangeSessionOptions = Event.None; From 1585ddadb1760c207c66cd6f85c8f8211bcda7af Mon Sep 17 00:00:00 2001 From: Paul Date: Fri, 1 May 2026 12:59:52 -0700 Subject: [PATCH 07/19] Add telemetry for ubb (#313772) --- .../chatSetup/chatSetupContributions.ts | 4 ++ .../browser/chatStatus/chatStatusDashboard.ts | 5 +- .../chat/common/chatEntitlementService.ts | 50 ++++++++++++++++++- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts index 2d1b50bedce16..34449705f9374 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts @@ -456,7 +456,9 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr const openerService = accessor.get(IOpenerService); const hostService = accessor.get(IHostService); const commandService = accessor.get(ICommandService); + const telemetryService = accessor.get(ITelemetryService); + telemetryService.publicLog2('workbenchActionExecuted', { id: 'workbench.action.chat.upgradePlan', from: 'command' }); openerService.open(URI.parse(defaultChat.upgradePlanUrl)); const entitlement = context.state.entitlement; @@ -518,6 +520,8 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr override async run(accessor: ServicesAccessor): Promise { const openerService = accessor.get(IOpenerService); + const telemetryService = accessor.get(ITelemetryService); + telemetryService.publicLog2('workbenchActionExecuted', { id: 'workbench.action.chat.manageAdditionalSpend', from: 'command' }); openerService.open(URI.parse(defaultChat.manageOverageUrl)); } } diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts index 432871c889859..5477f99f5d4aa 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts @@ -167,7 +167,10 @@ export class ChatStatusDashboard extends DomWidget { headerAdditionalSpendButton = this._store.add(new Button(header, { ...defaultButtonStyles, hoverDelegate: nativeHoverDelegate, secondary: true })); headerAdditionalSpendButton.element.classList.add('header-cta-button'); headerAdditionalSpendButton.label = initialAdditionalUsageEnabled ? localize('manageAdditionalSpend', "Manage Additional Spend") : localize('configureAdditionalSpend', "Configure Additional Spend"); - this._store.add(headerAdditionalSpendButton.onDidClick(() => this.runCommandAndClose(() => this.openerService.open(URI.parse(defaultChat.manageOverageUrl))))); + this._store.add(headerAdditionalSpendButton.onDidClick(() => { + this.telemetryService.publicLog2('workbenchActionExecuted', { id: 'workbench.action.chat.manageAdditionalSpend', from: 'chat-status' }); + this.runCommandAndClose(() => this.openerService.open(URI.parse(defaultChat.manageOverageUrl))); + })); if (actionBarElement) { header.insertBefore(headerAdditionalSpendButton.element, actionBarElement); } diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index 3ce93e672cba2..43d98e740fd57 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -299,6 +299,28 @@ function logChatEntitlements(state: IChatEntitlementContextState, configurationS }); } +type ChatAdditionalSpendConfigurationClassification = { + owner: 'pwang347'; + comment: 'Tracks when a user enables or disables additional spend.'; + enabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether additional spend is now enabled or disabled.' }; + entitlement: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The current chat entitlement of the user.' }; +}; +type ChatAdditionalSpendConfigurationEvent = { + enabled: boolean; + entitlement: ChatEntitlement; +}; + +type ChatAdditionalSpendActiveClassification = { + owner: 'pwang347'; + comment: 'Tracks when a user enters additional spend (included quota exhausted while additional spend is enabled).'; + entitlement: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The current chat entitlement of the user.' }; + additionalUsageCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of additional spend interactions used so far.' }; +}; +type ChatAdditionalSpendActiveEvent = { + entitlement: ChatEntitlement; + additionalUsageCount: number; +}; + export class ChatEntitlementService extends Disposable implements IChatEntitlementService { declare _serviceBrand: undefined; @@ -505,6 +527,23 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme if (chatChanged.remaining || completionsChanged.remaining || premiumChatChanged.remaining) { this._onDidChangeQuotaRemaining.fire(); } + + // Track additional spend configuration changes (only when both values come from server snapshots) + if (oldQuota.additionalUsageEnabled !== undefined && quotas.additionalUsageEnabled !== undefined && oldQuota.additionalUsageEnabled !== quotas.additionalUsageEnabled) { + this.telemetryService.publicLog2('chatAdditionalSpendConfiguration', { + enabled: quotas.additionalUsageEnabled ?? false, + entitlement: this.entitlement, + }); + } + + // Track entering additional spend: included quota just exhausted while additional spend is enabled + if (quotas.additionalUsageEnabled && quotas.premiumChat?.percentRemaining === 0 + && oldQuota.premiumChat?.percentRemaining !== undefined && oldQuota.premiumChat.percentRemaining > 0) { + this.telemetryService.publicLog2('chatAdditionalSpendActive', { + entitlement: this.entitlement, + additionalUsageCount: quotas.additionalUsageCount ?? 0, + }); + } } private compareQuotas(oldQuota: IQuotaSnapshot | undefined, newQuota: IQuotaSnapshot | undefined): { changed: { exceeded: boolean; remaining: boolean } } { @@ -598,6 +637,9 @@ type EntitlementClassification = { quotaPremiumChat: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of premium chat requests available to the user' }; quotaCompletions: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of inline suggestions available to the user' }; quotaResetDate: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The date the quota will reset' }; + usageBasedBilling: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the user is on usage-based billing' }; + additionalUsageEnabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether overage / additional spend is enabled' }; + additionalUsageCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of overage interactions used' }; owner: 'bpasero'; comment: 'Reporting chat entitlements'; }; @@ -610,6 +652,9 @@ type EntitlementEvent = { quotaPremiumChat: number | undefined; quotaCompletions: number | undefined; quotaResetDate: string | undefined; + usageBasedBilling: boolean | undefined; + additionalUsageEnabled: boolean | undefined; + additionalUsageCount: number | undefined; }; interface IEntitlements { @@ -765,7 +810,10 @@ export class ChatEntitlementRequests extends Disposable { quotaChat: entitlements.quotas?.chat?.percentRemaining, quotaPremiumChat: entitlements.quotas?.premiumChat?.percentRemaining, quotaCompletions: entitlements.quotas?.completions?.percentRemaining, - quotaResetDate: entitlements.quotas?.resetDate + quotaResetDate: entitlements.quotas?.resetDate, + usageBasedBilling: entitlements.quotas?.premiumChat?.usageBasedBilling, + additionalUsageEnabled: entitlements.quotas?.additionalUsageEnabled, + additionalUsageCount: entitlements.quotas?.additionalUsageCount }); return entitlements; From d87814177617373c1203e43b5e2a59444e1fa6eb Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Fri, 1 May 2026 13:26:02 -0700 Subject: [PATCH 08/19] Add reindex cmd pallete (#313776) * reindex cmd Co-authored-by: Copilot * few updates Co-authored-by: Copilot --------- Co-authored-by: Copilot --- extensions/copilot/package.json | 6 ++ extensions/copilot/package.nls.json | 1 + .../vscode-node/remoteSessionExporter.ts | 58 ++++++++++++++++++- 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 711272c5417c4..07fa4f9de3335 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -2684,6 +2684,12 @@ "category": "Chat", "enablement": "github.copilot.sessionSearch.enabled && config.chat.sessionSync.enabled" }, + { + "command": "github.copilot.chronicle.reindex", + "title": "%github.copilot.command.chronicle.reindex%", + "category": "Chat", + "enablement": "github.copilot.sessionSearch.enabled" + }, { "command": "github.copilot.nes.captureExpected.start", "title": "Record Expected Edit (NES)", diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 6a71d1a03dc9e..6c12f4474e567 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -172,6 +172,7 @@ "copilot.chronicle.standup.description": "Generate a standup report from recent chat sessions", "copilot.chronicle.tips.description": "Get personalized tips based on your chat session usage patterns", "github.copilot.command.sessionSync.deleteSessions": "Delete Session Sync Data", + "github.copilot.command.chronicle.reindex": "Reindex Sessions", "copilot.chronicle.reindex.description": "Rebuild the local session index from stored session logs. Add 'force' to re-process already indexed sessions.", "github.copilot.config.sessionSearch.enabled": "Enable session search and /chronicle commands. This is a team-internal setting.", "github.copilot.config.sessionSearch.localIndex.enabled": "Enable local session tracking. When enabled, Copilot tracks session data locally for /chronicle commands.", diff --git a/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts b/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts index ad3b81f43f39b..1459210c3118b 100644 --- a/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts +++ b/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts @@ -35,7 +35,7 @@ import { CloudSessionApiClient } from '../node/cloudSessionApiClient'; import { ISessionSyncStateService, type SessionSyncState } from '../common/sessionSyncStateService'; import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; import { CloudSessionIdStore } from '../node/cloudSessionIdStore'; -import { reindexCloudSessions, type CloudReindexResult } from '../node/sessionReindexer'; +import { reindexSessions, reindexCloudSessions, type CloudReindexResult } from '../node/sessionReindexer'; // ── Configuration ─────────────────────────────────────────────────────────────── @@ -239,6 +239,9 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr // Register cloud reindex command (called from chronicleIntent after local reindex) this._register(vscode.commands.registerCommand('github.copilot.sessionSync.reindex', (reportProgress: (msg: string) => void, token: vscode.CancellationToken) => this._reindexCloud(reportProgress, token))); + // Register user-facing reindex command (Command Palette) + this._register(vscode.commands.registerCommand('github.copilot.chronicle.reindex', () => this._reindexFromCommandPalette())); + // Register known auth tokens as dynamic secrets for filtering this._registerAuthSecrets(); @@ -335,6 +338,58 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr }); } + // ── Reindex (Command Palette) ─────────────────────────────────────────────── + + /** + * User-facing reindex command. Runs local reindex with a progress notification, + * then optionally runs cloud reindex if session sync is enabled. + */ + private async _reindexFromCommandPalette(): Promise { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t('Reindexing sessions...'), + cancellable: true, + }, + async (progress, token) => { + // Local reindex + const localResult = await reindexSessions( + this._sessionStore, + this._debugLogService, + msg => progress.report({ message: msg }), + token, + ); + + if (token.isCancellationRequested) { + return; + } + + progress.report({ message: vscode.l10n.t('{0} session(s) processed, {1} skipped', localResult.processed, localResult.skipped) }); + + // Cloud reindex (if enabled) + const cloudResult = await this._reindexCloud( + msg => progress.report({ message: msg }), + token, + ); + + // Show summary + if (localResult.processed === 0 && (!cloudResult || cloudResult.created === 0)) { + vscode.window.showInformationMessage( + vscode.l10n.t('Session index is up to date. {0} session(s) checked.', localResult.skipped) + ); + } else if (cloudResult && cloudResult.created > 0) { + vscode.window.showInformationMessage( + vscode.l10n.t('{0} session(s) indexed locally, {1} synced to cloud.', localResult.processed, cloudResult.created) + ); + } else { + vscode.window.showInformationMessage( + vscode.l10n.t('{0} session(s) indexed locally.', localResult.processed) + ); + } + }, + ); + } + // ── Delete sessions (Command Palette) ─────────────────────────────────────── private async _deleteCloudSessions(): Promise { @@ -616,6 +671,7 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr ); // Update sync state with new count + this._invalidateLocalSyncedCount(); this._setSyncState({ kind: 'up-to-date', syncedCount: this._getLocalSyncedCount() }); this._telemetryService.sendMSFTTelemetryEvent('chronicle.cloudSync', { From 53964c4e856f17076f1116f16fee4d2e764fc19d Mon Sep 17 00:00:00 2001 From: Anthony Kim <62267334+anthonykim1@users.noreply.github.com> Date: Fri, 1 May 2026 13:28:38 -0700 Subject: [PATCH 09/19] Get model and multiplier show up for Copilot CLI controller API route (#313079) * Try to get model and token info to show up for cli in chat Co-authored-by: Copilot * make sure that auto mode persists for CLI * make ui appear for non-contoller api route * make controller route work without modifying requestHandler Co-authored-by: Copilot * dqwdqwdwq * ship more stuf Co-authored-by: Copilot * gate this behind setting Co-authored-by: Copilot * cleaner Co-authored-by: Copilot * stop messing with tests Co-authored-by: Copilot * try fix test * Make sure the test pass Co-authored-by: Copilot * rename better Co-authored-by: Copilot --------- Co-authored-by: Copilot Co-authored-by: justschen --- extensions/copilot/package.json | 8 ++ extensions/copilot/package.nls.json | 1 + .../common/chatSessionMetadataStore.ts | 8 ++ .../copilotcli/common/copilotCLITools.ts | 77 ++++++++++++++--- .../common/test/copilotCLITools.spec.ts | 82 ++++++++++++++++++- .../copilotcli/node/copilotCli.ts | 11 ++- .../copilotcli/node/copilotcliSession.ts | 8 ++ .../node/copilotcliSessionService.ts | 20 +++-- .../node/test/copilotcliSession.spec.ts | 3 +- .../vscode-node/copilotCLIChatSessions.ts | 12 ++- .../copilotCLIChatSessionsContribution.ts | 61 +++++++------- .../vscode-node/copilotCLIModelDetails.ts | 53 ++++++++++++ .../copilotCLIChatSessionParticipant.spec.ts | 72 ++++++++++++++++ .../test/copilotCLIChatSessions.spec.ts | 11 ++- .../common/configurationService.ts | 1 + 15 files changed, 371 insertions(+), 57 deletions(-) create mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIModelDetails.ts diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 07fa4f9de3335..3d178e241cb64 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -4674,6 +4674,14 @@ "advanced" ] }, + "github.copilot.chat.agent.modelDetails.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "%github.copilot.config.chat.agent.modelDetails.enabled%", + "tags": [ + "advanced" + ] + }, "github.copilot.chat.cli.planCommand.enabled": { "type": "boolean", "default": true, diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 6c12f4474e567..dedacea37a071 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -413,6 +413,7 @@ "github.copilot.config.cli.showExternalSessions": "Show sessions created by other applications.", "github.copilot.config.cli.planExitMode.enabled": "Enable Plan Mode exit handling in Copilot CLI.", "github.copilot.config.cli.autoModel.enabled": "Enable the Auto model option in Copilot CLI, which automatically selects the best model for each request. Requires VS Code reload.", + "github.copilot.config.chat.agent.modelDetails.enabled": "Show model details (model name and request multiplier) on agent chat responses when using Copilot CLI or Claude agent in VS Code. Requires VS Code reload to update already loaded sessions.", "github.copilot.config.cli.planCommand.enabled": "Enable the /plan command in Copilot CLI to create implementation plans before coding.", "github.copilot.config.cli.lazyLoadSessionItem.enabled": "Enable lazy loading of session items in Copilot CLI. Requires VS Code reload.", "github.copilot.config.cli.aiGenerateBranchNames.enabled": "Enable AI-generated branch names in Copilot CLI.", diff --git a/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts b/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts index 0f8a4e2bada73..9a9ca2b509231 100644 --- a/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts +++ b/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts @@ -56,6 +56,14 @@ export interface RequestDetails { /** Mode instructions for this request (excluding toolReferences). */ modeInstructions?: StoredModeInstructions; + /** + * The concrete model id that produced the response for this request, as reported by the + * SDK's `assistant.usage` event. Captured so that on session reload we can render the + * correct model details (e.g. for `auto`, where the resolved model is not otherwise + * recoverable from the persisted SDK event log). + */ + responseModelId?: string; + /** Checkpoint reference for this request (primary workspace). */ checkpointRef?: string; diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts index 816e13cd9b2d9..21b148ed7a797 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts @@ -518,22 +518,57 @@ export interface RequestIdDetails { readonly requestId: string; readonly toolIdEditMap: Record; readonly modeInstructions?: StoredModeInstructions; + readonly responseModelId?: string; } /** * Build chat history from SDK events for VS Code chat session * Converts SDKEvents into ChatRequestTurn2 and ChatResponseTurn2 objects */ -export function buildChatHistoryFromEvents(sessionId: string, modelId: string | undefined, events: readonly SessionEvent[], getVSCodeRequestId: (sdkRequestId: string) => RequestIdDetails | undefined, delegationSummaryService: IChatDelegationSummaryService, logger: ILogger, workingDirectory?: URI, defaultModeInstructionsForLastRequest?: StoredModeInstructions, lastResponseDetails?: string): (ChatRequestTurn2 | ChatResponseTurn2)[] { +export function buildChatHistoryFromEvents(sessionId: string, modelId: string | undefined, events: readonly SessionEvent[], getVSCodeRequestId: (sdkRequestId: string) => RequestIdDetails | undefined, delegationSummaryService: IChatDelegationSummaryService, logger: ILogger, workingDirectory?: URI, defaultModeInstructionsForLastRequest?: StoredModeInstructions, modelDetailsById?: ReadonlyMap): (ChatRequestTurn2 | ChatResponseTurn2)[] { const turns: (ChatRequestTurn2 | ChatResponseTurn2)[] = []; let currentResponseParts: ExtendedChatResponsePart[] = []; const pendingToolInvocations = new Map(); let details: RequestIdDetails | undefined; let isFirstUserMessage = true; + let currentModelId = modelId; + let currentResponseModelId: string | undefined; + let currentRequestTurnIndex: number | undefined; const currentAssistantMessage: { chunks: string[] } = { chunks: [] }; const processedMessages = new Set(); + function getModelDetails(modelId: string | undefined): string | undefined { + if (!modelId || !modelDetailsById) { + return undefined; + } + return modelDetailsById.get(modelId.trim().toLowerCase()); + } + + function createResultForModel(modelId: string | undefined) { + const details = getModelDetails(modelId); + return details ? { details } : {}; + } + + function flushResponseParts() { + if (currentResponseParts.length > 0) { + turns.push(new ChatResponseTurn2(currentResponseParts, createResultForModel(currentResponseModelId ?? currentModelId), '')); + currentResponseParts = []; + } + currentResponseModelId = undefined; + currentRequestTurnIndex = undefined; + } + + function updateCurrentRequestModelId(modelId: string | undefined) { + if (currentRequestTurnIndex === undefined || !modelId) { + return; + } + const turn = turns[currentRequestTurnIndex]; + if (turn instanceof ChatRequestTurn2 && turn.modelId !== modelId) { + turns[currentRequestTurnIndex] = new ChatRequestTurn2(turn.prompt, turn.command, turn.references, turn.participant, [...turn.toolReferences], turn.editedFileEvents, turn.id, modelId, turn.modeInstructions2); + } + } + function processAssistantMessage(content: string) { // Extract PR metadata if present const { cleanedContent, prPart } = extractPRMetadata(content); @@ -562,16 +597,33 @@ export function buildChatHistoryFromEvents(sessionId: string, modelId: string | } switch (event.type) { + case 'session.start': + case 'session.resume': { + currentModelId = event.data.selectedModel ?? currentModelId; + break; + } + case 'session.model_change': { + currentModelId = event.data.newModel; + if (currentRequestTurnIndex !== undefined && currentResponseParts.length === 0) { + currentResponseModelId = currentModelId; + updateCurrentRequestModelId(currentModelId); + } + break; + } + case 'assistant.usage': { + currentModelId = event.data.model ?? currentModelId; + if (currentRequestTurnIndex !== undefined) { + currentResponseModelId = currentModelId; + updateCurrentRequestModelId(currentModelId); + } + break; + } case 'user.message': { if (isSyntheticUserMessage(event)) { continue; } details = getVSCodeRequestId(event.id); - // Flush any pending response parts before adding user message - if (currentResponseParts.length > 0) { - turns.push(new ChatResponseTurn2(currentResponseParts, {}, '')); - currentResponseParts = []; - } + flushResponseParts(); // Filter out vscode instruction files from references when building session history // TODO@rebornix filter instructions should be rendered as "references" in chat response like normal chat. const references: ChatPromptReference[] = []; @@ -675,7 +727,13 @@ export function buildChatHistoryFromEvents(sessionId: string, modelId: string | } } - turns.push(new ChatRequestTurn2(`${commandPrefix}${prompt}`, undefined, references, '', [], undefined, details?.requestId ?? event.id, modelId, modeInstructions2)); + // Prefer the persisted resolved model id (from `assistant.usage`) so that on reload + // `auto` sessions show the actual model used to produce the response. Falls back to + // the currently tracked model id (from `session.start`/`session.model_change`). + const resolvedRequestModelId = details?.responseModelId ?? currentModelId; + currentResponseModelId = resolvedRequestModelId; + turns.push(new ChatRequestTurn2(`${commandPrefix}${prompt}`, undefined, references, '', [], undefined, details?.requestId ?? event.id, resolvedRequestModelId, modeInstructions2)); + currentRequestTurnIndex = turns.length - 1; break; } case 'assistant.message_delta': { @@ -745,10 +803,7 @@ export function buildChatHistoryFromEvents(sessionId: string, modelId: string | } flushPendingAssistantMessage(); - - if (currentResponseParts.length > 0) { - turns.push(new ChatResponseTurn2(currentResponseParts, lastResponseDetails ? { details: lastResponseDetails } : {}, '')); - } + flushResponseParts(); return turns; } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/common/test/copilotCLITools.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/common/test/copilotCLITools.spec.ts index 1d358f035bf73..f6165f85e0828 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/common/test/copilotCLITools.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/common/test/copilotCLITools.spec.ts @@ -163,12 +163,92 @@ describe('CopilotCLITools', () => { { type: 'user.message', data: { content: 'Hello', attachments: [] } }, { type: 'assistant.message', data: { content: 'Hi there' } } ]; - const turns = buildChatHistoryFromEvents('', 'base', events, getVSCodeRequestId, delegationSummary, logger, undefined, undefined, 'Base • 2x'); + const turns = buildChatHistoryFromEvents('', 'base', events, getVSCodeRequestId, delegationSummary, logger, undefined, undefined, new Map([['base', 'Base • 2x']])); expect(turns).toHaveLength(2); const responseTurn = turns[1] as ChatResponseTurn2; expect(responseTurn.result).toEqual({ details: 'Base • 2x' }); }); + it('uses session model changes for each rebuilt response turn', () => { + const modelDetails = new Map([ + ['opus-4.6', 'Opus 4.6 • 4x'], + ['opus-4.7', 'Opus 4.7 • 4x'], + ['gpt-5.4', 'GPT 5.4 • 2x'], + ['gpt-5.3', 'GPT 5.3 • 1x'], + ]); + const events: any[] = [ + { type: 'session.start', data: { selectedModel: 'opus-4.6' } }, + { type: 'user.message', id: 'u1', data: { content: 'First', attachments: [] } }, + { type: 'assistant.message', data: { content: 'One' } }, + { type: 'session.model_change', data: { previousModel: 'opus-4.6', newModel: 'opus-4.7' } }, + { type: 'user.message', id: 'u2', data: { content: 'Second', attachments: [] } }, + { type: 'assistant.message', data: { content: 'Two' } }, + { type: 'session.model_change', data: { previousModel: 'opus-4.7', newModel: 'gpt-5.4' } }, + { type: 'user.message', id: 'u3', data: { content: 'Third', attachments: [] } }, + { type: 'assistant.message', data: { content: 'Three' } }, + { type: 'session.model_change', data: { previousModel: 'gpt-5.4', newModel: 'gpt-5.3' } }, + { type: 'user.message', id: 'u4', data: { content: 'Fourth', attachments: [] } }, + { type: 'assistant.message', data: { content: 'Four' } }, + ]; + + const turns = buildChatHistoryFromEvents('', undefined, events, getVSCodeRequestId, delegationSummary, logger, undefined, undefined, modelDetails); + + expect(turns.filter(turn => turn instanceof ChatRequestTurn2).map(turn => (turn as ChatRequestTurn2).modelId)).toEqual(['opus-4.6', 'opus-4.7', 'gpt-5.4', 'gpt-5.3']); + expect(turns.filter(turn => turn instanceof ChatResponseTurn2).map(turn => (turn as ChatResponseTurn2).result)).toEqual([ + { details: 'Opus 4.6 • 4x' }, + { details: 'Opus 4.7 • 4x' }, + { details: 'GPT 5.4 • 2x' }, + { details: 'GPT 5.3 • 1x' }, + ]); + }); + + it('uses assistant usage model for the active rebuilt response turn', () => { + const events: any[] = [ + { type: 'user.message', id: 'u1', data: { content: 'Hello', attachments: [] } }, + { type: 'assistant.message', data: { content: 'Hi' } }, + { type: 'assistant.usage', data: { model: 'gpt-5.4', inputTokens: 10, outputTokens: 5 } }, + ]; + + const turns = buildChatHistoryFromEvents('', undefined, events, getVSCodeRequestId, delegationSummary, logger, undefined, undefined, new Map([['gpt-5.4', 'GPT 5.4 • 2x']])); + + expect(turns).toHaveLength(2); + expect((turns[0] as ChatRequestTurn2).modelId).toBe('gpt-5.4'); + expect((turns[1] as ChatResponseTurn2).result).toEqual({ details: 'GPT 5.4 • 2x' }); + }); + + it('uses persisted responseModelId to recover model details on reload for auto sessions', () => { + // Simulates a reloaded `auto` session: the SDK only persists `selectedModel: "auto"` + // (the `assistant.usage` event that carried the resolved model id is ephemeral and + // dropped from the persisted event log). The resolved model id was previously + // captured by the participant and stored via the chat session metadata store as + // `RequestDetails.responseModelId`, then surfaced through the `getVSCodeRequestId` + // callback. The reload path must use it to render the model footer details. + const events: any[] = [ + { type: 'session.start', data: { selectedModel: 'auto' } }, + { type: 'user.message', id: 'u1', data: { content: 'First', attachments: [] } }, + { type: 'assistant.message', data: { content: 'One' } }, + { type: 'user.message', id: 'u2', data: { content: 'Second', attachments: [] } }, + { type: 'assistant.message', data: { content: 'Two' } }, + ]; + const detailsByEventId: Record = { + u1: { requestId: 'r1', toolIdEditMap: {}, responseModelId: 'gpt-5.4' }, + u2: { requestId: 'r2', toolIdEditMap: {}, responseModelId: 'claude-opus-4.7' }, + }; + const lookup = (sdkRequestId: string) => detailsByEventId[sdkRequestId]; + + const turns = buildChatHistoryFromEvents('', 'auto', events, lookup, delegationSummary, logger, undefined, undefined, new Map([ + ['gpt-5.4', 'GPT 5.4 • 2x'], + ['claude-opus-4.7', 'Claude Opus 4.7 • 4x'], + ])); + + expect(turns).toHaveLength(4); + expect(turns.filter(turn => turn instanceof ChatRequestTurn2).map(turn => (turn as ChatRequestTurn2).modelId)).toEqual(['gpt-5.4', 'claude-opus-4.7']); + expect(turns.filter(turn => turn instanceof ChatResponseTurn2).map(turn => (turn as ChatResponseTurn2).result)).toEqual([ + { details: 'GPT 5.4 • 2x' }, + { details: 'Claude Opus 4.7 • 4x' }, + ]); + }); + it('converts file attachments to references on user messages', () => { const events: any[] = [ { diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts index 027e2fd7b4985..d8e65edbf8d91 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts @@ -66,6 +66,11 @@ export function formatModelDetails(model: CopilotCLIModelInfo): string { return `${model.name}${model.multiplier ? ` • ${model.multiplier}x` : ''}`; } +export function matchesCopilotCLIModel(model: Pick, modelId: string): boolean { + const normalizedModelId = modelId.trim().toLowerCase(); + return model.id.trim().toLowerCase() === normalizedModelId || model.name.trim().toLowerCase() === normalizedModelId; +} + export const ICopilotCLISDK = createServiceIdentifier('ICopilotCLISDK'); export const ICopilotCLIModels = createServiceIdentifier('ICopilotCLIModels'); @@ -114,7 +119,7 @@ export class CopilotCLIModels extends Disposable implements ICopilotCLIModels { } const models = await this.getModels(); modelId = modelId.trim().toLowerCase(); - return models.find(m => m.id.toLowerCase() === modelId || m.name.toLowerCase() === modelId)?.id; + return models.find(m => matchesCopilotCLIModel(m, modelId))?.id; } public async getDefaultModel() { // First item in the list is always the default model (SDK sends the list ordered based on default preference) @@ -123,9 +128,9 @@ export class CopilotCLIModels extends Disposable implements ICopilotCLIModels { return; } const defaultModel = models[0]; - const preferredModelId = this.extensionContext.globalState.get(COPILOT_CLI_MODEL_MEMENTO_KEY, defaultModel.id)?.trim()?.toLowerCase(); + const preferredModelId = this.extensionContext.globalState.get(COPILOT_CLI_MODEL_MEMENTO_KEY, defaultModel.id)?.trim()?.toLowerCase() ?? defaultModel.id; - return models.find(m => m.id.toLowerCase() === preferredModelId)?.id ?? defaultModel.id; + return models.find(m => matchesCopilotCLIModel(m, preferredModelId))?.id ?? defaultModel.id; } public async setDefaultModel(modelId: string | undefined): Promise { diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index 1a0d58a55592b..10f76b9c795aa 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -727,6 +727,7 @@ export interface ICopilotCLISession extends IDisposable { addUserMessage(content: string): void; addUserAssistantMessage(content: string): void; getSelectedModelId(): Promise; + getLastResponseModelId(): string | undefined; } export class CopilotCLISession extends DisposableStore implements ICopilotCLISession { @@ -762,6 +763,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes } private _lastUsedModel: string | undefined; private _permissionLevel: string | undefined; + private _lastResponseModelId: string | undefined; private _pendingPrompt: string | undefined; private _bridgeProcessor: CopilotCliBridgeSpanProcessor | undefined; private readonly _todoSqlQuery = new TodoSqlQuery(); @@ -1008,6 +1010,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes this.attachments.push(...attachments); const prompt = getPromptLabel(input); this._pendingPrompt = prompt; + this._lastResponseModelId = undefined; this.logService.info(`[CopilotCLISession] Invoking session ${this.sessionId}`); const disposables = new DisposableStore(); const logStartTime = Date.now(); @@ -1268,6 +1271,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes sdkRequestId = sdkRequestId ?? event.id; }))); disposables.add(toDisposable(this._sdkSession.on('assistant.usage', (event) => { + this._lastResponseModelId = event.data.model; if (requestStream && typeof event.data.outputTokens === 'number' && typeof event.data.inputTokens === 'number') { reportUsage(event.data.inputTokens, event.data.outputTokens); } @@ -2366,6 +2370,10 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes return this._sdkSession.getSelectedModel(); } + public getLastResponseModelId(): string | undefined { + return this._lastResponseModelId; + } + private _logRequest(userPrompt: string, modelId: string, attachments: Attachment[], startTimeMs: number): void { const markdownContent = this._renderRequestToMarkdown(userPrompt, modelId, attachments, startTimeMs); this._requestLogger.addEntry({ diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index b42efd3f717ee..e8f9aa50c6b98 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -1039,7 +1039,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS for (const d of storedDetails) { if (d.copilotRequestId) { const modeInstructions = d.modeInstructions ?? await this.resolveAgentModeInstructions(d.agentId, customAgentLookup) ?? defaultModeInstructions; - detailsByCopilotId.set(d.copilotRequestId, { requestId: d.vscodeRequestId, toolIdEditMap: d.toolIdEditMap, modeInstructions }); + detailsByCopilotId.set(d.copilotRequestId, { requestId: d.vscodeRequestId, toolIdEditMap: d.toolIdEditMap, modeInstructions, responseModelId: d.responseModelId }); } } @@ -1060,8 +1060,10 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS return mapping; }; - const lastResponseDetails = await this.getModelDetailsString(modelId); - const history = buildChatHistoryFromEvents(sessionId, modelId, events, getVSCodeRequestId, this._delegationSummaryService, this.logService, getWorkingDirectory(workspace), defaultModeInstructions, lastResponseDetails); + const modelDetailsById = this.configurationService.getConfig(ConfigKey.Advanced.CLIModelDetailsEnabled) + ? await this.getModelDetailsById() + : undefined; + const history = buildChatHistoryFromEvents(sessionId, modelId, events, getVSCodeRequestId, this._delegationSummaryService, this.logService, getWorkingDirectory(workspace), defaultModeInstructions, modelDetailsById); if (legacyMappings.length > 0) { void this._chatSessionMetadataStore.updateRequestDetails(sessionId, legacyMappings).catch(error => { @@ -1110,16 +1112,16 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS }; } - private async getModelDetailsString(modelId: string | undefined): Promise { - if (!modelId) { - return undefined; - } + private async getModelDetailsById(): Promise> { const models = await this._copilotCLIModels.getModels().catch(ex => { this.logService.error(ex, 'Failed to get models'); return []; }); - const modelInfo = models.find(m => m.id === modelId); - return modelInfo ? formatModelDetails(modelInfo) : undefined; + const detailsById = new Map(); + for (const model of models) { + detailsById.set(model.id.trim().toLowerCase(), formatModelDetails(model)); + } + return detailsById; } 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 15d315f2c6f80..c56453daa6dbe 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 @@ -2135,7 +2135,7 @@ describe('CopilotCLISession', () => { it('reports usage from assistant.usage event with per-call tokens', async () => { sdkSession.send = async (options: any) => { sdkSession.emit('user.message', { content: options.prompt }); - sdkSession.emit('assistant.usage', { inputTokens: 200, outputTokens: 80 }); + sdkSession.emit('assistant.usage', { model: 'claude-opus-4.7', inputTokens: 200, outputTokens: 80 }); sdkSession.emit('assistant.turn_end', {}); }; @@ -2147,6 +2147,7 @@ describe('CopilotCLISession', () => { const usageFromEvent = stream.usages.find(u => u.promptTokens === 200 && u.completionTokens === 80); expect(usageFromEvent).toBeDefined(); + expect(session.getLastResponseModelId()).toBe('claude-opus-4.7'); }); it('reports usage from session.usage_info event immediately', async () => { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index 88831a5ae8bcc..3fbd57894a515 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -38,7 +38,7 @@ import { IChatDelegationSummaryService } from '../copilotcli/common/delegationSu import { clearPendingCopilotCLIRequestContext, setPendingCopilotCLIRequestContext, takePendingCopilotCLIRequestContext } from '../copilotcli/common/pendingRequestContext'; import { SessionIdForCLI } from '../copilotcli/common/utils'; import { getCopilotCLISessionDir } from '../copilotcli/node/cliHelpers'; -import { ICopilotCLISDK } from '../copilotcli/node/copilotCli'; +import { ICopilotCLIModels, ICopilotCLISDK } from '../copilotcli/node/copilotCli'; import { CopilotCLIPromptResolver } from '../copilotcli/node/copilotcliPromptResolver'; import { builtinSlashSCommands, CopilotCLICommand, copilotCLICommands, ICopilotCLISession } from '../copilotcli/node/copilotcliSession'; import { ICopilotCLISessionItem, ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionService'; @@ -48,6 +48,7 @@ import { ICopilotCLITerminalIntegration, TerminalOpenLocation } from './copilotC import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider'; import { UNTRUSTED_FOLDER_MESSAGE } from './folderRepositoryManagerImpl'; import { IPullRequestDetectionService } from './pullRequestDetectionService'; +import { getCopilotCLIModelDetails, persistCopilotCLIResponseModelId } from './copilotCLIModelDetails'; import { getSelectedSessionOptions, ISessionOptionGroupBuilder, OPEN_REPOSITORY_COMMAND_ID, toRepositoryOptionItem, toWorkspaceFolderOptionItem } from './sessionOptionGroupBuilder'; import { ISessionRequestLifecycle } from './sessionRequestLifecycle'; import { ICopilotCLIChatSessionInitializer, SessionInitOptions } from '../copilotcli/vscode-node/copilotCLIChatSessionInitializer'; @@ -661,6 +662,8 @@ export class CopilotCLIChatSessionParticipant extends Disposable { @ISessionRequestLifecycle private readonly sessionRequestLifecycle: ISessionRequestLifecycle, @IPullRequestDetectionService private readonly prDetectionService: IPullRequestDetectionService, @ISessionOptionGroupBuilder private readonly _optionGroupBuilder: ISessionOptionGroupBuilder, + @ICopilotCLIModels private readonly copilotCLIModels: ICopilotCLIModels, + @IChatSessionMetadataStore private readonly chatSessionMetadataStore: IChatSessionMetadataStore, ) { super(); @@ -858,12 +861,17 @@ export class CopilotCLIChatSessionParticipant extends Disposable { if (request.command === 'delegate') { await this.handleDelegationToCloud(session.object, request, context, stream, token); + return {}; } else { const { input, attachments } = await this.resolveInput(request, session.object, isNewSession, token); await session.object.handleRequest(request, input, attachments, model, authInfo, token); } - return {}; + const modelDetailsEnabled = this.configurationService.getConfig(ConfigKey.Advanced.CLIModelDetailsEnabled); + const { result, responseModelId } = await getCopilotCLIModelDetails(session.object, model, this.copilotCLIModels, this.logService, modelDetailsEnabled); + persistCopilotCLIResponseModelId(sdkSessionId, request.id, responseModelId, this.chatSessionMetadataStore, this.logService); + + return result; } catch (ex) { if (isCancellationError(ex)) { return {}; diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index e650294489cb8..2d9a627ecb5e8 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -45,13 +45,14 @@ import { emptyWorkspaceInfo, getWorkingDirectory, isIsolationEnabled, IWorkspace import { ICustomSessionTitleService } from '../copilotcli/common/customSessionTitleService'; import { IChatDelegationSummaryService } from '../copilotcli/common/delegationSummaryService'; import { getCopilotCLISessionDir } from '../copilotcli/node/cliHelpers'; -import { COPILOT_CLI_REASONING_EFFORT_PROPERTY, formatModelDetails, ICopilotCLIAgents, ICopilotCLIModels, ICopilotCLISDK, isWelcomeView } from '../copilotcli/node/copilotCli'; +import { COPILOT_CLI_REASONING_EFFORT_PROPERTY, ICopilotCLIAgents, ICopilotCLIModels, ICopilotCLISDK, isWelcomeView } from '../copilotcli/node/copilotCli'; import { CopilotCLIPromptResolver } from '../copilotcli/node/copilotcliPromptResolver'; import { builtinSlashSCommands, CopilotCLICommand, copilotCLICommands, ICopilotCLISession } from '../copilotcli/node/copilotcliSession'; import { ICopilotCLISessionItem, ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionService'; import { buildMcpServerMappings } from '../copilotcli/node/mcpHandler'; import { ICopilotCLISessionTracker } from '../copilotcli/vscode-node/copilotCLISessionTracker'; import { ICopilotCLIChatSessionItemProvider } from './copilotCLIChatSessions'; +import { getCopilotCLIModelDetails, persistCopilotCLIResponseModelId } from './copilotCLIModelDetails'; import { ICopilotCLITerminalIntegration, TerminalOpenLocation } from './copilotCLITerminalIntegration'; import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider'; import { convertReferenceToVariable } from '../copilotcli/vscode-node/copilotCLIPromptReferences'; @@ -1550,36 +1551,13 @@ export class CopilotCLIChatSessionParticipant extends Disposable { // Build the result before the untitled-session swap below. After the swap, // the chat UI reloads history from the SDK and discards the in-memory // result, which would drop our `details` field on the first request. - const models = await this.copilotCLIModels.getModels().catch(ex => { - this.logService.error(ex, 'Failed to get models'); - return []; - }); - const modelInfo = models.find(m => m.id === model?.model); - const result: vscode.ChatResult = modelInfo - ? { details: formatModelDetails(modelInfo) } - : {}; + const modelDetailsEnabled = this.configurationService.getConfig(ConfigKey.Advanced.CLIModelDetailsEnabled); + const { result, responseModelId } = await getCopilotCLIModelDetails(session.object, model, this.copilotCLIModels, this.logService, modelDetailsEnabled); - if (isUntitled && !token.isCancellationRequested) { - // Its possible the user tried steering, in that case, we should NOT swap the session item because the session. - // Else the messages may get lost (wait CHECK_FOR_STEERING_DELAYms to check if we have pending steering requests) - await new Promise(resolve => disposableTimeout(() => resolve(), CHECK_FOR_STEERING_DELAY, this._store)); - const pendingRequests = this.pendingRequestsForUntitledSessions.get(id); - if (pendingRequests) { - pendingRequests.delete(request.id); - // If we have more requests, that means we had the original request as well as at least one another steering request. - // Lets not swap anything here, until all pending requests have been completed. - if (pendingRequests.size > 0) { - return result; - } - } + persistCopilotCLIResponseModelId(sessionId, request.id, responseModelId, this.chatSessionMetadataStore, this.logService); - // Delete old information stored for untitled session id. - _sessionBranch.delete(id); - _sessionIsolation.delete(id); - this.sessionItemProvider.untitledSessionIdMapping.delete(id); - this.sessionItemProvider.sdkToUntitledUriMapping.delete(session.object.sessionId); - this.folderRepositoryManager.deleteNewSessionFolder(id); - this.sessionItemProvider.swap(chatSessionContext.chatSessionItem, { resource: SessionIdForCLI.getResource(session.object.sessionId), label: request.prompt }); + if (isUntitled && !token.isCancellationRequested) { + this.scheduleUntitledSessionSwap(id, request.id, request.prompt, session.object.sessionId, chatSessionContext.chatSessionItem); } return result; @@ -1607,6 +1585,31 @@ export class CopilotCLIChatSessionParticipant extends Disposable { } } + private scheduleUntitledSessionSwap(untitledSessionId: string, requestId: string, requestPrompt: string, sdkSessionId: string, chatSessionItem: vscode.ChatSessionItem): void { + // If the user tried steering, do not swap the session item yet or messages may get lost. + // Wait CHECK_FOR_STEERING_DELAYms to check if there are pending steering requests. + disposableTimeout(() => { + const pendingRequests = this.pendingRequestsForUntitledSessions.get(untitledSessionId); + if (pendingRequests) { + pendingRequests.delete(requestId); + // If we have more requests, there was the original request plus at least one steering request. + // Do not swap until all pending requests have been completed. + if (pendingRequests.size > 0) { + return; + } + this.pendingRequestsForUntitledSessions.delete(untitledSessionId); + } + + // Delete old information stored for untitled session id. + _sessionBranch.delete(untitledSessionId); + _sessionIsolation.delete(untitledSessionId); + this.sessionItemProvider.untitledSessionIdMapping.delete(untitledSessionId); + this.sessionItemProvider.sdkToUntitledUriMapping.delete(sdkSessionId); + this.folderRepositoryManager.deleteNewSessionFolder(untitledSessionId); + this.sessionItemProvider.swap(chatSessionItem, { resource: SessionIdForCLI.getResource(sdkSessionId), label: requestPrompt }); + }, CHECK_FOR_STEERING_DELAY, this._store); + } + private async lockRepoOptionForSession(context: vscode.ChatContext, token: vscode.CancellationToken) { const { chatSessionContext } = context; if (!chatSessionContext?.isUntitled) { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIModelDetails.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIModelDetails.ts new file mode 100644 index 0000000000000..126801cc906e0 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIModelDetails.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import { ILogService } from '../../../platform/log/common/logService'; +import { IChatSessionMetadataStore } from '../common/chatSessionMetadataStore'; +import { ICopilotCLIModels, formatModelDetails, matchesCopilotCLIModel } from '../copilotcli/node/copilotCli'; +import { ICopilotCLISession } from '../copilotcli/node/copilotcliSession'; + +export interface CopilotCLIModelDetails { + readonly result: vscode.ChatResult; + readonly responseModelId: string | undefined; +} + +/** + * Builds the chat result details for the model that produced the latest CLI response. + */ +export async function getCopilotCLIModelDetails(session: ICopilotCLISession, requestModel: { model: string; reasoningEffort?: string } | undefined, copilotCLIModels: ICopilotCLIModels, logService: ILogService, enabled: boolean): Promise { + if (!enabled) { + return { result: {}, responseModelId: undefined }; + } + + const models = await copilotCLIModels.getModels().catch(ex => { + logService.error(ex, 'Failed to get models'); + return []; + }); + const selectedModelId = await session.getSelectedModelId().catch(ex => { + logService.error(ex, 'Failed to get selected model'); + return undefined; + }); + const responseModelId = session.getLastResponseModelId(); + const modelInfo = [responseModelId, selectedModelId, requestModel?.model] + .map(modelId => modelId ? models.find(model => matchesCopilotCLIModel(model, modelId)) : undefined) + .find(modelInfo => !!modelInfo); + + return { + result: modelInfo ? { details: formatModelDetails(modelInfo) } : {}, + responseModelId, + }; +} + +/** + * Persists the concrete response model id so rebuilt history can recover details for auto-mode requests. + */ +export function persistCopilotCLIResponseModelId(sessionId: string, requestId: string, responseModelId: string | undefined, chatSessionMetadataStore: IChatSessionMetadataStore, logService: ILogService): void { + if (!responseModelId) { + return; + } + chatSessionMetadataStore.updateRequestDetails(sessionId, [{ vscodeRequestId: requestId, responseModelId }]) + .catch(ex => logService.error(ex, 'Failed to persist response model id')); +} \ No newline at end of file diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts index 5dc299b1ee599..fc70c937c88bd 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts @@ -248,12 +248,17 @@ function createChatContext(sessionId: string, isUntitled: boolean, ...requests: } as vscode.ChatContext; } +async function waitForScheduledUntitledSwap(): Promise { + await new Promise(resolve => setTimeout(resolve, 125)); +} + class TestCopilotCLISession extends CopilotCLISession { public requests: Array<{ input: CopilotCLISessionInput; attachments: Attachment[]; model: { model: string; reasoningEffort?: string } | undefined; authInfo: NonNullable; token: vscode.CancellationToken }> = []; public permissionLevel: string | undefined; public static nextHandleRequestResult: Promise | undefined; public static handleRequestHook: ((request: { id: string; toolInvocationToken: vscode.ChatParticipantToolToken; sessionResource?: vscode.Uri }, input: CopilotCLISessionInput) => Promise) | undefined; public static statusOverride?: vscode.ChatSessionStatus; + public static lastResponseModelId: string | undefined; override get status(): vscode.ChatSessionStatus | undefined { return TestCopilotCLISession.statusOverride; } @@ -268,6 +273,9 @@ class TestCopilotCLISession extends CopilotCLISession { this.permissionLevel = level; super.setPermissionLevel(level); } + override getLastResponseModelId(): string | undefined { + return TestCopilotCLISession.lastResponseModelId; + } } @@ -317,6 +325,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { TestCopilotCLISession.nextHandleRequestResult = undefined; TestCopilotCLISession.handleRequestHook = undefined; TestCopilotCLISession.statusOverride = undefined; + TestCopilotCLISession.lastResponseModelId = undefined; // By default, simulate VS Code core opening the delegated session and // re-invoking handleRequest with the copilotcli:// resource. This matches // the production flow where executeCommand opens the session. @@ -818,6 +827,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { resolveSteeringRequest3(); await fourthPromise; + await waitForScheduledUntitledSwap(); expect(itemProvider.swap).toHaveBeenCalledTimes(1); }); @@ -1040,6 +1050,66 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'my prompt' }); }); + it('returns live response details from the model reported by assistant usage', async () => { + const sessionId = 'existing-live-model'; + const sdkSession = new MockCliSdkSession(sessionId, new Date()); + manager.sessions.set(sessionId, sdkSession); + models.getModels = vi.fn(async () => [ + { id: 'base', name: 'Base', maxContextWindowTokens: 128000, supportsVision: false }, + { id: 'claude-opus-4.7', name: 'Claude Opus 4.7', multiplier: 4, maxContextWindowTokens: 200000, supportsVision: true } + ] as CopilotCLIModelInfo[]); + TestCopilotCLISession.lastResponseModelId = 'claude-opus-4.7'; + const request = new TestChatRequest('my prompt'); + const context = createChatContext(sessionId, false, request); + const stream = new MockChatResponseStream(); + const token = disposables.add(new CancellationTokenSource()).token; + + const result = await participant.createHandler()(request, context, stream, token); + + expect(result).toEqual({ details: 'Claude Opus 4.7 • 4x' }); + }); + + it('does not return live response details when model details are disabled', async () => { + await configurationService.setConfig(ConfigKey.Advanced.CLIModelDetailsEnabled, false); + const sessionId = 'existing-live-model-disabled'; + const sdkSession = new MockCliSdkSession(sessionId, new Date()); + manager.sessions.set(sessionId, sdkSession); + models.getModels = vi.fn(async () => [ + { id: 'claude-opus-4.7', name: 'Claude Opus 4.7', multiplier: 4, maxContextWindowTokens: 200000, supportsVision: true } + ] as CopilotCLIModelInfo[]); + TestCopilotCLISession.lastResponseModelId = 'claude-opus-4.7'; + const request = new TestChatRequest('my prompt'); + const context = createChatContext(sessionId, false, request); + const stream = new MockChatResponseStream(); + const token = disposables.add(new CancellationTokenSource()).token; + + const result = await participant.createHandler()(request, context, stream, token); + + expect(result).toEqual({}); + expect(models.getModels).not.toHaveBeenCalled(); + }); + + it('returns live response details before swapping an untitled session', async () => { + (itemProvider.isNewSession as ReturnType).mockImplementation((sessionId: string) => sessionId.startsWith('untitled:')); + models.getModels = vi.fn(async () => [ + { id: 'claude-opus-4.7', name: 'Claude Opus 4.7', multiplier: 4, maxContextWindowTokens: 200000, supportsVision: true } + ] as CopilotCLIModelInfo[]); + TestCopilotCLISession.lastResponseModelId = 'claude-opus-4.7'; + const request = new TestChatRequest('my prompt'); + const context = createChatContext('untitled:live-model', true, request); + const stream = new MockChatResponseStream(); + const token = disposables.add(new CancellationTokenSource()).token; + + const result = await participant.createHandler()(request, context, stream, token); + + expect(result).toEqual({ details: 'Claude Opus 4.7 • 4x' }); + expect(itemProvider.swap).not.toHaveBeenCalled(); + + await waitForScheduledUntitledSwap(); + + expect(itemProvider.swap).toHaveBeenCalledTimes(1); + }); + it('handles existing session with rejectedConfirmationData (proceeds normally)', async () => { // With the new flow, rejectedConfirmationData is no longer used for uncommitted changes. const sessionId = 'existing-confirm-reject'; @@ -1135,6 +1205,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { const token = disposables.add(new CancellationTokenSource()).token; await participant.createHandler()(request, context, stream, token); + await waitForScheduledUntitledSwap(); // Should swap with request.prompt as label expect(itemProvider.swap).toHaveBeenCalled(); @@ -2222,6 +2293,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { // Mapping should have existed during the request expect(mappingExistedDuringRequest).toBe(true); + await waitForScheduledUntitledSwap(); // After the request completes and the session is swapped, the mapping should be cleaned up expect(itemProvider.sdkToUntitledUriMapping.has(capturedSdkSessionId!)).toBe(false); }); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts index e7a6c9219abd0..46c80dc7087e5 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts @@ -27,7 +27,7 @@ import { IFolderRepositoryManager, IsolationMode } from '../../common/folderRepo import { emptyWorkspaceInfo } from '../../common/workspaceInfo'; import { IChatDelegationSummaryService } from '../../copilotcli/common/delegationSummaryService'; import { SessionIdForCLI } from '../../copilotcli/common/utils'; -import { ICopilotCLISDK } from '../../copilotcli/node/copilotCli'; +import { ICopilotCLIModels, ICopilotCLISDK } from '../../copilotcli/node/copilotCli'; import { CopilotCLIPromptResolver } from '../../copilotcli/node/copilotcliPromptResolver'; import { ICustomSessionTitleService } from '../../copilotcli/common/customSessionTitleService'; import { ICopilotCLISession } from '../../copilotcli/node/copilotcliSession'; @@ -338,6 +338,8 @@ describe('CopilotCLIChatSessionParticipant', () => { createdPullRequestUrl: undefined, attachStream: vi.fn(() => ({ dispose: vi.fn() })), handleRequest: vi.fn(async () => { }), + getSelectedModelId: vi.fn(async () => undefined), + getLastResponseModelId: vi.fn(() => undefined), } as unknown as ICopilotCLISession; const sessionService = new TestSessionService(); sessionService.isNewSessionId.mockImplementation(id => id === 'new-session'); @@ -392,6 +394,13 @@ describe('CopilotCLIChatSessionParticipant', () => { override lockInputStateGroups = vi.fn(); override updateBranchInInputState = vi.fn(); }(), + new class extends mock() { + declare readonly _serviceBrand: undefined; + override getModels = vi.fn(async () => []); + }(), + new class extends mock() { + declare readonly _serviceBrand: undefined; + }(), ); await participant.createHandler()( diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 2643c9bd3d4cd..109a9a0a3f51e 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -613,6 +613,7 @@ export namespace ConfigKey { export const CLIShowExternalSessions = defineSetting('chat.cli.showExternalSessions', ConfigType.Simple, true); export const CLIPlanExitModeEnabled = defineSetting('chat.cli.planExitMode.enabled', ConfigType.Simple, true); export const CLIAutoModelEnabled = defineSetting('chat.cli.autoModel.enabled', ConfigType.Simple, true); + export const CLIModelDetailsEnabled = defineSetting('chat.agent.modelDetails.enabled', ConfigType.Simple, true); export const CLIPlanCommandEnabled = defineSetting('chat.cli.planCommand.enabled', ConfigType.Simple, true); export const CLIChatLazyLoadSessionItem = defineSetting('chat.cli.lazyLoadSessionItem.enabled', ConfigType.Simple, true); export const CLIAIGenerateBranchNames = defineSetting('chat.cli.aiGenerateBranchNames.enabled', ConfigType.Simple, true); From cfa5454b64c582541f0b9a8b942b8627049563d9 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 1 May 2026 13:39:33 -0700 Subject: [PATCH 10/19] Run agent host shell tools on independent terminals (#313789) Concurrent invocations of the bash/powershell shell tools used to share a single PTY, which caused the input streams and output to interleave and garble each other. Acquire shells via a disposable IReference: idle shells are reused, busy ones are skipped, and a new terminal is created when none are idle. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/copilot/copilotShellTools.ts | 62 +++++++++++++++---- .../test/node/copilotShellTools.test.ts | 43 ++++++++++++- 2 files changed, 91 insertions(+), 14 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts index 9872b06bb3760..2b95d64fd040b 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts @@ -8,7 +8,7 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { URI } from '../../../../base/common/uri.js'; import { removeAnsiEscapeCodes } from '../../../../base/common/strings.js'; import * as platform from '../../../../base/common/platform.js'; -import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { DisposableStore, type IReference, toDisposable } from '../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { ILogService } from '../../../log/common/log.js'; import { TerminalClaimKind, type TerminalSessionClaim } from '../../common/state/protocol/state.js'; @@ -64,6 +64,8 @@ export class ShellManager { private readonly _shells = new Map(); private readonly _toolCallShells = new Map(); + /** Set of shell ids currently executing a command and unsafe to share. */ + private readonly _busyShellIds = new Set(); private readonly _onDidAssociateTerminal = new Emitter<{ toolCallId: string; terminalUri: string; displayName: string }>(); readonly onDidAssociateTerminal: Event<{ toolCallId: string; terminalUri: string; displayName: string }> = this._onDidAssociateTerminal.event; @@ -75,21 +77,36 @@ export class ShellManager { @ILogService private readonly _logService: ILogService, ) { } + /** + * Acquire a shell of the given type for executing a single command. The + * returned reference holds the shell exclusively — its terminal will not + * be handed out to another concurrent caller until the reference is + * disposed. If no idle shell of the requested type exists, a new one is + * created. + */ async getOrCreateShell( shellType: ShellType, turnId: string, toolCallId: string, cwd?: string, - ): Promise { + ): Promise> { for (const shell of this._shells.values()) { - if (shell.shellType === shellType && this._terminalManager.hasTerminal(shell.terminalUri)) { - const exitCode = this._terminalManager.getExitCode(shell.terminalUri); - if (exitCode === undefined) { - this._trackToolCall(toolCallId, shell.id); - return shell; - } + if (shell.shellType !== shellType || !this._terminalManager.hasTerminal(shell.terminalUri)) { + continue; + } + const exitCode = this._terminalManager.getExitCode(shell.terminalUri); + if (exitCode !== undefined) { this._shells.delete(shell.id); + continue; + } + if (this._busyShellIds.has(shell.id)) { + // Skip — a command is already running on this terminal. Sharing + // it would interleave input/output and garble both commands. + continue; } + this._busyShellIds.add(shell.id); + this._trackToolCall(toolCallId, shell.id); + return this._makeReference(shell); } const id = generateUuid(); @@ -113,9 +130,24 @@ export class ShellManager { const shell: IManagedShell = { id, terminalUri, shellType }; this._shells.set(id, shell); + this._busyShellIds.add(id); this._trackToolCall(toolCallId, id); this._logService.info(`[ShellManager] Created ${shellType} shell ${id} (terminal=${terminalUri})`); - return shell; + return this._makeReference(shell); + } + + private _makeReference(shell: IManagedShell): IReference { + let disposed = false; + return { + object: shell, + dispose: () => { + if (disposed) { + return; + } + disposed = true; + this._busyShellIds.delete(shell.id); + }, + }; } private _trackToolCall(toolCallId: string, shellId: string): void { @@ -156,6 +188,7 @@ export class ShellManager { } this._terminalManager.disposeTerminal(shell.terminalUri); this._shells.delete(id); + this._busyShellIds.delete(id); this._logService.info(`[ShellManager] Shut down shell ${id}`); return true; } @@ -168,6 +201,7 @@ export class ShellManager { } this._shells.clear(); this._toolCallShells.clear(); + this._busyShellIds.clear(); } } @@ -456,13 +490,17 @@ export function createShellTools( }, overridesBuiltInTool: true, handler: async (args, invocation) => { - const shell = await shellManager.getOrCreateShell( + const timeoutMs = args.timeout ?? DEFAULT_TIMEOUT_MS; + const ref = await shellManager.getOrCreateShell( shellType, invocation.toolCallId, invocation.toolCallId, ); - const timeoutMs = args.timeout ?? DEFAULT_TIMEOUT_MS; - return executeCommandInShell(shell, args.command, timeoutMs, terminalManager, logService); + try { + return await executeCommandInShell(ref.object, args.command, timeoutMs, terminalManager, logService); + } finally { + ref.dispose(); + } }, }; diff --git a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts index 698ab70dcdb7e..5fea1bbcf23ea 100644 --- a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts @@ -57,8 +57,8 @@ suite('CopilotShellTools', () => { const explicitCwd = URI.file('/explicit/cwd').fsPath; const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), URI.file(worktreePath))); - await shellManager.getOrCreateShell('bash', 'turn-1', 'tool-1'); - await shellManager.getOrCreateShell('bash', 'turn-2', 'tool-2', explicitCwd); + (await shellManager.getOrCreateShell('bash', 'turn-1', 'tool-1')).dispose(); + (await shellManager.getOrCreateShell('bash', 'turn-2', 'tool-2', explicitCwd)).dispose(); assert.deepStrictEqual(terminalManager.created.map(c => c.params.cwd), [ worktreePath, @@ -86,4 +86,43 @@ suite('CopilotShellTools', () => { assert.strictEqual(prefixForHistorySuppression('bash'), ' '); assert.strictEqual(prefixForHistorySuppression('powershell'), ''); }); + + test('getOrCreateShell reuses an idle shell after the reference is disposed', async () => { + const terminalManager = new TestAgentHostTerminalManager(); + // Pretend created terminals exist and are still running. + (terminalManager as unknown as { hasTerminal: () => boolean }).hasTerminal = () => true; + const services = new ServiceCollection(); + services.set(ILogService, new NullLogService()); + services.set(IAgentHostTerminalManager, terminalManager); + const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); + services.set(IInstantiationService, instantiationService); + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); + + const first = await shellManager.getOrCreateShell('bash', 'turn-1', 'tool-1'); + first.dispose(); + const second = await shellManager.getOrCreateShell('bash', 'turn-2', 'tool-2'); + + assert.strictEqual(second.object.id, first.object.id, 'should reuse idle shell'); + assert.strictEqual(terminalManager.created.length, 1); + second.dispose(); + }); + + test('getOrCreateShell creates a new shell when the existing reference is still held', async () => { + const terminalManager = new TestAgentHostTerminalManager(); + (terminalManager as unknown as { hasTerminal: () => boolean }).hasTerminal = () => true; + const services = new ServiceCollection(); + services.set(ILogService, new NullLogService()); + services.set(IAgentHostTerminalManager, terminalManager); + const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); + services.set(IInstantiationService, instantiationService); + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); + + const first = await shellManager.getOrCreateShell('bash', 'turn-1', 'tool-1'); + const second = await shellManager.getOrCreateShell('bash', 'turn-2', 'tool-2'); + + assert.notStrictEqual(second.object.id, first.object.id, 'should create a new shell when existing is busy'); + assert.strictEqual(terminalManager.created.length, 2); + first.dispose(); + second.dispose(); + }); }); From 7bf921d0e60f681f77cbcce11f97ddc52a194cbd Mon Sep 17 00:00:00 2001 From: yemohyleyemohyle <127880594+yemohyleyemohyle@users.noreply.github.com> Date: Fri, 1 May 2026 14:03:40 -0700 Subject: [PATCH 11/19] Yemohyle/add to ext telemetrey (#313159) * add last asst messages logs * add last asst messages logs ... * add tokens info * add parentHeaderrequestId value to subagent telemetry * remove debug logging * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * suggested change * extra verification * revert suggested change * remove debug logs * add modelcallId and parentModelCallId to response.success telemetry * add engine.messages logs * fix modelCallId * add logging * add turnIndex to response.success * add iterationNumber proxy for request turn * add iterationNumber proxy for request turn to model.modelCall events * add to response.success telemetry * change parentRequestId to point to correct corresponding requestId * remove debug logs * Update extensions/copilot/src/extension/intents/node/toolCallingLoop.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * atomatic review comments changes --------- Co-authored-by: Yevhen Mohylevskyy Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../extension/intents/node/toolCallingLoop.ts | 17 +- .../toolCallingLoopTelemetryLinking.spec.ts | 232 ++++++++++++++++++ .../src/extension/prompt/common/intents.ts | 5 + .../extension/prompt/node/chatMLFetcher.ts | 21 +- .../prompt/node/chatMLFetcherTelemetry.ts | 14 +- .../node/defaultIntentRequestHandler.ts | 1 + .../node/executionSubagentToolCallingLoop.ts | 6 +- .../node/searchSubagentToolCallingLoop.ts | 6 +- .../tools/node/executionSubagentTool.ts | 1 + .../tools/node/searchSubagentTool.ts | 1 + .../src/platform/chat/common/commonTypes.ts | 2 +- .../platform/networking/common/networking.ts | 4 + .../platform/networking/node/chatStream.ts | 3 + 13 files changed, 296 insertions(+), 17 deletions(-) create mode 100644 extensions/copilot/src/extension/intents/test/node/toolCallingLoopTelemetryLinking.spec.ts diff --git a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts index 536fba448ee31..524b5503d4627 100644 --- a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts +++ b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts @@ -104,7 +104,7 @@ export interface IToolCallingBuiltPromptEvent { tools: LanguageModelToolInformation[]; } -export type ToolCallingLoopFetchOptions = Required> & Pick; +export type ToolCallingLoopFetchOptions = Required> & Pick & { iterationNumber: number }; interface StartHookResult { /** @@ -177,6 +177,7 @@ export abstract class ToolCallingLoop { + public capturedContexts: IBuildPromptContext[] = []; + public fetchResponses: ChatResponse[] = []; + private fetchIndex = 0; + + protected override async buildPrompt(buildPromptContext: IBuildPromptContext): Promise { + this.capturedContexts.push(buildPromptContext); + return { + ...nullRenderPromptResult(), + messages: [{ role: Raw.ChatRole.User, content: [toTextPart('hello')] }], + }; + } + + protected override async getAvailableTools(): Promise { + return []; + } + + protected override async fetch(): Promise { + return this.fetchResponses[this.fetchIndex++]; + } +} + +const chatPanelLocation: ChatRequest['location'] = 1; + +function createMockChatRequest(overrides: Partial = {}): ChatRequest { + return { + prompt: 'test prompt', + command: undefined, + references: [], + location: chatPanelLocation, + location2: undefined, + attempt: 0, + enableCommandDetection: false, + isParticipantDetected: false, + toolReferences: [], + toolInvocationToken: {} as ChatRequest['toolInvocationToken'], + model: { family: 'test' } as LanguageModelChat, + tools: new Map(), + id: generateUuid(), + sessionId: generateUuid(), + sessionResource: {} as ChatRequest['sessionResource'], + hasHooksEnabled: false, + ...overrides, + } satisfies ChatRequest; +} + +function createStream(): ChatResponseStreamImpl { + return new ChatResponseStreamImpl( + () => { }, + () => { }, + undefined, + undefined, + undefined, + () => Promise.resolve(undefined), + ); +} + +describe('ToolCallingLoop telemetry linking', () => { + let disposables: DisposableStore; + let accessor: ITestingServicesAccessor; + let instantiationService: IInstantiationService; + let tokenSource: CancellationTokenSource; + + beforeEach(() => { + disposables = new DisposableStore(); + const serviceCollection = disposables.add(createExtensionUnitTestingServices()); + accessor = serviceCollection.createTestingAccessor(); + instantiationService = accessor.get(IInstantiationService); + tokenSource = new CancellationTokenSource(); + disposables.add(tokenSource); + }); + + afterEach(() => { + accessor.dispose(); + disposables.dispose(); + }); + + it('exposes parentHeaderRequestId and parentModelCallId from previous fetch in createPromptContext', async () => { + const request = createMockChatRequest(); + const loop = instantiationService.createInstance( + TelemetryLinkingTestLoop, + { + conversation: new Conversation(generateUuid(), [ + new Turn(generateUuid(), { type: 'user', message: request.prompt }) + ]), + toolCallLimit: 2, + request, + } + ); + disposables.add(loop); + + loop.fetchResponses = [ + { + type: ChatFetchResponseType.Success, + value: 'first response', + requestId: 'client-uuid-1', + serverRequestId: 'server-echoed-1', + modelCallId: 'model-call-1', + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + resolvedModel: 'gpt-4.1', + }, + { + type: ChatFetchResponseType.Success, + value: 'second response', + requestId: 'client-uuid-2', + serverRequestId: 'server-echoed-2', + modelCallId: 'model-call-2', + usage: { prompt_tokens: 20, completion_tokens: 10, total_tokens: 30 }, + resolvedModel: 'gpt-4.1', + }, + ]; + + await loop.runOne(createStream(), 0, tokenSource.token); + await loop.runOne(createStream(), 0, tokenSource.token); + + expect(loop.capturedContexts).toHaveLength(2); + // First iteration: no parent context yet + expect(loop.capturedContexts[0].parentHeaderRequestId).toBeUndefined(); + expect(loop.capturedContexts[0].parentModelCallId).toBeUndefined(); + // Second iteration: should have values from first fetch + expect(loop.capturedContexts[1].parentHeaderRequestId).toBe('server-echoed-1'); + expect(loop.capturedContexts[1].parentModelCallId).toBe('model-call-1'); + }); + + it('falls back to client requestId when serverRequestId is empty', async () => { + const request = createMockChatRequest(); + const loop = instantiationService.createInstance( + TelemetryLinkingTestLoop, + { + conversation: new Conversation(generateUuid(), [ + new Turn(generateUuid(), { type: 'user', message: request.prompt }) + ]), + toolCallLimit: 2, + request, + } + ); + disposables.add(loop); + + loop.fetchResponses = [ + { + type: ChatFetchResponseType.Success, + value: 'first response', + requestId: 'client-uuid-1', + serverRequestId: '', + modelCallId: 'model-call-1', + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + resolvedModel: 'gpt-4.1', + }, + { + type: ChatFetchResponseType.Success, + value: 'second response', + requestId: 'client-uuid-2', + serverRequestId: 'server-echoed-2', + modelCallId: 'model-call-2', + usage: { prompt_tokens: 20, completion_tokens: 10, total_tokens: 30 }, + resolvedModel: 'gpt-4.1', + }, + ]; + + await loop.runOne(createStream(), 0, tokenSource.token); + await loop.runOne(createStream(), 0, tokenSource.token); + + // serverRequestId was '' so should fall back to client requestId + expect(loop.capturedContexts[1].parentHeaderRequestId).toBe('client-uuid-1'); + expect(loop.capturedContexts[1].parentModelCallId).toBe('model-call-1'); + }); + + it('falls back to client requestId when serverRequestId is undefined', async () => { + const request = createMockChatRequest(); + const loop = instantiationService.createInstance( + TelemetryLinkingTestLoop, + { + conversation: new Conversation(generateUuid(), [ + new Turn(generateUuid(), { type: 'user', message: request.prompt }) + ]), + toolCallLimit: 2, + request, + } + ); + disposables.add(loop); + + loop.fetchResponses = [ + { + type: ChatFetchResponseType.Success, + value: 'first response', + requestId: 'client-uuid-1', + serverRequestId: undefined, + modelCallId: 'model-call-1', + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + resolvedModel: 'gpt-4.1', + }, + { + type: ChatFetchResponseType.Success, + value: 'second response', + requestId: 'client-uuid-2', + serverRequestId: 'server-echoed-2', + modelCallId: 'model-call-2', + usage: { prompt_tokens: 20, completion_tokens: 10, total_tokens: 30 }, + resolvedModel: 'gpt-4.1', + }, + ]; + + await loop.runOne(createStream(), 0, tokenSource.token); + await loop.runOne(createStream(), 0, tokenSource.token); + + // serverRequestId was undefined so should fall back to client requestId + expect(loop.capturedContexts[1].parentHeaderRequestId).toBe('client-uuid-1'); + expect(loop.capturedContexts[1].parentModelCallId).toBe('model-call-1'); + }); +}); diff --git a/extensions/copilot/src/extension/prompt/common/intents.ts b/extensions/copilot/src/extension/prompt/common/intents.ts index 7b878bfdbb115..c26be1e78c0d6 100644 --- a/extensions/copilot/src/extension/prompt/common/intents.ts +++ b/extensions/copilot/src/extension/prompt/common/intents.ts @@ -125,6 +125,11 @@ export interface IBuildPromptContext { * Used by subagent tools to link their telemetry back to the parent's HTTP request. */ readonly parentHeaderRequestId?: string; + /** + * The modelCallId from the most recent parent model call. + * Used by subagent tools to link their telemetry back to the parent's specific model call. + */ + readonly parentModelCallId?: string; } export const IBuildPromptContext = createServiceIdentifier('IBuildPromptContext'); diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts index 5a6b63d2df1d6..97bc8f821b4c8 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts @@ -201,6 +201,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { let actualStatusCode: number | undefined; let suspendEventSeen: boolean | undefined; let resumeEventSeen: boolean | undefined; + let actualModelCallId: string | undefined; let otelInferenceSpan: ISpanHandle | undefined; try { let response: ChatResults | ChatRequestFailed | ChatRequestCanceled; @@ -247,6 +248,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { actualStatusCode = fetchResult.statusCode; suspendEventSeen = fetchResult.suspendEventSeen; resumeEventSeen = fetchResult.resumeEventSeen; + actualModelCallId = fetchResult.modelCallId; otelInferenceSpan = fetchResult.otelSpan; // Tag span with debug name so orphaned spans (title, progressMessages, etc.) are identifiable otelInferenceSpan?.setAttribute(GenAiAttr.AGENT_NAME, debugName); @@ -327,7 +329,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { pendingLoggedChatRequest?.markTimeToFirstToken(timeToFirstToken); switch (response.type) { case FetchResponseKind.Success: { - const result = await this.processSuccessfulResponse(response, messages, requestBody, ourRequestId, maxResponseTokens, tokenCount, timeToFirstToken, streamRecorder, baseTelemetry, chatEndpoint, userInitiatedRequest, transport, actualFetcher, actualBytesReceived, suspendEventSeen, resumeEventSeen); + const result = await this.processSuccessfulResponse(response, messages, requestBody, ourRequestId, maxResponseTokens, tokenCount, timeToFirstToken, streamRecorder, baseTelemetry, chatEndpoint, userInitiatedRequest, transport, actualFetcher, actualBytesReceived, suspendEventSeen, resumeEventSeen, actualModelCallId); // Handle FilteredRetry case with augmented messages if (result.type === ChatFetchResponseType.FilteredRetry) { @@ -872,7 +874,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { requestKindOptions?: IBackgroundRequestOptions | ISubagentRequestOptions, summarizedAtRoundId?: string, modeChanged?: boolean, - ): Promise<{ result: ChatResults | ChatRequestFailed | ChatRequestCanceled; fetcher?: FetcherId; bytesReceived?: number; statusCode?: number; suspendEventSeen?: boolean; resumeEventSeen?: boolean; otelSpan?: ISpanHandle }> { + ): Promise<{ result: ChatResults | ChatRequestFailed | ChatRequestCanceled; fetcher?: FetcherId; bytesReceived?: number; statusCode?: number; suspendEventSeen?: boolean; resumeEventSeen?: boolean; otelSpan?: ISpanHandle; modelCallId?: string }> { const isPowerSaveBlockerEnabled = this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.ChatRequestPowerSaveBlocker, this._experimentationService); const blockerHandle = isPowerSaveBlockerEnabled && location !== ChatLocation.Other ? this._powerService.acquirePowerSaveBlocker() : undefined; @@ -951,7 +953,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { requestKindOptions?: IBackgroundRequestOptions | ISubagentRequestOptions, summarizedAtRoundId?: string, modeChanged?: boolean, - ): Promise<{ result: ChatResults | ChatRequestFailed | ChatRequestCanceled; fetcher?: FetcherId; bytesReceived?: number; statusCode?: number; otelSpan?: ISpanHandle }> { + ): Promise<{ result: ChatResults | ChatRequestFailed | ChatRequestCanceled; fetcher?: FetcherId; bytesReceived?: number; statusCode?: number; otelSpan?: ISpanHandle; modelCallId?: string }> { if (cancellationToken.isCancellationRequested) { return { result: { type: FetchResponseKind.Canceled, reason: 'before fetch request' } }; @@ -1084,7 +1086,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { requestKindOptions: IBackgroundRequestOptions | ISubagentRequestOptions | undefined, summarizedAtRoundId: string | undefined, modeChanged: boolean | undefined, - ): Promise<{ result: ChatResults | ChatRequestFailed | ChatRequestCanceled }> { + ): Promise<{ result: ChatResults | ChatRequestFailed | ChatRequestCanceled; modelCallId?: string }> { const intent = locationToIntent(location); const agentInteractionType = requestKindOptions?.kind === 'subagent' ? 'conversation-subagent' : @@ -1246,7 +1248,8 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { result: { type: FetchResponseKind.Success, chatCompletions, - } + }, + modelCallId, }; } @@ -1265,7 +1268,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { useFetcher: FetcherId | undefined, canRetryOnce: boolean | undefined, requestKindOptions: IBackgroundRequestOptions | ISubagentRequestOptions | undefined, - ): Promise<{ result: ChatResults | ChatRequestFailed | ChatRequestCanceled; fetcher?: FetcherId; bytesReceived?: number; statusCode?: number }> { + ): Promise<{ result: ChatResults | ChatRequestFailed | ChatRequestCanceled; fetcher?: FetcherId; bytesReceived?: number; statusCode?: number; modelCallId?: string }> { // Generate unique ID to link input and output messages const modelCallId = generateUuid(); @@ -1363,7 +1366,8 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { chatCompletions, }, fetcher: response.fetcher, - bytesReceived: response.bytesReceived + bytesReceived: response.bytesReceived, + modelCallId, }; } @@ -1754,6 +1758,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { bytesReceived: number | undefined, suspendEventSeen: boolean | undefined, resumeEventSeen: boolean | undefined, + modelCallId: string | undefined, ): Promise> { const completions: ChatCompletion[] = []; @@ -1777,6 +1782,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { bytesReceived, suspendEventSeen, resumeEventSeen, + modelCallId, } ); @@ -1794,6 +1800,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { value: successfulCompletions.map(c => getTextPart(c.message.content)), requestId, serverRequestId: successfulCompletions[0].requestId.headerRequestId, + modelCallId, }; } diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcherTelemetry.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcherTelemetry.ts index 936f0b3c82045..9650b9f9529f9 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcherTelemetry.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcherTelemetry.ts @@ -28,6 +28,7 @@ export interface IChatMLFetcherSuccessfulData { bytesReceived: number | undefined; suspendEventSeen: boolean | undefined; resumeEventSeen: boolean | undefined; + modelCallId: string | undefined; } export interface IChatMLFetcherCancellationProperties { @@ -99,6 +100,7 @@ export class ChatMLFetcherTelemetrySender { bytesReceived, suspendEventSeen, resumeEventSeen, + modelCallId, }: IChatMLFetcherSuccessfulData, ) { /* __GDPR__ @@ -144,7 +146,12 @@ export class ChatMLFetcherTelemetrySender { "connectivityTestErrorGitHubRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "GitHub request id of the connectivity test request if available" }, "retryAfterFilterCategory": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "If the response was filtered and this is a retry attempt, this contains the original filtered content category." }, "suspendEventSeen": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether a system suspend event was seen during the request", "isMeasurement": true }, - "resumeEventSeen": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether a system resume event was seen during the request", "isMeasurement": true } + "resumeEventSeen": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether a system resume event was seen during the request", "isMeasurement": true }, + "subType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Sub-type of the request" }, + "modelCallId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Unique identifier for this model call" }, + "parentRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The requestId from the parent response.success event for subagent calls" }, + "parentModelCallId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Model call ID of the parent request for subagent calls" }, + "iterationNumber": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Iteration number within the tool calling loop" } } */ telemetryService.sendTelemetryEvent('response.success', { github: true, microsoft: true }, { @@ -161,6 +168,11 @@ export class ChatMLFetcherTelemetrySender { associatedRequestId: baseTelemetry?.properties.associatedRequestId, reasoningEffort: requestBody.reasoning?.effort ?? requestBody.output_config?.effort, reasoningSummary: requestBody.reasoning?.summary, + modelCallId, + ...(baseTelemetry?.properties.subType ? { subType: baseTelemetry.properties.subType } : {}), + ...(baseTelemetry?.properties.parentHeaderRequestId ? { parentRequestId: baseTelemetry.properties.parentHeaderRequestId } : {}), + ...(baseTelemetry?.properties.parentModelCallId ? { parentModelCallId: baseTelemetry.properties.parentModelCallId } : {}), + ...(baseTelemetry?.properties.iterationNumber ? { iterationNumber: baseTelemetry.properties.iterationNumber } : {}), ...(fetcher ? { fetcher } : {}), transport, ...(baseTelemetry?.properties.retryAfterError ? { retryAfterError: baseTelemetry.properties.retryAfterError } : {}), diff --git a/extensions/copilot/src/extension/prompt/node/defaultIntentRequestHandler.ts b/extensions/copilot/src/extension/prompt/node/defaultIntentRequestHandler.ts index fa779396a862e..57800f49d1d4f 100644 --- a/extensions/copilot/src/extension/prompt/node/defaultIntentRequestHandler.ts +++ b/extensions/copilot/src/extension/prompt/node/defaultIntentRequestHandler.ts @@ -731,6 +731,7 @@ class DefaultToolCallingLoop extends ToolCallingLoop { messageSource: this.options.intent?.id && this.options.intent.id !== UnknownIntent.ID ? `${messageSourcePrefix}.${this.options.intent.id}` : `${messageSourcePrefix}.user`, subType: this.options.request.subAgentInvocationId ? `subagent` : this.options.request.isSystemInitiated ? 'system-initiated' : undefined, parentRequestId: this.options.request.parentRequestId, + iterationNumber: opts.iterationNumber.toString(), }, requestKindOptions: this.options.request.subAgentInvocationId ? { kind: 'subagent' } diff --git a/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts b/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts index 941c91955f5ec..7e572679db6ee 100644 --- a/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts +++ b/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts @@ -41,6 +41,8 @@ export interface IExecutionSubagentToolCallingLoopOptions extends IToolCallingLo parentToolCallId?: string; /** The headerRequestId from the parent agent's fetch response that triggered this subagent invocation. */ parentHeaderRequestId?: string; + /** The modelCallId from the parent agent's model call that triggered this subagent invocation. */ + parentModelCallId?: string; } /** A terminal command that is no longer being awaited by the subagent — either @@ -313,7 +315,7 @@ export class ExecutionSubagentToolCallingLoop extends ToolCallingLoop allowedExecutionTools.has(tool.name as ToolName)); } - protected async fetch({ messages, finishedCb, requestOptions, modelCapabilities }: ToolCallingLoopFetchOptions, token: CancellationToken): Promise { + protected async fetch({ messages, finishedCb, requestOptions, modelCapabilities, iterationNumber }: ToolCallingLoopFetchOptions, token: CancellationToken): Promise { const endpoint = await this.getEndpoint(); return endpoint.makeChatRequest2({ debugName: ExecutionSubagentToolCallingLoop.ID, @@ -335,6 +337,8 @@ export class ExecutionSubagentToolCallingLoop extends ToolCallingLoop allowedSearchTools.has(tool.name as ToolName)); } - protected async fetch({ messages, finishedCb, requestOptions, modelCapabilities }: ToolCallingLoopFetchOptions, token: CancellationToken): Promise { + protected async fetch({ messages, finishedCb, requestOptions, modelCapabilities, iterationNumber }: ToolCallingLoopFetchOptions, token: CancellationToken): Promise { const endpoint = await this.getEndpoint(); return endpoint.makeChatRequest2({ debugName: SearchSubagentToolCallingLoop.ID, @@ -167,6 +169,8 @@ export class SearchSubagentToolCallingLoop extends ToolCallingLoop { subAgentInvocationId: subAgentInvocationId, parentToolCallId: options.chatStreamToolCallId, parentHeaderRequestId: this._inputContext?.parentHeaderRequestId, + parentModelCallId: this._inputContext?.parentModelCallId, }); const stream = this._inputContext?.stream && ChatResponseStreamImpl.filter( diff --git a/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts b/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts index ead22274cf844..2953a66a049f4 100644 --- a/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts +++ b/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts @@ -125,6 +125,7 @@ class SearchSubagentTool implements ICopilotTool { subAgentInvocationId: subAgentInvocationId, parentToolCallId: options.chatStreamToolCallId, parentHeaderRequestId: this._inputContext?.parentHeaderRequestId, + parentModelCallId: this._inputContext?.parentModelCallId, thoroughness: thoroughnessEnabled ? options.input.thoroughness : undefined, }); diff --git a/extensions/copilot/src/platform/chat/common/commonTypes.ts b/extensions/copilot/src/platform/chat/common/commonTypes.ts index 3929343df9831..6598662913436 100644 --- a/extensions/copilot/src/platform/chat/common/commonTypes.ts +++ b/extensions/copilot/src/platform/chat/common/commonTypes.ts @@ -190,7 +190,7 @@ export type ChatFetchRetriableError = { type: ChatFetchResponseType.FilteredRetry; reason: string; category: FilterReason; value: T; requestId: string; serverRequestId: string | undefined }; export type FetchSuccess = - { type: ChatFetchResponseType.Success; value: T; requestId: string; serverRequestId: string | undefined; usage: APIUsage | undefined; resolvedModel: string }; + { type: ChatFetchResponseType.Success; value: T; requestId: string; serverRequestId: string | undefined; usage: APIUsage | undefined; resolvedModel: string; modelCallId?: string }; export type FetchResponse = FetchSuccess | ChatFetchError; diff --git a/extensions/copilot/src/platform/networking/common/networking.ts b/extensions/copilot/src/platform/networking/common/networking.ts index 483bddbb1c03a..c7d020da1c5a3 100644 --- a/extensions/copilot/src/platform/networking/common/networking.ts +++ b/extensions/copilot/src/platform/networking/common/networking.ts @@ -236,6 +236,10 @@ export type IChatRequestTelemetryProperties = { parentToolCallId?: string; /** For a subagent: The headerRequestId from the parent agent's fetch response that triggered this subagent invocation. */ parentHeaderRequestId?: string; + /** For a subagent: The modelCallId from the parent agent's model call that triggered this subagent invocation. */ + parentModelCallId?: string; + /** The 0-based iteration number of the tool-calling loop that produced this request. */ + iterationNumber?: string; }; export interface ICreateEndpointBodyOptions extends IMakeChatRequestOptions { diff --git a/extensions/copilot/src/platform/networking/node/chatStream.ts b/extensions/copilot/src/platform/networking/node/chatStream.ts index d01e07ee3a0e9..fd99ab39d36ab 100644 --- a/extensions/copilot/src/platform/networking/node/chatStream.ts +++ b/extensions/copilot/src/platform/networking/node/chatStream.ts @@ -393,6 +393,7 @@ function sendModelCallTelemetry(telemetryService: ITelemetryService, messageData for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { const parentToolCallId = telemetryData.properties.parentToolCallId; const parentHeaderRequestId = telemetryData.properties.parentHeaderRequestId; + const parentModelCallId = telemetryData.properties.parentModelCallId; const modelCallData = TelemetryData.createAndMarkAsIssued({ modelCallId, conversationId, // Trajectory identifier linking main and supplementary calls @@ -405,8 +406,10 @@ function sendModelCallTelemetry(telemetryService: ITelemetryService, messageData ...(requestTurn !== undefined && { requestTurn: requestTurn.toString() }), // Add requestTurn only for input calls ...(requestOptionsId && { requestOptionsId }), // Add requestOptionsId for input calls ...(telemetryData.properties.turnIndex && { turnIndex: telemetryData.properties.turnIndex }), // Add turnIndex from original telemetryData + ...(telemetryData.properties.iterationNumber && { iterationNumber: telemetryData.properties.iterationNumber }), // Add iterationNumber from tool calling loop ...(parentToolCallId && { parentToolCallId }), // Link subagent calls to parent tool invocation ...(parentHeaderRequestId && { parentHeaderRequestId }), // Link subagent calls to parent HTTP request + ...(parentModelCallId && { parentModelCallId }), // Link subagent calls to parent model call }, telemetryData.measurements); // Include measurements from original telemetryData telemetryService.sendInternalMSFTTelemetryEvent(eventName, modelCallData.properties, modelCallData.measurements); From 7211c0f37464f0b55cb9217f0d960e9a58c87e41 Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Fri, 1 May 2026 14:06:06 -0700 Subject: [PATCH 12/19] agentHost/claude: Phase 3 reference grounding + Phase 4 ClaudeAgent skeleton (#313780) --- .../platform/agentHost/common/agentService.ts | 32 ++ .../electron-main/electronAgentHostStarter.ts | 8 + .../platform/agentHost/node/agentHostMain.ts | 9 +- .../agentHost/node/agentHostServerMain.ts | 24 +- .../platform/agentHost/node/agentService.ts | 32 +- .../agentHost/node/claude/claudeAgent.ts | 255 ++++++++++ .../agentHost/node/claude/phase4-plan.md | 477 ++++++++++++++++++ .../platform/agentHost/node/claude/roadmap.md | 207 ++++++-- .../platform/agentHost/node/claude/smoke.md | 221 ++++++++ .../agentHost/node/copilot/copilotAgent.ts | 12 +- .../agentHost/node/nodeAgentHostStarter.ts | 9 + .../agentHost/test/node/agentService.test.ts | 65 +++ .../agentHost/test/node/claudeAgent.test.ts | 432 ++++++++++++++++ .../contrib/chat/browser/chat.contribution.ts | 9 +- 14 files changed, 1718 insertions(+), 74 deletions(-) create mode 100644 src/vs/platform/agentHost/node/claude/claudeAgent.ts create mode 100644 src/vs/platform/agentHost/node/claude/phase4-plan.md create mode 100644 src/vs/platform/agentHost/node/claude/smoke.md create mode 100644 src/vs/platform/agentHost/test/node/claudeAgent.test.ts diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 0fa89cecd9dab..fbdde84c50326 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -36,6 +36,22 @@ export const AgentHostEnabledSettingId = 'chat.agentHost.enabled'; /** Configuration key that controls whether per-host IPC traffic output channels are created. */ export const AgentHostIpcLoggingSettingId = 'chat.agentHost.ipcLoggingEnabled'; +/** + * Configuration key that controls whether the Claude agent provider is registered + * inside the agent host. Read on the workbench side and forwarded to the agent host + * process via the `VSCODE_AGENT_HOST_ENABLE_CLAUDE` environment variable; the agent + * host process must be restarted for changes to take effect. + */ +export const AgentHostClaudeAgentEnabledSettingId = 'chat.agentHost.claudeAgent.enabled'; + +/** + * Environment variable that, when set to `'1'`, causes the agent host process to + * register the Claude agent provider. Set by the agent host starters when + * {@link AgentHostClaudeAgentEnabledSettingId} is enabled, and may also be set + * directly by developers as an override. + */ +export const AgentHostEnableClaudeEnvVar = 'VSCODE_AGENT_HOST_ENABLE_CLAUDE'; + /** Result of starting the agent host WebSocket server on-demand. */ export interface IAgentHostSocketInfo { readonly socketPath: string; @@ -146,6 +162,22 @@ export interface AuthenticateResult { readonly authenticated: boolean; } +/** + * Canonical {@link ProtectedResourceMetadata} for the GitHub Copilot + * resource. Shared between every agent provider that consumes a GitHub + * Copilot bearer token (e.g. Copilot CLI, Claude) so they advertise an + * identical resource identifier to the auth flow — clients dispatch by + * `resource`, and divergent metadata would silently route the same + * token down separate code paths. + */ +export const GITHUB_COPILOT_PROTECTED_RESOURCE: ProtectedResourceMetadata = { + resource: 'https://api.github.com', + resource_name: 'GitHub Copilot', + authorization_servers: ['https://github.com/login/oauth'], + scopes_supported: ['read:user', 'user:email'], + required: true, +}; + export interface IAgentCreateSessionConfig { readonly provider?: AgentProvider; readonly model?: ModelSelection; diff --git a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts index a51c4033246a7..d6fabefdbcaee 100644 --- a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts +++ b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts @@ -19,6 +19,7 @@ import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; import { UtilityProcess } from '../../utilityProcess/electron-main/utilityProcess.js'; import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; +import { AgentHostClaudeAgentEnabledSettingId, AgentHostEnableClaudeEnvVar } from '../common/agentService.js'; import { deepClone } from '../../../base/common/objects.js'; export class ElectronAgentHostStarter extends Disposable implements IAgentHostStarter { @@ -63,6 +64,12 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt // PATH and other vars from the user's login shell (macOS/Linux GUI launches). const shellEnv = await this._resolveShellEnv(); + // Gate optional providers via env vars consumed by `agentHostMain.ts`. + // The Claude agent is opt-in: enabled when either the workbench setting is on + // or the env var is already set on the parent process (developer override). + const claudeEnabled = this._configurationService.getValue(AgentHostClaudeAgentEnabledSettingId) + || process.env[AgentHostEnableClaudeEnvVar] === '1'; + this.utilityProcess.start({ type: 'agentHost', name: 'agent-host', @@ -75,6 +82,7 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt VSCODE_ESM_ENTRYPOINT: 'vs/platform/agentHost/node/agentHostMain', VSCODE_PIPE_LOGGING: 'true', VSCODE_VERBOSE_LOGGING: 'true', + ...(claudeEnabled ? { [AgentHostEnableClaudeEnvVar]: '1' } : {}), } }); diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 44bc98932b693..ad151caa20f5b 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -15,12 +15,13 @@ import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import * as os from 'os'; import * as inspector from 'inspector'; -import { AgentHostIpcChannels, IAgentHostInspectInfo, IAgentHostSocketInfo, IConnectionTrackerService } from '../common/agentService.js'; +import { AgentHostEnableClaudeEnvVar, AgentHostIpcChannels, IAgentHostInspectInfo, IAgentHostSocketInfo, IConnectionTrackerService } from '../common/agentService.js'; import { AgentService } from './agentService.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; import { IAgentHostTerminalManager } from './agentHostTerminalManager.js'; import { CopilotAgent } from './copilot/copilotAgent.js'; import { CopilotApiService, ICopilotApiService } from './shared/copilotApiService.js'; +import { ClaudeAgent } from './claude/claudeAgent.js'; import { ClaudeProxyService, IClaudeProxyService } from './claude/claudeProxyService.js'; import { ProtocolServerHandler } from './protocolServerHandler.js'; import { WebSocketProtocolServer } from './webSocketTransport.js'; @@ -119,6 +120,12 @@ function startAgentHost(): void { diServices.set(IAgentHostTerminalManager, agentService.terminalManager); diServices.set(IAgentConfigurationService, agentService.configurationService); agentService.registerProvider(instantiationService.createInstance(CopilotAgent)); + // The Claude agent provider is opt-in. Gated on the + // `chat.agentHost.claudeAgent.enabled` workbench setting, forwarded by the + // agent host starters as `VSCODE_AGENT_HOST_ENABLE_CLAUDE`. + if (process.env[AgentHostEnableClaudeEnvVar] === '1') { + agentService.registerProvider(instantiationService.createInstance(ClaudeAgent)); + } } catch (err) { logService.error('Failed to create AgentService', err); throw err; diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index 10d4dd4c9ac11..a04f7da115967 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ // Standalone agent host server with WebSocket protocol transport. -// Start with: node out/vs/platform/agentHost/node/agentHostServerMain.js [--port ] [--host ] [--connection-token ] [--connection-token-file ] [--without-connection-token] [--enable-mock-agent] [--quiet] [--log ] +// Start with: node out/vs/platform/agentHost/node/agentHostServerMain.js [--port ] [--host ] [--connection-token ] [--connection-token-file ] [--without-connection-token] [--enable-mock-agent] [--enable-claude-agent] [--quiet] [--log ] import { fileURLToPath } from 'url'; @@ -32,7 +32,11 @@ import { IProductService } from '../../product/common/productService.js'; import { InstantiationService } from '../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; import { CopilotAgent } from './copilot/copilotAgent.js'; +import { CopilotApiService, ICopilotApiService } from './shared/copilotApiService.js'; +import { ClaudeAgent } from './claude/claudeAgent.js'; +import { ClaudeProxyService, IClaudeProxyService } from './claude/claudeProxyService.js'; import { AgentService } from './agentService.js'; +import { AgentHostEnableClaudeEnvVar } from '../common/agentService.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; import { IAgentHostTerminalManager } from './agentHostTerminalManager.js'; import { WebSocketProtocolServer } from './webSocketTransport.js'; @@ -66,6 +70,7 @@ interface IServerOptions { readonly port: number; readonly host: string | undefined; readonly enableMockAgent: boolean; + readonly enableClaudeAgent: boolean; readonly quiet: boolean; /** Connection token string, or `undefined` when `--without-connection-token`. */ readonly connectionToken: string | undefined; @@ -79,6 +84,10 @@ function parseServerOptions(): IServerOptions { const hostIdx = argv.indexOf('--host'); const host = hostIdx >= 0 ? argv[hostIdx + 1] : undefined; const enableMockAgent = argv.includes('--enable-mock-agent'); + // Claude agent registration is opt-in: enable via either the CLI flag or the + // shared env var (the env var is what the agent host starters use when the + // `chat.agentHost.claudeAgent.enabled` workbench setting is on). + const enableClaudeAgent = argv.includes('--enable-claude-agent') || process.env[AgentHostEnableClaudeEnvVar] === '1'; const quiet = argv.includes('--quiet'); // Connection token @@ -121,7 +130,7 @@ function parseServerOptions(): IServerOptions { connectionToken = generateUuid(); } - return { port, host, enableMockAgent, quiet, connectionToken }; + return { port, host, enableMockAgent, enableClaudeAgent, quiet, connectionToken }; } // ---- Main ------------------------------------------------------------------- @@ -191,9 +200,20 @@ async function main(): Promise { diServices.set(IAgentHostTerminalManager, agentService.terminalManager); diServices.set(IAgentConfigurationService, agentService.configurationService); diServices.set(IAgentHostGitService, gitService); + // Register `ICopilotApiService` BEFORE `IClaudeProxyService` — + // the proxy service constructor requires it. + const copilotApiService = instantiationService.createInstance(CopilotApiService, undefined); + diServices.set(ICopilotApiService, copilotApiService); + const claudeProxyService = disposables.add(instantiationService.createInstance(ClaudeProxyService)); + diServices.set(IClaudeProxyService, claudeProxyService); const copilotAgent = disposables.add(instantiationService.createInstance(CopilotAgent)); agentService.registerProvider(copilotAgent); log('CopilotAgent registered'); + if (options.enableClaudeAgent) { + const claudeAgent = disposables.add(instantiationService.createInstance(ClaudeAgent)); + agentService.registerProvider(claudeAgent); + log('ClaudeAgent registered'); + } } if (options.enableMockAgent) { diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 6a7dc566bb6fd..d27333d645beb 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -148,16 +148,32 @@ export class AgentService extends Disposable implements IAgentService { async authenticate(params: AuthenticateParams): Promise { this._logService.trace(`[AgentService] authenticate called: resource=${params.resource}`); - for (const provider of this._providers.values()) { - const resources = provider.getProtectedResources(); - if (resources.some(r => r.resource === params.resource)) { - const accepted = await provider.authenticate(params.resource, params.token); - if (accepted) { - return { authenticated: true }; - } + // Multiple providers may share the same protected resource (e.g. + // both Copilot CLI and Claude consume the GitHub Copilot token). + // Fan out to every matching provider in parallel; the request is + // considered authenticated if at least one accepts. Provider + // failures are isolated — one provider rejecting (e.g. proxy + // server bind failure) MUST NOT prevent another provider from + // accepting the same token. + const matching = [...this._providers.values()].filter( + p => p.getProtectedResources().some(r => r.resource === params.resource), + ); + const settled = await Promise.allSettled( + matching.map(p => p.authenticate(params.resource, params.token)), + ); + let authenticated = false; + for (let i = 0; i < settled.length; i++) { + const result = settled[i]; + if (result.status === 'fulfilled') { + authenticated ||= result.value; + } else { + this._logService.error( + result.reason, + `[AgentService] Provider '${matching[i].id}' authenticate threw for resource=${params.resource}`, + ); } } - return { authenticated: false }; + return { authenticated }; } // ---- session management ------------------------------------------------- diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts new file mode 100644 index 0000000000000..74f2c35bc6724 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -0,0 +1,255 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { CCAModel } from '@vscode/copilot-api'; +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IObservable, observableValue } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { ILogService } from '../../../log/common/log.js'; +import { ISyncedCustomization } from '../../common/agentPluginManager.js'; +import { AgentProvider, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata } from '../../common/agentService.js'; +import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; +import { ProtectedResourceMetadata, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; +import { CustomizationRef, SessionInputResponseKind, type SessionInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; +import { ICopilotApiService } from '../shared/copilotApiService.js'; +import { tryParseClaudeModelId } from './claudeModelId.js'; +import { IClaudeProxyHandle, IClaudeProxyService } from './claudeProxyService.js'; + +/** + * Returns true if `m` is a Claude-family model that should be advertised + * to clients picking a model for the Claude provider. + * + * Combines the same surface checks the extension uses (vendor, picker + * eligibility, tool-call support, `/v1/messages` endpoint) with a parse + * of the model id via {@link tryParseClaudeModelId}, which excludes + * synthetic ids like `auto` that aren't real Claude endpoints. + */ +function isClaudeModel(m: CCAModel): boolean { + return ( + m.vendor === 'Anthropic' && + !!m.supported_endpoints?.includes('/v1/messages') && + !!m.model_picker_enabled && + !!m.capabilities?.supports?.tool_calls && + tryParseClaudeModelId(m.id) !== undefined + ); +} + +/** + * Project a {@link CCAModel} into the agent host's + * {@link IAgentModelInfo} surface. The returned `provider` is the + * agent's id (`'claude'`) — clients filter the root state's model list + * by provider, so this must match {@link ClaudeAgent.id}, NOT the + * upstream `vendor: 'Anthropic'` field. + */ +function toAgentModelInfo(m: CCAModel, provider: AgentProvider): IAgentModelInfo { + return { + provider, + id: m.id, + name: m.name, + maxContextWindow: m.capabilities?.limits?.max_context_window_tokens, + supportsVision: !!m.capabilities?.supports?.vision, + }; +} + +/** + * Phase 4 skeleton {@link IAgent} provider for the Claude Agent SDK. + * + * What is implemented: + * - Provider id, descriptor, and protected resources surface so root + * state advertises Claude alongside Copilot CLI. + * - GitHub token capture via {@link authenticate} and lazy acquisition + * of an {@link IClaudeProxyHandle} from {@link IClaudeProxyService}. + * - {@link models} observable derived from {@link ICopilotApiService.models} + * filtered to Claude-family entries via {@link isClaudeModel}. + * + * What is stubbed: + * - All other {@link IAgent} methods throw `Error('TODO: Phase N')`. The + * exact phase numbers reference the roadmap in + * `src/vs/platform/agentHost/node/claude/roadmap.md`. + * + * The class is intentionally lean: each subsequent phase adds one + * concern (sessions, sendMessage, permissions, etc.) so the surface area + * of any single review stays small. + */ +export class ClaudeAgent extends Disposable implements IAgent { + readonly id: AgentProvider = 'claude'; + + private readonly _onDidSessionProgress = this._register(new Emitter()); + readonly onDidSessionProgress = this._onDidSessionProgress.event; + + private readonly _models = observableValue(this, []); + readonly models: IObservable = this._models; + + private _githubToken: string | undefined; + private _proxyHandle: IClaudeProxyHandle | undefined; + + constructor( + @ILogService private readonly _logService: ILogService, + @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, + @IClaudeProxyService private readonly _claudeProxyService: IClaudeProxyService, + ) { + super(); + } + + // #region Descriptor + auth + + getDescriptor(): IAgentDescriptor { + return { + provider: this.id, + displayName: localize('claudeAgent.displayName', "Claude"), + description: localize('claudeAgent.description', "Claude agent backed by the Anthropic Claude Agent SDK"), + }; + } + + getProtectedResources(): ProtectedResourceMetadata[] { + return [GITHUB_COPILOT_PROTECTED_RESOURCE]; + } + + async authenticate(resource: string, token: string): Promise { + if (resource !== GITHUB_COPILOT_PROTECTED_RESOURCE.resource) { + return false; + } + const tokenChanged = this._githubToken !== token; + if (!tokenChanged) { + this._logService.info('[Claude] Auth token unchanged'); + return true; + } + // Acquire the new handle BEFORE committing the token or disposing + // the old one. If `start()` throws, leave `_githubToken` and + // `_proxyHandle` untouched so the next `authenticate()` call still + // sees the token as new and retries — otherwise a transient proxy + // startup failure would leave us in a "token recorded, no proxy + // running" state and the retry path would short-circuit as + // "unchanged" and falsely return true. + // + // The proxy server's refcount stays >= 1 throughout this swap + // because the new handle is acquired before the old one is + // disposed; {@link IClaudeProxyService} applies most-recent-token- + // wins on subsequent `start()` calls. + const newHandle = await this._claudeProxyService.start(token); + const oldHandle = this._proxyHandle; + this._proxyHandle = newHandle; + this._githubToken = token; + this._logService.info('[Claude] Auth token updated'); + oldHandle?.dispose(); + void this._refreshModels(); + return true; + } + + private async _refreshModels(): Promise { + const tokenAtStart = this._githubToken; + if (!tokenAtStart) { + this._models.set([], undefined); + return; + } + try { + const all = await this._copilotApiService.models(tokenAtStart); + // Stale-write guard: if `authenticate()` rotated the token + // while we were awaiting the model list, a newer refresh has + // already published the right value — don't overwrite it. + if (this._githubToken !== tokenAtStart) { + return; + } + const filtered = all.filter(isClaudeModel).map(m => toAgentModelInfo(m, this.id)); + this._models.set(filtered, undefined); + } catch (err) { + this._logService.error(err, '[Claude] Failed to refresh models'); + if (this._githubToken === tokenAtStart) { + this._models.set([], undefined); + } + } + } + + // #endregion + + // #region Stubs — implemented in later phases + + createSession(_config?: IAgentCreateSessionConfig): Promise { + throw new Error('TODO: Phase 5'); + } + + disposeSession(_session: URI): Promise { + throw new Error('TODO: Phase 5'); + } + + /** + * Full transcript reconstruction from the SDK event log lands in + * Phase 13; the bare method shape is required by {@link IAgent}. + */ + getSessionMessages(_session: URI): Promise { + throw new Error('TODO: Phase 5'); + } + + listSessions(): Promise { + throw new Error('TODO: Phase 5'); + } + + resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise { + throw new Error('TODO: Phase 5'); + } + + sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { + throw new Error('TODO: Phase 5'); + } + + shutdown(): Promise { + throw new Error('TODO: Phase 5'); + } + + sendMessage(_session: URI, _prompt: string, _attachments?: IAgentAttachment[], _turnId?: string): Promise { + throw new Error('TODO: Phase 6'); + } + + respondToPermissionRequest(_requestId: string, _approved: boolean): void { + throw new Error('TODO: Phase 7'); + } + + respondToUserInputRequest(_requestId: string, _response: SessionInputResponseKind, _answers?: Record): void { + throw new Error('TODO: Phase 7'); + } + + abortSession(_session: URI): Promise { + throw new Error('TODO: Phase 9'); + } + + changeModel(_session: URI, _model: ModelSelection): Promise { + throw new Error('TODO: Phase 9'); + } + + setClientTools(_session: URI, _clientId: string, _tools: ToolDefinition[]): void { + throw new Error('TODO: Phase 10'); + } + + onClientToolCallComplete(_session: URI, _toolCallId: string, _result: ToolCallResult): void { + throw new Error('TODO: Phase 10'); + } + + setClientCustomizations(_clientId: string, _customizations: CustomizationRef[], _progress?: (results: ISyncedCustomization[]) => void): Promise { + throw new Error('TODO: Phase 11'); + } + + setCustomizationEnabled(_uri: string, _enabled: boolean): void { + throw new Error('TODO: Phase 11'); + } + + // #endregion + + override dispose(): void { + // Phase 6+ INVARIANT: SDK subprocess(es) MUST be killed BEFORE the + // proxy handle is disposed. After dispose the proxy may rebind on + // a different port and the subprocess would silently lose its + // endpoint. See `IClaudeProxyHandle` doc in `claudeProxyService.ts`. + // In Phase 4 there are no subprocesses, so this ordering is moot — + // but the comment is mandatory so future contributors don't break + // it when they wire the SDK in. + this._proxyHandle?.dispose(); + this._proxyHandle = undefined; + this._githubToken = undefined; + this._models.set([], undefined); + super.dispose(); + } +} diff --git a/src/vs/platform/agentHost/node/claude/phase4-plan.md b/src/vs/platform/agentHost/node/claude/phase4-plan.md new file mode 100644 index 0000000000000..8cb809378ccb6 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/phase4-plan.md @@ -0,0 +1,477 @@ +# Phase 4 Implementation Plan — `ClaudeAgent` Skeleton + +> **Handoff plan** — written to be executed by an agent with no prior conversation context. All file paths and line citations are verified against the workspace at synthesis time. Cross-reference [roadmap.md](./roadmap.md) before committing exact phase numbers. + +## 1. Goal + +Add a `ClaudeAgent` provider to the agent host that registers with `IAgentService`, advertises Anthropic models from `ICopilotApiService.models()`, and authenticates against the same GitHub resource as `CopilotAgent`. **No SDK / subprocess / sendMessage** in this phase — those are Phase 6+. Most `IAgent` methods throw `Error('TODO: Phase N')`. + +Because the provider is a stub for the next several phases (every user-facing method throws `TODO: Phase N`), registration is **off by default** behind a new `chat.agentHost.claudeAgent.enabled` workbench setting. See §4 for the gate; without opting in, the provider is invisible to users and to root state. This was added beyond the original Phase 4 brief — Phase 4 ships only the skeleton and its registration gate; opening the setting up by default waits until the user-facing methods stop throwing. + +**Exit criteria:** With `chat.agentHost.claudeAgent.enabled: true`, a workbench client connecting to the agent host sees a `claude` provider in root state, can pick a Claude model, and `sendMessage()` throws `TODO: Phase 6`. With the setting off (the default), only `copilotcli` is registered. + +## 2. Files to create / modify + +| Action | File | Purpose | +|---|---|---| +| **Create** | `src/vs/platform/agentHost/node/claude/claudeAgent.ts` | The `ClaudeAgent` class. ~200–300 lines. | +| **Create** | `src/vs/platform/agentHost/test/node/claudeAgent.test.ts` | Unit tests. | +| **Modify** | `src/vs/platform/agentHost/common/agentService.ts` | Export `AgentHostClaudeAgentEnabledSettingId` setting id and `AgentHostEnableClaudeEnvVar` constant. | +| **Modify** | `src/vs/workbench/contrib/chat/browser/chat.contribution.ts` | Register `chat.agentHost.claudeAgent.enabled` (boolean, default `false`, `experimental`+`advanced`, hidden in stable). | +| **Modify** | `src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts` | Read the setting; forward as `VSCODE_AGENT_HOST_ENABLE_CLAUDE` env var into the utility process. | +| **Modify** | `src/vs/platform/agentHost/node/nodeAgentHostStarter.ts` | Same forwarding for the Node child-process fallback. | +| **Modify** | `src/vs/platform/agentHost/node/agentHostMain.ts` | Conditionally register `ClaudeAgent` next to `CopilotAgent` based on the env var. | +| **Modify** | `src/vs/platform/agentHost/node/agentHostServerMain.ts` | Register `ICopilotApiService`, `IClaudeProxyService`, and `ClaudeAgent` (gated on env var or `--enable-claude-agent`). Currently has none of these. | +| **Modify** | `src/vs/platform/agentHost/node/claude/scripts/launch-smoke.sh` | Export `VSCODE_AGENT_HOST_ENABLE_CLAUDE=1` so the smoke flow does not require touching user settings. | + +## 3. `ClaudeAgent` class spec + +**Reference implementation map:** + +| Phase 4 concern | Reference | +|---|---| +| Class shell, `Disposable`, emitter, observable | `src/vs/platform/agentHost/node/copilot/copilotAgent.ts` (in-tree `IAgent` reference) | +| `getDescriptor` / `getProtectedResources` / `authenticate` | `CopilotAgent` ONLY — extension has no `IAgent` analog | +| Model filter | `extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts:165–179` | + +**Do NOT** copy `ClaudeCodeSession` (claudeCodeAgent.ts:122+) verbatim — that class has accreted ~20 layered concerns (MCP gateway, plugins, edit tracker, settings tracker, OTel, hooks, debug logger, ripgrep PATH, runtime data, folder MRU). Each concern enters the in-tree implementation in the phase that actually needs it. + +### 3.1 Class shell + +```ts +export class ClaudeAgent extends Disposable implements IAgent { + readonly id = 'claude' as const; + + private readonly _onDidSessionProgress = this._register(new Emitter()); + readonly onDidSessionProgress = this._onDidSessionProgress.event; + + private readonly _models = observableValue(this, []); + readonly models: IObservable = this._models; + + private _githubToken: string | undefined; + private _proxyHandle: IClaudeProxyHandle | undefined; + + constructor( + @ILogService private readonly _logService: ILogService, + @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, + @IClaudeProxyService private readonly _claudeProxyService: IClaudeProxyService, + ) { + super(); + } + // ... +} +``` + +**Provider id is `'claude'`** (not `'claude-code'`). The id becomes the URI scheme via `AgentSession.uri()` at `src/vs/platform/agentHost/common/agentService.ts:314`. Branding goes in `displayName`. + +### 3.2 `getDescriptor()` and `getProtectedResources()` + +Mirror `CopilotAgent` lines 249–266. Use `localize()` for user-visible strings: + +```ts +getDescriptor(): IAgentDescriptor { + return { + provider: 'claude', + displayName: localize('claudeAgent.displayName', "Claude"), + description: localize('claudeAgent.description', "Claude agent backed by the Anthropic Claude Agent SDK"), + }; +} + +getProtectedResources(): ProtectedResourceMetadata[] { + return [{ + resource: 'https://api.github.com', + resource_name: 'GitHub Copilot', + authorization_servers: ['https://github.com/login/oauth'], + scopes_supported: ['read:user', 'user:email'], + required: true, + }]; +} +``` + +### 3.3 `authenticate()` — the only real logic in this phase + +Defer proxy startup until a token arrives. `IClaudeProxyService.start()` requires a non-empty github token (`src/vs/platform/agentHost/node/claude/claudeProxyService.ts:61`), so eager construction is impossible. + +Mirror `CopilotAgent.authenticate()` (lines 277–309): + +```ts +async authenticate(resource: string, token: string): Promise { + if (resource !== 'https://api.github.com') { + return false; + } + const tokenChanged = this._githubToken !== token; + this._githubToken = token; + this._logService.info(`[Claude] Auth token ${tokenChanged ? 'updated' : 'unchanged'}`); + if (tokenChanged) { + // Restart proxy with new token. Old handle's dispose() decrements + // refcount; ClaudeProxyService applies most-recent-token-wins. + const oldHandle = this._proxyHandle; + this._proxyHandle = await this._claudeProxyService.start(token); + oldHandle?.dispose(); + void this._refreshModels(); + } + return true; +} +``` + +**Order matters:** acquire new handle before disposing old one so the proxy server doesn't tear down between the two calls (refcount stays ≥ 1 throughout). + +### 3.4 `_refreshModels()` — token-change-driven, with stale guard + +Copy CopilotAgent's pattern (lines 299–313): + +```ts +private async _refreshModels(): Promise { + const tokenAtStart = this._githubToken; + if (!tokenAtStart) { + this._models.set([], undefined); + return; + } + try { + const all = await this._copilotApiService.models(tokenAtStart); + const filtered = all.filter(m => isClaudeModel(m)).map(m => toAgentModelInfo(m, this.id)); + if (this._githubToken === tokenAtStart) { + this._models.set(filtered, undefined); + } + } catch (err) { + this._logService.error(err, '[Claude] Failed to refresh models'); + if (this._githubToken === tokenAtStart) { + this._models.set([], undefined); + } + } +} +``` + +### 3.5 Model filter (module-level helpers) + +Two checks combined: CCAModel surface filter + Claude id parser. The id parser elegantly excludes synthetic ids like `auto`: + +```ts +function isClaudeModel(m: CCAModel): boolean { + return ( + m.vendor === 'Anthropic' && + !!m.supported_endpoints?.includes('/v1/messages') && + !!m.model_picker_enabled && + !!m.capabilities?.supports?.tool_calls && + tryParseClaudeModelId(m.id) !== undefined // from claudeModelId.ts + ); +} + +function toAgentModelInfo(m: CCAModel, provider: AgentProvider): IAgentModelInfo { + return { + provider, + id: m.id, + name: m.name, + maxContextWindow: m.capabilities?.limits?.max_context_window_tokens, + supportsVision: !!m.capabilities?.supports?.vision, + }; +} +``` + +Field references (`src/typings/copilot-api.d.ts`): +- `vendor` → line 113 +- `supported_endpoints` → line 115 (optional, use `?.`) +- `model_picker_enabled` → line 140 +- `capabilities.supports.tool_calls` → line 150 +- `capabilities.limits.max_context_window_tokens` → line ~133 + +Verify the exact `IAgentModelInfo` shape against `src/vs/platform/agentHost/common/agentService.ts:204` before writing. Drop fields that don't exist in the interface. + +### 3.6 Stub map + +Every required `IAgent` method that isn't implemented yet throws `new Error('TODO: Phase N')`. Phase numbers from [roadmap.md](./roadmap.md): + +| Method | Phase N | Notes | +|---|---|---| +| `createSession` | 5 | | +| `disposeSession` | 5 | | +| `getSessionMessages` | 5 | Comment: full transcript reconstruction is Phase 13 | +| `listSessions` | 5 | | +| `resolveSessionConfig` | 5 | | +| `sessionConfigCompletions` | 5 | | +| `shutdown` | 5 | | +| `sendMessage` | 6 | | +| `respondToPermissionRequest` | 7 | | +| `respondToUserInputRequest` | 7 | | +| `abortSession` | 9 | | +| `changeModel` | 9 | | +| `setClientTools` | 10 | | +| `onClientToolCallComplete` | 10 | | +| `setClientCustomizations` | 11 | | +| `setCustomizationEnabled` | 11 | | + +**Cross-reference roadmap before committing each Phase N** — exact numbers may have shifted since this plan was written. + +**Optional methods to OMIT** (interface allows): `truncateSession?`, `setPendingMessages?`, `getCustomizations?`, `getSessionCustomizations?`, `onArchivedChanged?`, `onDidCustomizationsChange?`. + +### 3.7 `dispose()` — REAL, not a TODO + +`AgentService.dispose()` calls `provider.dispose()` unconditionally. The `_proxyHandle` is refcounted; failing to dispose leaks the proxy server lifetime. + +```ts +override dispose(): void { + // Phase 6+ INVARIANT: SDK subprocess(es) MUST be killed before disposing + // the proxy handle. In Phase 4 there are no subprocesses, so this is safe. + this._proxyHandle?.dispose(); + this._proxyHandle = undefined; + this._githubToken = undefined; + this._models.set([], undefined); + super.dispose(); +} +``` + +The comment is mandatory — Phase 6 will add SDK spawn and the order matters per `IClaudeProxyHandle` doc at `src/vs/platform/agentHost/node/claude/claudeProxyService.ts:33`. + +## 4. Registration changes + +Registration is **gated** so users on a default install never see a stub provider. The contract is: + +``` +[workbench] [agent host process] +chat.agentHost.claudeAgent.enabled ---> VSCODE_AGENT_HOST_ENABLE_CLAUDE=1 ---> registerProvider(ClaudeAgent) +``` + +- The setting key and env-var name live in `src/vs/platform/agentHost/common/agentService.ts` (`AgentHostClaudeAgentEnabledSettingId`, `AgentHostEnableClaudeEnvVar`) so both sides reference the same string. +- The setting is registered in `src/vs/workbench/contrib/chat/browser/chat.contribution.ts` next to `AgentHostEnabledSettingId`: `type: 'boolean'`, `default: false`, `tags: ['experimental', 'advanced']`, `included: product.quality !== 'stable'`. +- The env var also acts as a developer override: setting it on the parent process (e.g. in `launch-smoke.sh`) opts the agent host in regardless of the workbench setting. +- Changes require an agent host restart — the env var is captured at process spawn time. The setting description must say so. + +### 4.1 `chat.contribution.ts` — register the setting + +Import `AgentHostClaudeAgentEnabledSettingId` next to the existing agent-host setting ids and add a property to the `chat` configuration block: + +```ts +[AgentHostClaudeAgentEnabledSettingId]: { + type: 'boolean', + description: nls.localize('chat.agentHost.claudeAgent.enabled', "When enabled, the Claude agent provider is registered inside the agent host. Requires `#chat.agentHost.enabled#`. The agent host process must be restarted for changes to this setting to take effect."), + default: false, + tags: ['experimental', 'advanced'], + included: product.quality !== 'stable', +}, +``` + +### 4.2 Both starters — forward setting → env var + +**`electronAgentHostStarter.ts`** (utility process path). Inside `start()` before `utilityProcess.start({...})`: + +```ts +const claudeEnabled = this._configurationService.getValue(AgentHostClaudeAgentEnabledSettingId) + || process.env[AgentHostEnableClaudeEnvVar] === '1'; + +this.utilityProcess.start({ + // ... + env: { + ...deepClone(process.env), + ...shellEnv, + VSCODE_ESM_ENTRYPOINT: 'vs/platform/agentHost/node/agentHostMain', + VSCODE_PIPE_LOGGING: 'true', + VSCODE_VERBOSE_LOGGING: 'true', + ...(claudeEnabled ? { [AgentHostEnableClaudeEnvVar]: '1' } : {}), + } +}); +``` + +**`nodeAgentHostStarter.ts`** (Node child-process fallback). Same precedence (setting OR inherited env var), written into the `env` object before the `IIPCOptions` is constructed. + +### 4.3 `agentHostMain.ts` (already has DI for the prerequisites) + +`ICopilotApiService` and `IClaudeProxyService` are already registered at lines 110–112. Add one more `registerProvider` call after `CopilotAgent`, **gated on the env var**: + +```ts +agentService.registerProvider(instantiationService.createInstance(CopilotAgent)); +if (process.env[AgentHostEnableClaudeEnvVar] === '1') { + agentService.registerProvider(instantiationService.createInstance(ClaudeAgent)); +} +``` + +Add the imports: `ClaudeAgent` from `./claude/claudeAgent.js` and `AgentHostEnableClaudeEnvVar` from `../common/agentService.js`. + +### 4.4 `agentHostServerMain.ts` (DI bare — add three things, then gate) + +Currently this file does NOT register `ICopilotApiService` or `IClaudeProxyService`. Inside the `if (!options.quiet)` block (around line 188), before `instantiationService.createInstance(CopilotAgent)`: + +```ts +const copilotApiService = instantiationService.createInstance(CopilotApiService, undefined); +diServices.set(ICopilotApiService, copilotApiService); +const claudeProxyService = disposables.add(instantiationService.createInstance(ClaudeProxyService)); +diServices.set(IClaudeProxyService, claudeProxyService); +``` + +Then register Claude conditionally. The standalone server adds a CLI flag `--enable-claude-agent` (mirroring `--enable-mock-agent`) which OR's with the env var: + +```ts +// In parseServerOptions(): +const enableClaudeAgent = argv.includes('--enable-claude-agent') || process.env[AgentHostEnableClaudeEnvVar] === '1'; + +// Inside the !options.quiet block, after CopilotAgent registration: +if (options.enableClaudeAgent) { + const claudeAgent = disposables.add(instantiationService.createInstance(ClaudeAgent)); + agentService.registerProvider(claudeAgent); + log('ClaudeAgent registered'); +} +``` + +Update the `IServerOptions` interface, the file-header usage comment, and `parseServerOptions()` return value accordingly. + +Imports needed (mirror `agentHostMain.ts` lines ~20–25): +- `CopilotApiService`, `ICopilotApiService` from `./shared/copilotApiService.js` +- `ClaudeProxyService`, `IClaudeProxyService` from `./claude/claudeProxyService.js` +- `ClaudeAgent` from `./claude/claudeAgent.js` +- `AgentHostEnableClaudeEnvVar` from `../common/agentService.js` + +**`undefined` second arg to `CopilotApiService` is intentional** — that's the `fetchFn` slot, and `agentHostMain.ts:110` does the same. + +## 5. Test file spec + +New file: `src/vs/platform/agentHost/test/node/claudeAgent.test.ts`. Mirror the harness style of `src/vs/platform/agentHost/test/node/copilotAgent.test.ts` (read lines 233–262 for the `createTestAgentContext` pattern). + +**Mock services (no extensive mocking — minimal stand-ins):** +- `IClaudeProxyService` mock: `start(token)` returns `{ baseUrl: 'http://127.0.0.1:0', nonce: 'test-nonce', dispose: () => disposeCount++ }`. Track call count and last token. +- `ICopilotApiService` mock: `models(token)` returns a canned `CCAModel[]` mixing Anthropic + non-Anthropic + non-tool-calls + non-picker. + +**Test cases (use `assert.deepStrictEqual` snapshots per repo guidelines):** + +1. `getDescriptor()` returns `{ provider: 'claude', displayName: 'Claude', description: ... }`. +2. `getProtectedResources()` matches the GitHub resource shape. +3. Models observable is empty before `authenticate`. +4. `authenticate('https://api.github.com', token)` returns `true`, calls `start(token)`, populates models with only the Claude-family entries, in correct `IAgentModelInfo` shape. +5. `authenticate('https://other.example.com', token)` returns `false`; a follow-up `authenticate('https://api.github.com', token)` still works (proxy `start` called exactly once total). Catches implementations that early-return `false` after corrupting state. +6. Calling `authenticate` twice with the **same** token does NOT call `start()` again (token unchanged path). +7. Calling `authenticate` with a **different** token: new `start(tokenB)` was called AND old handle was disposed. +8. Filter excludes: non-Anthropic vendor (e.g. `vendor: 'copilot'` for the synthetic `auto` model — verified at `extensions/copilot/src/extension/conversation/vscode-node/chatParticipants.ts:312`), missing `/v1/messages` endpoint, `model_picker_enabled: false`, `tool_calls: false`, unparseable Claude id (e.g. id `'auto'` would also fail `tryParseClaudeModelId`). +9. `AgentSession.uri('claude', 'abc')` round-trips: scheme `'claude'`, `AgentSession.id()` returns `'abc'`, `AgentSession.provider()` returns `'claude'`. +10. `dispose()` disposes the proxy handle; second `dispose()` is idempotent. +11. Each stubbed method (sample 3–4) throws an `Error` whose message contains `'TODO: Phase'` and the right number. +12. **Registration smoke:** instantiate `AgentService` + register `ClaudeAgent` + assert it appears in root state via the public service surface. +13. **Stale-write guard:** if a slow `models(tokA)` call resolves *after* `authenticate(tokB)` has already published `[B]`, the late `[A]` result must be discarded. + Use `DeferredPromise` from [src/vs/base/common/async.ts](src/vs/base/common/async.ts#L1739) to control the resolution order: + ```ts + const tokAModels = new DeferredPromise(); + mockApi.models = (token: string) => token === 'tokA' ? tokAModels.p : Promise.resolve([CLAUDE_MODEL_B]); + + void agent.authenticate('https://api.github.com', 'tokA'); // refresh-A starts, hangs on tokAModels.p + await agent.authenticate('https://api.github.com', 'tokB'); // refresh-B runs to completion, models == [B] + + tokAModels.complete([CLAUDE_MODEL_A]); // refresh-A unblocks; guard must drop the write + await new Promise(r => setImmediate(r)); + + assert.deepStrictEqual(agent.models.get().map(m => m.id), [CLAUDE_MODEL_B.id]); + ``` + Codifies the invariant that the in-tree `CopilotAgent` already relies on but doesn't currently test. + +Use `ensureNoDisposablesAreLeakedInTestSuite()` at the top. + +## 6. Risks / gotchas + +| Risk | Mitigation | +|---|---| +| Older roadmap revisions said "start proxy in constructor" — that's wrong. `start()` requires a token. | This plan and the current `roadmap.md` agree: defer to `authenticate()`. If you find a contradicting note elsewhere, this plan + Phase 2's `claudeProxyService.ts:61` win. | +| `agentHostServerMain.ts` lacks DI for `ICopilotApiService`/`IClaudeProxyService` — server mode would crash. | Add both registrations as in §4.4. | +| `CCAModel.supported_endpoints` is `string[] \| undefined` — direct `.includes()` will throw. | Use `?.includes()` everywhere. | +| `_refreshModels` writes after `await` — token may have rotated. | Snapshot `_githubToken` at start, gate the write on equality. (Mirrors CopilotAgent.) | +| `_proxyHandle.dispose()` is the ONLY way to release the proxy refcount — leaving `dispose()` as a TODO leaks. | Implement real `dispose()` in this phase; comment Phase 6 ordering invariant. | +| `IClaudeProxyService` constructor requires `ICopilotApiService` (verified Phase 2). | Register `ICopilotApiService` first in `agentHostServerMain.ts`. | +| Test assertions after `authenticate` are async-sensitive (model fetch is a promise). | For tests that just need the refresh to settle: `await new Promise(r => setImmediate(r))` to drain microtasks. For tests that need to *control* refresh ordering (e.g. stale-write guard): use `DeferredPromise` from [async.ts](src/vs/base/common/async.ts#L1739) on the mock `models()` call. Avoid timer-based waits. | +| Disposable leak detection: mock `IClaudeProxyHandle` should not register with the test store unless tracked. | Use a lightweight object literal — its `dispose` is a closure, not a disposable. | +| Forgetting to gate — default-on registration would expose `TODO: Phase N` errors to every Insiders user. | Both `agentHostMain.ts` and `agentHostServerMain.ts` MUST gate on `process.env[AgentHostEnableClaudeEnvVar] === '1'`. Smoke without setting the env var: only `copilotcli` should appear in root state. | +| Setting flipped at runtime doesn't take effect. | The env var is captured at agent-host process spawn. Reload the window (or kill the host) to apply. The setting description must say so. | +| Smoke run claims "ClaudeAgent didn't register" — actually the env var was lost. | `launch-smoke.sh` exports `VSCODE_AGENT_HOST_ENABLE_CLAUDE=1` for the operator. The Electron starter forwards via `...deepClone(process.env)` then merges the explicit env var; the Node starter copies it explicitly into the spawned `env`. | + +## 7. Acceptance criteria + +The PR is **done** when every box below is checked. Run them in order — earlier failures invalidate later checks. + +### 7.1 Code structure + +- [ ] `src/vs/platform/agentHost/node/claude/claudeAgent.ts` exists with `export class ClaudeAgent extends Disposable implements IAgent`. +- [ ] `id = 'claude'` (not `'claude-code'`, not `'copilotcli'`). +- [ ] Constructor takes exactly `@ILogService`, `@ICopilotApiService`, `@IClaudeProxyService` (no other deps). +- [ ] All 16 stubbed methods (§3.6) throw `Error('TODO: Phase N')` with the correct N. +- [ ] No optional methods are present (`truncateSession?`, `getCustomizations?`, etc.). +- [ ] `dispose()` is real (not a TODO) and disposes `_proxyHandle`. +- [ ] Phase-6 subprocess-ownership invariant is in a code comment near `dispose()`. +- [ ] Microsoft copyright header is on every new file. + +### 7.2 Registration & gating + +- [ ] `AgentHostClaudeAgentEnabledSettingId` and `AgentHostEnableClaudeEnvVar` are exported from `src/vs/platform/agentHost/common/agentService.ts`. +- [ ] `chat.contribution.ts` registers `chat.agentHost.claudeAgent.enabled` with `default: false`, `tags: ['experimental', 'advanced']`, and `included: product.quality !== 'stable'`. +- [ ] Setting `description` notes the agent host must restart for changes to take effect. +- [ ] Both `electronAgentHostStarter.ts` and `nodeAgentHostStarter.ts` forward the setting (or inherited env var) as `VSCODE_AGENT_HOST_ENABLE_CLAUDE=1`. +- [ ] `agentHostMain.ts` registers `ClaudeAgent` ONLY when `process.env[AgentHostEnableClaudeEnvVar] === '1'` — verified by toggling the env var and observing root state. +- [ ] `agentHostServerMain.ts` registers `ICopilotApiService`, `IClaudeProxyService`, AND `ClaudeAgent` (was missing all three). `ClaudeAgent` registration is gated on `--enable-claude-agent` OR the env var; the file-header usage line documents the new flag. + +### 7.3 Compile + lint + layers + +- [ ] `VS Code - Build` task shows zero TypeScript errors. If task is unavailable, `npm run compile-check-ts-native` exits 0. +- [ ] `npm run eslint -- src/vs/platform/agentHost/node/claude/claudeAgent.ts src/vs/platform/agentHost/test/node/claudeAgent.test.ts` exits 0. +- [ ] `npm run valid-layers-check` exits 0. +- [ ] `npm run hygiene` exits 0. + +### 7.4 Tests + +- [ ] `src/vs/platform/agentHost/test/node/claudeAgent.test.ts` exists with all 13 cases from §5. +- [ ] `ensureNoDisposablesAreLeakedInTestSuite()` is at the top of the suite. +- [ ] `scripts/test.sh --grep ClaudeAgent` exits 0 — every case passes. +- [ ] No test uses `as any` or `as unknown as Foo` casts. +- [ ] No test depends on real network or real subprocesses. + +### 7.5 Behavioral exit criteria (matches §1) + +These are the *outcomes* the unit tests verify. §7.8 is the live-system smoke that proves they hold end-to-end. + +- [ ] **Default-off:** with no setting and no env var, a workbench client connecting to the agent host sees only `'copilotcli'` in root state — no `'claude'` provider. +- [ ] **Opt-in:** with `chat.agentHost.claudeAgent.enabled: true` (or `VSCODE_AGENT_HOST_ENABLE_CLAUDE=1` set on the parent process) and a fresh agent host, the client sees `'claude'` listed alongside `'copilotcli'`. +- [ ] After authenticate, the client sees Anthropic models in the picker (filtered by §3.5 predicate). +- [ ] Calling `sendMessage` on a Claude session throws `Error('TODO: Phase 6')` (or whatever §3.6 maps to in the current roadmap). + +### 7.6 PR readiness + +- [ ] PR title: `agentHost/claude: Phase 4 — ClaudeAgent skeleton`. +- [ ] PR description links to [roadmap.md](./roadmap.md) Phase 4 section and notes the exit criteria are met. +- [ ] PR description lists the stubbed methods + their target phase as a table. +- [ ] PR is opened as draft until the build passes; promote when green. + +### 7.7 What to do if a step fails + +| Failure | Likely cause | First debugging step | +|---|---|---| +| Compile error on `IAgentModelInfo` | Added a non-existent field (e.g. `family`) | Re-read `src/vs/platform/agentHost/common/agentService.ts:204`; drop unsupported fields. | +| Compile error on `supportsVision` | Returned `boolean \| undefined` | Coerce: `!!m.capabilities?.supports?.vision`. | +| `valid-layers-check` fails | Imported from a higher layer (`vs/workbench/`, `vs/sessions/`) by accident | Check imports — only `vs/base`, `vs/platform`, `vs/typings` allowed. | +| Test 13 (stale-write) flakes | Microtask draining timing | Replace timer waits with `DeferredPromise` resolution + `await new Promise(r => setImmediate(r))`. | +| `dispose()` test leaks | Mock proxy handle counted as a tracked disposable | Plain object literal, not `Disposable` subclass. | +| `agentHostServerMain.ts` server crashes at startup | DI registration order | Register `ICopilotApiService` BEFORE `IClaudeProxyService`. | +| `Cannot find module './claude/claudeAgent.js'` | Forgot the `.js` extension on the import | All in-tree imports use `.js` even for `.ts` source files. | +| Smoke logs show `CopilotAgent registered` but no `ClaudeAgent registered` | Setting off, env var not forwarded, or starter not reading the setting | Confirm `chat.agentHost.claudeAgent.enabled: true` in the user-data-dir's `settings.json`, OR that `launch-smoke.sh` exported `VSCODE_AGENT_HOST_ENABLE_CLAUDE=1`. Restart the agent host — the env var is captured at spawn. | +| Setting flipped in the UI but `'claude'` still missing | Env var is captured at agent host spawn time | Reload the window or kill the agent host process so the starter re-spawns with the new env. | + +### 7.8 Live-system smoke (mandatory before merging) + +This is the proof Phase 4 actually ships. The unit tests prove the class is wired correctly in isolation; the smoke proves it boots, registers, and surfaces in the UI when an authenticated user opens the Agents app. + +**Follow [`smoke.md`](./smoke.md).** It is a streamlined, repeatable plan distilled from the live walk that originally informed this section. The boilerplate is captured in two helper scripts under [`./scripts/`](./scripts/): + +- `launch-smoke.sh` — boots the Agents app on a chosen CDP port with the right env + flags. +- `verify-claude-logs.sh` — runs the five log-level invariants (registration, auth fan-out, proxy startup, root-state propagation, model filter) and saves evidence to `/tmp/claude-phase4-smoke//`. + +For Phase 4 specifically, the plan's per-phase table requires: + +- [ ] **Gate verified disabled:** launch the Agents app *without* the env var (and with the setting off) and confirm only `CopilotAgent registered` appears in `agenthost.log` — no `ClaudeAgent registered`, no `'claude'` provider in root state. +- [ ] **Gate verified enabled:** re-launch via `launch-smoke.sh` (which sets `VSCODE_AGENT_HOST_ENABLE_CLAUDE=1`) and confirm both providers register. +- [ ] At least one `claude:/` session URI appears in the IPC log after the user picks Claude (the session URI scheme is `claude:`, **not** `agent-host-claude:` — the longer form is the synced-customization namespace, observable separately). +- [ ] The first user prompt surfaces `TODO: Phase 5` in the response area. (`createSession` is the earliest stub on the path; `sendMessage` is reached only after `createSession` succeeds, which lands in Phase 5.) +- [ ] Attach `registration.log`, `picker-open.png`, `stub-error.png`, and `claude-session-uris.log` to the PR. + +If any step in §7.8 fails, the PR is **not** ready regardless of whether §7.1–7.7 are green. + +## 8. Resolved decisions + +**Should `dispose()` await an in-flight `_refreshModels`?** +No. `dispose()` clears `this._githubToken` *before* `super.dispose()`, so any hung `_refreshModels` will, on resume, see `this._githubToken !== tokenAtStart` and drop its write — making the in-flight refresh a no-op. Test 13 (stale-write guard) codifies this invariant. Fire-and-forget; matches `CopilotAgent`. + +**Why is registration gated on a setting when the spec only said "register"?** +Phase 4 is a stub. Every user-facing method (`createSession`, `sendMessage`, `respondToPermissionRequest`, …) throws `TODO: Phase N`. Shipping it default-on would mean Insiders users who pick Claude in the picker hit a `TODO: Phase 5` error on their first prompt. Default-off + a setting + a developer env var keeps Phase 4 testable without exposing it broadly. The setting is `included: product.quality !== 'stable'` so it is hidden from stable installs entirely. Flip the default to `true` (or remove the gate) only once the user-facing methods stop throwing — the natural milestone is Phase 6 (`sendMessage` lands). + +**Workbench setting vs. agent-host root config (`IAgentConfigurationService`)?** +Workbench setting. Root config is for runtime / per-session knobs that flow through the IPC protocol; this is a feature flag for whether a provider exists at all, decided at process spawn. Mirrors the precedent set by `chat.agentHost.enabled` (which gates whether the *whole* agent host process spawns). diff --git a/src/vs/platform/agentHost/node/claude/roadmap.md b/src/vs/platform/agentHost/node/claude/roadmap.md index df833c15b2c15..1b7f11699dd03 100644 --- a/src/vs/platform/agentHost/node/claude/roadmap.md +++ b/src/vs/platform/agentHost/node/claude/roadmap.md @@ -65,7 +65,8 @@ roadmap. settings/tools change, MCP gateway with idle timer, restart-on-toggle for customizations. **Caveats:** - Uses `_abortController.abort()` (line 719), not `Query.interrupt()` — - verify in Phase 3 spike which mechanism actually cancels the SDK turn. + Phase 9 should mirror this. `Query.interrupt()` exists in 0.2.112 but + is not used by the production reference. - Uses `mcpServers` config for HTTP-based MCP (lines 391–416) — does **not** use `createSdkMcpServer` + `tool()`. The in-process tool path lives in `extensions/copilot/src/extension/chatSessions/claude/common/mcpServers/ideMcpServer.ts`. @@ -223,43 +224,128 @@ Exit criteria: `curl` against the proxy with `Bearer .test` returns the same payload shape Anthropic would, and `ICopilotApiService` sees the right calls. -### Phase 3 — Claude Agent SDK integration spike - -A throw-away spike that proves the SDK can be pointed at `IClaudeProxyService` -and complete a tool-using turn end-to-end. **Not** an `IAgent` implementation -yet. +### Phase 3 — Ground the SDK contract in the production reference ✅ **DONE** + +The Copilot extension already ships a working integration of +`@anthropic-ai/claude-agent-sdk` 0.2.112 with a local proxy. That implementation +is our highest-fidelity evidence for what the SDK does and does not need; an +ad-hoc spike cannot beat a production user. + +**Reference files** (read these before touching Phase 4): + +- [`extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts`](../../../../../../extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts) + — `ClaudeCodeSession` builds the SDK `Options` and runs the message loop. +- [`extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts`](../../../../../../extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts) + — `ClaudeLanguageModelServer`, the extension's analogue of our + `ClaudeProxyService`. Implements `/v1/messages` (only), filters + `anthropic-beta` to a whitelist (`interleaved-thinking`, + `context-management`, `advanced-tool-use`), and intentionally ignores + `x-api-key` to prevent personal-key leakage. +- [`extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeSdkService.ts`](../../../../../../extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeSdkService.ts) + — DI wrapper around the SDK with lazy `import()`. + +**Use the extension as a reference, not a blueprint.** It has accreted ~20 +concerns (MCP gateway, plugins, edit tracker, settings change tracker, OTel +forwarding, hook events, debug file logger, ripgrep PATH munging, runtime data +caching, folder MRU, …) that are *layered on top of* the core SDK ↔ proxy +contract. Phase 4 should start with the smallest `Options` that produces a +working turn, and pull in each additional concern only when a phase actually +needs it. Adopting the full extension shape on day one would obscure which +pieces are essential and make incremental review impossible. **SDK version:** pin `@anthropic-ai/claude-agent-sdk` at **`0.2.112`** — the -same version currently in `extensions/copilot/package.json`. Do **not** -upgrade yet; versions > 0.2.112 introduce native binary dependencies that -require additional build infrastructure (see Phase 15). - -Goals: - -- Confirm the SDK actually routes every messages call through - `ANTHROPIC_BASE_URL` (no leaks to api.anthropic.com). -- Confirm the SSE event sequence the proxy emits is byte-compatible with what - the SDK expects. -- Confirm model-ID resolution works end-to-end. -- **Validate which abort mechanism actually works:** `Query.interrupt()` vs. - AbortController on the underlying fetch. The reference uses the - AbortController approach; verify whether `Query.interrupt()` even exists - on the SDK version we pin. -- **Validate `enableFileCheckpointing` and `Query.rewindFiles()` exist** in - the SDK version we pin (they're not in the reference; may be newer-only). - If absent, Phase 8 needs a different mechanism. -- Smoke `Query.setModel()`, `Query.setPermissionMode()`, `Query.streamInput()`. -- Identify any SDK behaviors not anticipated. - -Deliverable: a `node/claude/scripts/spike.ts` plus a short findings note -appended to this roadmap. **Code thrown away**, learnings captured. Spike -findings update Phase 6/8/9 plans. - -Exit criteria: an SDK-driven session completes one turn through the proxy, -including at least one tool call round-trip, with no traffic to anthropic.com. +same version `extensions/copilot/package.json` ships. Versions > 0.2.112 add +native binary dependencies that require build-infra changes (see Phase 15). + +#### Required for Phase 4 to function at all + +These are non-negotiable: omit any of them and either the SDK errors out, the +proxy can't authenticate, the agent host has no cancellation contract, or +egress leaks. Each row cites the production reference so future readers can +see why it's required. + +| `Options` field | Value (Phase 4 start) | Why required | Reference | +|---|---|---|---| +| `cwd` | session workspace folder | SDK validates and forwards to tools (Read/Write/Bash). | `claudeCodeAgent.ts` `_doStartSession` | +| `executable` | `process.execPath as 'node'` | Force the SDK to fork the agent host's node process; otherwise it tries to find its own runtime. | `claudeCodeAgent.ts` line 437 | +| `abortController` | per-session `AbortController` | The mechanism the extension actually uses to cancel a turn. `Query.interrupt()` exists but is not used in production. Phase 6 should mirror this. | `claudeCodeAgent.ts` line 138, 274, 435 | +| `allowDangerouslySkipPermissions` | `true` | Disables the SDK's built-in approval UI so we can drive permissions ourselves via `canUseTool`. The two are a *pair* — one without the other is broken. | `claudeCodeAgent.ts` line 433 | +| `canUseTool` | callback into `IClaudeToolPermissionService`-equivalent | Real per-tool permission UX. Phase 4 may stub to `{ behavior: 'allow' }` and defer the real UX to Phase 9. | `claudeCodeAgent.ts` line 463 | +| `model` | `` from session state | Required for any meaningful turn. Resolve the canonical Anthropic ID via the model registry. | `claudeCodeAgent.ts` line 442 | +| `permissionMode` | session permission mode | Required (default `'acceptEdits'` in the extension). | `claudeCodeAgent.ts` line 444 | +| `systemPrompt` | `{ type: 'preset', preset: 'claude_code' }` | Without this the SDK has no system prompt at all and behavior degrades. | `claudeCodeAgent.ts` line 482 | +| `settings.env.ANTHROPIC_BASE_URL` | `http://localhost:${proxy.port}` | Routes all CAPI traffic through our proxy. **Note:** under `settings.env`, NOT top-level `Options.env`. | `claudeCodeAgent.ts` line 454 | +| `settings.env.ANTHROPIC_AUTH_TOKEN` | `${proxy.nonce}.${sessionId}` | Per-session bearer; proxy splits at `.` to recover session id. | `claudeCodeAgent.ts` line 455 | +| `settings.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` | `'1'` | Disables Anthropic-direct telemetry/feature flags. Required for our leak-tightness guarantees. | `claudeCodeAgent.ts` line 456 | +| `disallowedTools` | `['WebSearch']` | CAPI doesn't support WebSearch; the SDK will error if invoked. | `claudeCodeAgent.ts` line 440 | +| `stderr` | wire to `ILogService.error` | Without it, SDK errors are invisible. | `claudeCodeAgent.ts` line 487 | + +#### Deferred to later phases + +Each of these has a clear home; pulling them in earlier than necessary just +expands the Phase 4 surface area for no review benefit. + +| Concern | Phase | Production reference | +|---|---|---| +| `settingSources` (load CLAUDE.md / hooks / agents from disk) | Phase 9 (settings/permissions UX). Phase 4 starts with `[]` for SDK isolation. | `claudeCodeAgent.ts` line 486 | +| `mcpServers` (per-session MCP gateway) | Phase 7 (tool integration) | `claudeCodeAgent.ts` line 472 | +| `plugins` (skill plugin locations) | Defer until skills are in scope | `claudeCodeAgent.ts` line 473 | +| `additionalDirectories` (multi-root) | When multi-root support is needed | `claudeCodeAgent.ts` line 432 | +| `effort` (reasoning controls) | When reasoning effort UX exists | `claudeCodeAgent.ts` line 436 | +| `resume` / `sessionId` | Phase 5 (persistence) | `claudeCodeAgent.ts` lines 446–448 | +| `includeHookEvents: true` | When hooks are exposed | `claudeCodeAgent.ts` line 471 | +| OTel env (`deriveClaudeOTelEnv`) | When OTel is wired in agent host | `claudeCodeAgent.ts` line 460 | +| Bundled ripgrep on `PATH` + `USE_BUILTIN_RIPGREP=0` | When bundled ripgrep is needed | `claudeCodeAgent.ts` lines 457–458 | +| `enableFileCheckpointing` + `Query.rewindFiles()` | Phase 8 (checkpoints). Type definitions confirm both exist in 0.2.112; the extension does not use them, so Phase 8 must validate them itself. | `sdk.d.ts` lines 1105, 1280 | +| Edit tracker, settings change tracker, runtime data cache, folder MRU, debug file logger | Workbench-side concerns; out of scope for the agent host core | `claudeCodeAgent.ts` (assorted) | + +#### One genuine open question + +**Byte-equivalence between our `ClaudeProxyService` and the extension's +`ClaudeLanguageModelServer`.** The extension can't answer this for us; both +proxies must produce streams the SDK accepts, but they're different +implementations and could diverge on edge cases (`input_json_delta` shape, +`message_delta.usage`, error-frame format). Closing path: + +- Phase 4 lands a unit test that points the SDK (with a stubbed CAPI) at + `ClaudeProxyService` and asserts the same `system/init → assistant → + user(tool_result) → assistant → result` sequence the extension's tests + assert. Lives in the repo and runs in CI. +- No throw-away spike required. + +#### Notes on items the extension does differently from our Phase 2 design + +These are not bugs — just places the extension and our proxy currently +diverge. Phase 4 should decide whether to converge. + +- **`/v1/models`:** the extension's proxy 404s it; ours forwards to + `ICopilotApiService.models()`. Either is fine in practice; the SDK does + not require `/v1/models` to function. Keeping ours is harmless. +- **`/v1/messages/count_tokens`:** the extension's proxy 404s it; ours + returns 501. The SDK does not call `count_tokens` during a normal turn, + so neither matters in practice. If a future SDK version starts calling + it, both proxies will need real support. +- **`anthropic-beta` whitelist:** the extension whitelists + `interleaved-thinking`, `context-management`, `advanced-tool-use`. Our + Phase 2 design only mentioned `interleaved-thinking`. Phase 4 should + widen the whitelist to match. +- **`x-api-key` header:** the extension intentionally ignores it to prevent + the user's personal Anthropic key from leaking through our proxy. Our + Phase 2 design already enforces nonce-only auth; matching the + "explicitly ignore" behavior is a defense-in-depth improvement. + +Exit criteria: this section captures (a) the required `Options` shape for +Phase 4, (b) the deferred-concerns map for later phases, and (c) the one +remaining open question (byte-equivalence) with a concrete plan to close it +in Phase 4. No throw-away code committed. ### Phase 4 — `ClaudeAgent` skeleton implementing `IAgent` +> **Implementation contract: [phase4-plan.md](./phase4-plan.md).** That file +> is the source of truth for the Phase 4 PR — code skeleton, registration +> sites, full test list, acceptance checklist, and live-system smoke. The +> summary below stays high-level for roadmap continuity. + A registered `IAgent` whose lifecycle methods are wired but minimal. Mirror the pattern in `node/copilot/copilotAgent.ts`. @@ -283,8 +369,12 @@ Scope (just enough surface for the agent to be discoverable): declares. - `authenticate(resource, token)` — store the GitHub token, push it into the proxy's GitHub-token slot. -- **Spin up the `IClaudeProxyService` once at agent construction time** (one - proxy per agent, shared across sessions). +- **Lazily acquire `IClaudeProxyService` handle inside `authenticate()`** (not + in the constructor). `IClaudeProxyService.start()` requires a non-empty + github token (`claudeProxyService.ts:61`), so eager construction is + impossible. The handle is refcounted; one outstanding handle per agent is + the right shared-proxy pattern. See [phase4-plan.md](./phase4-plan.md) §3.3 + for the acquire-then-dispose-old ordering. - **Strip `ANTHROPIC_API_KEY`** from any spawned SDK subprocess env. - `models` observable — derived from `ICopilotApiService.models()`, filtered to Claude-family models. @@ -404,8 +494,11 @@ SDK options pinned in this phase (matching the reference): Phase 11 customization-hooks wiring depends on receiving these events. - `spawnClaudeCodeProcess`: **not overridden** (Agent Host is the isolation boundary). -- `enableFileCheckpointing: true` if the spike confirms it exists; otherwise - defer to Phase 8 to find an alternative. +- `enableFileCheckpointing: true` — type definitions confirm it exists in + 0.2.112 (`sdk.d.ts:1105`). The production reference does not use it, so + Phase 8 must validate the actual checkpointing/rewind behavior before + committing to it. If it doesn't behave as advertised, fall back to the + in-agent edit-history mechanism described in Phase 8. - **`systemPrompt: { type: 'preset', preset: 'claude_code' }`** — match `claudeCodeAgent.ts:478`. **Not** `tools: { type: 'preset' }` (that was a v1 roadmap error). @@ -467,12 +560,14 @@ Build the Claude analog of `fileEditTracker.ts` from `node/copilot/`. client can render diffs, accept/reject per-file, and undo. - Per-file undo: client-driven, **not** part of `truncateSession`. - **Undo mechanism:** - - **If Phase 3 spike confirms `enableFileCheckpointing` + `Query.rewindFiles` - exist:** use them, but the surface stays per-file (the SDK rewinds all - files; we apply that selectively). - - **If they don't exist:** record file-edit history in the agent itself - and restore via direct file writes. Document this as a fallback in the - Phase 3 findings. + - **Preferred:** `enableFileCheckpointing` + `Query.rewindFiles()` (both + exist in 0.2.112 per `sdk.d.ts:1105, 1280`). Surface stays per-file — + the SDK rewinds all tracked files; we apply that selectively to honor + per-file accept/reject. + - **Fallback if rewind misbehaves:** record file-edit history in the + agent itself and restore via direct file writes. The production + reference does not exercise SDK rewind, so Phase 8 owns the + validation step. - **Known gap:** Bash-tool edits (`sed -i`, `cat > file`) aren't tracked by the SDK. Document; consider a file-watcher diff fallback in a follow-up. @@ -484,9 +579,11 @@ works. ### Phase 9 — Abort + steering + model change + shutdown polish -- **`abortSession`** — cancel the underlying SDK turn. The reference uses - `_abortController.abort()`; if Phase 3 spike confirms `Query.interrupt()` - also works, prefer it (cleaner). Propagates through SDK → proxy → +- **`abortSession`** — cancel the underlying SDK turn via + `_abortController.abort()`, matching the production reference. Phase 9 + may experiment with `Query.interrupt()` if the abort path turns out to + orphan the subprocess, but the default plan is the AbortController route + the extension already proves works. Propagates through SDK → proxy → `ICopilotApiService`. - **Steering / `setPendingMessages`** — use `Query.streamInput()` to push additional `SDKUserMessage`s mid-turn. @@ -659,9 +756,9 @@ in place. - Audit the native dependency: determine the addon's platform matrix, verify the agent-host build pipeline can package and code-sign it for all supported targets (win32-x64, darwin-x64, darwin-arm64, linux-x64). -- Validate the upgraded SDK against the full Phase 3 spike test matrix - (`Query.*` API surface, `enableFileCheckpointing`, `Query.rewindFiles`, - `Query.interrupt`). +- Validate the upgraded SDK against the full Phase 6–13 integration test + matrix (`Query.*` API surface, `enableFileCheckpointing`, + `Query.rewindFiles`, `Query.interrupt`). - Update `agentHost/package.json` (or the shared platform `package.json`) to the new version and update any API callsites that changed between 0.2.112 and the target version. @@ -680,10 +777,14 @@ native dependency is packaged in all production builds. signature. - **Model ID translation location** — tentatively the proxy. Confirm in Phase 1.5. -- **`Query.interrupt()` vs `_abortController.abort()`** — which actually - cancels a turn? Phase 3 spike answers. -- **`enableFileCheckpointing` / `Query.rewindFiles()` SDK availability** — - Phase 3 spike answers; Phase 8 plan branches. +- **`Query.interrupt()` vs `_abortController.abort()`** — the production + reference uses `_abortController.abort()`; Phase 9 starts there. If the + abort path orphans the subprocess in practice, Phase 9 evaluates + `Query.interrupt()` as a follow-up. +- **`enableFileCheckpointing` / `Query.rewindFiles()` runtime behavior** — + type definitions confirm both exist in 0.2.112; the production + reference does not exercise them. Phase 8 owns the validation step + before committing to the rewind-based undo path. - **ZodSchema generation strategy for client tools** — Phase 10. - **MCP gateway idle timeout default** — Phase 10. - **Transcript cache invalidation key** — diff --git a/src/vs/platform/agentHost/node/claude/smoke.md b/src/vs/platform/agentHost/node/claude/smoke.md new file mode 100644 index 0000000000000..bb9705d7af0f4 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/smoke.md @@ -0,0 +1,221 @@ +# Claude Agent — live-system smoke plan + +A streamlined, repeatable smoke test for the `ClaudeAgent` IAgent provider. +Use this whenever a phase changes the boot path, the registration code in +`agentHostMain.ts` / `agentHostServerMain.ts`, the model filter in +`isClaudeModel`, or the GitHub-token plumbing through `IClaudeProxyService`. + +It encodes everything we learned during the Phase 4 live walk so future runs +are deterministic. The two helper scripts under `./scripts/` capture the +boilerplate (launching the app, verifying the logs); the playwright steps +are still operator-driven because they depend on snapshot refs that change +between runs. + +## When to run + +| Phase | What this plan must continue to prove | +|-------|--------------------------------------| +| 4 (skeleton) | Both providers register; auth reaches `ClaudeAgent`; proxy binds; models surface; first user prompt throws `TODO: Phase 5` (the `createSession` stub fires before `sendMessage`). | +| 5 (sessions) | Same as above PLUS `createSession` succeeds; first user prompt throws `TODO: Phase 6`. | +| 6 (sendMessage) | Same as above PLUS prompt produces SDK output. | +| 7+ | Add per-phase assertion to the table in §6 below. | + +## Prerequisites + +- A fresh build. Confirm via the `VS Code - Build` task or run + `npm run compile-check-ts-native` once. +- `@playwright/cli` available (`npx @playwright/cli --version` should work). +- A real GitHub Copilot login. Models only populate after authenticate, and + the Anthropic catalog is only visible to authenticated Copilot accounts. + The `~/.vscode-oss-sessions-dev` user-data-dir caches login state across + runs, so you only need to sign in once. +- `ClaudeAgent` registration is opt-in. Pick **either**: + - Set `chat.agentHost.claudeAgent.enabled: true` in user settings (the + user-data-dir caches this between runs), **or** + - Rely on `launch-smoke.sh` — it exports + `VSCODE_AGENT_HOST_ENABLE_CLAUDE=1` so the agent host enables the + provider regardless of the workbench setting. + +## 1. Launch + +```bash +./src/vs/platform/agentHost/node/claude/scripts/launch-smoke.sh 9224 +``` + +This handles the `unset ELECTRON_RUN_AS_NODE`, `VSCODE_SKIP_PRELAUNCH=1`, +`--user-data-dir`, `--extensions-dir`, `--remote-debugging-port`, and the +"kill anything squatting on the port" steps. It exits zero once the CDP +port is listening. + +## 2. Verify the agent host wiring (no UI required) + +```bash +./src/vs/platform/agentHost/node/claude/scripts/verify-claude-logs.sh +``` + +Exits non-zero if any of the five log-level invariants fail: + +1. Both `copilotcli` AND `claude` providers registered. +2. `[Claude] Auth token updated` appears (proves `agentService.authenticate` + fans out to every provider that owns the resource — see §3 of + `phase4-plan.md` for why this matters). +3. `[ClaudeProxyService] listening on http://127.0.0.1:`. +4. The root-state IPC log carries a `"provider": "claude"` block. +5. ≥ 1 Claude-family model id (`claude-opus-*`, `claude-sonnet-*`, …) + surfaces in the IPC log — verifies the §3.5 model filter and + `tryParseClaudeModelId`. + +Captured artifacts land in `/tmp/claude-phase4-smoke//`: + +- `registration.log` — both `Registering agent provider: …` lines +- `auth.log` — `[Claude] Auth token …` +- `proxy.log` — `[ClaudeProxyService] listening on …` +- `root-state.log` — the claude block from a `RootStateChanged` event +- `claude-models.log` — sample of model entries +- `claude-session-uris.log` — every `claude:/` URI created + +## 3. Verify the picker UI (operator-driven) + +Attach playwright: + +```bash +for i in 1 2 3 4 5; do + npx @playwright/cli attach --cdp=http://127.0.0.1:9224 2>/dev/null && break + sleep 3 +done +npx @playwright/cli tab-list +``` + +Open the picker: + +```bash +npx @playwright/cli snapshot +# Find the ref: +SNAP=$(ls -t .playwright-cli/page-*.yml | head -1) +grep -nE 'Pick Session Type' "$SNAP" +# → e.g. "Pick Session Type, Copilot CLI" [ref=eXXX] +npx @playwright/cli click +``` + +Re-snapshot and confirm both options exist: + +```bash +npx @playwright/cli snapshot +SNAP=$(ls -t .playwright-cli/page-*.yml | head -1) +grep -nE 'option "(Copilot CLI|Claude)"' "$SNAP" +``` + +Expected: two `option "Copilot CLI" [ref=…]` and `option "Claude" [ref=…]` +lines. + +Capture screenshot for the PR: + +```bash +SMOKE_DIR=$(ls -td /tmp/claude-phase4-smoke/*/ | head -1) +npx @playwright/cli screenshot --filename="$SMOKE_DIR/picker-open.png" +``` + +> **Gotcha — clicking the option directly fails.** The dropdown overlay +> registers a `context-view-pointerBlock` element that intercepts pointer +> events on the option items. Use `ArrowDown` then `Enter` instead: +> +> ```bash +> npx @playwright/cli press ArrowDown +> npx @playwright/cli press Enter +> ``` + +After selecting Claude, re-snapshot and verify: + +```bash +npx @playwright/cli snapshot +SNAP=$(ls -t .playwright-cli/page-*.yml | head -1) +grep -nE 'Pick Session Type, Claude' "$SNAP" +``` + +Expected: `button "Pick Session Type, Claude" [ref=…]`. + +## 4. Drive a prompt to verify the stub fires + +```bash +# Find the chat textbox (its label is the placeholder text) +SNAP=$(ls -t .playwright-cli/page-*.yml | head -1) +grep -nE 'textbox.*\[active\]' "$SNAP" +# Click it, type, submit: +npx @playwright/cli click +npx @playwright/cli type "hello claude" +npx @playwright/cli press Enter +sleep 2 +``` + +Re-snapshot and grep for the expected stub message: + +```bash +npx @playwright/cli snapshot +SNAP=$(ls -t .playwright-cli/page-*.yml | head -1) +grep -nE 'TODO: Phase' "$SNAP" +``` + +Match the result against the phase-specific table: + +| Phase | Expected snapshot match | +|-------|------------------------| +| 4 | `TODO: Phase 5` (createSession is the first stub on the path) | +| 5 | `TODO: Phase 6` (sendMessage stub) | +| 6+ | no `TODO: Phase` match (real SDK response renders) | + +Capture screenshot: + +```bash +npx @playwright/cli screenshot --filename="$SMOKE_DIR/stub-error.png" +``` + +## 5. Verify the session URI scheme + +The session URI is observable in the IPC log, **not** as a +`data-session-uri` DOM attribute (none exists). `verify-claude-logs.sh` +already captures these to `claude-session-uris.log`, but you can re-grep: + +```bash +LOG=$(ls -td ~/.vscode-oss-sessions-dev/logs/*/ | head -1) +WIN=$(ls -td "$LOG"window1/output_*/ | head -1) +grep -oE '"session":\s*"claude:[^"]+"' "$WIN"agenthost.*.log | sort -u +``` + +Expected: at least one `"session": "claude:/"` line. The scheme is +`claude:` (the provider id, fed straight to `AgentSession.uri`); the +synced-customization namespace uses the longer `agent-host-claude` form, +which appears in the IPC log as `"uri": "vscode-synced-customization:/agent-host-claude"`. + +## 6. Tear down + +```bash +lsof -t -i :9224 | xargs -r kill +``` + +The `~/.vscode-oss-sessions-dev` data dir is intentionally preserved so +the next run skips GitHub login. + +## 7. Attach to the PR + +For a phase smoke PR, include in the description: + +- `registration.log` (two lines) +- `picker-open.png` +- `stub-error.png` +- `claude-session-uris.log` (one line per session created) + +The other captured artifacts are useful for triage if any check fails but +need not appear in every PR. + +## Appendix — common failures + +| Symptom | Likely cause | +|---------|--------------| +| `verify-claude-logs.sh` fails at check 1 (`copilotcli` missing) | A registration was deleted from `agentHostMain.ts` or `agentHostServerMain.ts`. | +| `verify-claude-logs.sh` fails at check 1 (`claude` missing) | Same, but for ClaudeAgent. Or import broken. | +| `verify-claude-logs.sh` fails at check 2 (`[Claude] Auth token updated` missing) | `agentService.authenticate` is short-circuiting on the first matching provider. The fan-out fix lives in `src/vs/platform/agentHost/node/agentService.ts`. | +| `verify-claude-logs.sh` fails at check 5 (zero models) | The §3.5 filter rejected everything. Inspect the upstream `[Copilot] Found N models` log line and check vendor / `supported_endpoints` / `model_picker_enabled` / `tool_calls`. | +| Picker shows only "Copilot CLI" but registration log is fine | Root state never propagated. Check the `autorun` in `agentSideEffects.ts` — `_publishAgentInfos` should fire on every `agents` observable change. | +| Stub fires `TODO: Phase 5` but plan expected Phase 6 | Operator clicked Claude on a brand-new session, which hits `createSession` first. Either start from an existing claude session or update the per-phase table in §4. | +| `npx @playwright/cli evaluate` returns a help screen | The command is `eval`, not `evaluate`. Use `--raw` to strip wrapper output. | +| `npx @playwright/cli click` retries forever with `pointer-block intercepts` | Use keyboard navigation (`press ArrowDown` + `press Enter`). | diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 20e57ffa54d6a..fed77e4e41621 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -25,7 +25,7 @@ import { ILogService } from '../../../log/common/log.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; import { AutoApproveLevel, ISchemaProperty, SessionMode, createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSession, AgentSignal, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo } from '../../common/agentService.js'; +import { AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo } from '../../common/agentService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; @@ -255,13 +255,7 @@ export class CopilotAgent extends Disposable implements IAgent { } getProtectedResources(): ProtectedResourceMetadata[] { - return [{ - resource: 'https://api.github.com', - resource_name: 'GitHub Copilot', - authorization_servers: ['https://github.com/login/oauth'], - scopes_supported: ['read:user', 'user:email'], - required: true, - }]; + return [GITHUB_COPILOT_PROTECTED_RESOURCE]; } getCustomizations(): readonly CustomizationRef[] { @@ -275,7 +269,7 @@ export class CopilotAgent extends Disposable implements IAgent { } async authenticate(resource: string, token: string): Promise { - if (resource !== 'https://api.github.com') { + if (resource !== GITHUB_COPILOT_PROTECTED_RESOURCE.resource) { return false; } const tokenChanged = this._githubToken !== token; diff --git a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts index ff617e6919ec2..954f290d8a916 100644 --- a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts +++ b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts @@ -13,6 +13,7 @@ import { parseAgentHostDebugPort } from '../../environment/node/environmentServi import { ILogService } from '../../log/common/log.js'; import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; +import { AgentHostClaudeAgentEnabledSettingId, AgentHostEnableClaudeEnvVar } from '../common/agentService.js'; /** * Options for configuring the agent host WebSocket server in the child process. @@ -72,6 +73,14 @@ export class NodeAgentHostStarter extends Disposable implements IAgentHostStarte VSCODE_VERBOSE_LOGGING: 'true', }; + // Gate optional providers via env vars consumed by `agentHostMain.ts`. + // The Claude agent is opt-in: enabled when either the workbench setting is on + // or the env var is already set on the parent process (developer override). + if (this._configurationService.getValue(AgentHostClaudeAgentEnabledSettingId) + || process.env[AgentHostEnableClaudeEnvVar] === '1') { + env[AgentHostEnableClaudeEnvVar] = '1'; + } + // Forward WebSocket server configuration to the child process via env vars if (this._wsConfig) { if (this._wsConfig.port) { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 0a6edebac672d..24a0e99c93066 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -514,6 +514,71 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual(result, { authenticated: false }); assert.strictEqual(copilotAgent.authenticateCalls.length, 0); }); + + test('fans out to every provider that owns the resource', async () => { + // Two providers share the same protected resource (the real + // motivating example: both Copilot CLI and Claude consume the + // GitHub Copilot token). Both must see the token — the + // previous for-loop short-circuit only delivered to the first. + const claudeAgent = new MockAgent('claude'); + claudeAgent.getProtectedResources = () => [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'], required: true }]; + disposables.add(toDisposable(() => claudeAgent.dispose())); + service.registerProvider(copilotAgent); + service.registerProvider(claudeAgent); + + const result = await service.authenticate({ resource: 'https://api.github.com', token: 'tok' }); + + assert.deepStrictEqual({ + result, + copilotCalls: copilotAgent.authenticateCalls, + claudeCalls: claudeAgent.authenticateCalls, + }, { + result: { authenticated: true }, + copilotCalls: [{ resource: 'https://api.github.com', token: 'tok' }], + claudeCalls: [{ resource: 'https://api.github.com', token: 'tok' }], + }); + }); + + test('isolates a provider that throws — others still authenticate', async () => { + // Regression: if any provider's authenticate() rejects, the + // fan-out must NOT sink the others. Previously the call used + // Promise.all, which propagated the first rejection. + const flakyAgent = new MockAgent('claude'); + flakyAgent.getProtectedResources = () => [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'], required: true }]; + flakyAgent.authenticate = async () => { throw new Error('proxy bind failed'); }; + disposables.add(toDisposable(() => flakyAgent.dispose())); + service.registerProvider(copilotAgent); + service.registerProvider(flakyAgent); + + const result = await service.authenticate({ resource: 'https://api.github.com', token: 'tok' }); + + assert.deepStrictEqual({ + result, + copilotCalls: copilotAgent.authenticateCalls, + }, { + result: { authenticated: true }, + copilotCalls: [{ resource: 'https://api.github.com', token: 'tok' }], + }); + }); + + test('reports not authenticated when every matching provider rejects', async () => { + // All matching providers fail — the result must be + // { authenticated: false } rather than a thrown error. + const flakyA = new MockAgent('claude'); + const flakyB = new MockAgent('mock'); + flakyA.getProtectedResources = () => [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'], required: true }]; + flakyB.getProtectedResources = () => [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'], required: true }]; + flakyA.authenticate = async () => { throw new Error('A'); }; + flakyB.authenticate = async () => { throw new Error('B'); }; + disposables.add(toDisposable(() => flakyA.dispose())); + disposables.add(toDisposable(() => flakyB.dispose())); + service.registerProvider(flakyA); + service.registerProvider(flakyB); + + const result = await service.authenticate({ resource: 'https://api.github.com', token: 'tok' }); + + assert.deepStrictEqual(result, { authenticated: false }); + }); }); // ---- shutdown ------------------------------------------------------- diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts new file mode 100644 index 0000000000000..18d184fd4228b --- /dev/null +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -0,0 +1,432 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type Anthropic from '@anthropic-ai/sdk'; +import type { CCAModel } from '@vscode/copilot-api'; +import assert from 'assert'; +import { DeferredPromise } from '../../../../base/common/async.js'; +import type { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; +import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; +import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; +import { ILogService, NullLogService } from '../../../log/common/log.js'; +import { IProductService } from '../../../product/common/productService.js'; +import { FileService } from '../../../files/common/fileService.js'; +import { AgentSession } from '../../common/agentService.js'; +import { ClaudeAgent } from '../../node/claude/claudeAgent.js'; +import { IClaudeProxyHandle, IClaudeProxyService } from '../../node/claude/claudeProxyService.js'; +import { ICopilotApiService, type ICopilotApiServiceRequestOptions } from '../../node/shared/copilotApiService.js'; +import { AgentService } from '../../node/agentService.js'; +import { createNoopGitService, createNullSessionDataService } from '../common/sessionTestHelpers.js'; + +// #region Test fakes + +interface IStartCall { + readonly token: string; +} + +class FakeClaudeProxyService implements IClaudeProxyService { + declare readonly _serviceBrand: undefined; + + readonly startCalls: IStartCall[] = []; + disposeCount = 0; + + async start(token: string): Promise { + this.startCalls.push({ token }); + return { + baseUrl: 'http://127.0.0.1:0', + nonce: `nonce-for-${token}`, + dispose: () => { this.disposeCount++; }, + }; + } + + dispose(): void { /* no-op for tests */ } +} + +class FakeCopilotApiService implements ICopilotApiService { + declare readonly _serviceBrand: undefined; + + models: (token: string, options?: ICopilotApiServiceRequestOptions) => Promise = + async () => []; + + messages(): never { throw new Error('not used in ClaudeAgent tests'); } + countTokens(): Promise { throw new Error('not used in ClaudeAgent tests'); } +} + +// #endregion + +// #region Fixture models + +/** Build a {@link CCAModel} with sensible defaults; override per test. */ +function makeModel(overrides: Partial & { readonly id: string; readonly name: string; readonly vendor: string }): CCAModel { + return { + billing: { is_premium: false, multiplier: 1, restricted_to: [] } as unknown as CCAModel['billing'], + capabilities: { + family: 'test', + limits: { max_context_window_tokens: 200_000, max_output_tokens: 8192, max_prompt_tokens: 200_000 }, + object: 'model_capabilities', + supports: { parallel_tool_calls: true, streaming: true, tool_calls: true, vision: false }, + tokenizer: 'o200k_base', + type: 'chat', + }, + is_chat_default: false, + is_chat_fallback: false, + model_picker_category: 'Anthropic', + model_picker_enabled: true, + object: 'model', + policy: { state: 'enabled', terms: '' }, + preview: false, + supported_endpoints: ['/v1/messages'], + version: '1', + ...overrides, + }; +} + +const CLAUDE_OPUS = makeModel({ id: 'claude-opus-4.6', name: 'Claude Opus 4.6', vendor: 'Anthropic' }); +const CLAUDE_SONNET = makeModel({ id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6', vendor: 'Anthropic' }); +const NON_ANTHROPIC = makeModel({ id: 'gpt-5', name: 'GPT-5', vendor: 'OpenAI' }); +const ANTHROPIC_NO_MESSAGES_ENDPOINT = makeModel({ id: 'claude-haiku-3.5', name: 'Claude Haiku 3.5', vendor: 'Anthropic', supported_endpoints: ['/chat/completions'] }); +const ANTHROPIC_PICKER_DISABLED = makeModel({ id: 'claude-opus-4.5', name: 'Claude Opus 4.5', vendor: 'Anthropic', model_picker_enabled: false }); +const ANTHROPIC_NO_TOOL_CALLS = makeModel({ + id: 'claude-sonnet-3.5', name: 'Claude Sonnet 3.5', vendor: 'Anthropic', + capabilities: { + family: 'test', + limits: { max_context_window_tokens: 200_000, max_output_tokens: 8192, max_prompt_tokens: 200_000 }, + object: 'model_capabilities', + supports: { parallel_tool_calls: false, streaming: true, tool_calls: false, vision: false }, + tokenizer: 'o200k_base', + type: 'chat', + }, +}); +const SYNTHETIC_AUTO = makeModel({ id: 'auto', name: 'Auto', vendor: 'copilot' }); + +const ALL_MODELS: readonly CCAModel[] = [ + CLAUDE_OPUS, CLAUDE_SONNET, NON_ANTHROPIC, + ANTHROPIC_NO_MESSAGES_ENDPOINT, ANTHROPIC_PICKER_DISABLED, + ANTHROPIC_NO_TOOL_CALLS, SYNTHETIC_AUTO, +]; + +// #endregion + +// #region Test harness + +interface ITestContext { + readonly agent: ClaudeAgent; + readonly proxy: FakeClaudeProxyService; + readonly api: FakeCopilotApiService; +} + +function createTestContext(disposables: Pick): ITestContext { + const proxy = new FakeClaudeProxyService(); + const api = new FakeCopilotApiService(); + api.models = async () => [...ALL_MODELS]; + + const services = new ServiceCollection( + [ILogService, new NullLogService()], + [ICopilotApiService, api], + [IClaudeProxyService, proxy], + ); + const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); + const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); + return { agent, proxy, api }; +} + +/** Drains the microtask queue so awaited refresh writes settle. */ +function tick(): Promise { + return new Promise(resolve => setImmediate(resolve)); +} + +// #endregion + +suite('ClaudeAgent', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('getDescriptor advertises the Claude provider', () => { + const { agent } = createTestContext(disposables); + const desc = agent.getDescriptor(); + assert.deepStrictEqual( + { provider: desc.provider, displayName: desc.displayName, hasDescription: desc.description.length > 0 }, + { provider: 'claude', displayName: 'Claude', hasDescription: true }, + ); + }); + + test('getProtectedResources returns the GitHub resource', () => { + const { agent } = createTestContext(disposables); + assert.deepStrictEqual(agent.getProtectedResources(), [{ + resource: 'https://api.github.com', + resource_name: 'GitHub Copilot', + authorization_servers: ['https://github.com/login/oauth'], + scopes_supported: ['read:user', 'user:email'], + required: true, + }]); + }); + + test('models observable is empty before authenticate', () => { + const { agent } = createTestContext(disposables); + assert.deepStrictEqual(agent.models.get(), []); + }); + + test('authenticate populates models filtered to Claude family', async () => { + const { agent, proxy } = createTestContext(disposables); + + const accepted = await agent.authenticate('https://api.github.com', 'tok'); + await tick(); + + assert.deepStrictEqual({ + accepted, + startCalls: proxy.startCalls.map(c => c.token), + models: agent.models.get(), + }, { + accepted: true, + startCalls: ['tok'], + models: [ + { provider: 'claude', id: 'claude-opus-4.6', name: 'Claude Opus 4.6', maxContextWindow: 200_000, supportsVision: false }, + { provider: 'claude', id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6', maxContextWindow: 200_000, supportsVision: false }, + ], + }); + }); + + test('authenticate rejects non-GitHub resources without disturbing state', async () => { + const { agent, proxy } = createTestContext(disposables); + + const rejected = await agent.authenticate('https://other.example.com', 'tok'); + const accepted = await agent.authenticate('https://api.github.com', 'tok'); + await tick(); + + assert.deepStrictEqual({ + rejected, + accepted, + startCalls: proxy.startCalls.map(c => c.token), + disposeCount: proxy.disposeCount, + }, { + rejected: false, + accepted: true, + startCalls: ['tok'], + disposeCount: 0, + }); + }); + + test('authenticate with the same token does not restart the proxy', async () => { + const { agent, proxy } = createTestContext(disposables); + + await agent.authenticate('https://api.github.com', 'tok'); + await agent.authenticate('https://api.github.com', 'tok'); + await tick(); + + assert.deepStrictEqual({ + startCalls: proxy.startCalls.length, + disposeCount: proxy.disposeCount, + }, { startCalls: 1, disposeCount: 0 }); + }); + + test('authenticate with a different token restarts the proxy and disposes the old handle', async () => { + const { agent, proxy } = createTestContext(disposables); + + await agent.authenticate('https://api.github.com', 'tokA'); + await agent.authenticate('https://api.github.com', 'tokB'); + await tick(); + + assert.deepStrictEqual({ + startTokens: proxy.startCalls.map(c => c.token), + disposeCount: proxy.disposeCount, + }, { + startTokens: ['tokA', 'tokB'], + disposeCount: 1, + }); + }); + + test('authenticate retries proxy startup after a transient failure', async () => { + // Regression: a previous implementation set `_githubToken = token` + // before awaiting `start()`. If start threw, the token was recorded + // but no proxy was running, and the next authenticate() call with + // the same token took the "unchanged" path and falsely returned + // true. This test pins the corrected ordering: state mutates only + // after start() succeeds. + const proxy = new FakeClaudeProxyService(); + const api = new FakeCopilotApiService(); + api.models = async () => [...ALL_MODELS]; + + // Replace start() with a fake that records every invocation + // (whether or not it succeeds) and fails the first attempt only. + let failNext = true; + proxy.start = async (token: string) => { + proxy.startCalls.push({ token }); + if (failNext) { + failNext = false; + throw new Error('proxy bind failed'); + } + return { + baseUrl: 'http://127.0.0.1:0', + nonce: `nonce-for-${token}`, + dispose: () => { proxy.disposeCount++; }, + }; + }; + + const services = new ServiceCollection( + [ILogService, new NullLogService()], + [ICopilotApiService, api], + [IClaudeProxyService, proxy], + ); + const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); + const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); + + await assert.rejects(agent.authenticate('https://api.github.com', 'tok'), /proxy bind failed/); + + // Models still empty (proxy never started, refresh never ran). + assert.deepStrictEqual(agent.models.get(), []); + + // Retry with the SAME token MUST attempt start() again — not + // short-circuit on `tokenChanged === false`. + const accepted = await agent.authenticate('https://api.github.com', 'tok'); + await tick(); + + assert.deepStrictEqual({ + accepted, + startTokens: proxy.startCalls.map(c => c.token), + disposeCount: proxy.disposeCount, + modelIds: agent.models.get().map(m => m.id), + }, { + accepted: true, + startTokens: ['tok', 'tok'], + disposeCount: 0, + modelIds: [CLAUDE_OPUS.id, CLAUDE_SONNET.id], + }); + }); + + test('model filter excludes non-Claude entries', async () => { + // Same fixture set as the populate test, but assert on ids only — + // catches every exclusion criterion in one snapshot. + const { agent } = createTestContext(disposables); + await agent.authenticate('https://api.github.com', 'tok'); + await tick(); + + assert.deepStrictEqual( + agent.models.get().map(m => m.id), + ['claude-opus-4.6', 'claude-sonnet-4.6'], + ); + }); + + test('AgentSession URI helpers round-trip the claude scheme', () => { + const uri = AgentSession.uri('claude', 'abc'); + assert.deepStrictEqual({ + scheme: uri.scheme, + id: AgentSession.id(uri), + provider: AgentSession.provider(uri), + }, { scheme: 'claude', id: 'abc', provider: 'claude' }); + }); + + test('dispose disposes the proxy handle and is idempotent', async () => { + const proxy = new FakeClaudeProxyService(); + const api = new FakeCopilotApiService(); + api.models = async () => []; + + const services = new ServiceCollection( + [ILogService, new NullLogService()], + [ICopilotApiService, api], + [IClaudeProxyService, proxy], + ); + const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); + const agent = instantiationService.createInstance(ClaudeAgent); + + await agent.authenticate('https://api.github.com', 'tok'); + await tick(); + + agent.dispose(); + agent.dispose(); + + assert.strictEqual(proxy.disposeCount, 1); + }); + + test('stubbed methods throw with the right phase number', () => { + const { agent } = createTestContext(disposables); + const cases: Array<{ name: string; phase: number; thunk: () => unknown }> = [ + { name: 'createSession', phase: 5, thunk: () => agent.createSession() }, + { name: 'sendMessage', phase: 6, thunk: () => agent.sendMessage(URI.parse('claude:/x'), 'hi') }, + { name: 'respondToPermissionRequest', phase: 7, thunk: () => agent.respondToPermissionRequest('id', true) }, + { name: 'abortSession', phase: 9, thunk: () => agent.abortSession(URI.parse('claude:/x')) }, + ]; + const observed = cases.map(c => { + try { + const result = c.thunk(); + if (result instanceof Promise) { + // Surface the rejection synchronously for snapshotting. + let err: Error | undefined; + result.catch(e => { err = e instanceof Error ? e : new Error(String(e)); }); + // Async stubs throw synchronously in this implementation, + // but if a future stub uses `async` the thunk will return + // a rejected promise — fall through and miss the assertion. + return { name: c.name, message: err?.message ?? 'no-throw' }; + } + return { name: c.name, message: 'no-throw' }; + } catch (e) { + return { name: c.name, message: e instanceof Error ? e.message : String(e) }; + } + }); + + assert.deepStrictEqual( + observed, + cases.map(c => ({ name: c.name, message: `TODO: Phase ${c.phase}` })), + ); + }); + + test('AgentService surfaces the registered ClaudeAgent in the providers map', () => { + const { agent } = createTestContext(disposables); + const fileService = disposables.add(new FileService(new NullLogService())); + const service = disposables.add(new AgentService( + new NullLogService(), + fileService, + createNullSessionDataService(), + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + )); + + service.registerProvider(agent); + + // AgentSideEffects publishes registered providers into root state + // on the next autorun tick. The state manager exposes the root + // state via a public accessor. + const rootAgents = service.stateManager.rootState.agents; + assert.deepStrictEqual( + rootAgents.map(a => ({ provider: a.provider, displayName: a.displayName })), + [{ provider: 'claude', displayName: 'Claude' }], + ); + }); + + test('stale model writes from an old token are dropped', async () => { + // Wire a controllable models() so token-A's refresh can hang + // while token-B's refresh runs to completion. Phase 4's stale- + // write guard MUST drop the late token-A result. + const proxy = new FakeClaudeProxyService(); + const api = new FakeCopilotApiService(); + const tokAModels = new DeferredPromise(); + api.models = (token: string) => token === 'tokA' + ? tokAModels.p + : Promise.resolve([CLAUDE_SONNET]); + + const services = new ServiceCollection( + [ILogService, new NullLogService()], + [ICopilotApiService, api], + [IClaudeProxyService, proxy], + ); + const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); + const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); + + // First authenticate: refresh-A starts and hangs on tokAModels.p. + await agent.authenticate('https://api.github.com', 'tokA'); + // Second authenticate: refresh-B runs to completion, models == [B]. + await agent.authenticate('https://api.github.com', 'tokB'); + await tick(); + assert.deepStrictEqual(agent.models.get().map(m => m.id), [CLAUDE_SONNET.id]); + + // Now unblock refresh-A: it must observe the rotated token and + // drop its write rather than overwrite refresh-B's result. + tokAModels.complete([CLAUDE_OPUS]); + await tick(); + assert.deepStrictEqual(agent.models.get().map(m => m.id), [CLAUDE_SONNET.id]); + }); +}); diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 7603e8dbd5da2..90cc1da21316a 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -9,7 +9,7 @@ import { Schemas } from '../../../../base/common/network.js'; import { isMacintosh } from '../../../../base/common/platform.js'; import { PolicyCategory } from '../../../../base/common/policy.js'; import { CopilotSessionSearchPolicy } from '../../../../base/common/defaultAccount.js'; -import { AgentHostEnabledSettingId, AgentHostIpcLoggingSettingId } from '../../../../platform/agentHost/common/agentService.js'; +import { AgentHostClaudeAgentEnabledSettingId, AgentHostEnabledSettingId, AgentHostIpcLoggingSettingId } from '../../../../platform/agentHost/common/agentService.js'; import { AgentNetworkFilterService, IAgentNetworkFilterService } from '../../../../platform/networkFilter/common/networkFilterService.js'; import { AgentNetworkDomainSettingId } from '../../../../platform/networkFilter/common/settings.js'; import { AgentSandboxSettingId } from '../../../../platform/sandbox/common/settings.js'; @@ -985,6 +985,13 @@ configurationRegistry.registerConfiguration({ tags: ['experimental', 'advanced'], included: product.quality !== 'stable', }, + [AgentHostClaudeAgentEnabledSettingId]: { + type: 'boolean', + description: nls.localize('chat.agentHost.claudeAgent.enabled', "When enabled, the Claude agent provider is registered inside the agent host. Requires `#chat.agentHost.enabled#`. The agent host process must be restarted for changes to this setting to take effect."), + default: false, + tags: ['experimental', 'advanced'], + included: product.quality !== 'stable', + }, [AgentHostIpcLoggingSettingId]: { type: 'boolean', description: nls.localize('chat.agentHost.ipcLogging', "When enabled, logs all IPC traffic for each agent host to a dedicated output channel."), From d3e91ab066bb27aa0b29122eeb75c751711ba3ad Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega <48293249+osortega@users.noreply.github.com> Date: Fri, 1 May 2026 14:07:37 -0700 Subject: [PATCH 13/19] Move agent host picker to sessions sidebar (web desktop) (#313619) * Move agent host picker to sessions sidebar (web desktop) On web desktop (vscode.dev/agents), surface the agent host picker in a new toolbar at the bottom of the sessions sidebar instead of the titlebar's left layout. The new widget is modeled on AICustomizationShortcutsWidget but is always expanded -- no collapse chevron, no storage key. Scope: - Web desktop: picker moves to the sidebar (new SidebarAgentHost menu). - Web phone: unchanged -- still rendered in MobileTitlebarPart. - Electron: unchanged -- no host picker, never had one. Visual alignment: HostFilterActionViewItem gains a HostFilterAppearance ('titlebar' | 'sidebar') parameter so it can render the same Monaco Button + .sidebar-action-button shell used by CustomizationLinkViewItem. The sidebar appearance lays out the picker button as [remote-icon] [host-name (flex:1)] [chevron-down], with the connect / disconnect / re-discover indicator as an independent 26x26 sibling control to the right. When there are no hosts the right slot becomes a refresh button that triggers rediscover(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update src/vs/sessions/contrib/remoteAgentHost/browser/hostFilterActionViewItem.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/vs/sessions/contrib/sessions/test/browser/agentHostShortcutsWidget.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Address PR #313619 review feedback - Drop the icon-by-state enumeration from the HostFilterActionViewItem class JSDoc. - Sidebar picker no longer no-ops with 0 hosts: pass the click event through to `_showMenu`, which already routes to `rediscover()` when no hosts are known. Same affordance as the dedicated refresh slot. - Widen `_showMenu(e)` to accept `Event`; this avoids fabricating a synthetic `MouseEvent('click')` that would anchor the context menu at (0,0) on touch/gesture activations. `dom.isMouseEvent(e)` keeps the runtime check. - Gate the `AgentHostShortcutsWidget` mount on `IsSessionsWindowContext && !IsAuxiliaryWindowContext && !IsPhoneLayoutContext` instead of `isWeb && !isPhoneLayout(...)`. Prevents an empty toolbar shell in auxiliary windows and aligns the runtime gate with the agents-window context the menu items target. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Avoid querySelector in HostFilterActionViewItem (sidebar) Hold stable references to the leading host icon and trailing chevron as `_sidebarLeadingIcon` / `_sidebarTrailingIcon` fields, created once in `_renderSidebar()`. `_renderSidebarButtonAffordances` now attaches/detaches the chevron via `appendChild` / `remove` on the stored reference instead of `querySelector(':scope > ...)`, fixing the `no-restricted-syntax` ESLint warnings reported by CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/vs/sessions/browser/menus.ts | 1 + .../browser/hostFilter.contribution.ts | 30 ++- .../browser/hostFilterActionViewItem.ts | 241 +++++++++++++++--- .../browser/media/hostFilter.css | 55 ++++ .../browser/mobileHostFilterActionViewItem.ts | 4 +- .../browser/agentHostShortcutsWidget.ts | 56 ++++ .../browser/media/agentHostToolbar.css | 30 +++ .../sessions/browser/views/sessionsView.ts | 24 ++ .../browser/agentHostShortcutsWidget.test.ts | 93 +++++++ 9 files changed, 490 insertions(+), 44 deletions(-) create mode 100644 src/vs/sessions/contrib/sessions/browser/agentHostShortcutsWidget.ts create mode 100644 src/vs/sessions/contrib/sessions/browser/media/agentHostToolbar.css create mode 100644 src/vs/sessions/contrib/sessions/test/browser/agentHostShortcutsWidget.test.ts diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index f6f3929a725a6..7260e6b366155 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -24,6 +24,7 @@ export const Menus = { AuxiliaryBarTitle: new MenuId('SessionsAuxiliaryBarTitle'), SidebarFooter: new MenuId('SessionsSidebarFooter'), SidebarCustomizations: new MenuId('SessionsSidebarCustomizations'), + SidebarAgentHost: new MenuId('SessionsSidebarAgentHost'), AccountMenu: new MenuId('SessionsAccountMenu'), AgentFeedbackEditorContent: new MenuId('AgentFeedbackEditorContent'), diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilter.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilter.contribution.ts index dfa766009d25f..b2e412b87291f 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilter.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilter.contribution.ts @@ -13,7 +13,7 @@ import { IsWebContext } from '../../../../platform/contextkey/common/contextkeys import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { IsNewChatSessionContext } from '../../../common/contextkeys.js'; +import { IsNewChatSessionContext, IsPhoneLayoutContext } from '../../../common/contextkeys.js'; import { Menus } from '../../../browser/menus.js'; import { IAgentHostFilterService } from '../common/agentHostFilter.js'; import { HostFilterActionViewItem } from './hostFilterActionViewItem.js'; @@ -22,10 +22,15 @@ import { MobileHostFilterActionViewItem } from './mobileHostFilterActionViewItem const PICK_HOST_FILTER_ID = 'sessions.agentHostFilter.pick'; /** - * Action that backs the host filter dropdown in the titlebar. Selection - * is actually handled by {@link HostFilterActionViewItem}, so the action's - * `run` is a no-op. Gated on `isWeb` via its menu `when` clause so the - * combo only shows up in the web build. + * Action that backs the host filter dropdown. Selection is actually handled + * by {@link HostFilterActionViewItem}, so the action's `run` is a no-op. + * + * Surface placement: + * - Web desktop (`isWeb && !isPhoneLayout`): rendered in the sessions + * sidebar via `Menus.SidebarAgentHost` (see `AgentHostShortcutsWidget`). + * - Web phone: rendered in the `MobileTitlebarPart` center slot via + * `Menus.MobileTitleBarCenter`. + * - Electron desktop: not surfaced (no `IsWebContext`). */ registerAction2(class PickAgentHostFilterAction extends Action2 { constructor() { @@ -34,16 +39,17 @@ registerAction2(class PickAgentHostFilterAction extends Action2 { title: localize2('agentHostFilter.pick', "Select Agent Host"), f1: false, menu: [{ - id: Menus.TitleBarLeftLayout, + id: Menus.SidebarAgentHost, group: 'navigation', order: 1, - // Always shown on web (regardless of host count): when no - // hosts are known the pill renders a re-discover affordance - // (refresh icon + click triggers `rediscover()`); when one - // or more are known it is the host picker. + // Always shown on web desktop (regardless of host count): + // when no hosts are known the pill renders a re-discover + // affordance (refresh icon + click triggers `rediscover()`); + // when one or more are known it is the host picker. when: ContextKeyExpr.and( IsWebContext, IsAuxiliaryWindowContext.toNegated(), + IsPhoneLayoutContext.negate(), ), }, { // On phone/mobile layouts the desktop titlebar is replaced @@ -95,9 +101,9 @@ class AgentHostFilterContribution extends Disposable implements IWorkbenchContri const refreshSignal = Event.any(filterService.onDidChange, filterService.onDidChangeDiscovering, registered.event); this._register(actionViewItemService.register( - Menus.TitleBarLeftLayout, + Menus.SidebarAgentHost, PICK_HOST_FILTER_ID, - (action, _options, instaService) => instaService.createInstance(HostFilterActionViewItem, action), + (action, _options, instaService) => instaService.createInstance(HostFilterActionViewItem, action, 'sidebar'), refreshSignal, )); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilterActionViewItem.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilterActionViewItem.ts index 84209185cdc23..3ee54e36e11e6 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilterActionViewItem.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilterActionViewItem.ts @@ -8,6 +8,7 @@ import * as dom from '../../../../base/browser/dom.js'; import { Gesture, EventType as TouchEventType } from '../../../../base/browser/touch.js'; import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { BaseActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js'; import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; @@ -18,21 +19,25 @@ import { MutableDisposable } from '../../../../base/common/lifecycle.js'; import { localize } from '../../../../nls.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { AgentHostFilterConnectionStatus, IAgentHostFilterEntry, IAgentHostFilterService } from '../common/agentHostFilter.js'; /** - * Compound titlebar widget shown next to the toggle sidebar button in the - * Agent Sessions window. It fills the remaining left-toolbar width (which - * matches the sidebar width) and renders two controls side-by-side: + * Visual appearance of {@link HostFilterActionViewItem}. * - * - Left: a dropdown pill indicating the currently selected host; clicking - * opens a context menu to pick a different host. When only a single - * host is available the pill renders as a static label (no chevron, - * no click target). - * - Right: a connection-status button for the selected host: - * • Connected → green `debug-connected` (non-interactive) - * • Connecting → `debug-connected` pulsing, non-interactive - * • Connected → clickable `debug-disconnect`; click tears down the connection\n * • Connecting → pulsing `debug-disconnect`; click cancels the attempt\n * • Disconnected → clickable `debug-connected`; click triggers a fresh connect + * - `titlebar` — the original compact pill designed for the desktop + * titlebar's left toolbar. Fixed-height pill with `--vscode-titleBar-…` + * text colors and a `max-width` so it never grows too wide. + * - `sidebar` — full-width row aligned with the rest of the agents + * sidebar (matches `.sidebar-action-button`'s rhythm), used by the + * {@link AgentHostShortcutsWidget} on web desktop. + */ +export type HostFilterAppearance = 'titlebar' | 'sidebar'; + +/** + * Compound widget showing the agent host picker plus a connection-state + * button. Originally lived in the desktop titlebar, now also rendered as a + * sidebar row via {@link HostFilterAppearance}. */ export class HostFilterActionViewItem extends BaseActionViewItem { @@ -40,12 +45,16 @@ export class HostFilterActionViewItem extends BaseActionViewItem { private _labelElement: HTMLElement | undefined; private _chevronElement: HTMLElement | undefined; private _connectElement: HTMLElement | undefined; + private _sidebarButton: Button | undefined; + private _sidebarLeadingIcon: HTMLElement | undefined; + private _sidebarTrailingIcon: HTMLElement | undefined; private readonly _dropdownHover = this._register(new MutableDisposable()); private readonly _connectHover = this._register(new MutableDisposable()); constructor( action: IAction, + private readonly _appearance: HostFilterAppearance = 'titlebar', @IAgentHostFilterService protected readonly _filterService: IAgentHostFilterService, @IContextMenuService private readonly _contextMenuService: IContextMenuService, @IHoverService private readonly _hoverService: IHoverService, @@ -64,6 +73,24 @@ export class HostFilterActionViewItem extends BaseActionViewItem { } this.element.classList.add('agent-host-filter-combo'); + if (this._appearance === 'sidebar') { + this.element.classList.add('sidebar'); + this._renderSidebar(); + } else { + this._renderTitlebar(); + } + + this._update(); + } + + /** + * Original compact pill rendered in the desktop titlebar's left toolbar. + * Custom DOM driven directly by click handlers + context menu service. + */ + private _renderTitlebar(): void { + if (!this.element) { + return; + } // --- Dropdown pill (left) ----------------------------------------------- this._dropdownElement = dom.append(this.element, dom.$('div.agent-host-filter-dropdown')); @@ -99,23 +126,113 @@ export class HostFilterActionViewItem extends BaseActionViewItem { // --- Connection button (right) ------------------------------------------ this._connectElement = dom.append(this.element, dom.$('div.agent-host-filter-connect')); + this._wireConnectButton(this._connectElement); + } + + /** + * Sidebar appearance — full-width row matching the Customizations links + * (`CustomizationLinkViewItem`). Same Monaco `Button` shell, same + * `.sidebar-action-button` styling, same `supportIcons` label rendering. + * The trailing connect indicator is rendered alongside the picker + * button as a sibling control, so the row visually mirrors the + * Customizations rows in the toolbar above without making the + * indicator part of the picker label. + */ + private _renderSidebar(): void { + if (!this.element) { + return; + } + + this.element.classList.add('sidebar-action'); + + // Picker button — same shell as `CustomizationLinkViewItem`. We + // drive the button content manually (rather than via `Button.label`) + // so the host name span can `flex: 1` and push the chevron all + // the way to the trailing edge. + const buttonContainer = dom.append(this.element, dom.$('.customization-link-button-container')); + this._sidebarButton = this._register(new Button(buttonContainer, { + ...defaultButtonStyles, + secondary: true, + title: false, + supportIcons: true, + buttonSecondaryBackground: 'transparent', + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + })); + this._sidebarButton.element.classList.add('customization-link-button', 'sidebar-action-button', 'agent-host-filter-button', 'monaco-text-button'); + + this._dropdownElement = this._sidebarButton.element; + // Build the button content manually as three direct children so + // we can keep stable references to each element (icon · label · + // chevron) without DOM querying. The label takes `flex: 1` so + // the trailing chevron is pushed to the right edge. + this._sidebarLeadingIcon = dom.append(this._sidebarButton.element, dom.$('span.agent-host-filter-leading-icon')); + this._sidebarLeadingIcon.classList.add('codicon', `codicon-${Codicon.remote.id}`); + this._labelElement = dom.append(this._sidebarButton.element, dom.$('span.agent-host-filter-label')); + // Trailing chevron is created up-front but only attached to the + // button when this is a real picker (2+ hosts). See + // `_renderSidebarButtonAffordances`. + this._sidebarTrailingIcon = dom.$('span.agent-host-filter-trailing-icon.codicon'); + this._sidebarTrailingIcon.classList.add(`codicon-${Codicon.chevronDown.id}`); + + this._register(this._sidebarButton.onDidClick(e => { + if (!this._isInteractive()) { + return; + } + // Pass the original event through to `_showMenu`. It will + // anchor on the mouse position when `e` is a real + // `MouseEvent` and otherwise fall back to anchoring on the + // dropdown element (the right behavior for keyboard / + // touch / gesture activations). When there are no hosts, + // `_showMenu` triggers re-discovery instead of opening the + // menu — same as the dedicated refresh button next to it. + this._showMenu(e); + })); + + // Connect indicator — sibling of the picker button so it reads as + // an independent control (not part of the picker label). + this._connectElement = dom.append(this.element, dom.$('div.agent-host-filter-connect')); + this._wireConnectButton(this._connectElement); + } - this._register(Gesture.addTarget(this._connectElement)); + private _wireConnectButton(connectElement: HTMLElement): void { + this._register(Gesture.addTarget(connectElement)); for (const eventType of [dom.EventType.CLICK, TouchEventType.Tap]) { - this._register(dom.addDisposableListener(this._connectElement, eventType, e => { + this._register(dom.addDisposableListener(connectElement, eventType, e => { + // Stop propagation so the host menu (parent button click) + // doesn't open when toggling the connection. dom.EventHelper.stop(e, true); this._onConnectClick(); })); } - this._register(dom.addDisposableListener(this._connectElement, dom.EventType.KEY_DOWN, e => { + this._register(dom.addDisposableListener(connectElement, dom.EventType.KEY_DOWN, e => { const event = new StandardKeyboardEvent(e); if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) { dom.EventHelper.stop(e, true); this._onConnectClick(); } })); + } - this._update(); + private _renderSidebarButtonAffordances(interactive: boolean, canRetry: boolean): void { + if (!this._sidebarButton || !this._sidebarTrailingIcon) { + return; + } + + // Trailing chevron — only attached when this is a real picker + // (i.e. there are 2+ hosts to choose from). For canRetry / + // single-host the button is *not* a dropdown — in canRetry the + // refresh action lives in the trailing connect slot instead, + // mirroring the disconnect button shape. + const showChevron = interactive && !canRetry; + if (showChevron) { + if (!this._sidebarTrailingIcon.isConnected) { + this._sidebarButton.element.appendChild(this._sidebarTrailingIcon); + } + } else { + this._sidebarTrailingIcon.remove(); + } } protected _isInteractive(): boolean { @@ -127,7 +244,13 @@ export class HostFilterActionViewItem extends BaseActionViewItem { } private _update(): void { - if (!this.element || !this._dropdownElement || !this._labelElement || !this._chevronElement || !this._connectElement) { + if (!this.element || !this._dropdownElement || !this._labelElement || !this._connectElement) { + return; + } + + // Titlebar appearance has a chevron element; sidebar does not. Bail + // only when a required element for the active appearance is missing. + if (!this._sidebarButton && !this._chevronElement) { return; } @@ -148,7 +271,19 @@ export class HostFilterActionViewItem extends BaseActionViewItem { : discovering ? localize('agentHostFilter.searching', "Searching…") : localize('agentHostFilter.none', "No Host"); - this._labelElement.textContent = text; + + if (this._sidebarButton) { + // Sidebar appearance: write the host name into our own label + // span (which is `flex: 1` so it consumes remaining space) and + // (re)position the leading host icon + trailing chevron + // around it. The chevron uses `$(refresh)` when there are no + // hosts (clicking re-runs discovery) and is omitted entirely + // for the non-interactive single-host case. + this._labelElement.textContent = text; + this._renderSidebarButtonAffordances(interactive, canRetry); + } else { + this._labelElement.textContent = text; + } this.element.classList.toggle('single-host', !interactive); // While discovery is running, suppress the label so the pill collapses @@ -159,15 +294,28 @@ export class HostFilterActionViewItem extends BaseActionViewItem { // Swap the chevron content based on the click affordance: a chevron // when the pill opens a menu, a refresh icon when it triggers re- - // discovery. Clearing first avoids stacking icon nodes. - dom.clearNode(this._chevronElement); - const chevronIconId = canRetry ? Codicon.refresh.id : Codicon.chevronDown.id; - this._chevronElement.append(...renderLabelWithIcons(`$(${chevronIconId})`)); + // discovery. Clearing first avoids stacking icon nodes. Sidebar + // mode has no chevron — the button label is the whole interactive + // surface. + if (this._chevronElement) { + dom.clearNode(this._chevronElement); + const chevronIconId = canRetry ? Codicon.refresh.id : Codicon.chevronDown.id; + this._chevronElement.append(...renderLabelWithIcons(`$(${chevronIconId})`)); + } if (interactive) { - this._dropdownElement.tabIndex = 0; - this._dropdownElement.role = 'button'; - if (hasMenu) { + if (!this._sidebarButton) { + // Titlebar: drive tabIndex / role on the dropdown DIV manually. + // The Button used in the sidebar appearance already provides + // its own focusability, role, and keyboard activation. + this._dropdownElement.tabIndex = 0; + this._dropdownElement.role = 'button'; + if (hasMenu) { + this._dropdownElement.setAttribute('aria-haspopup', 'menu'); + } else { + this._dropdownElement.removeAttribute('aria-haspopup'); + } + } else if (hasMenu) { this._dropdownElement.setAttribute('aria-haspopup', 'menu'); } else { this._dropdownElement.removeAttribute('aria-haspopup'); @@ -189,8 +337,10 @@ export class HostFilterActionViewItem extends BaseActionViewItem { () => hoverText, ); } else { - this._dropdownElement.removeAttribute('tabindex'); - this._dropdownElement.removeAttribute('role'); + if (!this._sidebarButton) { + this._dropdownElement.removeAttribute('tabindex'); + this._dropdownElement.removeAttribute('role'); + } this._dropdownElement.removeAttribute('aria-haspopup'); this._dropdownElement.setAttribute('aria-label', selected ? localize('agentHostFilter.aria.singleSelected', "Sessions scoped to host {0}", selected.label) @@ -198,18 +348,39 @@ export class HostFilterActionViewItem extends BaseActionViewItem { this._dropdownHover.clear(); } - this._updateConnectButton(selected); + this._updateConnectButton(selected, canRetry, discovering); } - private _updateConnectButton(selected: IAgentHostFilterEntry | undefined): void { + private _updateConnectButton(selected: IAgentHostFilterEntry | undefined, canRetry: boolean, discovering: boolean): void { if (!this._connectElement) { return; } dom.clearNode(this._connectElement); - this._connectElement.classList.remove('connected', 'connecting', 'disconnected', 'hidden'); + this._connectElement.classList.remove('connected', 'connecting', 'disconnected', 'rediscover', 'hidden'); this._connectHover.clear(); + // Sidebar appearance: when there are no known hosts, repurpose + // this trailing slot as a "Re-discover hosts" button so the + // user has an independent control next to the "No Host" picker + // — same shape as disconnect/connect on a real host. + if (!selected && this._sidebarButton && canRetry) { + this._connectElement.setAttribute('role', 'button'); + this._connectElement.tabIndex = 0; + this._connectElement.classList.add('rediscover'); + this._connectElement.append(...renderLabelWithIcons(`$(${Codicon.refresh.id})`)); + const hoverText = discovering + ? localize('agentHostFilter.hover.searching', "Searching for hosts…") + : localize('agentHostFilter.hover.retry', "Re-discover hosts"); + this._connectElement.setAttribute('aria-label', hoverText); + this._connectHover.value = this._hoverService.setupManagedHover( + getDefaultHoverDelegate('element'), + this._connectElement, + () => hoverText, + ); + return; + } + if (!selected) { this._connectElement.classList.add('hidden'); this._connectElement.removeAttribute('role'); @@ -255,6 +426,16 @@ export class HostFilterActionViewItem extends BaseActionViewItem { } private _onConnectClick(): void { + // Sidebar "no hosts" state: the connect slot doubles as a + // re-discovery affordance (refresh icon). Trigger discovery when + // we recognise that mode. + if (this._connectElement?.classList.contains('rediscover')) { + if (!this._filterService.isDiscovering) { + this._filterService.rediscover(); + } + return; + } + const selectedId = this._filterService.selectedProviderId; if (selectedId === undefined) { return; @@ -272,7 +453,7 @@ export class HostFilterActionViewItem extends BaseActionViewItem { } } - protected _showMenu(e: MouseEvent | KeyboardEvent): void { + protected _showMenu(e: Event): void { if (!this._dropdownElement) { return; } diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/media/hostFilter.css b/src/vs/sessions/contrib/remoteAgentHost/browser/media/hostFilter.css index 64421286b4496..a514a2108a16d 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/media/hostFilter.css +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/media/hostFilter.css @@ -21,6 +21,57 @@ padding-right: 4px; } +/* --- Sidebar appearance ------------------------------------------------- */ + +/* In sidebar mode the action item lays out as a horizontal flex row: + * picker button (full-width) + trailing connect button. Each is its own + * independent click target. */ +.monaco-action-bar .action-item.agent-host-filter-combo.sidebar { + display: flex; + align-items: center; + width: 100%; + max-width: 100%; + height: auto; + padding: 0; + gap: 4px; +} + +.agent-host-filter-combo.sidebar .customization-link-button-container { + flex: 1 1 auto; + min-width: 0; +} + +/* The picker button content is laid out as: + * [leading icon] [host-name span (flex:1)] [trailing chevron/refresh] + * The label span consumes remaining space so the trailing icon docks at + * the right edge with comfortable spacing from the host name. */ +.agent-host-filter-combo.sidebar .agent-host-filter-button .agent-host-filter-leading-icon { + flex: 0 0 auto; +} + +.agent-host-filter-combo.sidebar .agent-host-filter-button .agent-host-filter-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-host-filter-combo.sidebar .agent-host-filter-button .agent-host-filter-trailing-icon { + flex: 0 0 auto; + opacity: 0.7; + font-size: 12px; +} + +/* Connect indicator: independent control sitting next to the picker + * button. Square-ish 26px hit target aligned with the row height. */ +.agent-host-filter-combo.sidebar .agent-host-filter-connect { + flex: 0 0 auto; + margin-left: 0; + width: 26px; + height: 26px; +} + /* --- Dropdown pill ------------------------------------------------------ */ .agent-host-filter-combo .agent-host-filter-dropdown { @@ -127,6 +178,10 @@ color: var(--vscode-descriptionForeground); } +.agent-host-filter-combo .agent-host-filter-connect.rediscover .codicon { + color: var(--vscode-descriptionForeground); +} + .agent-host-filter-combo .agent-host-filter-connect:hover { background-color: var(--vscode-toolbar-hoverBackground); } diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/mobileHostFilterActionViewItem.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/mobileHostFilterActionViewItem.ts index 909798337a299..17aff0d9bae75 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/mobileHostFilterActionViewItem.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/mobileHostFilterActionViewItem.ts @@ -39,7 +39,7 @@ export class MobileHostFilterActionViewItem extends HostFilterActionViewItem { @IContextMenuService contextMenuService: IContextMenuService, @IHoverService hoverService: IHoverService, ) { - super(action, filterService, contextMenuService, hoverService); + super(action, 'titlebar', filterService, contextMenuService, hoverService); } /** @@ -52,7 +52,7 @@ export class MobileHostFilterActionViewItem extends HostFilterActionViewItem { return true; } - protected override _showMenu(_e: MouseEvent | KeyboardEvent): void { + protected override _showMenu(_e: Event): void { if (!this.element) { return; } diff --git a/src/vs/sessions/contrib/sessions/browser/agentHostShortcutsWidget.ts b/src/vs/sessions/contrib/sessions/browser/agentHostShortcutsWidget.ts new file mode 100644 index 0000000000000..f64becfeea2e7 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/agentHostShortcutsWidget.ts @@ -0,0 +1,56 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/agentHostToolbar.css'; +import * as DOM from '../../../../base/browser/dom.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { Menus } from '../../../browser/menus.js'; + +const $ = DOM.$; + +export interface IAgentHostShortcutsWidgetOptions { + readonly onDidChangeLayout?: () => void; +} + +/** + * Sidebar toolbar that hosts the agent host picker (with embedded + * connect/disconnect indicator) on web desktop. Always expanded — there is + * no collapse affordance, unlike `AICustomizationShortcutsWidget`. + * + * Mounted only when `isWeb && !isPhoneLayout` (electron desktop has no host + * picker today, and phone layout uses the mobile titlebar pill instead). + */ +export class AgentHostShortcutsWidget extends Disposable { + + constructor( + container: HTMLElement, + options: IAgentHostShortcutsWidgetOptions | undefined, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + + this._render(container, options); + } + + private _render(parent: HTMLElement, options: IAgentHostShortcutsWidgetOptions | undefined): void { + const container = DOM.append(parent, $('.agent-host-toolbar')); + + const toolbarContainer = DOM.append(container, $('.agent-host-toolbar-content')); + + const toolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, toolbarContainer, Menus.SidebarAgentHost, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + toolbarOptions: { primaryGroup: () => true }, + telemetrySource: 'sidebarAgentHost', + })); + + // Re-layout when toolbar items change (e.g. once host discovery + // completes and the picker swaps from "Searching…" to a real host). + this._register(toolbar.onDidChangeMenuItems(() => { + options?.onDidChangeLayout?.(); + })); + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/media/agentHostToolbar.css b/src/vs/sessions/contrib/sessions/browser/media/agentHostToolbar.css new file mode 100644 index 0000000000000..9105686866c3d --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/media/agentHostToolbar.css @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +/* Agent Host section - sits at the bottom of the sessions sidebar on web desktop. */ +.agent-host-toolbar { + display: flex; + flex-direction: column; + flex-shrink: 0; + border-top: 1px solid var(--vscode-agentsPanel-border, transparent); + margin: 0 10px; + padding: 6px 0 10px 0; +} + +/* Make the toolbar, action bar, and items fill the full width and stack vertically */ +.agent-host-toolbar .agent-host-toolbar-content .monaco-toolbar, +.agent-host-toolbar .agent-host-toolbar-content .monaco-action-bar { + width: 100%; +} + +.agent-host-toolbar .agent-host-toolbar-content .monaco-action-bar .actions-container { + display: flex; + flex-direction: column; + width: 100%; +} + +.agent-host-toolbar .agent-host-toolbar-content .monaco-action-bar .action-item { + width: 100%; + max-width: 100%; +} diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts index 548fe91baba05..743682145286a 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts @@ -11,6 +11,7 @@ import { Event } from '../../../../../base/common/event.js'; import { autorun } from '../../../../../base/common/observable.js'; import { isMobile, isWeb, OS } from '../../../../../base/common/platform.js'; import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../../../../workbench/common/contextkeys.js'; import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; @@ -25,6 +26,7 @@ import { localize } from '../../../../../nls.js'; import { SessionsList, SessionsGrouping, SessionsSorting } from './sessionsList.js'; import { SessionStatus } from '../../../../services/sessions/common/session.js'; import { AICustomizationShortcutsWidget } from '../aiCustomizationShortcutsWidget.js'; +import { AgentHostShortcutsWidget } from '../agentHostShortcutsWidget.js'; import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; @@ -41,6 +43,7 @@ import { Menus } from '../../../../browser/menus.js'; import { MobileSessionFilterChips } from '../../../../browser/parts/mobile/mobileSessionFilterChips.js'; import { IMobileSortGroupSheetItem, showMobileSortGroupSheet } from '../../../../browser/parts/mobile/mobileSortGroupSheet.js'; import { isPhoneLayout } from '../../../../browser/parts/mobile/mobileLayout.js'; +import { IsPhoneLayoutContext } from '../../../../common/contextkeys.js'; const $ = DOM.$; export const SessionsViewId = 'sessions.workbench.view.sessionsView'; @@ -271,6 +274,27 @@ export class SessionsView extends ViewPane { } }, })); + + // Agent Host toolbar (bottom, below customizations). Only rendered + // in the sessions window on desktop layouts: electron has no host + // picker today (gated out at the menu level), phone layout uses + // the mobile titlebar pill instead, and auxiliary windows do not + // contribute any host actions — without this gate they would show + // an empty toolbar shell. + if (this.scopedContextKeyService.contextMatchesRules(ContextKeyExpr.and( + IsSessionsWindowContext, + IsAuxiliaryWindowContext.toNegated(), + IsPhoneLayoutContext.negate(), + ))) { + this._register(this.instantiationService.createInstance(AgentHostShortcutsWidget, sessionsContainer, { + onDidChangeLayout: () => { + if (this.viewPaneContainer) { + const { offsetHeight, offsetWidth } = this.viewPaneContainer; + this.layoutBody(offsetHeight, offsetWidth); + } + }, + })); + } } private createNewSessionButton(container: HTMLElement): void { diff --git a/src/vs/sessions/contrib/sessions/test/browser/agentHostShortcutsWidget.test.ts b/src/vs/sessions/contrib/sessions/test/browser/agentHostShortcutsWidget.test.ts new file mode 100644 index 0000000000000..7ade765cc7743 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/agentHostShortcutsWidget.test.ts @@ -0,0 +1,93 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestCommandService } from '../../../../../editor/test/browser/editorTestServices.js'; +import { IActionViewItemFactory, IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; +import { IMenu, IMenuActionOptions, IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { MockContextKeyService, MockKeybindingService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { NullTelemetryServiceShape } from '../../../../../platform/telemetry/common/telemetryUtils.js'; +import { AgentHostShortcutsWidget } from '../../browser/agentHostShortcutsWidget.js'; + +class TestActionViewItemService implements IActionViewItemService { + declare _serviceBrand: undefined; + readonly onDidChange = Event.None; + register(_menu: MenuId, _commandId: string | MenuId, _provider: IActionViewItemFactory): { dispose(): void } { + return { dispose: () => { } }; + } + lookUp(_menu: MenuId, _commandId: string | MenuId): IActionViewItemFactory | undefined { + return undefined; + } +} + +class TestMenuService implements IMenuService { + declare readonly _serviceBrand: undefined; + createMenu(_id: MenuId): IMenu { + return { + onDidChange: Event.None, + dispose: () => { }, + getActions: () => [], + }; + } + getMenuActions(_id: MenuId, _contextKeyService: unknown, _options?: IMenuActionOptions) { return []; } + getMenuContexts() { return new Set(); } + resetHiddenStates() { } +} + +function createWidget(disposables: DisposableStore): { container: HTMLElement } { + const container = document.createElement('div'); + document.body.appendChild(container); + disposables.add({ dispose: () => container.remove() }); + + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.set(IMenuService, new TestMenuService()); + instantiationService.set(IActionViewItemService, new TestActionViewItemService()); + instantiationService.set(IContextKeyService, new MockContextKeyService()); + instantiationService.set(IContextMenuService, { + _serviceBrand: undefined, + onDidShowContextMenu: Event.None, + onDidHideContextMenu: Event.None, + showContextMenu: () => { }, + }); + instantiationService.set(IKeybindingService, new MockKeybindingService()); + instantiationService.set(ICommandService, new TestCommandService(instantiationService)); + instantiationService.set(ITelemetryService, new NullTelemetryServiceShape()); + + disposables.add(instantiationService.createInstance(AgentHostShortcutsWidget, container, undefined)); + return { container }; +} + +suite('AgentHostShortcutsWidget', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('renders an always-expanded toolbar with content and no collapse affordance', () => { + const testDisposables = disposables.add(new DisposableStore()); + const { container } = createWidget(testDisposables); + + const toolbar = container.querySelector('.agent-host-toolbar'); + const content = container.querySelector('.agent-host-toolbar-content'); + + assert.deepStrictEqual({ + toolbarPresent: !!toolbar, + contentPresent: !!content, + collapsed: toolbar?.classList.contains('collapsed') ?? false, + hasChevron: !!container.querySelector('.ai-customization-chevron, .agent-host-toolbar-chevron'), + }, { + toolbarPresent: true, + contentPresent: true, + collapsed: false, + hasChevron: false, + }); + }); +}); From 1d94ae1b8a7df7bfd2d7bc8aa112976bba19205c Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Fri, 1 May 2026 14:36:56 -0700 Subject: [PATCH 14/19] gate claude model behind setting too (#313801) --- .../vscode-node/claudeChatSessionContentProvider.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts index cc3361535d591..26b2fbdea774c 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts @@ -81,6 +81,7 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco @IClaudeSessionStateService private readonly sessionStateService: IClaudeSessionStateService, @IClaudeSlashCommandService private readonly slashCommandService: IClaudeSlashCommandService, @IClaudeCodeModels private readonly claudeModels: IClaudeCodeModels, + @IConfigurationService private readonly configurationService: IConfigurationService, @IInstantiationService instantiationService: IInstantiationService ) { super(); @@ -161,7 +162,8 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco // Clear usage handler after request completes this.sessionStateService.setUsageHandlerForSession(effectiveSessionId, undefined); - const details = endpoint ? formatClaudeModelDetails(endpoint) : undefined; + const modelDetailsEnabled = this.configurationService.getConfig(ConfigKey.Advanced.CLIModelDetailsEnabled); + const details = modelDetailsEnabled && endpoint ? formatClaudeModelDetails(endpoint) : undefined; return { ...(details ? { details } : {}), ...(result.errorDetails ? { errorDetails: result.errorDetails } : {}), @@ -173,7 +175,8 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco async provideChatSessionContent(sessionResource: vscode.Uri, token: vscode.CancellationToken, context?: { readonly inputState: vscode.ChatSessionInputState }): Promise { const existingSession = await this.sessionService.getSession(sessionResource, token); - const detailsByModelId = existingSession ? await this._buildModelDetailsLookup(existingSession, token) : undefined; + const modelDetailsEnabled = this.configurationService.getConfig(ConfigKey.Advanced.CLIModelDetailsEnabled); + const detailsByModelId = existingSession && modelDetailsEnabled ? await this._buildModelDetailsLookup(existingSession, token) : undefined; const history = existingSession ? buildChatHistory(existingSession, detailsByModelId ? id => detailsByModelId.get(id) : undefined) : []; From 682c7907d39af351f75df628019b5dc4e5ffae60 Mon Sep 17 00:00:00 2001 From: Paul Date: Fri, 1 May 2026 15:58:30 -0700 Subject: [PATCH 15/19] Don't dismiss hover when mouse exits due to programmatic resize (#313584) --- src/vs/platform/hover/browser/hoverService.ts | 14 +-- src/vs/platform/hover/browser/hoverWidget.ts | 27 ++++- .../hover/test/browser/hoverService.test.ts | 104 ++++++++++++++++++ 3 files changed, 136 insertions(+), 9 deletions(-) diff --git a/src/vs/platform/hover/browser/hoverService.ts b/src/vs/platform/hover/browser/hoverService.ts index e0a22ac57eea7..6e0f7b019e7a2 100644 --- a/src/vs/platform/hover/browser/hoverService.ts +++ b/src/vs/platform/hover/browser/hoverService.ts @@ -339,13 +339,13 @@ export class HoverService extends Disposable implements IHoverService { options.container = this._layoutService.getContainer(getWindow(targetElement)); } - if (options.persistence?.sticky) { - hoverDisposables.add(addDisposableListener(getWindow(options.container).document, EventType.MOUSE_DOWN, e => { - if (!isAncestor(e.target as HTMLElement, hover.domNode)) { - this._hideHoverAndDescendants(hover); - } - })); - } else { + hoverDisposables.add(addDisposableListener(getWindow(options.container).document, EventType.MOUSE_DOWN, e => { + if (!isAncestor(e.target as HTMLElement, hover.domNode)) { + this._hideHoverAndDescendants(hover); + } + })); + + if (!options.persistence?.sticky) { if ('targetElements' in options.target) { for (const element of options.target.targetElements) { hoverDisposables.add(addDisposableListener(element, EventType.CLICK, () => this._hideHoverAndDescendants(hover))); diff --git a/src/vs/platform/hover/browser/hoverWidget.ts b/src/vs/platform/hover/browser/hoverWidget.ts index 8d8146bb693b6..1ca3e989f57ba 100644 --- a/src/vs/platform/hover/browser/hoverWidget.ts +++ b/src/vs/platform/hover/browser/hoverWidget.ts @@ -59,6 +59,7 @@ export class HoverWidget extends Widget implements IHoverWidget { private _enableFocusTraps: boolean = false; private _addedFocusTrap: boolean = false; private _maxHeightRatioRelativeToWindow: number = 0.5; + private _mouseTracker: CompositeMouseTracker | undefined; private get _targetWindow(): Window { return dom.getWindow(this._target.targetElements[0]); @@ -268,7 +269,7 @@ export class HoverWidget extends Widget implements IHoverWidget { if (!hideOnHover) { mouseTrackerTargets.push(this._hoverContainer); } - const mouseTracker = this._register(new CompositeMouseTracker(mouseTrackerTargets)); + const mouseTracker = this._mouseTracker = this._register(new CompositeMouseTracker(mouseTrackerTargets)); this._register(mouseTracker.onMouseOut(() => { if (!this._isLocked) { this.dispose(); @@ -348,6 +349,14 @@ export class HoverWidget extends Widget implements IHoverWidget { } public layout() { + // Cancel any pending mouseout timers since the hover is being + // repositioned (e.g. due to content resize from collapsible sections). + // The mouse may end up back inside the hover after the layout. + this._mouseTracker?.suppressPendingMouseOut(); + if (this._lockMouseTracker !== this._mouseTracker) { + this._lockMouseTracker?.suppressPendingMouseOut(); + } + this._hover.containerDomNode.classList.remove('right-aligned'); this._hover.contentsDomNode.style.maxHeight = ''; @@ -667,6 +676,7 @@ export class HoverWidget extends Widget implements IHoverWidget { class CompositeMouseTracker extends Widget { private _isMouseIn: boolean = true; + private _suppressNextMouseOut: boolean = false; private readonly _mouseTimer: MutableDisposable = this._register(new MutableDisposable()); private readonly _onMouseOut = this._register(new Emitter()); @@ -694,6 +704,7 @@ class CompositeMouseTracker extends Widget { private _onTargetMouseOver(): void { this._isMouseIn = true; + this._suppressNextMouseOut = false; this._mouseTimer.clear(); } @@ -705,11 +716,23 @@ class CompositeMouseTracker extends Widget { } private _fireIfMouseOutside(): void { - if (!this._isMouseIn) { + if (!this._isMouseIn && !this._suppressNextMouseOut) { this._onMouseOut.fire(); } } + /** + * Suppresses the next pending mouseout dismissal. Call this when tracked + * elements are being resized or repositioned to avoid spurious dismissals + * caused by the element shrinking away from the cursor. The suppression + * is cleared when the mouse next enters a tracked element. + */ + suppressPendingMouseOut(): void { + if (!this._isMouseIn) { + this._suppressNextMouseOut = true; + } + } + /** * Adds an element to be tracked by this mouse tracker. Mouse events on this * element will be considered as being "inside" the tracked area. diff --git a/src/vs/platform/hover/test/browser/hoverService.test.ts b/src/vs/platform/hover/test/browser/hoverService.test.ts index a26814c75e4c4..90d70d197052f 100644 --- a/src/vs/platform/hover/test/browser/hoverService.test.ts +++ b/src/vs/platform/hover/test/browser/hoverService.test.ts @@ -672,4 +672,108 @@ suite('HoverService', () => { assert.strictEqual(remainingHovers.length, 0, 'No hovers should remain in DOM after cleanup'); }); }); + + suite('layout and resize', () => { + test('layout should suppress pending mouseout so content resize does not dismiss hover', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const target = createTarget(); + const content = document.createElement('div'); + content.textContent = 'Resizable content'; + + const hover = hoverService.showInstantHover({ + content, + target + }); + assert.ok(hover); + assertInDOM(hover, 'Hover should be in DOM'); + + const widget = asHoverWidget(hover); + + // Simulate a mouseleave on the hover container (as happens when content shrinks) + widget.domNode.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true })); + + // Before the debounce timer fires, trigger a layout (as ResizeObserver would) + widget.layout(); + + // Wait longer than the CompositeMouseTracker debounce (200ms) + await timeout(300); + + // The hover should still be in the DOM because layout() cancelled the pending mouseout + assertInDOM(hover, 'Hover should remain in DOM after layout suppresses mouseout'); + + hover.dispose(); + assertNotInDOM(hover, 'Hover should be removed from DOM after dispose'); + })); + + test('hover should still dismiss on mouseout when no layout occurs', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const target = createTarget(); + const content = document.createElement('div'); + content.textContent = 'Content'; + + const hover = hoverService.showInstantHover({ + content, + target + }); + assert.ok(hover); + assertInDOM(hover, 'Hover should be in DOM'); + + const widget = asHoverWidget(hover); + + // Simulate a mouseleave without a subsequent layout + widget.domNode.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true })); + + // Wait for the debounce to fire + await timeout(300); + + // Without layout suppression, the hover should be dismissed + assertNotInDOM(hover, 'Hover should be dismissed after mouseout without layout'); + })); + + test('suppression clears after mouse re-enters and a new mouseleave dismisses normally', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const target = createTarget(); + const content = document.createElement('div'); + content.textContent = 'Resizable content'; + + const hover = hoverService.showInstantHover({ + content, + target + }); + assert.ok(hover); + assertInDOM(hover, 'Hover should be in DOM'); + + const widget = asHoverWidget(hover); + + // Simulate mouseleave + layout to suppress + widget.domNode.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true })); + widget.layout(); + await timeout(300); + assertInDOM(hover, 'Hover should remain after suppressed mouseout'); + + // Mouse re-enters, clearing the suppression flag + widget.domNode.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + + // Mouse leaves again — this time no layout, so it should dismiss + widget.domNode.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true })); + await timeout(300); + + assertNotInDOM(hover, 'Hover should dismiss on normal mouseout after suppression was cleared'); + })); + + test('clicking outside should dismiss non-sticky hover', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const target = createTarget(); + const content = document.createElement('div'); + content.textContent = 'Content'; + + const hover = hoverService.showInstantHover({ + content, + target + }); + assert.ok(hover); + assertInDOM(hover, 'Hover should be in DOM'); + + // Click outside the hover + document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + + assertNotInDOM(hover, 'Non-sticky hover should be dismissed after clicking outside'); + })); + }); }); From 8d9a4bbeae8c55d7a1bb577001c612d42f3ea6af Mon Sep 17 00:00:00 2001 From: Paul Date: Fri, 1 May 2026 16:01:20 -0700 Subject: [PATCH 16/19] Add support for UBB in manage models view (#313741) --- .../claude/node/claudeCodeModels.ts | 3 + .../vscode-node/languageModelAccess.ts | 3 + .../platform/networking/common/networking.ts | 2 +- .../api/common/extHostLanguageModels.ts | 6 + .../chatManagement/chatModelsWidget.ts | 135 ++++++++++++++---- .../chatManagement/media/chatModelsWidget.css | 11 +- .../contrib/chat/common/languageModels.ts | 3 + .../chatManagement/chatModelsWidget.test.ts | 135 ++++++++++++++++++ .../chat/common/chatEntitlementService.ts | 1 + .../vscode.proposed.languageModelPricing.d.ts | 33 +++++ 10 files changed, 303 insertions(+), 29 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsWidget.test.ts diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts index ca7cdb0e74e72..54425c7a1dc5f 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts @@ -97,6 +97,9 @@ export class ClaudeCodeModels extends Disposable implements IClaudeCodeModels { maxInputTokens: endpoint.modelMaxPromptTokens, maxOutputTokens: endpoint.maxOutputTokens, pricing: multiplier ?? (endpoint.tokenPricing ? formatPricingLabel(endpoint.tokenPricing) : undefined), + inputCost: endpoint.tokenPricing?.inputPrice, + outputCost: endpoint.tokenPricing?.outputPrice, + cacheCost: endpoint.tokenPricing?.cacheReadTokenPrice, multiplierNumeric: endpoint.multiplier, tooltip, isUserSelectable: true, diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts index e1a0182c4365e..abcc80caa8791 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts @@ -259,6 +259,9 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib family: endpoint.family, tooltip: modelTooltip, pricing: endpoint instanceof AutoChatEndpoint ? undefined : (multiplier ?? (endpoint.tokenPricing ? formatPricingLabel(endpoint.tokenPricing) : undefined)), + inputCost: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.tokenPricing?.inputPrice, + outputCost: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.tokenPricing?.outputPrice, + cacheCost: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.tokenPricing?.cacheReadTokenPrice, multiplierNumeric: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.multiplier, detail: modelDetail, category: modelCategory, diff --git a/extensions/copilot/src/platform/networking/common/networking.ts b/extensions/copilot/src/platform/networking/common/networking.ts index c7d020da1c5a3..3a34543e6a9d4 100644 --- a/extensions/copilot/src/platform/networking/common/networking.ts +++ b/extensions/copilot/src/platform/networking/common/networking.ts @@ -427,7 +427,7 @@ function networkRequest( Authorization: `Bearer ${secretKey}`, 'X-Request-Id': requestId, 'OpenAI-Intent': intent, // Tells CAPI who flighted this request. Helps find buggy features - 'X-GitHub-Api-Version': '2025-05-01', + 'X-GitHub-Api-Version': '2026-01-09', ...additionalHeaders, ...(endpoint.getExtraHeaders ? endpoint.getExtraHeaders(location) : {}), }; diff --git a/src/vs/workbench/api/common/extHostLanguageModels.ts b/src/vs/workbench/api/common/extHostLanguageModels.ts index 29ecf875dece5..96ebc6a543594 100644 --- a/src/vs/workbench/api/common/extHostLanguageModels.ts +++ b/src/vs/workbench/api/common/extHostLanguageModels.ts @@ -223,6 +223,9 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { version: m.version, multiplierNumeric: m.multiplierNumeric, pricing: m.pricing, + inputCost: m.inputCost, + outputCost: m.outputCost, + cacheCost: m.cacheCost, maxInputTokens: m.maxInputTokens, maxOutputTokens: m.maxOutputTokens, auth, @@ -415,6 +418,9 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { version: model.info.version, name: model.info.name, pricing: model.metadata.pricing, + inputCost: model.metadata.inputCost, + outputCost: model.metadata.outputCost, + cacheCost: model.metadata.cacheCost, capabilities: { supportsImageToText: model.metadata.capabilities?.vision ?? false, supportsToolCalling: !!model.metadata.capabilities?.toolCalling, diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts index 9d74f77dec3a5..397b2fb9fb2e8 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts @@ -70,6 +70,27 @@ export function getModelHoverContent(model: ILanguageModel): MarkdownString { markdown.appendText(`\n`); } + if (model.metadata.inputCost !== undefined || model.metadata.outputCost !== undefined || model.metadata.cacheCost !== undefined) { + if (model.metadata.inputCost !== undefined) { + markdown.appendMarkdown(model.metadata.inputCost === 1 + ? localize('models.inputCost.singular', 'Input Cost: {0} credit per 1M tokens', model.metadata.inputCost) + : localize('models.inputCost.plural', 'Input Cost: {0} credits per 1M tokens', model.metadata.inputCost)); + markdown.appendText(`\n`); + } + if (model.metadata.outputCost !== undefined) { + markdown.appendMarkdown(model.metadata.outputCost === 1 + ? localize('models.outputCost.singular', 'Output Cost: {0} credit per 1M tokens', model.metadata.outputCost) + : localize('models.outputCost.plural', 'Output Cost: {0} credits per 1M tokens', model.metadata.outputCost)); + markdown.appendText(`\n`); + } + if (model.metadata.cacheCost !== undefined) { + markdown.appendMarkdown(model.metadata.cacheCost === 1 + ? localize('models.cacheCost.singular', 'Cache Cost: {0} credit per 1M tokens', model.metadata.cacheCost) + : localize('models.cacheCost.plural', 'Cache Cost: {0} credits per 1M tokens', model.metadata.cacheCost)); + markdown.appendText(`\n`); + } + } + if (model.metadata.maxInputTokens || model.metadata.maxOutputTokens) { const totalTokens = (model.metadata.maxInputTokens ?? 0) + (model.metadata.maxOutputTokens ?? 0); markdown.appendMarkdown(`${localize('models.contextSize', 'Context Size')}: `); @@ -511,57 +532,98 @@ class ModelNameColumnRenderer extends ModelsTableColumnRenderer { - static readonly TEMPLATE_ID = 'pricing'; +class CombinedCostColumnRenderer extends ModelsTableColumnRenderer { + static readonly TEMPLATE_ID = 'combinedCost'; - readonly templateId: string = PricingColumnRenderer.TEMPLATE_ID; + readonly templateId: string = CombinedCostColumnRenderer.TEMPLATE_ID; constructor( - @IHoverService private readonly hoverService: IHoverService + @IHoverService private readonly hoverService: IHoverService, ) { super(); } - renderTemplate(container: HTMLElement): IPricingColumnTemplateData { + renderTemplate(container: HTMLElement): ICombinedCostColumnTemplateData { const disposables = new DisposableStore(); const elementDisposables = new DisposableStore(); - const pricingElement = DOM.append(container, $('.model-pricing')); + const grid = DOM.append(container, $('.model-cost-grid')); + const inputCell = DOM.append(grid, $('span.model-cost-cell')); + const outputCell = DOM.append(grid, $('span.model-cost-cell')); + const cacheCell = DOM.append(grid, $('span.model-cost-cell')); return { container, - pricingElement, + inputCell, + outputCell, + cacheCell, disposables, elementDisposables }; } - override renderElement(entry: IViewModelEntry, index: number, templateData: IPricingColumnTemplateData): void { - templateData.pricingElement.textContent = ''; + override renderElement(entry: IViewModelEntry, index: number, templateData: ICombinedCostColumnTemplateData): void { + templateData.inputCell.textContent = ''; + templateData.outputCell.textContent = ''; + templateData.cacheCell.textContent = ''; super.renderElement(entry, index, templateData); } - override renderGroupElement(element: ILanguageModelGroupEntry, index: number, templateData: IPricingColumnTemplateData): void { + override renderGroupElement(_element: ILanguageModelGroupEntry, _index: number, _templateData: ICombinedCostColumnTemplateData): void { } - override renderVendorElement(element: ILanguageModelProviderEntry, index: number, templateData: IPricingColumnTemplateData): void { - + override renderVendorElement(_element: ILanguageModelProviderEntry, _index: number, _templateData: ICombinedCostColumnTemplateData): void { } - override renderModelElement(entry: ILanguageModelEntry, index: number, templateData: IPricingColumnTemplateData): void { - const pricingText = entry.model.metadata.pricing ?? '-'; - templateData.pricingElement.textContent = pricingText; + override renderModelElement(entry: ILanguageModelEntry, index: number, templateData: ICombinedCostColumnTemplateData): void { + const { inputCost, outputCost, cacheCost } = entry.model.metadata; + const hasCost = inputCost !== undefined || outputCost !== undefined || cacheCost !== undefined; + + if (hasCost) { + templateData.inputCell.textContent = inputCost !== undefined ? localize('cost.input', "In: {0}", inputCost) : ''; + templateData.outputCell.textContent = outputCost !== undefined ? localize('cost.output', "Out: {0}", outputCost) : ''; + templateData.cacheCell.textContent = cacheCost !== undefined ? localize('cost.cache', "Cache: {0}", cacheCost) : ''; - if (pricingText !== '-') { + const parts: string[] = []; + if (inputCost !== undefined) { + parts.push(inputCost === 1 + ? localize('cost.inputHover.singular', "Input: {0} credit per 1M tokens", inputCost) + : localize('cost.inputHover.plural', "Input: {0} credits per 1M tokens", inputCost)); + } + if (outputCost !== undefined) { + parts.push(outputCost === 1 + ? localize('cost.outputHover.singular', "Output: {0} credit per 1M tokens", outputCost) + : localize('cost.outputHover.plural', "Output: {0} credits per 1M tokens", outputCost)); + } + if (cacheCost !== undefined) { + parts.push(cacheCost === 1 + ? localize('cost.cacheHover.singular', "Cache: {0} credit per 1M tokens", cacheCost) + : localize('cost.cacheHover.plural', "Cache: {0} credits per 1M tokens", cacheCost)); + } templateData.elementDisposables.add(this.hoverService.setupDelayedHoverAtMouse(templateData.container, () => ({ - content: localize('pricing.tooltip', "Pricing: {0}", pricingText), + content: parts.join('\n'), appearance: { compact: true, skipFadeInAnimation: true } }))); + } else { + // Fallback for non-token-based billing (premium requests users) + const pricingText = entry.model.metadata.pricing; + if (pricingText) { + templateData.inputCell.textContent = pricingText; + templateData.elementDisposables.add(this.hoverService.setupDelayedHoverAtMouse(templateData.container, () => ({ + content: localize('pricing.tooltip', "Pricing: {0}", pricingText), + appearance: { + compact: true, + skipFadeInAnimation: true + } + }))); + } } } } @@ -877,7 +939,7 @@ export class ChatModelsWidget extends Disposable { private readonly searchFocusContextKey: IContextKey; - private tableDisposables = this._register(new DisposableStore()); + private readonly tableDisposables = this._register(new DisposableStore()); constructor( @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @@ -1015,7 +1077,10 @@ export class ChatModelsWidget extends Disposable { // Create table this.createTable(); this._register(this.viewModel.onDidChangeGrouping(() => this.createTable())); - this._register(this.chatEntitlementService.onDidChangeEntitlement(() => this.updateAddModelsButton())); + this._register(this.chatEntitlementService.onDidChangeEntitlement(() => { + this.updateAddModelsButton(); + this.createTable(); + })); this._register(this.languageModelsService.onDidChangeLanguageModelVendors(() => this.updateAddModelsButton())); this._register(this.contextKeyService.onDidChangeContext(e => { if (e.affectsSome(new Set(['github.copilot.clientByokEnabled']))) { @@ -1030,7 +1095,7 @@ export class ChatModelsWidget extends Disposable { const gutterColumnRenderer = this.instantiationService.createInstance(GutterColumnRenderer, this.viewModel); const modelNameColumnRenderer = this.instantiationService.createInstance(ModelNameColumnRenderer); - const costColumnRenderer = this.instantiationService.createInstance(PricingColumnRenderer); + const combinedCostColumnRenderer = this.instantiationService.createInstance(CombinedCostColumnRenderer); const tokenLimitsColumnRenderer = this.instantiationService.createInstance(TokenLimitsColumnRenderer); const capabilitiesColumnRenderer = this.instantiationService.createInstance(CapabilitiesColumnRenderer); const actionsColumnRenderer = this.instantiationService.createInstance(ActionsColumnRenderer, this.viewModel); @@ -1075,6 +1140,7 @@ export class ChatModelsWidget extends Disposable { }); } + const hasAnyCostFields = this.viewModel.viewModelEntries.some(e => !isLanguageModelProviderEntry(e) && !isLanguageModelGroupEntry(e) && !isStatusEntry(e) && (e.model.metadata.inputCost !== undefined || e.model.metadata.outputCost !== undefined || e.model.metadata.cacheCost !== undefined)); columns.push( { label: localize('tokenLimits', 'Context Size'), @@ -1093,11 +1159,11 @@ export class ChatModelsWidget extends Disposable { project(row: IViewModelEntry): IViewModelEntry { return row; } }, { - label: localize('cost', 'Pricing'), + label: hasAnyCostFields ? localize('cost', 'Cost (Credits per 1M Tokens)') : localize('pricing', 'Pricing'), tooltip: '', - weight: 0.15, - minimumWidth: 200, - templateId: PricingColumnRenderer.TEMPLATE_ID, + weight: hasAnyCostFields ? 0.24 : 0.15, + minimumWidth: hasAnyCostFields ? 240 : 200, + templateId: CombinedCostColumnRenderer.TEMPLATE_ID, project(row: IViewModelEntry): IViewModelEntry { return row; } }, { @@ -1120,7 +1186,7 @@ export class ChatModelsWidget extends Disposable { [ gutterColumnRenderer, modelNameColumnRenderer, - costColumnRenderer, + combinedCostColumnRenderer, tokenLimitsColumnRenderer, capabilitiesColumnRenderer, actionsColumnRenderer, @@ -1151,6 +1217,21 @@ export class ChatModelsWidget extends Disposable { if (pricingText !== '-') { ariaLabels.push(localize('pricing.ariaLabel', "Pricing: {0}", pricingText)); } + if (e.model.metadata.inputCost !== undefined) { + ariaLabels.push(e.model.metadata.inputCost === 1 + ? localize('inputCost.ariaLabel.singular', "Input cost: {0} credit per 1M tokens", e.model.metadata.inputCost) + : localize('inputCost.ariaLabel.plural', "Input cost: {0} credits per 1M tokens", e.model.metadata.inputCost)); + } + if (e.model.metadata.outputCost !== undefined) { + ariaLabels.push(e.model.metadata.outputCost === 1 + ? localize('outputCost.ariaLabel.singular', "Output cost: {0} credit per 1M tokens", e.model.metadata.outputCost) + : localize('outputCost.ariaLabel.plural', "Output cost: {0} credits per 1M tokens", e.model.metadata.outputCost)); + } + if (e.model.metadata.cacheCost !== undefined) { + ariaLabels.push(e.model.metadata.cacheCost === 1 + ? localize('cacheCost.ariaLabel.singular', "Cache cost: {0} credit per 1M tokens", e.model.metadata.cacheCost) + : localize('cacheCost.ariaLabel.plural', "Cache cost: {0} credits per 1M tokens", e.model.metadata.cacheCost)); + } if (e.model.visible) { ariaLabels.push(localize('model.visible', 'This model is visible in the chat model picker')); } else { diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/media/chatModelsWidget.css b/src/vs/workbench/contrib/chat/browser/chatManagement/media/chatModelsWidget.css index 14aa0e140449a..248ca44a5d493 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/media/chatModelsWidget.css +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/media/chatModelsWidget.css @@ -155,9 +155,18 @@ /** Cost column styling **/ -.models-widget .models-table-container .monaco-table-td .model-pricing { +.models-widget .models-table-container .monaco-table-td .model-cost-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 4px; + width: 100%; + overflow: hidden; +} + +.models-widget .models-table-container .monaco-table-td .model-cost-grid .model-cost-cell { overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; } /** Token Limits column styling **/ diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 3817fd7e9c125..59001c7a24eb8 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -192,6 +192,9 @@ export interface ILanguageModelChatMetadata { readonly detail?: string; readonly multiplierNumeric?: number; readonly pricing?: string; + readonly inputCost?: number; + readonly outputCost?: number; + readonly cacheCost?: number; readonly family: string; readonly maxInputTokens: number; readonly maxOutputTokens: number; diff --git a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsWidget.test.ts new file mode 100644 index 0000000000000..b9d31c4e52039 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsWidget.test.ts @@ -0,0 +1,135 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; +import { ILanguageModelChatMetadata } from '../../../common/languageModels.js'; +import { getModelHoverContent } from '../../../browser/chatManagement/chatModelsWidget.js'; +import { ILanguageModel } from '../../../browser/chatManagement/chatModelsViewModel.js'; +import { ChatAgentLocation } from '../../../common/constants.js'; + +function createModel(overrides: Partial = {}): ILanguageModel { + return { + metadata: { + extension: new ExtensionIdentifier('github.copilot'), + id: 'gpt-4', + name: 'GPT-4', + family: 'gpt-4', + version: '1.0', + vendor: 'copilot', + maxInputTokens: 8192, + maxOutputTokens: 4096, + isUserSelectable: true, + isDefaultForLocation: { + [ChatAgentLocation.Chat]: false + }, + ...overrides + }, + identifier: 'copilot-gpt-4', + provider: { + vendor: { vendor: 'copilot', displayName: 'GitHub Copilot', isDefault: true }, + group: { name: 'GitHub Copilot' } + }, + visible: true + } as ILanguageModel; +} + +suite('ChatModelsWidget', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('getModelHoverContent', () => { + + test('includes cost fields when all three are present', () => { + const model = createModel({ + inputCost: 4, + outputCost: 14, + cacheCost: 1 + }); + + const markdown = getModelHoverContent(model); + const value = markdown.value; + + assert.ok(value.includes('Input Cost')); + assert.ok(value.includes('4 credits per 1M tokens')); + assert.ok(value.includes('Output Cost')); + assert.ok(value.includes('14 credits per 1M tokens')); + assert.ok(value.includes('Cache Cost')); + assert.ok(value.includes('1 credit per 1M tokens')); + }); + + test('includes only present cost fields', () => { + const model = createModel({ + inputCost: 3, + outputCost: 12 + // cacheCost intentionally omitted + }); + + const markdown = getModelHoverContent(model); + const value = markdown.value; + + assert.ok(value.includes('Input Cost')); + assert.ok(value.includes('3 credits per 1M tokens')); + assert.ok(value.includes('Output Cost')); + assert.ok(value.includes('12 credits per 1M tokens')); + assert.ok(!value.includes('Cache Cost')); + }); + + test('omits cost section when no cost fields are set', () => { + const model = createModel({}); + + const markdown = getModelHoverContent(model); + const value = markdown.value; + + assert.ok(!value.includes('Input Cost')); + assert.ok(!value.includes('Output Cost')); + assert.ok(!value.includes('Cache Cost')); + assert.ok(!value.includes('credits per 1M tokens')); + assert.ok(!value.includes('credit per 1M tokens')); + }); + + test('includes pricing text when set', () => { + const model = createModel({ pricing: '1x' }); + + const markdown = getModelHoverContent(model); + const value = markdown.value; + + assert.ok(value.includes('Pricing')); + assert.ok(value.includes('1x')); + }); + + test('includes both pricing and cost fields when both are present', () => { + const model = createModel({ + pricing: '1x', + inputCost: 4, + outputCost: 14, + cacheCost: 1 + }); + + const markdown = getModelHoverContent(model); + const value = markdown.value; + + assert.ok(value.includes('Pricing')); + assert.ok(value.includes('1x')); + assert.ok(value.includes('Input Cost')); + assert.ok(value.includes('4 credits per 1M tokens')); + }); + + test('handles zero cost values', () => { + const model = createModel({ + inputCost: 0, + outputCost: 0, + cacheCost: 0 + }); + + const markdown = getModelHoverContent(model); + const value = markdown.value; + + assert.ok(value.includes('Input Cost')); + assert.ok(value.includes('0 credits per 1M tokens')); + }); + }); +}); diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index 43d98e740fd57..912d5bbf6d776 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -551,6 +551,7 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme changed: { exceeded: (oldQuota?.percentRemaining === 0) !== (newQuota?.percentRemaining === 0), remaining: oldQuota?.percentRemaining !== newQuota?.percentRemaining + || oldQuota?.usageBasedBilling !== newQuota?.usageBasedBilling } }; } diff --git a/src/vscode-dts/vscode.proposed.languageModelPricing.d.ts b/src/vscode-dts/vscode.proposed.languageModelPricing.d.ts index 77f2abada66a7..2ccf5e71d3815 100644 --- a/src/vscode-dts/vscode.proposed.languageModelPricing.d.ts +++ b/src/vscode-dts/vscode.proposed.languageModelPricing.d.ts @@ -13,6 +13,24 @@ declare module 'vscode' { * This value is meant for display purposes and will be shown in the model management UI. */ readonly pricing?: string; + + /** + * Optional input cost in AI credits for this model. + * Displayed in the model management UI as the cost per million input tokens. + */ + readonly inputCost?: number; + + /** + * Optional output cost in AI credits for this model. + * Displayed in the model management UI as the cost per million output tokens. + */ + readonly outputCost?: number; + + /** + * Optional cache cost in AI credits for this model. + * Displayed in the model management UI as the cost per million cached tokens. + */ + readonly cacheCost?: number; } export interface LanguageModelChat { @@ -21,5 +39,20 @@ declare module 'vscode' { * This value is provided by the model provider and is meant for display purposes only. */ readonly pricing?: string; + + /** + * Optional input cost in AI credits for this model. + */ + readonly inputCost?: number; + + /** + * Optional output cost in AI credits for this model. + */ + readonly outputCost?: number; + + /** + * Optional cache cost in AI credits for this model. + */ + readonly cacheCost?: number; } } From e6b9ae7ff17a8f4dd8ff3dbcbb4668a8e36281f8 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 1 May 2026 19:12:30 -0400 Subject: [PATCH 17/19] AHP/Customizations: BrowserPluginGitCommandService for adding plugins in web (#313575) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chat: add real BrowserPluginGitCommandService for adding plugins in web Replaces the throwing stub at src/vs/workbench/contrib/chat/browser/pluginGitCommandService.ts with a real implementation that lets browser/web clients install agent plugins from public (and authenticated) GitHub repositories without a local git binary or AHP server. How it works - Resolve the requested ref to a commit SHA via GitHub's /repos/{owner}/{repo}/commits/{ref} endpoint. - Download the tarball at that SHA, decompress with the platform DecompressionStream, and stream-extract the USTAR archive directly into the workbench virtual file system at the caller's targetDir. The standard GitHub-archive wrapper directory ({repo}-{shortSha}/) is stripped so consumers see a clean tree, and any prior contents are wiped first so files removed upstream don't linger. - Persist {owner, repo, ref, sha, fetchedAt} per-target via IStorageService (chat.plugins.browserCache.v1). This lets revParse() answer locally and lets pull()/checkout() short-circuit when the upstream SHA matches the cached one -- which also feeds CustomizationRef.nonce so the AHP server's plugin manager dedupes. - Best-effort silent IAuthenticationService lookup attaches a GitHub token when one is already available, enabling private-repo installs; public repos still work with no session. 401/403 surfaces a typed GitHubAuthRequiredError so future UI can drive sign-in. - checkout() handles SHA-pinned plugin sources (the AbstractGitPluginSource path): no-op when the SHA matches, otherwise fetches the tarball at the requested SHA. Branches/tags/short SHAs resolve through the commits API. - Non-GitHub clone URLs throw an actionable localized error directing users to the desktop client or a remote agent host. - TAR extraction validates entry paths (rejects '.', '..', empty, NUL, leading-slash segments and double-checks isEqualOrParent) so a malicious archive cannot escape targetDir. The DI singleton registration in chat.contribution.ts already wires IPluginGitService -> BrowserPluginGitCommandService; the new constructor parameters are injected automatically. Tests - New src/vs/workbench/contrib/chat/test/browser/pluginGitCommandService.test.ts covers URL parsing (canonical / trailing-slash / extra-segment / malformed), tarball fetch+extract, no-op pull on SHA match, re-download on SHA change, stale-file cleanup, path-traversal entry rejection, checkout no-op / re-extract / no-metadata, and the auth-required error path. Test fixtures build a minimal valid USTAR + gzip via CompressionStream so bytes round-trip through the production DecompressionStream. Reuse notes - Uses isSuccess / isClientError / asJson from platform/request rather than rolling status-code checks. - Uses dirname / isEqualOrParent / joinPath from base/common/resources for path arithmetic and traversal defence. - GitHubApiClient (sessions/contrib/github) was considered but is layering-isolated, JSON-only, and forces sign-in -- wrong semantics for best-effort silent auth and binary tarball download. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: address council review on browser plugin git service Council review surfaced ~12 follow-ups across correctness, security, and parser robustness. This commit addresses the actionable ones. Correctness - Stage extraction in a sibling `.staging-{uuid}` directory and swap into place via `IFileService.move(..., overwrite=true)` only on success. If anything throws (network, gunzip, malformed tar, cancellation, FS write error), the staging dir is cleaned up and the existing `targetDir` is left untouched. Previously the target was wiped *before* extraction began, so a mid-flight failure left the persisted SHA cache pointing at an empty directory. (consensus C1) - `pull()` now wraps its catch block with `_maybeLogTransientError` for parity with `cloneRepository` / `checkout`. (S6) - `revParse(repoDir, ref)` no longer silently ignores `ref`: when asked for a 40-hex SHA that does not match the cached one, it throws instead of lying. (S3) Rate-limit detection - New `GitHubRateLimitError`, distinct from `GitHubAuthRequiredError`, thrown when GitHub returns 403 with `X-RateLimit-Remaining: 0` or a `Retry-After` header. Higher-layer UI can present "wait" rather than "sign in". `_maybeLogTransientError` logs the retry-after window. (C2) Auth + redirects - Drop the dead `followRedirects: 5` option (browser fetch ignores it per IRequestService impl). Add a comment on `fetchAndExtractGitHubTarball` explaining the codeload signed-URL flow: GitHub's tarball endpoint 302s to a URL whose authorization is encoded in the URL itself, so private-repo downloads survive the cross-origin Authorization-header strip. (C3 cleanup) - Document the multi-account auth-session selection limitation in `_lookupGitHubToken` rather than try to solve it here -- account selection is the auth provider's responsibility. (C6) Parser robustness - `readOctal` -> `readNumericField`. Now handles: - leading whitespace (legal POSIX padding) -- previously truncated to 0 - GNU base-256 binary encoding (high bit of byte 0 set) -- previously silently mis-aligned subsequent block offsets - Invalid fields throw rather than silently returning 0, so corrupt tarballs surface as errors instead of producing empty entries. - USTAR `prefix` field now joined unconditionally per spec (`${prefix}/${name}`); previous heuristic skipped the prefix when `name.startsWith(prefix)` which is non-standard. The GNU LongLink path correctly bypasses prefix join via a `fromLongLink` flag. (C5) - `stripArchiveRoot` rejects absolute paths instead of silently rebasing them under `targetDir`. (S4) - `safeJoinUnderTarget` also rejects backslash-bearing segments to defend against Windows-style separators on tar entries that would escape `targetDir` when materialised through a Windows AHP server's `agent-client:` provider. (S1) URL parsing - Reorder normalisation in `parseGitHubCloneUrl` so trailing slashes are stripped before the `.git` suffix; URLs like `https://github.com/o/r.git/` now parse correctly. (S2) Cache hygiene - Cache-key on `getComparisonKey(targetDir, ignoreFragment=true)` instead of `URI.toString()`, so callers passing equivalent URIs with different trailing-slash / percent-encoding don't silently miss the cache. (S5) - On first cache load, kick off a fire-and-forget sweep that drops entries whose `targetDir` no longer exists on disk. Bounds the storage map size when external code (e.g. `cleanupPluginSource`) deletes a plugin directory without notifying us. (C4) Tests - Rate-limit (`GitHubRateLimitError`) on `403 + X-RateLimit-Remaining: 0`. - Failed extraction leaves the previous `targetDir` intact and the cache reporting the previous SHA. - Backslash-traversal entry rejected. - USTAR prefix split + GNU LongLink paths via a new `makeGzippedTarWithSpecial` test fixture. - `parseGitHubCloneUrl` accepts `https://github.com/o/r.git/`. - `revParse` throws on unrelated full SHA, accepts cached one. Council items not addressed in this commit - Multi-account session selection (C6): documented as a VS Code-wide auth UX concern, not a plugin-git issue. - Cross-origin redirect end-to-end test (C3): the unit-test stub doesn't simulate redirects; the fix is a real-world smoke test against vscode.dev which is out of scope for this commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: keep default plugin toolbar actions * chat: refine plugin add actions * chat: reuse signed in github session for plugin clone * chat: fallback to anonymous plugin clone * chat: fetch plugin repos via tree+raw to bypass CORS GitHub's /tarball/ endpoint 302-redirects to codeload.github.com, which returns no CORS headers. Browser fetch() therefore fails the preflight check with TypeError: Failed to fetch before any of the existing auth-retry logic in BrowserPluginGitCommandService can run, so even public repos with a signed-in session cannot be installed. Replace the tarball flow with two CORS-friendly endpoints: - GET /repos/{owner}/{repo}/git/trees/{sha}?recursive=1 returns the full file listing. - GET https://raw.githubusercontent.com/{owner}/{repo}/{sha}/{path} returns each blob's bytes (and accepts the same Authorization header for private repos). Drop the now-unused gunzip + tar parser. Update tests to stub the new tree + raw responses instead of building synthetic gzipped tar archives. * chat: fetch plugin blobs via api.github.com to bypass CORS raw.githubusercontent.com refuses the OPTIONS preflight that an Authorization: Bearer header forces, so the previous tree+raw flow still failed the CORS check (TypeError: Failed to fetch). Switch the per-blob download to api.github.com's /git/blobs/{sha} endpoint, which is properly CORS-enabled and accepts auth headers. The blob SHA already comes back from the tree response, so no extra round-trips are needed; content is base64-encoded JSON which we decode via decodeBase64. Also add a loggedRequest wrapper that re-throws transport-level errors with the URL we were trying to reach. Without it, browser fetch CORS / DNS failures bubble up as a bare 'TypeError: Failed to fetch' that hides which request actually failed. Surface the same context through _maybeLogTransientError in the install path. * chat: drop unused bytesResponse test helper * chat: rename githubTarballFetcher to githubRepoFetcher The file no longer contains tarball logic — it fetches the repo tree and individual blobs via api.github.com. Rename to match. * chat: trim verbose comments in plugin git service * chat: tidy plugin git service auth ladder and comments - Flatten the nested try/catch in cloneRepository into a linear loop over the auth-ladder rungs (signed-in token → anonymous → fresh repo session). - Trim further verbose comments in the fetcher and cache helpers. * chat: surface locally-installed plugin items in remote-harness view When the active harness has both an itemProvider and a syncProvider (remote agent host), fetchItems blends remote items with local items. The local pass only included PromptsStorage.local / .user files, so files contributed by locally-installed agent plugins (e.g. one just cloned into vscode-userdata:/User/agent-plugins/...) never reached the customizations UI. Widen the local pass to also include PromptsStorage.plugin files. Plugin files are not individually syncable — the plugin is the unit of sync — so they're returned without the syncable marker. They still get the right grouping via the normalizer's plugin-URI check. * chat: refresh customizations debug output - Stage 6: render fromMarketplace as name@version (marketplace, type) instead of [object Object]. - Stage 3: surface syncable count and per-item syncable / pluginUri flags so the local syncable vs locally-installed plugin split (added in fetchLocalSyncableItems) is visible. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/AI_CUSTOMIZATIONS.md | 2 +- .../remoteAgentHostCustomizationHarness.ts | 2 +- .../aiCustomizationDebugPanel.ts | 22 +- .../aiCustomizationItemSource.ts | 36 +- .../aiCustomization/pluginListWidget.ts | 164 +++--- .../contrib/chat/browser/githubRepoFetcher.ts | 420 +++++++++++++++ .../chat/browser/pluginGitCommandService.ts | 356 ++++++++++++- .../common/customizationHarnessService.ts | 8 +- .../chat/common/plugins/pluginGitService.ts | 18 +- .../electron-browser/chat.contribution.ts | 3 +- .../browser/pluginGitCommandService.test.ts | 493 ++++++++++++++++++ 11 files changed, 1424 insertions(+), 100 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/githubRepoFetcher.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/pluginGitCommandService.test.ts diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index e719e773a095e..69d13b1c8bac9 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -96,7 +96,7 @@ In sessions, harnesses are accepted for any session type that has a registered c Remote agent hosts can also register **external harnesses** dynamically. Each remote agent harness may contribute: - an `itemProvider` that surfaces plugins already configured on the remote host (or synced into the active remote session), - a `disableProvider` that lets users opt out individual files/plugins from auto-sync, and -- `pluginActions` that replace the default local install/create buttons in the Plugins section toolbar with environment-specific commands such as "Add Remote Plugin". +- `pluginActions` that add environment-specific commands such as "Add Remote Plugin" to the Plugins section add menu alongside the default install-from-source action. The create action remains a separate toolbar button. The Plugins section renders remote harness `itemProvider` entries with `type: 'plugin'` directly. This is separate from the prompt-file pipeline used for Agents, Skills, Instructions, Prompts, and Hooks. diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts index 8c9000d47f9c1..55d92a25d51f1 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts @@ -130,7 +130,7 @@ export class RemoteAgentPluginController extends Disposable { id: 'remoteAgentHost.addPlugin', label: localize('remoteAgentHost.addPlugin', "Add Remote Plugin"), tooltip: localize('remoteAgentHost.addPluginTooltip', "Add a plugin folder that already exists on this remote agent host."), - icon: Codicon.add, + icon: Codicon.remote, run: () => this.addConfiguredPlugin(), }, ]; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts index 1250785f188ef..4117322a1e94f 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts @@ -16,7 +16,7 @@ import { IAgentPluginService } from '../../common/plugins/agentPluginService.js' * Snapshot of the list widget's internal state, passed in to avoid coupling. */ export interface IDebugWidgetState { - readonly allItems: readonly { readonly name?: string; readonly storage?: PromptsStorage; readonly groupKey?: string }[]; + readonly allItems: readonly { readonly name?: string; readonly storage?: PromptsStorage; readonly groupKey?: string; readonly syncable?: boolean; readonly pluginUri?: URI }[]; readonly displayEntries: readonly { type: string; label?: string; count?: number; collapsed?: boolean }[]; } @@ -273,9 +273,20 @@ function appendWidgetState(lines: string[], state: IDebugWidgetState): void { lines.push(` user: ${state.allItems.filter(i => i.storage === PromptsStorage.user).length}`); lines.push(` extension: ${state.allItems.filter(i => i.storage === PromptsStorage.extension).length}`); lines.push(` plugin: ${state.allItems.filter(i => i.storage === PromptsStorage.plugin).length}`); + const syncableCount = state.allItems.filter(i => i.syncable).length; + if (syncableCount > 0) { + lines.push(` syncable: ${syncableCount}`); + } for (const item of state.allItems) { - lines.push(` - ${item.name} [storage=${item.storage ?? '?'}, groupKey=${item.groupKey ?? '(none)'}]`); + const flags: string[] = [`storage=${item.storage ?? '?'}`, `groupKey=${item.groupKey ?? '(none)'}`]; + if (item.syncable) { + flags.push('syncable'); + } + if (item.pluginUri) { + flags.push(`pluginUri=${item.pluginUri.toString()}`); + } + lines.push(` - ${item.name} [${flags.join(', ')}]`); } lines.push(` displayEntries (after filterItems): ${state.displayEntries.length}`); @@ -342,7 +353,12 @@ function appendInstalledPlugins(lines: string[], agentPluginService: IAgentPlugi lines.push(` Total: ${plugins.length}`); for (const p of plugins) { lines.push(` [${p.label}] ${p.uri.toString()}`); - lines.push(` fromMarketplace: ${p.fromMarketplace}`); + if (p.fromMarketplace) { + const m = p.fromMarketplace; + lines.push(` fromMarketplace: ${m.name}@${m.version} (marketplace=${m.marketplace}, type=${m.marketplaceType})`); + } else { + lines.push(` fromMarketplace: (none)`); + } } lines.push(''); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index c5828220b6872..5a8bce3163459 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -458,26 +458,34 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour return []; } - const providerItems: ICustomizationItem[] = files - .filter(file => file.storage === PromptsStorage.local || file.storage === PromptsStorage.user) - .map(file => ({ - uri: file.uri, - type: promptType, - name: getFriendlyName(basename(file.uri)), - groupKey: 'sync-local', - enabled: true, - extensionId: file.extension?.id, - pluginUri: file.pluginUri, - userInvocable: undefined - })); - - return this.itemNormalizer.normalizeItems(providerItems, promptType) + const toProviderItem = (file: typeof files[number]): ICustomizationItem => ({ + uri: file.uri, + type: promptType, + name: getFriendlyName(basename(file.uri)), + groupKey: 'sync-local', + enabled: true, + extensionId: file.extension?.id, + pluginUri: file.pluginUri, + userInvocable: undefined + }); + + // Local/user files are sync-eligible (the user picks individual items + // to push to the remote agent host). Locally-installed plugin files + // always show up but are not individually syncable — the plugin is + // the unit of sync. + const syncEligibleFiles = files.filter(file => file.storage === PromptsStorage.local || file.storage === PromptsStorage.user); + const pluginFiles = files.filter(file => file.storage === PromptsStorage.plugin); + + const syncEligibleItems = this.itemNormalizer.normalizeItems(syncEligibleFiles.map(toProviderItem), promptType) .map(item => ({ ...item, id: `sync-${item.id}`, syncable: true, synced: !syncProvider.isDisabled(item.uri), })); + const pluginItems = this.itemNormalizer.normalizeItems(pluginFiles.map(toProviderItem), promptType); + + return [...syncEligibleItems, ...pluginItems]; } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts index 77e9580c436a3..026adb791d7e3 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts @@ -13,7 +13,7 @@ import { WorkbenchList } from '../../../../../platform/list/browser/listService. import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent } from '../../../../../base/browser/ui/list/list.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { Codicon } from '../../../../../base/common/codicons.js'; -import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { Button, ButtonWithDropdown } from '../../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { autorun } from '../../../../../base/common/observable.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; @@ -26,6 +26,7 @@ import { Delayer } from '../../../../../base/common/async.js'; import { Action, IAction, Separator } from '../../../../../base/common/actions.js'; import { basename, dirname, isEqual } from '../../../../../base/common/resources.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { isWeb } from '../../../../../base/common/platform.js'; import { IAgentPlugin, IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { isContributionEnabled } from '../../common/enablement.js'; import { getInstalledPluginContextMenuActions } from '../agentPluginActions.js'; @@ -438,9 +439,13 @@ export class PluginListWidget extends Disposable { private disabledIcon!: HTMLElement; private disabledMessage!: HTMLElement; private readonly disabledLinkListener = this._register(new MutableDisposable()); + private buttonContainer!: HTMLElement; private browseButton!: Button; - private installFromSourceButton!: Button; + private addButtonContainer!: HTMLElement; + private addButtonSimple!: Button; + private addButton!: ButtonWithDropdown; private createPluginButton!: Button; + private readonly addDropdownActions = this._register(new DisposableStore()); private installedItems: IInstalledPluginItem[] = []; private remoteItems: ICustomizationItem[] = []; @@ -506,21 +511,35 @@ export class PluginListWidget extends Disposable { } })); - // Button container (Browse Marketplace + Install from Source) - const buttonContainer = DOM.append(this.searchAndButtonContainer, $('.list-button-group')); + // Button container (Browse Marketplace + Add actions + Create Plugin) + this.buttonContainer = DOM.append(this.searchAndButtonContainer, $('.list-button-group')); - const browseButtonContainer = DOM.append(buttonContainer, $('.list-add-button-container')); + const browseButtonContainer = DOM.append(this.buttonContainer, $('.list-add-button-container')); this.browseButton = this._register(new Button(browseButtonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); this.browseButton.element.classList.add('list-add-button'); this._register(this.browseButton.onDidClick(() => this.runPrimaryButtonAction())); - this.installFromSourceButton = this._register(new Button(buttonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); - this.installFromSourceButton.element.classList.add('list-icon-button'); - this._register(this.installFromSourceButton.onDidClick(() => this.runSecondaryButtonAction(0))); + this.addButtonContainer = DOM.append(this.buttonContainer, $('.list-add-button-container')); + this.addButtonSimple = this._register(new Button(this.addButtonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); + this.addButtonSimple.element.classList.add('list-add-button'); + this._register(this.addButtonSimple.onDidClick(() => this.runPrimaryAddAction())); + + this.addButton = this._register(new ButtonWithDropdown(this.addButtonContainer, { + ...defaultButtonStyles, + secondary: true, + supportIcons: true, + contextMenuProvider: this.contextMenuService, + addPrimaryActionToDropdown: false, + actions: { getActions: () => this.getAddDropdownActions() }, + })); + this.addButton.element.classList.add('list-add-button'); + this._register(this.addButton.onDidClick(() => this.runPrimaryAddAction())); - this.createPluginButton = this._register(new Button(buttonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); + const createPluginLabel = localize('createPlugin', "Create Plugin"); + this.createPluginButton = this._register(new Button(this.buttonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: createPluginLabel, ariaLabel: createPluginLabel })); this.createPluginButton.element.classList.add('list-icon-button'); - this._register(this.createPluginButton.onDidClick(() => this.runSecondaryButtonAction(1))); + this.createPluginButton.label = `$(${Codicon.newFile.id})`; + this._register(this.createPluginButton.onDidClick(() => this.runCreatePluginAction())); // Empty state this.emptyContainer = DOM.append(this.element, $('.mcp-empty-state')); @@ -727,80 +746,99 @@ export class PluginListWidget extends Disposable { } private updateToolbarActions(): void { - const actions = this.pluginActions; - if (actions.length > 0) { - if (this.browseMode) { - this.toggleBrowseMode(false); - } + const browseMarketplaceAvailable = this.isBrowseMarketplaceAvailable(); + if (!browseMarketplaceAvailable && this.browseMode) { + this.toggleBrowseMode(false); + } - const [primary, firstSecondary, secondSecondary] = actions; - this.browseButton.element.parentElement!.style.display = ''; - this.browseButton.label = this.formatActionLabel(primary); - this.browseButton.enabled = primary.enabled !== false; - this.browseButton.setTitle(primary.tooltip ?? primary.label); - - const secondary = [ - [this.installFromSourceButton, firstSecondary], - [this.createPluginButton, secondSecondary], - ] as const; - for (const [button, action] of secondary) { - if (!action) { - button.element.style.display = 'none'; - continue; - } + this.browseButton.element.parentElement!.style.display = this.browseMode ? 'none' : ''; + this.browseButton.label = `$(${Codicon.library.id}) ${localize('browseMarketplace', "Browse Marketplace")}`; + this.browseButton.enabled = browseMarketplaceAvailable; + this.browseButton.setTitle(browseMarketplaceAvailable + ? localize('browseMarketplace', "Browse Marketplace") + : localize('browseMarketplaceUnsupportedWeb', "Browse Marketplace is not available in VS Code for the Web.")); - button.element.style.display = ''; - button.label = this.formatActionLabel(action, true); - button.enabled = action.enabled !== false; - button.setTitle(action.tooltip ?? action.label); - } + this.updateAddButton(); + this.createPluginButton.enabled = true; + } + + private isBrowseMarketplaceAvailable(): boolean { + return !isWeb; + } + + private updateAddButton(): void { + const actions = this.buildAddActions(); + const [primary, ...dropdown] = actions; + const hasDropdown = dropdown.length > 0; + + this.addButton.element.style.display = hasDropdown ? '' : 'none'; + this.addButtonSimple.element.style.display = hasDropdown ? 'none' : ''; + + if (!primary) { + this.addButton.element.style.display = 'none'; + this.addButtonSimple.element.style.display = 'none'; return; } - this.browseButton.label = `$(${Codicon.library.id}) ${localize('browseMarketplace', "Browse Marketplace")}`; - this.browseButton.enabled = true; - this.browseButton.setTitle(localize('browseMarketplace', "Browse Marketplace")); + if (hasDropdown) { + this.addButton.label = this.formatActionLabel(primary); + this.addButton.enabled = primary.enabled !== false; + this.addButton.primaryButton.setTitle(primary.tooltip ?? primary.label); + this.addButton.dropdownButton.setTitle(localize('morePluginAddActions', "More Plugin Add Actions...")); + } else { + this.addButtonSimple.label = this.formatActionLabel(primary); + this.addButtonSimple.enabled = primary.enabled !== false; + this.addButtonSimple.setTitle(primary.tooltip ?? primary.label); + } + } - this.installFromSourceButton.element.style.display = ''; - this.installFromSourceButton.label = `$(${Codicon.add.id})`; - this.installFromSourceButton.enabled = true; - this.installFromSourceButton.setTitle(localize('installFromSource', "Install Plugin from Source")); + private buildAddActions(): readonly ICustomizationItemAction[] { + return [ + ...this.pluginActions, + { + id: 'plugin.installFromSource', + label: localize('installFromSource', "Install Plugin from Source"), + tooltip: localize('installFromSource', "Install Plugin from Source"), + icon: Codicon.add, + run: () => this.commandService.executeCommand('workbench.action.chat.installPluginFromSource'), + }, + ]; + } - this.createPluginButton.element.style.display = ''; - this.createPluginButton.label = `$(${Codicon.newFile.id})`; - this.createPluginButton.enabled = true; - this.createPluginButton.setTitle(localize('createPlugin', "Create Plugin")); + private getAddDropdownActions(): Action[] { + this.addDropdownActions.clear(); + return this.buildAddActions().slice(1).map((action, index) => this.addDropdownActions.add(new Action(`plugin_add_${index}`, this.formatActionLabel(action), undefined, action.enabled !== false, () => this.runPluginAction(action)))); } private async runPrimaryButtonAction(): Promise { - const action = this.pluginActions[0]; - if (action) { - if (action.enabled !== false) { - await action.run(); - } + if (!this.isBrowseMarketplaceAvailable()) { return; } this.toggleBrowseMode(!this.browseMode); } - private async runSecondaryButtonAction(index: number): Promise { - const action = this.pluginActions[index + 1]; - if (action) { - if (action.enabled !== false) { - await action.run(); - } - return; + private async runPrimaryAddAction(): Promise { + const [primary] = this.buildAddActions(); + if (primary) { + await this.runPluginAction(primary); } + } - if (index === 0) { - await this.commandService.executeCommand('workbench.action.chat.installPluginFromSource'); - } else { - await this.commandService.executeCommand('workbench.action.chat.createPlugin'); + private async runCreatePluginAction(): Promise { + await this.commandService.executeCommand('workbench.action.chat.createPlugin'); + } + + private async runPluginAction(action: ICustomizationItemAction): Promise { + if (action.enabled !== false) { + await action.run(); } } public showBrowseMarketplace(): void { + if (!this.isBrowseMarketplaceAvailable()) { + return; + } if (!this.browseMode) { this.toggleBrowseMode(true); } @@ -967,7 +1005,7 @@ export class PluginListWidget extends Disposable { this.emptySubtext.textContent = localize('tryDifferentSearch', "Try a different search term"); } else if (this.harnessService.getActiveDescriptor().itemProvider) { this.emptyText.textContent = localize('noRemotePlugins', "No plugins configured"); - this.emptySubtext.textContent = localize('addRemotePlugins', "Use the toolbar to add plugins from the remote agent host."); + this.emptySubtext.textContent = localize('addRemotePlugins', "Use the toolbar to add remote plugins or install plugins from a source."); } else { this.emptyText.textContent = localize('noPlugins', "No plugins installed"); this.emptySubtext.textContent = localize('browseToAdd', "Browse the marketplace to discover and install plugins"); diff --git a/src/vs/workbench/contrib/chat/browser/githubRepoFetcher.ts b/src/vs/workbench/contrib/chat/browser/githubRepoFetcher.ts new file mode 100644 index 0000000000000..ef815ed75f28f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/githubRepoFetcher.ts @@ -0,0 +1,420 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Limiter } from '../../../../base/common/async.js'; +import { VSBuffer, decodeBase64 } from '../../../../base/common/buffer.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { CancellationError } from '../../../../base/common/errors.js'; +import { dirname, isEqualOrParent, joinPath } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IRequestService, asJson, isClientError, isSuccess } from '../../../../platform/request/common/request.js'; + +/** + * GitHub `owner/repo` parsed from a clone URL. Only `https://github.com/...` URLs + * are supported in the browser implementation. + */ +export interface IGitHubRepoRef { + readonly owner: string; + readonly repo: string; +} + +const GITHUB_HOSTS = new Set(['github.com', 'www.github.com']); + +/** + * Parse a clone URL into an `owner/repo` pair. + * @returns the parsed reference, or `undefined` if the URL is not a recognised + * GitHub HTTPS clone URL. + */ +export function parseGitHubCloneUrl(cloneUrl: string): IGitHubRepoRef | undefined { + let url: URL; + try { + url = new URL(cloneUrl); + } catch { + return undefined; + } + if (url.protocol !== 'https:' || !GITHUB_HOSTS.has(url.hostname.toLowerCase())) { + return undefined; + } + // Trim slashes before stripping `.git` so `.../o/r.git/` normalises to `o/r`. + const path = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '').replace(/\.git$/i, ''); + const segments = path.split('/'); + // Require exactly two segments to avoid mis-parsing `.../owner/repo/issues/42`. + if (segments.length !== 2 || !segments[0] || !segments[1]) { + return undefined; + } + return { owner: segments[0], repo: segments[1] }; +} + +/** Response shape from GitHub's `GET /repos/{owner}/{repo}/commits/{ref}`. */ +interface IGitHubCommitResponse { + readonly sha: string; +} + +/** + * Wrap a `requestService.request` call so transport-level errors (browser + * `fetch` throws an opaque `TypeError: Failed to fetch` for CORS / DNS / + * connection failures) include the URL and call site. + */ +async function loggedRequest( + requestService: IRequestService, + options: { url: string; headers: Record; callSite: string }, + token: CancellationToken, +) { + try { + return await requestService.request({ type: 'GET', url: options.url, headers: options.headers, callSite: options.callSite }, token); + } catch (err) { + const reason = err instanceof Error ? `${err.name}: ${err.message}` : String(err); + throw new Error(`Network error during ${options.callSite} (GET ${options.url}): ${reason}`, { cause: err instanceof Error ? err : undefined }); + } +} + +/** + * Resolve a ref (branch / tag / SHA / undefined for default branch) to a + * commit SHA via the GitHub commits API. + */ +export async function resolveGitHubRefToSha( + requestService: IRequestService, + repo: IGitHubRepoRef, + ref: string | undefined, + authToken: string | undefined, + token: CancellationToken, +): Promise { + const refSegment = ref && ref.length > 0 ? encodeURIComponent(ref) : 'HEAD'; + const url = `https://api.github.com/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}/commits/${refSegment}`; + const headers: Record = { + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }; + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + const ctx = await loggedRequest(requestService, { url, headers, callSite: 'pluginGit.resolveSha' }, token); + if (token.isCancellationRequested) { + throw new CancellationError(); + } + const status = ctx.res.statusCode ?? 0; + if (status === 403 && isRateLimited(ctx.res.headers)) { + throw new GitHubRateLimitError(`GitHub rate limit hit resolving ref '${ref ?? 'HEAD'}' on ${repo.owner}/${repo.repo}`, retryAfterFromHeaders(ctx.res.headers)); + } + if (status === 401 || status === 403) { + throw new GitHubAuthRequiredError(`GitHub returned ${status} resolving ref '${ref ?? 'HEAD'}' on ${repo.owner}/${repo.repo}`); + } + if (status === 404) { + throw new Error(`GitHub repository or ref not found: ${repo.owner}/${repo.repo}@${ref ?? 'HEAD'}`); + } + if (!isSuccess(ctx)) { + throw new Error(`GitHub returned ${status}${isClientError(ctx) ? ' (client error)' : ''} resolving ref for ${repo.owner}/${repo.repo}`); + } + const body = await asJson(ctx); + if (!body || typeof body.sha !== 'string') { + throw new Error(`GitHub commit response for ${repo.owner}/${repo.repo} missing 'sha' field`); + } + return body.sha; +} + +/** + * Thrown when GitHub responds with 401/403 to indicate the caller needs to + * sign in (or has insufficient permissions) for a private repository. + */ +export class GitHubAuthRequiredError extends Error { + constructor(message: string) { + super(message); + this.name = 'GitHubAuthRequiredError'; + } +} + +/** + * Thrown when GitHub responds with 403 + `X-RateLimit-Remaining: 0` to + * indicate the caller has exhausted the request quota for the current + * window. Distinct from {@link GitHubAuthRequiredError} so callers don't + * push users to sign in when the actual fix is "wait". + */ +export class GitHubRateLimitError extends Error { + constructor(message: string, public readonly retryAfterSeconds?: number) { + super(message); + this.name = 'GitHubRateLimitError'; + } +} + +function isRateLimited(headers: Record | undefined): boolean { + if (!headers) { + return false; + } + if (readHeader(headers, 'x-ratelimit-remaining') === '0') { + return true; + } + // Secondary rate limit: GitHub omits X-RateLimit-Remaining but sets Retry-After. + return readHeader(headers, 'retry-after') !== undefined; +} + +function retryAfterFromHeaders(headers: Record | undefined): number | undefined { + if (!headers) { + return undefined; + } + const value = readHeader(headers, 'retry-after'); + if (!value) { + return undefined; + } + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function readHeader(headers: Record, name: string): string | undefined { + const value = headers[name] ?? headers[name.toLowerCase()]; + if (Array.isArray(value)) { + return value[0]; + } + return value; +} + +/** + * Fetches the file tree of a GitHub repository at the given SHA and writes + * each blob into {@link targetDir} via {@link IFileService}. + * + * Uses two CORS-friendly endpoints on `api.github.com` (the `/tarball/` host + * has no CORS, and `raw.githubusercontent.com` rejects the OPTIONS preflight + * forced by an `Authorization` header): + * + * - `GET /repos/{owner}/{repo}/git/trees/{sha}?recursive=1` for the listing. + * - `GET /repos/{owner}/{repo}/git/blobs/{blob_sha}` for each blob (base64). + * + * Extraction is staged to a sibling directory and atomically swapped into + * place on success; failures clean up the staging dir and leave `targetDir` + * untouched. Symlinks (`mode === '120000'`) and submodules (`type === 'commit'`) + * are skipped — neither is representable in the workbench virtual file system. + */ +export async function fetchAndExtractGitHubRepo( + requestService: IRequestService, + fileService: IFileService, + logService: ILogService, + repo: IGitHubRepoRef, + sha: string, + targetDir: URI, + authToken: string | undefined, + token: CancellationToken, +): Promise { + const tree = await fetchGitHubTree(requestService, repo, sha, authToken, token); + if (tree.truncated) { + // GitHub caps the recursive tree response at ~100K entries / ~7MB. + logService.warn(`[GitHubRepoFetcher] Tree for ${repo.owner}/${repo.repo}@${sha} is truncated; some files will be missing from the install`); + } + + const stagingDir = joinPath(dirname(targetDir), `.staging-${generateUuid()}`); + try { + await fileService.createFolder(stagingDir); + const blobsToFetch: { entry: IGitHubTreeEntry; dest: URI }[] = []; + const createdDirs = new Set([stagingDir.toString()]); + + for (const entry of tree.tree) { + if (token.isCancellationRequested) { + throw new CancellationError(); + } + if (entry.type === 'commit') { + logService.trace(`[GitHubRepoFetcher] Skipping submodule entry ${entry.path}`); + continue; + } + if (entry.mode === '120000') { + logService.trace(`[GitHubRepoFetcher] Skipping symlink entry ${entry.path}`); + continue; + } + const dest = safeJoinUnderTarget(stagingDir, entry.path); + if (!dest) { + logService.warn(`[GitHubRepoFetcher] Skipping unsafe tree entry path: ${entry.path}`); + continue; + } + if (entry.type === 'tree') { + if (!createdDirs.has(dest.toString())) { + await fileService.createFolder(dest); + createdDirs.add(dest.toString()); + } + continue; + } + if (entry.type !== 'blob') { + logService.trace(`[GitHubRepoFetcher] Skipping tree entry with unsupported type '${entry.type}': ${entry.path}`); + continue; + } + // Pre-create the parent directory so parallel blob writes don't race on `createFolder`. + const parent = dirname(dest); + if (parent.toString() !== dest.toString() && !createdDirs.has(parent.toString())) { + await fileService.createFolder(parent); + createdDirs.add(parent.toString()); + } + blobsToFetch.push({ entry, dest }); + } + + const limiter = new Limiter(MAX_PARALLEL_BLOB_FETCHES); + await Promise.all(blobsToFetch.map(({ entry, dest }) => limiter.queue(async () => { + if (token.isCancellationRequested) { + throw new CancellationError(); + } + const content = await fetchGitHubBlob(requestService, repo, sha, entry, authToken, token); + await fileService.writeFile(dest, content); + }))); + + await fileService.move(stagingDir, targetDir, true); + } catch (err) { + try { + if (await fileService.exists(stagingDir)) { + await fileService.del(stagingDir, { recursive: true }); + } + } catch (cleanupErr) { + logService.warn(`[GitHubRepoFetcher] Failed to clean up staging dir ${stagingDir.toString()}:`, cleanupErr); + } + throw err; + } +} + +/** Maximum number of blob downloads to issue in parallel. */ +const MAX_PARALLEL_BLOB_FETCHES = 10; + +/** A single entry in a GitHub git-trees API response. */ +interface IGitHubTreeEntry { + readonly path: string; + /** + * File mode as a string of octal digits. The values we care about are + * `'120000'` (symlink, skipped) and the various blob/tree modes. + */ + readonly mode: string; + /** `'blob'` for files, `'tree'` for directories, `'commit'` for submodules. */ + readonly type: 'blob' | 'tree' | 'commit'; + readonly sha: string; + readonly size?: number; +} + +/** Response shape from `GET /repos/{owner}/{repo}/git/trees/{sha}?recursive=1`. */ +interface IGitHubTreeResponse { + readonly sha: string; + readonly tree: readonly IGitHubTreeEntry[]; + readonly truncated: boolean; +} + +async function fetchGitHubTree( + requestService: IRequestService, + repo: IGitHubRepoRef, + sha: string, + authToken: string | undefined, + token: CancellationToken, +): Promise { + const url = `https://api.github.com/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}/git/trees/${encodeURIComponent(sha)}?recursive=1`; + const headers: Record = { + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }; + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + const ctx = await loggedRequest(requestService, { url, headers, callSite: 'pluginGit.tree' }, token); + if (token.isCancellationRequested) { + throw new CancellationError(); + } + const status = ctx.res.statusCode ?? 0; + if (status === 403 && isRateLimited(ctx.res.headers)) { + throw new GitHubRateLimitError(`GitHub rate limit hit fetching tree for ${repo.owner}/${repo.repo}@${sha}`, retryAfterFromHeaders(ctx.res.headers)); + } + if (status === 401 || status === 403) { + throw new GitHubAuthRequiredError(`GitHub returned ${status} fetching tree for ${repo.owner}/${repo.repo}@${sha}`); + } + if (status === 404) { + throw new Error(`GitHub repository or commit not found: ${repo.owner}/${repo.repo}@${sha}`); + } + if (!isSuccess(ctx)) { + throw new Error(`GitHub returned ${status}${isClientError(ctx) ? ' (client error)' : ''} fetching tree for ${repo.owner}/${repo.repo}@${sha}`); + } + const body = await asJson(ctx); + if (!body || !Array.isArray(body.tree)) { + throw new Error(`GitHub tree response for ${repo.owner}/${repo.repo}@${sha} missing 'tree' array`); + } + return body; +} + +async function fetchGitHubBlob( + requestService: IRequestService, + repo: IGitHubRepoRef, + commitSha: string, + entry: IGitHubTreeEntry, + authToken: string | undefined, + token: CancellationToken, +): Promise { + // Use api.github.com's blobs endpoint rather than raw.githubusercontent.com: + // the raw host rejects the OPTIONS preflight that an `Authorization` header + // forces. The blob SHA comes from the tree response. + const url = `https://api.github.com/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}/git/blobs/${encodeURIComponent(entry.sha)}`; + const headers: Record = { + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }; + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + const ctx = await loggedRequest(requestService, { url, headers, callSite: 'pluginGit.blob' }, token); + if (token.isCancellationRequested) { + throw new CancellationError(); + } + const status = ctx.res.statusCode ?? 0; + if (status === 403 && isRateLimited(ctx.res.headers)) { + throw new GitHubRateLimitError(`GitHub rate limit hit fetching blob '${entry.path}' for ${repo.owner}/${repo.repo}@${commitSha}`, retryAfterFromHeaders(ctx.res.headers)); + } + if (status === 401 || status === 403) { + throw new GitHubAuthRequiredError(`GitHub returned ${status} fetching blob '${entry.path}' for ${repo.owner}/${repo.repo}@${commitSha}`); + } + if (!isSuccess(ctx)) { + throw new Error(`GitHub returned ${status} fetching blob '${entry.path}' for ${repo.owner}/${repo.repo}@${commitSha}`); + } + const body = await asJson(ctx); + if (!body || typeof body.content !== 'string') { + throw new Error(`GitHub blob response for '${entry.path}' missing 'content' field`); + } + if (body.encoding !== 'base64') { + throw new Error(`GitHub blob response for '${entry.path}' has unsupported encoding '${body.encoding}'`); + } + // GitHub wraps base64 at 60 columns; strip whitespace before decoding. + return decodeBase64(body.content.replace(/\s+/g, '')); +} + +/** Response shape from `GET /repos/{owner}/{repo}/git/blobs/{sha}`. */ +interface IGitHubBlobResponse { + readonly content: string; + readonly encoding: string; +} + +/** + * Sanitize a tree entry path to safe segments under {@link targetDir}. Rejects + * NUL bytes, absolute paths, `.`/`..` segments, and segments containing a + * backslash (which Windows path APIs would treat as a separator). The result + * is double-checked with `isEqualOrParent` as defence in depth. + * + * @returns the resolved URI, or `undefined` for paths that should be skipped. + */ +function safeJoinUnderTarget(targetDir: URI, inner: string): URI | undefined { + if (inner.includes('\0') || inner.startsWith('/') || inner.startsWith('\\')) { + return undefined; + } + const segments: string[] = []; + for (const seg of inner.split('/')) { + if (seg.length === 0 || seg === '.') { + continue; + } + if (seg === '..' || seg.includes('\\')) { + return undefined; + } + segments.push(seg); + } + if (segments.length === 0) { + return undefined; + } + const dest = joinPath(targetDir, ...segments); + // Defence in depth: ensure the joined URI has not escaped via any + // platform-specific normalisation we missed. + if (!isEqualOrParent(dest, targetDir)) { + return undefined; + } + return dest; +} diff --git a/src/vs/workbench/contrib/chat/browser/pluginGitCommandService.ts b/src/vs/workbench/contrib/chat/browser/pluginGitCommandService.ts index 4e38b92e2bfca..c653b758c97c2 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginGitCommandService.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginGitCommandService.ts @@ -4,27 +4,359 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { CancellationError } from '../../../../base/common/errors.js'; +import { getComparisonKey } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IRequestService } from '../../../../platform/request/common/request.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IAuthenticationService } from '../../../services/authentication/common/authentication.js'; import { IPluginGitService } from '../common/plugins/pluginGitService.js'; +import { + GitHubAuthRequiredError, + GitHubRateLimitError, + IGitHubRepoRef, + fetchAndExtractGitHubRepo, + parseGitHubCloneUrl, + resolveGitHubRefToSha, +} from './githubRepoFetcher.js'; -function notSupported(): never { - throw new Error(localize('pluginsNotSupported', 'Agent plugins are not available in this environment')); +/** Storage key for the per-target metadata index used by this service. */ +const BROWSER_CACHE_STORAGE_KEY = 'chat.plugins.browserCache.v1'; + +/** + * Per-target metadata persisted via {@link IStorageService}. Keyed by the + * `targetDir.toString()` of the cloned repository so we can answer + * `revParse('HEAD')` and detect "is the cached snapshot still current?" on + * `pull()` without an extra GitHub round-trip. + */ +interface IBrowserPluginCacheEntry { + readonly owner: string; + readonly repo: string; + readonly ref?: string; + readonly sha: string; + readonly fetchedAt: number; } +type IStoredBrowserPluginCache = Record; + /** - * Stub implementation of {@link IPluginGitService} that throws on - * every call. On desktop the native implementation is registered instead; - * this exists only so the browser layer has a default registration. + * Browser implementation of {@link IPluginGitService}. + * + * `git` is not available in the browser, so plugin contents are reconstructed + * from the GitHub REST API: `/git/trees/{sha}?recursive=1` for the listing and + * `/git/blobs/{blob_sha}` for each file's bytes. Both live on `api.github.com`, + * which is the only GitHub host that handles CORS with auth headers — the + * `/tarball/` endpoint redirects to `codeload.github.com` (no CORS) and + * `raw.githubusercontent.com` rejects the OPTIONS preflight forced by + * `Authorization: Bearer`. + * + * Only HTTPS GitHub clone URLs are supported; everything else throws an + * actionable localized error pointing at desktop or a remote agent host. + * + * Per-target metadata is persisted via {@link IStorageService} so `revParse` + * answers locally, `pull()` skips the re-download when the upstream SHA has + * not moved, and the persisted SHA feeds `CustomizationRef.nonce` for AHP + * dedupe. */ export class BrowserPluginGitCommandService implements IPluginGitService { declare readonly _serviceBrand: undefined; - async cloneRepository(_cloneUrl: string, _targetDir: URI, _ref?: string, _token?: CancellationToken): Promise { notSupported(); } - async pull(_repoDir: URI, _token?: CancellationToken): Promise { notSupported(); } - async checkout(_repoDir: URI, _treeish: string, _detached?: boolean, _token?: CancellationToken): Promise { notSupported(); } - async revParse(_repoDir: URI, _ref: string): Promise { notSupported(); } - async fetch(_repoDir: URI, _token?: CancellationToken): Promise { notSupported(); } - async fetchRepository(_repoDir: URI, _token?: CancellationToken): Promise { notSupported(); } - async revListCount(_repoDir: URI, _fromRef: string, _toRef: string): Promise { notSupported(); } + private _cache: Map | undefined; + + constructor( + @IFileService private readonly _fileService: IFileService, + @ILogService private readonly _logService: ILogService, + @IRequestService private readonly _requestService: IRequestService, + @IStorageService private readonly _storageService: IStorageService, + @IAuthenticationService private readonly _authenticationService: IAuthenticationService, + ) { } + + async cloneRepository(cloneUrl: string, targetDir: URI, ref?: string, token?: CancellationToken): Promise { + const repo = this._parseOrThrow(cloneUrl); + const cancel = token ?? CancellationToken.None; + const cloneWithToken = async (authToken: string | undefined): Promise => { + const sha = await resolveGitHubRefToSha(this._requestService, repo, ref, authToken, cancel); + await fetchAndExtractGitHubRepo(this._requestService, this._fileService, this._logService, repo, sha, targetDir, authToken, cancel); + this._setCacheEntry(targetDir, { owner: repo.owner, repo: repo.repo, ref, sha, fetchedAt: Date.now() }); + }; + + // Auth ladder: signed-in token → anonymous → freshly-requested repo session. + // Each rung only runs when the previous one failed with a 401/403 (the + // `GitHubAuthRequiredError`); other errors propagate immediately. + const initialAuthToken = await this._lookupGitHubToken(); + const attempts: Array<() => Promise> = [ + async () => initialAuthToken, + ]; + if (initialAuthToken) { + attempts.push(async () => undefined); + } + attempts.push(() => this._requestGitHubToken(repo)); + + let lastErr: unknown; + for (const getToken of attempts) { + if (cancel.isCancellationRequested) { + throw new CancellationError(); + } + try { + await cloneWithToken(await getToken()); + return; + } catch (err) { + lastErr = err; + this._maybeLogTransientError(err, repo); + if (!(err instanceof GitHubAuthRequiredError)) { + throw err; + } + } + } + + if (lastErr instanceof GitHubAuthRequiredError) { + throw new Error(localize( + 'pluginsBrowserGitHubAccessRequired', + "GitHub authentication is required to install '{0}'. Sign in with an account that has access to this repository, then try again.", + `${repo.owner}/${repo.repo}`, + )); + } + throw lastErr; + } + + async pull(repoDir: URI, token?: CancellationToken): Promise { + const entry = this._getCacheEntry(repoDir); + if (!entry) { + throw new Error(`Cannot pull plugin: no cached metadata for ${repoDir.toString()}`); + } + const cancel = token ?? CancellationToken.None; + const authToken = await this._lookupGitHubToken(); + const repo: IGitHubRepoRef = { owner: entry.owner, repo: entry.repo }; + try { + const newSha = await resolveGitHubRefToSha(this._requestService, repo, entry.ref, authToken, cancel); + if (newSha === entry.sha) { + return false; + } + await fetchAndExtractGitHubRepo(this._requestService, this._fileService, this._logService, repo, newSha, repoDir, authToken, cancel); + this._setCacheEntry(repoDir, { ...entry, sha: newSha, fetchedAt: Date.now() }); + return true; + } catch (err) { + this._maybeLogTransientError(err, repo); + throw err; + } + } + + async checkout(repoDir: URI, treeish: string, _detached?: boolean, token?: CancellationToken): Promise { + const entry = this._getCacheEntry(repoDir); + if (!entry) { + throw new Error(`Cannot checkout plugin: no cached metadata for ${repoDir.toString()}`); + } + + const cancel = token ?? CancellationToken.None; + const authToken = await this._lookupGitHubToken(); + const repo: IGitHubRepoRef = { owner: entry.owner, repo: entry.repo }; + const requestedRef = treeish.trim(); + + // 40-hex SHA refs skip the resolveSha round-trip (clone pins to the SHA already). + const isFullSha = /^[0-9a-f]{40}$/i.test(requestedRef); + const requestedSha = isFullSha + ? requestedRef.toLowerCase() + : await resolveGitHubRefToSha(this._requestService, repo, requestedRef, authToken, cancel); + + if (requestedSha === entry.sha.toLowerCase()) { + return; + } + + try { + await fetchAndExtractGitHubRepo(this._requestService, this._fileService, this._logService, repo, requestedSha, repoDir, authToken, cancel); + this._setCacheEntry(repoDir, { + ...entry, + ref: isFullSha ? entry.ref : requestedRef, + sha: requestedSha, + fetchedAt: Date.now(), + }); + } catch (err) { + this._maybeLogTransientError(err, repo); + throw err; + } + } + + async revParse(repoDir: URI, ref: string): Promise { + const entry = this._getCacheEntry(repoDir); + if (!entry) { + throw new Error(`Cannot resolve ref: no cached metadata for ${repoDir.toString()}`); + } + // Reject unrelated SHAs so callers notice they got a cache hit instead of `git rev-parse`. + const trimmed = ref.trim(); + const isFullSha = /^[0-9a-f]{40}$/i.test(trimmed); + if (isFullSha && trimmed.toLowerCase() !== entry.sha.toLowerCase()) { + throw new Error(`Cannot resolve ref '${ref}' in tree-cached plugin: only HEAD/${entry.sha} is materialised`); + } + return entry.sha; + } + + async fetch(_repoDir: URI, _token?: CancellationToken): Promise { + // No-op: there is no local git database. `pull()` re-fetches when needed. + } + + async fetchRepository(_repoDir: URI, _token?: CancellationToken): Promise { + // No-op for the same reason as `fetch()`. + } + + async revListCount(_repoDir: URI, _fromRef: string, _toRef: string): Promise { + // No commit history available in the cache; 0 means "up to date" to + // the silent-fetch caller in `AgentPluginRepositoryService.fetchRepository`. + return 0; + } + + // -- helpers -------------------------------------------------------------- + + private _parseOrThrow(cloneUrl: string): IGitHubRepoRef { + const parsed = parseGitHubCloneUrl(cloneUrl); + if (!parsed) { + throw new Error(localize( + 'pluginsBrowserUnsupportedHost', + "Agent plugins in the browser can only be installed from GitHub HTTPS URLs. To install '{0}', use the desktop application or connect to a remote agent host.", + cloneUrl, + )); + } + return parsed; + } + + private _maybeLogTransientError(err: unknown, repo: IGitHubRepoRef): void { + if (err instanceof GitHubAuthRequiredError) { + this._logService.warn(`[BrowserPluginGitCommandService] GitHub auth required for ${repo.owner}/${repo.repo}: ${err.message}`); + } else if (err instanceof GitHubRateLimitError) { + const wait = err.retryAfterSeconds !== undefined ? ` (retry after ${err.retryAfterSeconds}s)` : ''; + this._logService.warn(`[BrowserPluginGitCommandService] GitHub rate limit hit for ${repo.owner}/${repo.repo}${wait}: ${err.message}`); + } else if (err instanceof Error) { + // Surface the URL + cause so opaque `TypeError: Failed to fetch` errors + // (CORS, DNS, offline) don't reach the user without context. + const cause = err.cause instanceof Error ? ` (cause: ${err.cause.name}: ${err.cause.message})` : ''; + this._logService.error(`[BrowserPluginGitCommandService] Clone failed for ${repo.owner}/${repo.repo}: ${err.message}${cause}`); + } + } + + /** + * Best-effort silent lookup of an existing GitHub session token. Returns + * `undefined` when no session is available; callers fall back to anonymous, + * which still works for public repos. Prefers a `repo`-scoped session when + * multiple are present (e.g. EMU + personal). + */ + private async _lookupGitHubToken(): Promise { + try { + const sessions = await this._authenticationService.getSessions('github', [], { silent: true }); + if (sessions.length === 0) { + return undefined; + } + const repoScopeSession = sessions.find(session => session.scopes.includes('repo')); + return repoScopeSession?.accessToken ?? sessions[0].accessToken; + } catch (err) { + this._logService.trace('[BrowserPluginGitCommandService] Silent GitHub session lookup failed:', err); + return undefined; + } + } + + private async _requestGitHubToken(repo: IGitHubRepoRef): Promise { + try { + const session = await this._authenticationService.createSession('github', ['repo'], { activateImmediate: true }); + return session.accessToken; + } catch (err) { + this._logService.trace('[BrowserPluginGitCommandService] GitHub session request failed:', err); + throw new Error(localize( + 'pluginsBrowserGitHubSignInRequired', + "Sign in to GitHub with an account that has access to '{0}' to install this plugin.", + `${repo.owner}/${repo.repo}`, + )); + } + } + + // -- metadata cache (IStorageService) ------------------------------------- + + private _cacheKey(targetDir: URI): string { + // Normalise trailing slashes / percent-encoding case so semantically-equivalent URIs hit the same entry. + return getComparisonKey(targetDir, true); + } + + private async _pruneStaleEntries(cache: Map, knownDirs: ReadonlyMap): Promise { + // Best-effort background sweep of cache entries whose target dir no + // longer exists; the next read for a removed key would re-clone anyway. + const removed: string[] = []; + await Promise.all(Array.from(knownDirs, async ([key, uri]) => { + try { + if (!(await this._fileService.exists(uri))) { + removed.push(key); + } + } catch { + // ignore -- treat as still-present rather than risk a false-positive removal + } + })); + if (removed.length === 0) { + return; + } + for (const key of removed) { + cache.delete(key); + } + this._logService.trace(`[BrowserPluginGitCommandService] Pruned ${removed.length} stale cache entries`); + this._persistCache(); + } + + private _ensureCacheLoaded(): Map { + if (this._cache) { + return this._cache; + } + const cache = new Map(); + const stored = this._storageService.getObject(BROWSER_CACHE_STORAGE_KEY, StorageScope.APPLICATION); + const knownDirs = new Map(); + if (stored) { + for (const [key, entry] of Object.entries(stored)) { + if (entry && typeof entry.sha === 'string' && typeof entry.owner === 'string' && typeof entry.repo === 'string') { + cache.set(key, { + owner: entry.owner, + repo: entry.repo, + ref: typeof entry.ref === 'string' ? entry.ref : undefined, + sha: entry.sha, + fetchedAt: typeof entry.fetchedAt === 'number' ? entry.fetchedAt : 0, + }); + try { + knownDirs.set(key, URI.parse(key)); + } catch { + // invalid stored key -- drop it on the floor at next persist + cache.delete(key); + } + } + } + } + this._cache = cache; + // Fire-and-forget prune of dirs that no longer exist on disk. + if (knownDirs.size > 0) { + this._pruneStaleEntries(cache, knownDirs).catch(err => { + this._logService.trace('[BrowserPluginGitCommandService] Cache prune failed:', err); + }); + } + return cache; + } + + private _getCacheEntry(targetDir: URI): IBrowserPluginCacheEntry | undefined { + return this._ensureCacheLoaded().get(this._cacheKey(targetDir)); + } + + private _setCacheEntry(targetDir: URI, entry: IBrowserPluginCacheEntry): void { + const cache = this._ensureCacheLoaded(); + cache.set(this._cacheKey(targetDir), entry); + this._persistCache(); + } + + private _persistCache(): void { + if (!this._cache) { + return; + } + const serialized: IStoredBrowserPluginCache = {}; + for (const [key, entry] of this._cache) { + serialized[key] = entry; + } + if (Object.keys(serialized).length === 0) { + this._storageService.remove(BROWSER_CACHE_STORAGE_KEY, StorageScope.APPLICATION); + return; + } + this._storageService.store(BROWSER_CACHE_STORAGE_KEY, JSON.stringify(serialized), StorageScope.APPLICATION, StorageTarget.MACHINE); + } } diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index ad6b5bb57a51b..aaca2a17b2aa7 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -145,10 +145,10 @@ export interface IHarnessDescriptor { */ readonly syncProvider?: ICustomizationSyncProvider; /** - * Optional plugin-management actions shown in the Plugins section toolbar. - * Harnesses can use these to replace the default local install/create - * actions with environment-specific commands (for example, configuring - * plugins on a remote agent host). + * Optional plugin-management actions shown in the Plugins section add menu. + * Harnesses can use these to add environment-specific commands alongside + * the default install-from-source action (for example, configuring plugins on + * a remote agent host). The create action remains a separate toolbar button. */ readonly pluginActions?: readonly ICustomizationItemAction[]; } diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginGitService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginGitService.ts index 26447f80811c6..a57a429cf91b8 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginGitService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginGitService.ts @@ -13,7 +13,23 @@ export const IPluginGitService = createDecorator('pluginGitSe * Abstracts git operations used by the agent plugin system. * * Concrete behavior depends on the platform-specific implementation that is - * registered for this service. + * registered for this service. The current decision matrix is: + * + * | Deployment flavor | Implementation | Materialisation strategy | Test fixture | + * | ---------------------------------- | ------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------- | + * | Desktop (no remote AHP) | `NativePluginGitCommandService` | Real `git` via `ILocalGitService` in the shared process | `chat/test/electron-browser/pluginGitCommandService.test.ts` | + * | Desktop + remote AHP backend | `NativePluginGitCommandService` | Real `git` locally; server pulls dir via AHP FS | + `platform/agentHost/test/node/agentPluginManager.test.ts` | + * | Web standalone (no AHP) | `BrowserPluginGitCommandService`| GitHub tarball fetch + extract into virtual FS | `chat/test/browser/pluginGitCommandService.test.ts` | + * | Web + remote AHP backend | `BrowserPluginGitCommandService`| Tarball locally; server pulls dir via AHP FS | + `platform/agentHost/test/node/agentPluginManager.test.ts` | + * + * The "+ remote AHP" rows reuse the local impl unchanged: the server-side + * `AgentPluginManager` consumes the already-materialised plugin dir through + * the AHP filesystem provider (`agent-client:`), so its test fixture is + * agnostic to which client impl wrote the bytes. + * + * A future "server-side clone" path (Approach B) would add a fifth arm + * where the server itself runs `git clone` based on a typed git-source + * variant of `CustomizationRef`. That work is not yet implemented. */ export interface IPluginGitService { readonly _serviceBrand: undefined; 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 23514fbdea3aa..3620b9a1c7bd7 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts @@ -51,7 +51,8 @@ import { NativeBuiltinToolsContribution } from './builtInTools/tools.js'; import { NativePluginGitCommandService } from './pluginGitCommandService.js'; // Override the browser PluginGitCommandService with the native one that always -// runs git locally via the shared process. +// runs git locally via the shared process. See the decision matrix on the +// `IPluginGitService` interface for the full per-flavor wiring. registerSingleton(IPluginGitService, NativePluginGitCommandService, InstantiationType.Delayed); registerSharedProcessRemoteService(ILocalGitService, 'localGit'); diff --git a/src/vs/workbench/contrib/chat/test/browser/pluginGitCommandService.test.ts b/src/vs/workbench/contrib/chat/test/browser/pluginGitCommandService.test.ts new file mode 100644 index 0000000000000..0be4f4e017be9 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/pluginGitCommandService.test.ts @@ -0,0 +1,493 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { bufferToStream, VSBuffer } from '../../../../../base/common/buffer.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { FileService } from '../../../../../platform/files/common/fileService.js'; +import { InMemoryFileSystemProvider } from '../../../../../platform/files/common/inMemoryFilesystemProvider.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { IRequestContext, IRequestOptions } from '../../../../../base/parts/request/common/request.js'; +import { IRequestService } from '../../../../../platform/request/common/request.js'; +import { InMemoryStorageService } from '../../../../../platform/storage/common/storage.js'; +import { AuthenticationSession, IAuthenticationService } from '../../../../services/authentication/common/authentication.js'; +import { BrowserPluginGitCommandService } from '../../browser/pluginGitCommandService.js'; +import { parseGitHubCloneUrl } from '../../browser/githubRepoFetcher.js'; + +suite('BrowserPluginGitCommandService', () => { + + const disposables = new DisposableStore(); + let fileService: FileService; + let requestStub: StubRequestService; + let storage: InMemoryStorageService; + let service: BrowserPluginGitCommandService; + + setup(() => { + fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); + requestStub = new StubRequestService(); + storage = disposables.add(new InMemoryStorageService()); + service = new BrowserPluginGitCommandService( + fileService, + new NullLogService(), + requestStub as unknown as IRequestService, + storage, + stubAuthenticationService(), + ); + }); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + const targetDir = URI.from({ scheme: Schemas.inMemory, path: '/cache/github.com/octocat/hello' }); + + // ---- parseGitHubCloneUrl ----------------------------------------------- + + suite('parseGitHubCloneUrl', () => { + test('parses canonical github HTTPS clone URL', () => { + assert.deepStrictEqual(parseGitHubCloneUrl('https://github.com/octocat/Hello-World.git'), { owner: 'octocat', repo: 'Hello-World' }); + }); + + test('strips trailing slash and missing .git suffix', () => { + assert.deepStrictEqual(parseGitHubCloneUrl('https://github.com/octocat/Hello-World/'), { owner: 'octocat', repo: 'Hello-World' }); + assert.deepStrictEqual(parseGitHubCloneUrl('https://github.com/octocat/Hello-World'), { owner: 'octocat', repo: 'Hello-World' }); + // Order of trim + .git strip matters: trailing slash first, then .git + assert.deepStrictEqual(parseGitHubCloneUrl('https://github.com/octocat/Hello-World.git/'), { owner: 'octocat', repo: 'Hello-World' }); + }); + + test('rejects URLs with extra path segments', () => { + assert.strictEqual(parseGitHubCloneUrl('https://github.com/octocat/Hello-World/issues/42'), undefined); + assert.strictEqual(parseGitHubCloneUrl('https://github.com/octocat/Hello-World/tree/main'), undefined); + }); + + test('rejects non-HTTPS, non-GitHub, and malformed URLs', () => { + assert.strictEqual(parseGitHubCloneUrl('git@github.com:octocat/repo.git'), undefined); + assert.strictEqual(parseGitHubCloneUrl('https://gitlab.com/octocat/repo.git'), undefined); + assert.strictEqual(parseGitHubCloneUrl('https://github.com/octocat'), undefined); + assert.strictEqual(parseGitHubCloneUrl('not-a-url'), undefined); + }); + }); + + // ---- cloneRepository ---------------------------------------------------- + + suite('cloneRepository', () => { + test('rejects non-GitHub clone URLs with an actionable message', async () => { + await assert.rejects( + () => service.cloneRepository('https://gitlab.com/foo/bar.git', targetDir), + /can only be installed from GitHub HTTPS URLs/, + ); + }); + + test('downloads tree + blobs and persists SHA metadata', async () => { + requestStub.queue('GET', /\/commits\/main$/, jsonResponse(200, { sha: 'deadbeef' })); + queueRepoFetch(requestStub, 'deadbeef', { + 'README.md': 'hello\n', + 'src/index.js': 'console.log(1);', + }); + + await service.cloneRepository('https://github.com/octocat/Hello-World.git', targetDir, 'main'); + + const readme = await fileService.readFile(URI.joinPath(targetDir, 'README.md')); + assert.strictEqual(readme.value.toString(), 'hello\n'); + const index = await fileService.readFile(URI.joinPath(targetDir, 'src/index.js')); + assert.strictEqual(index.value.toString(), 'console.log(1);'); + + // revParse should now answer from the persisted metadata. + assert.strictEqual(await service.revParse(targetDir, 'HEAD'), 'deadbeef'); + }); + + test('surfaces a sign-in message on 401 when auth is unavailable', async () => { + requestStub.queue('GET', /\/commits\/main$/, plainResponse(401)); + + await assert.rejects( + () => service.cloneRepository('https://github.com/octocat/Private.git', targetDir, 'main'), + /Sign in to GitHub/, + ); + }); + + test('surfaces a GitHubRateLimitError on 403 with X-RateLimit-Remaining: 0', async () => { + requestStub.queue('GET', /\/commits\/main$/, plainResponse(403, VSBuffer.fromString('rate limit'), { + 'x-ratelimit-remaining': '0', + 'retry-after': '60', + })); + + let captured: unknown; + try { + await service.cloneRepository('https://github.com/octocat/Hello-World.git', targetDir, 'main'); + assert.fail('expected rejection'); + } catch (err) { + captured = err; + } + assert.ok(captured instanceof Error && captured.name === 'GitHubRateLimitError', `expected GitHubRateLimitError, got ${(captured as Error)?.name}`); + }); + + test('requests GitHub auth and retries when GitHub returns 403', async () => { + const state = { createSessionCalls: 0 }; + service = new BrowserPluginGitCommandService( + fileService, + new NullLogService(), + requestStub as unknown as IRequestService, + storage, + stubAuthenticationService({ createdAccessToken: 'repo-token', state }), + ); + + requestStub.queue('GET', /\/commits\/main$/, plainResponse(403)); + requestStub.queue('GET', /\/commits\/main$/, jsonResponse(200, { sha: 'sha1' })); + queueRepoFetch(requestStub, 'sha1', { 'private.txt': 'secret' }); + + await service.cloneRepository('https://github.com/octocat/Private.git', targetDir, 'main'); + + const file = await fileService.readFile(URI.joinPath(targetDir, 'private.txt')); + assert.strictEqual(file.value.toString(), 'secret'); + assert.strictEqual(state.createSessionCalls, 1); + }); + + test('uses an existing signed-in GitHub session before falling back to anonymous requests', async () => { + service = new BrowserPluginGitCommandService( + fileService, + new NullLogService(), + requestStub as unknown as IRequestService, + storage, + stubAuthenticationService({ sessions: [createAuthenticationSession('signed-in-token')] }), + ); + + requestStub.queue('GET', /\/commits\/main$/, jsonResponse(200, { sha: 'sha1' })); + queueRepoFetch(requestStub, 'sha1', { 'auth.txt': 'authed' }); + + await service.cloneRepository('https://github.com/octocat/Private.git', targetDir, 'main'); + + assert.strictEqual(requestStub.requests[0].headers?.Authorization, 'Bearer signed-in-token'); + assert.strictEqual(requestStub.requests[1].headers?.Authorization, 'Bearer signed-in-token'); + assert.strictEqual(requestStub.requests[2].headers?.Authorization, 'Bearer signed-in-token'); + }); + + test('falls back to anonymous when the signed-in GitHub session is rejected', async () => { + const state = { createSessionCalls: 0 }; + service = new BrowserPluginGitCommandService( + fileService, + new NullLogService(), + requestStub as unknown as IRequestService, + storage, + stubAuthenticationService({ sessions: [createAuthenticationSession('sso-blocked-token')], state }), + ); + + requestStub.queue('GET', /\/commits\/main$/, plainResponse(403)); + requestStub.queue('GET', /\/commits\/main$/, jsonResponse(200, { sha: 'sha1' })); + queueRepoFetch(requestStub, 'sha1', { 'public.txt': 'public' }); + + await service.cloneRepository('https://github.com/octocat/Public.git', targetDir, 'main'); + + const file = await fileService.readFile(URI.joinPath(targetDir, 'public.txt')); + assert.strictEqual(file.value.toString(), 'public'); + assert.strictEqual(requestStub.requests[0].headers?.Authorization, 'Bearer sso-blocked-token'); + assert.strictEqual(requestStub.requests[1].headers?.Authorization, undefined); + assert.strictEqual(requestStub.requests[2].headers?.Authorization, undefined); + assert.strictEqual(state.createSessionCalls, 0); + }); + + test('failed extraction leaves the previous targetDir intact', async () => { + // First install: succeeds. + requestStub.queue('GET', /\/commits\/main$/, jsonResponse(200, { sha: 'sha1' })); + queueRepoFetch(requestStub, 'sha1', { 'keep.txt': 'preserved' }); + await service.cloneRepository('https://github.com/octocat/Hello-World.git', targetDir, 'main'); + + // Second install: tree fetch returns 500 -> aborts before touching the staged dir. + requestStub.queue('GET', /\/commits\/main$/, jsonResponse(200, { sha: 'sha2' })); + requestStub.queue('GET', /\/git\/trees\/sha2/, plainResponse(500, VSBuffer.fromString('boom'))); + + await assert.rejects(() => service.cloneRepository('https://github.com/octocat/Hello-World.git', targetDir, 'main')); + + // Original tree still readable; cache still reports old SHA. + const keep = await fileService.readFile(URI.joinPath(targetDir, 'keep.txt')); + assert.strictEqual(keep.value.toString(), 'preserved'); + assert.strictEqual(await service.revParse(targetDir, 'HEAD'), 'sha1'); + }); + + test('skips symlink and submodule entries', async () => { + requestStub.queue('GET', /\/commits\/main$/, jsonResponse(200, { sha: 'sha1' })); + requestStub.queue('GET', /\/git\/trees\/sha1/, jsonResponse(200, { + sha: 'sha1', + truncated: false, + tree: [ + { path: 'README.md', mode: '100644', type: 'blob', sha: 'b-readme', size: 3 }, + { path: 'link.txt', mode: '120000', type: 'blob', sha: 'b-link', size: 8 }, + { path: 'subrepo', mode: '160000', type: 'commit', sha: 'b-sub' }, + ], + })); + requestStub.queue('GET', /\/git\/blobs\/b-readme$/, jsonResponse(200, { content: encodeBase64(new TextEncoder().encode('hi\n')), encoding: 'base64' })); + + await service.cloneRepository('https://github.com/octocat/Hello-World.git', targetDir, 'main'); + + assert.strictEqual((await fileService.readFile(URI.joinPath(targetDir, 'README.md'))).value.toString(), 'hi\n'); + assert.strictEqual(await fileService.exists(URI.joinPath(targetDir, 'link.txt')), false); + assert.strictEqual(await fileService.exists(URI.joinPath(targetDir, 'subrepo')), false); + }); + }); + + // ---- pull --------------------------------------------------------------- + + suite('pull', () => { + test('returns false when upstream SHA is unchanged', async () => { + requestStub.queue('GET', /\/commits\/main$/, jsonResponse(200, { sha: 'sha1' })); + queueRepoFetch(requestStub, 'sha1', { 'a.txt': 'a' }); + await service.cloneRepository('https://github.com/octocat/Hello-World.git', targetDir, 'main'); + + requestStub.queue('GET', /\/commits\/main$/, jsonResponse(200, { sha: 'sha1' })); + + assert.strictEqual(await service.pull(targetDir), false); + }); + + test('re-downloads tree and returns true when SHA moves', async () => { + requestStub.queue('GET', /\/commits\/main$/, jsonResponse(200, { sha: 'sha1' })); + queueRepoFetch(requestStub, 'sha1', { 'a.txt': 'old' }); + await service.cloneRepository('https://github.com/octocat/Hello-World.git', targetDir, 'main'); + + requestStub.queue('GET', /\/commits\/main$/, jsonResponse(200, { sha: 'sha2' })); + queueRepoFetch(requestStub, 'sha2', { 'a.txt': 'new' }); + + assert.strictEqual(await service.pull(targetDir), true); + const a = await fileService.readFile(URI.joinPath(targetDir, 'a.txt')); + assert.strictEqual(a.value.toString(), 'new'); + assert.strictEqual(await service.revParse(targetDir, 'HEAD'), 'sha2'); + }); + + test('throws when called for a target with no cached metadata', async () => { + await assert.rejects(() => service.pull(targetDir), /no cached metadata/); + }); + + test('clears stale files from a prior extraction', async () => { + requestStub.queue('GET', /\/commits\/main$/, jsonResponse(200, { sha: 'sha1' })); + queueRepoFetch(requestStub, 'sha1', { + 'keep.txt': 'k1', + 'removed.txt': 'will be deleted', + }); + await service.cloneRepository('https://github.com/octocat/Hello-World.git', targetDir, 'main'); + + requestStub.queue('GET', /\/commits\/main$/, jsonResponse(200, { sha: 'sha2' })); + queueRepoFetch(requestStub, 'sha2', { 'keep.txt': 'k2' }); + assert.strictEqual(await service.pull(targetDir), true); + + assert.strictEqual(await fileService.exists(URI.joinPath(targetDir, 'removed.txt')), false); + const keep = await fileService.readFile(URI.joinPath(targetDir, 'keep.txt')); + assert.strictEqual(keep.value.toString(), 'k2'); + }); + + test('rejects path-traversal entries in the tree', async () => { + requestStub.queue('GET', /\/commits\/main$/, jsonResponse(200, { sha: 'sha1' })); + requestStub.queue('GET', /\/git\/trees\/sha1/, jsonResponse(200, { + sha: 'sha1', + truncated: false, + tree: [ + { path: 'safe.txt', mode: '100644', type: 'blob', sha: 'b-safe', size: 4 }, + { path: '../escaped.txt', mode: '100644', type: 'blob', sha: 'b-escape', size: 4 }, + ], + })); + requestStub.queue('GET', /\/git\/blobs\/b-safe$/, jsonResponse(200, { content: encodeBase64(new TextEncoder().encode('safe')), encoding: 'base64' })); + + await service.cloneRepository('https://github.com/octocat/Hello-World.git', targetDir, 'main'); + + // safe entry written; escaped entry was rejected and never written + // to a sibling of `targetDir` (or anywhere outside it). + const safe = await fileService.readFile(URI.joinPath(targetDir, 'safe.txt')); + assert.strictEqual(safe.value.toString(), 'safe'); + const escapedSibling = URI.from({ scheme: targetDir.scheme, path: '/cache/github.com/octocat/escaped.txt' }); + assert.strictEqual(await fileService.exists(escapedSibling), false); + }); + + test('rejects backslash-traversal entries (Windows path separator)', async () => { + requestStub.queue('GET', /\/commits\/main$/, jsonResponse(200, { sha: 'sha1' })); + requestStub.queue('GET', /\/git\/trees\/sha1/, jsonResponse(200, { + sha: 'sha1', + truncated: false, + tree: [ + { path: 'safe.txt', mode: '100644', type: 'blob', sha: 'b-safe', size: 4 }, + { path: '..\\..\\escaped.txt', mode: '100644', type: 'blob', sha: 'b-escape', size: 4 }, + ], + })); + requestStub.queue('GET', /\/git\/blobs\/b-safe$/, jsonResponse(200, { content: encodeBase64(new TextEncoder().encode('safe')), encoding: 'base64' })); + + await service.cloneRepository('https://github.com/octocat/Hello-World.git', targetDir, 'main'); + + const safe = await fileService.readFile(URI.joinPath(targetDir, 'safe.txt')); + assert.strictEqual(safe.value.toString(), 'safe'); + // The malicious entry should not have been written under any + // reasonable interpretation of its path. + assert.strictEqual(await fileService.exists(URI.joinPath(targetDir, '..\\..\\escaped.txt')), false); + }); + }); + + // ---- checkout ----------------------------------------------------------- + + suite('checkout', () => { + test('no-ops when the requested SHA matches the cached SHA', async () => { + requestStub.queue('GET', /\/commits\/main$/, jsonResponse(200, { sha: 'aabbccddeeff00112233445566778899aabbccdd' })); + queueRepoFetch(requestStub, 'aabbccddeeff00112233445566778899aabbccdd', { 'a.txt': 'a' }); + await service.cloneRepository('https://github.com/octocat/Hello-World.git', targetDir, 'main'); + + // No additional queued responses — checkout to the same SHA must + // not issue any HTTP calls. + await service.checkout(targetDir, 'aabbccddeeff00112233445566778899aabbccdd', true); + }); + + test('re-extracts when the SHA differs', async () => { + requestStub.queue('GET', /\/commits\/main$/, jsonResponse(200, { sha: '1111111111111111111111111111111111111111' })); + queueRepoFetch(requestStub, '1111111111111111111111111111111111111111', { 'a.txt': 'old' }); + await service.cloneRepository('https://github.com/octocat/Hello-World.git', targetDir, 'main'); + + queueRepoFetch(requestStub, '2222222222222222222222222222222222222222', { 'a.txt': 'new' }); + + await service.checkout(targetDir, '2222222222222222222222222222222222222222', true); + + const a = await fileService.readFile(URI.joinPath(targetDir, 'a.txt')); + assert.strictEqual(a.value.toString(), 'new'); + assert.strictEqual(await service.revParse(targetDir, 'HEAD'), '2222222222222222222222222222222222222222'); + }); + + test('throws when called for a target with no cached metadata', async () => { + await assert.rejects(() => service.checkout(targetDir, 'abc'), /no cached metadata/); + }); + }); + + // ---- revParse ----------------------------------------------------------- + + suite('revParse', () => { + test('throws when asked for an unrelated full SHA', async () => { + requestStub.queue('GET', /\/commits\/main$/, jsonResponse(200, { sha: 'aabbccddeeff00112233445566778899aabbccdd' })); + queueRepoFetch(requestStub, 'aabbccddeeff00112233445566778899aabbccdd', { 'a.txt': 'a' }); + await service.cloneRepository('https://github.com/octocat/Hello-World.git', targetDir, 'main'); + + // Querying the cached SHA still works + assert.strictEqual(await service.revParse(targetDir, 'aabbccddeeff00112233445566778899aabbccdd'), 'aabbccddeeff00112233445566778899aabbccdd'); + // Querying an unrelated SHA must not silently lie + await assert.rejects(() => service.revParse(targetDir, '1111111111111111111111111111111111111111'), /only HEAD/); + }); + }); + + // ---- noop ops ----------------------------------------------------------- + + test('fetch / fetchRepository / revListCount are inert', async () => { + await service.fetch(targetDir); + await service.fetchRepository(targetDir); + assert.strictEqual(await service.revListCount(targetDir, 'HEAD', '@{u}'), 0); + }); +}); + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +interface QueuedResponse { + readonly methodMatcher: string; + readonly urlMatcher: RegExp; + readonly response: () => IRequestContext; +} + +class StubRequestService implements Partial { + declare readonly _serviceBrand: undefined; + + private readonly _queue: QueuedResponse[] = []; + readonly requests: IRequestOptions[] = []; + + queue(method: string, urlMatcher: RegExp, response: () => IRequestContext): void { + this._queue.push({ methodMatcher: method, urlMatcher, response }); + } + + async request(options: IRequestOptions, _token: CancellationToken): Promise { + this.requests.push(options); + const url = options.url ?? ''; + const method = options.type ?? 'GET'; + const idx = this._queue.findIndex(q => q.methodMatcher === method && q.urlMatcher.test(url)); + if (idx === -1) { + throw new Error(`No queued response for ${method} ${url}`); + } + const [{ response }] = this._queue.splice(idx, 1); + return response(); + } +} + +function plainResponse(statusCode: number, body: VSBuffer = VSBuffer.alloc(0), headers: Record = {}): () => IRequestContext { + return () => ({ + res: { statusCode, headers }, + stream: bufferToStream(body), + }); +} + +function jsonResponse(statusCode: number, body: unknown): () => IRequestContext { + return plainResponse(statusCode, VSBuffer.fromString(JSON.stringify(body))); +} + +/** + * Queue stub responses representing a recursive Git Trees fetch followed + * by per-blob `git/blobs/{sha}` downloads for the given commit SHA and + * file map. The order of `files` does not matter; the request stub picks + * the first regex that matches each outgoing URL. + */ +function queueRepoFetch(stub: StubRequestService, sha: string, files: Record): void { + const entries = Object.entries(files); + const tree = entries.map(([path, content], i) => ({ + path, + mode: '100644', + type: 'blob' as const, + sha: `b${i}`, + size: content.length, + })); + stub.queue('GET', new RegExp(`/git/trees/${escapeForRegExp(sha)}\\?recursive=1$`), jsonResponse(200, { sha, tree, truncated: false })); + entries.forEach(([, content], i) => { + stub.queue('GET', blobShaMatcher(tree[i].sha), jsonResponse(200, { + content: encodeBase64(new TextEncoder().encode(content)), + encoding: 'base64', + })); + }); +} + +function blobShaMatcher(blobSha: string): RegExp { + return new RegExp(`/git/blobs/${escapeForRegExp(blobSha)}$`); +} + +function encodeBase64(bytes: Uint8Array): string { + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +function escapeForRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +interface IStubAuthenticationServiceOptions { + readonly sessions?: readonly AuthenticationSession[]; + readonly createdAccessToken?: string; + readonly state?: { createSessionCalls: number }; +} + +function stubAuthenticationService(options: IStubAuthenticationServiceOptions = {}): IAuthenticationService { + return { + getSessions: async () => options.sessions ?? [], + createSession: async () => { + if (options.state) { + options.state.createSessionCalls++; + } + if (!options.createdAccessToken) { + throw new Error('No GitHub session available'); + } + return { accessToken: options.createdAccessToken }; + }, + } as unknown as IAuthenticationService; +} + +function createAuthenticationSession(accessToken: string, scopes: readonly string[] = []): AuthenticationSession { + return { + id: accessToken, + accessToken, + account: { label: 'octocat', id: 'octocat' }, + scopes, + }; +} From 675ef9f09b9665b12ab79795201147a19ce655fe Mon Sep 17 00:00:00 2001 From: Peng Lyu Date: Fri, 1 May 2026 17:01:22 -0700 Subject: [PATCH 18/19] [cli] reduce info logs (#313822) --- .../chatSessions/copilotcli/node/copilotcliSession.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index 10f76b9c795aa..ba58710ed8c92 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -1115,7 +1115,6 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes const shouldHandleExitPlanModeRequests = this.configurationService.getConfig(ConfigKey.Advanced.CLIPlanExitModeEnabled); disposables.add(toDisposable(this._sdkSession.on('*', (event) => { this.logService.trace(`[CopilotCLISession] CopilotCLI Event: ${JSON.stringify(event, null, 2)}`); - this.logService.info(`[CopilotCLISession] on(*) fired: ${event.type}`); // Forward events to Mission Control if remote control is active this._bufferMcEvent(event); }))); @@ -1718,7 +1717,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes // Step 4: Create Mission Control session const agentTaskId = `${Date.now()}-${Math.random().toString(36).substring(2, 10)}`; - this.logService.info('[CopilotCLISession] Creating MC session'); + this.logService.trace('[CopilotCLISession] Creating MC session'); let mcData: McSessionCreateResult; try { @@ -1752,7 +1751,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes mcSessionResource: SessionIdForCLI.getResource(this.sessionId), }; mcStateBySessionId.set(this.sessionId, sharedState); - this.logService.info(`[CopilotCLISession] Set shared MC state for session ${this.sessionId}, mcSessionId=${mcData.id}`); + this.logService.trace(`[CopilotCLISession] Set shared MC state for session ${this.sessionId}, mcSessionId=${mcData.id}`); // Step 6: Send the initial session.start event — MC requires this to // transition out of "Fueling the runtime engines..." loading state. @@ -1802,7 +1801,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes replayed++; } } - this.logService.info(`[CopilotCLISession] Replayed ${replayed}/${existingEvents.length} existing events to MC`); + this.logService.trace(`[CopilotCLISession] Replayed ${replayed}/${existingEvents.length} existing events to MC`); await this._flushMcEvents(); @@ -1852,7 +1851,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes // Step 8: Construct and display the frontend URL const frontendUrl = `https://github.com/${nwo.owner}/${nwo.repo}/tasks/${taskId}`; sharedState.mcFrontendUrl = frontendUrl; - this.logService.info(`[CopilotCLISession] MC session created, URL: ${frontendUrl}`); + this.logService.trace(`[CopilotCLISession] MC session created, URL: ${frontendUrl}`); await this._showRemoteControlEnabled(frontendUrl); From 9b9238852f3969a8d35b7e13913b5474111b0265 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Sat, 2 May 2026 02:11:35 +0200 Subject: [PATCH 19/19] UseChatSessionCustomizationsForCustomAgents setting (#313781) Co-authored-by: Copilot --- .../contrib/chat/browser/chat.contribution.ts | 9 +++++++ .../contrib/chat/common/chatModes.ts | 25 ++++++++++++++++--- .../contrib/chat/common/constants.ts | 1 + .../chat/test/common/chatModeService.test.ts | 7 +++++- 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 90cc1da21316a..0aeb73f10bd22 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -1612,6 +1612,15 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.customizations.harnessSelector.enabled', "Controls whether the harness selector is shown in the Chat Customizations editor sidebar. When disabled, the editor always shows all customizations without filtering."), default: true, }, + [ChatConfiguration.UseChatSessionCustomizationsForCustomAgents]: { + type: 'boolean', + description: nls.localize('chat.customizations.useChatSessionCustomizationsForCustomAgents', "When enabled, custom agents shown in the chat mode picker are sourced from the customization harness service (scoped per session type) instead of the prompts service."), + default: false, + tags: ['experimental', 'advanced'], + experiment: { + mode: 'auto' + } + }, } }); Registry.as(EditorExtensions.EditorPane).registerEditorPane( diff --git a/src/vs/workbench/contrib/chat/common/chatModes.ts b/src/vs/workbench/contrib/chat/common/chatModes.ts index c2555358d51b5..203dc545fa16e 100644 --- a/src/vs/workbench/contrib/chat/common/chatModes.ts +++ b/src/vs/workbench/contrib/chat/common/chatModes.ts @@ -21,6 +21,7 @@ import { ChatContextKeys } from './actions/chatContextKeys.js'; import { ChatConfiguration, ChatModeKind } from './constants.js'; import { IHandOff } from './promptSyntax/promptFileParser.js'; import { IAgentSource, ICustomAgent, ICustomAgentVisibility, IPromptsService, isCustomAgentVisibility, matchesSessionType, PromptsStorage } from './promptSyntax/service/promptsService.js'; +import { ICustomizationHarnessService } from './customizationHarnessService.js'; import { PromptFileSource, Target } from './promptSyntax/promptTypes.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { Codicon } from '../../../../base/common/codicons.js'; @@ -90,6 +91,7 @@ class ChatModes extends Disposable implements IChatModes { @ILogService private readonly logService: ILogService, @IStorageService private readonly storageService: IStorageService, @IConfigurationService private readonly configurationService: IConfigurationService, + @ICustomizationHarnessService private readonly customizationHarnessService: ICustomizationHarnessService, ) { super(); @@ -103,6 +105,12 @@ class ChatModes extends Disposable implements IChatModes { this._register(this.promptsService.onDidChangeCustomAgents(() => { this._pendingRefresh = this.refreshCustomPromptModes(true); })); + // When the harness service is the source, also react to its change events for our session type. + this._register(this.customizationHarnessService.onDidChangeCustomAgents(e => { + if (e.sessionType === this.sessionType && this.useChatSessionCustomizationsForCustomAgents()) { + this._pendingRefresh = this.refreshCustomPromptModes(true); + } + })); this._register(this.storageService.onWillSaveState(() => this.saveCachedModes())); // Builtin mode availability depends on configuration policy and tools-agent availability. @@ -110,6 +118,10 @@ class ChatModes extends Disposable implements IChatModes { if (e.affectsConfiguration(ChatConfiguration.AgentEnabled)) { this._onDidChange.fire(); } + if (e.affectsConfiguration(ChatConfiguration.UseChatSessionCustomizationsForCustomAgents)) { + // Source switched: re-fetch from the now-active provider. + this._pendingRefresh = this.refreshCustomPromptModes(true); + } })); let didHaveToolsAgent = this.chatAgentService.hasToolsAgent; this._register(this.chatAgentService.onDidChangeAgents(() => { @@ -201,16 +213,23 @@ class ChatModes extends Disposable implements IChatModes { } } + private useChatSessionCustomizationsForCustomAgents(): boolean { + return this.configurationService.getValue(ChatConfiguration.UseChatSessionCustomizationsForCustomAgents) === true; + } + private async refreshCustomPromptModes(fireChangeEvent?: boolean): Promise { try { - const customModes = await this.promptsService.getCustomAgents(CancellationToken.None); + const useHarness = this.useChatSessionCustomizationsForCustomAgents(); + + const customModes = useHarness + ? await this.customizationHarnessService.getCustomAgents(this.sessionType, CancellationToken.None) + : (await this.promptsService.getCustomAgents(CancellationToken.None)).filter(mode => matchesSessionType(mode.sessionTypes, this.sessionType)); // Create a new set of mode instances, reusing existing ones where possible const seenUris = new Set(); for (const customMode of customModes) { - // Filter custom agents by the session type this instance was created for - if (!customMode.visibility.userInvocable || !customMode.enabled || !matchesSessionType(customMode.sessionTypes, this.sessionType)) { + if (!customMode.visibility.userInvocable || !customMode.enabled) { continue; } diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index e3a659093bbbb..0bbd2bbed20ba 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -65,6 +65,7 @@ export enum ChatConfiguration { GrowthNotificationEnabled = 'chat.growthNotification.enabled', ChatCustomizationHarnessSelectorEnabled = 'chat.customizations.harnessSelector.enabled', + UseChatSessionCustomizationsForCustomAgents = 'chat.customizations.useChatSessionCustomizationsForCustomAgents', AutopilotEnabled = 'chat.autopilot.enabled', DefaultPermissionLevel = 'chat.permissions.default', ImageCarouselEnabled = 'imageCarousel.chat.enabled', diff --git a/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts index 8c773de6aa36c..791b45ba55257 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { timeout } from '../../../../../base/common/async.js'; -import { Emitter } from '../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -21,6 +21,7 @@ import { ChatMode, ChatModeService } from '../../common/chatModes.js'; import { localChatSessionType } from '../../common/chatSessionsService.js'; import { ChatModeKind } from '../../common/constants.js'; import { IAgentSource, ICustomAgent, IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; import { MockPromptsService } from './promptSyntax/service/mockPromptsService.js'; import { Target } from '../../common/promptSyntax/promptTypes.js'; @@ -67,6 +68,10 @@ suite('ChatModeService', () => { instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IContextKeyService, new MockContextKeyService()); instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(ICustomizationHarnessService, { + onDidChangeCustomAgents: Event.None, + getCustomAgents: async () => [], + }); chatModeService = testDisposables.add(instantiationService.createInstance(ChatModeService)); // Eagerly create the ChatModes for the local session type and await