From 6a6f46483e6441e9f2797e877f16c98bbf271527 Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Fri, 21 Aug 2026 12:36:40 -0400 Subject: [PATCH 01/21] Refactor inline survey and ask question tool CSS into shared components (#331975) * chat: extract a shared large-card primitive for inline cards Chat has two card tiers. `.chat-confirmation-widget2` is the medium tier and already had a name; the large tier -- rounded border, panel background, clipped content, plus a 22px chrome free icon button -- was open coded in four places. `chatPlanReview.css` made this plainest by taking the medium tier primitive and overriding it back up to the large one with the same six declarations the survey and the question carousel open coded. Name that tier. `chatCard.ts` owns the stylesheet so its position in the bundle is deterministic rather than decided by module graph order, and exports the class names plus a `createChatCardIconButton` helper. The nine icon button copies all carried `!important`, because `Button` writes its colors as inline styles that no selector can outrank. Rather than move the `!important` into the shared file, build these buttons with no color options at all: `Button` then writes empty strings and the stylesheet owns the appearance. Removes 228 lines of duplicated CSS for 103 shared ones. The one rendering change is in the question carousel title, which asked for `--vscode-agents-fontWeight-semiBold`; that token is registered by `workbench.common.main.ts` but not by the component fixture harness, so the title fell back to 400 there instead of the intended 600. The generic token is also the correct one for `workbench/contrib` per the design token guidance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 14187097-e970-4d2b-8d7c-cfa8c5aa6306 * chat: fix silent arrow key navigation in the question carousel The single select list mixed two ARIA focus models. It declared `aria-activedescendant` on the listbox, but auto focus moved real DOM focus to an option, and arrow keys only updated `aria-activedescendant` without moving focus. `aria-activedescendant` is only honoured on the element that actually has DOM focus, so arrowing through options announced nothing to a screen reader. Focus the listbox instead, which is the element that declares it. Multi select is left alone: it uses real focus on the item and never sets `aria-activedescendant`, so its model is already coherent. Focusing the list also revealed that the rule meant to indicate a focused list never applied. It was nested inside `.chat-question-list` while also naming that class, so it compiled to a list inside a list. With `outline: none` set on both the list and its items, that left keyboard users with no focus indicator at all on this list. Anchor it with `&` so the selected row picks up active selection colors while the list has focus, matching the survey and the rest of the workbench. Adds characterization tests for the list's keyboard contract -- arrow clamping, digit selection, digit past the last option moving to freeform, and where focus and `aria-activedescendant` actually land. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 14187097-e970-4d2b-8d7c-cfa8c5aa6306 * chat: share the listbox ARIA scaffolding between the card lists The question carousel and the model feedback survey each hand roll a listbox, and each has to keep three things in agreement: the class that paints the active row, `aria-selected` on every option, and `aria-activedescendant` on the container. Keeping that in step by hand is what produced the carousel bug fixed in the previous commit, and it is silent when it breaks. `ChatCardListbox` owns exactly that: option registration, the three way state sync, and focus. Focus is only handed out through `focus()`, which puts it on the container, so the invariant that makes `aria-activedescendant` work is enforceable rather than conventional. Keyboard handling and row rendering stay at the call sites. The survey wraps at the ends and supports Home/End; the carousel clamps and binds digits; arrowing commits in the carousel but not in the survey. Those are product differences, not duplication, and folding them into option flags would have produced two widgets wearing one name -- so the shared piece is deliberately narrow and `clampedIndex` / `wrappedIndex` are offered rather than imposed. This trades a small net line increase for a single place where the announcement contract can be got right. The option element id format changes as a result; it is internal and only referenced by `aria-activedescendant`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 14187097-e970-4d2b-8d7c-cfa8c5aa6306 * chat: adopt the shared card on the tool confirmation carousel This was the one large card left open coding its own chrome. It kept a `transparent` border fallback while the other three fall back to `chat-requestBorder`, which is why it was held back from the earlier extraction. That difference is drift rather than intent. The fallback dates from the original design, where the carousel was fused to the chat input: no bottom border and a top only radius. It later gained a full border and a full radius but kept the fallback. More decisively, `input.border` is null only in dark and light and resolves to `contrastBorder` in both high contrast themes, so all four cards already agree in high contrast and diverge only in dark and light. A deliberate borderless treatment would hold across themes. So converge instead of adding a modifier: the carousel now shows the same hairline as its siblings in dark and light, and is unchanged in high contrast. Also drops the duplicated agents/editor background override and the header actions block, which matched the shared one on all four declarations. The overlay header keeps its own padding and centre alignment. It is a compact bar for a collapsed carousel rather than a titled card header, so folding it into the shared header would be a real visual change for no structural gain. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 14187097-e970-4d2b-8d7c-cfa8c5aa6306 * chat: make the large card tier win where it composes with the medium tier Plan review carries both tier classes on one element. Its old rule set the large shell at four classes of specificity, which beat `.chat-confirmation-widget2`; the shared `.chat-card-large` only ties it, so the medium tier was winning on stylesheet order and the card had regressed to a 6px radius and the request border. Name both classes together so the outcome does not depend on load order. Also puts the header separator on `var(--vscode-strokeThickness)` to match the shell border, and condenses the listbox and focus comments to the constraint that is not obvious from the code. The carousel test helper now defines `keyCode` on the event rather than passing it through the init dict. Chromium does accept it there -- the navigation assertions were exercising the intended branches -- but it is non-standard and needed a cast, so this matches the survey test helper instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 14187097-e970-4d2b-8d7c-cfa8c5aa6306 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 14187097-e970-4d2b-8d7c-cfa8c5aa6306 --- .../chatModelFeedbackSurveyWidget.ts | 59 ++++----- .../media/chatModelFeedbackSurvey.css | 54 +------- .../contrib/chat/browser/widget/chatCard.ts | 77 ++++++++++++ .../chat/browser/widget/chatCardListbox.ts | 85 +++++++++++++ .../chatContentParts/chatPlanReviewPart.ts | 19 +-- .../chatQuestionCarouselPart.ts | 105 +++++++--------- .../chatContentParts/media/chatPlanReview.css | 28 +---- .../media/chatQuestionCarousel.css | 101 ++------------- .../media/chatToolConfirmationCarousel.css | 73 +---------- .../chatToolConfirmationCarouselPart.ts | 21 ++-- .../chat/browser/widget/media/chatCard.css | 117 ++++++++++++++++++ .../chatModelFeedbackSurveyWidget.test.ts | 2 +- .../chatQuestionCarouselPart.test.ts | 113 +++++++++++++++++ 13 files changed, 495 insertions(+), 359 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/widget/chatCard.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/chatCardListbox.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/media/chatCard.css diff --git a/src/vs/workbench/contrib/chat/browser/feedbackSurvey/chatModelFeedbackSurveyWidget.ts b/src/vs/workbench/contrib/chat/browser/feedbackSurvey/chatModelFeedbackSurveyWidget.ts index 4dc275f7f3f418..70861500f9a5ff 100644 --- a/src/vs/workbench/contrib/chat/browser/feedbackSurvey/chatModelFeedbackSurveyWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/feedbackSurvey/chatModelFeedbackSurveyWidget.ts @@ -18,6 +18,8 @@ import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../../platf import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatModelFeedbackSurveyStepKind, IChatModelFeedbackSurveyTextStep } from '../../common/feedbackSurvey/chatModelFeedbackSurveyConfig.js'; import { IChatResponseViewModel } from '../../common/model/chatViewModel.js'; +import { CHAT_CARD_HEADER_CLASS, CHAT_CARD_LARGE_CLASS, CHAT_CARD_TITLE_CLASS, createChatCardIconButton } from '../widget/chatCard.js'; +import { ChatCardListbox } from '../widget/chatCardListbox.js'; import { ChatModelFeedbackSurveyStatus, IChatModelFeedbackSurveyService, IChatModelFeedbackSurveyState } from './chatModelFeedbackSurveyService.js'; import './media/chatModelFeedbackSurvey.css'; @@ -149,9 +151,9 @@ export class ChatModelFeedbackSurveyWidget extends Disposable { return; } - const panel = dom.append(this.container, dom.$('.chat-feedback-survey-container')); - const header = dom.append(panel, dom.$('.chat-feedback-survey-header')); - const title = dom.append(header, dom.$('.chat-feedback-survey-title')); + const panel = dom.append(this.container, dom.$(`.chat-feedback-survey-container.${CHAT_CARD_LARGE_CLASS}`)); + const header = dom.append(panel, dom.$(`.chat-feedback-survey-header.${CHAT_CARD_HEADER_CLASS}`)); + const title = dom.append(header, dom.$(`.chat-feedback-survey-title.${CHAT_CARD_TITLE_CLASS}`)); title.textContent = state.isSubmitted ? localize('chat.feedbackSurvey.acknowledgement', "Thanks, your feedback has been recorded.") : step.title; @@ -192,40 +194,22 @@ export class ChatModelFeedbackSurveyWidget extends Disposable { private renderCloseButton(header: HTMLElement): Button { const label = localize('chat.feedbackSurvey.dismiss', "Dismiss Survey"); - const close = this.renderDisposables.add(new Button(header, { ...defaultButtonStyles, secondary: true, supportIcons: true })); - close.label = `$(${Codicon.closeSmall.id})`; - close.element.classList.add('chat-feedback-survey-close'); - close.element.setAttribute('aria-label', label); - this.renderDisposables.add(this.hoverService.setupDelayedHover(close.element, { content: label })); + const close = createChatCardIconButton(this.renderDisposables, header, this.hoverService, { + icon: Codicon.closeSmall, + ariaLabel: label, + hoverContent: label, + }); this.renderDisposables.add(close.onDidClick(() => this.dismiss())); return close; } /** Renders the options as a single select list, matching the ask question tool. */ private renderChoiceStep(response: IChatResponseViewModel, body: HTMLElement, instanceId: string, stepId: string, options: readonly { id: string; label: string }[], title: string): HTMLElement { - const list = dom.append(body, dom.$('.chat-feedback-survey-list')); - list.setAttribute('role', 'listbox'); - list.setAttribute('aria-label', title); - list.tabIndex = 0; - - const items: HTMLElement[] = []; - let activeIndex = 0; - - const setActive = (index: number) => { - activeIndex = index; - items.forEach((item, i) => { - const isActive = i === index; - item.classList.toggle('active', isActive); - item.setAttribute('aria-selected', String(isActive)); - }); - list.setAttribute('aria-activedescendant', items[index].id); - }; + const listbox = new ChatCardListbox(dom.append(body, dom.$('.chat-feedback-survey-list')), title, 'active'); options.forEach((option, index) => { - const item = dom.append(list, dom.$('.chat-feedback-survey-list-item')); - item.id = `chat-feedback-survey-option-${instanceId}-${stepId}-${index}`; - item.setAttribute('role', 'option'); - item.setAttribute('aria-selected', 'false'); + const item = dom.append(listbox.domNode, dom.$('.chat-feedback-survey-list-item')); + listbox.addOption(item, `chat-feedback-survey-${instanceId}-${stepId}`); const label = dom.append(item, dom.$('.chat-feedback-survey-list-label')); label.textContent = option.label; @@ -234,32 +218,31 @@ export class ChatModelFeedbackSurveyWidget extends Disposable { dom.EventHelper.stop(e, true); this.surveyService.answerChoice(response, stepId, option.id); })); - items.push(item); }); - setActive(0); + listbox.setActive(0); - this.renderDisposables.add(dom.addDisposableListener(list, dom.EventType.KEY_DOWN, e => { + this.renderDisposables.add(dom.addDisposableListener(listbox.domNode, dom.EventType.KEY_DOWN, e => { const event = new StandardKeyboardEvent(e); if (event.keyCode === KeyCode.DownArrow) { event.preventDefault(); - setActive(activeIndex === items.length - 1 ? 0 : activeIndex + 1); + listbox.setActive(listbox.wrappedIndex(listbox.activeIndex + 1)); } else if (event.keyCode === KeyCode.UpArrow) { event.preventDefault(); - setActive(activeIndex === 0 ? items.length - 1 : activeIndex - 1); + listbox.setActive(listbox.wrappedIndex(listbox.activeIndex - 1)); } else if (event.keyCode === KeyCode.Home) { event.preventDefault(); - setActive(0); + listbox.setActive(0); } else if (event.keyCode === KeyCode.End) { event.preventDefault(); - setActive(items.length - 1); + listbox.setActive(listbox.length - 1); } else if (event.keyCode === KeyCode.Enter || event.keyCode === KeyCode.Space) { event.preventDefault(); - this.surveyService.answerChoice(response, stepId, options[activeIndex].id); + this.surveyService.answerChoice(response, stepId, options[listbox.activeIndex].id); } })); - return list; + return listbox.domNode; } private renderTextStep(response: IChatResponseViewModel, state: IChatModelFeedbackSurveyState, body: HTMLElement, step: IChatModelFeedbackSurveyTextStep): HTMLElement { diff --git a/src/vs/workbench/contrib/chat/browser/feedbackSurvey/media/chatModelFeedbackSurvey.css b/src/vs/workbench/contrib/chat/browser/feedbackSurvey/media/chatModelFeedbackSurvey.css index 11ab45dd984379..ed7be11d82a0e6 100644 --- a/src/vs/workbench/contrib/chat/browser/feedbackSurvey/media/chatModelFeedbackSurvey.css +++ b/src/vs/workbench/contrib/chat/browser/feedbackSurvey/media/chatModelFeedbackSurvey.css @@ -3,66 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* Matches the ask question tool so the two inline surfaces read as one family. */ +/* Card chrome, header, title and the close button come from widget/media/chatCard.css. */ .chat-feedback-survey-widget.hidden { display: none; } .chat-feedback-survey-container { - display: flex; - flex-direction: column; margin: 8px 0; - border: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-chat-requestBorder)); - border-radius: var(--vscode-cornerRadius-large); - background-color: var(--vscode-panel-background); - overflow: hidden; -} - -.chat-feedback-survey-container:focus-within { - border-color: var(--vscode-focusBorder); -} - -/* In the agents window and the editor the surface is the editor background. */ -.agent-sessions-workbench .chat-feedback-survey-container, -.editor-instance .chat-feedback-survey-container { - background-color: var(--vscode-editor-background); -} - -.chat-feedback-survey-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--vscode-spacing-size80); - padding: var(--vscode-spacing-size80) var(--vscode-spacing-size80) var(--vscode-spacing-size80) var(--vscode-spacing-size160); - border-bottom: 1px solid var(--vscode-chat-requestBorder); -} - -.chat-feedback-survey-title { - flex: 1; - min-width: 0; - margin: 0; - font-size: var(--vscode-fontSize-heading3); - font-weight: var(--vscode-fontWeight-semiBold); - line-height: 1.4; - overflow-wrap: anywhere; -} - -/* Chrome free, matching the close button on the ask question tool. */ -.chat-feedback-survey-container .monaco-button.chat-feedback-survey-close { - flex-shrink: 0; - width: 22px; - min-width: 22px; - height: 22px; - padding: 0; - border: none !important; - box-shadow: none !important; - background: transparent !important; - color: var(--vscode-icon-foreground) !important; -} - -.chat-feedback-survey-container .monaco-button.chat-feedback-survey-close:hover:not(.disabled) { - background: var(--vscode-toolbar-hoverBackground) !important; } .chat-feedback-survey-body { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatCard.ts b/src/vs/workbench/contrib/chat/browser/widget/chatCard.ts new file mode 100644 index 00000000000000..abd61d5a812884 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatCard.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Button, IButtonStyles } from '../../../../../base/browser/ui/button/button.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import './media/chatCard.css'; + +/** + * The large inline card shell: rounded border, panel background, clipped content. Chat's other + * card tier is `.chat-confirmation-widget2`, which is smaller and has no background. + */ +export const CHAT_CARD_LARGE_CLASS = 'chat-card-large'; + +/** Header strip of a large card: title on the left, actions on the right, separated by a rule. */ +export const CHAT_CARD_HEADER_CLASS = 'chat-card-header'; + +export const CHAT_CARD_TITLE_CLASS = 'chat-card-title'; + +export const CHAT_CARD_HEADER_ACTIONS_CLASS = 'chat-card-header-actions'; + +/** + * Button styles that set no colors at all. + * + * `Button` writes its background, foreground and border as *inline* styles, which no selector can + * outrank -- that is why every hand rolled copy of this button needed `!important`. Passing no + * colors makes `Button` write empty strings instead, leaving the appearance to the stylesheet. + */ +export const chatCardButtonStyles: IButtonStyles = { + buttonBackground: undefined, + buttonHoverBackground: undefined, + buttonForeground: undefined, + buttonSeparator: undefined, + buttonSecondaryBackground: undefined, + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + buttonBorder: undefined, +}; + +export interface IChatCardIconButtonOptions { + /** Omit for buttons whose glyph changes over time; set `label` on the result instead. */ + readonly icon?: ThemeIcon; + readonly ariaLabel: string; + /** Adds a delayed hover. Pass the aria label again when the two should match. */ + readonly hoverContent?: string; + /** `strong` reads as content rather than chrome, `padded` sizes to a label. */ + readonly variant?: 'strong' | 'padded'; +} + +/** + * Creates a chrome free 22px icon button for a card header or footer. + * + * Takes the store rather than returning one, so the button and its hover share the caller's + * single lifetime. + */ +export function createChatCardIconButton(store: DisposableStore, container: HTMLElement, hoverService: IHoverService, options: IChatCardIconButtonOptions): Button { + const button = store.add(new Button(container, { ...chatCardButtonStyles, secondary: true, supportIcons: true })); + button.element.classList.add('chat-card-icon-button'); + if (options.variant) { + button.element.classList.add(`chat-card-icon-button-${options.variant}`); + } + + if (options.icon) { + button.label = `$(${options.icon.id})`; + } + + button.element.setAttribute('aria-label', options.ariaLabel); + if (options.hoverContent !== undefined) { + store.add(hoverService.setupDelayedHover(button.element, { content: options.hoverContent })); + } + + return button; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatCardListbox.ts b/src/vs/workbench/contrib/chat/browser/widget/chatCardListbox.ts new file mode 100644 index 00000000000000..f8006c5ada6729 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatCardListbox.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * The ARIA scaffolding for the small single select lists inside chat cards. + * + * Keeps the active row's class, `aria-selected`, and `aria-activedescendant` in agreement, which is + * silent to get wrong. Keyboard handling and row rendering stay with the caller, since the + * consumers differ on wrapping, digit shortcuts, and what a selection change commits. + */ +export class ChatCardListbox { + + private readonly options: HTMLElement[] = []; + private _activeIndex = -1; + + constructor( + readonly domNode: HTMLElement, + ariaLabel: string, + /** Class toggled on the active row. */ + private readonly activeClass: string, + ) { + this.domNode.setAttribute('role', 'listbox'); + this.domNode.setAttribute('aria-label', ariaLabel); + this.domNode.tabIndex = 0; + } + + get activeIndex(): number { + return this._activeIndex; + } + + get length(): number { + return this.options.length; + } + + /** + * Registers a row as an option. The element is given an id, because + * `aria-activedescendant` can only refer to one. + */ + addOption(element: HTMLElement, idPrefix: string): void { + element.id = `${idPrefix}-option-${this.options.length}`; + element.setAttribute('role', 'option'); + element.setAttribute('aria-selected', 'false'); + this.options.push(element); + } + + /** Moves the active option. Pass -1 to clear it, which some callers use for freeform input. */ + setActive(index: number): void { + this._activeIndex = index; + this.options.forEach((option, i) => { + const isActive = i === index; + option.classList.toggle(this.activeClass, isActive); + option.setAttribute('aria-selected', String(isActive)); + }); + + const active = this.options[index]; + if (active) { + this.domNode.setAttribute('aria-activedescendant', active.id); + } else { + this.domNode.removeAttribute('aria-activedescendant'); + } + } + + /** + * Focuses the container. `aria-activedescendant` is only honoured on the element that actually + * has DOM focus, so focus must never move into an option or arrowing goes unannounced. + */ + focus(): void { + this.domNode.focus(); + } + + /** Clamps to the ends, matching the workbench lists. */ + clampedIndex(index: number): number { + return Math.max(0, Math.min(index, this.options.length - 1)); + } + + /** Wraps around the ends. */ + wrappedIndex(index: number): number { + if (this.options.length === 0) { + return -1; + } + return (index + this.options.length) % this.options.length; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts index 0db59afd065f2b..6da7d942b7b261 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts @@ -27,6 +27,7 @@ import { IContextMenuService } from '../../../../../../platform/contextview/brow import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { FileChangeType, IFileService } from '../../../../../../platform/files/common/files.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { CHAT_CARD_LARGE_CLASS, chatCardButtonStyles } from '../chatCard.js'; import { IMarkdownRendererService } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; import { defaultButtonStyles } from '../../../../../../platform/theme/browser/defaultStyles.js'; import { IEditorService } from '../../../../../services/editor/common/editorService.js'; @@ -145,7 +146,7 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { // Build DOM that mirrors chat-confirmation-widget2 so we inherit its // styling (title bar, scrollable message, blue/grey button row). const elements = dom.h('.chat-confirmation-widget-container.chat-plan-review-container@container', [ - dom.h('.chat-confirmation-widget2.chat-plan-review@root', [ + dom.h(`.chat-confirmation-widget2.chat-plan-review.${CHAT_CARD_LARGE_CLASS}@root`, [ dom.h('.chat-confirmation-widget-title.chat-plan-review-title@title', [ dom.h('.chat-plan-review-title-content', [ dom.h('.chat-plan-review-title-label@titleLabel'), @@ -189,15 +190,15 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { const reviewButtonTooltip = review.canProvideFeedback ? localize('chat.planReview.reviewTooltip', 'Review {0}', fileName) : localize('chat.planReview.openTooltip', 'Open {0}', fileName); - const reviewButton = this._register(new Button(this._titleActionsEl, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: reviewButtonTooltip, ariaLabel: reviewButtonTooltip })); - reviewButton.element.classList.add('chat-plan-review-title-button', 'chat-plan-review-review-button'); + const reviewButton = this._register(new Button(this._titleActionsEl, { ...chatCardButtonStyles, secondary: true, supportIcons: true, title: reviewButtonTooltip, ariaLabel: reviewButtonTooltip })); + reviewButton.element.classList.add('chat-card-icon-button', 'chat-card-icon-button-padded', 'chat-plan-review-title-button', 'chat-plan-review-review-button'); this._reviewButton = reviewButton; this._register(reviewButton.onDidClick(() => void this.enterReviewMode())); } // Chevron collapse toggle. - this._collapseButton = this._register(new Button(this._titleActionsEl, { ...defaultButtonStyles, secondary: true, supportIcons: true })); - this._collapseButton.element.classList.add('chat-plan-review-title-button', 'chat-plan-review-title-icon-button'); + this._collapseButton = this._register(new Button(this._titleActionsEl, { ...chatCardButtonStyles, secondary: true, supportIcons: true })); + this._collapseButton.element.classList.add('chat-card-icon-button', 'chat-card-icon-button-padded', 'chat-plan-review-title-button', 'chat-plan-review-title-icon-button'); this._register(this._collapseButton.onDidClick(() => this.toggleCollapsed())); // Scrollable message area (markdown). @@ -335,8 +336,8 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { // Clear All — visibility is toggled with the comments list. if (this.review.planUri) { const clearAllLabel = localize('chat.planReview.clearAll', "Clear All"); - const clearAllButton = this._register(new Button(headerActions, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: clearAllLabel, ariaLabel: clearAllLabel })); - clearAllButton.element.classList.add('chat-plan-review-title-button', 'chat-plan-review-feedback-clear-all'); + const clearAllButton = this._register(new Button(headerActions, { ...chatCardButtonStyles, secondary: true, supportIcons: true, title: clearAllLabel, ariaLabel: clearAllLabel })); + clearAllButton.element.classList.add('chat-card-icon-button', 'chat-card-icon-button-padded', 'chat-plan-review-title-button', 'chat-plan-review-feedback-clear-all'); clearAllButton.label = clearAllLabel; this._register(clearAllButton.onDidClick(() => this.clearAllInlineFeedback())); this._clearAllButtonEl = clearAllButton.element; @@ -346,8 +347,8 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { // and Clear All handle deletion explicitly. if (this.review.planUri) { const closeButtonLabel = localize('chat.planReview.close', "Close"); - const closeButton = this._register(new Button(headerActions, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: closeButtonLabel, ariaLabel: closeButtonLabel })); - closeButton.element.classList.add('chat-plan-review-title-button', 'chat-plan-review-title-icon-button', 'chat-plan-review-feedback-close'); + const closeButton = this._register(new Button(headerActions, { ...chatCardButtonStyles, secondary: true, supportIcons: true, title: closeButtonLabel, ariaLabel: closeButtonLabel })); + closeButton.element.classList.add('chat-card-icon-button', 'chat-card-icon-button-padded', 'chat-plan-review-title-button', 'chat-plan-review-title-icon-button', 'chat-plan-review-feedback-close'); closeButton.label = `$(${Codicon.closeSmall.id})`; this._register(closeButton.onDidClick(() => this.exitFeedbackMode())); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index 56d0546a8e1a98..2e957506ce5a86 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -42,6 +42,8 @@ import { ITerminalChatService } from '../../../../terminal/browser/terminal.js'; import { AgentHostAutoReplyAnswer } from '../../../../../../platform/agentHost/common/agentHostSchema.js'; import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; import { getChatMarkdownRenderOptions } from '../chatContentMarkdownRenderer.js'; +import { CHAT_CARD_HEADER_CLASS, CHAT_CARD_LARGE_CLASS, CHAT_CARD_TITLE_CLASS, createChatCardIconButton } from '../chatCard.js'; +import { ChatCardListbox } from '../chatCardListbox.js'; import './media/chatQuestionCarousel.css'; const PREVIOUS_QUESTION_ACTION_ID = 'workbench.action.chat.previousQuestion'; @@ -166,7 +168,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent ) { super(); - this.domNode = dom.$('.chat-question-carousel-container'); + this.domNode = dom.$(`.chat-question-carousel-container.${CHAT_CARD_LARGE_CLASS}`); this.domNode.classList.toggle('chat-question-carousel-conversation', carousel.answerPresentation === 'conversation'); this.domNode.id = generateUuid(); this._inChatQuestionCarouselContextKey = ChatContextKeys.inChatQuestionCarousel.bindTo(this._contextKeyService); @@ -232,20 +234,22 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._headerActionsContainer = dom.$('.chat-question-header-actions'); const collapseToggleTitle = localize('chat.questionCarousel.collapseTitle', 'Collapse Questions'); - const collapseButton = interactiveStore.add(new Button(this._headerActionsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); + const collapseButton = createChatCardIconButton(interactiveStore, this._headerActionsContainer, this._hoverService, { + ariaLabel: collapseToggleTitle, + }); collapseButton.element.classList.add('chat-question-collapse-toggle'); - collapseButton.element.setAttribute('aria-label', collapseToggleTitle); this._collapseButton = collapseButton; // Close/skip button (X) - placed in header row, only shown when allowSkip is true if (carousel.allowSkip) { this._closeButtonContainer = dom.$('.chat-question-close-container'); const skipAllTitle = localize('chat.questionCarousel.skipAllTitle', 'Skip all questions'); - const skipAllButton = interactiveStore.add(new Button(this._closeButtonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); - skipAllButton.label = `$(${Codicon.closeSmall.id})`; + const skipAllButton = createChatCardIconButton(interactiveStore, this._closeButtonContainer, this._hoverService, { + icon: Codicon.closeSmall, + ariaLabel: skipAllTitle, + hoverContent: skipAllTitle, + }); skipAllButton.element.classList.add('chat-question-close'); - skipAllButton.element.setAttribute('aria-label', skipAllTitle); - interactiveStore.add(this._hoverService.setupDelayedHover(skipAllButton.element, { content: skipAllTitle })); this._skipAllButton = skipAllButton; } @@ -257,11 +261,12 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const focusTerminalAriaLabel = kbLabel ? localize('chat.questionCarousel.focusTerminalAriaLabel', 'Focus Terminal ({0})', kbLabel) : focusTerminalTitle; - const focusTerminalButton = interactiveStore.add(new Button(this._focusTerminalButtonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); - focusTerminalButton.label = `$(${Codicon.terminal.id})`; + const focusTerminalButton = createChatCardIconButton(interactiveStore, this._focusTerminalButtonContainer, this._hoverService, { + icon: Codicon.terminal, + ariaLabel: focusTerminalAriaLabel, + hoverContent: focusTerminalTitle, + }); focusTerminalButton.element.classList.add('chat-question-focus-terminal'); - focusTerminalButton.element.setAttribute('aria-label', focusTerminalAriaLabel); - interactiveStore.add(this._hoverService.setupDelayedHover(focusTerminalButton.element, { content: focusTerminalTitle })); interactiveStore.add(focusTerminalButton.onDidClick(() => this._focusTerminal())); // Dismiss the carousel when the user types directly in the terminal, @@ -776,7 +781,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } const headerRow = dom.$('.chat-question-header-row'); - const titleRow = dom.$('.chat-question-title-row'); + const titleRow = dom.$(`.chat-question-title-row.${CHAT_CARD_HEADER_CLASS}`); // Render carousel-level message if present (e.g. from MCP elicitation) if (this.carousel.message && this._currentIndex === 0) { @@ -789,7 +794,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const questionText = getDisplayedQuestionText(question); if (questionText) { - const title = dom.$('.chat-question-title'); + const title = dom.$(`.chat-question-title.${CHAT_CARD_TITLE_CLASS}`); const messageContent = this.getQuestionText(questionText); title.setAttribute('aria-label', messageContent); @@ -928,20 +933,24 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const arrowsContainer = dom.$('.chat-question-nav-arrows'); const previousLabel = this.getLabelWithKeybinding(localize('previous', 'Previous'), PREVIOUS_QUESTION_ACTION_ID); - const prevButton = interactiveStore.add(new Button(arrowsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); + const prevButton = createChatCardIconButton(interactiveStore, arrowsContainer, this._hoverService, { + icon: Codicon.chevronLeft, + ariaLabel: previousLabel, + hoverContent: previousLabel, + variant: 'strong', + }); prevButton.element.classList.add('chat-question-nav-arrow', 'chat-question-nav-prev'); - prevButton.label = `$(${Codicon.chevronLeft.id})`; - prevButton.element.setAttribute('aria-label', previousLabel); - interactiveStore.add(this._hoverService.setupDelayedHover(prevButton.element, { content: previousLabel })); interactiveStore.add(prevButton.onDidClick(() => this.navigate(-1))); this._prevButton = prevButton; const nextLabel = this.getLabelWithKeybinding(localize('next', 'Next'), NEXT_QUESTION_ACTION_ID); - const nextButton = interactiveStore.add(new Button(arrowsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); + const nextButton = createChatCardIconButton(interactiveStore, arrowsContainer, this._hoverService, { + icon: Codicon.chevronRight, + ariaLabel: nextLabel, + hoverContent: nextLabel, + variant: 'strong', + }); nextButton.element.classList.add('chat-question-nav-arrow', 'chat-question-nav-next'); - nextButton.label = `$(${Codicon.chevronRight.id})`; - nextButton.element.setAttribute('aria-label', nextLabel); - interactiveStore.add(this._hoverService.setupDelayedHover(nextButton.element, { content: nextLabel })); interactiveStore.add(nextButton.onDidClick(() => this.navigate(1))); this._nextButton = nextButton; @@ -1123,9 +1132,6 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent private renderSingleSelect(container: HTMLElement, question: IChatQuestion): void { const orderedOptions = getOptionsWithDefaultsFirst(question); const selectContainer = dom.$('.chat-question-list'); - selectContainer.setAttribute('role', 'listbox'); - selectContainer.setAttribute('aria-label', question.title); - selectContainer.tabIndex = 0; container.appendChild(selectContainer); // Restore previous answer if exists @@ -1147,22 +1153,19 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } }); - const listItems: HTMLElement[] = []; + const listbox = new ChatCardListbox(selectContainer, question.title, 'selected'); const indicators: HTMLElement[] = []; - const updateSelection = (newIndex: number) => { - // Update visual state - listItems.forEach((item, i) => { + /** Paints the row state without committing, which is what initial render needs. */ + const paintSelection = (newIndex: number) => { + listbox.setActive(newIndex); + indicators.forEach((indicator, i) => { const isSelected = i === newIndex; - item.classList.toggle('selected', isSelected); - item.setAttribute('aria-selected', String(isSelected)); - const indicator = indicators[i]; indicator.classList.toggle('codicon', isSelected); indicator.classList.toggle('codicon-check', isSelected); }); - // Update aria-activedescendant for screen reader announcements - if (newIndex >= 0 && newIndex < listItems.length) { - selectContainer.setAttribute('aria-activedescendant', listItems[newIndex].id); - } + }; + const updateSelection = (newIndex: number) => { + paintSelection(newIndex); // Update tracked state const data = this._singleSelectItems.get(question.id); if (data) { @@ -1172,24 +1175,17 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this.saveCurrentAnswer(); }; + const listItems: HTMLElement[] = []; orderedOptions.forEach(({ option }, index) => { - const isSelected = index === selectedIndex; const listItem = dom.$('.chat-question-list-item'); - listItem.setAttribute('role', 'option'); - listItem.setAttribute('aria-selected', String(isSelected)); + listbox.addOption(listItem, `option-${question.id}`); listItem.setAttribute('aria-label', localize('chat.questionCarousel.optionLabel', "Option {0}: {1}", index + 1, option.label)); - listItem.id = `option-${question.id}-${index}`; - listItem.tabIndex = -1; const number = dom.$('.chat-question-list-number'); number.textContent = `${index + 1}`; listItem.appendChild(number); - // Selection indicator (checkmark when selected) const indicator = dom.$('.chat-question-list-indicator'); - if (isSelected) { - indicator.classList.add('codicon', 'codicon-check'); - } indicators.push(indicator); // Label with optional description (format: "Title - Description") @@ -1210,10 +1206,6 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent listItem.appendChild(label); listItem.appendChild(indicator); - if (isSelected) { - listItem.classList.add('selected'); - } - // if we select an option, clear text and go to next question this._inputBoxes.add(dom.addDisposableListener(listItem, dom.EventType.CLICK, (e: MouseEvent) => { e.preventDefault(); @@ -1238,10 +1230,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._singleSelectItems.set(question.id, { items: listItems, selectedIndex, optionIndices: orderedOptions.map(o => o.originalIndex) }); - // Set initial aria-activedescendant if there's a selected item - if (selectedIndex >= 0 && selectedIndex < listItems.length) { - selectContainer.setAttribute('aria-activedescendant', listItems[selectedIndex].id); - } + // Paints the initial row and points `aria-activedescendant` at it. Deliberately not + // `updateSelection`, which would commit an answer the user has not given yet. + paintSelection(selectedIndex); // Show freeform input only when explicitly allowed let freeformTextarea: HTMLTextAreaElement | undefined; @@ -1294,10 +1285,10 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent if (event.keyCode === KeyCode.DownArrow) { e.preventDefault(); - newIndex = Math.min(data.selectedIndex + 1, listItems.length - 1); + newIndex = listbox.clampedIndex(data.selectedIndex + 1); } else if (event.keyCode === KeyCode.UpArrow) { e.preventDefault(); - newIndex = Math.max(data.selectedIndex - 1, 0); + newIndex = listbox.clampedIndex(data.selectedIndex - 1); } else if ((event.keyCode === KeyCode.Enter || event.keyCode === KeyCode.Space) && !event.metaKey && !event.ctrlKey) { // Enter confirms current selection and advances to next question e.preventDefault(); @@ -1323,7 +1314,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } })); - // focus on the row when first rendered or textarea if it has content + // Focus the list itself, not an option: `aria-activedescendant` is only honoured on the + // focused element. Or the textarea, when it already has content. if (this._shouldAutoFocus()) { if (freeformTextarea && previousFreeform) { const capturedFreeform = freeformTextarea; @@ -1331,13 +1323,12 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent capturedFreeform.focus(); })); } else if (listItems.length > 0) { - const focusIndex = selectedIndex >= 0 ? selectedIndex : 0; // if no default and no freeform text, select the first answer if (selectedIndex < 0) { updateSelection(0); } this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(selectContainer), () => { - listItems[focusIndex]?.focus(); + listbox.focus(); })); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatPlanReview.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatPlanReview.css index 2504a4ad9617c1..b52156aaba3e8b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatPlanReview.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatPlanReview.css @@ -38,14 +38,8 @@ } .interactive-session .chat-plan-review-container > .chat-confirmation-widget2.chat-plan-review { - display: flex; - flex-direction: column; - border: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-chat-requestBorder)); - border-radius: var(--vscode-cornerRadius-large); - background-color: var(--vscode-panel-background); max-height: min(420px, 50vh); min-height: 0; - overflow: hidden; margin-bottom: 8px; } @@ -110,27 +104,7 @@ flex-shrink: 0; } -/* Small, transparent title-bar buttons — matches the question carousel - * chat-question-collapse-toggle / chat-question-close styles. */ -.interactive-session .chat-plan-review-container .monaco-button.chat-plan-review-title-button { - min-width: 22px; - height: 22px; - padding: 0 6px; - border: none !important; - box-shadow: none !important; - background: transparent !important; - color: var(--vscode-icon-foreground) !important; - display: inline-flex; - align-items: center; - justify-content: center; -} - -.interactive-session .chat-plan-review-container .monaco-button.chat-plan-review-title-button:hover:not(.disabled) { - background: var(--vscode-toolbar-hoverBackground) !important; -} - -/* Icon-only square buttons (restore, chevron, edit). Exactly 22x22 with no - * padding, matching `.monaco-button.chat-question-collapse-toggle`. */ +/* Icon-only square buttons (restore, chevron, edit): back to the default 22x22 square. */ .interactive-session .chat-plan-review-container .monaco-button.chat-plan-review-title-icon-button { width: 22px; padding: 0; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 6c49afefb5a766..45db0653732540 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -15,15 +15,9 @@ margin: 0; } -/* general questions styling - matches the tool confirmation (permissions) box family */ +/* general questions styling - card chrome comes from widget/media/chatCard.css */ .interactive-session .chat-question-carousel-container { margin: 8px 0; - border: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-chat-requestBorder)); - border-radius: var(--vscode-cornerRadius-large); - background-color: var(--vscode-panel-background); - display: flex; - flex-direction: column; - overflow: hidden; container-type: inline-size; max-height: min(420px, 45vh); position: relative; @@ -33,17 +27,10 @@ outline: none; } -.interactive-session .chat-question-carousel-container:focus-visible, -.interactive-session .chat-question-carousel-container:focus-within { +.interactive-session .chat-question-carousel-container:focus-visible { border-color: var(--vscode-focusBorder); } -/* in the agents window / editor the surface is the editor background */ -.agent-sessions-workbench .interactive-session .chat-question-carousel-container, -.editor-instance .interactive-session .chat-question-carousel-container { - background-color: var(--vscode-editor-background); -} - /* input part wrapper */ .interactive-session .interactive-input-part > .chat-question-carousel-widget-container, .interactive-session .interactive-input-part .interactive-input-and-edit-session > .chat-question-carousel-widget-container { @@ -77,24 +64,12 @@ flex-shrink: 0; .chat-question-title-row { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 8px; min-width: 0; - padding: 8px 8px 8px 16px; - border-bottom: 1px solid var(--vscode-chat-requestBorder); } .chat-question-title { - flex: 1; - min-width: 0; word-break: break-word; - overflow-wrap: anywhere; white-space: normal; - font-weight: var(--vscode-agents-fontWeight-semiBold); - font-size: var(--vscode-agents-fontSize-heading3); - margin: 0; user-select: text; -webkit-user-select: text; @@ -123,55 +98,10 @@ .chat-question-focus-terminal-container { flex-shrink: 0; - - .monaco-button.chat-question-focus-terminal { - min-width: 22px; - width: 22px; - height: 22px; - padding: 0; - border: none !important; - box-shadow: none !important; - background: transparent !important; - color: var(--vscode-icon-foreground) !important; - } - - .monaco-button.chat-question-focus-terminal:hover:not(.disabled) { - background: var(--vscode-toolbar-hoverBackground) !important; - } } .chat-question-close-container { flex-shrink: 0; - - .monaco-button.chat-question-close { - min-width: 22px; - width: 22px; - height: 22px; - padding: 0; - border: none !important; - box-shadow: none !important; - background: transparent !important; - color: var(--vscode-icon-foreground) !important; - } - - .monaco-button.chat-question-close:hover:not(.disabled) { - background: var(--vscode-toolbar-hoverBackground) !important; - } - } - - .monaco-button.chat-question-collapse-toggle { - min-width: 22px; - width: 22px; - height: 22px; - padding: 0; - border: none !important; - box-shadow: none !important; - background: transparent !important; - color: var(--vscode-icon-foreground) !important; - } - - .monaco-button.chat-question-collapse-toggle:hover:not(.disabled) { - background: var(--vscode-toolbar-hoverBackground) !important; } } } @@ -305,8 +235,12 @@ } } - /* When the question list has focus, use active selection styling */ - .chat-question-list:focus .chat-question-list-item.selected { + /* + * When the question list has focus, use active selection styling. Written with `&` because + * this rule is nested inside `.chat-question-list`; spelling the class out again would + * compile to a list inside a list and never match. + */ + &:focus .chat-question-list-item.selected { background-color: var(--vscode-list-activeSelectionBackground, var(--vscode-list-hoverBackground)); color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); @@ -440,25 +374,6 @@ gap: 4px; } - .monaco-button.chat-question-nav-arrow { - min-width: 22px; - width: 22px; - height: 22px; - padding: 0; - border: none !important; - box-shadow: none !important; - background: transparent !important; - color: var(--vscode-foreground) !important; - } - - .monaco-button.chat-question-nav-arrow:hover:not(.disabled) { - background: var(--vscode-toolbar-hoverBackground) !important; - } - - .monaco-button.chat-question-nav-arrow.disabled { - opacity: 0.4; - } - .chat-question-step-indicator { font-size: var(--vscode-chat-font-size-body-s); color: var(--vscode-descriptionForeground); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatToolConfirmationCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatToolConfirmationCarousel.css index 26c8881692e841..0aaac3339548e8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatToolConfirmationCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatToolConfirmationCarousel.css @@ -19,22 +19,16 @@ } } +/* Card chrome comes from widget/media/chatCard.css. */ .chat-tool-confirmation-carousel { color: var(--vscode-foreground); - display: flex; - flex-direction: column; max-height: min(300px, 45vh); - border: 1px solid var(--vscode-input-border, transparent); - border-radius: var(--vscode-cornerRadius-large); - background-color: var(--vscode-panel-background); - overflow: hidden; &:focus { outline: none !important; } - &:focus-visible, - &:focus-within { + &:focus-visible { border-color: var(--vscode-focusBorder); } @@ -99,32 +93,9 @@ } .chat-tool-carousel-overlay-actions { - display: flex; - align-items: center; - gap: 4px; - flex-shrink: 0; margin-left: auto; } - .monaco-button.chat-tool-carousel-dismiss-button { - min-width: 22px; - width: 22px; - height: 22px; - padding: 0; - border: none !important; - box-shadow: none !important; - background: transparent !important; - color: var(--vscode-icon-foreground) !important; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - } - - .monaco-button.chat-tool-carousel-dismiss-button:hover { - background: var(--vscode-toolbar-hoverBackground) !important; - } - button:focus, .monaco-button:focus { outline: none !important; @@ -136,44 +107,6 @@ align-items: center; } - .monaco-button.chat-tool-carousel-nav-arrow { - min-width: 22px; - width: 22px; - height: 22px; - padding: 0; - border: none !important; - box-shadow: none !important; - background: transparent !important; - color: var(--vscode-foreground) !important; - } - - .monaco-button.chat-tool-carousel-nav-arrow:hover:not(.disabled) { - background: var(--vscode-toolbar-hoverBackground) !important; - } - - .monaco-button.chat-tool-carousel-nav-arrow.disabled { - opacity: 0.4; - } - - .monaco-button.chat-tool-carousel-header-button { - min-width: 22px; - width: 22px; - height: 22px; - padding: 0; - border: none !important; - box-shadow: none !important; - background: transparent !important; - color: var(--vscode-icon-foreground) !important; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - } - - .monaco-button.chat-tool-carousel-header-button:hover:not(.disabled) { - background: var(--vscode-toolbar-hoverBackground) !important; - } - &.chat-tool-carousel-content-expanded { max-height: min(650px, 70vh); } @@ -311,8 +244,6 @@ .agent-sessions-workbench .chat-tool-confirmation-carousel, .editor-instance .chat-tool-confirmation-carousel { - background-color: var(--vscode-editor-background); - .interactive-result-editor { background-color: var(--vscode-interactive-result-editor-background-color, var(--vscode-editor-background)); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationCarouselPart.ts index 92c0d474b949cd..a15baa417e9176 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationCarouselPart.ts @@ -15,6 +15,7 @@ import { autorun } from '../../../../../../../base/common/observable.js'; import { generateUuid } from '../../../../../../../base/common/uuid.js'; import { localize } from '../../../../../../../nls.js'; import { defaultButtonStyles } from '../../../../../../../platform/theme/browser/defaultStyles.js'; +import { CHAT_CARD_HEADER_ACTIONS_CLASS, CHAT_CARD_LARGE_CLASS, chatCardButtonStyles } from '../../chatCard.js'; import { IChatToolInvocation, ToolConfirmKind } from '../../../../common/chatService/chatService.js'; import { ChatToolInvocationPart } from './chatToolInvocationPart.js'; import '../media/chatToolConfirmationCarousel.css'; @@ -79,13 +80,13 @@ export class ChatToolConfirmationCarouselPart extends Disposable { ) { super(); - const elements = dom.h('.chat-tool-confirmation-carousel@root', [ + const elements = dom.h(`.chat-tool-confirmation-carousel.${CHAT_CARD_LARGE_CLASS}@root`, [ dom.h('.chat-tool-carousel-overlay@overlay', [ dom.h('.chat-tool-carousel-title-group@titleGroup', [ dom.h('span.chat-tool-carousel-collapsed-title@collapsedTitle'), dom.h('button.chat-tool-carousel-agent-label@agentLabel'), ]), - dom.h('.chat-tool-carousel-overlay-actions@overlayActions', [ + dom.h(`.chat-tool-carousel-overlay-actions.${CHAT_CARD_HEADER_ACTIONS_CLASS}@overlayActions`, [ dom.h('.chat-tool-carousel-step-indicator@stepIndicator'), dom.h('.chat-tool-carousel-nav-arrows@navArrows'), ]), @@ -112,15 +113,15 @@ export class ChatToolConfirmationCarouselPart extends Disposable { this.allowAllButton.label = localize('allowAll', "Allow All"); this._register(this.allowAllButton.onDidClick(() => this.allowAll())); - this.expandContentButton = this._register(new Button(elements.overlayActions, { ...defaultButtonStyles, secondary: true, supportIcons: true })); - this.expandContentButton.element.classList.add('chat-tool-carousel-header-button', 'chat-tool-carousel-expand-content-button'); + this.expandContentButton = this._register(new Button(elements.overlayActions, { ...chatCardButtonStyles, secondary: true, supportIcons: true })); + this.expandContentButton.element.classList.add('chat-card-icon-button', 'chat-tool-carousel-header-button', 'chat-tool-carousel-expand-content-button'); this.expandContentButton.element.setAttribute('aria-controls', this.contentContainer.id); this.updateExpandContentButton(); dom.hide(this.expandContentButton.element); this._register(this.expandContentButton.onDidClick(() => this.toggleContentExpanded())); - this.dismissButton = this._register(new Button(elements.overlayActions, { ...defaultButtonStyles, secondary: true, supportIcons: true })); - this.dismissButton.element.classList.add('chat-tool-carousel-dismiss-button'); + this.dismissButton = this._register(new Button(elements.overlayActions, { ...chatCardButtonStyles, secondary: true, supportIcons: true })); + this.dismissButton.element.classList.add('chat-card-icon-button', 'chat-tool-carousel-dismiss-button'); this.dismissButton.label = `$(${Codicon.closeSmall.id})`; const dismissButtonLabel = this.items.length === 1 ? localize('skip', "Skip") @@ -130,21 +131,21 @@ export class ChatToolConfirmationCarouselPart extends Disposable { this._register(this.dismissButton.onDidClick(() => this.skipAll())); this.prevButton = this._register(new Button(elements.navArrows, { - ...defaultButtonStyles, + ...chatCardButtonStyles, secondary: true, supportIcons: true, })); - this.prevButton.element.classList.add('chat-tool-carousel-nav-arrow'); + this.prevButton.element.classList.add('chat-card-icon-button', 'chat-card-icon-button-strong', 'chat-tool-carousel-nav-arrow'); this.prevButton.label = `$(${Codicon.chevronLeft.id})`; this.prevButton.element.setAttribute('aria-label', localize('previous', "Previous")); this._register(this.prevButton.onDidClick(() => this.navigateRelative(-1))); this.nextButton = this._register(new Button(elements.navArrows, { - ...defaultButtonStyles, + ...chatCardButtonStyles, secondary: true, supportIcons: true, })); - this.nextButton.element.classList.add('chat-tool-carousel-nav-arrow'); + this.nextButton.element.classList.add('chat-card-icon-button', 'chat-card-icon-button-strong', 'chat-tool-carousel-nav-arrow'); this.nextButton.label = `$(${Codicon.chevronRight.id})`; this.nextButton.element.setAttribute('aria-label', localize('next', "Next")); this._register(this.nextButton.onDidClick(() => this.navigateRelative(1))); diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatCard.css b/src/vs/workbench/contrib/chat/browser/widget/media/chatCard.css new file mode 100644 index 00000000000000..84e493f642b12a --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chatCard.css @@ -0,0 +1,117 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* + * Shared chrome for the large inline cards in chat: the question carousel, the model feedback + * survey, the tool confirmation carousel, and plan review. + * + * Chat has two card tiers. `.chat-confirmation-widget2` is the medium tier (medium radius, + * request border, no background). This file is the large tier, which was open coded in all four + * places above before it had a name. + * + * Rules here are deliberately unscoped. The consumers sit under different ancestors (the survey + * is not inside `.interactive-session` at all), and the styles they replace are being deleted in + * the same change, so there is nothing left to outrank. + */ + +.chat-card-large { + display: flex; + flex-direction: column; + border: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-chat-requestBorder)); + border-radius: var(--vscode-cornerRadius-large); + background-color: var(--vscode-panel-background); + overflow: hidden; +} + +.chat-card-large:focus-within { + border-color: var(--vscode-focusBorder); +} + +/* + * Plan review composes both tiers on one element: it is a confirmation widget presented as a large + * card. The two tier classes have equal specificity, so name them together to state which tier owns + * the shell rather than leaving it to the order the stylesheets happen to load in. + */ +.chat-card-large.chat-confirmation-widget2 { + border: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-chat-requestBorder)); + border-radius: var(--vscode-cornerRadius-large); +} + +.chat-card-large.chat-confirmation-widget2:focus-within { + border-color: var(--vscode-focusBorder); +} + +/* In the agents window and the editor the surface is the editor background. */ +.agent-sessions-workbench .chat-card-large, +.editor-instance .chat-card-large { + background-color: var(--vscode-editor-background); +} + +.chat-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--vscode-spacing-size80); + padding: var(--vscode-spacing-size80) var(--vscode-spacing-size80) var(--vscode-spacing-size80) var(--vscode-spacing-size160); + border-bottom: var(--vscode-strokeThickness) solid var(--vscode-chat-requestBorder); + flex-shrink: 0; +} + +.chat-card-title { + flex: 1; + min-width: 0; + margin: 0; + font-size: var(--vscode-fontSize-heading3); + font-weight: var(--vscode-fontWeight-semiBold); + line-height: 1.4; + overflow-wrap: anywhere; +} + +.chat-card-header-actions { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size40); + flex-shrink: 0; +} + +/* + * Chrome free icon buttons. `Button` writes its colors as inline styles, so these are only + * reachable from CSS when the button is built without color options -- see + * `chatCardButtonStyles` in chatCard.ts. That is why no rule here needs `!important`. + */ +.monaco-button.chat-card-icon-button { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 22px; + min-width: 22px; + height: 22px; + padding: 0; + border: none; + box-shadow: none; + background: transparent; + color: var(--vscode-icon-foreground); + cursor: pointer; +} + +.monaco-button.chat-card-icon-button:hover:not(.disabled) { + background: var(--vscode-toolbar-hoverBackground); +} + +.monaco-button.chat-card-icon-button.disabled { + opacity: 0.4; +} + +/* Navigation arrows read as content rather than chrome, so they take the regular foreground. */ +.monaco-button.chat-card-icon-button.chat-card-icon-button-strong { + color: var(--vscode-foreground); +} + +/* For labelled title bar buttons, which size to their content instead of a fixed square. */ +.monaco-button.chat-card-icon-button.chat-card-icon-button-padded { + width: auto; + padding: 0 var(--vscode-spacing-size60); +} diff --git a/src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/chatModelFeedbackSurveyWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/chatModelFeedbackSurveyWidget.test.ts index 2156c6fe4f1009..52453e1df02167 100644 --- a/src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/chatModelFeedbackSurveyWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/feedbackSurvey/chatModelFeedbackSurveyWidget.test.ts @@ -180,7 +180,7 @@ suite('ChatModelFeedbackSurveyWidget', () => { assert.deepStrictEqual({ initial, afterDown, activeDescendant: list.getAttribute('aria-activedescendant') }, { initial: ['true', 'false', 'false'], afterDown: ['false', 'true', 'false'], - activeDescendant: 'chat-feedback-survey-option-instance-1-routing-1', + activeDescendant: 'chat-feedback-survey-instance-1-routing-option-1', }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts index 9120660316182a..fa12bfdd430ac1 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts @@ -715,6 +715,119 @@ suite('ChatQuestionCarouselPart', () => { }); }); + suite('Single Select Keyboard Navigation', () => { + function createSelectWidget(optionCount: number = 3, allowFreeformInput: boolean = true) { + const options = Array.from({ length: optionCount }, (_, i) => ({ + id: String.fromCharCode(97 + i), + label: `Option ${String.fromCharCode(65 + i)}`, + value: String.fromCharCode(97 + i), + })); + createWidget(createMockCarousel([{ id: 'q1', type: 'singleSelect', title: 'Choose one', options, allowFreeformInput }])); + return widget.domNode.querySelector('.chat-question-list') as HTMLElement; + } + + /** + * `keyCode` is a legacy read-only property. Chromium does accept it in the init dict, but + * that is non-standard and would need a cast, so define it explicitly as the survey test + * helper does. `StandardKeyboardEvent` reads it to derive its own key code. + */ + function press(target: HTMLElement, keyCode: number, key: string): void { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }); + Object.defineProperty(event, 'keyCode', { get: () => keyCode }); + target.dispatchEvent(event); + } + + /** The option index the list reports as selected, via the class the styling keys off. */ + function selectedIndex(): number { + const items = [...widget.domNode.querySelectorAll('.chat-question-list-item')]; + return items.findIndex(i => i.classList.contains('selected')); + } + + /** The option index `aria-activedescendant` points at, which is what a screen reader reads. */ + function activeDescendantIndex(list: HTMLElement): number { + const id = list.getAttribute('aria-activedescendant'); + const items = [...widget.domNode.querySelectorAll('.chat-question-list-item')]; + return items.findIndex(i => i.id === id); + } + + test('arrow keys move the selection and clamp at both ends', () => { + const list = createSelectWidget(3); + + const start = selectedIndex(); + press(list, 40 /* DownArrow */, 'ArrowDown'); + const afterDown = selectedIndex(); + press(list, 38 /* UpArrow */, 'ArrowUp'); + press(list, 38 /* UpArrow */, 'ArrowUp'); + const clampedAtTop = selectedIndex(); + press(list, 40 /* DownArrow */, 'ArrowDown'); + press(list, 40 /* DownArrow */, 'ArrowDown'); + press(list, 40 /* DownArrow */, 'ArrowDown'); + const clampedAtBottom = selectedIndex(); + + assert.deepStrictEqual({ start, afterDown, clampedAtTop, clampedAtBottom }, { + start: 0, + afterDown: 1, + clampedAtTop: 0, + clampedAtBottom: 2, + }); + }); + + test('number keys select the matching option, and the one past the last focuses freeform', () => { + const list = createSelectWidget(3); + + press(list, 51 /* Digit3 */, '3'); + const afterDigit3 = selectedIndex(); + press(list, 52 /* Digit4 */, '4'); + const afterDigitPastEnd = selectedIndex(); + const freeform = widget.domNode.querySelector('.chat-question-freeform-textarea'); + + assert.deepStrictEqual({ afterDigit3, afterDigitPastEnd, freeformFocused: mainWindow.document.activeElement === freeform }, { + afterDigit3: 2, + afterDigitPastEnd: -1, + freeformFocused: true, + }); + }); + + test('aria-activedescendant follows the selection', () => { + const list = createSelectWidget(3); + + const initial = activeDescendantIndex(list); + press(list, 40 /* DownArrow */, 'ArrowDown'); + const afterDown = activeDescendantIndex(list); + + assert.deepStrictEqual({ initial, afterDown, matchesSelection: afterDown === selectedIndex() }, { + initial: 0, + afterDown: 1, + matchesSelection: true, + }); + }); + + /** + * `aria-activedescendant` is only honoured on the element that actually has DOM focus. The + * list declares it, so the list is what has to be focused for the active option to be + * announced as the user arrows through the options. + */ + test('auto focus lands on the listbox that owns aria-activedescendant', async () => { + const list = createSelectWidget(3); + await new Promise(resolve => mainWindow.requestAnimationFrame(() => mainWindow.requestAnimationFrame(() => resolve()))); + + const items = [...widget.domNode.querySelectorAll('.chat-question-list-item')] as HTMLElement[]; + const active = mainWindow.document.activeElement as HTMLElement | null; + + assert.deepStrictEqual({ + focusedElementOwnsActiveDescendant: !!active?.hasAttribute('aria-activedescendant'), + focusIsOnList: active === list, + focusIsOnAnOption: items.includes(active as HTMLElement), + optionsAreNotTabStops: items.every(i => i.tabIndex === -1), + }, { + focusedElementOwnsActiveDescendant: true, + focusIsOnList: true, + focusIsOnAnOption: false, + optionsAreNotTabStops: true, + }); + }); + }); + suite('hasSameContent', () => { test('returns true for same carousel instance', () => { const carousel = createMockCarousel([ From e1801a05353f5f3e557f524b63a9d09cf7d736d1 Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Fri, 21 Aug 2026 12:58:25 -0400 Subject: [PATCH 02/21] Implement paste threshold adjustment and "Insert in Prompt" feature (#331978) * Agent Host changes for lramos15/agents/vscode-issue-331852-proposal * Refactor chat input and attachment components for improved performance - Remove unused code from agentHostInputCompletions and newChatContextAttachments. - Optimize chatInputPart and chatAttachmentWidgets for better efficiency. - Update tests for newChatInputPaste to reflect changes in functionality. * Fix undefined artifactLocation in session artifact images The image section called `artifactLocation`, but the helper is named `sessionArtifactLocation`, as the five other call sites use. This broke compilation and threw `ReferenceError` from the two Session Artifacts tests that build an image section. Introduced by #331946 and present on main. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b8277a07-8be3-4c23-a886-ba92a9c4cac0 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b8277a07-8be3-4c23-a886-ba92a9c4cac0 --- .../contrib/chat/browser/newChatInput.ts | 5 +++ .../contrib/chat/browser/sessionArtifacts.ts | 2 +- .../browser/sessionsChatAccessibilityHelp.ts | 1 + .../test/browser/newChatInputPaste.test.ts | 10 +++-- .../browser/actions/chatAccessibilityHelp.ts | 1 + .../chat/browser/actions/chatCopyActions.ts | 11 ++++++ .../chat/browser/chat.shared.contribution.ts | 6 +++ .../widget/input/editor/chatPasteProviders.ts | 39 +++++++++++++++---- .../contrib/chat/common/constants.ts | 1 + .../input/editor/chatPasteProviders.test.ts | 22 +++++++---- 10 files changed, 79 insertions(+), 19 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index 514664ea27a2f8..ff6c110034c2dc 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -799,13 +799,18 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation })); const dictationFocusKey = SessionsChatInputHasDictationFocus.bindTo(inputScopedContextKeyService); + // The composer is a chat input, so it carries the shared focus key that + // chat input keybindings such as paste as text are scoped to. + const inputHasFocusKey = ChatContextKeys.inputHasFocus.bindTo(inputScopedContextKeyService); this._register(this._editor.onDidFocusEditorWidget(() => { dictationFocusKey.set(true); + inputHasFocusKey.set(true); activeDictationComposer = this; this._onDidFocus.fire(); })); this._register(this._editor.onDidBlurEditorWidget(() => { dictationFocusKey.set(false); + inputHasFocusKey.set(false); if (activeDictationComposer === this) { activeDictationComposer = undefined; } diff --git a/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts b/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts index 4786ffd10fbfd7..594b46ef69db84 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts @@ -184,7 +184,7 @@ export function buildSessionArtifactSections(artifacts: readonly ISessionArtifac id: uri.toString(), label, resource: uri, - ...artifactLocation(uri, label), + ...sessionArtifactLocation(uri, label), ...(imageCarouselEnabled ? { ariaLabel: localize('sessionArtifacts.openImage', "Open {0} in Images Preview", label), diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 5f45f7fd63f9fc..327c7648469e15 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -56,6 +56,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.micContextMenu', "To choose a microphone or turn off dictation or Voice Mode, focus the microphone button in the input toolbar and open its context menu (for example Shift+F10).")); content.push(localize('sessionsChat.contextReferences', "Type # in the chat input to attach context. Use #file to reference a file or folder, or #session to reference another agent session. Referencing a session together with the /troubleshoot command analyzes that session's logs instead of the current one. Accept a suggestion with Tab or Enter; the reference appears as a pill above the input that you can remove.")); content.push(localize('sessionsChat.pastedText', "Long pasted text is stored as an attached text item and replaced in the input with a numbered inline reference.")); + content.push(localize('sessionsChat.pasteAsText', "To paste the clipboard as plain text, without converting it to Markdown or storing it as an attachment, invoke Paste as Text{0}.", '')); content.push(localize('sessionsChat.backgroundActivities', "Press Shift+Tab from the chat input to reach metadata and status pills above it, then press Enter or Space to activate a pill. Live browsers appear in their own pill, and background activities such as running subagents in another. A pill with more than one entry opens a picker; use the up and down arrows to navigate, Enter to open an entry, and Escape to dismiss the picker and return focus to the pill.")); content.push(localize('sessionsChat.conversations', "When multiple chats appear as tabs in a single group, the tab row replaces the session header and includes the session actions. Side-by-side chat groups retain the session header and keep their tab rows compact. Activate New Chat at the end of a tab row to start another chat in that group.")); content.push(localize('sessionsChat.subagentPills', "Subagent pills in the chat transcript can be dragged to a chat group's edge to open the subagent beside the current chat. With the keyboard, focus a subagent pill and press Alt+Enter to open it beside the current chat.")); diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatInputPaste.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatInputPaste.test.ts index 0e9d5e08d0dc33..cb91a2f50919a4 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatInputPaste.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatInputPaste.test.ts @@ -21,8 +21,9 @@ import { createTextModel } from '../../../../../editor/test/common/testTextModel import { withTestCodeEditor } from '../../../../../editor/test/browser/testCodeEditor.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IChatPasteTarget, IChatPasteTargetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; -import { PasteTextProvider } from '../../../../../workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.js'; +import { PasteTextProvider, pastedTextArtifactDefaultMinLength } from '../../../../../workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.js'; import { IChatRequestVariableEntry, isPastedTextArtifact } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; @@ -113,6 +114,9 @@ suite('NewChatInputPasteTarget', () => { pasteTargetService, new class extends mock() { }, new class extends mock() { }, + new class extends mock() { + override getValue(): T { return pastedTextArtifactDefaultMinLength as T; } + }, ); const transfer = new VSDataTransfer(); @@ -167,7 +171,7 @@ suite('NewChatInputPasteTarget', () => { } test('keeps the attachment and its inline reference consistent across undo and redo', async () => { - const pastedText = 'x'.repeat(1200); + const pastedText = `${'x'.repeat(1200)}\n`.repeat(10); const snapshots = await runPasteLifecycle(pastedText); const attached = { attachments: ['Pasted text #1'], codeIsPreserved: true, sent: [{ name: 'Pasted text #1', text: '#attachment:Pasted text #1' }] }; @@ -188,7 +192,7 @@ suite('NewChatInputPasteTarget', () => { }); test('removing the attachment takes its inline reference out of the input', async () => { - const pastedText = 'x'.repeat(1200); + const pastedText = `${'x'.repeat(1200)}\n`.repeat(10); const snapshots = await runPasteLifecycle(pastedText, attachments => { attachments.removeAttachment(attachments.attachments[0].id); }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 0d829b198e6d2b..a9201cb4444d76 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -176,6 +176,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('chat.find', 'To search the chat transcript, invoke Find in Chat{0}. Find Next{1} and Find Previous{2} move between results, scrolling each one into view.', '', '', '')); } content.push(localize('chat.attachments.pastedText', "Long pasted text is stored as an attached text item and replaced in the input with a numbered inline reference.")); + content.push(localize('chat.paste.asText', "To paste the clipboard as plain text, without converting it to Markdown or storing it as an attachment, invoke Paste as Text{0}.", '')); content.push(localize('chat.signals', "Accessibility Signals can be changed via settings with a prefix of signals.chat. By default, if a request takes more than 4 seconds, you will hear a sound indicating that progress is still occurring.")); return content.join('\n'); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts index 947f9df1ffe596..2eafd3dacf6063 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts @@ -8,6 +8,7 @@ import * as dom from '../../../../../base/browser/dom.js'; import { disposableTimeout } from '../../../../../base/common/async.js'; import { IActionRunner } from '../../../../../base/common/actions.js'; import { Codicon } from '../../../../../base/common/codicons.js'; +import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { Disposable, markAsSingleton, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; @@ -18,6 +19,7 @@ import { Action2, MenuId, MenuItemAction, registerAction2 } from '../../../../.. import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingsRegistry, KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { katexContainerClassName, katexContainerLatexAttributeName } from '../../../markdown/common/markedKatexExtension.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; @@ -143,6 +145,15 @@ export class ChatCopyActionRendering extends Disposable implements IWorkbenchCon } export function registerChatCopyActions() { + // A plain paste in the chat input may become Markdown or an attachment, so + // keep the usual "paste without formatting" chord for verbatim text. + KeybindingsRegistry.registerKeybindingRule({ + id: 'editor.action.pasteAsText', + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyV, + when: ChatContextKeys.inputHasFocus, + }); + registerAction2(class CopyAllAction extends Action2 { constructor() { super({ diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 2d0f60c8b62453..167a1ee5b49a6d 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -885,6 +885,12 @@ configurationRegistry.registerConfiguration({ enum: ['inline', 'hover', 'input', 'none'], default: 'inline', }, + [ChatConfiguration.PasteAsAttachmentThreshold]: { + markdownDescription: nls.localize('chat.pasteAsAttachmentThreshold', "The number of characters a paste must exceed before it is added to the chat input as an attachment instead of being inserted inline. A paste must also span several lines, so a long single-line paste is always inserted inline. Set this to a very large number to always paste inline."), + type: 'number', + minimum: 0, + default: 10000, + }, [ChatConfiguration.ChatViewSessionsEnabled]: { type: 'boolean', default: true, diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.ts index 84a43038c8175a..54c963d10ce91e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.ts @@ -26,17 +26,24 @@ import { localize } from '../../../../../../../nls.js'; import { IEnvironmentService } from '../../../../../../../platform/environment/common/environment.js'; import { IFileService } from '../../../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; +import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { ILogService } from '../../../../../../../platform/log/common/log.js'; import { IExtensionService, isProposedApiEnabled } from '../../../../../../services/extensions/common/extensions.js'; import { IChatRequestPasteVariableEntry, IChatRequestVariableEntry, isImageVariableEntry, toPasteVariableEntry, ChatPasteAttachmentMetadata } from '../../../../common/attachments/chatVariableEntries.js'; import { chatVariableLeader } from '../../../../common/requestParser/chatParserTypes.js'; import { IDynamicVariable } from '../../../../common/attachments/chatVariables.js'; import { IChatPasteTarget, IChatPasteTargetService } from '../../../chat.js'; -import { chatInputSchemes, isChatInputModel } from '../../../../common/constants.js'; +import { chatInputSchemes, isChatInputModel, ChatConfiguration } from '../../../../common/constants.js'; import { cleanupOldImages, createFileForMedia, resizeImage } from '../../../chatImageUtils.js'; const COPY_MIME_TYPES = 'application/vnd.code.additional-editor-data'; -const pastedTextArtifactMinLength = 1000; +export const pastedTextArtifactDefaultMinLength = 10000; +/** + * A long single line, such as a URL, a stack frame, or a dictated sentence, is + * content the user means to write with, so length alone must not turn it into + * an attachment. Only text that is also shaped like a document qualifies. + */ +const pastedTextArtifactMinLines = 10; export const CHAT_ATTACHMENT_MIME_TYPE = 'application/vnd.chat.attachment+json'; interface SerializedCopyData { @@ -345,6 +352,7 @@ export class PasteTextProvider implements DocumentPasteEditProvider { private readonly pasteTargetService: IChatPasteTargetService, private readonly modelService: IModelService, private readonly logService: ILogService, + private readonly configurationService: IConfigurationService, ) { } async provideDocumentPasteEdits(model: ITextModel, ranges: readonly IRange[], dataTransfer: IReadonlyVSDataTransfer, _context: DocumentPasteContext, token: CancellationToken): Promise { @@ -400,7 +408,10 @@ export class PasteTextProvider implements DocumentPasteEditProvider { if (token.isCancellationRequested) { return; } - const artifact = hasRicherPaste ? undefined : createPastedTextArtifact(textdata, target.attachments, markdown); + const artifact = hasRicherPaste ? undefined : createPastedTextArtifact(textdata, target.attachments, { + content: markdown, + minLength: this.configurationService.getValue(ChatConfiguration.PasteAsAttachmentThreshold, { resource: model.uri }), + }); if (artifact) { if (ranges.length !== 1 || target.isTerminalCommandPaste(textdata, ranges[0])) { return; @@ -448,10 +459,16 @@ export class PasteTextProvider implements DocumentPasteEditProvider { export function createPastedTextArtifact( text: string, existingAttachments: readonly IChatRequestVariableEntry[], - /** Richer representation to store instead of `text`, e.g. Markdown from pasted HTML. */ - content?: string, + options?: { + /** Richer representation to store instead of `text`, e.g. Markdown from pasted HTML. */ + readonly content?: string; + /** Character count the paste must exceed to become an attachment. */ + readonly minLength?: number; + }, ): { readonly attachment: IChatRequestPasteVariableEntry; readonly referenceText: string } | undefined { - if (text.trim().length < pastedTextArtifactMinLength) { + const trimmed = text.trim(); + const minLength = options?.minLength ?? pastedTextArtifactDefaultMinLength; + if (trimmed.length < minLength || countLines(trimmed) < pastedTextArtifactMinLines) { return undefined; } @@ -461,8 +478,9 @@ export function createPastedTextArtifact( name = localize('pastedTextArtifact.name', "Pasted text #{0}", index++); } while (existingAttachments.some(attachment => attachment.name === name)); + const content = options?.content; const value = content ?? text; - const lineCount = value.split(/\r\n|\r|\n/).length; + const lineCount = countLines(value); const pastedLines = lineCount === 1 ? localize('pastedTextArtifact.oneLine', "1 line") : localize('pastedTextArtifact.multipleLines', "{0} lines", lineCount); @@ -479,6 +497,10 @@ export function createPastedTextArtifact( }; } +function countLines(value: string): number { + return value.split(/\r\n|\r|\n/).length; +} + function getCopiedContext(code: string, file: URI, language: string, range: IRange): IChatRequestPasteVariableEntry { const fileName = basename(file); const start = range.startLineNumber; @@ -846,12 +868,13 @@ export class ChatPasteProvidersFeature extends Disposable { @IModelService modelService: IModelService, @IEnvironmentService environmentService: IEnvironmentService, @ILogService logService: ILogService, + @IConfigurationService configurationService: IConfigurationService, ) { super(); const chatInputProviders: DocumentPasteEditProvider[] = [ instaService.createInstance(CopyAttachmentsProvider), new PasteImageProvider(pasteTargetService, extensionService, fileService, environmentService, logService), - new PasteTextProvider(pasteTargetService, modelService, logService), + new PasteTextProvider(pasteTargetService, modelService, logService, configurationService), new PasteHtmlProvider(), ]; for (const scheme of chatInputSchemes) { diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 8fff0edb4e452b..74098086c57ea8 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -50,6 +50,7 @@ export enum ChatConfiguration { ExtensionToolsEnabled = 'chat.extensionTools.enabled', RepoInfoEnabled = 'chat.repoInfo.enabled', EditRequests = 'chat.editRequests', + PasteAsAttachmentThreshold = 'chat.pasteAsAttachmentThreshold', InlineReferencesStyle = 'chat.inlineReferences.style', AutoReply = 'chat.autoReply', GlobalAutoApprove = 'chat.tools.global.autoApprove', diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatPasteProviders.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatPasteProviders.test.ts index 4f4097368ba05c..04b2ed721faf61 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatPasteProviders.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatPasteProviders.test.ts @@ -16,10 +16,11 @@ import { DocumentPasteTriggerKind, ICustomEdit } from '../../../../../../../../e import { ITextModel } from '../../../../../../../../editor/common/model.js'; import { IModelService } from '../../../../../../../../editor/common/services/model.js'; import { TestInstantiationService } from '../../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IConfigurationService } from '../../../../../../../../platform/configuration/common/configuration.js'; import { ILogService } from '../../../../../../../../platform/log/common/log.js'; import { IChatPasteTarget, IChatPasteTargetService } from '../../../../../browser/chat.js'; import { IChatSessionsService } from '../../../../../common/chatSessionsService.js'; -import { CHAT_ATTACHMENT_MIME_TYPE, createPastedTextArtifact, PasteTextProvider } from '../../../../../browser/widget/input/editor/chatPasteProviders.js'; +import { CHAT_ATTACHMENT_MIME_TYPE, createPastedTextArtifact, pastedTextArtifactDefaultMinLength, PasteTextProvider } from '../../../../../browser/widget/input/editor/chatPasteProviders.js'; import { ChatPasteAttachmentMetadata, IChatRequestVariableEntry } from '../../../../../common/attachments/chatVariableEntries.js'; import { isSupportedChatFileScheme } from '../../../../../common/constants.js'; import { ChatResponseResource } from '../../../../../common/model/chatModel.js'; @@ -41,14 +42,16 @@ suite('Chat Paste Providers', () => { }); test('creates sequential artifacts only for long pasted text', () => { - const longText = 'x'.repeat(1000); + const longText = `${'x'.repeat(10000)}\n`.repeat(10); const first = createPastedTextArtifact(longText, []); assert.ok(first); const second = createPastedTextArtifact(`${longText}\nsecond line`, [first.attachment]); assert.ok(second); assert.deepStrictEqual({ - belowThreshold: createPastedTextArtifact('x'.repeat(999), []), + belowLengthThreshold: createPastedTextArtifact(`${'x'.repeat(100)}\n`.repeat(10), []), + belowLineThreshold: createPastedTextArtifact('x'.repeat(20000), []), + respectsConfiguredThreshold: !!createPastedTextArtifact(`${'x'.repeat(10)}\n`.repeat(10), [], { minLength: 100 }), first: { name: first.attachment.name, referenceText: first.referenceText, @@ -65,21 +68,23 @@ suite('Chat Paste Providers', () => { pastedLines: second.attachment.pastedLines, }, }, { - belowThreshold: undefined, + belowLengthThreshold: undefined, + belowLineThreshold: undefined, + respectsConfiguredThreshold: true, first: { name: 'Pasted text #1', referenceText: '#attachment:Pasted text #1', codeIsPreserved: true, language: 'plaintext', fileName: 'Pasted text #1', - pastedLines: '1 line', + pastedLines: '11 lines', metadataKind: 'paste', isTextArtifact: true, }, second: { name: 'Pasted text #2', referenceText: '#attachment:Pasted text #2', - pastedLines: '2 lines', + pastedLines: '12 lines', }, }); }); @@ -118,12 +123,15 @@ suite('Chat Paste Providers', () => { pasteTargetService, new class extends mock() { }, new class extends mock() { }, + new class extends mock() { + override getValue(): T { return pastedTextArtifactDefaultMinLength as T; } + }, ); const model = upcastPartial({ uri: modelUri, getOffsetAt: position => position.column - 1, }); - const longText = 'x'.repeat(1000); + const longText = `${'x'.repeat(10000)}\n`.repeat(10); const transferOf = (entries: Record) => { const transfer = new VSDataTransfer(); for (const [mime, value] of Object.entries(entries)) { From 0c29c285eee219cb1068cf65fd72ab6e9a96de29 Mon Sep 17 00:00:00 2001 From: vritant24 Date: Fri, 21 Aug 2026 10:15:26 -0700 Subject: [PATCH 03/21] Agent Host changes for agents/log-analysis-error-fix-prioritization-58b68a87 --- .../agentHost/agentHostSessionHandler.ts | 3 + .../agentHostChatContribution.test.ts | 62 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 1ec57fa4094958..9574c85aed4a6c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -3250,6 +3250,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // across the natural-completion path; cancellation paths can // still call `dispose()` proactively (idempotent). queueMicrotask(() => { + if (store.isDisposed) { + return; + } try { opts.onTurnEnded?.(lastTurn); } finally { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 52732493b884e4..6d32132bdb52a8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -10507,6 +10507,68 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(chatSession.isCompleteObs!.get(), true); })); + test('stale completion from a replaced server turn does not complete the next response', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/replaced-server-turn' }); + const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => chatSession.dispose())); + + agentHostService.dispatchedActions.length = 0; + const registered = chatAgentService.registeredAgents.get('agent-host-copilot')!; + const initialTurn = registered.impl.invoke( + makeRequest({ message: 'Init', sessionResource }), + () => { }, [], CancellationToken.None, + ); + await timeout(10); + const initialDispatch = agentHostService.turnActions[0]; + const initialAction = initialDispatch.action as ITurnStartedAction; + const session = initialDispatch.channel.toString(); + agentHostService.fireAction({ channel: session, action: initialAction, serverSeq: 1, origin: { clientId: agentHostService.clientId, clientSeq: initialDispatch.clientSeq } }); + agentHostService.fireAction({ channel: session, action: { type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId: initialAction.turnId } as ChatAction, serverSeq: 2, origin: undefined }); + await initialTurn; + + const serverRequestIds: string[] = []; + disposables.add(chatSession.onDidStartServerRequest!(request => serverRequestIds.push(request.id))); + const firstTurnId = 'server-turn-first'; + const secondTurnId = 'server-turn-second'; + agentHostService.fireAction({ + channel: session, + action: { type: 'chat/turnStarted', startedAt: '2025-01-01T00:00:00.000Z', session, turnId: firstTurnId, message: { text: 'first', origin: { kind: MessageKind.User } } } as ChatAction, + serverSeq: 3, origin: undefined, + }); + await timeout(10); + + agentHostService.fireAction({ + channel: session, + action: { type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId: firstTurnId } as ChatAction, + serverSeq: 4, origin: undefined, + }); + agentHostService.fireAction({ + channel: session, + action: { type: 'chat/turnStarted', startedAt: '2025-01-01T00:00:00.000Z', session, turnId: secondTurnId, message: { text: 'second', origin: { kind: MessageKind.User } } } as ChatAction, + serverSeq: 5, origin: undefined, + }); + agentHostService.fireAction({ + channel: session, + action: { type: 'chat/responsePart', session, turnId: secondTurnId, part: { kind: 'markdown', id: 'md-second', content: 'new response' } } as ChatAction, + serverSeq: 6, origin: undefined, + }); + await timeout(10); + + assert.deepStrictEqual({ + serverRequestIds, + isComplete: chatSession.isCompleteObs!.get(), + markdown: chatSession.progressObs!.get() + .filter((part): part is IChatMarkdownContent => part.kind === 'markdownContent') + .map(part => part.content.value), + }, { + serverRequestIds: [firstTurnId, secondTurnId], + isComplete: false, + markdown: ['new response'], + }); + })); + test('disposing chat session does not call disposeSession on connection', async () => { const { sessionHandler, agentHostService } = createContribution(disposables); From eb6755c072c6f4f650e1c5fd4e636448bf9fb20f Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:41:30 +0200 Subject: [PATCH 04/21] Remove flaky session artifact image tests (#331998) * Remove flaky session artifact image tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix session artifact image location helper Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/browser/sessionArtifacts.test.ts | 60 +------------------ 1 file changed, 1 insertion(+), 59 deletions(-) diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionArtifacts.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionArtifacts.test.ts index 0472982acac96f..13474f47aeb3b2 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionArtifacts.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionArtifacts.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { isMarkdownString } from '../../../../../base/common/htmlContent.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { buildSessionArtifactSections, type ISessionArtifactActions, type ISessionArtifactImage } from '../../browser/sessionArtifacts.js'; +import { buildSessionArtifactSections, type ISessionArtifactActions } from '../../browser/sessionArtifacts.js'; import { type ISessionArtifact, SessionArtifactKind, SessionFileOperation } from '../../../../services/sessions/common/session.js'; suite('Session Artifacts', () => { @@ -49,62 +49,4 @@ suite('Session Artifacts', () => { ]); }); - test('groups artifact images separately and opens all images in the carousel', () => { - const screenshotUri = URI.file('/artifacts/screenshot.png'); - const diagramUri = URI.file('/external/diagram.jpg'); - const reportUri = URI.file('/artifacts/report.md'); - const opened: { images: readonly ISessionArtifactImage[]; startIndex: number }[] = []; - const imageActions: ISessionArtifactActions = { - ...actions, - openImages: (images, startIndex) => opened.push({ images, startIndex }), - }; - const artifacts: readonly ISessionArtifact[] = [ - { id: 'screenshot', kind: SessionArtifactKind.File, label: 'Screenshot', uri: screenshotUri }, - { id: 'report', kind: SessionArtifactKind.File, label: 'Report', uri: reportUri }, - ]; - - const sections = buildSessionArtifactSections(artifacts, [ - { uri: diagramUri, operation: SessionFileOperation.Created }, - ], imageActions, true); - const imageSection = sections.find(section => section.title === 'Images'); - assert.ok(imageSection); - imageSection.entries[1].open(); - - assert.deepStrictEqual({ - sections: sections.map(section => ({ title: section.title, labels: section.entries.map(entry => entry.label) })), - opened: opened.map(entry => ({ images: entry.images.map(image => image.uri.path), startIndex: entry.startIndex })), - }, { - sections: [ - { title: 'Images', labels: ['screenshot.png', 'diagram.jpg'] }, - { title: 'Files', labels: ['report.md'] }, - ], - opened: [{ images: ['/artifacts/screenshot.png', '/external/diagram.jpg'], startIndex: 1 }], - }); - }); - - test('opens the image resource when the image carousel is disabled', () => { - const screenshotUri = URI.file('/artifacts/screenshot.png'); - const opened: string[] = []; - const imageActions: ISessionArtifactActions = { - ...actions, - openImages: () => opened.push('carousel'), - openResource: uri => opened.push(uri.path), - }; - const artifacts: readonly ISessionArtifact[] = [ - { id: 'screenshot', kind: SessionArtifactKind.File, label: 'Screenshot', uri: screenshotUri }, - ]; - - const sections = buildSessionArtifactSections(artifacts, [], imageActions, false); - const imageSection = sections.find(section => section.title === 'Images'); - assert.ok(imageSection); - imageSection.entries[0].open(); - - assert.deepStrictEqual({ - ariaLabel: imageSection.entries[0].ariaLabel, - opened, - }, { - ariaLabel: 'Open screenshot.png', - opened: ['/artifacts/screenshot.png'], - }); - }); }); From 8f4fc8d3273653bfb73b8d4c3cfc7eeecd00722c Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 21 Aug 2026 10:56:37 -0700 Subject: [PATCH 05/21] chat: identify Agent Host telemetry sessions (#332000) * chat: identify Agent Host telemetry sessions Adds a session-mode property to chat request, user-action, edit, and follow-up telemetry. This lets telemetry split Agent Host sessions from legacy sessions, including local fallback sessions. - Adds isAgentHostSession to workbench chat telemetry events.\n- Adds whole-file edit outcomes and tags hunk outcomes.\n- Tags shared accepted and rejected edit telemetry.\n- Adds focused tests for Agent Host and legacy telemetry values. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: tag code block telemetry sessions Tags sidebar code-block telemetry with the actual session mode. Corrects the remaining-edits value for user-modified file outcomes. - Shares Agent Host session resource detection.\n- Tags code-block suggestions and acceptance actions.\n- Reports pending edits when users modify a reviewed file.\n- Extends focused telemetry coverage. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/actions/chatCodeblockActions.ts | 4 + .../browser/actions/codeBlockOperations.ts | 2 + .../chatEditingModifiedFileEntry.ts | 6 +- .../chatMarkdownContentPart.ts | 3 +- .../common/chatService/chatServiceImpl.ts | 2 +- .../chatService/chatServiceTelemetry.ts | 91 +++++++++++++++---- .../chat/common/chatSessionsService.ts | 4 + .../common/chatService/chatService.test.ts | 63 ++++++++++++- .../aiEditTelemetry/aiEditTelemetryService.ts | 3 + .../aiEditTelemetryServiceImpl.ts | 9 ++ .../test/browser/editTelemetry.test.ts | 37 ++++++++ 11 files changed, 198 insertions(+), 26 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts index 640998f1220d15..af600ebf796c24 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts @@ -33,6 +33,7 @@ import { reviewEdits } from './reviewEdits.js'; import { ITerminalEditorService, ITerminalGroupService, ITerminalService } from '../../../terminal/browser/terminal.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatCopyKind, IChatService } from '../../common/chatService/chatService.js'; +import { isAgentHostSessionResource } from '../../common/chatSessionsService.js'; import { IChatRequestViewModel, IChatResponseViewModel, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js'; import { ChatAgentLocation } from '../../common/constants.js'; import { IChatCodeBlockContextProviderService, IChatWidgetService } from '../chat.js'; @@ -201,6 +202,7 @@ export function registerChatCodeBlockActions() { applyCodeBlockSuggestionId: undefined, source: undefined, sourceRequestId: undefined, + isAgentHostSession: isAgentHostSessionResource(context.element.sessionResource), }); } } @@ -269,6 +271,7 @@ export function registerChatCodeBlockActions() { applyCodeBlockSuggestionId: undefined, source: undefined, sourceRequestId: undefined, + isAgentHostSession: isAgentHostSessionResource(element.sessionResource), }); } @@ -427,6 +430,7 @@ export function registerChatCodeBlockActions() { applyCodeBlockSuggestionId: undefined, source: undefined, sourceRequestId: undefined, + isAgentHostSession: isAgentHostSessionResource(context.element.sessionResource), }); } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts b/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts index c3650b84faa993..06507add6b7d3b 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts @@ -36,6 +36,7 @@ import { CellKind, ICellEditOperation, NOTEBOOK_EDITOR_ID } from '../../../noteb import { INotebookService } from '../../../notebook/common/notebookService.js'; import { ICodeMapperCodeBlock, ICodeMapperRequest, ICodeMapperResponse, ICodeMapperService } from '../../common/editing/chatCodeMapperService.js'; import { ChatUserAction, IChatService } from '../../common/chatService/chatService.js'; +import { isAgentHostSessionResource } from '../../common/chatSessionsService.js'; import { IChatRequestViewModel, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js'; import { ICodeBlockActionContext } from '../widget/chatContentParts/codeBlockPart.js'; @@ -91,6 +92,7 @@ export class InsertCodeBlockOperation { applyCodeBlockSuggestionId: undefined, source: undefined, sourceRequestId: undefined, + isAgentHostSession: isAgentHostSessionResource(context.element.sessionResource), }); } } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts index 94f750d632f105..dc6e1279c0370c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts @@ -24,6 +24,7 @@ import { IFilesConfigurationService } from '../../../../services/filesConfigurat import { IAiEditTelemetryService } from '../../../editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.js'; import { ICellEditOperation } from '../../../notebook/common/notebookCommon.js'; import { ChatUserAction, IChatService } from '../../common/chatService/chatService.js'; +import { isAgentHostSessionResource } from '../../common/chatSessionsService.js'; import { ChatEditKind, IModifiedEntryTelemetryInfo, IModifiedFileEntry, IModifiedFileEntryEditorIntegration, ISnapshotEntry, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; import { IChatResponseModel } from '../../common/model/chatModel.js'; @@ -269,10 +270,11 @@ export abstract class AbstractChatEditingModifiedFileEntry extends Disposable im protected abstract _doReject(): Promise; protected _notifySessionAction(outcome: 'accepted' | 'rejected' | 'userModified') { - this._notifyAction({ kind: 'chatEditingSessionAction', uri: this.modifiedURI, hasRemainingEdits: false, outcome }); + this._notifyAction({ kind: 'chatEditingSessionAction', uri: this.modifiedURI, hasRemainingEdits: outcome === 'userModified', outcome }); } protected _notifyAction(action: ChatUserAction) { + const isAgentHostSession = isAgentHostSessionResource(this._telemetryInfo.sessionResource); if (action.kind === 'chatEditingHunkAction' && action.outcome === 'accepted') { this._aiEditTelemetryService.handleCodeAccepted({ suggestionId: undefined, // TODO@hediet try to figure this out @@ -291,6 +293,7 @@ export abstract class AbstractChatEditingModifiedFileEntry extends Disposable im languageId: action.languageId, source: undefined, sourceRequestId: this._telemetryInfo.requestId, + isAgentHostSession, }); } else if (action.kind === 'chatEditingHunkAction' && action.outcome === 'rejected') { this._aiEditTelemetryService.handleCodeRejected({ @@ -310,6 +313,7 @@ export abstract class AbstractChatEditingModifiedFileEntry extends Disposable im languageId: action.languageId, source: undefined, sourceRequestId: this._telemetryInfo.requestId, + isAgentHostSession, }); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts index 5811f5f7b3da52..d73c6fe24ee9fa 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts @@ -47,7 +47,7 @@ import { extractCodeblockUrisFromText, extractVulnerabilitiesFromText } from '.. import { IEditSessionEntryDiff } from '../../../common/editing/chatEditingService.js'; import { IChatProgressRenderableResponseContent } from '../../../common/model/chatModel.js'; import { IChatContentInlineReference, IChatMarkdownContent, IChatService, IChatUndoStop } from '../../../common/chatService/chatService.js'; -import { IChatSessionsService } from '../../../common/chatSessionsService.js'; +import { IChatSessionsService, isAgentHostSessionResource } from '../../../common/chatSessionsService.js'; import { isRequestVM, isResponseVM } from '../../../common/model/chatViewModel.js'; import { ChatConfiguration } from '../../../common/constants.js'; import { IChatCodeBlockInfo } from '../../chat.js'; @@ -384,6 +384,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP applyCodeBlockSuggestionId: undefined, source: undefined, sourceRequestId: undefined, + isAgentHostSession: isAgentHostSessionResource(element.sessionResource), }) }; })); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index d297690e19d961..493c470cfe69aa 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -1810,7 +1810,7 @@ export class ChatService extends Disposable implements IChatService { agentOrCommandFollowups.then(followups => { model.setFollowups(completedRequest, followups); const commandForTelemetry = agentSlashCommandPart ? agentSlashCommandPart.command.name : commandPart?.slashCommand.command; - this._chatServiceTelemetry.retrievedFollowups(agentPart?.agent.id ?? '', commandForTelemetry, followups?.length ?? 0); + this._chatServiceTelemetry.retrievedFollowups(model.sessionResource, agentPart?.agent.id ?? '', commandForTelemetry, followups?.length ?? 0); }); } } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts index 00aed85de18eaf..d711dad0665547 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts @@ -14,15 +14,24 @@ import { isImageVariableEntry } from '../attachments/chatVariableEntries.js'; import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../constants.js'; import { ILanguageModelsService } from '../languageModels.js'; import { chatSessionResourceToId, getChatSessionType } from '../model/chatUri.js'; +import { isAgentHostSessionResource } from '../chatSessionsService.js'; import { isRemoteAgentHostSessionType, parseRemoteAgentHostHarness } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; -type ChatVoteEvent = { +type ChatSessionModeEvent = { + isAgentHostSession: boolean; +}; + +type ChatSessionModeClassification = { + isAgentHostSession: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the action was performed in an Agent Host-backed chat session.' }; +}; + +type ChatVoteEvent = ChatSessionModeEvent & { direction: 'up' | 'down'; agentId: string; command: string | undefined; }; -type ChatVoteClassification = { +type ChatVoteClassification = ChatSessionModeClassification & { direction: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the user voted up or down.' }; agentId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the chat agent that this vote is for.' }; command: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the slash command that this vote is for.' }; @@ -30,13 +39,13 @@ type ChatVoteClassification = { comment: 'Provides insight into the performance of Chat agents.'; }; -type ChatCopyEvent = { +type ChatCopyEvent = ChatSessionModeEvent & { copyKind: 'action' | 'toolbar'; agentId: string; command: string | undefined; }; -type ChatCopyClassification = { +type ChatCopyClassification = ChatSessionModeClassification & { copyKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How the copy was initiated.' }; agentId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the chat agent that the copy acted on.' }; command: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the slash command the copy acted on.' }; @@ -44,13 +53,13 @@ type ChatCopyClassification = { comment: 'Provides insight into the usage of Chat features.'; }; -type ChatInsertEvent = { +type ChatInsertEvent = ChatSessionModeEvent & { newFile: boolean; agentId: string; command: string | undefined; }; -type ChatInsertClassification = { +type ChatInsertClassification = ChatSessionModeClassification & { newFile: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the code was inserted into a new untitled file.' }; agentId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the chat agent that this insertion is for.' }; command: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the slash command that this insertion is for.' }; @@ -58,7 +67,7 @@ type ChatInsertClassification = { comment: 'Provides insight into the usage of Chat features.'; }; -type ChatApplyEvent = { +type ChatApplyEvent = ChatSessionModeEvent & { newFile: boolean; agentId: string; command: string | undefined; @@ -66,7 +75,7 @@ type ChatApplyEvent = { editsProposed: boolean; }; -type ChatApplyClassification = { +type ChatApplyClassification = ChatSessionModeClassification & { newFile: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the code was inserted into a new untitled file.' }; agentId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the chat agent that this insertion is for.' }; command: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the slash command that this insertion is for.' }; @@ -76,25 +85,25 @@ type ChatApplyClassification = { comment: 'Provides insight into the usage of Chat features.'; }; -type ChatFollowupEvent = { +type ChatFollowupEvent = ChatSessionModeEvent & { agentId: string; command: string | undefined; }; -type ChatFollowupClassification = { +type ChatFollowupClassification = ChatSessionModeClassification & { agentId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the related chat agent.' }; command: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the related slash command.' }; owner: 'roblourens'; comment: 'Provides insight into the usage of Chat features.'; }; -type ChatTerminalEvent = { +type ChatTerminalEvent = ChatSessionModeEvent & { languageId: string; agentId: string; command: string | undefined; }; -type ChatTerminalClassification = { +type ChatTerminalClassification = ChatSessionModeClassification & { languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language of the code that was run in the terminal.' }; agentId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the related chat agent.' }; command: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the related slash command.' }; @@ -102,13 +111,13 @@ type ChatTerminalClassification = { comment: 'Provides insight into the usage of Chat features.'; }; -type ChatFollowupsRetrievedEvent = { +type ChatFollowupsRetrievedEvent = ChatSessionModeEvent & { agentId: string; command: string | undefined; numFollowups: number; }; -type ChatFollowupsRetrievedClassification = { +type ChatFollowupsRetrievedClassification = ChatSessionModeClassification & { agentId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the related chat agent.' }; command: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the related slash command.' }; numFollowups: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of followup prompts returned by the agent.' }; @@ -116,7 +125,27 @@ type ChatFollowupsRetrievedClassification = { comment: 'Provides insight into the usage of Chat features.'; }; -type ChatEditHunkEvent = { +type ChatEditSessionEvent = ChatSessionModeEvent & { + agentId: string; + outcome: 'accepted' | 'rejected' | 'userModified'; + hasRemainingEdits: boolean; + requestId: string; + modelId: string; + modeId: string; +}; + +type ChatEditSessionClassification = ChatSessionModeClassification & { + agentId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the related chat agent.' }; + outcome: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The outcome of the edited file action.' }; + hasRemainingEdits: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether there are remaining edits in the file after this action.' }; + requestId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the chat request that produced the edit.' }; + modelId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The AI model used to generate the edit.' }; + modeId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat mode used for the request (e.g. ask, edit, agent).' }; + owner: 'roblourens'; + comment: 'Provides insight into the usage of Chat features.'; +}; + +type ChatEditHunkEvent = ChatSessionModeEvent & { agentId: string; outcome: 'accepted' | 'rejected'; lineCount: number; @@ -126,7 +155,7 @@ type ChatEditHunkEvent = { modeId: string; }; -type ChatEditHunkClassification = { +type ChatEditHunkClassification = ChatSessionModeClassification & { agentId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the related chat agent.' }; outcome: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The outcome of the edit hunk action.' }; lineCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of lines in the relevant change.' }; @@ -138,7 +167,7 @@ type ChatEditHunkClassification = { comment: 'Provides insight into the usage of Chat features.'; }; -export type ChatProviderInvokedEvent = { +export type ChatProviderInvokedEvent = ChatSessionModeEvent & { timeToFirstProgress: number | undefined; totalTime: number | undefined; result: 'success' | 'error' | 'errorWithOutput' | 'cancelled' | 'filtered'; @@ -161,7 +190,7 @@ export type ChatProviderInvokedEvent = { harness: string | undefined; }; -export type ChatProviderInvokedClassification = { +export type ChatProviderInvokedClassification = ChatSessionModeClassification & { timeToFirstProgress: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The time in milliseconds from invoking the provider to getting the first data.' }; totalTime: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The total time it took to run the provider\'s `provideResponseWithProgress`.' }; result: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether invoking the ChatProvider resulted in an error.' }; @@ -192,26 +221,31 @@ export class ChatServiceTelemetry { ) { } notifyUserAction(action: IChatUserActionEvent): void { + const isAgentHostSession = getIsAgentHostSessionForTelemetry(action.sessionResource); if (action.action.kind === 'vote') { this.telemetryService.publicLog2('interactiveSessionVote', { + isAgentHostSession, direction: action.action.direction === ChatAgentVoteDirection.Up ? 'up' : 'down', agentId: action.agentId ?? '', command: action.command, }); } else if (action.action.kind === 'copy') { this.telemetryService.publicLog2('interactiveSessionCopy', { + isAgentHostSession, copyKind: action.action.copyKind === ChatCopyKind.Action ? 'action' : 'toolbar', agentId: action.agentId ?? '', command: action.command, }); } else if (action.action.kind === 'insert') { this.telemetryService.publicLog2('interactiveSessionInsert', { + isAgentHostSession, newFile: !!action.action.newFile, agentId: action.agentId ?? '', command: action.command, }); } else if (action.action.kind === 'apply') { this.telemetryService.publicLog2('interactiveSessionApply', { + isAgentHostSession, newFile: !!action.action.newFile, codeMapper: action.action.codeMapper, agentId: action.agentId ?? '', @@ -220,17 +254,30 @@ export class ChatServiceTelemetry { }); } else if (action.action.kind === 'runInTerminal') { this.telemetryService.publicLog2('interactiveSessionRunInTerminal', { + isAgentHostSession, languageId: action.action.languageId ?? '', agentId: action.agentId ?? '', command: action.command, }); } else if (action.action.kind === 'followUp') { this.telemetryService.publicLog2('chatFollowupClicked', { + isAgentHostSession, agentId: action.agentId ?? '', command: action.command, }); + } else if (action.action.kind === 'chatEditingSessionAction') { + this.telemetryService.publicLog2('chatEditSession', { + isAgentHostSession, + agentId: action.agentId ?? '', + outcome: action.action.outcome, + hasRemainingEdits: action.action.hasRemainingEdits, + requestId: action.requestId, + modelId: escapeModelIdForTelemetry(action.modelId) ?? '', + modeId: action.modeId ?? '', + }); } else if (action.action.kind === 'chatEditingHunkAction') { this.telemetryService.publicLog2('chatEditHunk', { + isAgentHostSession, agentId: action.agentId ?? '', outcome: action.action.outcome, lineCount: action.action.lineCount, @@ -242,8 +289,9 @@ export class ChatServiceTelemetry { } } - retrievedFollowups(agentId: string, command: string | undefined, numFollowups: number): void { + retrievedFollowups(sessionResource: URI, agentId: string, command: string | undefined, numFollowups: number): void { this.telemetryService.publicLog2('chatFollowupsRetrieved', { + isAgentHostSession: getIsAgentHostSessionForTelemetry(sessionResource), agentId, command, numFollowups, @@ -325,6 +373,7 @@ export class ChatRequestTelemetry { chatMode: this.opts.options?.modeInfo?.telemetryModeName ?? this.opts.options?.modeInfo?.telemetryModeId, sessionType: getChatSessionTypeForTelemetry(this.opts.sessionResource), harness: getHarnessForTelemetry(this.opts.sessionResource), + isAgentHostSession: getIsAgentHostSessionForTelemetry(this.opts.sessionResource), }); } @@ -378,6 +427,10 @@ function getChatSessionTypeForTelemetry(sessionResource: URI): string { return isRemoteAgentHostSessionType(sessionType) ? 'remote-agent-host' : sessionType; } +function getIsAgentHostSessionForTelemetry(sessionResource: URI): boolean { + return isAgentHostSessionResource(sessionResource); +} + /** * For remote agent host sessions, the underlying harness/provider so remote * activity can be split by harness (the collapsed sessionType cannot). See diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 2a00f4c78ff3a1..cd031faf26fef3 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -400,6 +400,10 @@ export function isAgentHostTarget(target: string): boolean { return isLocalAgentHostTarget(target) || isRemoteAgentHostTarget(target); } +export function isAgentHostSessionResource(resource: URI): boolean { + return isAgentHostTarget(resource.scheme); +} + /** * The session type used for local agent chat sessions. */ diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index fa9cb0decffdc1..27482ee542a754 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -28,7 +28,8 @@ import { MockContextKeyService } from '../../../../../../platform/keybinding/tes import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, WillSaveStateReason } from '../../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; -import { NullTelemetryService } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; +import { NullTelemetryService, NullTelemetryServiceShape } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; +import { ClassifiedEvent, IGDPRProperty, OmitMetadata, StrictPropertyCheck } from '../../../../../../platform/telemetry/common/gdprTypings.js'; import { IUserDataProfilesService, toUserDataProfile } from '../../../../../../platform/userDataProfile/common/userDataProfile.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IWorkbenchAssignmentService } from '../../../../../services/assignment/common/assignmentService.js'; @@ -45,8 +46,9 @@ import { IChatRequestVariableEntry } from '../../../common/attachments/chatVaria import { IChatVariablesService } from '../../../common/attachments/chatVariables.js'; import { IChatDebugService } from '../../../common/chatDebugService.js'; import { ChatDebugServiceImpl } from '../../../common/chatDebugServiceImpl.js'; -import { ChatRequestQueueKind, ChatSendResult, IChatFollowup, IChatModelReference, IChatProgress, IChatService, ResponseModelState } from '../../../common/chatService/chatService.js'; +import { ChatRequestQueueKind, ChatSendResult, IChatFollowup, IChatModelReference, IChatProgress, IChatService, IChatUserActionEvent, ResponseModelState } from '../../../common/chatService/chatService.js'; import { backfillTransferredModel, backfillRestoredPickerState, ChatService } from '../../../common/chatService/chatServiceImpl.js'; +import { ChatServiceTelemetry } from '../../../common/chatService/chatServiceTelemetry.js'; import { ChatRequestOriginKind } from '../../../common/chatRequestOrigin.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { ChatEditingSessionState, IChatEditingService, IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; @@ -62,7 +64,7 @@ import { MockChatVariablesService } from '../mockChatVariables.js'; import { MockPromptsService } from '../promptSyntax/service/mockPromptsService.js'; import { MockLanguageModelToolsService } from '../tools/mockLanguageModelToolsService.js'; import { MockChatService } from './mockChatService.js'; -import { ChatSessionOptionsMap, IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionServerRequest, IChatSessionsService } from '../../../common/chatSessionsService.js'; +import { ChatSessionOptionsMap, IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionServerRequest, IChatSessionsService, SessionType } from '../../../common/chatSessionsService.js'; import { MockChatSessionsService } from '../mockChatSessionsService.js'; import { AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING, COPILOT_SKILL_URI_SCHEME, TROUBLESHOOT_SKILL_PATH } from '../../../common/promptSyntax/promptTypes.js'; import { ChatRequestSlashPromptPart } from '../../../common/requestParser/chatParserTypes.js'; @@ -2098,8 +2100,61 @@ suite('ChatService', () => { assert.deepStrictEqual(providerInvokedEvents.map(event => ({ sessionType: event.sessionType, + isAgentHostSession: event.isAgentHostSession, hasRequestId: typeof event.requestId === 'string', - })), [{ sessionType: 'remote-agent-host', hasRequestId: true }]); + })), [{ sessionType: 'remote-agent-host', isAgentHostSession: true, hasRequestId: true }]); + }); + + test('user action telemetry distinguishes agent host sessions from local sessions', () => { + const telemetryEvents: { readonly name: string; readonly isAgentHostSession: boolean }[] = []; + class TestTelemetryService extends NullTelemetryServiceShape { + override publicLog2> = never, T extends IGDPRProperty = never>(name?: string, data?: StrictPropertyCheck): void { + const isAgentHostSession = data && typeof data === 'object' ? Reflect.get(data, 'isAgentHostSession') : undefined; + if ((name === 'chatEditHunk' || name === 'chatEditSession') && typeof isAgentHostSession === 'boolean') { + telemetryEvents.push({ name, isAgentHostSession }); + } + } + } + const telemetry = new ChatServiceTelemetry(new TestTelemetryService()); + const sessionAction = { + action: { + kind: 'chatEditingSessionAction', + uri: URI.file('/test/file.ts'), + outcome: 'accepted', + hasRemainingEdits: false, + }, + agentId: 'agent', + command: undefined, + requestId: 'request', + result: undefined, + } satisfies Omit; + const action = { + action: { + kind: 'chatEditingHunkAction', + uri: URI.file('/test/file.ts'), + lineCount: 1, + linesAdded: 1, + linesRemoved: 0, + outcome: 'accepted', + hasRemainingEdits: false, + }, + agentId: 'agent', + command: undefined, + requestId: 'request', + result: undefined, + } satisfies Omit; + + telemetry.notifyUserAction({ ...sessionAction, sessionResource: URI.from({ scheme: SessionType.AgentHostCopilot, path: '/session' }) }); + telemetry.notifyUserAction({ ...sessionAction, sessionResource: URI.from({ scheme: SessionType.Local, path: '/session' }) }); + telemetry.notifyUserAction({ ...action, sessionResource: URI.from({ scheme: SessionType.AgentHostCopilot, path: '/session' }) }); + telemetry.notifyUserAction({ ...action, sessionResource: URI.from({ scheme: SessionType.Local, path: '/session' }) }); + + assert.deepStrictEqual(telemetryEvents, [ + { name: 'chatEditSession', isAgentHostSession: true }, + { name: 'chatEditSession', isAgentHostSession: false }, + { name: 'chatEditHunk', isAgentHostSession: true }, + { name: 'chatEditHunk', isAgentHostSession: false }, + ]); }); test('sendRequest with agentIdSilent passes agent host session capabilities to the request parser', async () => { diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.ts index b349871d0e8914..a6eac7374c8d72 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.ts @@ -65,6 +65,9 @@ export interface IEditTelemetryBaseData { /** Source controlled id. For agent edits (sideBarChat/highlightedEdit) this is the chat request id. */ sourceRequestId: string | undefined; + + /** Whether the edit was generated by an Agent Host-backed chat session. */ + isAgentHostSession?: boolean; } export interface IEditTelemetryCodeSuggestedData extends IEditTelemetryBaseData { diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryServiceImpl.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryServiceImpl.ts index cc3bbf0e3686ae..b557846e4da97a 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryServiceImpl.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryServiceImpl.ts @@ -45,6 +45,7 @@ export class AiEditTelemetryServiceImpl implements IAiEditTelemetryService { modelId: string | undefined; applyCodeBlockSuggestionId: string | undefined; sourceRequestId: string | undefined; + isAgentHostSession: boolean | undefined; }, { owner: 'hediet'; comment: 'Reports when code from AI is suggested to the user. @sentToGitHub'; @@ -69,6 +70,7 @@ export class AiEditTelemetryServiceImpl implements IAiEditTelemetryService { modelId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The AI model used to generate the suggestion.' }; applyCodeBlockSuggestionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'If this suggestion is for applying a suggested code block, this is the id of the suggested code block.' }; sourceRequestId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat request ID that produced the suggestion, for correlating suggestions with specific requests.' }; + isAgentHostSession: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the edit was generated by an Agent Host-backed chat session.' }; }>('editTelemetry.codeSuggested', { eventId: this._randomService.generatePrefixedUuid('evt'), suggestionId: suggestionId as unknown as string, @@ -89,6 +91,7 @@ export class AiEditTelemetryServiceImpl implements IAiEditTelemetryService { modelId: escapeModelIdForTelemetry(data.modelId), applyCodeBlockSuggestionId: data.applyCodeBlockSuggestionId as unknown as string, sourceRequestId: data.sourceRequestId, + isAgentHostSession: data.isAgentHostSession, ...forwardToChannelIf(isCopilotLikeExtension(data.source?.extensionId)), }); @@ -119,6 +122,7 @@ export class AiEditTelemetryServiceImpl implements IAiEditTelemetryService { modelId: string | undefined; applyCodeBlockSuggestionId: string | undefined; sourceRequestId: string | undefined; + isAgentHostSession: boolean | undefined; acceptanceMethod: | 'insertAtCursor' @@ -152,6 +156,7 @@ export class AiEditTelemetryServiceImpl implements IAiEditTelemetryService { applyCodeBlockSuggestionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'If this suggestion is for applying a suggested code block, this is the id of the suggested code block.' }; sourceRequestId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat request ID that produced the edit, for correlating accepts/rejects with specific requests.' }; + isAgentHostSession: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the edit was generated by an Agent Host-backed chat session.' }; acceptanceMethod: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How the user accepted the code suggestion. See #IEditTelemetryCodeAcceptedData.acceptanceMethod for possible values.' }; }>('editTelemetry.codeAccepted', { eventId: this._randomService.generatePrefixedUuid('evt'), @@ -173,6 +178,7 @@ export class AiEditTelemetryServiceImpl implements IAiEditTelemetryService { modelId: escapeModelIdForTelemetry(data.modelId), applyCodeBlockSuggestionId: data.applyCodeBlockSuggestionId as unknown as string, sourceRequestId: data.sourceRequestId, + isAgentHostSession: data.isAgentHostSession, acceptanceMethod: data.acceptanceMethod, ...forwardToChannelIf(isCopilotLikeExtension(data.source?.extensionId)), @@ -201,6 +207,7 @@ export class AiEditTelemetryServiceImpl implements IAiEditTelemetryService { modelId: string | undefined; applyCodeBlockSuggestionId: string | undefined; sourceRequestId: string | undefined; + isAgentHostSession: boolean | undefined; rejectionMethod: 'reject'; }, { @@ -228,6 +235,7 @@ export class AiEditTelemetryServiceImpl implements IAiEditTelemetryService { applyCodeBlockSuggestionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'If this suggestion is for applying a suggested code block, this is the id of the suggested code block.' }; sourceRequestId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat request ID that produced the edit, for correlating accepts/rejects with specific requests.' }; + isAgentHostSession: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the edit was generated by an Agent Host-backed chat session.' }; rejectionMethod: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How the user rejected the code suggestion. See #IEditTelemetryCodeRejectedData.rejectionMethod for possible values.' }; }>('editTelemetry.codeRejected', { eventId: this._randomService.generatePrefixedUuid('evt'), @@ -249,6 +257,7 @@ export class AiEditTelemetryServiceImpl implements IAiEditTelemetryService { modelId: escapeModelIdForTelemetry(data.modelId), applyCodeBlockSuggestionId: data.applyCodeBlockSuggestionId as unknown as string, sourceRequestId: data.sourceRequestId, + isAgentHostSession: data.isAgentHostSession, rejectionMethod: data.rejectionMethod, ...forwardToChannelIf(isCopilotLikeExtension(data.source?.extensionId)), diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/editTelemetry.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/editTelemetry.test.ts index 9020dd44fcf895..c87eba43df6f6e 100644 --- a/src/vs/workbench/contrib/editTelemetry/test/browser/editTelemetry.test.ts +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/editTelemetry.test.ts @@ -36,6 +36,43 @@ import { ITextFileService } from '../../../../services/textfile/common/textfiles suite('Edit Telemetry', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('reports Agent Host session mode for accepted and rejected edits', () => { + const instantiationService = new TestInstantiationService(); + const sentTelemetry: { readonly eventName: string; readonly data: Record | undefined }[] = []; + instantiationService.stub(ITelemetryService, { + publicLog2(eventName, data) { + sentTelemetry.push({ eventName, data }); + }, + }); + instantiationService.stub(IRandomService, new DeterministicRandomService()); + const aiEditTelemetryService = instantiationService.createInstance(AiEditTelemetryServiceImpl); + const baseData = { + suggestionId: undefined, + presentation: 'highlightedEdit' as const, + feature: 'inlineChat' as const, + source: undefined, + languageId: undefined, + editDeltaInfo: undefined, + modeId: undefined, + applyCodeBlockSuggestionId: undefined, + modelId: undefined, + sourceRequestId: undefined, + }; + + aiEditTelemetryService.createSuggestionId({ ...baseData, isAgentHostSession: true }); + aiEditTelemetryService.handleCodeAccepted({ ...baseData, acceptanceMethod: 'accept', isAgentHostSession: true }); + aiEditTelemetryService.handleCodeRejected({ ...baseData, rejectionMethod: 'reject', isAgentHostSession: false }); + + assert.deepStrictEqual(sentTelemetry.map(event => ({ + eventName: event.eventName, + isAgentHostSession: event.data?.isAgentHostSession, + })), [ + { eventName: 'editTelemetry.codeSuggested', isAgentHostSession: true }, + { eventName: 'editTelemetry.codeAccepted', isAgentHostSession: true }, + { eventName: 'editTelemetry.codeRejected', isAgentHostSession: false }, + ]); + }); + test('1', async () => runWithFakedTimers({}, async () => { const disposables = new DisposableStore(); const instantiationService = disposables.add(new TestInstantiationService(new ServiceCollection( From 9b7657cd980280eedad7688e9e19e9825ec0713c Mon Sep 17 00:00:00 2001 From: Anthony Kim <62267334+anthonykim1@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:11:35 -1000 Subject: [PATCH 06/21] Dispose AgentHostPty on terminal shutdown, exit, and detach (#331908) * Dispose AgentHostPty on terminal exit * Dispose AgentHostPty with its terminal instance * Align AgentHostPty disposal with terminal precedent * Let reconnect supersede a pending AgentHostPty startup --- .../contrib/terminal/browser/agentHostPty.ts | 255 ++++++-- .../browser/agentHostTerminalService.ts | 121 +++- .../test/browser/agentHostPty.test.ts | 570 ++++++++++++++++-- .../browser/agentHostTerminalService.test.ts | 266 ++++++++ 4 files changed, 1083 insertions(+), 129 deletions(-) create mode 100644 src/vs/workbench/contrib/terminal/test/browser/agentHostTerminalService.test.ts diff --git a/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts b/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts index 4549c4a0fd26a0..b20ffcf7a6ae29 100644 --- a/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts +++ b/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts @@ -3,11 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Barrier } from '../../../../base/common/async.js'; +import { Barrier, DeferredPromise, disposableTimeout, raceCancellation } from '../../../../base/common/async.js'; +import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { DisposableStore, IReference } from '../../../../base/common/lifecycle.js'; +import { DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; -import { IProcessPropertyMap, ITerminalChildProcess, ITerminalLaunchError, ITerminalLaunchResult, ProcessPropertyType } from '../../../../platform/terminal/common/terminal.js'; +import { IProcessPropertyMap, ITerminalChildProcess, ITerminalLaunchError, ITerminalLaunchResult, ITerminalLogService, ProcessPropertyType } from '../../../../platform/terminal/common/terminal.js'; import { IAgentConnection } from '../../../../platform/agentHost/common/agentService.js'; import { AGENT_HOST_SCHEME, fromAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js'; import { ActionType, ActionEnvelope } from '../../../../platform/agentHost/common/state/sessionActions.js'; @@ -94,9 +95,11 @@ function isCopilotSentinelCommand(commandLine: string): boolean { export class AgentHostPty extends BasePty implements ITerminalChildProcess { private readonly _startBarrier = new Barrier(); - private readonly _subscriptionDisposables = this._register(new DisposableStore()); - private _subscriptionRef: IReference> | undefined; + private readonly _lifetime = this._register(new CancellationTokenSource()); + private readonly _subscription = this._register(new MutableDisposable()); + private _terminalCreation: Promise | undefined; private _initialCwd = ''; + private _didSignalReady = false; private readonly _onCommandExecuted = this._register(new Emitter()); readonly onCommandExecuted: Event = this._onCommandExecuted.event; @@ -122,39 +125,59 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { id: number, private _connection: IAgentConnection, private readonly _terminalUri: URI, - private readonly _options?: IAgentHostPtyOptions, + private readonly _options: IAgentHostPtyOptions | undefined, + private readonly _logService: ITerminalLogService, ) { super(id, /* shouldPersist */ false); } async start(): Promise { + // Reconnect can race a pending startup: the pty is registered before + // start() completes, so a successful reconnect() may replace the + // connection while terminal creation is still in flight. Capture the + // creation's connection so a superseded startup neither re-subscribes + // nor reports a stale launch failure. + const connection = this._connection; try { + if (this._lifetime.token.isCancellationRequested) { + return undefined; + } + // 1. Create the terminal on the agent host (skip for attach-only mode // where the terminal already exists, e.g. created by a tool) if (!this._options?.attachOnly) { - await this._connection.createTerminal({ + const terminalCreation = connection.createTerminal({ channel: this._terminalUri.toString(), - claim: { kind: TerminalClaimKind.Client, clientId: this._connection.clientId }, + claim: { kind: TerminalClaimKind.Client, clientId: connection.clientId }, name: this._options?.name, cwd: this._resolveCwdForProtocol(this._options?.cwd), cols: this._lastDimensions.cols > 0 ? this._lastDimensions.cols : undefined, rows: this._lastDimensions.rows > 0 ? this._lastDimensions.rows : undefined, }); + this._terminalCreation = terminalCreation; + terminalCreation.then( + () => this._clearTerminalCreation(terminalCreation), + () => this._clearTerminalCreation(terminalCreation), + ); + const created = await raceCancellation(terminalCreation.then(() => true), this._lifetime.token, false); + if (!created) { + return undefined; + } + } + + if (this._lifetime.token.isCancellationRequested || this._connection !== connection) { + return undefined; } // 2. Get a subscription for the terminal URI (auto-subscribes) - this._subscriptionRef = this._connection.getSubscription(StateComponents.Terminal, this._terminalUri, 'AgentHostPty'); - const subscription = this._subscriptionRef.object; + const { subscription, store } = this._createSubscription(this._connection); // 3. Wait for hydration via onDidChange, then replay snapshot - if (subscription.value === undefined) { - await new Promise(resolve => { - const listener = subscription.onDidChange(() => { - listener.dispose(); - resolve(); - }); - this._subscriptionDisposables.add(listener); - }); + if (!await this._waitForHydration(subscription, store)) { + return undefined; + } + if (this._lifetime.token.isCancellationRequested || this._subscription.value !== store) { + return undefined; } const state = subscription.value as TerminalState; @@ -174,29 +197,50 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { this._properties.title = state.title; } - // 6. Wire up action listener for streaming updates via the subscription - this._subscriptionDisposables.add(subscription.onDidApplyAction(envelope => { + // 6. Signal that the process is ready + this._signalReady(); + + // 7. Wire up action listener for streaming updates via the subscription + store.add(subscription.onDidApplyAction(envelope => { this._handleAction(envelope); })); - // 7. Signal that the process is ready - this._startBarrier.open(); - this.handleReady({ pid: -1, cwd: this._initialCwd, windowsPty: undefined }); return undefined; } catch (err) { - this._startBarrier.open(); + if (this._lifetime.token.isCancellationRequested || this._connection !== connection) { + return undefined; + } return { message: err instanceof Error ? err.message : String(err) }; + } finally { + this._startBarrier.open(); } } + private _clearTerminalCreation(terminalCreation: Promise): void { + if (this._terminalCreation === terminalCreation) { + this._terminalCreation = undefined; + } + } + + private _signalReady(): void { + if (this._didSignalReady || this._lifetime.token.isCancellationRequested) { + return; + } + this._didSignalReady = true; + this.handleReady({ pid: -1, cwd: this._initialCwd, windowsPty: undefined }); + } + private _handleAction(envelope: ActionEnvelope): void { + if (this._lifetime.token.isCancellationRequested) { + return; + } const action = envelope.action; switch (action.type) { case ActionType.TerminalData: this.handleData(action.data); break; case ActionType.TerminalExited: - this.handleExit(action.exitCode); + this._exitAndDispose(action.exitCode); break; case ActionType.TerminalCwdChanged: this._properties.cwd = action.cwd.toString(); @@ -300,11 +344,43 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { return cwd.toString(); } + private _createSubscription(connection: IAgentConnection): { readonly subscription: IAgentSubscription; readonly store: DisposableStore } { + const store = new DisposableStore(); + try { + const subscription = store.add(connection.getSubscription(StateComponents.Terminal, this._terminalUri, 'AgentHostPty')).object; + this._subscription.value = store; + return { subscription, store }; + } catch (error) { + store.dispose(); + throw error; + } + } + + private async _waitForHydration(subscription: IAgentSubscription, store: DisposableStore, timeoutMs?: number): Promise { + if (subscription.value !== undefined) { + return true; + } + + const hydration = new DeferredPromise(); + const timeout = timeoutMs === undefined ? undefined : disposableTimeout(() => hydration.error(new Error('Reconnect hydration timed out')), timeoutMs, store); + store.add(toDisposable(() => hydration.complete(false))); + store.add(Event.once(subscription.onDidChange)(() => { + // Clear the timer explicitly — on successful hydration the store + // lives on as the active subscription generation. + timeout?.dispose(); + hydration.complete(true); + })); + return raceCancellation(hydration.p, this._lifetime.token, false); + } + input(data: string): void { - if (this._inReplay) { + if (this._inReplay || this._lifetime.token.isCancellationRequested) { return; } this._startBarrier.wait().then(() => { + if (this._lifetime.token.isCancellationRequested) { + return; + } this._connection.dispatch( this._terminalUri.toString(), { type: ActionType.TerminalInput, data }, @@ -313,12 +389,15 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { } resize(cols: number, rows: number): void { - if (this._inReplay || (this._lastDimensions.cols === cols && this._lastDimensions.rows === rows)) { + if (this._lifetime.token.isCancellationRequested || this._inReplay || (this._lastDimensions.cols === cols && this._lastDimensions.rows === rows)) { return; } this._lastDimensions.cols = cols; this._lastDimensions.rows = rows; this._startBarrier.wait().then(() => { + if (this._lifetime.token.isCancellationRequested) { + return; + } this._connection.dispatch( this._terminalUri.toString(), { type: ActionType.TerminalResized, cols, rows }, @@ -327,16 +406,58 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { } shutdown(_immediate: boolean): void { - this._startBarrier.wait().then(() => { - // In attach-only mode, don't dispose the server-side terminal — - // it's owned by the tool/session, not by this client. - if (!this._options?.attachOnly) { - this._connection.disposeTerminal(this._terminalUri); - } - this._subscriptionRef?.dispose(); - this._subscriptionRef = undefined; - this._subscriptionDisposables.clear(); - this.handleExit(undefined); + if (this._lifetime.token.isCancellationRequested) { + return; + } + this._requestHostTerminalDisposal(); + this._exitAndDispose(undefined); + } + + private _requestHostTerminalDisposal(): void { + // Attach-only terminals are owned by the tool/session that created them. + if (this._options?.attachOnly) { + return; + } + + // Request cleanup immediately for completed/lost-response creation, then + // retry after an in-flight request settles. Host disposal is idempotent. + this._disposeHostTerminal(); + if (this._terminalCreation) { + void this._terminalCreation.then( + () => this._disposeHostTerminal(), + () => this._disposeHostTerminal(), + ); + } + } + + private _disposeHostTerminal(): void { + try { + void this._connection.disposeTerminal(this._terminalUri).catch(err => this._logHostDisposalError(err)); + } catch (err) { + this._logHostDisposalError(err); + } + } + + private _logHostDisposalError(err: unknown): void { + this._logService.warn(`[AgentHostPty] Failed to dispose host terminal: ${err instanceof Error ? err.message : String(err)}`); + } + + private _exitAndDispose(exitCode: number | undefined): void { + if (this._lifetime.token.isCancellationRequested) { + return; + } + this._lifetime.cancel(); + this._subscription.clear(); + this._startBarrier.open(); + // Defer the exit event: TerminalProcessManager calls shutdown() while its + // process exit listener is still attached — in dispose() (which disposes + // _processListeners only after shutdown) and via + // SeamlessRelaunchDataFilter.newProcess() during relaunch (where a + // synchronous exit would clobber the freshly assigned process and dispose + // the instance). Other ptys always deliver exit asynchronously (over RPC). + queueMicrotask(() => { + this.handleExit(exitCode); + this.dispose(); }); } @@ -349,6 +470,9 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { } async clearBuffer(): Promise { + if (this._lifetime.token.isCancellationRequested) { + return; + } // Send a clear action to the agent host this._connection.dispatch( this._terminalUri.toString(), @@ -390,35 +514,29 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { * @returns `true` if reconnection succeeded, `false` otherwise. */ async reconnect(newConnection: IAgentConnection): Promise { - // Clean up old subscription - this._subscriptionDisposables.clear(); - this._subscriptionRef?.dispose(); - this._subscriptionRef = undefined; + if (this._lifetime.token.isCancellationRequested) { + return false; + } - // Swap connection + // Replace the old subscription generation and swap the connection. + this._subscription.clear(); this._connection = newConnection; + let subscriptionStore: DisposableStore | undefined; try { // Re-subscribe to the terminal state - this._subscriptionRef = this._connection.getSubscription(StateComponents.Terminal, this._terminalUri, 'AgentHostPty'); - const subscription = this._subscriptionRef.object; + const result = this._createSubscription(this._connection); + const subscription = result.subscription; + subscriptionStore = result.store; // Wait for hydration with a timeout — the terminal may no longer // exist on the server (e.g. agent process restarted). - if (subscription.value === undefined) { - const RECONNECT_HYDRATE_TIMEOUT_MS = 10_000; - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - listener.dispose(); - reject(new Error('Reconnect hydration timed out')); - }, RECONNECT_HYDRATE_TIMEOUT_MS); - const listener = subscription.onDidChange(() => { - clearTimeout(timer); - listener.dispose(); - resolve(); - }); - this._subscriptionDisposables.add(listener); - }); + const RECONNECT_HYDRATE_TIMEOUT_MS = 10_000; + if (!await this._waitForHydration(subscription, subscriptionStore, RECONNECT_HYDRATE_TIMEOUT_MS)) { + return false; + } + if (this._lifetime.token.isCancellationRequested || this._subscription.value !== subscriptionStore) { + return false; } const state = subscription.value as TerminalState; @@ -441,15 +559,28 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { if (state.title) { this._properties.title = state.title; } + this._signalReady(); // Wire up action listener for streaming updates - this._subscriptionDisposables.add(subscription.onDidApplyAction(envelope => { + subscriptionStore.add(subscription.onDidApplyAction(envelope => { this._handleAction(envelope); })); return true; } catch (err) { - console.warn('[AgentHostPty] Reconnection failed:', err instanceof Error ? err.message : String(err)); + if (!this._lifetime.token.isCancellationRequested) { + this._logService.warn(`[AgentHostPty] Reconnection failed: ${err instanceof Error ? err.message : String(err)}`); + } + if (subscriptionStore && this._subscription.value === subscriptionStore) { + this._subscription.clear(); + } + if (!this._didSignalReady && !this._lifetime.token.isCancellationRequested) { + // The terminal never became usable — release the host terminal + // like shutdown() would, then tear down locally. Attach-only + // terminals are left to their owning tool/session. + this._requestHostTerminalDisposal(); + this._exitAndDispose(undefined); + } return false; } } @@ -460,8 +591,8 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { } override dispose(): void { - this._subscriptionRef?.dispose(); - this._subscriptionRef = undefined; + this._lifetime.cancel(); + this._subscription.clear(); super.dispose(); } } diff --git a/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts b/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts index 0618a77365eca7..591c5f5717fd25 100644 --- a/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts @@ -11,6 +11,7 @@ import { localize } from '../../../../nls.js'; import { IAgentConnection } from '../../../../platform/agentHost/common/agentService.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; +import { ITerminalLogService } from '../../../../platform/terminal/common/terminal.js'; import { AgentHostPty } from './agentHostPty.js'; import { AgentHostOutputChannel } from './agentHostOutputChannel.js'; import { AhpTerminalCommandSource } from './ahpTerminalCommandSource.js'; @@ -42,6 +43,20 @@ export interface IAgentHostTerminalProfileInfo { readonly address: string; } +/** + * Tracks the {@link AgentHostPty} produced by one terminal creation's + * `customPtyImplementation` factory. The factory can legitimately run after + * the terminal instance was disposed (the process launch is driven + * asynchronously once the instance's xterm is ready), so — unlike + * `MutableDisposable`, which silently leaks a value set after disposal — + * setting a pty on a disposed registration disposes the pty locally without + * sending `disposeTerminal` to the host, and never touches the active pty map + * (a stale registration must not overwrite or delete a replacement's entry). + */ +interface IAgentHostPtyRegistration extends IDisposable { + setPty(pty: AgentHostPty): void; +} + const AGENT_HOST_PROFILE_EXT_ID = 'vscode.agent-host-terminal'; export const IAgentHostTerminalService = createDecorator('agentHostTerminalService'); @@ -126,6 +141,7 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe @ITerminalChatService private readonly _terminalChatService: ITerminalChatService, @ITerminalProfileService private readonly _terminalProfileService: ITerminalProfileService, @IQuickInputService private readonly _quickInputService: IQuickInputService, + @ITerminalLogService private readonly _logService: ITerminalLogService, ) { super(); } @@ -295,30 +311,35 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe const terminalUri = URI.from({ scheme: 'agenthost-terminal', path: `/${generateUuid()}` }); const name = options?.name ?? localize('agentHostTerminal.default', "Agent Host Terminal"); const key = terminalUri.toString(); + const ptyRegistration = this._createPtyRegistration(key, connection.clientId); - const instance = await this._terminalService.createTerminal({ - config: { - customPtyImplementation: (id, cols, rows) => { - const pty = new AgentHostPty(id, connection, terminalUri, { - name, - cwd: options?.cwd, - }); - if (cols > 0 && rows > 0) { - pty.resize(cols, rows); - } - this._activePtys.set(key, { pty, clientId: connection.clientId }); - return pty; + let instance: ITerminalInstance; + try { + instance = await this._terminalService.createTerminal({ + config: { + customPtyImplementation: (id, cols, rows) => { + const pty = new AgentHostPty(id, connection, terminalUri, { + name, + cwd: options?.cwd, + }, this._logService); + if (cols > 0 && rows > 0) { + pty.resize(cols, rows); + } + ptyRegistration.setPty(pty); + return pty; + }, + name, + icon: { id: 'remote' }, + isFeatureTerminal: false, }, - name, - icon: { id: 'remote' }, - isFeatureTerminal: false, - }, - location: options?.location, - }); + location: options?.location, + }); + } catch (error) { + ptyRegistration.dispose(); + throw error; + } - this._register(instance.onDisposed(() => { - this._activePtys.delete(key); - })); + this._registerInstancePtyCleanup(instance, key, ptyRegistration); return instance; } @@ -352,13 +373,14 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe } const store = new DisposableStore(); const commandSource = store.add(new AhpTerminalCommandSource()); + const ptyRegistration = this._createPtyRegistration(key, connection.clientId); const instancePromise = Promise.resolve().then(() => this._terminalService.createTerminal({ config: { customPtyImplementation: (id, cols, rows) => { const pty = new AgentHostPty(id, connection, terminalUri, { attachOnly: true, - }); + }, this._logService); if (cols > 0 && rows > 0) { pty.resize(cols, rows); } @@ -367,7 +389,7 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe commandSource.connect(instance, pty); } - this._activePtys.set(key, { pty, clientId: connection.clientId }); + ptyRegistration.setPty(pty); return pty; }, name: localize('agentHostTerminal.tool', "Agent Host Terminal"), @@ -381,20 +403,63 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe instance = await instancePromise; } catch (error) { store.dispose(); + ptyRegistration.dispose(); throw error; } this._terminalChatService.registerTerminalInstanceWithToolSession(terminalToolSessionId, instance); this._revivedInstances.set(key, instance); instance.store.add(store); - this._register(instance.onDisposed(() => { - this._revivedInstances.delete(key); - this._activePtys.delete(key); - })); + this._registerInstancePtyCleanup(instance, key, ptyRegistration); return instance; } + /** Creates the registration that owns the pty produced for {@link key}. */ + private _createPtyRegistration(key: string, clientId: string): IAgentHostPtyRegistration { + let pty: AgentHostPty | undefined; + let isDisposed = false; + return { + setPty: value => { + if (isDisposed) { + value.dispose(); + return; + } + pty = value; + this._activePtys.set(key, { pty, clientId }); + }, + dispose: () => { + if (isDisposed) { + return; + } + isDisposed = true; + if (this._activePtys.get(key)?.pty === pty) { + this._activePtys.delete(key); + } + pty?.dispose(); + }, + }; + } + + /** + * Ties the registration's lifetime to the terminal instance so the local + * pty is disposed even on paths that never call `shutdown()`. + */ + private _registerInstancePtyCleanup(instance: ITerminalInstance, key: string, ptyRegistration: IAgentHostPtyRegistration): void { + const cleanup = () => { + if (this._revivedInstances.get(key) === instance) { + this._revivedInstances.delete(key); + } + // Instance disposal can bypass PTY shutdown (for example Detach Session). + ptyRegistration.dispose(); + }; + if (instance.isDisposed) { + cleanup(); + } else { + instance.store.add(instance.onDisposed(cleanup)); + } + } + async reconnectTerminals(newConnection: IAgentConnection, oldClientId: string): Promise<{ recovered: number; total: number }> { // Only reconnect terminals that belonged to the old connection // identified by oldClientId. In multi-host setups, other hosts' @@ -413,7 +478,7 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe // Update the clientId to the new connection entry.clientId = newConnection.clientId; } else { - console.warn(`[AgentHostTerminalService] Failed to reconnect terminal: ${key}`); + this._logService.warn(`[AgentHostTerminalService] Failed to reconnect terminal: ${key}`); } }) ); diff --git a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts index f9b5f00b35c5b9..338b63b6a135df 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts @@ -4,10 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { DisposableStore, IReference } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; import { constObservable, IObservable } from '../../../../../base/common/observable.js'; import { AgentHostDebugLogsArtifactKind, IAgentConnection, IAgentCreateSessionConfig, IAgentHostDebugLogsArtifact, IAgentHostDebugLogsChunk, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../../../../../platform/agentHost/common/agentService.js'; import { ActionType, StateAction } from '../../../../../platform/agentHost/common/state/protocol/actions.js'; @@ -16,6 +18,8 @@ import type { CompletionsParams, CompletionsResult, CreateTerminalParams, Resolv import type { ActionEnvelope, IRootConfigChangedAction, SessionAction, TerminalAction, INotification, ClientAnnotationsAction } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWriteParams, ResourceWriteResult, CreateResourceWatchParams, CreateResourceWatchResult, ResourceMkdirParams, ResourceMkdirResult } from '../../../../../platform/agentHost/common/state/sessionProtocol.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import type { ITerminalLogService } from '../../../../../platform/terminal/common/terminal.js'; import { AgentHostPty } from '../../browser/agentHostPty.js'; import { AgentHostOutputChannel } from '../../browser/agentHostOutputChannel.js'; import { IActiveSubscriptionInfo, IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; @@ -40,6 +44,7 @@ class MockAgentConnection implements IAgentConnection { readonly createdTerminals: CreateTerminalParams[] = []; readonly disposedTerminals: URI[] = []; readonly subscribedResources: URI[] = []; + disposedSubscriptions = 0; private _terminalState: TerminalState = { title: 'Test Terminal', content: [], claim: { kind: TerminalClaimKind.Client, clientId: 'test-client' }, @@ -124,7 +129,13 @@ class MockAgentConnection implements IAgentConnection { } }); return { - object: sub as IAgentSubscription, dispose: () => { listener.dispose(); onDidChange.dispose(); onWillApplyAction.dispose(); onDidApplyAction.dispose(); }, + object: sub as IAgentSubscription, dispose: () => { + this.disposedSubscriptions++; + listener.dispose(); + onDidChange.dispose(); + onWillApplyAction.dispose(); + onDidApplyAction.dispose(); + }, }; } getSubscriptionUnmanaged(_kind: StateComponents, _resource: URI): IAgentSubscription | undefined { @@ -146,12 +157,26 @@ class MockAgentConnection implements IAgentConnection { } } +class TestAgentHostPty extends AgentHostPty { + disposeCount = 0; + + override dispose(): void { + this.disposeCount++; + super.dispose(); + } +} + +function createLogService(): ITerminalLogService { + return new class extends NullLogService { readonly _logBrand = undefined; }; +} + // ---- Tests ------------------------------------------------------------------ suite('AgentHostPty', () => { const disposables = new DisposableStore(); const terminalUri = URI.parse('agenthost-terminal:///test-term-1'); + const logService = createLogService(); setup(() => { disposables.clear(); @@ -166,7 +191,7 @@ suite('AgentHostPty', () => { test('start() creates terminal and subscribes', async () => { const conn = new MockAgentConnection(); disposables.add(conn); - const pty = disposables.add(new AgentHostPty(1, conn, terminalUri, { name: 'test' })); + const pty = disposables.add(new AgentHostPty(1, conn, terminalUri, { name: 'test' }, logService)); const result = await pty.start(); @@ -180,7 +205,7 @@ suite('AgentHostPty', () => { test('start() fires onProcessReady', async () => { const conn = new MockAgentConnection(); disposables.add(conn); - const pty = disposables.add(new AgentHostPty(1, conn, terminalUri)); + const pty = disposables.add(new AgentHostPty(1, conn, terminalUri, undefined, logService)); let ready = false; disposables.add(pty.onProcessReady!(() => { ready = true; })); @@ -192,7 +217,7 @@ suite('AgentHostPty', () => { test('replays existing content from snapshot', async () => { const conn = new MockAgentConnection({ content: [{ type: 'unclassified', value: 'existing output\n' }] }); disposables.add(conn); - const pty = disposables.add(new AgentHostPty(1, conn, terminalUri)); + const pty = disposables.add(new AgentHostPty(1, conn, terminalUri, undefined, logService)); const dataReceived: string[] = []; disposables.add(pty.onProcessData!(e => { @@ -221,7 +246,7 @@ suite('AgentHostPty', () => { test('input() dispatches terminal/input action', async () => { const conn = new MockAgentConnection(); disposables.add(conn); - const pty = disposables.add(new AgentHostPty(1, conn, terminalUri)); + const pty = disposables.add(new AgentHostPty(1, conn, terminalUri, undefined, logService)); await pty.start(); pty.input('hello'); @@ -237,7 +262,7 @@ suite('AgentHostPty', () => { test('resize() dispatches terminal/resized action', async () => { const conn = new MockAgentConnection(); disposables.add(conn); - const pty = disposables.add(new AgentHostPty(1, conn, terminalUri)); + const pty = disposables.add(new AgentHostPty(1, conn, terminalUri, undefined, logService)); await pty.start(); pty.resize(120, 40); @@ -253,7 +278,7 @@ suite('AgentHostPty', () => { test('resize() skips duplicate dimensions', async () => { const conn = new MockAgentConnection(); disposables.add(conn); - const pty = disposables.add(new AgentHostPty(1, conn, terminalUri)); + const pty = disposables.add(new AgentHostPty(1, conn, terminalUri, undefined, logService)); await pty.start(); pty.resize(80, 24); @@ -268,7 +293,7 @@ suite('AgentHostPty', () => { test('terminal/data action fires onProcessData', async () => { const conn = new MockAgentConnection(); disposables.add(conn); - const pty = disposables.add(new AgentHostPty(1, conn, terminalUri)); + const pty = disposables.add(new AgentHostPty(1, conn, terminalUri, undefined, logService)); const dataReceived: string[] = []; disposables.add(pty.onProcessData!(e => { @@ -283,24 +308,42 @@ suite('AgentHostPty', () => { assert.deepStrictEqual(dataReceived, ['hello world\r\n']); }); - test('terminal/exited action fires onProcessExit', async () => { + test('terminal/exited action finalizes the local PTY exactly once', async () => { const conn = new MockAgentConnection(); disposables.add(conn); - const pty = disposables.add(new AgentHostPty(1, conn, terminalUri)); + const pty = new TestAgentHostPty(1, conn, terminalUri, undefined, logService); - let exitCode: number | undefined; - disposables.add(pty.onProcessExit!(e => { exitCode = e; })); + const exitCodes: (number | undefined)[] = []; + disposables.add(pty.onProcessExit!(e => exitCodes.push(e))); await pty.start(); conn.fireAction(terminalUri, { type: ActionType.TerminalExited, exitCode: 42 }); - - assert.strictEqual(exitCode, 42); + conn.fireAction(terminalUri, { type: ActionType.TerminalExited, exitCode: 42 }); + pty.shutdown(false); + pty.input('ignored'); + pty.resize(120, 40); + await pty.clearBuffer(); + await Promise.resolve(); + + assert.deepStrictEqual({ + exitCodes, + disposeCount: pty.disposeCount, + disposedSubscriptions: conn.disposedSubscriptions, + disposedTerminals: conn.disposedTerminals, + dispatchedActions: conn.dispatchedActions, + }, { + exitCodes: [42], + disposeCount: 1, + disposedSubscriptions: 1, + disposedTerminals: [], + dispatchedActions: [], + }); }); test('terminal/cwdChanged updates cwd property', async () => { const conn = new MockAgentConnection(); disposables.add(conn); - const pty = disposables.add(new AgentHostPty(1, conn, terminalUri)); + const pty = disposables.add(new AgentHostPty(1, conn, terminalUri, undefined, logService)); await pty.start(); conn.fireAction(terminalUri, { type: ActionType.TerminalCwdChanged, cwd: '/home/user/project' }); @@ -312,7 +355,7 @@ suite('AgentHostPty', () => { test('terminal/titleChanged updates title property', async () => { const conn = new MockAgentConnection(); disposables.add(conn); - const pty = disposables.add(new AgentHostPty(1, conn, terminalUri)); + const pty = disposables.add(new AgentHostPty(1, conn, terminalUri, undefined, logService)); let changedTitle = ''; disposables.add(pty.onDidChangeProperty!(e => { @@ -330,7 +373,7 @@ suite('AgentHostPty', () => { test('ignores actions for other terminals', async () => { const conn = new MockAgentConnection(); disposables.add(conn); - const pty = disposables.add(new AgentHostPty(1, conn, terminalUri)); + const pty = disposables.add(new AgentHostPty(1, conn, terminalUri, undefined, logService)); const dataReceived: string[] = []; disposables.add(pty.onProcessData!(e => { @@ -346,32 +389,215 @@ suite('AgentHostPty', () => { test('shutdown() disposes terminal and unsubscribes', async () => { const conn = new MockAgentConnection(); disposables.add(conn); - const pty = disposables.add(new AgentHostPty(1, conn, terminalUri)); + const pty = new TestAgentHostPty(1, conn, terminalUri, undefined, logService); let exitFired = false; disposables.add(pty.onProcessExit!(() => { exitFired = true; })); await pty.start(); pty.shutdown(false); + assert.strictEqual(exitFired, false, 'shutdown should not emit exit synchronously'); + assert.deepStrictEqual(conn.disposedTerminals.map(uri => uri.toString()), [terminalUri.toString()], 'shutdown should dispose the host terminal synchronously'); + + await timeout(0); + + assert.deepStrictEqual({ + disposedTerminals: conn.disposedTerminals.map(uri => uri.toString()), + disposedSubscriptions: conn.disposedSubscriptions, + exitFired, + disposeCount: pty.disposeCount, + }, { + disposedTerminals: [terminalUri.toString()], + disposedSubscriptions: 1, + exitFired: true, + disposeCount: 1, + }); + }); - await new Promise(resolve => setTimeout(resolve, 10)); + test('shutdown() disposes while initial subscription hydration is pending', async () => { + const conn = new MockAgentConnection(); + disposables.add(conn); + conn.getSubscription = (_kind: StateComponents, _resource: URI): IReference> => { + const onDidChange = new Emitter(); + disposables.add(onDidChange); + return { + object: { + value: undefined, + verifiedValue: undefined, + onDidChange: onDidChange.event, + onWillApplyAction: Event.None, + onDidApplyAction: Event.None, + } as IAgentSubscription, + dispose: () => onDidChange.dispose(), + }; + }; + const pty = new TestAgentHostPty(1, conn, terminalUri, undefined, logService); + + const start = pty.start(); + await timeout(0); + pty.shutdown(false); + await start; + await timeout(0); + + assert.deepStrictEqual({ + disposedTerminals: conn.disposedTerminals.map(uri => uri.toString()), + disposeCount: pty.disposeCount, + }, { + disposedTerminals: [terminalUri.toString()], + disposeCount: 1, + }); + }); + + test('shutdown() retries host disposal after pending terminal creation settles', async () => { + const conn = new MockAgentConnection(); + disposables.add(conn); + const creationBarrier = new DeferredPromise(); + conn.createTerminal = async params => { + conn.createdTerminals.push(params); + await creationBarrier.p; + }; + const pty = new TestAgentHostPty(1, conn, terminalUri, undefined, logService); + + const start = pty.start(); + await timeout(0); + pty.shutdown(false); + await Promise.resolve(); + assert.deepStrictEqual({ + disposeCount: pty.disposeCount, + disposedTerminals: conn.disposedTerminals.map(uri => uri.toString()), + }, { + disposeCount: 1, + disposedTerminals: [terminalUri.toString()], + }); + + await creationBarrier.complete(); + await start; + await timeout(0); + + assert.deepStrictEqual(conn.disposedTerminals.map(uri => uri.toString()), [terminalUri.toString(), terminalUri.toString()]); + }); + + test('shutdown() attempts host disposal when terminal creation rejects', async () => { + const conn = new MockAgentConnection(); + disposables.add(conn); + const creationBarrier = new DeferredPromise(); + conn.createTerminal = async params => { + conn.createdTerminals.push(params); + await creationBarrier.p; + throw new Error('transport disconnected'); + }; + const pty = new AgentHostPty(1, conn, terminalUri, undefined, logService); + + const start = pty.start(); + await timeout(0); + pty.shutdown(false); + await creationBarrier.complete(); + const result = await start; + await timeout(0); + + assert.deepStrictEqual({ + error: result, + disposedTerminals: conn.disposedTerminals.map(uri => uri.toString()), + }, { + error: undefined, + disposedTerminals: [terminalUri.toString(), terminalUri.toString()], + }); + }); + + test('start() returns a launch error when terminal creation fails', async () => { + const conn = new MockAgentConnection(); + disposables.add(conn); + conn.createTerminal = async () => { throw new Error('transport disconnected'); }; + const pty = new TestAgentHostPty(1, conn, terminalUri, undefined, logService); + + const result = await pty.start(); + pty.shutdown(false); + await timeout(0); + + assert.deepStrictEqual({ + result, + disposeCount: pty.disposeCount, + disposedTerminals: conn.disposedTerminals.map(uri => uri.toString()), + }, { + result: { message: 'transport disconnected' }, + disposeCount: 1, + disposedTerminals: [terminalUri.toString()], + }); + }); + + test('shutdown() does not dispose an attach-only terminal on the host', async () => { + const conn = new MockAgentConnection(); + disposables.add(conn); + const pty = new TestAgentHostPty(1, conn, terminalUri, { attachOnly: true }, logService); + + await pty.start(); + pty.shutdown(false); + await timeout(0); + + assert.deepStrictEqual({ + disposeCount: pty.disposeCount, + disposedTerminals: conn.disposedTerminals, + }, { + disposeCount: 1, + disposedTerminals: [], + }); + }); + + test('shutdown() finalizes locally when host disposal throws synchronously', async () => { + const conn = new MockAgentConnection(); + disposables.add(conn); + conn.disposeTerminal = () => { throw new Error('client unavailable'); }; + const warnings: string[] = []; + const pty = new TestAgentHostPty(1, conn, terminalUri, undefined, new class extends NullLogService { + readonly _logBrand = undefined; + override warn(message: string): void { warnings.push(message); } + }); + await pty.start(); + pty.shutdown(false); + await Promise.resolve(); + + assert.deepStrictEqual({ + disposeCount: pty.disposeCount, + warnings, + }, { + disposeCount: 1, + warnings: ['[AgentHostPty] Failed to dispose host terminal: client unavailable'], + }); + }); + + test('natural exit finalizes an attach-only PTY without disposing the host terminal', async () => { + const conn = new MockAgentConnection(); + disposables.add(conn); + const pty = new TestAgentHostPty(1, conn, terminalUri, { attachOnly: true }, logService); + const exitCodes: (number | undefined)[] = []; + disposables.add(pty.onProcessExit!(exitCode => exitCodes.push(exitCode))); - assert.strictEqual(conn.disposedTerminals.length, 1); - assert.strictEqual(conn.disposedTerminals[0].toString(), terminalUri.toString()); - assert.ok(exitFired); + await pty.start(); + conn.fireAction(terminalUri, { type: ActionType.TerminalExited, exitCode: 0 }); + await Promise.resolve(); + + assert.deepStrictEqual({ + exitCodes, + disposeCount: pty.disposeCount, + disposedTerminals: conn.disposedTerminals, + }, { + exitCodes: [0], + disposeCount: 1, + disposedTerminals: [], + }); }); test('shouldPersist is false', () => { const conn = new MockAgentConnection(); disposables.add(conn); - const pty = disposables.add(new AgentHostPty(1, conn, terminalUri)); + const pty = disposables.add(new AgentHostPty(1, conn, terminalUri, undefined, logService)); assert.strictEqual(pty.shouldPersist, false); }); test('getInitialCwd returns cwd from snapshot', async () => { const conn = new MockAgentConnection({ cwd: '/home/user' }); disposables.add(conn); - const pty = disposables.add(new AgentHostPty(1, conn, terminalUri)); + const pty = disposables.add(new AgentHostPty(1, conn, terminalUri, undefined, logService)); await pty.start(); const cwd = await pty.getInitialCwd(); @@ -381,7 +607,7 @@ suite('AgentHostPty', () => { test('reconnect() re-subscribes with new connection and replays content', async () => { const conn1 = new MockAgentConnection({ content: [{ type: 'unclassified', value: 'old output\n' }] }); disposables.add(conn1); - const pty = disposables.add(new AgentHostPty(1, conn1, terminalUri)); + const pty = disposables.add(new AgentHostPty(1, conn1, terminalUri, undefined, logService)); await pty.start(); @@ -410,7 +636,7 @@ suite('AgentHostPty', () => { test('reconnect() streams new actions from new connection', async () => { const conn1 = new MockAgentConnection(); disposables.add(conn1); - const pty = disposables.add(new AgentHostPty(1, conn1, terminalUri)); + const pty = disposables.add(new AgentHostPty(1, conn1, terminalUri, undefined, logService)); await pty.start(); const conn2 = new MockAgentConnection(); @@ -434,10 +660,269 @@ suite('AgentHostPty', () => { assert.deepStrictEqual(dataReceived, ['post-reconnect data']); }); - test('reconnect() times out when subscription never hydrates', async () => { + test('reconnect() settles initial hydration from the replaced subscription generation', async () => { + const conn1 = new MockAgentConnection(); + disposables.add(conn1); + const initialOnDidChange = disposables.add(new Emitter()); + conn1.getSubscription = (_kind: StateComponents, _resource: URI): IReference> => ({ + object: { + value: undefined, + verifiedValue: undefined, + onDidChange: initialOnDidChange.event, + onWillApplyAction: Event.None, + onDidApplyAction: Event.None, + } as IAgentSubscription, + dispose: () => initialOnDidChange.dispose(), + }); + const pty = disposables.add(new AgentHostPty(1, conn1, terminalUri, undefined, logService)); + let readyCount = 0; + disposables.add(pty.onProcessReady!(() => readyCount++)); + const start = pty.start(); + await timeout(0); + + const conn2 = new MockAgentConnection({ title: 'Reconnected' }); + disposables.add(conn2); + const reconnect = pty.reconnect(conn2); + await Promise.all([start, reconnect]); + pty.input('after reconnect'); + await Promise.resolve(); + + assert.deepStrictEqual({ + readyCount, + dispatchedActions: conn2.dispatchedActions, + }, { + readyCount: 1, + dispatchedActions: [{ + channel: terminalUri.toString(), + action: { type: ActionType.TerminalInput, data: 'after reconnect' }, + }], + }); + }); + + test('reconnect() during pending terminal creation supersedes start()', async () => { const conn1 = new MockAgentConnection(); disposables.add(conn1); - const pty = disposables.add(new AgentHostPty(1, conn1, terminalUri)); + const creationBarrier = new DeferredPromise(); + conn1.createTerminal = async params => { + conn1.createdTerminals.push(params); + await creationBarrier.p; + }; + const pty = disposables.add(new TestAgentHostPty(1, conn1, terminalUri, undefined, logService)); + let readyCount = 0; + disposables.add(pty.onProcessReady!(() => readyCount++)); + const dataReceived: string[] = []; + disposables.add(pty.onProcessData!(e => dataReceived.push(typeof e === 'string' ? e : e.data))); + + const start = pty.start(); + await timeout(0); + + const conn2 = new MockAgentConnection({ content: [{ type: 'unclassified', value: 'recovered output\n' }] }); + disposables.add(conn2); + const reconnected = await pty.reconnect(conn2); + dataReceived.length = 0; // drop the reconnect replay + + await creationBarrier.complete(); + const startResult = await start; + conn2.fireAction(terminalUri, { type: ActionType.TerminalData, data: 'streamed' }); + + assert.deepStrictEqual({ + reconnected, + startResult, + readyCount, + dataReceived, + disposedSubscriptions: conn2.disposedSubscriptions, + }, { + reconnected: true, + startResult: undefined, + readyCount: 1, + dataReceived: ['streamed'], + disposedSubscriptions: 0, + }); + }); + + test('a stale creation failure does not tear down a reconnected PTY', async () => { + const conn1 = new MockAgentConnection(); + disposables.add(conn1); + const creationBarrier = new DeferredPromise(); + conn1.createTerminal = async params => { + conn1.createdTerminals.push(params); + await creationBarrier.p; + throw new Error('transport disconnected'); + }; + const pty = disposables.add(new TestAgentHostPty(1, conn1, terminalUri, undefined, logService)); + const exitCodes: (number | undefined)[] = []; + disposables.add(pty.onProcessExit!(exitCode => exitCodes.push(exitCode))); + + const start = pty.start(); + await timeout(0); + + const conn2 = new MockAgentConnection(); + disposables.add(conn2); + const reconnected = await pty.reconnect(conn2); + + await creationBarrier.complete(); + const startResult = await start; + await Promise.resolve(); + + assert.deepStrictEqual({ + reconnected, + startResult, + exitCodes, + disposeCount: pty.disposeCount, + }, { + reconnected: true, + startResult: undefined, + exitCodes: [], + disposeCount: 0, + }); + }); + + test('failed reconnect finalizes a PTY whose initial hydration was replaced', async () => { + const conn1 = new MockAgentConnection(); + disposables.add(conn1); + const initialOnDidChange = disposables.add(new Emitter()); + conn1.getSubscription = (_kind: StateComponents, _resource: URI): IReference> => ({ + object: { + value: undefined, + verifiedValue: undefined, + onDidChange: initialOnDidChange.event, + onWillApplyAction: Event.None, + onDidApplyAction: Event.None, + } as IAgentSubscription, + dispose: () => initialOnDidChange.dispose(), + }); + const pty = new TestAgentHostPty(1, conn1, terminalUri, undefined, logService); + const exitCodes: (number | undefined)[] = []; + disposables.add(pty.onProcessExit!(exitCode => exitCodes.push(exitCode))); + const start = pty.start(); + await timeout(0); + + const conn2 = new MockAgentConnection(); + disposables.add(conn2); + conn2.getSubscription = () => { throw new Error('reconnect failed'); }; + const reconnect = await pty.reconnect(conn2); + await start; + await Promise.resolve(); + + assert.deepStrictEqual({ + reconnect, + exitCodes, + disposeCount: pty.disposeCount, + disposedTerminals: conn2.disposedTerminals.map(uri => uri.toString()), + }, { + reconnect: false, + exitCodes: [undefined], + disposeCount: 1, + disposedTerminals: [terminalUri.toString()], + }); + }); + + test('a stale hydration timeout does not affect a successfully reconnected PTY', () => runWithFakedTimers({}, async () => { + const conn1 = new MockAgentConnection(); + disposables.add(conn1); + const warnings: string[] = []; + const pty = disposables.add(new TestAgentHostPty(1, conn1, terminalUri, undefined, new class extends NullLogService { + readonly _logBrand = undefined; + override warn(message: string): void { warnings.push(message); } + })); + const exitCodes: (number | undefined)[] = []; + disposables.add(pty.onProcessExit!(exitCode => exitCodes.push(exitCode))); + const dataReceived: string[] = []; + disposables.add(pty.onProcessData!(e => dataReceived.push(typeof e === 'string' ? e : e.data))); + await pty.start(); + + const conn2 = new MockAgentConnection(); + disposables.add(conn2); + const hydration: { state: TerminalState | undefined } = { state: undefined }; + const onDidChange = disposables.add(new Emitter()); + const onDidApplyAction = disposables.add(new Emitter()); + conn2.getSubscription = (_kind: StateComponents, _resource: URI): IReference> => ({ + object: { + get value() { return hydration.state; }, + get verifiedValue() { return hydration.state; }, + onDidChange: onDidChange.event, + onWillApplyAction: Event.None, + onDidApplyAction: onDidApplyAction.event, + } as IAgentSubscription, + dispose: () => { }, + }); + + const reconnect = pty.reconnect(conn2); + hydration.state = { title: 'Reconnected', content: [], claim: { kind: TerminalClaimKind.Client, clientId: 'test-client' } }; + onDidChange.fire(hydration.state); + assert.strictEqual(await reconnect, true); + dataReceived.length = 0; // drop the replayed clear sequence + + // Advance virtual time past the hydration deadline — the stale timeout + // must not finalize, warn on, or deafen the live PTY. + await timeout(11_000); + onDidApplyAction.fire({ channel: terminalUri.toString(), action: { type: ActionType.TerminalData, data: 'post-timeout data' }, serverSeq: 1, origin: undefined }); + + assert.deepStrictEqual({ + exitCodes, + warnings, + disposeCount: pty.disposeCount, + dataReceived, + }, { + exitCodes: [], + warnings: [], + disposeCount: 0, + dataReceived: ['post-timeout data'], + }); + })); + + test('shutdown() cancels pending reconnect hydration', async () => { + const conn1 = new MockAgentConnection(); + disposables.add(conn1); + const pty = new TestAgentHostPty(1, conn1, terminalUri, undefined, logService); + const dataReceived: string[] = []; + disposables.add(pty.onProcessData!(event => dataReceived.push(typeof event === 'string' ? event : event.data))); + await pty.start(); + + const conn2 = new MockAgentConnection(); + disposables.add(conn2); + const onDidChange = disposables.add(new Emitter()); + const onDidApplyAction = disposables.add(new Emitter()); + conn2.getSubscription = (_kind: StateComponents, _resource: URI): IReference> => ({ + object: { + value: undefined, + verifiedValue: undefined, + onDidChange: onDidChange.event, + onWillApplyAction: Event.None, + onDidApplyAction: onDidApplyAction.event, + } as IAgentSubscription, + dispose: () => { + onDidChange.dispose(); + onDidApplyAction.dispose(); + }, + }); + + const reconnect = pty.reconnect(conn2); + pty.shutdown(false); + const result = await reconnect; + onDidChange.fire({ title: 'Late', content: [{ type: 'unclassified', value: 'late data' }], claim: { kind: TerminalClaimKind.Client, clientId: 'test-client' } }); + onDidApplyAction.fire({ channel: terminalUri.toString(), action: { type: ActionType.TerminalData, data: 'late action' }, serverSeq: 1, origin: undefined }); + await Promise.resolve(); + + assert.deepStrictEqual({ + result, + disposeCount: pty.disposeCount, + dataReceived, + dispatchedActions: conn2.dispatchedActions, + }, { + result: false, + disposeCount: 1, + dataReceived: [], + dispatchedActions: [], + }); + }); + + test('reconnect() times out when subscription never hydrates', () => runWithFakedTimers({}, async () => { + const conn1 = new MockAgentConnection(); + disposables.add(conn1); + const pty = disposables.add(new TestAgentHostPty(1, conn1, terminalUri, undefined, logService)); + const exitCodes: (number | undefined)[] = []; + disposables.add(pty.onProcessExit!(exitCode => exitCodes.push(exitCode))); await pty.start(); // Create a connection whose subscription never fires onDidChange @@ -458,21 +943,28 @@ suite('AgentHostPty', () => { }; }; - // Suppress the expected console.warn from reconnect failure - const origWarn = console.warn; - console.warn = () => { }; - try { - const result = await pty.reconnect(conn2); - assert.strictEqual(result, false, 'reconnect() should fail on timeout'); - } finally { - console.warn = origWarn; - } - }).timeout(15000); // Allow for the 10s hydration timeout + const result = await pty.reconnect(conn2); + await Promise.resolve(); + + // The PTY was ready before the reconnect attempt — a failed reconnect + // must leave the live terminal and its host terminal untouched. + assert.deepStrictEqual({ + result, + exitCodes, + disposeCount: pty.disposeCount, + disposedTerminals: conn2.disposedTerminals, + }, { + result: false, + exitCodes: [], + disposeCount: 0, + disposedTerminals: [], + }); + })); test('reconnect() dispatches input to new connection', async () => { const conn1 = new MockAgentConnection(); disposables.add(conn1); - const pty = disposables.add(new AgentHostPty(1, conn1, terminalUri)); + const pty = disposables.add(new AgentHostPty(1, conn1, terminalUri, undefined, logService)); await pty.start(); const conn2 = new MockAgentConnection(); diff --git a/src/vs/workbench/contrib/terminal/test/browser/agentHostTerminalService.test.ts b/src/vs/workbench/contrib/terminal/test/browser/agentHostTerminalService.test.ts new file mode 100644 index 00000000000000..a5596fb79ccfb4 --- /dev/null +++ b/src/vs/workbench/contrib/terminal/test/browser/agentHostTerminalService.test.ts @@ -0,0 +1,266 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore, IReference } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; +import { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; +import { ActionType } from '../../../../../platform/agentHost/common/state/protocol/actions.js'; +import { TerminalClaimKind } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import type { ClientAnnotationsAction, IRootConfigChangedAction, SessionAction, TerminalAction } from '../../../../../platform/agentHost/common/state/sessionActions.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; +import { IShellLaunchConfig, ITerminalChildProcess } from '../../../../../platform/terminal/common/terminal.js'; +import { AgentHostPty } from '../../browser/agentHostPty.js'; +import { AgentHostTerminalService } from '../../browser/agentHostTerminalService.js'; +import { ICreateTerminalOptions, ITerminalChatService, ITerminalInstance, ITerminalService } from '../../browser/terminal.js'; +import { ITerminalProfileService } from '../../common/terminal.js'; + +class TestTerminalInstance extends mock() { + override readonly store = new DisposableStore(); + private readonly _onDisposed = this.store.add(new Emitter()); + override readonly onDisposed = this._onDisposed.event; + override readonly onWillData = Event.None; + private _isDisposed = false; + override get isDisposed(): boolean { return this._isDisposed; } + + override dispose(): void { + if (this._isDisposed) { + return; + } + this._isDisposed = true; + this._onDisposed.fire(this); + this.store.dispose(); + } +} + +class TestTerminalService extends mock() { + private readonly _ptyFactories: NonNullable[] = []; + failNextCreation = false; + disposeInstanceOnCreation = false; + + constructor(private readonly _store: Pick) { + super(); + } + + override async createTerminal(options?: ICreateTerminalOptions): Promise { + const config = options?.config; + assert.ok(config); + const factory = (config as IShellLaunchConfig).customPtyImplementation; + assert.ok(factory); + this._ptyFactories.push(factory); + if (this.failNextCreation) { + this.failNextCreation = false; + throw new Error('terminal creation failed'); + } + const instance = this._store.add(new TestTerminalInstance()); + if (this.disposeInstanceOnCreation) { + instance.dispose(); + } + return instance; + } + + createPty(index = this._ptyFactories.length - 1): AgentHostPty { + const pty: ITerminalChildProcess = this._ptyFactories[index](1, 80, 30); + assert.ok(pty instanceof AgentHostPty); + return pty; + } +} + +class TestAgentConnection extends mock() { + override readonly clientId = 'test-client'; + createTerminalCallCount = 0; + disposeTerminalCallCount = 0; + disposedSubscriptions = 0; + readonly dispatchedActions: (SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction)[] = []; + + override async createTerminal(): Promise { + this.createTerminalCallCount++; + } + + override async disposeTerminal(): Promise { + this.disposeTerminalCallCount++; + } + + override dispatch(_channel: string, action: SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction): void { + this.dispatchedActions.push(action); + } + + override getSubscription(): IReference> { + return { + object: { + value: { title: 'Test Terminal', content: [], claim: { kind: TerminalClaimKind.Client, clientId: this.clientId } }, + verifiedValue: undefined, + onDidChange: Event.None, + onWillApplyAction: Event.None, + onDidApplyAction: Event.None, + } as IAgentSubscription, + dispose: () => { this.disposedSubscriptions++; }, + }; + } +} + +suite('AgentHostTerminalService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + let terminalService: TestTerminalService; + let connection: TestAgentConnection; + let service: AgentHostTerminalService; + + setup(() => { + terminalService = new TestTerminalService(store); + connection = new TestAgentConnection(); + service = store.add(new AgentHostTerminalService( + terminalService, + new class extends mock() { + override registerAhpCommandSource() { return Disposable.None; } + override registerTerminalInstanceWithToolSession() { } + }, + new class extends mock() { }, + new class extends mock() { }, + new class extends NullLogService { readonly _logBrand = undefined; }, + )); + }); + + test('instance disposal locally disposes a created PTY without deleting the host terminal', async () => { + const instance = await service.createTerminal(connection); + const pty = terminalService.createPty(); + await pty.start(); + assert.strictEqual(connection.disposedSubscriptions, 0, 'the subscription should be live while the instance is live'); + + instance.dispose(); + instance.dispose(); + pty.input('ignored'); + await Promise.resolve(); + const reconnectResult = await service.reconnectTerminals(connection, connection.clientId); + + assert.deepStrictEqual({ + createTerminalCallCount: connection.createTerminalCallCount, + disposedSubscriptions: connection.disposedSubscriptions, + hostDisposeCallCount: connection.disposeTerminalCallCount, + dispatchedActions: connection.dispatchedActions, + reconnectResult, + }, { + createTerminalCallCount: 1, + disposedSubscriptions: 1, + hostDisposeCallCount: 0, + dispatchedActions: [{ type: ActionType.TerminalResized, cols: 80, rows: 30 }], + reconnectResult: { recovered: 0, total: 0 }, + }); + }); + + test('a PTY created after its terminal instance was disposed is immediately disposed', async () => { + const instance = await service.createTerminal(connection); + + instance.dispose(); + const pty = terminalService.createPty(); + const startResult = await pty.start(); + const reconnectResult = await service.reconnectTerminals(connection, connection.clientId); + + assert.deepStrictEqual({ + startResult, + createTerminalCallCount: connection.createTerminalCallCount, + hostDisposeCallCount: connection.disposeTerminalCallCount, + reconnectResult, + }, { + startResult: undefined, + createTerminalCallCount: 0, + hostDisposeCallCount: 0, + reconnectResult: { recovered: 0, total: 0 }, + }); + }); + + test('instance disposal locally disposes a revived attach-only PTY and allows revival again', async () => { + const terminalUri = URI.parse('agenthost-terminal:/tool-terminal'); + const instance = await service.reviveTerminal(connection, terminalUri, 'tool-session'); + const pty = terminalService.createPty(); + await pty.start(); + assert.strictEqual(connection.disposedSubscriptions, 0, 'the subscription should be live while the instance is live'); + + instance.dispose(); + instance.dispose(); + const replacement = await service.reviveTerminal(connection, terminalUri, 'tool-session'); + const reconnectResult = await service.reconnectTerminals(connection, connection.clientId); + + assert.deepStrictEqual({ + createTerminalCallCount: connection.createTerminalCallCount, + disposedSubscriptions: connection.disposedSubscriptions, + hostDisposeCallCount: connection.disposeTerminalCallCount, + createdReplacement: replacement !== instance, + reconnectResult, + }, { + createTerminalCallCount: 0, + disposedSubscriptions: 1, + hostDisposeCallCount: 0, + createdReplacement: true, + reconnectResult: { recovered: 0, total: 0 }, + }); + }); + + test('a late revived PTY cannot replace the current PTY registration', async () => { + const terminalUri = URI.parse('agenthost-terminal:/tool-terminal'); + const oldInstance = await service.reviveTerminal(connection, terminalUri, 'tool-session'); + oldInstance.dispose(); + const replacement = await service.reviveTerminal(connection, terminalUri, 'tool-session'); + terminalService.createPty(1); + terminalService.createPty(0); + + replacement.dispose(); + const reconnectResult = await service.reconnectTerminals(connection, connection.clientId); + + assert.deepStrictEqual({ + hostDisposeCallCount: connection.disposeTerminalCallCount, + reconnectResult, + }, { + hostDisposeCallCount: 0, + reconnectResult: { recovered: 0, total: 0 }, + }); + }); + + test('a failed terminal creation disposes the PTY registration', async () => { + terminalService.failNextCreation = true; + await assert.rejects(() => service.createTerminal(connection)); + + const pty = terminalService.createPty(); + const startResult = await pty.start(); + const reconnectResult = await service.reconnectTerminals(connection, connection.clientId); + + assert.deepStrictEqual({ + startResult, + createTerminalCallCount: connection.createTerminalCallCount, + hostDisposeCallCount: connection.disposeTerminalCallCount, + reconnectResult, + }, { + startResult: undefined, + createTerminalCallCount: 0, + hostDisposeCallCount: 0, + reconnectResult: { recovered: 0, total: 0 }, + }); + }); + + test('cleanup runs immediately for an instance already disposed when creation resolves', async () => { + terminalService.disposeInstanceOnCreation = true; + await service.createTerminal(connection); + + const pty = terminalService.createPty(); + const startResult = await pty.start(); + const reconnectResult = await service.reconnectTerminals(connection, connection.clientId); + + assert.deepStrictEqual({ + startResult, + createTerminalCallCount: connection.createTerminalCallCount, + hostDisposeCallCount: connection.disposeTerminalCallCount, + reconnectResult, + }, { + startResult: undefined, + createTerminalCallCount: 0, + hostDisposeCallCount: 0, + reconnectResult: { recovered: 0, total: 0 }, + }); + }); +}); From c1ca63f5f5503bfdccf053ccb9552c55fd213d47 Mon Sep 17 00:00:00 2001 From: Bryan Chen <41454397+bryanchen-d@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:13:57 -0700 Subject: [PATCH 07/21] Default to an installed build, and report missing video tooling (#331881) * Default to an installed build, and report missing video tooling Three problems surfaced running the skill from a fresh checkout. Nothing ran without a target. With no flags the runner used the build from the checkout, which only exists after compiling the product, so the documented starting point failed to launch. Reproducing a reported issue means running the shipped product anyway, so with no target flag it now finds an installed VS Code Insiders (falling back to Stable) and logs which one it chose. `--dev` selects the checkout build, and `--build` still pins an exact install. A missing ffmpeg was only discovered after the run, as a raw ENOENT, and it threw out of `runScenario` after the report had been written. The runner now checks for ffmpeg and ffprobe before launching anything and prints the install command for the platform, and caption rendering can no longer fail a run that has already produced its evidence. The window did not fill the recording. The canvas is 1920x1080 while VS Code sizes its own window (1440x900 with a workspace, 1200x800 empty), so the capture showed the window in the top-left corner surrounded by dead space. The window is now sized to the canvas once recording is on; a window larger than the display still renders at that size, so this holds on smaller screens. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: adb443eb-11e5-40a1-8608-7f593fa79485 * Find an installed ffmpeg, pace the steps, and classify blocked steps Follow-up to the same skill run. Captions were missing even though ffmpeg was installed. A PATH edit only reaches processes started afterwards, so an editor that was already running never sees it, and the runner concluded ffmpeg was absent. It now looks in the usual install locations as well as PATH, which is the difference between an annotated recording and a raw one on a machine that already has ffmpeg. Steps flowed past too quickly to read. A caption is only legible for as long as its step is on screen, and steps that assert rather than type can complete in a few hundred milliseconds. Each finished step is now held briefly, controlled by `stepPauseMs` and disabled with `0` for timing-sensitive scenarios. Steps that cannot be automated were indistinguishable from ones that were merely unavailable. `skip` now takes `needs: human` or `needs: infrastructure`: the first means a person has to check it, the second means the harness could do it but cannot yet, which is an enhancement request rather than a permanent limit. The distinction is recorded in the manifest, highlighted in the report, shown on the video caption, and printed at the end of the run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: adb443eb-11e5-40a1-8608-7f593fa79485 * Report the quality of the build that ran, and finish the target options Review follow-ups. The evidence labelled every installed run `Dev`. Quality was read from the environment, which only describes a build made from this checkout, so a run against installed Insiders was reported as Dev in both the manifest and the report - the evidence named the wrong product. An installed build stamps its own quality in `product.json`, so that is now the source when a build path is given. This also corrects `--build`, which had the same problem before this change. Linux missed Snap installs. Snap keeps the app under a read-only revision root, so a machine with VS Code installed only through Snap found nothing and fell back to the unbuilt checkout - the exact failure the new default exists to avoid. The web launcher recorded 1920x1080 while sizing the page to 1440x900, so the no-empty-margins claim did not hold there. It now matches the canvas while recording and keeps its established size otherwise, so smoke runs are unchanged. `--dev` was accepted but undocumented in the runner's own help, which now lists all three targets. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: adb443eb-11e5-40a1-8608-7f593fa79485 * Detect a staged update instead of timing out VS Code on Windows applies a downloaded update by swapping the executable during startup, so a launch attempt exits before showing a window. Playwright then waits the full launch timeout and reports that the process "likely crashed or hung", which sends the reader looking for crash dumps that do not exist. Insiders downloads an update most days, so anyone reproducing an issue will meet this. A `new_` beside the target is the marker, and checking for it turns a 60s misleading timeout into an immediate statement of the cause and the fix. Confirmed the launch failure is environmental rather than harness behaviour: a bare Playwright launch of the same installed build, with none of this code in the path, also never receives a window while the update is staged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: adb443eb-11e5-40a1-8608-7f593fa79485 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: adb443eb-11e5-40a1-8608-7f593fa79485 --- .github/skills/validate-ui-scenario/SKILL.md | 94 ++++++++++++---- test/automation/src/playwrightBrowser.ts | 5 +- test/automation/src/playwrightElectron.ts | 20 +++- test/scenario/src/application.ts | 112 ++++++++++++++++++- test/scenario/src/evidence.ts | 29 ++++- test/scenario/src/options.ts | 4 +- test/scenario/src/renderEvidenceChapters.ts | 85 ++++++++++++-- test/scenario/src/runScenario.ts | 108 +++++++++++++++--- 8 files changed, 399 insertions(+), 58 deletions(-) diff --git a/.github/skills/validate-ui-scenario/SKILL.md b/.github/skills/validate-ui-scenario/SKILL.md index 16b002502f7d3e..afc97d15746d51 100644 --- a/.github/skills/validate-ui-scenario/SKILL.md +++ b/.github/skills/validate-ui-scenario/SKILL.md @@ -18,21 +18,36 @@ step boundary, writes the report, and captions the recording with each step and ## Prepare ```bash -npm install # once +npm install # once npm --prefix test/scenario run compile # after any change under test/scenario ``` -Add `ffmpeg` and `ffprobe` to `PATH` to get the caption band on the video. Without them the run still -succeeds and the raw recording is kept. - -| Target | Extra flags | Also required | Use for | -|--------|-------------|---------------|---------| -| Installed Insiders | `--build ` | nothing | Reproducing a report against shipped behavior | -| Dev build from this checkout | *(none)* | `npm run electron`, `npm run transpile-client` | Verifying an unmerged change | +**Check `ffmpeg` and `ffprobe` are available before running.** The runner looks on `PATH` and in the +usual install locations, so an ffmpeg installed after the editor started is still found. Without them +the scenario still runs and keeps the raw recording, but the video is not captioned with step titles. +The runner warns at startup; if they are missing, tell the user how to install them rather than +silently returning an unannotated video: + +| Platform | Install | +|----------|---------| +| Windows | `winget install Gyan.FFmpeg` | +| macOS | `brew install ffmpeg` | +| Linux | `sudo apt install ffmpeg` | + +A new terminal may be needed for `PATH` to pick them up, or set `FFMPEG_PATH` and `FFPROBE_PATH`. An +existing run can be annotated afterwards with +`node test/scenario/out/renderEvidenceChapters.js `. + +| Target | Flags | Also required | Use for | +|--------|-------|---------------|---------| +| Installed Insiders, else Stable | *(none — the default)* | nothing | Reproducing a report against shipped behavior | +| Dev build from this checkout | `--dev` | `npm run electron`, `npm run transpile-client` | Verifying an unmerged change | +| A specific install | `--build ` | nothing | Pinning an exact build | | Web | `--web --headless` | `npm run transpile-client` | Browser-only behavior | -`--build` takes the application root — the install directory on Windows and Linux, or the `.app` -bundle on macOS: +With no target flag the runner finds an installed VS Code Insiders (falling back to Stable) and logs +which one it chose. `--build` takes the application root — the install directory on Windows and +Linux, or the `.app` bundle on macOS: ```bash # Windows @@ -41,9 +56,12 @@ bundle on macOS: --build "/Applications/Visual Studio Code - Insiders.app" ``` -An installed build runs with its own profile and extensions directory, so your extensions and -settings never leak into the recording. Insiders only reproduces **shipped** behavior — to validate -an unmerged change, run the dev build from a checkout that contains it. +Every target runs with its own profile and extensions directory, so your extensions and settings +never leak into the recording, and the window is sized to the recording canvas so the capture has no +empty margins. The evidence records the quality of the build that actually ran (`Insiders`, +`Stable`, `Dev`), so a report always names the product it validated. An installed build only +reproduces **shipped** behavior — to validate an unmerged change, use `--dev` in a checkout that +contains it. ## Write the scenario @@ -117,20 +135,41 @@ module.exports = { | `workspacePath` | Disposable folder to open | | `userSettings` | Settings seeded into the profile before launch | | `extraArgs` | Extra VS Code command-line arguments | +| `stepPauseMs` | How long to hold each finished step so its caption is readable. Defaults to `1000`; set `0` when the scenario is timing-sensitive | -Each step receives a `context` with `app`, `workbench`, `code`, `page`, and `skip(reason)`. +Each step receives a `context` with `app`, `workbench`, `code`, `page`, and `skip(reason, options)`. `workbench` exposes the feature helpers (`settingsEditor`, `quickaccess`, `editors`, `terminal`, `chat`, …); `page` is the Playwright page for anything they do not cover. - **Return a string** describing how the step was validated. It appears in the report. - **Throw** to fail the step. The message is recorded, and the run stops. -- **Call `skip(reason)`** when hardware, an account, or a service is unavailable. The run stops and - is reported as `aborted`, never as passed. +- **Call `skip(reason, { needs })`** when the step cannot be validated automatically. The run stops + and is reported as `aborted`, never as passed. + +## Steps that cannot be automated + +Decide this while planning, before writing the scenario, and classify each one — the two kinds have +different consequences: + +| `needs` | Meaning | What to do | +|---------|---------|------------| +| `human` | A person is required: physical hardware, a subjective judgement, a sign-in that cannot be scripted | Report the step so someone can check it by hand | +| `infrastructure` | Automatable in principle, but the harness cannot do it yet | Report it as an **enhancement to this skill**, naming the missing capability | + +```js +ctx.skip('Comparing physical print output requires a person with a printer.', { needs: 'human' }); +ctx.skip('The harness cannot drive native OS file dialogs.', { needs: 'infrastructure' }); +``` + +Blocked steps are recorded in `manifest.json`, highlighted in a **Needs attention** section of +`report.html`, marked on the video caption (`SKIPPED - NEEDS HUMAN`), and printed at the end of the +run. Surface them in your summary — never quietly drop a step you could not perform, and never +weaken an assertion so that it passes. ## Run it ```bash -node test/scenario/out/runScenario.js --build "" +node test/scenario/out/runScenario.js ``` Exit code `0` means every step passed, `1` means the run failed or was aborted, `2` a usage error. @@ -170,6 +209,12 @@ Summarize the outcome, list failed or skipped steps, link `report.html`, and sta VS Code version and quality (both are in `manifest.json`), and the source issue. Attach the video to the issue or pull request by dragging it into the comment box. +Always call out, separately from the pass/fail result: + +- **steps that need a person**, so someone knows what is still unverified; +- **steps blocked on a missing harness capability**, named as a concrete enhancement to this skill; +- **anything that degraded the evidence**, such as a missing ffmpeg leaving the video uncaptioned. + ## Related - **Interactive exploration.** `test/mcp` also serves these tools over MCP (`vscode_automation_*`), @@ -181,16 +226,17 @@ the issue or pull request by dragging it into the comment box. skill when a scenario is not yet covered there, or to iterate locally before proposing one. -User: "/validate-ui-scenario reproduce https://github.com/microsoft/vscode/issues/250159 against my -installed VS Code Insiders, and give me the report and the annotated video." +User: "/validate-ui-scenario reproduce https://github.com/microsoft/vscode/issues/250159" -1. Read the issue and identify the observable claim: searching `chat confirm` in the Settings editor +1. Confirm `ffmpeg`/`ffprobe` are available; if not, say so and give the install command before + running, so the user is not surprised by a video without step titles. +2. Read the issue and identify the observable claim: searching `chat confirm` in the Settings editor should match **Max Requests**, whose description mentions confirmation. -2. Add a baseline step (`max requests` finds the setting) so a failure cannot be explained by the +3. Add a baseline step (`max requests` finds the setting) so a failure cannot be explained by the setting being missing from the build. -3. Write `.build/vscode-playwright-mcp/issue-250159.cjs`, run it with `--build`, and read the - printed report path. -4. Report the outcome per step, link `report.html`, and attach `videos/annotated.mp4`. +4. Write `.build/vscode-playwright-mcp/issue-250159.cjs` and run it with no target flag, which uses + the installed Insiders; read the printed report path. +5. Report the outcome per step, link `report.html`, and attach `videos/annotated.mp4`. The run fails at the search step, and that is the answer: the issue reproduces. Report it as a successful reproduction, not as a broken scenario. diff --git a/test/automation/src/playwrightBrowser.ts b/test/automation/src/playwrightBrowser.ts index a0459eed009db3..deeb7b07d214bb 100644 --- a/test/automation/src/playwrightBrowser.ts +++ b/test/automation/src/playwrightBrowser.ts @@ -133,7 +133,10 @@ async function launchBrowser(options: LaunchOptions, endpoint: string) { // long enough to visibly skew offsets measured against it. const videoStartedAt = options.videosPath ? Date.now() : undefined; const page = await measureAndLog(() => context.newPage(), 'context.newPage()', logger); - await measureAndLog(() => page.setViewportSize({ width: 1440, height: 900 }), 'page.setViewportSize', logger); + // Match the recording canvas while recording, so the capture has no empty + // margins; keep the established size otherwise so smoke runs are unchanged. + const viewport = options.videosPath ? { width: 1920, height: 1080 } : { width: 1440, height: 900 }; + await measureAndLog(() => page.setViewportSize(viewport), 'page.setViewportSize', logger); // Always log failed requests and console errors/warnings (even without // `--verbose`) so that hard-to-reproduce startup stalls can be root caused diff --git a/test/automation/src/playwrightElectron.ts b/test/automation/src/playwrightElectron.ts index c161c5d29b93df..1e4dc298437003 100644 --- a/test/automation/src/playwrightElectron.ts +++ b/test/automation/src/playwrightElectron.ts @@ -37,6 +37,12 @@ export async function launch(options: LaunchOptions): Promise<{ electronProcess: async function launchElectron(configuration: IElectronConfiguration, options: LaunchOptions) { const { logger, tracing, snapshots } = options; + // The recording canvas is fixed, but VS Code sizes its own window (1440x900 + // with a workspace, 1200x800 empty), so the capture would otherwise show the + // window in the top-left corner of a larger frame. The window is resized to + // match below, which also renders reliably when it is larger than the screen. + const videoSize = { width: 1920, height: 1080 }; + const playwrightImpl = options.playwright ?? playwright; let electron; try { @@ -46,7 +52,7 @@ async function launchElectron(configuration: IElectronConfiguration, options: La recordVideo: options.videosPath ? { dir: options.videosPath, - size: { width: 1920, height: 1080 } + size: videoSize } : undefined, env: configuration.env as { [key: string]: string }, timeout: LAUNCH_TIMEOUT @@ -63,6 +69,18 @@ async function launchElectron(configuration: IElectronConfiguration, options: La throw enrichLaunchError(error, options); } } + if (options.videosPath) { + try { + await electron.evaluate(({ BrowserWindow }, size) => { + const target = BrowserWindow.getAllWindows()[0]; + target?.setBounds({ x: 0, y: 0, width: size.width, height: size.height }); + }, videoSize); + } catch (error) { + // A mismatched window only wastes pixels in the recording, so never fail + // a run because the window could not be resized. + logger.log(`Playwright (Electron): Failed to size the window to the recording (${error})`); + } + } // Recording is per page, so sample the origin once the first window exists // rather than when the application finished launching. const videoStartedAt = options.videosPath ? Date.now() : undefined; diff --git a/test/scenario/src/application.ts b/test/scenario/src/application.ts index dfbe0e00d72de5..193a3a308ac21c 100644 --- a/test/scenario/src/application.ts +++ b/test/scenario/src/application.ts @@ -71,12 +71,47 @@ function fail(errorMessage): void { let quality: Quality; let version: string | undefined; -function parseQuality(): Quality { - if (process.env.VSCODE_DEV === '1') { +/** + * Read the `quality` a build was stamped with. + * + * `parseQuality` reads the environment, which only describes a build made from + * this checkout. An installed build carries its own quality in `product.json`, + * and without it every installed run is labelled `Dev` in the evidence, which + * misreports which product was actually validated. + */ +function readBuildQuality(root: string): string | undefined { + // Windows installs nest the app under a commit-stamped directory, so the + // manifest is not always directly under the application root. + const candidates = [path.join(root, 'resources', 'app', 'product.json')]; + try { + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (entry.isDirectory()) { + candidates.push(path.join(root, entry.name, 'resources', 'app', 'product.json')); + } + } + } catch { + // an unreadable root is reported by the electron path check below + } + candidates.push(path.join(root, 'Contents', 'Resources', 'app', 'product.json')); // macOS bundle + for (const candidate of candidates) { + try { + const product = JSON.parse(fs.readFileSync(candidate, 'utf8')) as { quality?: string }; + if (product.quality) { + return product.quality; + } + } catch { + // try the next location + } + } + return undefined; +} + +function parseQuality(stamped?: string): Quality { + if (!stamped && process.env.VSCODE_DEV === '1') { return Quality.Dev; } - const quality = process.env.VSCODE_QUALITY ?? ''; + const quality = stamped ?? process.env.VSCODE_QUALITY ?? ''; switch (quality) { case 'stable': @@ -95,10 +130,69 @@ function parseQuality(): Quality { // // #### Electron #### // +/** + * Locate an installed VS Code Insiders, then Stable. + * + * Reproducing a reported issue is the common case, and that means running the + * shipped product rather than a build from this checkout, so an installed build + * is used when the caller did not choose a target. + */ +function findInstalledBuild(): string | undefined { + const candidates: string[] = []; + switch (process.platform) { + case 'win32': { + const roots = [process.env.LOCALAPPDATA, process.env.ProgramFiles, process.env['ProgramFiles(x86)']].filter((root): root is string => !!root); + for (const root of roots) { + candidates.push(path.join(root, 'Programs', 'Microsoft VS Code Insiders'), path.join(root, 'Microsoft VS Code Insiders')); + } + for (const root of roots) { + candidates.push(path.join(root, 'Programs', 'Microsoft VS Code'), path.join(root, 'Microsoft VS Code')); + } + break; + } + case 'darwin': + candidates.push( + '/Applications/Visual Studio Code - Insiders.app', + path.join(os.homedir(), 'Applications', 'Visual Studio Code - Insiders.app'), + '/Applications/Visual Studio Code.app', + path.join(os.homedir(), 'Applications', 'Visual Studio Code.app') + ); + break; + default: + candidates.push( + '/usr/share/code-insiders', + '/opt/visual-studio-code-insiders', + // Snap keeps the app under a read-only revision root. + '/snap/code-insiders/current/usr/share/code-insiders', + '/usr/share/code', + '/opt/visual-studio-code', + '/snap/code/current/usr/share/code' + ); + break; + } + return candidates.find(candidate => { + try { + return fs.existsSync(candidate) && fs.existsSync(getBuildElectronPath(candidate)); + } catch { + return false; // an incomplete install is not a usable target + } + }); +} + if (!opts.web) { let testCodePath = opts.build; let electronPath: string | undefined; + if (!testCodePath && !opts.dev) { + testCodePath = findInstalledBuild(); + if (testCodePath) { + // `getApplication` launches whatever `opts.build` names, so record the + // choice there rather than only in this block. + opts.build = testCodePath; + logger.log(`No target given, using the installed build at ${testCodePath}. Pass --dev to run this checkout instead.`); + } + } + if (testCodePath) { electronPath = getBuildElectronPath(testCodePath); version = getBuildVersion(testCodePath); @@ -111,10 +205,18 @@ if (!opts.web) { } if (!fs.existsSync(electronPath || '')) { - fail(`Cannot find VSCode at ${electronPath}. Please run VSCode once first (scripts/code.sh, scripts\\code.bat) and try again.`); + fail(`Cannot find VS Code at ${electronPath}. Install VS Code Insiders, pass --build , or build this checkout and pass --dev.`); } - quality = parseQuality(); + // Windows applies a downloaded update by swapping the executable during + // startup, so the launched process exits before it ever shows a window and + // the failure reads as a crash. Insiders updates daily, so say what is + // actually wrong instead of leaving a 60s timeout to be misread. + if (electronPath && fs.existsSync(path.join(path.dirname(electronPath), `new_${path.basename(electronPath)}`))) { + fail(`${electronPath} has a downloaded update waiting to be applied, and it exits during startup to install it instead of opening a window. Start and quit VS Code once to apply the update, then run this again.`); + } + + quality = parseQuality(testCodePath ? readBuildQuality(testCodePath) : undefined); if (opts.remote) { logger.log(`Running desktop remote smoke tests against ${electronPath}`); diff --git a/test/scenario/src/evidence.ts b/test/scenario/src/evidence.ts index d2a3f3c029d683..856cb23bd03f51 100644 --- a/test/scenario/src/evidence.ts +++ b/test/scenario/src/evidence.ts @@ -15,6 +15,15 @@ const logsRootPath = path.join(artifactRootPath, 'logs'); const qualityNames = ['Dev', 'Insiders', 'Stable', 'Exploration', 'OSS']; export type StepStatus = 'started' | 'passed' | 'failed' | 'skipped'; +/** + * Why a step could not be validated automatically. + * + * `human` means the step needs a person (hardware, a judgement call, a sign-in + * that cannot be scripted). `infrastructure` means it could be automated, but + * the harness is missing a capability — those are enhancement requests, not + * permanent limits, so they are reported separately. + */ +export type StepBlocker = 'human' | 'infrastructure'; export type RunOutcome = 'passed' | 'failed' | 'aborted'; interface EvidenceCapture { @@ -23,6 +32,7 @@ interface EvidenceCapture { screenshot: string; windowUrl: string; details?: string; + blockedOn?: StepBlocker; } interface EvidenceStep { @@ -180,7 +190,7 @@ export class EvidenceService { } } - async step(id: string, title: string, status: StepStatus, details?: string): Promise<{ screenshot: Buffer; screenshotPath: string }> { + async step(id: string, title: string, status: StepStatus, details?: string, blockedOn?: StepBlocker): Promise<{ screenshot: Buffer; screenshotPath: string }> { const run = this.requireRun(); if (run.state !== 'active') { throw new Error(`Evidence run '${run.id}' is busy (${run.state}).`); @@ -243,7 +253,8 @@ export class EvidenceService { timestamp: new Date().toISOString(), screenshot: screenshotName, windowUrl: app.code.driver.currentPage.url(), - details + details, + blockedOn }); this.writeManifest(); @@ -453,19 +464,27 @@ export class EvidenceService { const rows = run.steps.map(step => { const result = step.captures.at(-1); const screenshots = step.captures.map(capture => `${escapeHtml(capture.status)}`).join(', '); - return `${escapeHtml(step.id)}${escapeHtml(step.title)}${escapeHtml(result?.status ?? 'unknown')}${screenshots}${escapeHtml(result?.details ?? '')}`; + const blocker = result?.blockedOn ? ` (needs ${escapeHtml(result.blockedOn)})` : ''; + return `${escapeHtml(step.id)}${escapeHtml(step.title)}${escapeHtml(result?.status ?? 'unknown')}${blocker}${screenshots}${escapeHtml(result?.details ?? '')}`; }).join(''); + const blocked = run.steps + .map(step => ({ step, capture: step.captures.at(-1) })) + .filter((entry): entry is { step: EvidenceStep; capture: EvidenceCapture } => !!entry.capture?.blockedOn); + const blockedSection = blocked.length + ? `

Needs attention

    ${blocked.map(({ step, capture }) => + `
  • ${escapeHtml(step.id)} needs ${escapeHtml(capture.blockedOn ?? '')}: ${escapeHtml(capture.details ?? '')}
  • `).join('')}
` + : ''; const videoElements = run.artifacts.videos.length ? run.artifacts.videos.map(video => ``).join('') : '

No video file was produced.

'; const logs = run.artifacts.logs.map(log => `
  • ${escapeHtml(log)}
  • `).join(''); const html = ` ${escapeHtml(run.title)} - +

    ${escapeHtml(run.title)}

    Scenario: ${escapeHtml(run.scenarioId)}
    Outcome: ${escapeHtml(run.outcome ?? 'unknown')}
    Started: ${escapeHtml(run.startedAt)}
    Completed: ${escapeHtml(run.completedAt ?? '')}

    Source: ${run.source ? `${escapeHtml(run.source)}` : 'Not recorded'}
    Workspace: ${escapeHtml(run.workspacePath ?? 'Not specified')}
    Environment: ${escapeHtml(`${run.environment.platform} ${run.environment.architecture}; VS Code ${run.environment.vscodeVersion} (${run.environment.quality}); Node ${run.environment.nodeVersion}; commit ${run.environment.commit ?? 'unknown'}`)}

    ${escapeHtml(run.notes ?? '')}

    Steps

    ${rows}
    IDTitleResultScreenshotsDetails
    -

    Video

    ${videoElements}

    Trace and logs

    ${logs ? `
      ${logs}
    ` : '

    No new trace or log content was produced.

    '}`; +${blockedSection}

    Video

    ${videoElements}

    Trace and logs

    ${logs ? `
      ${logs}
    ` : '

    No new trace or log content was produced.

    '}`; const reportPath = path.join(run.runPath, 'report.html'); fs.writeFileSync(reportPath, html); return reportPath; diff --git a/test/scenario/src/options.ts b/test/scenario/src/options.ts index 424f6c271a858b..24ce8520eb3e90 100644 --- a/test/scenario/src/options.ts +++ b/test/scenario/src/options.ts @@ -20,7 +20,8 @@ export const opts = minimist(args, { 'web', 'headless', 'video', - 'autostart' + 'autostart', + 'dev' ], default: { verbose: false @@ -31,6 +32,7 @@ export const opts = minimist(args, { headless?: boolean; web?: boolean; build?: string; + dev?: boolean; browser?: 'chromium' | 'webkit' | 'firefox' | 'chromium-msedge' | 'chromium-chrome' | undefined; electronArgs?: string; video?: boolean; diff --git a/test/scenario/src/renderEvidenceChapters.ts b/test/scenario/src/renderEvidenceChapters.ts index 2e0d51e84ef630..3a0c528396315e 100644 --- a/test/scenario/src/renderEvidenceChapters.ts +++ b/test/scenario/src/renderEvidenceChapters.ts @@ -24,6 +24,7 @@ interface Capture { status?: string; timestamp?: string; details?: string; + blockedOn?: string; } interface Step { @@ -50,8 +51,62 @@ interface Caption { accent: string; } -const ffmpeg = process.env.FFMPEG_PATH ?? 'ffmpeg'; -const ffprobe = process.env.FFPROBE_PATH ?? 'ffprobe'; +/** + * Locate ffmpeg or ffprobe. + * + * A PATH edit only reaches processes started afterwards, so ffmpeg is commonly + * installed and still invisible to an editor that was already running. Well + * known install locations are probed before giving up, which is the difference + * between an annotated recording and a raw one. + */ +export function resolveVideoTool(tool: 'ffmpeg' | 'ffprobe'): string | undefined { + const override = process.env[`${tool.toUpperCase()}_PATH`]; + const executable = process.platform === 'win32' ? `${tool}.exe` : tool; + const candidates = override ? [override] : [tool, ...installedToolCandidates(executable)]; + for (const candidate of candidates) { + try { + execFileSync(candidate, ['-version'], { stdio: 'ignore' }); + return candidate; + } catch { + // try the next location + } + } + return undefined; +} + +function installedToolCandidates(executable: string): string[] { + const candidates: string[] = []; + if (process.platform === 'win32') { + const localAppData = process.env.LOCALAPPDATA; + if (localAppData) { + candidates.push(path.join(localAppData, 'Microsoft', 'WinGet', 'Links', executable)); + // winget unpacks into Packages///bin, so the build directory + // carries a version that cannot be hard-coded. + const packages = path.join(localAppData, 'Microsoft', 'WinGet', 'Packages'); + for (const pkg of readDirectories(packages).filter(name => /ffmpeg/iu.test(name))) { + for (const build of readDirectories(path.join(packages, pkg))) { + candidates.push(path.join(packages, pkg, build, 'bin', executable)); + } + } + } + candidates.push( + path.join(process.env.ProgramData ?? '', 'chocolatey', 'bin', executable), + path.join(process.env.ProgramFiles ?? '', 'ffmpeg', 'bin', executable) + ); + } else { + candidates.push(`/opt/homebrew/bin/${executable}`, `/usr/local/bin/${executable}`, `/usr/bin/${executable}`); + } + return candidates.filter(candidate => fs.existsSync(candidate)); +} + +function readDirectories(root: string): string[] { + try { + return fs.readdirSync(root, { withFileTypes: true }).filter(entry => entry.isDirectory()).map(entry => entry.name); + } catch { + return []; + } +} + const fontCandidates = process.env.CHAPTER_FONT ? [process.env.CHAPTER_FONT] : [ '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', @@ -99,6 +154,11 @@ export function renderChapters(runRoot: string): void { console.log('No usable font was found, so no captions were rendered.'); return; } + const ffmpeg = resolveVideoTool('ffmpeg'); + const ffprobe = resolveVideoTool('ffprobe'); + if (!ffmpeg || !ffprobe) { + throw new Error(`${[!ffmpeg && 'ffmpeg', !ffprobe && 'ffprobe'].filter(Boolean).join(' and ')} could not be found`); + } const videoPath = path.join(runRoot, relativeVideo); const outputRelative = 'videos/annotated.mp4'; @@ -172,7 +232,7 @@ export function renderChapters(runRoot: string): void { captions.push({ from: boundary.at, to: index + 1 < boundaries.length ? boundaries[index + 1].at : duration, - eyebrow: `STEP ${index + 1} OF ${boundaries.length} ${String(step.id ?? '').toUpperCase()} ${status.toUpperCase()}`, + eyebrow: `STEP ${index + 1} OF ${boundaries.length} ${String(step.id ?? '').toUpperCase()} ${status.toUpperCase()}${closing?.blockedOn ? ` - NEEDS ${closing.blockedOn.toUpperCase()}` : ''}`, title: wrap(step.title ?? '', columnsFor(titleSize), MAX_TITLE_LINES), details: wrap(closing?.details ?? '', columnsFor(detailSize), MAX_DETAIL_LINES), accent: accentFor(status) @@ -299,12 +359,21 @@ function wrap(value: string, limit: number, maxLines: number): string[] { return lines; } -if (require.main === module) { +/** + * Render captions without letting a presentation step fail a validation run. + * + * The raw recording is authoritative, so a missing or failing ffmpeg is reported + * and otherwise ignored. + */ +export function tryRenderChapters(runRoot: string): void { try { - renderChapters(path.resolve(process.argv[2] ?? process.env.RUN_ROOT ?? '.')); + renderChapters(runRoot); } catch (error) { - // Captions are a presentation aid, so never fail a validation run because - // the recording could not be annotated. The raw recording is authoritative. - console.warn(`Unable to render evidence captions: ${error instanceof Error ? error.message : error}`); + const message = error instanceof Error ? error.message : String(error); + console.warn(`Unable to render evidence captions: ${message}. The raw recording is unaffected.`); } } + +if (require.main === module) { + tryRenderChapters(path.resolve(process.argv[2] ?? process.env.RUN_ROOT ?? '.')); +} diff --git a/test/scenario/src/runScenario.ts b/test/scenario/src/runScenario.ts index aa45a1b72173bc..50970fcc9d8c05 100644 --- a/test/scenario/src/runScenario.ts +++ b/test/scenario/src/runScenario.ts @@ -7,14 +7,44 @@ import type { Page } from '@playwright/test'; import * as path from 'path'; import type { Application, Code, Workbench } from '../../automation'; import { ApplicationService, JSONValue } from './application'; -import { EvidenceService } from './evidence'; -import { renderChapters } from './renderEvidenceChapters'; +import { EvidenceService, StepBlocker } from './evidence'; +import { resolveVideoTool, tryRenderChapters } from './renderEvidenceChapters'; + +/** + * Report missing video tooling before anything is launched. + * + * Captions are rendered after the run, so a missing ffmpeg is only discovered + * once the scenario has already finished. Say so up front, with the command to + * fix it, rather than letting the run complete and produce no annotated video. + */ +function checkVideoTooling(): void { + const missing = (['ffmpeg', 'ffprobe'] as const).filter(tool => !resolveVideoTool(tool)); + if (!missing.length) { + return; + } + const install = process.platform === 'win32' + ? 'winget install Gyan.FFmpeg' + : process.platform === 'darwin' + ? 'brew install ffmpeg' + : 'sudo apt install ffmpeg'; + console.warn( + `Warning: ${missing.join(' and ')} could not be found, so the recording will not be captioned with step titles.\n` + + ` The run still produces the raw video, screenshots, trace and report.\n` + + ` Install ffmpeg (${install}) and re-run. If it is already installed, a PATH change does not\n` + + ` reach an editor that was already running, so restart it or set FFMPEG_PATH and FFPROBE_PATH,\n` + + ` then annotate the finished run with: node test/scenario/out/renderEvidenceChapters.js ` + ); +} + +function wait(milliseconds: number): Promise { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +} /** * Runs a UI validation scenario end to end and writes an evidence bundle. * * ``` - * node test/scenario/out/runScenario.js [--build ] + * node test/scenario/out/runScenario.js [--build | --dev] * ``` * * The scenario file is not part of this repository, so it can be written next to @@ -31,8 +61,15 @@ export interface ScenarioContext { readonly code: Code; /** The window the driver is currently attached to. */ readonly page: Page; - /** Marks the current step `skipped` and stops the run. */ - skip(reason: string): never; + /** + * Marks the current step `skipped` and stops the run. + * + * Say what is missing rather than what failed, and classify it: `human` when + * a person is required, `infrastructure` when the harness could do it but + * cannot yet. Both are reported prominently; the second is an enhancement + * request against this skill. + */ + skip(reason: string, options?: { needs?: StepBlocker }): never; } export interface ScenarioStep { @@ -57,10 +94,25 @@ export interface Scenario { readonly workspacePath?: string; readonly userSettings?: Record; readonly extraArgs?: string[]; + /** + * How long to hold on each completed step, in milliseconds. + * + * The recording is watched by a person, and a caption is only readable for as + * long as its step is on screen, so each step is held briefly once it + * finishes. Set `0` when the scenario depends on timing and must run at full + * speed. + */ + readonly stepPauseMs?: number; readonly steps: readonly ScenarioStep[]; } -class SkipStep extends Error { } +const DEFAULT_STEP_PAUSE_MS = 1000; + +class SkipStep extends Error { + constructor(reason: string, readonly needs?: StepBlocker) { + super(reason); + } +} function loadScenario(scenarioPath: string): Scenario { // A CommonJS scenario needs a `.cjs` extension because this package is an ES @@ -94,7 +146,17 @@ function loadScenario(scenarioPath: string): Scenario { return scenario; } -export async function runScenario(scenario: Scenario): Promise<{ runPath: string; outcome: 'passed' | 'failed' | 'aborted' }> { +export interface ScenarioBlocker { + readonly id: string; + readonly title: string; + readonly needs: StepBlocker; + readonly reason: string; +} + +export async function runScenario(scenario: Scenario): Promise<{ runPath: string; outcome: 'passed' | 'failed' | 'aborted'; blockers: ScenarioBlocker[] }> { + checkVideoTooling(); + const pauseMs = Math.max(0, scenario.stepPauseMs ?? DEFAULT_STEP_PAUSE_MS); + const blockers: ScenarioBlocker[] = []; const appService = new ApplicationService(); const evidence = new EvidenceService(appService); const runPath = await evidence.start( @@ -122,17 +184,23 @@ export async function runScenario(scenario: Scenario): Promise<{ runPath: string workbench: app.workbench, code: app.code, page: app.code.driver.currentPage, - skip: (reason: string) => { throw new SkipStep(reason); } + skip: (reason: string, options?: { needs?: StepBlocker }) => { throw new SkipStep(reason, options?.needs); } }; try { const details = await step.run(context); await evidence.step(step.id, step.title, 'passed', details || undefined); console.log(` PASS ${step.id} ${step.title}`); + // Hold the finished step so its caption is readable in the recording. + await wait(pauseMs); } catch (error) { const message = error instanceof Error ? error.message : String(error); const skipped = error instanceof SkipStep; - await evidence.step(step.id, step.title, skipped ? 'skipped' : 'failed', message); - console.log(` ${skipped ? 'SKIP' : 'FAIL'} ${step.id} ${step.title}: ${message}`); + const needs = error instanceof SkipStep ? error.needs : undefined; + await evidence.step(step.id, step.title, skipped ? 'skipped' : 'failed', message, needs); + console.log(` ${skipped ? 'SKIP' : 'FAIL'} ${step.id} ${step.title}${needs ? ` [needs ${needs}]` : ''}: ${message}`); + if (needs) { + blockers.push({ id: step.id, title: step.title, needs, reason: message }); + } // A later step cannot be trusted once the product is in an // unexpected state, and a skipped step means its precondition is // unavailable, so stop either way rather than reporting noise. @@ -140,6 +208,7 @@ export async function runScenario(scenario: Scenario): Promise<{ runPath: string // reported as aborted rather than passed. outcome = skipped ? 'aborted' : 'failed'; notes = `${skipped ? 'Skipped' : 'Failed'} at step '${step.id}': ${message}`; + await wait(pauseMs); break; } } @@ -149,16 +218,29 @@ export async function runScenario(scenario: Scenario): Promise<{ runPath: string console.error(`Scenario aborted: ${notes}`); } + if (blockers.length) { + notes = [notes, ...blockers.map(blocker => `Step '${blocker.id}' needs ${blocker.needs}: ${blocker.reason}`)].filter(Boolean).join('\n'); + } + const reportPath = await evidence.finish(outcome, notes); console.log(`Report: ${reportPath}`); - renderChapters(runPath); - return { runPath, outcome }; + tryRenderChapters(runPath); + for (const blocker of blockers) { + const reason = blocker.reason.replace(/\s*\.\s*$/u, ''); + console.log(blocker.needs === 'human' + ? `Needs a person: ${blocker.id} ${blocker.title} - ${reason}.` + : `Needs harness support: ${blocker.id} ${blocker.title} - ${reason}. This is automatable, so report it as an enhancement to the skill.`); + } + return { runPath, outcome, blockers }; } if (require.main === module) { const scenarioArgument = process.argv.slice(2).find(argument => !argument.startsWith('--')); if (!scenarioArgument) { - console.error('Usage: node test/scenario/out/runScenario.js [--build ]'); + console.error('Usage: node test/scenario/out/runScenario.js [--build | --dev]'); + console.error(' (no target) run the installed VS Code Insiders, else Stable'); + console.error(' --build run a specific installed build'); + console.error(' --dev run the build from this checkout'); process.exit(2); } const scenarioPath = path.resolve(scenarioArgument); From ec0c2dd6cd9b9354e54eaa79b902871abaeb3378 Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:26:00 -0700 Subject: [PATCH 08/21] Refactor agentic browser opening lifecycle (#331996) * Refactor agentic browser opening lifecycle * feedback --- .../browserView/common/browserView.ts | 67 ++++--- .../browserView/common/browserViewGroup.ts | 8 +- .../browserView/common/playwrightService.ts | 9 +- .../electron-main/browserSession.ts | 32 ++-- .../browserView/electron-main/browserView.ts | 9 +- .../electron-main/browserViewGroup.ts | 24 ++- .../browserViewGroupMainService.ts | 6 +- .../electron-main/browserViewMainService.ts | 178 ++++++++---------- .../node/browserViewGroupRemoteService.ts | 9 +- .../browserView/node/playwrightService.ts | 76 +++++--- .../browserView/browser/sessionBrowserView.ts | 4 +- .../chat/browser/sessionBrowsersControl.ts | 2 +- .../browser/sessionBrowsersControl.test.ts | 2 +- .../contrib/editor/browser/addTabActions.ts | 2 +- .../browser/browserView.contribution.ts | 11 +- .../browserView/common/browserEditorInput.ts | 42 ++--- .../contrib/browserView/common/browserView.ts | 33 ++-- .../browserView.contribution.ts | 11 +- .../electron-browser/browserViewCDPService.ts | 9 +- .../browserViewWorkbenchService.ts | 113 +++++++---- .../electron-browser/tools/openBrowserTool.ts | 11 +- .../browserEditorInput.test.ts | 80 +++++++- .../tools/openBrowserTool.test.ts | 60 +++++- .../sessionChatInputToolbar.fixture.ts | 2 +- 24 files changed, 507 insertions(+), 293 deletions(-) diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index 5c9bc769bca5e5..12dba9d4fb1d68 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -10,6 +10,7 @@ import { URI, UriComponents } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { ITunnelProxyInfo } from '../../tunnel/common/tunnelProxy.js'; import { IPermissionCategoryState, ISerializedBrowserPermissionsSnapshot, IBrowserDeviceCandidate, BrowserDeviceType, PermissionCategory } from './browserPermissions.js'; +import type { IntegratedBrowserOpenSource } from './browserViewTelemetry.js'; const commandPrefix = 'workbench.action.browser'; export enum BrowserViewCommandId { @@ -226,16 +227,10 @@ export interface IBrowserViewCaptureScreenshotOptions { awaitNextPaint?: boolean; } -/** - * Identifies who owns a browser view's lifecycle. - * The owner is set at creation time and never changes. - */ -export interface IBrowserViewOwner { - /** The main code window ID that owns this view's lifecycle. */ - readonly mainWindowId: number; - /** Optional session ID identifying the agent session that created this view. */ - readonly sessionId?: string; -} +/** Identifies who controls a browser view. */ +export type IBrowserViewOwner = + | { readonly type: 'user' } + | { readonly type: 'agent'; readonly sessionId: string }; /** * Grants matching agents access to a browser view. Omitted identifiers match all values. @@ -266,15 +261,14 @@ export function matchesBrowserViewAudience(candidate: IBrowserViewAudience, patt */ export interface IBrowserViewInfo { readonly id: string; + readonly hostWindowId: number; readonly owner: IBrowserViewOwner; readonly associatedResource?: UriComponents; readonly state: IBrowserViewState; } -/** - * Editor opening hints passed from the main process to the workbench. - */ -export interface IBrowserViewOpenOptions { +/** Controls how the workbench presents a newly created browser view as an editor. */ +export interface IBrowserViewEditorOpenOptions { readonly preserveFocus?: boolean; readonly background?: boolean; readonly pinned?: boolean; @@ -286,16 +280,25 @@ export interface IBrowserViewOpenOptions { export interface IBrowserViewCreatedEvent { readonly info: IBrowserViewInfo; - - // May be omitted to create the view without opening an editor. - readonly openOptions?: IBrowserViewOpenOptions; + readonly initialUrl?: string; + /** Omitted when the creator does not request an editor. */ + readonly editorOpenRequest?: IBrowserViewEditorOpenOptions; } -export interface IBrowserViewCreateOptions { +/** Host, ownership, storage, and initial access for a newly created browser view. */ +export interface IBrowserViewCreationContext { + readonly hostWindowId: number; readonly owner: IBrowserViewOwner; - readonly sessionOptions: IBrowserSessionOptions; + readonly session: BrowserViewSessionSelector; + /** Grants automation clients access before the view is announced to other processes. */ + readonly initialAudiences?: readonly IBrowserViewAudience[]; +} + +/** Complete main-process creation contract for a browser view. */ +export interface IBrowserViewCreateOptions extends IBrowserViewCreationContext { readonly associatedResource?: UriComponents; - readonly initialState?: Partial; + readonly initialUrl?: string; + readonly openSource?: IntegratedBrowserOpenSource; } export function isBrowserViewAssociatedResourceNavigation(associatedResource: URI, target: string): boolean { @@ -431,9 +434,27 @@ export enum BrowserViewStorageScope { Ephemeral = 'ephemeral' } -export interface IBrowserSessionOptions { - /** Storage / data-isolation scope for the session. */ - scope: BrowserViewStorageScope; +export type IBrowserViewSessionOptions = + | { readonly scope: BrowserViewStorageScope.Global } + | { readonly scope: BrowserViewStorageScope.Workspace } + | { + readonly scope: BrowserViewStorageScope.Ephemeral; + /** Views with the same affinity share one in-memory browser session. */ + readonly affinity?: string; + }; + +/** Selects an existing browser context by ID or resolves one from storage options. */ +export type BrowserViewSessionSelector = string | IBrowserViewSessionOptions; + +export function getAgentBrowserViewCreationDefaults(sessionId: string) { + return { + owner: { type: 'agent', sessionId } as const, + initialAudiences: [{ type: 'agent' }] as const, + session: { + scope: BrowserViewStorageScope.Ephemeral, + affinity: sessionId + } as const + }; } export const ipcBrowserViewChannelName = 'browserView'; diff --git a/src/vs/platform/browserView/common/browserViewGroup.ts b/src/vs/platform/browserView/common/browserViewGroup.ts index 99444f7a5f55e6..5bdfbc1537bbe1 100644 --- a/src/vs/platform/browserView/common/browserViewGroup.ts +++ b/src/vs/platform/browserView/common/browserViewGroup.ts @@ -5,7 +5,7 @@ import { Event } from '../../../base/common/event.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; -import { IBrowserViewAudience, IBrowserViewOwner, matchesBrowserViewAudience } from './browserView.js'; +import { IBrowserViewAudience, IBrowserViewCreationContext, matchesBrowserViewAudience } from './browserView.js'; import { CDPEvent, CDPRequest, CDPResponse } from './cdp/types.js'; export const ipcBrowserViewGroupChannelName = 'browserViewGroup'; @@ -25,7 +25,9 @@ export interface IBrowserViewGroup extends IDisposable { } export interface IBrowserViewGroupFilter { + /** Include views granted to this audience. */ readonly audience?: IBrowserViewAudience; + /** Include these views regardless of their audiences. */ readonly browserIds?: readonly string[]; } @@ -52,11 +54,11 @@ export interface IBrowserViewGroupService { /** * Create a new browser view group. - * @param owner The owner of the group's lifecycle. * @param filter The browser views to include in the group. + * @param targetContext Context inherited by targets created through the group's CDP endpoint. * @returns The id of the newly created group. */ - createGroup(owner: IBrowserViewOwner, filter?: IBrowserViewGroupFilter): Promise; + createGroup(filter: IBrowserViewGroupFilter, targetContext: IBrowserViewCreationContext): Promise; /** * Destroy a browser view group. diff --git a/src/vs/platform/browserView/common/playwrightService.ts b/src/vs/platform/browserView/common/playwrightService.ts index b804480d131f5f..565a496a8ec643 100644 --- a/src/vs/platform/browserView/common/playwrightService.ts +++ b/src/vs/platform/browserView/common/playwrightService.ts @@ -33,13 +33,8 @@ export interface IInvokeFunctionResult { export interface IPlaywrightService { readonly _serviceBrand: undefined; - /** - * Opens a new page in the browser and returns its associated view ID. - * @param sessionId Identifies the session making the request. - * @param url The URL to open in the new page. - * @returns An object containing the new page's view ID and a summary of its initial state. - */ - openPage(sessionId: string, url: string): Promise<{ pageId: string; summary: string }>; + /** Waits for a newly created browser view to become available and returns its initial summary. */ + waitForPageAndGetSummary(sessionId: string, pageId: string, expectedUrl: string, discoveryTimeoutMs: number): Promise; /** * Gets a summary of the page's current state, including its DOM and visual representation. diff --git a/src/vs/platform/browserView/electron-main/browserSession.ts b/src/vs/platform/browserView/electron-main/browserSession.ts index 7748280b41d32d..bf4eae2f6a4a9a 100644 --- a/src/vs/platform/browserView/electron-main/browserSession.ts +++ b/src/vs/platform/browserView/electron-main/browserSession.ts @@ -4,13 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { session } from 'electron'; +import { createHash } from 'crypto'; import { normalize } from '../../../base/common/path.js'; import { isLinux } from '../../../base/common/platform.js'; import { joinPath } from '../../../base/common/resources.js'; import { TernarySearchTree } from '../../../base/common/ternarySearchTree.js'; import { URI } from '../../../base/common/uri.js'; import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; -import { BrowserViewStorageScope, IBrowserSessionOptions } from '../common/browserView.js'; +import { BrowserViewStorageScope, IBrowserViewSessionOptions } from '../common/browserView.js'; import { BrowserSessionTrust, IBrowserSessionTrust } from './browserSessionTrust.js'; import { BrowserSessionHistory, IBrowserSessionHistory } from './browserSessionHistory.js'; import { BrowserSessionPermissions, IBrowserSessionPermissions } from './browserSessionPermissions.js'; @@ -55,7 +56,9 @@ export class BrowserSession { * ID derivation rules (one-to-one with Electron sessions): * - Global scope -> `"global"` * - Workspace scope -> `"workspace:${workspaceId}"` - * - Ephemeral scope -> `"ephemeral:${viewId}"` or `"${type}:${viewId}"` for custom types + * - Ephemeral per-view -> `"ephemeral:${viewId}"` + * - Ephemeral affinity -> `"ephemeral-affinity:${affinityHash}"` + * - Custom type -> `"${type}:${viewId}"` */ private static readonly _byId = new Map>(); @@ -132,7 +135,7 @@ export class BrowserSession { } /** - * Get or create an ephemeral session for the given view / target id. + * Get or create an ephemeral session for the given view or target ID. */ static getOrCreateEphemeral(instantiationService: IInstantiationService, viewId: string, type?: string): BrowserSession { if (type === 'workspace' || type === 'ephemeral') { @@ -145,6 +148,13 @@ export class BrowserSession { ?? instantiationService.createInstance(BrowserSession, sessionId, electronSession, BrowserViewStorageScope.Ephemeral); } + private static getOrCreateEphemeralForAffinity(instantiationService: IInstantiationService, affinity: string): BrowserSession { + const affinityHash = createHash('sha256').update(affinity).digest('hex'); + const electronSession = session.fromPartition(`vscode-browser-affinity-${affinityHash}`); + return BrowserSession._bySession.get(electronSession) + ?? instantiationService.createInstance(BrowserSession, `ephemeral-affinity:${affinityHash}`, electronSession, BrowserViewStorageScope.Ephemeral); + } + /** * Get or create a session for a workbench-originated browser view. * The session id is derived from the *scope* -- not the view id -- so @@ -154,9 +164,8 @@ export class BrowserSession { * @param instantiationService Used to construct the session and inject * its service dependencies (tunnel proxy, * log) when a new session is needed. - * @param viewId Used only for ephemeral sessions where every view - * needs its own Electron session. - * @param sessionOptions Determines the storage scope for the session. + * @param viewId Used for ephemeral sessions without an explicit affinity. + * @param options Determines the storage scope for the session. * @param workspaceStorageHome Root folder under which per-workspace * browser storage is created * (`IEnvironmentMainService.workspaceStorageHome`). @@ -165,21 +174,22 @@ export class BrowserSession { static getOrCreate( instantiationService: IInstantiationService, viewId: string, - sessionOptions: IBrowserSessionOptions, + options: IBrowserViewSessionOptions, workspaceStorageHome: URI, workspaceId?: string, ): BrowserSession { - switch (sessionOptions.scope) { + switch (options.scope) { case BrowserViewStorageScope.Global: return BrowserSession.getOrCreateGlobal(instantiationService); case BrowserViewStorageScope.Workspace: if (workspaceId) { return BrowserSession.getOrCreateWorkspace(instantiationService, workspaceId, workspaceStorageHome); } - // fallthrough -- no workspace context -> ephemeral - case BrowserViewStorageScope.Ephemeral: - default: return BrowserSession.getOrCreateEphemeral(instantiationService, viewId); + case BrowserViewStorageScope.Ephemeral: + return options.affinity !== undefined + ? BrowserSession.getOrCreateEphemeralForAffinity(instantiationService, options.affinity) + : BrowserSession.getOrCreateEphemeral(instantiationService, viewId); } } diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index c34331abeaec0a..e3653019c46651 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -7,7 +7,7 @@ import { screen, WebContentsView, webContents } from 'electron'; import { Disposable } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { IBrowserViewAudience, IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex, IBrowserViewOwner, IBrowserViewOpenOptions, IBrowserViewPermissionRequestEvent, equalsBrowserViewAudience, isBrowserViewAssociatedResourceNavigation, matchesBrowserViewAudience } from '../common/browserView.js'; +import { IBrowserViewAudience, IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex, IBrowserViewOwner, IBrowserViewEditorOpenOptions, IBrowserViewPermissionRequestEvent, equalsBrowserViewAudience, isBrowserViewAssociatedResourceNavigation, matchesBrowserViewAudience } from '../common/browserView.js'; import { BrowserViewEmulator } from './browserViewEmulator.js'; import { BrowserViewInspector } from './browserViewInspector.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; @@ -119,10 +119,11 @@ export class BrowserView extends Disposable { constructor( public readonly id: string, + public readonly hostWindowId: number, public readonly owner: IBrowserViewOwner, public readonly associatedResource: URI | undefined, public readonly session: BrowserSession, - private readonly _createChildView: (url: string, electronOptions: Electron.WebContentsViewConstructorOptions | undefined, openOptions: IBrowserViewOpenOptions) => BrowserView, + private readonly _createChildView: (url: string, electronOptions: Electron.WebContentsViewConstructorOptions | undefined, editorOptions: IBrowserViewEditorOpenOptions) => BrowserView, openContextMenu: (view: BrowserView, params: Electron.ContextMenuParams) => void, options: Electron.WebContentsViewConstructorOptions | undefined, @IWindowsMainService private readonly windowsMainService: IWindowsMainService, @@ -161,9 +162,9 @@ export class BrowserView extends Disposable { this._view.setBounds({ x: 0, y: 0, width: 1024, height: 768 }); this._view.setBackgroundColor('#FFFFFF'); - this._ownerWindow = this.windowsMainService.getWindowById(owner.mainWindowId)!; + this._ownerWindow = this.windowsMainService.getWindowById(hostWindowId)!; if (!this._ownerWindow) { - throw new Error(`Window with ID ${owner.mainWindowId} not found`); + throw new Error(`Window with ID ${hostWindowId} not found`); } this._register(this._ownerWindow.onDidClose(() => this.dispose())); this._register(this._ownerWindow.onWillLoad((e) => { diff --git a/src/vs/platform/browserView/electron-main/browserViewGroup.ts b/src/vs/platform/browserView/electron-main/browserViewGroup.ts index a1ec98bd7d2594..9808c0edaea109 100644 --- a/src/vs/platform/browserView/electron-main/browserViewGroup.ts +++ b/src/vs/platform/browserView/electron-main/browserViewGroup.ts @@ -9,7 +9,7 @@ import { BrowserView } from './browserView.js'; import { ICDPTarget, CDPBrowserVersion, CDPWindowBounds, CDPTargetInfo, ICDPConnection, ICDPBrowserTarget, CDPRequest, CDPResponse, CDPEvent } from '../common/cdp/types.js'; import { CDPBrowserProxy } from '../common/cdp/proxy.js'; import { IBrowserViewGroup, IBrowserViewGroupFilter, matchesBrowserViewGroupFilter } from '../common/browserViewGroup.js'; -import { IBrowserViewAudience, IBrowserViewOwner } from '../common/browserView.js'; +import { IBrowserViewCreationContext } from '../common/browserView.js'; import { IBrowserViewMainService } from './browserViewMainService.js'; import { IProductService } from '../../product/common/productService.js'; import { BrowserSession } from './browserSession.js'; @@ -48,8 +48,8 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I constructor( readonly id: string, - readonly owner: IBrowserViewOwner, - private readonly filter: IBrowserViewGroupFilter | undefined, + private readonly filter: IBrowserViewGroupFilter, + private readonly targetContext: IBrowserViewCreationContext, @IBrowserViewMainService private readonly browserViewMainService: IBrowserViewMainService, @IProductService private readonly productService: IProductService, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -58,7 +58,7 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I super(); this._register(this.browserViewMainService.onDidCreateBrowserView(({ info }) => { - if (!this.filter || info.owner.mainWindowId !== this.owner.mainWindowId) { + if (info.hostWindowId !== this.targetContext.hostWindowId) { return; } @@ -91,11 +91,7 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I } this._isActive = true; - if (!this.filter) { - return; - } - - const views = await this.browserViewMainService.getBrowserViews(this.owner.mainWindowId); + const views = await this.browserViewMainService.getBrowserViews(this.targetContext.hostWindowId); await Promise.all(views.map(async info => { const view = this.browserViewMainService.tryGetBrowserView(info.id); if (view) { @@ -123,7 +119,7 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I } private async _reconcileView(view: BrowserView): Promise { - const matches = this.filter !== undefined && matchesBrowserViewGroupFilter(view.id, view.audiences, this.filter); + const matches = matchesBrowserViewGroupFilter(view.id, view.audiences, this.filter); if (matches) { await this.addView(view.id); } else { @@ -245,7 +241,7 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I const view = target.view.getWebContentsView(); const viewBounds = view.getBounds(); return { - windowId: this.owner.mainWindowId, + windowId: this.targetContext.hostWindowId, bounds: { left: viewBounds.x, top: viewBounds.y, @@ -282,8 +278,10 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I throw new Error(`Unknown browser context ${browserContextId}`); } - const audience: IBrowserViewAudience | undefined = this.filter?.audience ? { type: this.filter.audience.type } : undefined; - const target = await this.browserViewMainService.createTarget(url, this.owner, browserContextId, audience); + const target = await this.browserViewMainService.createTarget(url, { + ...this.targetContext, + session: browserContextId ?? this.targetContext.session + }); if (target instanceof BrowserView) { await this.addView(target.id); return this.viewTargets.get(target.id)!; diff --git a/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts b/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts index d4647f3cc499f9..7f4b9ecbed0629 100644 --- a/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts @@ -8,7 +8,7 @@ import { Event } from '../../../base/common/event.js'; import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { IBrowserViewGroupFilter, IBrowserViewGroupService } from '../common/browserViewGroup.js'; -import { IBrowserViewOwner } from '../common/browserView.js'; +import { IBrowserViewCreationContext } from '../common/browserView.js'; import { BrowserViewGroup } from './browserViewGroup.js'; import { CDPEvent, CDPRequest, CDPResponse } from '../common/cdp/types.js'; @@ -35,9 +35,9 @@ export class BrowserViewGroupMainService extends Disposable implements IBrowserV super(); } - async createGroup(owner: IBrowserViewOwner, filter?: IBrowserViewGroupFilter): Promise { + async createGroup(filter: IBrowserViewGroupFilter, targetContext: IBrowserViewCreationContext): Promise { const id = generateUuid(); - const group = this.instantiationService.createInstance(BrowserViewGroup, id, owner, filter); + const group = this.instantiationService.createInstance(BrowserViewGroup, id, filter, targetContext); this.groups.set(id, group); Event.once(group.onDidDestroy)(() => { diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index 3d71e514787fd1..c6948d70c37132 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -6,7 +6,7 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { IBrowserElementCommentsUpdate, IBrowserElementSelectionOptions, IBrowserViewAudience, IBrowserViewBounds, IBrowserViewState, IBrowserViewService, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IBrowserViewOwner, IBrowserViewInfo, IBrowserViewCreatedEvent, IBrowserViewOpenOptions, IBrowserViewCreateOptions, IBrowserViewWindowConfiguration, IBrowserDeviceProfile } from '../common/browserView.js'; +import { BrowserViewSessionSelector, IBrowserElementCommentsUpdate, IBrowserElementSelectionOptions, IBrowserViewAudience, IBrowserViewBounds, IBrowserViewState, IBrowserViewService, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IBrowserViewOwner, IBrowserViewInfo, IBrowserViewCreatedEvent, IBrowserViewEditorOpenOptions, IBrowserViewCreateOptions, IBrowserViewCreationContext, IBrowserViewWindowConfiguration, IBrowserDeviceProfile } from '../common/browserView.js'; import { clipboard, Menu, MenuItem } from 'electron'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; @@ -24,6 +24,7 @@ import { htmlAttributeEncodeValue } from '../../../base/common/strings.js'; import { BrowserViewInspectElementId } from './browserViewInspector.js'; import { equals } from '../../../base/common/objects.js'; import { URI } from '../../../base/common/uri.js'; +import { ILogService } from '../../log/common/log.js'; export const IBrowserViewMainService = createDecorator('browserViewMainService'); @@ -33,7 +34,7 @@ export interface IBrowserViewMainService extends IBrowserViewService { tryGetBrowserView(id: string): BrowserView | undefined; /** Create a new target and return it. */ - createTarget(url: string, owner: IBrowserViewOwner, browserContextId?: string, audience?: IBrowserViewAudience): Promise; + createTarget(url: string, context: IBrowserViewCreationContext): Promise; } export class BrowserViewMainService extends Disposable implements IBrowserViewMainService { @@ -65,65 +66,51 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa @IWindowsMainService private readonly windowsMainService: IWindowsMainService, @ITelemetryService private readonly telemetryService: ITelemetryService, @INativeHostMainService private readonly nativeHostMainService: INativeHostMainService, - @IApplicationStorageMainService private readonly applicationStorageMainService: IApplicationStorageMainService + @IApplicationStorageMainService private readonly applicationStorageMainService: IApplicationStorageMainService, + @ILogService private readonly logService: ILogService, ) { super(); } async getOrCreateBrowserView(id: string, options: IBrowserViewCreateOptions): Promise { - const associatedResource = URI.revive(options.associatedResource); if (this.browserViews.has(id)) { const view = this.browserViews.get(id)!; return this._getViewInfo(view); } - const ownerWindow = this.windowsMainService.getWindowById(options.owner.mainWindowId); - if (!ownerWindow) { - throw new Error(`Owner window with ID ${options.owner.mainWindowId} not found`); - } - - const browserSession = BrowserSession.getOrCreate( - this.instantiationService, - id, - options.sessionOptions, - this.environmentMainService.workspaceStorageHome, - ownerWindow.openedWorkspace?.id - ); - - const view = this.createBrowserView(id, options.owner, browserSession, associatedResource); - if (options.initialState?.audiences) { - view.setAudiences(options.initialState.audiences); - } - - if (options.initialState?.url) { - void view.loadURL(options.initialState.url); - } - - const info = { - ...this._getViewInfo(view), - state: { - ...view.getState(), - ...options.initialState - } - }; - this._onDidCreateBrowserView.fire({ info }); - return info; + const view = this._createBrowserView(id, options); + return this._getViewInfo(view); } tryGetBrowserView(id: string): BrowserView | undefined { return this.browserViews.get(id); } - async createTarget(url: string, owner: IBrowserViewOwner, browserContextId?: string, audience?: IBrowserViewAudience): Promise { - const browserSession = browserContextId ? BrowserSession.get(browserContextId) : undefined; + async createTarget(url: string, context: IBrowserViewCreationContext): Promise { + return this.openNew(url, context, { preserveFocus: true }, 'cdpCreated'); + } - return this.openNew(url, { - owner, - session: browserSession, - openOptions: { preserveFocus: true }, - source: 'cdpCreated', - audience - }); + private _resolveBrowserSession(id: string, hostWindowId: number, selector: BrowserViewSessionSelector): BrowserSession { + if (typeof selector === 'string') { + const browserSession = BrowserSession.get(selector); + if (browserSession) { + return browserSession; + } + return BrowserSession.getOrCreateEphemeral(this.instantiationService, id); + } + + const hostWindow = this.windowsMainService.getWindowById(hostWindowId); + if (!hostWindow) { + throw new Error(`Host window with ID ${hostWindowId} not found`); + } + + return BrowserSession.getOrCreate( + this.instantiationService, + id, + selector, + this.environmentMainService.workspaceStorageHome, + hostWindow.openedWorkspace?.id + ); } /** @@ -140,6 +127,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa private _getViewInfo(view: BrowserView): IBrowserViewInfo { return { id: view.id, + hostWindowId: view.hostWindowId, owner: view.owner, associatedResource: view.associatedResource, state: view.getState() @@ -149,7 +137,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa async getBrowserViews(windowId?: number): Promise { const result: IBrowserViewInfo[] = []; for (const [, view] of this.browserViews) { - if (windowId !== undefined && view.owner.mainWindowId !== windowId) { + if (windowId !== undefined && view.hostWindowId !== windowId) { continue; } result.push(this._getViewInfo(view)); @@ -382,7 +370,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa this._ensureWindowCloseSubscription(windowId); for (const [, view] of this.browserViews) { - if (view.owner.mainWindowId === windowId) { + if (view.hostWindowId === windowId) { if (didThemeChange) { view.inspector.setTheme(config.theme); } @@ -430,13 +418,13 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa /** * Create a browser view backed by the given {@link BrowserSession}. */ - private createBrowserView(id: string, owner: IBrowserViewOwner, browserSession: BrowserSession, associatedResource?: URI, options?: Electron.WebContentsViewConstructorOptions): BrowserView { + private _createNativeBrowserView(id: string, hostWindowId: number, owner: IBrowserViewOwner, browserSession: BrowserSession, associatedResource?: URI, options?: Electron.WebContentsViewConstructorOptions): BrowserView { if (this.browserViews.has(id)) { throw new Error(`Browser view with id ${id} already exists`); } browserSession.connectStorage(this.applicationStorageMainService); - const windowConfiguration = this._windowConfigurations.get(owner.mainWindowId); + const windowConfiguration = this._windowConfigurations.get(hostWindowId); if (typeof windowConfiguration?.maxHistoryEntries === 'number') { browserSession.history.setMaxEntries(windowConfiguration.maxHistoryEntries); } @@ -447,25 +435,18 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa const view = this.instantiationService.createInstance( BrowserView, id, + hostWindowId, owner, associatedResource, browserSession, - // Recursive factory for nested windows (child views share the same session and owner). - (url, electronOptions, openOptions) => { - const child = this.createBrowserView(generateUuid(), owner, browserSession, undefined, electronOptions); - // child.setAudiences(view.audiences); - - if (url) { - void child.loadURL(url).catch(() => { }); - } - - const info = this._getViewInfo(child); - this._onDidCreateBrowserView.fire({ - info: url ? { ...info, state: { ...info.state, url } } : info, - openOptions - }); - - return child; + // Child views share their host, owner, and storage, but do not implicitly inherit agent access. + (url, electronOptions, editorOptions) => { + return this._createBrowserView(generateUuid(), { + hostWindowId, + owner, + session: browserSession.id, + initialUrl: url || undefined + }, editorOptions, electronOptions); }, (v, params) => this.showContextMenu(v, params), options @@ -483,41 +464,36 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return view; } - private async openNew( - url: string, - { - owner, - session, - openOptions, - source, - audience - }: { - owner: IBrowserViewOwner; - session: BrowserSession | undefined; - openOptions: IBrowserViewOpenOptions | undefined; - source: IntegratedBrowserOpenSource; - audience?: IBrowserViewAudience; + private _createBrowserView(id: string, options: IBrowserViewCreateOptions, editorOpenRequest?: IBrowserViewEditorOpenOptions, electronOptions?: Electron.WebContentsViewConstructorOptions): BrowserView { + const browserSession = this._resolveBrowserSession(id, options.hostWindowId, options.session); + const view = this._createNativeBrowserView(id, options.hostWindowId, options.owner, browserSession, URI.revive(options.associatedResource), electronOptions); + if (options.initialAudiences) { + view.setAudiences(options.initialAudiences); } - ): Promise { - const targetId = generateUuid(); - const view = this.createBrowserView(targetId, owner, session || BrowserSession.getOrCreateEphemeral(this.instantiationService, targetId)); - if (audience) { - view.setAudience(audience, true); + if (options.initialUrl) { + void view.loadURL(options.initialUrl).catch(error => { + this.logService.error(`[BrowserViewMainService] Failed to load initial URL for browser view ${id}`, error); + }); } - - if (url) { - void view.loadURL(url).catch(() => { }); + if (options.openSource) { + logBrowserOpen(this.telemetryService, options.openSource); } - - logBrowserOpen(this.telemetryService, source); - - // Fire creation event so the workbench can open an editor tab - const info = this._getViewInfo(view); this._onDidCreateBrowserView.fire({ - info: url ? { ...info, state: { ...info.state, url } } : info, - openOptions + info: this._getViewInfo(view), + initialUrl: options.initialUrl, + editorOpenRequest }); + return view; + } + private async openNew( + url: string, + context: IBrowserViewCreationContext, + editorOpenRequest: IBrowserViewEditorOpenOptions | undefined, + source: IntegratedBrowserOpenSource, + ): Promise { + const targetId = generateUuid(); + const view = this._createBrowserView(targetId, { ...context, initialUrl: url || undefined, openSource: source }, editorOpenRequest); return view; } @@ -531,7 +507,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return; } - const windowConfiguration = this._windowConfigurations.get(view.owner.mainWindowId); + const windowConfiguration = this._windowConfigurations.get(view.hostWindowId); const inspectTarget = windowConfiguration?.aiFeaturesDisabled ? undefined : params.frame && await view.inspector.getElementHandle(BrowserViewInspectElementId.ContextMenuTarget, params.frame); @@ -542,11 +518,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa label: localize('browser.contextMenu.openLinkInNewTab', 'Open Link in New Tab'), click: () => { void this.openNew(params.linkURL, { + hostWindowId: view.hostWindowId, owner: view.owner, - session: view.session, - openOptions: { preserveFocus: true, background: true }, - source: 'browserLinkBackground' - }); + session: view.session.id, + }, { preserveFocus: true, background: true }, 'browserLinkBackground'); } })); menu.append(new MenuItem({ @@ -573,11 +548,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa label: localize('browser.contextMenu.openImageInNewTab', 'Open Image in New Tab'), click: () => { void this.openNew(params.srcURL!, { + hostWindowId: view.hostWindowId, owner: view.owner, - session: view.session, - openOptions: { preserveFocus: true, background: true }, - source: 'browserLinkBackground' - }); + session: view.session.id, + }, { preserveFocus: true, background: true }, 'browserLinkBackground'); } })); menu.append(new MenuItem({ diff --git a/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts b/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts index f69a4d14567267..8711b79700382c 100644 --- a/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts +++ b/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts @@ -8,7 +8,7 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; import { IBrowserViewGroup, IBrowserViewGroupFilter, IBrowserViewGroupService, ipcBrowserViewGroupChannelName } from '../common/browserViewGroup.js'; -import { IBrowserViewOwner } from '../common/browserView.js'; +import { IBrowserViewCreationContext } from '../common/browserView.js'; import { CDPEvent, CDPRequest, CDPResponse } from '../common/cdp/types.js'; /** @@ -23,9 +23,8 @@ import { CDPEvent, CDPRequest, CDPResponse } from '../common/cdp/types.js'; export interface IBrowserViewGroupRemoteService { /** * Create a new browser view group. - * @param owner The owner of the group's lifecycle. */ - createGroup(owner: IBrowserViewOwner, filter?: IBrowserViewGroupFilter): Promise; + createGroup(filter: IBrowserViewGroupFilter, targetContext: IBrowserViewCreationContext): Promise; } /** @@ -75,8 +74,8 @@ export class BrowserViewGroupRemoteService implements IBrowserViewGroupRemoteSer this._groupService = ProxyChannel.toService(channel); } - async createGroup(owner: IBrowserViewOwner, filter?: IBrowserViewGroupFilter): Promise { - const id = await this._groupService.createGroup(owner, filter); + async createGroup(filter: IBrowserViewGroupFilter, targetContext: IBrowserViewCreationContext): Promise { + const id = await this._groupService.createGroup(filter, targetContext); return this._wrap(id); } diff --git a/src/vs/platform/browserView/node/playwrightService.ts b/src/vs/platform/browserView/node/playwrightService.ts index bcb8bd7eb18a09..135069b93b3fb5 100644 --- a/src/vs/platform/browserView/node/playwrightService.ts +++ b/src/vs/platform/browserView/node/playwrightService.ts @@ -4,13 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableMap, IDisposable } from '../../../base/common/lifecycle.js'; -import { DeferredPromise, disposableTimeout, raceTimeout } from '../../../base/common/async.js'; +import { DeferredPromise, disposableTimeout, raceTimeout, timeout } from '../../../base/common/async.js'; import { ILogService } from '../../log/common/log.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { IAgentNetworkFilterService } from '../../networkFilter/common/networkFilterService.js'; import { IInvokeFunctionResult, IPlaywrightService } from '../common/playwrightService.js'; import { IBrowserViewGroupRemoteService } from '../node/browserViewGroupRemoteService.js'; import { IBrowserViewGroup } from '../common/browserViewGroup.js'; +import { getAgentBrowserViewCreationDefaults } from '../common/browserView.js'; import { PlaywrightTab, DialogInterruptedError } from './playwrightTab.js'; import { CDPRequest, CDPResponse, CDPTargetInfo } from '../common/cdp/types.js'; import { generateUuid } from '../../../base/common/uuid.js'; @@ -113,8 +114,11 @@ export class PlaywrightService extends Disposable implements IPlaywrightService this.logService.debug(`[PlaywrightService] Initializing session ${sessionId}`); const group = await this.browserViewGroupRemoteService.createGroup( - { mainWindowId: this.windowId, sessionId }, - { audience: { type: 'agent', sessionId } } + { audience: { type: 'agent', sessionId } }, + { + hostWindowId: this.windowId, + ...getAgentBrowserViewCreationDefaults(sessionId) + } ); const actionScope: IPlaywrightActionScope = { activeCalls: 0 }; @@ -192,9 +196,9 @@ export class PlaywrightService extends Disposable implements IPlaywrightService // --- Playwright operations (delegated to per-session instances) --- - async openPage(sessionId: string, url: string): Promise<{ pageId: string; summary: string }> { + async waitForPageAndGetSummary(sessionId: string, pageId: string, expectedUrl: string, discoveryTimeoutMs: number): Promise { const session = await this._getOrCreateSession(sessionId); - return session.openPage(url); + return session.waitForPageAndGetSummary(pageId, expectedUrl, discoveryTimeoutMs); } async getSummary(sessionId: string, pageId: string): Promise { @@ -276,7 +280,6 @@ class PlaywrightSession extends Disposable { private readonly _pageDiscoveryPromises = new Map>(); private readonly _watchedContexts = new WeakSet(); - private _openContext: BrowserContext | undefined = undefined; /** In-flight deferred results keyed by their generated ID. */ private readonly _deferredResults = this._register(new DisposableMap { - if (!this._openContext) { - this._openContext = await this._browser.newContext(); - this._onContextAdded(this._openContext); - } - - const page = await this._openContext.newPage(); - const viewId = await this._onPageAdded(page); - - if (url && url !== 'about:blank' && page.url() !== url) { - try { - await page.goto(url, { waitUntil: 'domcontentloaded', timeout: OPEN_PAGE_NAVIGATION_TIMEOUT_MS }); - } catch (error) { - if (!isNavigationTimeoutError(error)) { - throw error; - } + async waitForPageAndGetSummary(pageId: string, expectedUrl: string, discoveryTimeoutMs: number): Promise { + const page = await this._waitForPage(pageId, Date.now() + discoveryTimeoutMs); - throw new Error(`Navigation to ${url} timed out after ${OPEN_PAGE_NAVIGATION_TIMEOUT_MS} ms. The page (ID: ${viewId}) is open and can be reused.`); + try { + if (expectedUrl !== 'about:blank' && page.url() === 'about:blank') { + await page.waitForURL(url => url.toString() !== 'about:blank', { waitUntil: 'domcontentloaded', timeout: OPEN_PAGE_NAVIGATION_TIMEOUT_MS }); + } else { + await page.waitForLoadState('domcontentloaded', { timeout: OPEN_PAGE_NAVIGATION_TIMEOUT_MS }); } + } catch (error) { + if (!isNavigationTimeoutError(error)) { + throw error; + } + throw new Error(`Timed out waiting for browser page "${pageId}" to navigate to "${expectedUrl}". The page is open and can be reused.`, { cause: error }); } - const summary = await this._getSummary(viewId); - return { pageId: viewId, summary }; + return this._getSummary(pageId); } async getSummary(pageId: string): Promise { @@ -526,6 +523,33 @@ class PlaywrightSession extends Disposable { // --- Private: page matching (view ↔ page pairing) --- private async _getPage(viewId: string): Promise { + const page = await this._tryGetPage(viewId); + if (page) { + return page; + } + throw new Error(`Page "${viewId}" not found`); + } + + private async _waitForPage(viewId: string, deadline: number): Promise { + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new Error(`Timed out waiting for browser page "${viewId}" to become available. The page is open and can be reused.`); + } + + const page = await raceTimeout(this._tryGetPage(viewId), remaining); + if (page) { + return page; + } + + const delay = Math.min(50, deadline - Date.now()); + if (delay > 0) { + await timeout(delay); + } + } + } + + private async _tryGetPage(viewId: string): Promise { const resolved = this._viewIdToPage.get(viewId); if (resolved) { return resolved; @@ -538,7 +562,7 @@ class PlaywrightSession extends Disposable { if (discovered) { return discovered; } - throw new Error(`Page "${viewId}" not found`); + return undefined; } private _onPageAdded(page: Page): Promise { diff --git a/src/vs/sessions/contrib/browserView/browser/sessionBrowserView.ts b/src/vs/sessions/contrib/browserView/browser/sessionBrowserView.ts index bab5739bcc4372..fa5b9238094b6b 100644 --- a/src/vs/sessions/contrib/browserView/browser/sessionBrowserView.ts +++ b/src/vs/sessions/contrib/browserView/browser/sessionBrowserView.ts @@ -60,7 +60,7 @@ export class SessionBrowserViewController extends Disposable implements IWorkben this._register(this._browserViewService.registerContextualFilter({ include: (input, context) => { const tracked = this._trackedInputs.get(input.id); - const ownerId = input.model?.owner.sessionId ?? tracked?.session.resource.toString(); + const ownerId = input.model?.owner.type === 'agent' ? input.model.owner.sessionId : tracked?.session.resource.toString(); if (!ownerId) { return true; // no owning session known } @@ -77,7 +77,7 @@ export class SessionBrowserViewController extends Disposable implements IWorkben // Only open a browser tab automatically when its owning session is the active session. this._register(this._browserViewService.registerOpenHandler({ shouldOpenEditor: (_input, owner) => { - if (!owner.sessionId) { + if (owner.type !== 'agent') { return true; // no owning session known; open in the active session } const owningSession = this._resolveOwningSession(owner.sessionId); diff --git a/src/vs/sessions/contrib/chat/browser/sessionBrowsersControl.ts b/src/vs/sessions/contrib/chat/browser/sessionBrowsersControl.ts index b645265aef92a5..d6cf0fa18b081d 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionBrowsersControl.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionBrowsersControl.ts @@ -99,7 +99,7 @@ export class SessionBrowsersControl extends Disposable { private _collectBrowsers(ownerIds: ReadonlySet, chat: IChat | undefined): IChatPillEntry[] { const entries: IChatPillEntry[] = []; for (const input of this._browserViewService.getKnownBrowserViews().values()) { - const ownerId = input.model?.owner.sessionId; + const ownerId = input.model?.owner.type === 'agent' ? input.model.owner.sessionId : undefined; if (ownerId && ownerIds.has(ownerId)) { entries.push(this._entry(input.title?.trim() || localize('browsers.browser', "Browser"), input, chat)); } diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionBrowsersControl.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionBrowsersControl.test.ts index b8b03fa95f07ba..381c99af1215aa 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionBrowsersControl.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionBrowsersControl.test.ts @@ -60,7 +60,7 @@ function createControl(spec: IControlSpec, store: ReturnType() { - override readonly owner = ownerId ? { mainWindowId: 1, sessionId: ownerId } : { mainWindowId: 1 }; + override readonly owner = ownerId ? { type: 'agent' as const, sessionId: ownerId } : { type: 'user' as const }; override readonly sharingState = browser.sharingState ?? BrowserViewSharingState.NotShared; }(); return new class extends mock() { diff --git a/src/vs/sessions/contrib/editor/browser/addTabActions.ts b/src/vs/sessions/contrib/editor/browser/addTabActions.ts index 19876dd7b4e30d..e47880c69f9cba 100644 --- a/src/vs/sessions/contrib/editor/browser/addTabActions.ts +++ b/src/vs/sessions/contrib/editor/browser/addTabActions.ts @@ -121,7 +121,7 @@ export class NewBrowserTabAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); const editorService = accessor.get(IEditorService); - const browserInput = browserViewWorkbenchService.getOrCreateLazy(generateUuid(), {}); + const browserInput = browserViewWorkbenchService.getOrCreateLazy({ id: generateUuid() }); await editorService.openEditor(browserInput); } diff --git a/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts index a75e75f7b6fa2e..d9b8548260b5e3 100644 --- a/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts @@ -4,13 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; -import { IBrowserViewWorkbenchService, IBrowserViewCDPService, IBrowserViewModel, IBrowserEditorViewState, IBrowserViewContextualFilter, IBrowserViewOpenHandler } from '../common/browserView.js'; +import { IBrowserViewWorkbenchService, IBrowserViewCDPService, IBrowserViewModel, IBrowserViewContextualFilter, IBrowserViewOpenHandler, IBrowserViewWorkbenchCreateOptions } from '../common/browserView.js'; import type { PreferredGroup } from '../../../services/editor/common/editorService.js'; import { Event } from '../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; +import { IBrowserViewEditorOpenOptions } from '../../../../platform/browserView/common/browserView.js'; import { CDPEvent, CDPRequest, CDPResponse } from '../../../../platform/browserView/common/cdp/types.js'; import { ITunnelProxyInfo } from '../../../../platform/tunnel/common/tunnelProxy.js'; -import { BrowserEditorInput } from '../common/browserEditorInput.js'; +import { BrowserEditorInput, IBrowserEditorInputData } from '../common/browserEditorInput.js'; class WebBrowserViewWorkbenchService implements IBrowserViewWorkbenchService { declare readonly _serviceBrand: undefined; @@ -47,7 +48,11 @@ class WebBrowserViewWorkbenchService implements IBrowserViewWorkbenchService { return Disposable.None; } - getOrCreateLazy(_id: string, _state: IBrowserEditorViewState): BrowserEditorInput { + async createBrowserView(_options: IBrowserViewWorkbenchCreateOptions, _editorOpenOptions?: IBrowserViewEditorOpenOptions): Promise { + throw new Error('Integrated Browser is not available in web.'); + } + + getOrCreateLazy(_data: IBrowserEditorInputData): BrowserEditorInput { throw new Error('Integrated Browser is not available in web.'); } diff --git a/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts b/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts index 30eaf54096f727..19856c6983a93a 100644 --- a/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts +++ b/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts @@ -46,6 +46,8 @@ const MAX_TITLE_LENGTH = 30; export interface IBrowserEditorInputData extends IBrowserEditorViewState { readonly id: string; readonly associatedResource?: URI; + /** Whether the tab came from the default localhost link opener. Not serialized. */ + readonly isDefaultLinkOpen?: boolean; } /** @@ -136,7 +138,10 @@ export class BrowserEditorInput extends EditorInput { this._modelStore.add(this._model.onDidChangeTitle(() => this._onDidChangeLabel.fire())); this._modelStore.add(this._model.onDidChangeFavicon(() => this._onDidChangeLabel.fire())); this._modelStore.add(this._model.onDidChangeLoadingState(() => this._onDidChangeLabel.fire())); - this._modelStore.add(this._model.onDidNavigate(() => this._onDidChangeLabel.fire())); + this._modelStore.add(this._model.onDidNavigate(() => { + this._initialData = { ...this._initialData, title: undefined, favicon: undefined }; + this._onDidChangeLabel.fire(); + })); this._onDidChangeLabel.fire(); this._onDidResolveModel.fire(model); @@ -160,18 +165,15 @@ export class BrowserEditorInput extends EditorInput { } get url(): string | undefined { - // Use model URL if available, otherwise fall back to initial data - return this._model ? this._model.url : this._initialData.url; + return this._model?.url || this._initialData.url; } get title(): string | undefined { - // Use model title if available, otherwise fall back to initial data - return this._model ? this._model.title : this._initialData.title; + return this._model?.title || this._initialData.title; } get favicon(): string | undefined { - // Use model favicon if available, otherwise fall back to initial data - return this._model ? this._model.favicon : this._initialData.favicon; + return this._model?.favicon ?? this._initialData.favicon; } /** @@ -191,7 +193,6 @@ export class BrowserEditorInput extends EditorInput { if (this._model) { void this._model.loadURL(destination, options); } else { - // If the model isn't created yet, update the initial data so that the URL is correct when the model is created this._initialData = { id: this._id, url: destination @@ -235,7 +236,6 @@ export class BrowserEditorInput extends EditorInput { override getIcon(): ThemeIcon | URI | undefined { const defaultIcon = this._associatedResource ? undefined : Codicon.globe; - // Use model data if available, otherwise fall back to initial data if (this._model) { if (this._model.loading) { const color = this.themeService.getColorTheme().getColor(TAB_ACTIVE_FOREGROUND); @@ -244,9 +244,7 @@ export class BrowserEditorInput extends EditorInput { if (this._model.favicon) { return URI.parse(this._model.favicon); } - return defaultIcon; } - // Model not created yet, use initial data if available if (this._initialData.favicon) { return URI.parse(this._initialData.favicon); } @@ -254,8 +252,7 @@ export class BrowserEditorInput extends EditorInput { } override getName(): string { - const hasTitle = this._model ? !!this._model.title : !!this._initialData.title; - if (hasTitle) { + if (this.title) { return truncate(this.title!, MAX_TITLE_LENGTH); } @@ -264,9 +261,8 @@ export class BrowserEditorInput extends EditorInput { } override getTitle(verbosity = Verbosity.MEDIUM): string { - const hasTitle = this._model ? !!this._model.title : !!this._initialData.title; const description = this.getDescription(verbosity); - const title = hasTitle ? `${this.title} (${description})` : description; + const title = this.title ? `${this.title} (${description})` : description; return title || BrowserEditorInput.DEFAULT_LABEL; } @@ -345,11 +341,13 @@ export class BrowserEditorInput extends EditorInput { return this.instantiationService.invokeFunction((accessor) => { const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); - return browserViewWorkbenchService.getOrCreateLazy(generateUuid(), { + return browserViewWorkbenchService.getOrCreateLazy({ + id: generateUuid(), url: this.url, title: this.title, - favicon: this.favicon - }, this._associatedResource); + favicon: this.favicon, + associatedResource: this._associatedResource + }); }); } @@ -446,11 +444,13 @@ export class BrowserEditorSerializer implements IEditorSerializer { const data: IBrowserEditorInputData = JSON.parse(serializedEditor); return instantiationService.invokeFunction((accessor) => { const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); - return browserViewWorkbenchService.getOrCreateLazy(data.id, { + return browserViewWorkbenchService.getOrCreateLazy({ + id: data.id, url: data.url, title: data.title, - favicon: data.favicon - }, URI.revive(data.associatedResource)); + favicon: data.favicon, + associatedResource: URI.revive(data.associatedResource) + }); }); } catch { return undefined; diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index f87ca3f36c5c06..ef6ae27f9cdda1 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -23,7 +23,7 @@ import { BrowserPermissionStore, IPermissionCategoryState, } from '../../../../platform/browserView/common/browserPermissions.js'; -import type { BrowserEditorInput } from './browserEditorInput.js'; +import type { BrowserEditorInput, IBrowserEditorInputData } from './browserEditorInput.js'; import type { PreferredGroup } from '../../../services/editor/common/editorService.js'; import { IBrowserViewBounds, @@ -46,7 +46,9 @@ import { IBrowserElementCommentsUpdate, IBrowserElementSelectionOptions, IBrowserViewOwner, - IBrowserViewOpenOptions, + IBrowserViewEditorOpenOptions, + BrowserViewSessionSelector, + IBrowserViewAudience, IBrowserViewRect, browserZoomDefaultIndex, browserZoomFactors, @@ -60,6 +62,7 @@ import { isLocalhostAuthority } from '../../../../platform/url/common/trustedDom import { IAgentNetworkFilterService } from '../../../../platform/networkFilter/common/networkFilterService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IBrowserZoomService } from './browserZoomService.js'; +import type { IntegratedBrowserOpenSource } from '../../../../platform/browserView/common/browserViewTelemetry.js'; export const enum BrowserViewSharingState { /** Tools are available and the page is shared with the agent. */ @@ -169,14 +172,6 @@ export interface IBrowserEditorViewState { readonly url?: string; readonly title?: string; readonly favicon?: string; - - /** - * When true, indicates that this browser tab was opened via the localhost - * link opener while the user has not explicitly configured the setting - * (i.e. the default value was used). This is a transient flag and is not - * serialized. - */ - readonly isDefaultLinkOpen?: boolean; } export const IBrowserViewWorkbenchService = createDecorator('browserViewWorkbenchService'); @@ -206,7 +201,7 @@ export interface IBrowserViewFilterContext { /** * The session *resource* URI string (`session.resource.toString()`) of the * relevant session, if any. This is the same value stored in - * {@link IBrowserViewOwner.sessionId} — not the composite + * an agent {@link IBrowserViewOwner} — not the composite * `ISession.sessionId` (`providerId:resource`). */ activeSessionId?: string; @@ -223,7 +218,16 @@ export interface IBrowserViewOpenHandler { * Return `false` to prevent the editor from being opened. A view is opened * only when every registered handler allows it. */ - shouldOpenEditor(input: BrowserEditorInput, owner: IBrowserViewOwner, openOptions: IBrowserViewOpenOptions): boolean; + shouldOpenEditor(input: BrowserEditorInput, owner: IBrowserViewOwner, editorOptions: IBrowserViewEditorOpenOptions): boolean; +} + +export interface IBrowserViewWorkbenchCreateOptions { + readonly owner: IBrowserViewOwner; + readonly session: BrowserViewSessionSelector; + readonly initialAudiences?: readonly IBrowserViewAudience[]; + readonly initialUrl?: string; + readonly associatedResource?: URI; + readonly openSource?: IntegratedBrowserOpenSource; } /** @@ -300,11 +304,14 @@ export interface IBrowserViewWorkbenchService { */ registerOpenHandler(handler: IBrowserViewOpenHandler): IDisposable; + /** Creates and resolves a browser view, optionally requesting editor presentation. */ + createBrowserView(options: IBrowserViewWorkbenchCreateOptions, editorOpenOptions?: IBrowserViewEditorOpenOptions): Promise; + /** * Get an existing browser view for the given ID, or create a new one if it doesn't exist. * The underlying browser view is not created until the editor is opened or the model is resolved. */ - getOrCreateLazy(id: string, initialState?: IBrowserEditorViewState, associatedResource?: URI): BrowserEditorInput; + getOrCreateLazy(data: IBrowserEditorInputData): BrowserEditorInput; /** * Clear all storage data for the global browser session diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index 011375078fc381..e48ef83bf1bdf5 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -88,7 +88,10 @@ class BrowserEditorResolverContribution implements IWorkbenchContribution { throw new Error(`Invalid browser view resource: ${resource.toString()}`); } - const browserInput = browserViewWorkbenchService.getOrCreateLazy(parsed.id, options?.viewState); + const browserInput = browserViewWorkbenchService.getOrCreateLazy({ + id: parsed.id, + ...options?.viewState + }); // Start resolving the input right away. This will create the browser view. // This allows browser views to be loaded in the background. @@ -122,10 +125,12 @@ class BrowserEditorResolverContribution implements IWorkbenchContribution { logBrowserOpen(telemetryService, 'fileResource'); const viewState = options?.viewState; - const browserInput = browserViewWorkbenchService.getOrCreateLazy(generateUuid(), { + const browserInput = browserViewWorkbenchService.getOrCreateLazy({ + id: generateUuid(), + associatedResource: resource, ...viewState, url: getBrowserViewStateUrl(viewState) ?? resource.toString() - }, resource); + }); void browserInput.resolve(); return { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewCDPService.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewCDPService.ts index d88d17fe0d6c9d..abf2c74f1e0982 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewCDPService.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewCDPService.ts @@ -11,6 +11,7 @@ import { IBrowserViewGroupService, ipcBrowserViewGroupChannelName } from '../../ import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; import { IBrowserViewCDPService } from '../common/browserView.js'; import { mainWindow } from '../../../../base/browser/window.js'; +import { BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js'; export class BrowserViewCDPService extends Disposable implements IBrowserViewCDPService { declare readonly _serviceBrand: undefined; @@ -27,8 +28,12 @@ export class BrowserViewCDPService extends Disposable implements IBrowserViewCDP async createSessionGroup(browserId: string): Promise { return this._groupService.createGroup( - { mainWindowId: mainWindow.vscodeWindowId }, - { browserIds: [browserId] } + { browserIds: [browserId] }, + { + hostWindowId: mainWindow.vscodeWindowId, + owner: { type: 'user' }, + session: { scope: BrowserViewStorageScope.Ephemeral } + } ); } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts index dee98bb03efc65..99f97400015066 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BrowserViewCommandId, BrowserViewStorageScope, IBrowserViewInfo, IBrowserViewOpenOptions, IBrowserViewOwner, IBrowserViewService, IBrowserViewTheme, ipcBrowserViewChannelName } from '../../../../platform/browserView/common/browserView.js'; -import { IBrowserViewWorkbenchService, IBrowserViewModel, BrowserViewModel, IBrowserEditorViewState, IBrowserViewContextualFilter, IBrowserViewFilterContext, IBrowserViewOpenHandler } from '../common/browserView.js'; +import { BrowserViewCommandId, BrowserViewStorageScope, IBrowserViewEditorOpenOptions, IBrowserViewInfo, IBrowserViewOwner, IBrowserViewService, IBrowserViewTheme, ipcBrowserViewChannelName } from '../../../../platform/browserView/common/browserView.js'; +import { IBrowserViewWorkbenchService, IBrowserViewModel, BrowserViewModel, IBrowserViewContextualFilter, IBrowserViewFilterContext, IBrowserViewOpenHandler, IBrowserViewWorkbenchCreateOptions } from '../common/browserView.js'; import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -17,7 +17,7 @@ import { ACTIVE_GROUP, AUX_WINDOW_GROUP, IEditorService, PreferredGroup, SIDE_GR import { mainWindow } from '../../../../base/browser/window.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IWorkspaceTrustEnablementService, IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; -import { BrowserEditorInput } from '../common/browserEditorInput.js'; +import { BrowserEditorInput, IBrowserEditorInputData } from '../common/browserEditorInput.js'; import { IEditorGroup, IEditorGroupsService, preferredSideBySideGroupDirection } from '../../../services/editor/common/editorGroupsService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -40,10 +40,13 @@ import { getCopilotRootPaths } from '../../../../platform/agentHost/common/copil import { localChatSessionType } from '../../chat/common/chatSessionsService.js'; import { INativeWorkbenchEnvironmentService } from '../../../services/environment/electron-browser/environmentService.js'; import { ITunnelProxyInfo } from '../../../../platform/tunnel/common/tunnelProxy.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { raceTimeout } from '../../../../base/common/async.js'; export const BrowserMaxHistoryEntriesSettingId = 'workbench.browser.maxHistoryEntries'; export const BrowserRemoteProxyEnabledSettingId = 'workbench.browser.enableRemoteProxy'; export const BrowserNewTabPlacementSettingId = 'workbench.browser.newTabPlacement'; +const OPEN_BROWSER_NAVIGATION_TIMEOUT_MS = 30_000; /** * Where new integrated browser tabs are opened. @@ -172,16 +175,16 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV // Listen for new browser views this._register(this._browserViewService.onDidCreateBrowserView(e => { - if (e.info.owner.mainWindowId !== this._mainWindowId) { + if (e.info.hostWindowId !== this._mainWindowId) { return; // Not for this window } // Eagerly create the model from the state we already have - this._createModel(e.info); + this._createModel(e.info, e.initialUrl); const editor = this._known.get(e.info.id); - if (editor && e.openOptions) { - void this._openEditorForCreatedView(editor, e.info.owner, e.openOptions).catch(error => { + if (editor && e.editorOpenRequest) { + void this._openEditorForCreatedView(editor, e.info.owner, e.editorOpenRequest).catch(error => { this.logService.error('[BrowserViewWorkbenchService] Failed to open editor for created browser view.', error); }); } @@ -324,22 +327,49 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV }); } - getOrCreateLazy(id: string, initialState?: IBrowserEditorViewState, associatedResource?: URI, model?: IBrowserViewModel): BrowserEditorInput { + async createBrowserView(options: IBrowserViewWorkbenchCreateOptions, editorOpenOptions?: IBrowserViewEditorOpenOptions): Promise { + const input = this._getOrCreateLazy({ + id: generateUuid(), + associatedResource: options.associatedResource, + url: options.initialUrl + }, undefined, { ...options, initialUrl: undefined }); + const model = await input.resolve(); + if (editorOpenOptions) { + void this._openEditorForCreatedView(input, options.owner, editorOpenOptions).catch(error => { + this.logService.error('[BrowserViewWorkbenchService] Failed to open editor for created browser view.', error); + }); + } + const initialUrl = options.initialUrl; + if (initialUrl) { + const didNavigate = await raceTimeout((async () => { + await model.loadURL(initialUrl); + return true; + })(), OPEN_BROWSER_NAVIGATION_TIMEOUT_MS); + if (!didNavigate) { + throw new Error(`Navigation to ${initialUrl} timed out after ${OPEN_BROWSER_NAVIGATION_TIMEOUT_MS} ms. The page (ID: ${input.id}) is open and can be reused.`); + } + } + return input; + } + + getOrCreateLazy(data: IBrowserEditorInputData): BrowserEditorInput { + return this._getOrCreateLazy(data); + } + + private _getOrCreateLazy(data: IBrowserEditorInputData, model?: IBrowserViewModel, createOptions?: IBrowserViewWorkbenchCreateOptions): BrowserEditorInput { + const { id, associatedResource } = data; if (!this._known.has(id)) { - const input = this.instantiationService.createInstance(BrowserEditorInput, { id, ...initialState, associatedResource }, async () => { + const input = this.instantiationService.createInstance(BrowserEditorInput, data, async () => { const info = await this._browserViewService.getOrCreateBrowserView( id, { - owner: this._getDefaultOwner(), + hostWindowId: this._mainWindowId, + owner: createOptions?.owner ?? { type: 'user' }, associatedResource, - sessionOptions: { - scope: await this._resolveStorageScope() - }, - initialState: { - url: initialState?.url, - title: initialState?.title, - lastFavicon: initialState?.favicon - } + session: createOptions?.session ?? { scope: await this._resolveStorageScope() }, + initialAudiences: createOptions?.initialAudiences, + initialUrl: createOptions ? createOptions.initialUrl : data.url, + openSource: createOptions?.openSource } ); return this._createModel(info); @@ -367,10 +397,6 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV return this._browserViewService.clearWorkspaceStorage(workspaceId); } - private _getDefaultOwner(): IBrowserViewOwner { - return { mainWindowId: this._mainWindowId }; - } - private async _resolveStorageScope(): Promise { let dataStorage = this.configurationService.getValue( 'workbench.browser.dataStorage' @@ -406,18 +432,29 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV } } - private _createModel(info: IBrowserViewInfo): IBrowserViewModel { + private _createModel(info: IBrowserViewInfo, initialUrl?: string): IBrowserViewModel { const associatedResource = URI.revive(info.associatedResource); // Don't double-create - const existing = this._known.get(info.id)?.model; + const input = this._known.get(info.id); + const existing = input?.model; if (existing) { return existing; } - const model = this.instantiationService.createInstance(BrowserViewModel, info.id, info.owner, associatedResource, info.state, this._browserViewService); + const state = input + ? { + ...info.state, + url: input.url ?? info.state.url, + title: input.title ?? info.state.title, + lastFavicon: input.favicon ?? info.state.lastFavicon + } + : initialUrl + ? { ...info.state, url: initialUrl } + : info.state; + const model = this.instantiationService.createInstance(BrowserViewModel, info.id, info.owner, associatedResource, state, this._browserViewService); // Sanity: both pass and assign the model to be sure. It will no-op if already set. - this.getOrCreateLazy(info.id, {}, associatedResource, model).model = model; + this._getOrCreateLazy({ id: info.id, associatedResource, url: initialUrl }, model).model = model; this._onDidChangeBrowserViews.fire(); @@ -427,22 +464,20 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV /** * Open an editor tab for a newly created browser view. */ - private async _openEditorForCreatedView(view: BrowserEditorInput, owner: IBrowserViewOwner, openOptions: IBrowserViewOpenOptions): Promise { - const opts = openOptions; - + private async _openEditorForCreatedView(view: BrowserEditorInput, owner: IBrowserViewOwner, options: IBrowserViewEditorOpenOptions): Promise { // Give registered handlers a chance to prevent the editor from opening. for (const handler of this._openHandlers) { - if (!handler.shouldOpenEditor(view, owner, opts)) { + if (!handler.shouldOpenEditor(view, owner, options)) { return; } } // Resolve target group: auxiliary window, parent's group, or default let targetGroup: PreferredGroup | undefined; - if (opts.auxiliaryWindow) { + if (options.auxiliaryWindow) { targetGroup = AUX_WINDOW_GROUP; - } else if (opts.parentViewId) { - targetGroup = this._findEditorGroupForView(opts.parentViewId); + } else if (options.parentViewId) { + targetGroup = this._findEditorGroupForView(options.parentViewId); if (targetGroup === undefined) { return; // If the parent isn't open, don't open the child either } @@ -453,11 +488,11 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV } const editorOptions = { - inactive: opts.background, - preserveFocus: opts.preserveFocus, - pinned: opts.pinned, - auxiliary: opts.auxiliaryWindow - ? { bounds: opts.auxiliaryWindow, compact: true } + inactive: options.background, + preserveFocus: options.preserveFocus, + pinned: options.pinned, + auxiliary: options.auxiliaryWindow + ? { bounds: options.auxiliaryWindow, compact: true } : undefined, }; @@ -465,7 +500,7 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV // only open in the foreground if the session's widget is currently visible // and not the active editor in the target group. const [group] = await this.instantiationService.invokeFunction(findGroup, { editor: view, options: editorOptions }, targetGroup); - if (owner.sessionId) { + if (owner.type === 'agent') { const sessionResource = URI.parse(owner.sessionId); const widget = this.chatWidgetService.getWidgetBySessionResource(sessionResource); const isWidgetVisible = !!widget && widget.domNode.offsetParent !== null; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts index 10b525fa084c54..e6e5c69760c170 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts @@ -26,8 +26,10 @@ import { BrowserEditorInput } from '../../common/browserEditorInput.js'; import { BrowserChatToolReferenceName } from '../../../../../platform/browserView/common/browserChatToolReferenceNames.js'; import { createBrowserPageLink, findExistingPagesByHost, getExistingPagesResult, getSessionId, remoteUrlRewriteNotice, rewriteRemoteLocalhostUrl } from './browserToolHelpers.js'; import { IRemoteExplorerService } from '../../../../services/remote/common/remoteExplorerService.js'; +import { getAgentBrowserViewCreationDefaults } from '../../../../../platform/browserView/common/browserView.js'; export const OpenPageToolId = 'open_browser_page'; +const OPEN_PAGE_READY_TIMEOUT_MS = 5000; export const OpenBrowserToolData: IToolData = { id: OpenPageToolId, @@ -284,8 +286,13 @@ export class OpenBrowserTool implements IToolImpl { } private async _openNewPage(sessionId: string, url: string): Promise { - const { pageId, summary } = await this.playwrightService.openPage(sessionId, url); - return this._pageResult(pageId, summary, localize('browser.open.result', "Opened {0}", createBrowserPageLink(pageId))); + const input = await this.browserViewService.createBrowserView({ + ...getAgentBrowserViewCreationDefaults(sessionId), + initialUrl: url, + openSource: 'cdpCreated' + }, { preserveFocus: true }); + const summary = await this.playwrightService.waitForPageAndGetSummary(sessionId, input.id, url, OPEN_PAGE_READY_TIMEOUT_MS); + return this._pageResult(input.id, summary, localize('browser.open.result', "Opened {0}", createBrowserPageLink(input.id))); } private async _shareExistingPage(sessionId: string, editor: BrowserEditorInput): Promise { diff --git a/src/vs/workbench/contrib/browserView/test/electron-browser/browserEditorInput.test.ts b/src/vs/workbench/contrib/browserView/test/electron-browser/browserEditorInput.test.ts index 5ff5cf21fd81a4..19a1915e59980c 100644 --- a/src/vs/workbench/contrib/browserView/test/electron-browser/browserEditorInput.test.ts +++ b/src/vs/workbench/contrib/browserView/test/electron-browser/browserEditorInput.test.ts @@ -4,17 +4,19 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Event } from '../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; import { hasKey } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { IBrowserViewEditorOpenOptions, IBrowserViewNavigationEvent } from '../../../../../platform/browserView/common/browserView.js'; import { BrowserViewUri } from '../../../../../platform/browserView/common/browserViewUri.js'; import { IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; import { ITunnelProxyInfo } from '../../../../../platform/tunnel/common/tunnelProxy.js'; import { BrowserEditorInput, BrowserEditorSerializer, IBrowserEditorInputData } from '../../common/browserEditorInput.js'; -import { IBrowserEditorViewState, IBrowserViewContextualFilter, IBrowserViewFilterContext, IBrowserViewOpenHandler, IBrowserViewWorkbenchService } from '../../common/browserView.js'; +import { IBrowserViewContextualFilter, IBrowserViewFilterContext, IBrowserViewModel, IBrowserViewOpenHandler, IBrowserViewWorkbenchCreateOptions, IBrowserViewWorkbenchService } from '../../common/browserView.js'; import { IUntypedEditorInput } from '../../../../common/editor.js'; import { applyAvailableEditorIds } from '../../../../common/contextkeys.js'; import { IEditorResolverService, RegisteredEditorPriority } from '../../../../services/editor/common/editorResolverService.js'; @@ -58,11 +60,15 @@ class TestBrowserViewWorkbenchService implements IBrowserViewWorkbenchService { return Disposable.None; } - getOrCreateLazy(id: string, initialState?: IBrowserEditorViewState, associatedResource?: URI): BrowserEditorInput { + async createBrowserView(_options: IBrowserViewWorkbenchCreateOptions, _editorOpenOptions?: IBrowserViewEditorOpenOptions): Promise { + throw new Error('Not implemented for this test.'); + } + + getOrCreateLazy(data: IBrowserEditorInputData): BrowserEditorInput { this.lastCreate = { - id, - url: initialState?.url, - associatedResource: associatedResource?.toString() + id: data.id, + url: data.url, + associatedResource: data.associatedResource?.toString() }; if (!this.input) { throw new Error('No browser editor input configured for test.'); @@ -166,6 +172,68 @@ suite('BrowserEditorInput', () => { }); }); + test('uses restored presentation until the browser reports navigation', () => { + let url = ''; + let title = ''; + const onDidNavigate = disposables.add(new Emitter()); + const model = new class extends mock() { + override readonly owner = { type: 'user' as const }; + override get url(): string { return url; } + override get title(): string { return title; } + override get favicon(): string | undefined { return undefined; } + override readonly loading = false; + override readonly onWillDispose = Event.None; + override readonly onDidClose = Event.None; + override readonly onDidChangeTitle = Event.None; + override readonly onDidChangeFavicon = Event.None; + override readonly onDidChangeLoadingState = Event.None; + override readonly onDidNavigate = onDidNavigate.event; + override dispose(): void { } + }(); + const input = createInput({ + id: 'restored-browser', + url: 'https://restored.example/', + title: 'Restored title', + favicon: 'data:image/png;base64,restored' + }); + input.model = model; + + const restored = { + url: input.url, + title: input.title, + favicon: input.favicon + }; + url = 'https://loaded.example/'; + title = ''; + onDidNavigate.fire({ + url, + title, + canGoBack: false, + canGoForward: false, + certificateError: undefined + }); + + assert.deepStrictEqual({ + restored, + loaded: { + url: input.url, + title: input.title, + favicon: input.favicon + } + }, { + restored: { + url: 'https://restored.example/', + title: 'Restored title', + favicon: 'data:image/png;base64,restored' + }, + loaded: { + url: 'https://loaded.example/', + title: undefined, + favicon: undefined + } + }); + }); + test('describes and restricts resource-backed pages for browser tools', () => { const associatedResource = URI.file('/workspace/index.html'); const input = createInput({ diff --git a/src/vs/workbench/contrib/browserView/test/electron-browser/tools/openBrowserTool.test.ts b/src/vs/workbench/contrib/browserView/test/electron-browser/tools/openBrowserTool.test.ts index a23408ff46d4c2..c43a987af1ea13 100644 --- a/src/vs/workbench/contrib/browserView/test/electron-browser/tools/openBrowserTool.test.ts +++ b/src/vs/workbench/contrib/browserView/test/electron-browser/tools/openBrowserTool.test.ts @@ -15,8 +15,12 @@ import { AgentNetworkDomainSettingId } from '../../../../../../platform/networkF import { IEditorService } from '../../../../../services/editor/common/editorService.js'; import { IRemoteExplorerService } from '../../../../../services/remote/common/remoteExplorerService.js'; import { IChatService } from '../../../../chat/common/chatService/chatService.js'; -import { IBrowserViewWorkbenchService } from '../../../common/browserView.js'; +import { IBrowserViewWorkbenchCreateOptions, IBrowserViewWorkbenchService } from '../../../common/browserView.js'; import { OpenBrowserTool } from '../../../electron-browser/tools/openBrowserTool.js'; +import { BrowserEditorInput } from '../../../common/browserEditorInput.js'; +import { BrowserViewStorageScope, IBrowserViewEditorOpenOptions } from '../../../../../../platform/browserView/common/browserView.js'; +import { IToolInvocation, ToolProgress } from '../../../../chat/common/tools/languageModelToolsService.js'; +import { URI } from '../../../../../../base/common/uri.js'; suite('OpenBrowserTool', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -57,4 +61,58 @@ suite('OpenBrowserTool', () => { assert.deepStrictEqual(blocked, [true, true]); }); + + test('creates agent-owned pages through the workbench before summarizing', async () => { + let createOptions: IBrowserViewWorkbenchCreateOptions | undefined; + let editorOpenOptions: IBrowserViewEditorOpenOptions | undefined; + let summaryArguments: readonly [string, string, string, number] | undefined; + const input = upcastPartial({ id: 'page-id' }); + const tool = new OpenBrowserTool( + upcastPartial({ + waitForPageAndGetSummary: async (...args) => { + summaryArguments = args; + return 'Page summary'; + } + }), + upcastPartial({}), + upcastPartial({ + willUseRemoteProxy: () => true, + createBrowserView: async (options, openOptions) => { + createOptions = options; + editorOpenOptions = openOptions; + return input; + } + }), + upcastPartial({}), + upcastPartial({}), + upcastPartial({}), + new TestConfigurationService(), + upcastPartial({}), + ); + + await tool.invoke( + upcastPartial({ + parameters: { url: 'https://example.com', forceNew: true }, + context: { sessionResource: URI.parse('chat:session') } + }), + async () => 0, + upcastPartial({ report: () => { } }), + CancellationToken.None + ); + + assert.deepStrictEqual({ createOptions, editorOpenOptions, summaryArguments }, { + createOptions: { + owner: { type: 'agent', sessionId: 'chat:session' }, + initialAudiences: [{ type: 'agent' }], + session: { + scope: BrowserViewStorageScope.Ephemeral, + affinity: 'chat:session' + }, + initialUrl: 'https://example.com', + openSource: 'cdpCreated' + }, + editorOpenOptions: { preserveFocus: true }, + summaryArguments: ['chat:session', 'page-id', 'https://example.com', 5000] + }); + }); }); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts index 16beb9650eab9f..f327098c3e33a4 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts @@ -107,7 +107,7 @@ function createMockSession(spec: ISessionSpec): IMockSessionAndChat { const browsers = (spec.browsers ?? []).map((browser, index) => { const owner = browser.ownerSubagent === undefined ? chat : subagents[browser.ownerSubagent]; const model = new class extends mock() { - override readonly owner = { mainWindowId: 1, sessionId: owner.resource.toString() }; + override readonly owner = { type: 'agent' as const, sessionId: owner.resource.toString() }; }(); return new class extends mock() { override get id(): string { return `browser-${index}`; } From e6676d08ada19a85c653c606c70073db28fed396 Mon Sep 17 00:00:00 2001 From: roblourens Date: Fri, 21 Aug 2026 11:49:12 -0700 Subject: [PATCH 09/21] agentHost: Use neutral protocol client name (#332006) Rename RemoteAgentHostProtocolClient to AgentHostProtocolClient because the implementation is shared by local and remote agent host services.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentHostIpcChannelTransport.ts | 2 +- ...olClient.ts => agentHostProtocolClient.ts} | 18 +++++------ .../browser/remoteAgentHostServiceImpl.ts | 10 +++--- .../agentHost/common/taskEventReplay.ts | 2 +- .../electron-browser/localAgentHostService.ts | 8 ++--- .../sshRemoteAgentHostServiceImpl.ts | 10 +++--- .../wslRemoteAgentHostServiceImpl.ts | 10 +++--- ...est.ts => agentHostProtocolClient.test.ts} | 32 +++++++++---------- .../localAgentHostService.test.ts | 4 +-- .../remoteAgentHostService.test.ts | 2 +- .../sshRemoteAgentHostService.test.ts | 8 ++--- src/vs/server/node/agentHostChannel.ts | 2 +- .../browser/browserTunnelAgentHostService.ts | 4 +-- .../browser/cloudSandboxAgentHostService.ts | 6 ++-- .../browser/remoteAgentHost.contribution.ts | 6 ++-- .../browser/remoteAgentHostLogForwarder.ts | 6 ++-- .../browser/webTunnelAgentHostService.ts | 6 ++-- .../tunnelAgentHostServiceImpl.ts | 6 ++-- .../editorRemoteAgentHostServiceClient.ts | 10 +++--- ...editorRemoteAgentHostServiceClient.test.ts | 6 ++-- 20 files changed, 78 insertions(+), 80 deletions(-) rename src/vs/platform/agentHost/browser/{remoteAgentHostProtocolClient.ts => agentHostProtocolClient.ts} (98%) rename src/vs/platform/agentHost/test/electron-browser/{remoteAgentHostProtocolClient.test.ts => agentHostProtocolClient.test.ts} (98%) diff --git a/src/vs/platform/agentHost/browser/agentHostIpcChannelTransport.ts b/src/vs/platform/agentHost/browser/agentHostIpcChannelTransport.ts index a6f07f92dbee60..5ef7abcd35e780 100644 --- a/src/vs/platform/agentHost/browser/agentHostIpcChannelTransport.ts +++ b/src/vs/platform/agentHost/browser/agentHostIpcChannelTransport.ts @@ -6,7 +6,7 @@ // IPC channel transport for the agent host protocol. Wraps an `IChannel` // (typically obtained via `IRemoteAgentConnection.getChannel('agentHost')`) // to satisfy the same `IClientTransport` interface as `WebSocketClientTransport`, -// so the existing `RemoteAgentHostProtocolClient` can be reused unchanged. +// so the existing `AgentHostProtocolClient` can be reused unchanged. // // The server-side counterpart (`AgentHostChannel`) opens an AHP WebSocket // upstream to the local agent host process and pipes raw JSON frames over diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts similarity index 98% rename from src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts rename to src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index 115f6498c9df8a..fa167a26fcb7ff 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -3,9 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// Protocol client for communicating with a remote agent host process. -// Wraps WebSocketClientTransport and SessionClientState to provide a -// higher-level API matching IAgentService. +// Protocol client for communicating with an agent host process. import { DeferredPromise, TimeoutTimer } from '../../../base/common/async.js'; import { CancellationError } from '../../../base/common/errors.js'; @@ -111,8 +109,8 @@ interface IPendingRequest { } /** - * High-level connection state of a {@link RemoteAgentHostProtocolClient}. - * Exposed via {@link RemoteAgentHostProtocolClient.onDidChangeConnectionState} + * High-level connection state of an {@link AgentHostProtocolClient}. + * Exposed via {@link AgentHostProtocolClient.onDidChangeConnectionState} * so consumers can surface transient reconnect activity in the UI. */ export const enum AgentHostClientState { @@ -166,14 +164,14 @@ type ClientState = | { readonly kind: AgentHostClientState.Closed; readonly error: ProtocolError }; /** - * A protocol-level client for a single remote agent host connection. - * Manages the WebSocket transport, handshake, subscriptions, action dispatch, + * A protocol-level client for a single agent host connection. + * Manages the transport, handshake, subscriptions, action dispatch, * and command/response correlation. * * Implements {@link IAgentConnection} so consumers can program against * a single interface regardless of whether the agent host is local or remote. */ -export class RemoteAgentHostProtocolClient extends Disposable implements IAgentConnection { +export class AgentHostProtocolClient extends Disposable implements IAgentConnection { declare readonly _serviceBrand: undefined; @@ -336,7 +334,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC this._subscriptionManager = this._register(new AgentSubscriptionManager( this._clientId, () => this.nextClientSeq(), - msg => this._logService.warn(`[RemoteAgentHostProtocolClient] ${msg}`), + msg => this._logService.warn(`[AgentHostProtocolClient] ${msg}`), resource => this.subscribe(resource), resource => this.unsubscribe(resource), )); @@ -760,7 +758,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC if (error instanceof ProtocolError && error.code === AHP_CLIENT_CONNECTION_CLOSED) { throw error; } - this._logService.warn(`[RemoteAgentHostProtocolClient] Failed to restore subscription ${subscription.resource.toString()} after host restart: ${error instanceof Error ? error.message : String(error)}`); + this._logService.warn(`[AgentHostProtocolClient] Failed to restore subscription ${subscription.resource.toString()} after host restart: ${error instanceof Error ? error.message : String(error)}`); this._subscriptionManager.markSubscriptionsMissing([subscription.resource]); } })); diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts index b8f80e2668cc63..edb749fcac164c 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts @@ -32,7 +32,7 @@ import { type IRemoteAgentHostConnectionInfo, type IRemoteAgentHostEntry, } from '../common/remoteAgentHostService.js'; -import { RemoteAgentHostProtocolClient, AgentHostClientState } from './remoteAgentHostProtocolClient.js'; +import { AgentHostProtocolClient, AgentHostClientState } from './agentHostProtocolClient.js'; import { WebSocketClientTransport } from './webSocketClientTransport.js'; import { AGENT_HOST_LABEL_FORMATTER, AGENT_HOST_SCHEME, agentHostAuthority, normalizeRemoteAgentHostAddress } from '../common/agentHostUri.js'; import { PROTOCOL_VERSION } from '../common/state/protocol/version/registry.js'; @@ -44,7 +44,7 @@ const SSH_REMOTE_AGENT_HOSTS_STORAGE_KEY = 'remoteAgentHost.sshConnections'; /** Tracks a single remote connection through its lifecycle. */ interface IConnectionEntry { readonly store: DisposableStore; - readonly client: RemoteAgentHostProtocolClient; + readonly client: AgentHostProtocolClient; /** * Optional teardown for the shared-process tunnel that this entry's * transport is using (SSH or dev-tunnels). Tracked separately from @@ -340,7 +340,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo const store = new DisposableStore(); // Create a connection entry wrapping the pre-connected client - const protocolClient = connection as RemoteAgentHostProtocolClient; + const protocolClient = connection as AgentHostProtocolClient; store.add(protocolClient); const connEntry: IConnectionEntry = { store, client: protocolClient, transportDisposable, connected: RemoteAgentHostConnectionStatus.isConnected(status), status }; this._entries.set(address, connEntry); @@ -517,7 +517,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo const ahpLoggingEnabled = !!this._configurationService.getValue(AgentHostAhpJsonlLoggingSettingId); // Factory so the protocol client can replace the underlying transport // across transient drops and use the `reconnect` RPC to resume — see - // {@link RemoteAgentHostProtocolClient}. The store owns only the client; + // {@link AgentHostProtocolClient}. The store owns only the client; // individual transports are owned by the client itself. const transportFactory = () => this._instantiationService.createInstance( WebSocketClientTransport, @@ -527,7 +527,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo ? { logsHome: this._environmentService.logsHome, connectionId: address, transport: 'websocket' } : undefined, ); - const client = store.add(this._instantiationService.createInstance(RemoteAgentHostProtocolClient, address, transportFactory, undefined, undefined, this.clientInfo)); + const client = store.add(this._instantiationService.createInstance(AgentHostProtocolClient, address, transportFactory, undefined, undefined, this.clientInfo)); const entry: IConnectionEntry = { store, client, connected: false, status: RemoteAgentHostConnectionStatus.connecting }; this._entries.set(address, entry); diff --git a/src/vs/platform/agentHost/common/taskEventReplay.ts b/src/vs/platform/agentHost/common/taskEventReplay.ts index ff10148a56f3f5..14d04851e11b05 100644 --- a/src/vs/platform/agentHost/common/taskEventReplay.ts +++ b/src/vs/platform/agentHost/common/taskEventReplay.ts @@ -122,7 +122,7 @@ function parseActionEnvelope(value: unknown, eventIndex: number): ActionEnvelope requireNonEmptyString(rejectionReason, 'payload.data.rejectionReason', eventIndex); } - // The live path (`remoteAgentHostProtocolClient`) likewise forwards the wire envelope as-is; + // The live path (`agentHostProtocolClient`) likewise forwards the wire envelope as-is; // the protocol's `URI` is a string alias, so no revival is needed. return value as unknown as ActionEnvelope; } diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index 6d53f2868c5937..599e7c2f547c55 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -22,7 +22,7 @@ import { IInstantiationService } from '../../instantiation/common/instantiation. import { ILogService } from '../../log/common/log.js'; import { INotificationService } from '../../notification/common/notification.js'; import { AgentHostIpcChannelTransport } from '../browser/agentHostIpcChannelTransport.js'; -import { AgentHostClientState, RemoteAgentHostProtocolClient } from '../browser/remoteAgentHostProtocolClient.js'; +import { AgentHostClientState, AgentHostProtocolClient } from '../browser/agentHostProtocolClient.js'; import { AhpJsonlLogger } from '../common/ahpJsonlLogger.js'; import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, AgentHostClientByokLmChannel, NullAgentHostClientByokLmChannel } from '../common/agentHostClientByokLmChannel.js'; import { getAgentHostClientType } from '../common/agentHostClientInfo.js'; @@ -147,7 +147,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos private readonly _clientStore = this._register(new MutableDisposable()); private readonly _managementConnection = this._register(new LocalAgentHostManagementConnection()); private readonly _ahpLogger: AhpJsonlLogger | undefined; - private _protocolClient: RemoteAgentHostProtocolClient | undefined; + private _protocolClient: AgentHostProtocolClient | undefined; private _connectStarted = false; private _didAcquireInitialMessagePort = false; private _didConnectInitially = false; @@ -211,7 +211,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos (callback, timeoutMs) => disposableTimeout(callback, timeoutMs), )); this._protocolClient = this._register(this._instantiationService.createInstance( - RemoteAgentHostProtocolClient, + AgentHostProtocolClient, LOCAL_AGENT_HOST_RESOURCE_IDENTITY, () => this._createTransport(), undefined, @@ -322,7 +322,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos } } - private _requireClient(): RemoteAgentHostProtocolClient { + private _requireClient(): AgentHostProtocolClient { if (!this._protocolClient) { throw new Error('Local agent host is not connected.'); } diff --git a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts index 72337395c3afd1..0ee136cd8fa824 100644 --- a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts @@ -27,7 +27,7 @@ import type { AgentHostServerType } from '../common/agentHostEndpointRegistry.js import { IRemoteAgentHostLocationPreferenceService } from '../common/remoteAgentHostLocationPreference.js'; import { promptRemoteAgentHostLocationPreference } from '../common/remoteAgentHostLocationPreferenceDialog.js'; import { SSHRelayTransport } from './sshRelayTransport.js'; -import { RemoteAgentHostProtocolClient } from '../browser/remoteAgentHostProtocolClient.js'; +import { AgentHostProtocolClient } from '../browser/agentHostProtocolClient.js'; import { agentsWindowAgentHostClientInfo } from '../common/agentHostClientInfo.js'; import { PROTOCOL_VERSION } from '../common/state/protocol/version/registry.js'; import { @@ -72,7 +72,7 @@ export const ISSHRelayClientFactory = createDecorator('s export interface ISSHRelayClientFactory { readonly _serviceBrand: undefined; - createClient(mainService: ISSHRemoteAgentHostMainService, connectionId: string, address: string): RemoteAgentHostProtocolClient; + createClient(mainService: ISSHRemoteAgentHostMainService, connectionId: string, address: string): AgentHostProtocolClient; } export class SSHRelayClientFactory implements ISSHRelayClientFactory { @@ -84,14 +84,14 @@ export class SSHRelayClientFactory implements ISSHRelayClientFactory { @IEnvironmentService private readonly _environmentService: IEnvironmentService, ) { } - createClient(mainService: ISSHRemoteAgentHostMainService, connectionId: string, address: string): RemoteAgentHostProtocolClient { + createClient(mainService: ISSHRemoteAgentHostMainService, connectionId: string, address: string): AgentHostProtocolClient { const ahpLoggingEnabled = !!this._configurationService.getValue(AgentHostAhpJsonlLoggingSettingId); const logger = ahpLoggingEnabled ? this._instantiationService.createInstance( AhpJsonlLogger, { logsHome: this._environmentService.logsHome, connectionId, transport: 'ssh' }, ) : undefined; const transport = this._instantiationService.createInstance(SSHRelayTransport, connectionId, mainService, logger); - return this._instantiationService.createInstance(RemoteAgentHostProtocolClient, address, transport, undefined, undefined, agentsWindowAgentHostClientInfo); + return this._instantiationService.createInstance(AgentHostProtocolClient, address, transport, undefined, undefined, agentsWindowAgentHostClientInfo); } } @@ -417,7 +417,7 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA }); } - private _createRelayClient(result: { connectionId: string; address: string }): RemoteAgentHostProtocolClient { + private _createRelayClient(result: { connectionId: string; address: string }): AgentHostProtocolClient { return this._relayClientFactory.createClient(this._mainService, result.connectionId, result.address); } diff --git a/src/vs/platform/agentHost/electron-browser/wslRemoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/electron-browser/wslRemoteAgentHostServiceImpl.ts index 7a69c02e58540c..cfc03d07077a1c 100644 --- a/src/vs/platform/agentHost/electron-browser/wslRemoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/electron-browser/wslRemoteAgentHostServiceImpl.ts @@ -17,7 +17,7 @@ import { createDecorator, IInstantiationService } from '../../instantiation/comm import { AhpJsonlLogger } from '../common/ahpJsonlLogger.js'; import { AgentHostAhpJsonlLoggingSettingId } from '../common/agentService.js'; import { WSLRelayTransport } from './wslRelayTransport.js'; -import { RemoteAgentHostProtocolClient } from '../browser/remoteAgentHostProtocolClient.js'; +import { AgentHostProtocolClient } from '../browser/agentHostProtocolClient.js'; import { agentsWindowAgentHostClientInfo } from '../common/agentHostClientInfo.js'; import { IWSLRemoteAgentHostService, @@ -35,7 +35,7 @@ export const IWSLRelayClientFactory = createDecorator('w export interface IWSLRelayClientFactory { readonly _serviceBrand: undefined; - createClient(mainService: IWSLRemoteAgentHostMainService, connectionId: string, address: string): RemoteAgentHostProtocolClient; + createClient(mainService: IWSLRemoteAgentHostMainService, connectionId: string, address: string): AgentHostProtocolClient; } export class WSLRelayClientFactory implements IWSLRelayClientFactory { @@ -47,14 +47,14 @@ export class WSLRelayClientFactory implements IWSLRelayClientFactory { @IEnvironmentService private readonly _environmentService: IEnvironmentService, ) { } - createClient(mainService: IWSLRemoteAgentHostMainService, connectionId: string, address: string): RemoteAgentHostProtocolClient { + createClient(mainService: IWSLRemoteAgentHostMainService, connectionId: string, address: string): AgentHostProtocolClient { const ahpLoggingEnabled = !!this._configurationService.getValue(AgentHostAhpJsonlLoggingSettingId); const logger = ahpLoggingEnabled ? this._instantiationService.createInstance( AhpJsonlLogger, { logsHome: this._environmentService.logsHome, connectionId, transport: 'wsl' }, ) : undefined; const transport = this._instantiationService.createInstance(WSLRelayTransport, connectionId, mainService, logger); - return this._instantiationService.createInstance(RemoteAgentHostProtocolClient, address, transport, undefined, undefined, agentsWindowAgentHostClientInfo); + return this._instantiationService.createInstance(AgentHostProtocolClient, address, transport, undefined, undefined, agentsWindowAgentHostClientInfo); } } @@ -186,7 +186,7 @@ export class WSLRemoteAgentHostService extends Disposable implements IWSLRemoteA this._onDidChangeConnections.fire(); } - let protocolClient: RemoteAgentHostProtocolClient | undefined; + let protocolClient: AgentHostProtocolClient | undefined; let handle: WSLAgentHostConnectionHandle | undefined; let registeredHandle = false; try { diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts similarity index 98% rename from src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts rename to src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index 71c6ed558225b2..8bf5e6a4ef19c1 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -15,7 +15,7 @@ import { URI } from '../../../../base/common/uri.js'; import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; -import { AgentHostClientState, RemoteAgentHostProtocolClient } from '../../browser/remoteAgentHostProtocolClient.js'; +import { AgentHostClientState, AgentHostProtocolClient } from '../../browser/agentHostProtocolClient.js'; import { AgentHostPermissionMode, AgentHostResourceIdentity, AgentHostResourcePermissionError, IAgentHostResourceService, LOCAL_AGENT_HOST_RESOURCE_IDENTITY } from '../../common/agentHostResourceService.js'; import { buildAnnotationsUri } from '../../common/annotationsUri.js'; import { ConfigurationTarget, type IConfigurationValue } from '../../../configuration/common/configuration.js'; @@ -42,17 +42,17 @@ import { Registry } from '../../../registry/common/platform.js'; // configuration registry is a process-wide singleton, so a side-effect import // here would leak its registrations (and their `managedSettings` policies) into // every other suite in the run. -const SYNC_SETTING_A = 'test.remoteAgentHostProtocolClient.syncA'; +const SYNC_SETTING_A = 'test.agentHostProtocolClient.syncA'; const SYNC_CONFIG_KEY_A = 'testSyncValueA'; -const SYNC_SETTING_B = 'test.remoteAgentHostProtocolClient.syncB'; +const SYNC_SETTING_B = 'test.agentHostProtocolClient.syncB'; const SYNC_CONFIG_KEY_B = 'testSyncValueB'; -const SYNC_LOCAL_SETTING = 'test.remoteAgentHostProtocolClient.syncLocal'; +const SYNC_LOCAL_SETTING = 'test.agentHostProtocolClient.syncLocal'; const SYNC_LOCAL_CONFIG_KEY = 'testSyncLocal'; -const SYNC_AMBIENT_SETTING = 'test.remoteAgentHostProtocolClient.syncAmbient'; +const SYNC_AMBIENT_SETTING = 'test.agentHostProtocolClient.syncAmbient'; const SYNC_AMBIENT_CONFIG_KEY = 'testSyncAmbient'; const syncTestConfigurationNode = { - id: 'testRemoteAgentHostProtocolClientSync', + id: 'testAgentHostProtocolClientSync', type: 'object' as const, properties: { [SYNC_SETTING_A]: { @@ -248,7 +248,7 @@ class ManagedPermissionsConfigurationService extends TestConfigurationService { } } -suite('RemoteAgentHostProtocolClient', () => { +suite('AgentHostProtocolClient', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); @@ -315,16 +315,16 @@ suite('RemoteAgentHostProtocolClient', () => { }; } - function createClientForIdentity(identity: AgentHostResourceIdentity, transport = disposables.add(new TestProtocolTransport()), permissionService = createPermissionService(), loadEstimator?: { hasHighLoad(): boolean }, logService: ILogService = new NullLogService(), configurationService = new TestConfigurationService(), clientId?: string, clientInfo?: Implementation, telemetryService: ITelemetryService = NullTelemetryService): { client: RemoteAgentHostProtocolClient; transport: TestProtocolTransport; configurationService: TestConfigurationService } { - const client = disposables.add(new RemoteAgentHostProtocolClient(identity, transport, loadEstimator, clientId, clientInfo, logService, permissionService, configurationService, telemetryService)); + function createClientForIdentity(identity: AgentHostResourceIdentity, transport = disposables.add(new TestProtocolTransport()), permissionService = createPermissionService(), loadEstimator?: { hasHighLoad(): boolean }, logService: ILogService = new NullLogService(), configurationService = new TestConfigurationService(), clientId?: string, clientInfo?: Implementation, telemetryService: ITelemetryService = NullTelemetryService): { client: AgentHostProtocolClient; transport: TestProtocolTransport; configurationService: TestConfigurationService } { + const client = disposables.add(new AgentHostProtocolClient(identity, transport, loadEstimator, clientId, clientInfo, logService, permissionService, configurationService, telemetryService)); return { client, transport, configurationService }; } - function createClient(transport = disposables.add(new TestProtocolTransport()), permissionService = createPermissionService(), loadEstimator?: { hasHighLoad(): boolean }, logService: ILogService = new NullLogService(), configurationService = new TestConfigurationService(), clientId?: string, clientInfo?: Implementation): { client: RemoteAgentHostProtocolClient; transport: TestProtocolTransport; configurationService: TestConfigurationService } { + function createClient(transport = disposables.add(new TestProtocolTransport()), permissionService = createPermissionService(), loadEstimator?: { hasHighLoad(): boolean }, logService: ILogService = new NullLogService(), configurationService = new TestConfigurationService(), clientId?: string, clientInfo?: Implementation): { client: AgentHostProtocolClient; transport: TestProtocolTransport; configurationService: TestConfigurationService } { return createClientForIdentity('test.example:1234', transport, permissionService, loadEstimator, logService, configurationService, clientId, clientInfo); } - async function connectClient(client: RemoteAgentHostProtocolClient, transport: TestProtocolTransport): Promise { + async function connectClient(client: AgentHostProtocolClient, transport: TestProtocolTransport): Promise { const connectPromise = client.connect(); while (transport.sentMessages.length === 0) { await Promise.resolve(); @@ -1069,7 +1069,7 @@ suite('RemoteAgentHostProtocolClient', () => { test('forwards the actual telemetry service restriction during initialization and config sync', async () => { const transport = disposables.add(new TestProtocolTransport(AgentHostClientConnectionKind.RemoteExtensionHost)); const configurationService = new TestConfigurationService(); - const client = disposables.add(new RemoteAgentHostProtocolClient( + const client = disposables.add(new AgentHostProtocolClient( 'test.example:1234', transport, undefined, @@ -1937,7 +1937,7 @@ suite('RemoteAgentHostProtocolClient', () => { } /** Connect `client`, subscribe to `sessionUri`, and answer the `subscribe` request with an empty session snapshot. */ - async function subscribeToSession(client: RemoteAgentHostProtocolClient, transport: TestProtocolTransport, sessionUri: URI): Promise { + async function subscribeToSession(client: AgentHostProtocolClient, transport: TestProtocolTransport, sessionUri: URI): Promise { client.getSubscription(StateComponents.Session, sessionUri, 'test'); let subscribeReq: JsonRpcRequest | undefined; while (!subscribeReq) { @@ -2035,7 +2035,7 @@ suite('RemoteAgentHostProtocolClient', () => { } /** Wait until the client transitions into the {@link AgentHostClientState.Reconnecting} state. */ - async function waitForReconnecting(client: RemoteAgentHostProtocolClient): Promise { + async function waitForReconnecting(client: AgentHostProtocolClient): Promise { if (client.connectionState === AgentHostClientState.Reconnecting) { return; } @@ -2079,14 +2079,14 @@ suite('RemoteAgentHostProtocolClient', () => { * client plus a `transports` array recording each transport handed * out, so tests can drive handshake/reconnect interactions. */ - function createFactoryClient(permissionService = createPermissionService(), clientInfo?: Implementation, telemetryService: ITelemetryService = NullTelemetryService): { client: RemoteAgentHostProtocolClient; transports: TestClientProtocolTransport[] } { + function createFactoryClient(permissionService = createPermissionService(), clientInfo?: Implementation, telemetryService: ITelemetryService = NullTelemetryService): { client: AgentHostProtocolClient; transports: TestClientProtocolTransport[] } { const transports: TestClientProtocolTransport[] = []; const factory = () => { const t = disposables.add(new TestClientProtocolTransport()); transports.push(t); return t; }; - const client = disposables.add(new RemoteAgentHostProtocolClient( + const client = disposables.add(new AgentHostProtocolClient( 'test.example:1234', factory, undefined, undefined, clientInfo, new NullLogService(), permissionService, new TestConfigurationService(), telemetryService, )); return { client, transports }; diff --git a/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts index 4328908b56f063..a79b89fc9ac439 100644 --- a/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts @@ -19,7 +19,7 @@ import { INotificationService } from '../../../notification/common/notification. import { TestNotificationService } from '../../../notification/test/common/testNotificationService.js'; import { ITelemetryData } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryServiceShape } from '../../../telemetry/common/telemetryUtils.js'; -import { AgentHostClientState, RemoteAgentHostProtocolClient } from '../../browser/remoteAgentHostProtocolClient.js'; +import { AgentHostClientState, AgentHostProtocolClient } from '../../browser/agentHostProtocolClient.js'; import { isFatalAgentHostStartError, toFatalAgentHostStartError } from '../../common/agent.js'; import { AGENT_HOST_CLIENT_PROXY_CHANNEL } from '../../common/agentHostClientProxyChannel.js'; import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, AgentHostClientByokLmChannel } from '../../common/agentHostClientByokLmChannel.js'; @@ -143,7 +143,7 @@ suite('registerAgentHostClientChannels', () => { instantiationService.stub(IConfigurationService, new TestConfigurationService()); instantiationService.stub(IEnvironmentService, { logsHome: URI.file('/logs') } as Partial); instantiationService.stub(INotificationService, notifications); - instantiationService.stubInstance(RemoteAgentHostProtocolClient, protocolClient); + instantiationService.stubInstance(AgentHostProtocolClient, protocolClient); instantiationService.stubInstance(AgentHostStartupTelemetry, startupTelemetry); instantiationService.set(IInstantiationService, instantiationService); const service = disposables.add(instantiationService.createInstance(LocalAgentHostServiceClient, editorWindowAgentHostClientInfo)); diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts index c8a5702f776e15..1a0849bba46c25 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts @@ -193,7 +193,7 @@ suite('RemoteAgentHostService', () => { // Mock the instantiation service to capture created protocol clients. // `_connectTo` calls `createInstance` for `WebSocketClientTransport` - // and `RemoteAgentHostProtocolClient`. We only care about tracking + // and `AgentHostProtocolClient`. We only care about tracking // the protocol client; for the transport we return a no-op // disposable so the test can keep asserting on `createdClients.length`. const mockInstantiationService: Partial = { diff --git a/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts index b006226d67fd39..32718a3e725e0e 100644 --- a/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts @@ -43,7 +43,7 @@ import type { import type { IRelayMessage } from '../../common/relayTransport.js'; import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; import { ISSHRelayClientFactory, SSHRemoteAgentHostService } from '../../electron-browser/sshRemoteAgentHostServiceImpl.js'; -import { RemoteAgentHostProtocolClient } from '../../browser/remoteAgentHostProtocolClient.js'; +import { AgentHostProtocolClient } from '../../browser/agentHostProtocolClient.js'; /** * In-renderer mock of the shared-process SSH service. Exposes the same @@ -411,7 +411,7 @@ suite('SSHRemoteAgentHostService (renderer)', () => { const index = createdClients.length; createdClients.push(c); clientWaiters[index]?.complete(c); - return c as unknown as RemoteAgentHostProtocolClient; + return c as unknown as AgentHostProtocolClient; }, }); @@ -806,7 +806,7 @@ suite('SSHRemoteAgentHostService endpoint selection preference (renderer)', () = instantiationService.stub(IRemoteAgentHostService, disposables.add(new MockRemoteAgentHostService()) as Partial); instantiationService.stub(INotificationService, new CapturingNotificationService() as Partial); instantiationService.stub(ISSHRelayClientFactory, { - createClient: () => disposables.add(new MockProtocolClient()) as unknown as RemoteAgentHostProtocolClient, + createClient: () => disposables.add(new MockProtocolClient()) as unknown as AgentHostProtocolClient, }); locationPreferenceService = disposables.add(new TestRemoteAgentHostLocationPreferenceService()); @@ -1078,7 +1078,7 @@ suite('SSHRemoteAgentHostService host key verification (renderer)', () => { notificationService = new CapturingNotificationService(); instantiationService.stub(INotificationService, notificationService as Partial); instantiationService.stub(ISSHRelayClientFactory, { - createClient: () => disposables.add(new MockProtocolClient()) as unknown as RemoteAgentHostProtocolClient, + createClient: () => disposables.add(new MockProtocolClient()) as unknown as AgentHostProtocolClient, }); instantiationService.stub(IRemoteAgentHostLocationPreferenceService, disposables.add(new TestRemoteAgentHostLocationPreferenceService()) as Partial); instantiationService.stub(IProductService, { _serviceBrand: undefined, nameShort: 'Test Product' } as IProductService); diff --git a/src/vs/server/node/agentHostChannel.ts b/src/vs/server/node/agentHostChannel.ts index b26fc646dc28dd..322ed923f35ae3 100644 --- a/src/vs/server/node/agentHostChannel.ts +++ b/src/vs/server/node/agentHostChannel.ts @@ -10,7 +10,7 @@ // // The renderer-side counterpart is `AgentHostIpcChannelTransport` in // `src/vs/platform/agentHost/browser/`. Together they reuse the existing -// `RemoteAgentHostProtocolClient` over IPC instead of a raw WebSocket. +// `AgentHostProtocolClient` over IPC instead of a raw WebSocket. import { Emitter, Event } from '../../base/common/event.js'; import { Disposable, IDisposable } from '../../base/common/lifecycle.js'; diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts index c23ffa4a88d46f..a50c1faef98571 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts @@ -5,7 +5,7 @@ import { Emitter, Event } from '../../../../../base/common/event.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; -import { RemoteAgentHostProtocolClient } from '../../../../../platform/agentHost/browser/remoteAgentHostProtocolClient.js'; +import { AgentHostProtocolClient } from '../../../../../platform/agentHost/browser/agentHostProtocolClient.js'; import { agentsWindowAgentHostClientInfo } from '../../../../../platform/agentHost/common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind } from '../../../../../platform/agentHost/common/agentHostTelemetry.js'; import { IRemoteAgentHostLocationPreferenceService } from '../../../../../platform/agentHost/common/remoteAgentHostLocationPreference.js'; @@ -233,7 +233,7 @@ export class BrowserTunnelAgentHostService extends Disposable implements ITunnel } const transport = new BrowserTunnelConnectionTransport(result.connectionId, this._connector, this._logService); const protocolClient = this._instantiationService.createInstance( - RemoteAgentHostProtocolClient, result.address, transport, undefined, undefined, agentsWindowAgentHostClientInfo, + AgentHostProtocolClient, result.address, transport, undefined, undefined, agentsWindowAgentHostClientInfo, ); let status: RemoteAgentHostConnectionStatus = RemoteAgentHostConnectionStatus.connected; diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts index 1d83de5e3d6a5c..18695012b47f2e 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts @@ -8,7 +8,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Disposable, DisposableMap, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { timeout } from '../../../../../base/common/async.js'; import { IProtocolTransport } from '../../../../../platform/agentHost/common/state/sessionTransport.js'; -import { RemoteAgentHostProtocolClient } from '../../../../../platform/agentHost/browser/remoteAgentHostProtocolClient.js'; +import { AgentHostProtocolClient } from '../../../../../platform/agentHost/browser/agentHostProtocolClient.js'; import { editorWindowAgentHostClientInfo } from '../../../../../platform/agentHost/common/agentHostClientInfo.js'; import { WebPubSubRelayTransport } from '../../../../../platform/agentHost/browser/webPubSubRelayTransport.js'; import { GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../../../../platform/agentHost/common/agentService.js'; @@ -39,7 +39,7 @@ const MAX_WAKING_RETRIES = 20; * * Mirrors {@link WebTunnelAgentHostService}: establishes a connection * out-of-band (mint creds → open a {@link WebPubSubRelayTransport} → drive the - * AHP handshake) and hands the pre-connected {@link RemoteAgentHostProtocolClient} + * AHP handshake) and hands the pre-connected {@link AgentHostProtocolClient} * to {@link IRemoteAgentHostService.addManagedConnection}, so the existing * remote-agent-host contribution surfaces it as a native, interactive session. */ @@ -126,7 +126,7 @@ export class CloudSandboxAgentHostService extends Disposable implements ICloudSa // Mission Control mints the client id and binds the relay lane to it, so the AHP identity // must match or the host rejects requests on that lane. const protocolClient = this._instantiationService.createInstance( - RemoteAgentHostProtocolClient, address, transportFactory, undefined, clientToken.client_id, editorWindowAgentHostClientInfo, + AgentHostProtocolClient, address, transportFactory, undefined, clientToken.client_id, editorWindowAgentHostClientInfo, ); let status: RemoteAgentHostConnectionStatus = RemoteAgentHostConnectionStatus.connected; diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 757def74cb2b49..de9a269bef1e33 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -11,7 +11,7 @@ import { StopWatch } from '../../../../../base/common/stopwatch.js'; import { URI } from '../../../../../base/common/uri.js'; import * as nls from '../../../../../nls.js'; import { agentHostAuthority } from '../../../../../platform/agentHost/common/agentHostUri.js'; -import { RemoteAgentHostProtocolClient } from '../../../../../platform/agentHost/browser/remoteAgentHostProtocolClient.js'; +import { AgentHostProtocolClient } from '../../../../../platform/agentHost/browser/agentHostProtocolClient.js'; import { type AgentProvider, type AuthenticateParams, type AuthenticateResult } from '../../../../../platform/agentHost/common/agent.js'; import { type IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostEntry, IRemoteAgentHostService, type IRemoteAgentHostSSHConnection, RemoteAgentHostAutoConnectSettingId, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, getEntryAddress } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; @@ -834,13 +834,13 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc // Bridge the host's OTLP logs channel into a dedicated workbench // Output channel (`Agent Host (${name})`). Concrete clients // returned by `IRemoteAgentHostService.getConnection` are always - // `RemoteAgentHostProtocolClient` instances — `IAgentConnection` + // `AgentHostProtocolClient` instances — `IAgentConnection` // erases the concrete type, so cast here at the integration // point rather than polluting that interface with OTLP-specific // surface. store.add(this._instantiationService.createInstance( RemoteAgentHostLogForwarder, - connection as RemoteAgentHostProtocolClient, + connection as AgentHostProtocolClient, address, name || address, )); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostLogForwarder.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostLogForwarder.ts index 263e199f8574a4..9d981d80391fce 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostLogForwarder.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostLogForwarder.ts @@ -9,13 +9,13 @@ import { UriTemplate } from '../../../../../base/common/uriTemplate.js'; import { ILogService, LogLevel } from '../../../../../platform/log/common/log.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { iterateOtlpLogRecords, logLevelToOtlpLevelName, severityNumberToLogLevel, type IOtlpLogRecord, type OtlpLogLevelName } from '../../../../../platform/agentHost/common/otlp/otlpLogEmitter.js'; -import { AgentHostClientState, type RemoteAgentHostProtocolClient } from '../../../../../platform/agentHost/browser/remoteAgentHostProtocolClient.js'; +import { AgentHostClientState, type AgentHostProtocolClient } from '../../../../../platform/agentHost/browser/agentHostProtocolClient.js'; import { remoteAgentHostLogOutputChannelId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { formatHostBuildInfo, readHostBuildInfo } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { Extensions, IOutputChannel, IOutputChannelRegistry, IOutputService } from '../../../../../workbench/services/output/common/output.js'; /** - * Forwarder that bridges a connected {@link RemoteAgentHostProtocolClient}'s + * Forwarder that bridges a connected {@link AgentHostProtocolClient}'s * OTLP logs channel into the workbench's Output panel. * * For each {@link AgentHostClientState.Connected} transition (initial @@ -53,7 +53,7 @@ export class RemoteAgentHostLogForwarder extends Disposable { private _currentLevel: OtlpLogLevelName | undefined; constructor( - private readonly _client: RemoteAgentHostProtocolClient, + private readonly _client: AgentHostProtocolClient, address: string, displayName: string, @IOutputService private readonly _outputService: IOutputService, diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.ts index ebbd06df282087..d5370a60da3308 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.ts @@ -5,7 +5,7 @@ import { Emitter, Event } from '../../../../../base/common/event.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; -import { RemoteAgentHostProtocolClient } from '../../../../../platform/agentHost/browser/remoteAgentHostProtocolClient.js'; +import { AgentHostProtocolClient } from '../../../../../platform/agentHost/browser/agentHostProtocolClient.js'; import { agentsWindowAgentHostClientInfo } from '../../../../../platform/agentHost/common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind } from '../../../../../platform/agentHost/common/agentHostTelemetry.js'; import { deriveConnectionToken } from '../../../../../platform/agentHost/common/tunnelAgentHostConnector.js'; @@ -160,7 +160,7 @@ export class WebTunnelAgentHostService extends Disposable implements ITunnelAgen const transport = new TunnelConnectionTransport(connection, this._logService); const address = `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`; const protocolClient = this._instantiationService.createInstance( - RemoteAgentHostProtocolClient, address, transport, undefined, undefined, agentsWindowAgentHostClientInfo, + AgentHostProtocolClient, address, transport, undefined, undefined, agentsWindowAgentHostClientInfo, ); // Keep an incompatible handshake from tearing down the relay: the @@ -278,7 +278,7 @@ export class WebTunnelAgentHostService extends Disposable implements ITunnelAgen /** * Adapts an {@link ITunnelConnection} (embedder-provided) into an - * {@link IProtocolTransport} for {@link RemoteAgentHostProtocolClient}. + * {@link IProtocolTransport} for {@link AgentHostProtocolClient}. * * The connection is already established by the time this adapter is created, * so there is no `connect()` method — the protocol client skips that step. diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts index 5368d6749291cc..d9ca6d4d318d33 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts @@ -44,7 +44,7 @@ import { selectGatewayFallbackAfterRejection, TunnelFailoverTracker, } from '../../../../../platform/agentHost/common/tunnelGatewaySelection.js'; -import { RemoteAgentHostProtocolClient } from '../../../../../platform/agentHost/browser/remoteAgentHostProtocolClient.js'; +import { AgentHostProtocolClient } from '../../../../../platform/agentHost/browser/agentHostProtocolClient.js'; import { agentsWindowAgentHostClientInfo } from '../../../../../platform/agentHost/common/agentHostClientInfo.js'; import { TunnelRelayTransport } from '../../../../../platform/agentHost/electron-browser/tunnelRelayTransport.js'; @@ -203,7 +203,7 @@ export class TunnelAgentHostService extends Disposable implements ITunnelAgentHo // Build relay transport + protocol client. If construction itself // fails (rare — would mean the AHP logger or transport ctor threw) // tear the just-opened main-side relay down before propagating. - let protocolClient: RemoteAgentHostProtocolClient; + let protocolClient: AgentHostProtocolClient; try { const ahpLoggingEnabled = !!this._configurationService.getValue(AgentHostAhpJsonlLoggingSettingId); const logger = ahpLoggingEnabled ? this._instantiationService.createInstance( @@ -212,7 +212,7 @@ export class TunnelAgentHostService extends Disposable implements ITunnelAgentHo ) : undefined; const transport = new TunnelRelayTransport(result.connectionId, this._mainService, logger); protocolClient = this._instantiationService.createInstance( - RemoteAgentHostProtocolClient, result.address, transport, undefined, undefined, agentsWindowAgentHostClientInfo, + AgentHostProtocolClient, result.address, transport, undefined, undefined, agentsWindowAgentHostClientInfo, ); } catch (err) { this._logService.error(`${LOG_PREFIX} Connection setup failed`, err); diff --git a/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts b/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts index ce69995d90ea2f..25887e098dddc8 100644 --- a/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts +++ b/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts @@ -5,7 +5,7 @@ // Renderer-side `IAgentHostService` that talks to the agent host running on // the connected remote, via the remote agent's existing IPC pipe. The -// underlying `RemoteAgentHostProtocolClient` is created eagerly so callers +// underlying `AgentHostProtocolClient` is created eagerly so callers // can subscribe to `rootState` etc. immediately; the actual transport // connection (and AHP handshake) happens asynchronously in the background. @@ -19,7 +19,7 @@ import { AgentHostIpcChannels, IAgentCreateChatOptions, IAgentCreateSessionConfi import { IAgentHostEnablementService } from '../../../../platform/agentHost/common/agentHostEnablementService.js'; import { AgentHostIpcChannelTransport } from '../../../../platform/agentHost/browser/agentHostIpcChannelTransport.js'; import { AgentHostClientConnectionKind } from '../../../../platform/agentHost/common/agentHostTelemetry.js'; -import { AgentHostClientState, RemoteAgentHostProtocolClient } from '../../../../platform/agentHost/browser/remoteAgentHostProtocolClient.js'; +import { AgentHostClientState, AgentHostProtocolClient } from '../../../../platform/agentHost/browser/agentHostProtocolClient.js'; import type { IActiveSubscriptionInfo, IAgentSubscription } from '../../../../platform/agentHost/common/state/agentSubscription.js'; import type { CompletionsParams, CompletionsResult, ContentEncoding, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../../../platform/agentHost/common/state/protocol/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../../../../platform/agentHost/common/state/protocol/channels-changeset/commands.js'; @@ -58,7 +58,7 @@ export class EditorRemoteAgentHostServiceClient extends Disposable implements IA readonly authenticationPending: IObservable = this._authenticationPending; private _authenticationSettled = false; - private readonly _protocolClient: RemoteAgentHostProtocolClient | undefined; + private readonly _protocolClient: AgentHostProtocolClient | undefined; private readonly _noopRootState: IAgentSubscription = { value: undefined, verifiedValue: undefined, @@ -93,7 +93,7 @@ export class EditorRemoteAgentHostServiceClient extends Disposable implements IA const createTransport = () => new AgentHostIpcChannelTransport(connection.getChannel(AgentHostIpcChannels.RemoteProxy), undefined, AgentHostClientConnectionKind.RemoteExtensionHost); const address = `vscode-remote://${connection.remoteAuthority}`; const clientInfo = environmentService.isSessionsWindow ? agentsWindowAgentHostClientInfo : editorWindowAgentHostClientInfo; - this._protocolClient = this._register(instantiationService.createInstance(RemoteAgentHostProtocolClient, address, createTransport, undefined, undefined, clientInfo)); + this._protocolClient = this._register(instantiationService.createInstance(AgentHostProtocolClient, address, createTransport, undefined, undefined, clientInfo)); // Resources this client hands out (e.g. debug-log artifacts) are stamped with the // address-derived authority, so register it for reads. The ambient `local` authority // registered elsewhere covers a different URI namespace. @@ -126,7 +126,7 @@ export class EditorRemoteAgentHostServiceClient extends Disposable implements IA await this._protocolClient.connect(); } - private _requireClient(): RemoteAgentHostProtocolClient { + private _requireClient(): AgentHostProtocolClient { if (!this._protocolClient) { throw new Error('Remote agent host is not enabled or no remote connection is available.'); } diff --git a/src/vs/workbench/services/agentHost/test/browser/editorRemoteAgentHostServiceClient.test.ts b/src/vs/workbench/services/agentHost/test/browser/editorRemoteAgentHostServiceClient.test.ts index 9127f5b77e9c97..800ea20614a771 100644 --- a/src/vs/workbench/services/agentHost/test/browser/editorRemoteAgentHostServiceClient.test.ts +++ b/src/vs/workbench/services/agentHost/test/browser/editorRemoteAgentHostServiceClient.test.ts @@ -13,7 +13,7 @@ import type { IChannel, IServerChannel } from '../../../../../base/parts/ipc/com import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { IAgentHostEnablementService } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { IWorkbenchEnvironmentService } from '../../../environment/common/environmentService.js'; -import { AgentHostClientState, RemoteAgentHostProtocolClient } from '../../../../../platform/agentHost/browser/remoteAgentHostProtocolClient.js'; +import { AgentHostClientState, AgentHostProtocolClient } from '../../../../../platform/agentHost/browser/agentHostProtocolClient.js'; import { editorWindowAgentHostClientInfo } from '../../../../../platform/agentHost/common/agentHostClientInfo.js'; import { agentHostAuthority } from '../../../../../platform/agentHost/common/agentHostUri.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -123,7 +123,7 @@ suite('EditorRemoteAgentHostServiceClient', () => { ensureSyncedCustomizationProvider: () => { }, }], ))); - instantiationService.stubInstance(RemoteAgentHostProtocolClient, protocolClient); + instantiationService.stubInstance(AgentHostProtocolClient, protocolClient); instantiationService.set(IInstantiationService, instantiationService); const createInstanceSpy = sinon.spy(instantiationService, 'createInstance'); @@ -139,7 +139,7 @@ suite('EditorRemoteAgentHostServiceClient', () => { onDidChangeConnectionState.fire(AgentHostClientState.Connected); await started; - const protocolClientCall = createInstanceSpy.getCalls().find(call => call.args[0] === RemoteAgentHostProtocolClient); + const protocolClientCall = createInstanceSpy.getCalls().find(call => call.args[0] === AgentHostProtocolClient); assert.deepStrictEqual({ beforeReady, afterReady: connectCalls, From eb95feb5aed51a23184a3192006ea3ee2e38a7c0 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:51:40 -0700 Subject: [PATCH 10/21] agentHost: fix legacy Copilot CLI migration issues (opening, worktrees, archived state) (#331896) * Handle archive session and add telemetry * agentHost: keep migrated legacy CLI sessions matching by project root * worktree fix and log update * feedback updates * Feedback updates * test fix --- src/vs/platform/agentHost/common/agent.ts | 28 ++ .../agentHost/common/state/sessionState.ts | 38 ++ .../agentHost/node/agentHostStateManager.ts | 3 +- .../platform/agentHost/node/agentService.ts | 197 +++++++--- .../agentHost/node/copilot/copilotAgent.ts | 200 ++++++++-- .../node/shared/worktreeIsolation.ts | 11 + .../test/node/agentHostStateManager.test.ts | 16 + .../agentHost/test/node/agentService.test.ts | 114 +++++- .../agentHost/test/node/copilotAgent.test.ts | 346 +++++++++++++++++- .../browser/localAgentHostSessionsProvider.ts | 4 +- .../agentHost/agentHostLegacyMigration.ts | 67 +++- .../agentHost/agentHostSessionListStore.ts | 32 +- .../agentSessions/agentSessionsOpener.ts | 47 ++- .../widgetHosts/editor/chatEditorInput.ts | 4 + .../agentHostChatContribution.test.ts | 107 +++++- .../agentHostLegacyMigration.test.ts | 39 +- .../agentSessions/agentSessionsOpener.test.ts | 115 +++++- .../editor/chatEditorInput.test.ts | 3 + 18 files changed, 1257 insertions(+), 114 deletions(-) diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index 4e66e6de8b2bfa..66f1500b352962 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -1026,6 +1026,30 @@ export interface IActiveClient { customizations: readonly ClientPluginCustomization[]; } +/** Worktree identity a predecessor recorded for a chat, so a missing checkout can be recreated on resume. */ +export interface IAgentAdoptedWorktree { + readonly branchName: string; + readonly baseBranch: string | undefined; + readonly worktreePath: URI; + readonly repositoryRoot: URI; +} + +/** + * Why an adoption attempt ended the way it did. Reported in logs and telemetry so + * a session that did not migrate can be diagnosed without reproducing it. + */ +export type AgentChatAdoptionReason = + /** Already has Agent Host metadata — native or previously adopted. */ + | 'alreadyNative' + /** Not a legacy extension-host Copilot CLI chat (e.g. standalone CLI, Local agent). */ + | 'notLegacyChat' + /** A legacy chat whose recorded working directory no longer exists and could not be resolved. */ + | 'workingDirectoryMissing' + /** A legacy chat whose extension-host marker could not be re-read, leaving its archived state unknown. */ + | 'markerUnavailable' + /** Newly adopted. */ + | 'adopted'; + /** Outcome of attempting to adopt a legacy provider-native chat. */ export interface IAgentChatAdoptionResult { /** Whether this call newly seeded Agent Host metadata. */ @@ -1034,6 +1058,10 @@ export interface IAgentChatAdoptionResult { readonly eligible: boolean; /** Whether the chat already has Agent Host metadata, i.e. it is ours regardless of adoption. */ readonly native?: boolean; + /** Set when the adopted chat ran in a worktree that no longer exists and can be recreated. */ + readonly worktree?: IAgentAdoptedWorktree; + /** Diagnostic reason behind {@link adopted}. */ + readonly reason?: AgentChatAdoptionReason; } /** diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 3cf0ec824c6c95..bb012f799c26e5 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -1904,6 +1904,44 @@ export function withSessionEhcliAdoptable(meta: SessionSummaryMeta | undefined): return { ...meta, [SESSION_META_EHCLI_ADOPTABLE_KEY]: true }; } +/** + * Session-DB key recording that a session was adopted from a legacy Copilot CLI + * (extension-host) chat. Unlike {@link SESSION_META_EHCLI_ADOPTABLE_KEY} this + * survives adoption, so consumers can keep treating the session as legacy for + * the rest of its life — a migrated session must not change how it is listed. + */ +export const AH_META_EHCLI_ADOPTED_DB_KEY = 'agentHost.ehcliAdopted'; + +/** `_meta` key mirroring {@link AH_META_EHCLI_ADOPTED_DB_KEY} on a summary. */ +export const SESSION_META_EHCLI_ADOPTED_KEY = 'ehcliAdopted'; + +/** Whether the session was adopted from a legacy Copilot CLI chat. */ +export function readSessionEhcliAdopted(meta: SessionSummaryMeta | undefined): boolean { + return meta?.[SESSION_META_EHCLI_ADOPTED_KEY] === true; +} + +/** Returns a copy of `meta` with the adopted-legacy provenance marker updated. */ +export function withSessionEhcliAdopted(meta: SessionSummaryMeta | undefined, adopted: boolean): SessionSummaryMeta | undefined { + const next: { [key: string]: unknown } = { ...meta }; + if (adopted) { + next[SESSION_META_EHCLI_ADOPTED_KEY] = true; + } else { + delete next[SESSION_META_EHCLI_ADOPTED_KEY]; + } + return Object.keys(next).length > 0 ? next : undefined; +} + +/** + * Whether a session should be matched against a workspace folder by its project + * (repository) root in addition to its working directories. True only for + * legacy Copilot CLI sessions, which run out of a worktree outside the + * repository; agent-host-native worktree sessions are deliberately not surfaced + * in a window opened on their source repository. + */ +export function readSessionMatchesByProjectRoot(meta: SessionSummaryMeta | undefined): boolean { + return readSessionEhcliAdoptable(meta) || readSessionEhcliAdopted(meta); +} + // ---- RootState _meta accessors --------------------------------------------- /** diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 73c882cffcf6f2..bf2863810ce395 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -941,7 +941,8 @@ export class AgentHostStateManager extends Disposable { // adoptable-legacy session) is already known to clients with a different // summary. Emit the delta so they update the entry in place — clearing the // adoptable marker — rather than dropping the just-opened session on the - // next list reconcile. Never-announced sessions record the summary silently. + // next list reconcile. Never-announced sessions record the summary silently + // and stay hidden until {@link setSessionSummaryPublished}. if (this._summaryNotifier.isAnnounced(key)) { this._summaryNotifier.flush(key); } else { diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 4b5c72a560cc31..6722bd8ed8d797 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -20,7 +20,7 @@ import { hasKey } from '../../../base/common/types.js'; import { localize } from '../../../nls.js'; import { FileChangeType, FileOperationResult, IFileChange, IFileService, toFileOperationResult, type FileChangesEvent } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentProvider, AgentSession, AgentSignal, IAgent, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateChatSideChatSelection, IAgentCreateChatSideChatSource, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDiscoveredChat, IAgentHostAuthTokenRequest, IAgentHostNetworkEndpoint, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentChatAdoptionResult, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, IMcpNotification, SubagentChatSignal, subagentChatTitle } from '../common/agent.js'; +import { AgentProvider, AgentSession, AgentSignal, IAgent, type IAgentAdoptedWorktree, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateChatSideChatSelection, IAgentCreateChatSideChatSource, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDiscoveredChat, IAgentHostAuthTokenRequest, IAgentHostNetworkEndpoint, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentChatAdoptionResult, type AgentChatAdoptionReason, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, IMcpNotification, SubagentChatSignal, subagentChatTitle } from '../common/agent.js'; import { AgentHostSessionReleaseGraceMsEnvVar, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentService } from '../common/agentService.js'; import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; import { IAgentEditAttributionService, ICancelEditAttributionFlushParams, ICommitEditAttributionFlushParams, IEditAttributionFlushResult, IPrepareEditAttributionFlushParams, IPreparedEditAttributionFlush, parseEditAttributionResource } from '../common/fileEditAttribution.js'; @@ -35,7 +35,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type Annotation, type AnnotationEntry, type AnnotationsState, type ChatOrigin, type Customization, type Message, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js'; -import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_ORCHESTRATION_DB_KEY, readSessionSpawnDepth, parseSessionOrchestration, withSessionSpawnDepth, withSessionOrchestration, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn, type UsageInfo, chatStorageUri, hasReportedUsage } from '../common/state/sessionState.js'; +import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_ORCHESTRATION_DB_KEY, readSessionSpawnDepth, parseSessionOrchestration, withSessionSpawnDepth, withSessionOrchestration, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn, type UsageInfo, chatStorageUri, hasReportedUsage } from '../common/state/sessionState.js'; import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../common/meta/agentSnapshotAttachmentMeta.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js'; @@ -116,6 +116,7 @@ type AgentHostLegacyMigrationEvent = { hasWorktree: boolean; workingDirectoryCount: number; errorMessage: string | undefined; + reason: string; }; type AgentHostLegacyMigrationClassification = { @@ -128,6 +129,7 @@ type AgentHostLegacyMigrationClassification = { hasWorktree: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the migrated session ran in a pre-existing git worktree that was bridged during adoption.' }; workingDirectoryCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of working directories associated with the migrated session.' }; errorMessage: { classification: 'CallstackOrException'; purpose: 'PerformanceAndHealth'; comment: 'Error message when the migration failed; absent for migrated/skipped outcomes.' }; + reason: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Why adoption ended as it did: adopted, alreadyNative, notLegacyChat, workingDirectoryMissing, or unknown. Separates a skipped session that was never ours from one whose working directory vanished, which need different fixes.' }; owner: 'vijayupadya'; comment: 'Tracks one-time adopt-on-open migration of legacy extension-host Copilot CLI sessions into the agent host to measure attempt, success, failure, and skipped rates.'; }; @@ -1579,7 +1581,9 @@ export class AgentService extends Disposable implements IAgentService { */ private async _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise { - const existing = new Map((await this._listRegisteredSessions()).map(session => [session.session.toString(), session.external])); + // Keys only: discovery arrives in batches, and the full listing re-runs the + // per-row provenance migration for every registered session each time. + const registeredKeys = new Set(await this._sessionRegistry.listSessionKeys()); const discoveryLimiter = new Limiter(4); let suppressed = 0; let skippedAsStale = 0; @@ -1591,8 +1595,7 @@ export class AgentService extends Disposable implements IAgentService { const session = sessionMetadata.session; try { // Matching registry entries need no per-session I/O. - const known = existing.get(session.toString()); - if (known !== undefined) { + if (registeredKeys.has(session.toString())) { alreadyRegistered++; return false; } @@ -1611,10 +1614,12 @@ export class AgentService extends Disposable implements IAgentService { ); if (registered) { registryChanged = true; - if (external && existing.get(session.toString()) !== true) { + // Only reached for a session the registry did not already hold, so its + // external read state has never been seeded. + if (external) { await this._initializeExternalSessionReadState(session); } - existing.set(session.toString(), external); + registeredKeys.add(session.toString()); if (external && !readSessionEhcliAdoptable(sessionMetadata._meta)) { registeredExternal = true; } else { @@ -1657,10 +1662,14 @@ export class AgentService extends Disposable implements IAgentService { const existing = new Map((await this._listRegisteredSessions()).map(session => [session.session.toString(), session.external])); const migrationLimiter = new Limiter(4); const identities = await Promise.all(sessions.map(s => migrationLimiter.queue(async (): Promise => { - if (isSubagentSession(s.session.toString()) || await this._isChatBacking(s.session)) { + if (isSubagentSession(s.session.toString())) { + return undefined; + } + const facts = await this._readSessionRegistrationFacts(s.session); + if (facts.chatBacking) { return undefined; } - const external = await this._isExternalProviderChat(s.session); + const external = !facts.hostCreated; return { session: s.session, provider: provider.id, startTime: s.startTime, external, source: external ? 'discovery' : 'restore' }; }))); let registeredExternal = false; @@ -1716,6 +1725,32 @@ export class AgentService extends Disposable implements IAgentService { } } + /** + * Both facts registry backfill needs about a session, from a single database + * open — it asks for both per session, and a large catalogue makes the second + * open the dominant cost of the pass. + */ + private async _readSessionRegistrationFacts(session: URI): Promise<{ readonly chatBacking: boolean; readonly hostCreated: boolean }> { + if (this._unpersistedChatBackings.has(session.toString())) { + return { chatBacking: true, hostCreated: false }; + } + // A read failure is deliberately not caught: registering on a guess would + // durably mark a host-created session external, whereas failing the pass + // leaves it unmarked and retried. + const ref = await this._sessionDataService.tryOpenDatabase(session); + if (!ref) { + return { chatBacking: false, hostCreated: false }; + } + try { + const metadata = await ref.object.getMetadataObject({ [CHAT_BACKING_METADATA_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true }); + // The workspace-less marker is written when the host creates a session, + // so its presence is what identifies a host-created session. + return { chatBacking: !!metadata[CHAT_BACKING_METADATA_KEY], hostCreated: metadata[AH_META_WORKSPACELESS_DB_KEY] !== undefined }; + } finally { + ref.dispose(); + } + } + private async _migrateRegisteredSession(entry: IStoredRegisteredSession): Promise { if (entry.external !== undefined) { return undefined; @@ -1880,8 +1915,8 @@ export class AgentService extends Disposable implements IAgentService { const sessionStr = s.session.toString(); const changesetKeys = this._changesetCoordinator.getListMetadataKeys(sessionStr); const metadataKeys: Record = changesetKeys - ? { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_ORCHESTRATION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS, ...changesetKeys } - : { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_ORCHESTRATION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS }; + ? { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_ORCHESTRATION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS, ...changesetKeys } + : { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_ORCHESTRATION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS }; const m = await ref.object.getMetadataObject(metadataKeys); // This session is an internal peer-chat backing (e.g. a // Claude peer chat's SDK session, enumerated by the agent's @@ -1935,6 +1970,9 @@ export class AgentService extends Disposable implements IAgentService { if (m[AH_META_WORKSPACELESS_DB_KEY] !== undefined) { updated = { ...updated, _meta: withSessionWorkspaceless(updated._meta, m[AH_META_WORKSPACELESS_DB_KEY] === 'true') }; } + if (m[AH_META_EHCLI_ADOPTED_DB_KEY] !== undefined) { + updated = { ...updated, _meta: withSessionEhcliAdopted(updated._meta, m[AH_META_EHCLI_ADOPTED_DB_KEY] === 'true') }; + } const multiRoot = parseSessionMultiRootMetadata(m[SESSION_META_MULTI_ROOT_KEY]); if (multiRoot) { updated = { ...updated, _meta: withSessionMultiRootMetadata(updated._meta, multiRoot) }; @@ -2188,6 +2226,9 @@ export class AgentService extends Disposable implements IAgentService { /** Adoptable keys retracted in this window; re-enabling also recovers earlier ones from the catalog. */ private readonly _retractedAdoptableKeys = new Set(); + /** Serializes adoptable re-surfacing, kept off the external-reconciliation chain. */ + private _adoptableResurface: Promise = Promise.resolve(); + private _isMigrateLegacyEnabled(): boolean { return this._configurationService.getRootValue(platformRootSchema, AgentHostMigrateLegacyCopilotCliEnabledConfigKey) === true; } @@ -2205,8 +2246,11 @@ export class AgentService extends Disposable implements IAgentService { this._lastMigrateLegacyEnabled = enabled; if (enabled) { // Discovery skips chats already in the registry, so it cannot re-announce - // what disabling retracted — restore them from the registry instead. - this._sessionListReconciliation = this._sessionListReconciliation + // what disabling retracted. `_retractedAdoptableKeys` is process-local, so + // after a restart the catalog is the only record of them. Runs on its own + // chain: this scan on `_sessionListReconciliation` would stall external + // session reconciliation behind it. + this._adoptableResurface = this._adoptableResurface .then(() => this._resurfaceAdoptableSessions()) .catch(error => this._logService.warn('[AgentService] Re-surfacing adoptable legacy sessions failed', error)); return; @@ -2226,9 +2270,10 @@ export class AgentService extends Disposable implements IAgentService { } /** - * Re-announces adoptable-legacy sessions that are not currently surfaced — - * those this window retracted, plus any the catalog still reports as adoptable, - * so rows retracted before a restart are recovered too. + * A key is forgotten only once it is confirmed surfaced, so a failed listing — + * or migration being disabled again before this runs — leaves it restorable. + * Covers both what this process retracted and what the catalog still reports as + * adoptable, so rows retracted before a restart are recovered too. */ private async _resurfaceAdoptableSessions(): Promise { if (!this._isMigrateLegacyEnabled()) { @@ -2236,10 +2281,6 @@ export class AgentService extends Disposable implements IAgentService { } for (const metadata of await this.listSessions()) { const key = metadata.session.toString(); - if (this._announcedSurfacedKeys.has(key) || this._stateManager.getSessionState(key)) { - this._retractedAdoptableKeys.delete(key); - continue; - } if (!this._retractedAdoptableKeys.has(key) && !readSessionEhcliAdoptable(metadata._meta)) { continue; } @@ -4730,7 +4771,7 @@ export class AgentService extends Disposable implements IAgentService { provider: string, outcome: AgentHostLegacyMigrationEvent['outcome'], startTime: number, - extra: { turnCount?: number; hasProject?: boolean; hasWorktree?: boolean; workingDirectoryCount?: number; errorMessage?: string }, + extra: { turnCount?: number; hasProject?: boolean; hasWorktree?: boolean; workingDirectoryCount?: number; errorMessage?: string; reason?: AgentChatAdoptionReason }, ): void { this._telemetryService.publicLog2('agentHost.legacyCopilotCliMigration', { provider, @@ -4742,6 +4783,7 @@ export class AgentService extends Disposable implements IAgentService { hasWorktree: extra.hasWorktree ?? false, workingDirectoryCount: extra.workingDirectoryCount ?? 0, errorMessage: extra.errorMessage, + reason: extra.reason ?? 'unknown', }); } @@ -4762,22 +4804,25 @@ export class AgentService extends Disposable implements IAgentService { if (await this._sessionRegistry.isTombstoned(session)) { throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session was explicitly deleted: ${sessionStr}`); } - // Wait for the provider's one-time catalog migration before reading - // metadata, mirroring `listSessions`, so restore does not misread an - // unwarmed catalog as a missing session (#331648). A catalog that stays - // unavailable is non-fatal: fall through, but remember it so a resulting - // miss is classified as unavailable rather than absent. - let catalogReadable = true; - try { - await this._awaitInitialProviderMigrationForProvider(agent); - } catch (err) { - catalogReadable = false; - this._logService.warn(`[AgentService] restore: initial catalog migration for provider ${agent.id} failed; a metadata miss will be reported as unavailable, not missing`, err); - } - // Re-check after the (possibly lengthy) wait so a delete that landed meanwhile is not resurrected. - if (await this._sessionRegistry.isTombstoned(session)) { - throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session was explicitly deleted: ${sessionStr}`); - } + // Warming the provider catalogue is O(catalogue) — ~48s on a large + // `~/.copilot` — and the only decision that needs it is whether a metadata + // miss is authoritative (#331648). Defer it so a session that resolves from + // its own per-session lookup never pays for the whole catalogue. + let catalogReadable: Promise | undefined; + const awaitCatalogReadable = () => catalogReadable ??= (async () => { + let readable = true; + try { + await this._awaitInitialProviderMigrationForProvider(agent); + } catch (err) { + readable = false; + this._logService.warn(`[AgentService] restore: initial catalog migration for provider ${agent.id} failed; a metadata miss will be reported as unavailable, not missing`, err); + } + // This wait can be lengthy, so re-check that a delete has not landed meanwhile. + if (await this._sessionRegistry.isTombstoned(session)) { + throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session was explicitly deleted: ${sessionStr}`); + } + return readable; + })(); const registeredSession = (await this._listRegisteredSessions()).find(entry => entry.session.toString() === sessionStr); const external = registeredSession?.external ?? false; this._logService.trace(`[AgentService] restore: catalog and registry resolved for ${sessionStr} (registered=${!!registeredSession}, external=${external})`); @@ -4805,25 +4850,49 @@ export class AgentService extends Disposable implements IAgentService { // created, hidden while `showExternalSessions` is `none`) would be // materialized here and thereby claimed away from the extension host's list. if (!registeredSession && migrateLegacyEnabled && agent.ensureChatAdopted && !adoption.eligible && !adoption.native) { + this._logService.info(`[AgentService] restore refused for unregistered ${sessionStr}: not an adoptable legacy chat (reason=${adoption.reason ?? 'unknown'})`); throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session is not an adoptable legacy chat: ${sessionStr}`); } // From here the whole restore is wrapped so `migrated` is reported only // after every required step succeeds, and any failure after a successful // adoption is surfaced as a migration failure. + let registeredAfterAdoption = !!registeredSession; try { - const facts = await this._restoreSessionState(agent, session, sessionStr, adopted, external, registeredSession?.source ?? 'restore', catalogReadable, !!registeredSession); + // Adoption has already claimed the chat on disk, which is what stops the + // extension host listing it. Register it before restoring so a later restore + // failure (e.g. a worktree whose branch is gone) leaves a session that + // reports an error like any native one, instead of one that exists in no + // list at all. A registration that cannot be made durable fails the + // migration: continuing would leave exactly the orphan this prevents. + if (adopted && !registeredSession) { + await this._retryRegistryMutation( + () => this._sessionRegistry.register(session, { provider: agent.id, startTime: Date.now(), source: 'restore' }, { checkTombstone: true }), + `adoption registration for ${sessionStr}`, + ); + registeredAfterAdoption = true; + this._invalidateSessionList(); + } + const facts = await this._restoreSessionState(agent, session, sessionStr, adopted, external, registeredSession?.source ?? 'restore', awaitCatalogReadable, !!registeredSession, adoption.worktree); await this._restoreAnnotations(session); if (adopted) { - this._reportLegacyMigration(agent.id, 'migrated', migrationStartTime, facts); + // Discovery never surfaced this chat when migration was enabled after + // startup, so clients have no entry for it and a restore alone stays + // silent. Publishing announces it with the adopted summary. + this._stateManager.setSessionSummaryPublished(sessionStr, true); + this._reportLegacyMigration(agent.id, 'migrated', migrationStartTime, { ...facts, reason: adoption.reason }); } else if (adoption.eligible) { // Migrate setting on and a genuine legacy candidate, but not adopted // this pass (e.g. its on-disk working directory could not be resolved). - this._reportLegacyMigration(agent.id, 'skipped', migrationStartTime, { hasProject: facts.hasProject, workingDirectoryCount: facts.workingDirectoryCount }); + this._logService.info(`[AgentService] legacy session ${sessionStr} was a migration candidate but was not adopted (reason=${adoption.reason ?? 'unknown'})`); + this._reportLegacyMigration(agent.id, 'skipped', migrationStartTime, { hasProject: facts.hasProject, workingDirectoryCount: facts.workingDirectoryCount, reason: adoption.reason }); } } catch (err) { if (adopted) { - this._reportLegacyMigration(agent.id, 'failed', migrationStartTime, { errorMessage: toErrorMessage(err) }); + this._logService.error(registeredAfterAdoption + ? `[AgentService] legacy session ${sessionStr} was adopted but its restore failed; it is registered so it surfaces with an error rather than disappearing` + : `[AgentService] legacy session ${sessionStr} was adopted but could not be registered; the extension host no longer lists it, so it will not appear until the next successful restore`, err); + this._reportLegacyMigration(agent.id, 'failed', migrationStartTime, { errorMessage: toErrorMessage(err), reason: adoption.reason }); } throw err; } @@ -4909,17 +4978,26 @@ export class AgentService extends Disposable implements IAgentService { * Returns the facts used for migration telemetry; throws if any required step * fails so the caller can report the outcome accurately. */ - private async _restoreSessionState(agent: IAgent, session: URI, sessionStr: string, adopted: boolean, external: boolean, registrationSource: IRegisteredSession['source'], catalogReadable: boolean, sessionKnownToRegistry: boolean): Promise<{ turnCount: number; hasProject: boolean; hasWorktree: boolean; workingDirectoryCount: number }> { + private async _restoreSessionState(agent: IAgent, session: URI, sessionStr: string, adopted: boolean, external: boolean, registrationSource: IRegisteredSession['source'], awaitCatalogReadable: () => Promise, sessionKnownToRegistry: boolean, adoptionWorktree: IAgentAdoptedWorktree | undefined): Promise<{ turnCount: number; hasProject: boolean; hasWorktree: boolean; workingDirectoryCount: number }> { this._logService.trace(`[AgentService] restore: reading provider metadata for ${sessionStr}`); let meta = await this._getSessionMetadataForRestore(agent, session, external); if (!meta) { - // Authoritative absence only when the catalog was readable this run and - // the registry has no record of the session; a miss for a known - // (registered) session, or while the catalog was unavailable, is - // transient — e.g. a provider whose SDK is not downloaded yet (#331648). - throw catalogReadable && !sessionKnownToRegistry - ? new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found on backend: ${sessionStr}`) - : new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Provider ${agent.id} could not describe ${sessionStr} yet`); + // Only a miss needs the catalogue: it decides whether the session is + // genuinely absent, and warming it may enumerate thousands of sessions. + const catalogReadable = await awaitCatalogReadable(); + meta = await this._getSessionMetadataForRestore(agent, session, external); + // The registry is backfilled by that same pass, so re-read it before + // concluding the session is unknown. + const knownToRegistry = sessionKnownToRegistry || (await this._listRegisteredSessions()).some(entry => entry.session.toString() === sessionStr); + if (!meta) { + // Authoritative absence only when the catalog was readable this run and + // the registry has no record of the session; a miss for a known + // (registered) session, or while the catalog was unavailable, is + // transient — e.g. a provider whose SDK is not downloaded yet (#331648). + throw catalogReadable && !knownToRegistry + ? new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found on backend: ${sessionStr}`) + : new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Provider ${agent.id} could not describe ${sessionStr} yet`); + } } this._logService.trace(`[AgentService] restore: provider metadata resolved for ${sessionStr}`); @@ -4930,8 +5008,23 @@ export class AgentService extends Disposable implements IAgentService { // worktree-isolated sessions. No-op for folder / primary-checkout cwds. let adoptedWorktree = false; if (adopted && this._worktree) { + // The predecessor recorded this worktree but its checkout is gone, so it + // cannot be probed; seed the same metadata a native session persists at + // creation and let resume recreate it. + if (adoptionWorktree) { + try { + await this._worktree.recordAdoptedWorktreeMetadata(session, adoptionWorktree); + adoptedWorktree = true; + const worktreeProject = await this._worktree.resolveWorktreeProject(session); + if (worktreeProject) { + meta = { ...meta, project: worktreeProject }; + } + } catch (err) { + this._logService.warn(`[AgentService] adopt: recording recorded worktree metadata failed for ${sessionStr}`, err); + } + } const adoptedWorkingDirectory = meta.workingDirectories?.[0]; - if (adoptedWorkingDirectory) { + if (!adoptedWorktree && adoptedWorkingDirectory) { try { if (await this._worktree.adoptExistingWorktreeMetadata(session, adoptedWorkingDirectory)) { adoptedWorktree = true; @@ -5016,6 +5109,7 @@ export class AgentService extends Disposable implements IAgentService { [AH_META_IS_DONE_DB_KEY]: true, configValues: true, [AH_META_WORKSPACELESS_DB_KEY]: true, + [AH_META_EHCLI_ADOPTED_DB_KEY]: true, [AH_META_ORCHESTRATION_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, @@ -5077,6 +5171,9 @@ export class AgentService extends Disposable implements IAgentService { if (m[AH_META_WORKSPACELESS_DB_KEY] !== undefined) { sessionMetadata = withSessionWorkspaceless(sessionMetadata, m[AH_META_WORKSPACELESS_DB_KEY] === 'true'); } + if (m[AH_META_EHCLI_ADOPTED_DB_KEY] !== undefined) { + sessionMetadata = withSessionEhcliAdopted(sessionMetadata, m[AH_META_EHCLI_ADOPTED_DB_KEY] === 'true'); + } const orchestration = parseSessionOrchestration(m[AH_META_ORCHESTRATION_DB_KEY]); if (orchestration) { sessionMetadata = withSessionOrchestration(sessionMetadata, orchestration); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 7e539c16c40cd2..772c3ed7d5cd63 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -14,7 +14,7 @@ import { CancellationError, getErrorMessage } from '../../../../base/common/erro import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, type IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; -import { FileAccess } from '../../../../base/common/network.js'; +import { FileAccess, Schemas } from '../../../../base/common/network.js'; import { formatTokenCount } from '../../../../base/common/numbers.js'; import { equals } from '../../../../base/common/objects.js'; import { autorun, observableValue, observableValueOpts, type IObservable, type ISettableObservable } from '../../../../base/common/observable.js'; @@ -43,7 +43,7 @@ import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabl import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { decodeProviderData, encodeProviderData, type IPersistedChat } from '../agentChatBackings.js'; import { prepareSideChatPrompt, sliceSideChatTurns } from '../agentPeerChats.js'; -import { AgentChatOperationContext, AgentSession, AgentSignal, AuthenticateParams, IActiveClient, IAgent, IAgentChatAdoptionResult, IAgentChatConfigCompletionsParams, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentChats, IAgentLegacyChat, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentDescriptor, IAgentDiscoveredChat, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentKnownSessionsFilter, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveChatConfigParams, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, SubagentChatSignal, resolveAgentChatContext, resolveAgentHostCustomizations, resolveAgentHostInstructions, resolveSubagentChatParent } from '../../common/agent.js'; +import { AgentChatOperationContext, AgentSession, AgentSignal, AuthenticateParams, IActiveClient, IAgent, IAgentChatAdoptionResult, type IAgentAdoptedWorktree, IAgentChatConfigCompletionsParams, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentChats, IAgentLegacyChat, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentDescriptor, IAgentDiscoveredChat, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentKnownSessionsFilter, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveChatConfigParams, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, SubagentChatSignal, resolveAgentChatContext, resolveAgentHostCustomizations, resolveAgentHostInstructions, resolveSubagentChatParent } from '../../common/agent.js'; import { getReasoningEffortDescription, getReasoningEffortLabel, resolveDefaultReasoningEffort } from '../../common/reasoningEffort.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; @@ -57,7 +57,7 @@ import type { ErrorInfo } from '../../common/state/protocol/common/state.js'; import { ProtectedResourceMetadata, type AgentSelection, type ChildCustomizationType, type ConfigPropertySchema, type ConfigSchema, type CustomizationEnablement, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, AuthRequiredReason, type AuthRequiredParams, type SessionAction } from '../../common/state/sessionActions.js'; import { areAdditionalWorkingDirectoriesEqual } from '../../common/state/sessionWorkingDirectories.js'; -import { AgentCustomization, CustomizationLoadStatus, CustomizationType, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, buildDefaultChatUri, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_READ_DB_KEY, isDefaultChatUri, withSessionEhcliAdoptable, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type ISessionFolderPickerDecision, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ChatInputAnswer, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; +import { AgentCustomization, CustomizationLoadStatus, CustomizationType, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, buildDefaultChatUri, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_READ_DB_KEY, isDefaultChatUri, withSessionEhcliAdoptable, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type ISessionFolderPickerDecision, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ChatInputAnswer, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; import { getByokLmAgentModelId, resolveByokLmEnablement } from '../../common/agentHostByokLm.js'; import { isCustomizationEnabled } from '../../common/customizationEnablement.js'; import { ActiveClientToolSet, structuralToolsEqual } from '../activeClientState.js'; @@ -577,10 +577,12 @@ const EXTENSION_HOST_CLI_MARKER_FILE = 'vscode.metadata.json'; interface IExtensionHostCliMarker { readonly origin?: string; readonly customTitle?: string; + /** Whether the user archived the session in the extension host list. */ + readonly archived?: boolean; /** Folder-mode repository root recorded by the extension host. */ readonly repositoryProperties?: { readonly repositoryPath?: string }; /** Worktree-mode checkout; `worktreePath` is the directory the session ran in. */ - readonly worktreeProperties?: { readonly worktreePath?: string; readonly repositoryPath?: string }; + readonly worktreeProperties?: { readonly worktreePath?: string; readonly repositoryPath?: string; readonly branchName?: string; readonly baseBranchName?: string }; readonly workspaceFolder?: { readonly folderPath?: string }; } @@ -628,6 +630,15 @@ function extensionHostCliWorkingDirectoryPaths(marker: IExtensionHostCliMarker | ].filter((path): path is string => typeof path === 'string' && path.length > 0); } +/** + * The local repository root the extension host recorded for a chat. Survives a + * deleted worktree checkout, unlike resolving git from the working directory. + */ +function extensionHostCliRepositoryPath(marker: IExtensionHostCliMarker | undefined): string | undefined { + const path = marker?.worktreeProperties?.repositoryPath ?? marker?.repositoryProperties?.repositoryPath; + return typeof path === 'string' && path.length > 0 ? path : undefined; +} + /** * Shape of the extension-host Copilot CLI `vscode.requests.metadata.json` * sidecar written next to a session's SDK event log. Only the fields adoption @@ -2418,6 +2429,7 @@ export class CopilotAgent extends Disposable implements IAgent { let outsideImportWindow = 0; let withoutRepository = 0; let suppressedAdoptable = 0; + let suppressedArchived = 0; let failed = 0; let discovered = 0; let external = 0; @@ -2433,6 +2445,13 @@ export class CopilotAgent extends Disposable implements IAgent { suppressedAdoptable++; return undefined; } + // A chat the user archived in the extension host list stays archived: + // surfacing it here would resurface everything they filed away. It is + // still adoptable once unarchived there. + if (adoptable && await this._isExtensionHostCliSessionArchived(s.sessionId)) { + suppressedArchived++; + return undefined; + } // A legacy chat the SDK reports without a cwd is still reachable: the // extension host records its own directory in the marker, and that is // the only source once the extension is retired. @@ -2465,7 +2484,10 @@ export class CopilotAgent extends Disposable implements IAgent { modifiedTime, // Always key the project off the resolved working directory: a worktree // session's context repository/gitRoot would resolve to the repo root. - project: await this._resolveSessionProject({ ...s.context, cwd: workingDirectory.fsPath }, projectLimiter, projectByContext), + project: await this._localProject( + await this._resolveSessionProject({ ...s.context, cwd: workingDirectory.fsPath }, projectLimiter, projectByContext), + adoptable ? s.sessionId : undefined, + ), summary: s.summary, workingDirectories: [workingDirectory], _meta: adoptable ? withSessionEhcliAdoptable(undefined) : undefined, @@ -2489,7 +2511,7 @@ export class CopilotAgent extends Disposable implements IAgent { publish(chats); } } - this._logService.info(`[Copilot] Chat discovery: ${sessions.length} SDK session(s) -> ${external} external, ${discovered - external} adoptable legacy extension-host, ${suppressedAdoptable} suppressed adoptable legacy extension-host, ${known} already known to Agent Host, ${withoutWorkingDirectory} without a working directory, ${unsupportedClientName} with unsupported or missing client name, ${outsideImportWindow} outside the import window, ${withoutRepository} without repository metadata, ${failed} failed to classify (adopt legacy extension-host chats: ${emitAdoptable})`); + this._logService.info(`[Copilot] Chat discovery: ${sessions.length} SDK session(s) -> ${external} external, ${discovered - external} adoptable legacy extension-host, ${suppressedAdoptable} suppressed adoptable legacy extension-host, ${suppressedArchived} suppressed archived legacy extension-host, ${known} already known to Agent Host, ${withoutWorkingDirectory} without a working directory, ${unsupportedClientName} with unsupported or missing client name, ${outsideImportWindow} outside the import window, ${withoutRepository} without repository metadata, ${failed} failed to classify (adopt legacy extension-host chats: ${emitAdoptable})`); return true; } @@ -3075,12 +3097,50 @@ export class CopilotAgent extends Disposable implements IAgent { return isExtensionHostCliMarker(await this._readExtensionHostCliMarker(sessionId)); } + /** Reads the marker from disk, bypassing the cache, for its mutable fields. */ + private async _readExtensionHostCliMarkerUncached(sessionId: string): Promise { + try { + const marker = parseExtensionHostCliMarker(await fs.readFile(this._extensionHostCliSidecarPath(sessionId, EXTENSION_HOST_CLI_MARKER_FILE), 'utf8')); + if (marker) { + this._extensionHostCliMarkerCache.set(sessionId, Promise.resolve(marker)); + } + return marker; + } catch { + return undefined; + } + } + /** Reads a legacy extension-host Copilot CLI custom title, if present. */ private async _readExtensionHostCliCustomTitle(sessionId: string): Promise { const title = (await this._readExtensionHostCliMarker(sessionId))?.customTitle; return typeof title === 'string' && title.trim() ? title : undefined; } + /** + * Whether the user archived this session in the extension host list, or + * `undefined` when the current state cannot be established (unreadable or + * malformed marker, or one that no longer identifies a VS Code legacy chat). + * Callers that would commit to the state must not treat that as unarchived. + */ + private async _isExtensionHostCliSessionArchived(sessionId: string): Promise { + // Archive state is toggled in the extension host while this agent runs, so it + // cannot be served from the marker cache, which memoizes successful reads. + const marker = await this._readExtensionHostCliMarkerUncached(sessionId); + if (!isExtensionHostCliMarker(marker)) { + return undefined; + } + return marker?.archived === true; + } + + /** Whether `path` is a directory that still exists on disk. */ + private async _isExistingDirectory(path: string): Promise { + try { + return (await fs.stat(path)).isDirectory(); + } catch { + return false; + } + } + /** * Working directory recorded in the extension host's own marker, used when the * SDK reports no `workingDirectory` for a legacy chat. The extension host @@ -3091,17 +3151,61 @@ export class CopilotAgent extends Disposable implements IAgent { // Adoption is durable and one-way, so never persist a recorded path that no // longer exists (a deleted worktree is the common case). for (const candidate of extensionHostCliWorkingDirectoryPaths(await this._readExtensionHostCliMarker(sessionId))) { - try { - if ((await fs.stat(candidate)).isDirectory()) { - return URI.file(candidate); - } - } catch { - // Missing or unreadable; fall through to the next candidate. + if (await this._isExistingDirectory(candidate)) { + return URI.file(candidate); } } return undefined; } + /** + * Worktree identity the extension host recorded, when its checkout is gone but + * the repository remains. Resume recreates the worktree from this, matching how + * a natively worktree-isolated session recovers. + */ + private async _extensionHostCliAdoptedWorktree(sessionId: string): Promise { + const worktree = (await this._readExtensionHostCliMarker(sessionId))?.worktreeProperties; + if (!worktree?.worktreePath || !worktree.repositoryPath || !worktree.branchName) { + return undefined; + } + if (await this._isExistingDirectory(worktree.worktreePath) || !(await this._isExistingDirectory(worktree.repositoryPath))) { + return undefined; + } + return { + branchName: worktree.branchName, + baseBranch: worktree.baseBranchName, + worktreePath: URI.file(worktree.worktreePath), + repositoryRoot: URI.file(worktree.repositoryPath), + }; + } + + /** + * Records the durable adopted-legacy marker on a session adopted by a build + * that predates it. Without this those sessions keep the extension-host marker + * but no provenance, so a worktree one stays filtered out of the window opened + * on its repository. Keyed off the marker, so it never claims a native session. + */ + private async _backfillAdoptedLegacyMarker(session: URI, sessionId: string): Promise { + const ref = await this._sessionDataService.tryOpenDatabase(session); + if (!ref) { + return; + } + try { + if (await ref.object.getMetadata(AH_META_EHCLI_ADOPTED_DB_KEY) !== undefined) { + return; + } + if (!(await this._isExtensionHostCliSession(sessionId))) { + return; + } + await ref.object.setMetadata(AH_META_EHCLI_ADOPTED_DB_KEY, 'true'); + this._logService.info(`[Copilot] Backfilled the adopted-legacy marker for ${sessionId}, migrated before it was recorded`); + } catch (err) { + this._logService.warn(`[Copilot] Failed to backfill the adopted-legacy marker for ${sessionId}`, err); + } finally { + ref.dispose(); + } + } + /** Adopts a legacy extension-host Copilot CLI session in place when it is eligible on disk. */ async ensureChatAdopted(chat: URI, context: URI | IAgentChatContext): Promise { const session = resolveAgentChatContext(context, chat).configurationResource; @@ -3114,30 +3218,54 @@ export class CopilotAgent extends Disposable implements IAgent { // existence — to avoid falsely treating an empty DB as migrated. const existing = await this._readStoredSessionMetadata(session); if (existing?.workingDirectory) { - return { adopted: false, eligible: false, native: true }; // already native / adopted + await this._backfillAdoptedLegacyMarker(session, sessionId); + this._logService.trace(`[Copilot] Adoption skipped for ${sessionId}: already has Agent Host metadata (cwd=${existing.workingDirectory.fsPath})`); + return { adopted: false, eligible: false, native: true, reason: 'alreadyNative' }; } // Only migrate legacy EH Copilot CLI sessions — never other Copilot SDK // sessions (standalone CLI, Local agent, …) that share `~/.copilot`. if (!(await this._isExtensionHostCliSession(sessionId))) { - return { adopted: false, eligible: false }; + this._logService.info(`[Copilot] Adoption declined for ${sessionId}: not a legacy extension-host Copilot CLI chat (no VS Code marker in its SDK session directory)`); + return { adopted: false, eligible: false, reason: 'notLegacyChat' }; } const client = await this._ensureClient(); const sdkMetadata = await client.getSessionMetadata(sessionId).catch(() => undefined); - const workingDirectory = (typeof sdkMetadata?.context?.workingDirectory === 'string' ? URI.file(sdkMetadata.context.workingDirectory) : undefined) + // The SDK reports the directory recorded when the session ran, which may since + // have been deleted (a removed worktree). Adopting it anyway commits the claim + // and then fails to resume, leaving the session in neither list. + const sdkWorkingDirectory = typeof sdkMetadata?.context?.workingDirectory === 'string' ? sdkMetadata.context.workingDirectory : undefined; + // A deleted worktree is recoverable the same way a native session recovers + // one: keep it as the working directory and let resume recreate it from the + // recorded branch. + const adoptedWorktree = await this._extensionHostCliAdoptedWorktree(sessionId); + const workingDirectory = adoptedWorktree?.worktreePath + ?? (sdkWorkingDirectory && await this._isExistingDirectory(sdkWorkingDirectory) ? URI.file(sdkWorkingDirectory) : undefined) ?? await this._extensionHostCliWorkingDirectory(sessionId); if (!workingDirectory) { // An eligible legacy session whose on-disk working directory could not // be resolved: a genuine migration candidate that did not migrate. - return { adopted: false, eligible: true }; + this._logService.warn(`[Copilot] Adoption skipped for ${sessionId}: no usable working directory (sdk='${sdkWorkingDirectory ?? '(none)'}' exists=${sdkWorkingDirectory ? await this._isExistingDirectory(sdkWorkingDirectory) : false}, no recorded worktree, no marker fallback). The session stays on the legacy provider.`); + return { adopted: false, eligible: true, reason: 'workingDirectoryMissing' }; } - this._logService.info(`[Copilot] Adopting legacy session ${sessionId} in place (reusing on-disk events.jsonl)`); + this._logService.info(`[Copilot] Adopting legacy session ${sessionId} in place (reusing on-disk events.jsonl): cwd=${workingDirectory.fsPath}${adoptedWorktree ? ` worktree=${adoptedWorktree.worktreePath.fsPath} branch=${adoptedWorktree.branchName} base=${adoptedWorktree.baseBranch ?? '(none)'} repo=${adoptedWorktree.repositoryRoot.fsPath} (checkout missing, will be recreated on resume)` : ''}`); // Resolve the project from the SDK-derived cwd (authoritative) — the // caller may not have supplied a working directory (e.g. the chat // editor), so we cannot trust a hint. - const project = await projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService); + const project = await this._localProject( + await projectFromCopilotContext({ cwd: (adoptedWorktree?.repositoryRoot ?? workingDirectory).fsPath }, this._gitService), + sessionId, + ); // Carry over the user-chosen session name (EH `customTitle`) so the // adopted session keeps its title instead of regenerating one. const customTitle = await this._readExtensionHostCliCustomTitle(sessionId); + const archived = await this._isExtensionHostCliSessionArchived(sessionId); + if (archived === undefined) { + // Adoption commits the archived state, and the extension host stops listing + // the chat once it does. Guessing `false` here would resurface a session the + // user had filed away, so leave it for the next open instead. + this._logService.warn(`[Copilot] Adoption skipped for ${sessionId}: its extension-host marker could not be re-read, so the archived state is unknown`); + return { adopted: false, eligible: true, reason: 'markerUnavailable' }; + } // Seed VS Code-layer metadata only — the SDK event log on disk is // untouched. Writing `agentSessionData//session.db` here // is also what makes the legacy extension-host Copilot CLI list stop @@ -3145,9 +3273,10 @@ export class CopilotAgent extends Disposable implements IAgent { // `isolation: 'folder'` keeps the session in place in the reused cwd — // a git repo would otherwise default to worktree and show a spurious // "Creating worktree…". - await this._storeSessionMetadata(session, undefined, workingDirectory, [workingDirectory], workingDirectory, project, project !== undefined, { [SessionConfigKey.Isolation]: 'folder' }, customTitle, /* markRead */ true); + await this._storeSessionMetadata(session, undefined, workingDirectory, [workingDirectory], workingDirectory, project, project !== undefined, { [SessionConfigKey.Isolation]: 'folder' }, customTitle, /* markRead */ true, archived, /* ehcliAdopted */ true); await this._adoptLegacyTurnUsage(session, sessionId); - return { adopted: true, eligible: true }; + this._logService.info(`[Copilot] Adopted legacy session ${sessionId}: project=${project ? project.uri.fsPath : '(unresolved)'} archived=${archived} customTitle=${customTitle !== undefined} worktreeBridged=${!!adoptedWorktree}`); + return { adopted: true, eligible: true, reason: 'adopted', ...(adoptedWorktree ? { worktree: adoptedWorktree } : {}) }; }); } @@ -4751,7 +4880,7 @@ export class CopilotAgent extends Disposable implements IAgent { } - private async _storeSessionMetadata(session: URI, model: ModelSelection | undefined, workingDirectory: URI | undefined, workingDirectories: readonly URI[] | undefined, customizationDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined, configValues?: Record, customTitle?: string, markRead?: boolean): Promise { + private async _storeSessionMetadata(session: URI, model: ModelSelection | undefined, workingDirectory: URI | undefined, workingDirectories: readonly URI[] | undefined, customizationDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined, configValues?: Record, customTitle?: string, markRead?: boolean, archived?: boolean, ehcliAdopted?: boolean): Promise { const dbRef = this._sessionDataService.openDatabase(session); const db = dbRef.object; try { @@ -4763,6 +4892,16 @@ export class CopilotAgent extends Disposable implements IAgent { if (markRead) { work.push(db.setMetadata(AH_META_IS_READ_DB_KEY, 'true')); } + // Archiving is user-curated state; losing it on adoption would resurface + // everything the user filed away in the extension host list. + if (archived) { + work.push(db.setMetadata(AH_META_IS_ARCHIVED_DB_KEY, 'true')); + } + // Outlives the transient `ehcliAdoptable` summary marker so the session + // keeps being listed like the legacy session it was migrated from. + if (ehcliAdopted) { + work.push(db.setMetadata(AH_META_EHCLI_ADOPTED_DB_KEY, 'true')); + } if (workingDirectory) { work.push(db.setMetadata(CopilotAgent._META_CWD, workingDirectory.toString())); } @@ -4921,6 +5060,25 @@ export class CopilotAgent extends Disposable implements IAgent { await this._storeSessionMetadata(session, undefined, undefined, undefined, undefined, project, true); } + /** + * Git resolution runs in the session's working directory, so a legacy session + * whose worktree checkout was deleted falls back to the remote (e.g. + * `https://github.com/owner/repo`). That is not a location on disk, so the + * session could never be matched to the repository folder a window has open. + * The extension host recorded the local repository root — prefer it. + */ + private async _localProject(project: IAgentSessionProjectInfo | undefined, adoptableSessionId: string | undefined): Promise { + if (project?.uri.scheme === Schemas.file || adoptableSessionId === undefined) { + return project; + } + const repositoryPath = extensionHostCliRepositoryPath(await this._readExtensionHostCliMarker(adoptableSessionId)); + if (!repositoryPath) { + return project; + } + const uri = URI.file(repositoryPath); + return { uri, displayName: resourceBasename(uri) || project?.displayName || uri.toString() }; + } + private _resolveSessionProject(context: ICopilotSessionContext | undefined, limiter: Limiter, projectByContext: Map>): Promise { const key = this._projectContextKey(context); if (!key) { diff --git a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts index 2e1c9aef4a0da0..82eae8c1c7a31d 100644 --- a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts +++ b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts @@ -957,6 +957,17 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI return true; } + /** + * Records worktree identity supplied by a predecessor for an adopted session whose + * checkout is gone, so resume recreates it exactly like a native worktree session. + * Values come from the predecessor's own record rather than probing the (missing) + * directory, which is what {@link adoptExistingWorktreeMetadata} requires. + */ + async recordAdoptedWorktreeMetadata(sessionUri: URI, metadata: { readonly branchName: string; readonly baseBranch: string | undefined; readonly worktreePath: URI; readonly repositoryRoot: URI }): Promise { + this._logService.info(`[${this._logLabel}:${AgentSession.id(sessionUri)}] Recorded adopted worktree metadata: worktree='${metadata.worktreePath.fsPath}' branch='${metadata.branchName}' base='${metadata.baseBranch ?? '(none)'}' repo='${metadata.repositoryRoot.fsPath}'`); + await this._writeWorktreeMetadata(sessionUri, metadata); + } + /** * Records repository identity for an externally-owned linked worktree without taking ownership of its lifecycle. */ diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 25608e1d0cbabf..5092d017a0a12e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -713,6 +713,22 @@ suite('AgentHostStateManager', () => { assert.strictEqual(readSessionEhcliAdoptable(changed[0].changes._meta), false); }); + test('publishing a restored session announces it to clients that never saw it', () => { + // A legacy chat adopted after startup was never surfaced by discovery, so + // restore records it silently and clients have no entry. Publishing is what + // makes an adopted session appear instead of existing only on the host. + manager.restoreSession(makeSessionSummary(), []); + const notifications: INotification[] = []; + disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); + + manager.setSessionSummaryPublished(sessionUri, true); + + assert.deepStrictEqual( + notifications.filter(n => n.type === NotificationType.SessionAdded).map(n => (n as { summary: { resource: string } }).summary.resource), + [sessionUri], + ); + }); + suite('unused-draft tracking', () => { test('reports draft status by origin, addressable by session or chat URI', () => { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 9b5136b6f4b05e..aa5226380b3269 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -41,7 +41,7 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AgentMergeConfigKey, readAgentMergeSessionState } from '../../common/agentMerge.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope, NotificationType } from '../../common/state/sessionActions.js'; -import { AH_META_IS_READ_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_ORCHESTRATION_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isDefaultChatUri, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionOrchestration, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionOrchestration, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; +import { AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_ORCHESTRATION_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isDefaultChatUri, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionOrchestration, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionOrchestration, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; import { ChatInteractivity, type MessageAttachment } from '../../common/state/protocol/state.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { IProductService } from '../../../product/common/productService.js'; @@ -4966,6 +4966,25 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual(sessions[0]._meta, { 'vscode.external': true, workspaceless: true }); }); + test('listSessions overlays the adopted-legacy marker so a migrated session keeps its legacy listing', async () => { + const db = new TestSessionDatabase(); + await db.setMetadata(AH_META_EHCLI_ADOPTED_DB_KEY, 'true'); + const sessionId = 'test-session-ehcli-adopted'; + const sessionUri = AgentSession.uri('copilot', sessionId); + const agent = new MockAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + (agent as unknown as { _sessions: Map })._sessions.set(sessionId, sessionUri); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + svc.registerProvider(agent); + + const sessions = await svc.listSessions(); + assert.deepStrictEqual( + { count: sessions.length, adopted: readSessionEhcliAdopted(sessions[0]?._meta) }, + { count: 1, adopted: true }, + ); + }); + test('listSessions restores persisted multi-root metadata', async () => { const db = new TestSessionDatabase(); const multiRoot = { @@ -6662,7 +6681,10 @@ suite('AgentService (node dispatcher)', () => { let rejected: unknown; const restore = svc.restoreSession(session).catch(err => { rejected = err; }); - await advanceUntil(() => agent.listChatsToMigrateCalls > 0); + // The gated catalogue migration starts from `registerProvider`, so waiting + // on it alone would sample the counters before restore's own (independent) + // metadata read has landed. + await advanceUntil(() => agent.listChatsToMigrateCalls > 0 && agent.getChatMetadataCalls > 0); const beforeGate = { metadataRead: agent.getChatMetadataCalls, hydrated: !!svc.stateManager.getSessionState(session.toString()), @@ -6676,7 +6698,7 @@ suite('AgentService (node dispatcher)', () => { rejected, hydratedAfter: !!svc.stateManager.getSessionState(session.toString()), }, { - beforeGate: { metadataRead: 0, hydrated: false }, + beforeGate: { metadataRead: 1, hydrated: false }, rejected: undefined, hydratedAfter: true, }); @@ -6691,7 +6713,9 @@ suite('AgentService (node dispatcher)', () => { let rejected: unknown; const restore = svc.restoreSession(session).catch(err => { rejected = err; }); - await advanceUntil(() => agent.listChatsToMigrateCalls > 0); + // Wait until restore is parked on the catalogue: deleting before it reads + // metadata would trip the early tombstone check instead of the one after. + await advanceUntil(() => agent.listChatsToMigrateCalls > 0 && agent.getChatMetadataCalls > 0); await svc.disposeSession(session); agent.migrationGate.complete(); await restore; @@ -6704,11 +6728,33 @@ suite('AgentService (node dispatcher)', () => { }, { isProtocolError: true, code: AHP_SESSION_NOT_FOUND, - metadataRead: 0, + // Restore reads per-session metadata before waiting on the catalogue, + // so one read happens even for a session deleted during the wait. + metadataRead: 1, hydrated: false, }); }); + test('restores a session the provider can describe without waiting for the catalogue', async () => { + // Warming the catalogue is O(catalogue) — ~48s on a large `~/.copilot` — + // so a session that resolves from its own metadata must not pay for it. + const svc = makeService(); + const agent = disposables.add(new StartupRaceAgent('copilot')); + const session = AgentSession.uri('copilot', 'describable-session'); + seedSession(agent, session); + // Describable immediately, while the catalogue migration stays gated. + agent.sdkReady = true; + svc.registerProvider(agent); + + await svc.restoreSession(session); + + assert.deepStrictEqual( + { hydrated: !!svc.stateManager.getSessionState(session.toString()), catalogueSettled: agent.migrationGate.isSettled }, + { hydrated: true, catalogueSettled: false }, + ); + agent.migrationGate.complete(); + }); + test('reports a genuinely missing session as not found once migration completes', async () => { const svc = makeService(); const agent = disposables.add(new StartupRaceAgent('copilot')); @@ -7227,6 +7273,7 @@ suite('AgentService (node dispatcher)', () => { ); }); + test('adopts a surfaced legacy session on open only when the migrate setting is on', async () => { // Open-adoption is strictly gated on the live migrate setting. class AdoptOnOpenAgent extends MockAgent { @@ -7276,6 +7323,63 @@ suite('AgentService (node dispatcher)', () => { ); }); + test('an adopted chat whose restore fails is still registered, not lost from every list', async () => { + // Adoption claims the chat on disk, which stops the extension host listing + // it. If restore then fails (e.g. a worktree whose branch is gone) and the + // chat was never registered, it exists in no list at all. + class AdoptThenFailAgent extends MockAgent { + constructor() { super('copilot'); } + // Absent from the catalogue, so only the adoption path can register it. + override async listChatsToMigrate(): Promise { + return []; + } + async ensureChatAdopted(_chat: URI, _context: URI | IAgentChatContext): Promise { + return { adopted: true, eligible: true }; + } + override async materializeChat(): Promise { + throw new Error('working directory no longer exists'); + } + } + + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new AdoptThenFailAgent()); + localService.registerProvider(agent); + localService.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + const session = AgentSession.uri('copilot', 'adopted-restore-fails'); + (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(session), session); + + await assert.rejects(() => localService.restoreSession(session)); + + const registry = (localService as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry; + assert.strictEqual((await registry.get(session))?.session.toString(), session.toString()); + }); + + test('an adopted chat whose registration cannot be made durable fails the migration', async () => { + // Continuing unregistered would leave exactly the orphan the registration is + // there to prevent: adopted on disk, so the extension host stops listing it, + // but present in no Agent Host list either. + class AdoptAgent extends MockAgent { + constructor() { super('copilot'); } + override async listChatsToMigrate(): Promise { + return []; + } + async ensureChatAdopted(_chat: URI, _context: URI | IAgentChatContext): Promise { + return { adopted: true, eligible: true }; + } + } + + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new AdoptAgent()); + localService.registerProvider(agent); + localService.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + const session = AgentSession.uri('copilot', 'adopted-registration-fails'); + (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(session), session); + const registry = (localService as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry; + registry.register = async () => { throw new Error('registry unavailable'); }; + + await assert.rejects(() => localService.restoreSession(session)); + }); + test('does not materialize state for an unregistered chat that is not adoptable', async () => { // An external chat (e.g. created by the GitHub app) is hidden while // `showExternalSessions` is `none`, so it is absent from the registered diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 631d9fdd728b59..b564e8b584d5a4 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -43,7 +43,7 @@ import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, type AgentSignal, type import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind } from '../../common/agentHostTelemetry.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; -import { buildDefaultChatUri, buildChatUri, buildSubagentChatUri, buildSubagentSessionUri, parseRequiredSessionUriFromChatUri, CustomizationLoadStatus, MessageKind, readSessionEhcliAdoptable, ResponsePartKind, ROOT_STATE_URI, ToolResultContentType, TurnState, customizationId, AH_META_IS_READ_DB_KEY, type ClientPluginCustomization, type Customization, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js'; +import { buildDefaultChatUri, buildChatUri, buildSubagentChatUri, buildSubagentSessionUri, parseRequiredSessionUriFromChatUri, CustomizationLoadStatus, MessageKind, readSessionEhcliAdoptable, ResponsePartKind, ROOT_STATE_URI, ToolResultContentType, TurnState, customizationId, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_READ_DB_KEY, type ClientPluginCustomization, type Customization, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js'; import { ChatOriginKind, CustomizationEnablementKind, CustomizationType, SessionStatus, ToolCallContributorKind, type AgentSelection, type ModelSelection, type ProtectedResourceMetadata, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js'; @@ -5569,6 +5569,27 @@ suite('CopilotAgent', () => { } }); + test('does not surface a legacy chat the user archived in the extension host', async () => { + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/archived-discovery-home-`)); + const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/archived-discovery-cwd-`); + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([sdkSession('ehcli-archived', workingDirectory)]); + await writeExtensionHostMarker(userHome, 'ehcli-archived', { origin: 'vscode', archived: true }); + const { agent } = createTestAgentContext(disposables, { + sessionDataService, + copilotClient: client, + userHome, + rootConfig: { [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }, + }); + try { + assert.deepStrictEqual(await collectDiscoveredChats(agent), []); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await fs.rm(workingDirectory, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + test('does not surface a session Agent Host owns or one the SDK reports without a working directory', async () => { const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/owned-discovery-home-`)); const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/owned-discovery-cwd-`); @@ -11088,6 +11109,248 @@ suite('CopilotAgent', () => { await fs.writeFile(join(dir, 'vscode.requests.metadata.json'), JSON.stringify(details), 'utf8'); } + test('keeps a deleted worktree as the working directory so resume can recreate it', async () => { + // Parity with native worktree sessions: the checkout is recreated from the + // recorded branch rather than the session being re-rooted at the repository. + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`)); + const repositoryRoot = await fs.mkdtemp(`${os.tmpdir()}/adopt-repo-`); + const worktreePath = join(repositoryRoot, '..', 'gone.worktrees', 'feature-x'); + const sessionId = 'legacy-worktree-gone'; + const session = AgentSession.uri('copilotcli', sessionId); + const sessionDataService = disposables.add(new TestSessionDataService()); + // The SDK still reports the deleted checkout, exactly as it does on disk. + const client = new TestCopilotClient([sdkSession(sessionId, worktreePath)]); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await writeExtensionHostMarker(userHome, sessionId, { + origin: 'vscode', + worktreeProperties: { worktreePath, repositoryPath: repositoryRoot, branchName: 'feature/x', baseBranchName: 'main' }, + }); + + const adopted = await ensureDefaultChatAdopted(agent, session); + + const db = await sessionDataService.tryOpenDatabase(session); + const persistedCwd = await db?.object.getMetadata('copilot.workingDirectory'); + db?.dispose(); + + assert.deepStrictEqual( + { + adopted: adopted.adopted, + worktree: adopted.worktree && { + branchName: adopted.worktree.branchName, + baseBranch: adopted.worktree.baseBranch, + worktreePath: adopted.worktree.worktreePath.fsPath, + repositoryRoot: adopted.worktree.repositoryRoot.fsPath, + }, + persistedCwd, + }, + { + adopted: true, + worktree: { branchName: 'feature/x', baseBranch: 'main', worktreePath: URI.file(worktreePath).fsPath, repositoryRoot: URI.file(repositoryRoot).fsPath }, + persistedCwd: URI.file(worktreePath).toString(), + }, + ); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await fs.rm(repositoryRoot, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('adopts a deleted worktree with the local repository as its project, not the remote', async () => { + // Git resolution runs in the (missing) checkout and falls back to the + // remote, whose URI is not a path — the session could then never be + // matched to the repository folder a window has open. + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`)); + const repositoryRoot = await fs.mkdtemp(`${os.tmpdir()}/adopt-repo-`); + const worktreePath = join(repositoryRoot, '..', 'gone.worktrees', 'feature-y'); + const sessionId = 'legacy-worktree-remote-project'; + const session = AgentSession.uri('copilotcli', sessionId); + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([sdkSession(sessionId, worktreePath)]); + // No git root resolves for a checkout that is gone, so the project would + // otherwise come from `context.repository`. + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await writeExtensionHostMarker(userHome, sessionId, { + origin: 'vscode', + worktreeProperties: { worktreePath, repositoryPath: repositoryRoot, branchName: 'feature/y', baseBranchName: 'main' }, + }); + + await ensureDefaultChatAdopted(agent, session); + + const db = await sessionDataService.tryOpenDatabase(session); + const projectUri = await db?.object.getMetadata('copilot.project.uri'); + db?.dispose(); + + assert.strictEqual(projectUri, URI.file(repositoryRoot).toString()); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await fs.rm(repositoryRoot, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('backfills the adopted-legacy marker for a session migrated by an older build', async () => { + // Those sessions already have a working directory, so adoption short-circuits + // as `alreadyNative` and never reaches the write. Without the backfill a + // migrated worktree session stays filtered out of its repository window. + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`)); + const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/adopt-old-`); + const sessionId = 'legacy-already-adopted'; + const session = AgentSession.uri('copilotcli', sessionId); + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await writeExtensionHostMarker(userHome, sessionId); + // Metadata an older build wrote: adopted, but without the provenance marker. + const seed = sessionDataService.openDatabase(session); + await seed.object.setMetadata('copilot.workingDirectory', URI.file(workingDirectory).toString()); + seed.dispose(); + + const adopted = await ensureDefaultChatAdopted(agent, session); + + const db = await sessionDataService.tryOpenDatabase(session); + const marker = await db?.object.getMetadata('agentHost.ehcliAdopted'); + db?.dispose(); + + assert.deepStrictEqual( + { reason: adopted.reason, marker }, + { reason: 'alreadyNative', marker: 'true' }, + ); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await fs.rm(workingDirectory, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('does not backfill the adopted-legacy marker onto a native session', async () => { + // No extension-host marker means the session was never a legacy chat. + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`)); + const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/adopt-native-`); + const sessionId = 'native-session'; + const session = AgentSession.uri('copilotcli', sessionId); + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const seed = sessionDataService.openDatabase(session); + await seed.object.setMetadata('copilot.workingDirectory', URI.file(workingDirectory).toString()); + seed.dispose(); + + await ensureDefaultChatAdopted(agent, session); + + const db = await sessionDataService.tryOpenDatabase(session); + const marker = await db?.object.getMetadata('agentHost.ehcliAdopted'); + db?.dispose(); + + assert.strictEqual(marker, undefined); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await fs.rm(workingDirectory, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('sees an archive toggled in the extension host after the marker was cached', async () => { + // The marker cache memoizes successful reads for the agent's lifetime, but + // `archived` is user-toggled while both hosts run, so it must be re-read. + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`)); + const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/adopt-archive-`); + const sessionId = 'legacy-archived-later'; + const session = AgentSession.uri('copilotcli', sessionId); + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await writeExtensionHostMarker(userHome, sessionId, { origin: 'vscode', archived: false }); + // Populate the marker cache, as discovery does when it classifies the chat. + await (agent as unknown as { _isExtensionHostCliSession(id: string): Promise })._isExtensionHostCliSession(sessionId); + // The user archives it in the extension host list afterwards. + await writeExtensionHostMarker(userHome, sessionId, { origin: 'vscode', archived: true }); + + await ensureDefaultChatAdopted(agent, session); + + const db = await sessionDataService.tryOpenDatabase(session); + const archived = await db?.object.getMetadata('isArchived'); + db?.dispose(); + + assert.strictEqual(archived, 'true'); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await fs.rm(workingDirectory, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('declines adoption when the archived state can no longer be read', async () => { + // Adoption commits the archived state and makes the extension host stop + // listing the chat, so guessing "not archived" would resurface a session the + // user had filed away. Leave it for the next open instead. + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`)); + const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/adopt-marker-gone-`); + const sessionId = 'legacy-marker-unreadable'; + const session = AgentSession.uri('copilotcli', sessionId); + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await writeExtensionHostMarker(userHome, sessionId); + // Classify it as legacy while the marker is readable, then corrupt it. + await (agent as unknown as { _isExtensionHostCliSession(id: string): Promise })._isExtensionHostCliSession(sessionId); + await fs.writeFile(join(getCopilotHomePath(userHome.fsPath, process.env), 'session-state', sessionId, 'vscode.metadata.json'), '{ not json', 'utf8'); + + const adopted = await ensureDefaultChatAdopted(agent, session); + + const db = await sessionDataService.tryOpenDatabase(session); + const persistedCwd = await db?.object.getMetadata('copilot.workingDirectory'); + db?.dispose(); + + assert.deepStrictEqual( + { adopted, persistedCwd }, + { adopted: { adopted: false, eligible: true, reason: 'markerUnavailable' }, persistedCwd: undefined }, + ); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await fs.rm(workingDirectory, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('reports no recorded worktree when the checkout still exists', async () => { + // A live worktree is handled by the existing probe-the-directory bridge. + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`)); + const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/adopt-live-`); + const sessionId = 'legacy-worktree-live'; + const session = AgentSession.uri('copilotcli', sessionId); + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await writeExtensionHostMarker(userHome, sessionId, { + origin: 'vscode', + worktreeProperties: { worktreePath: workingDirectory, repositoryPath: workingDirectory, branchName: 'feature/y' }, + }); + + const adopted = await ensureDefaultChatAdopted(agent, session); + + assert.deepStrictEqual({ adopted: adopted.adopted, worktree: adopted.worktree }, { adopted: true, worktree: undefined }); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await fs.rm(workingDirectory, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + test('adopts a legacy extension-host session in place and seeds folder isolation', async () => { const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`)); const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/adopt-cwd-`); @@ -11111,7 +11374,70 @@ suite('CopilotAgent', () => { assert.deepStrictEqual( { first, second, configValues }, - { first: { adopted: true, eligible: true }, second: { adopted: false, eligible: false, native: true }, configValues: JSON.stringify({ [SessionConfigKey.Isolation]: 'folder' }) }, + { first: { adopted: true, eligible: true, reason: 'adopted' }, second: { adopted: false, eligible: false, native: true, reason: 'alreadyNative' }, configValues: JSON.stringify({ [SessionConfigKey.Isolation]: 'folder' }) }, + ); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await fs.rm(workingDirectory, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('does not adopt a session whose recorded working directory no longer exists', async () => { + // A months-old session may have run in a worktree that has since been + // deleted. Adopting it commits the claim (the extension host list stops + // showing it) and then fails to resume, leaving it in neither list. + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`)); + const deletedWorkingDirectory = await fs.mkdtemp(`${os.tmpdir()}/adopt-gone-`); + await fs.rm(deletedWorkingDirectory, { recursive: true, force: true }); + const sessionId = 'legacy-missing-cwd'; + const session = AgentSession.uri('copilotcli', sessionId); + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([sdkSession(sessionId, deletedWorkingDirectory)]); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await writeExtensionHostMarker(userHome, sessionId); + + const adopted = await ensureDefaultChatAdopted(agent, session); + + const db = await sessionDataService.tryOpenDatabase(session); + const persistedCwd = await db?.object.getMetadata('copilot.workingDirectory'); + db?.dispose(); + + assert.deepStrictEqual( + { adopted, persistedCwd }, + { adopted: { adopted: false, eligible: true, reason: 'workingDirectoryMissing' }, persistedCwd: undefined }, + ); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('carries over the legacy archived state on adoption', async () => { + // Archiving is user-curated: adopting must not resurface a session the + // user filed away in the extension host list. + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adopt-home-`)); + const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/adopt-cwd-`); + const sessionId = 'legacy-archived'; + const session = AgentSession.uri('copilotcli', sessionId); + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await writeExtensionHostMarker(userHome, sessionId, { origin: 'vscode', archived: true }); + + const adopted = await ensureDefaultChatAdopted(agent, session); + + const db = await sessionDataService.tryOpenDatabase(session); + const archived = await db?.object.getMetadata(AH_META_IS_ARCHIVED_DB_KEY); + db?.dispose(); + + assert.deepStrictEqual( + { adopted, archived }, + { adopted: { adopted: true, eligible: true, reason: 'adopted' }, archived: 'true' }, ); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); @@ -11149,7 +11475,7 @@ suite('CopilotAgent', () => { assert.deepStrictEqual( { adopted, usages }, { - adopted: { adopted: true, eligible: true }, + adopted: { adopted: true, eligible: true, reason: 'adopted' }, usages: [ ['evt-1', JSON.stringify({ model: 'gpt-5.4', _meta: { copilotUsage: { totalNanoAiu: 1_500_000_000 } } })], ['evt-2', JSON.stringify({ model: 'gpt-5.4-mini', _meta: { copilotUsage: { totalNanoAiu: 0 } } })], @@ -11183,7 +11509,7 @@ suite('CopilotAgent', () => { assert.deepStrictEqual( { adopted, customTitle }, - { adopted: { adopted: true, eligible: true }, customTitle: 'My Legacy Session' }, + { adopted: { adopted: true, eligible: true, reason: 'adopted' }, customTitle: 'My Legacy Session' }, ); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); @@ -11212,7 +11538,7 @@ suite('CopilotAgent', () => { assert.deepStrictEqual( { adopted, isRead }, - { adopted: { adopted: true, eligible: true }, isRead: 'true' }, + { adopted: { adopted: true, eligible: true, reason: 'adopted' }, isRead: 'true' }, ); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); @@ -11236,7 +11562,7 @@ suite('CopilotAgent', () => { assert.deepStrictEqual( { adopted, getSessionMetadataCalls: client.getSessionMetadataCalls, openedDatabases: sessionDataService.openedSessions }, - { adopted: { adopted: false, eligible: false }, getSessionMetadataCalls: [], openedDatabases: [] }, + { adopted: { adopted: false, eligible: false, reason: 'notLegacyChat' }, getSessionMetadataCalls: [], openedDatabases: [] }, ); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); @@ -11266,7 +11592,7 @@ suite('CopilotAgent', () => { assert.deepStrictEqual( { adopted, getSessionMetadataCalls: client.getSessionMetadataCalls, openedDatabases: sessionDataService.openedSessions }, - { adopted: { adopted: false, eligible: false }, getSessionMetadataCalls: [], openedDatabases: [] }, + { adopted: { adopted: false, eligible: false, reason: 'notLegacyChat' }, getSessionMetadataCalls: [], openedDatabases: [] }, ); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); @@ -11291,7 +11617,7 @@ suite('CopilotAgent', () => { const adopted = await ensureDefaultChatAdopted(agent, session); - assert.deepStrictEqual(adopted, { adopted: true, eligible: true }); + assert.deepStrictEqual(adopted, { adopted: true, eligible: true, reason: 'adopted' }); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); await fs.rm(workingDirectory, { recursive: true, force: true }); @@ -11317,7 +11643,7 @@ suite('CopilotAgent', () => { assert.deepStrictEqual( { adopted, getSessionMetadataCalls: client.getSessionMetadataCalls, openedDatabases: sessionDataService.openedSessions }, - { adopted: { adopted: false, eligible: false }, getSessionMetadataCalls: [], openedDatabases: [] }, + { adopted: { adopted: false, eligible: false, reason: 'notLegacyChat' }, getSessionMetadataCalls: [], openedDatabases: [] }, ); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); @@ -11353,7 +11679,7 @@ suite('CopilotAgent', () => { assert.deepStrictEqual( { adopted, getSessionMetadataCalls: client.getSessionMetadataCalls, usages }, - { adopted: { adopted: false, eligible: false, native: true }, getSessionMetadataCalls: [], usages: [] }, + { adopted: { adopted: false, eligible: false, native: true, reason: 'alreadyNative' }, getSessionMetadataCalls: [], usages: [] }, ); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index 3279a263cb2173..6789aeb7a16887 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -21,6 +21,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; import { AutomationStore } from '../../../automations/browser/automationService.js'; @@ -102,7 +103,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide // Startup restore reopens persisted slots against a cold host, where the // first catalog pass is far slower than an interactive open. const timeoutMs = reason === 'restore' ? LEGACY_MIGRATION_RESTORE_TIMEOUT_MS : LEGACY_MIGRATION_TIMEOUT_MS; - return adoptLegacyCopilotCliResource(this.connection, resource, this._logService, this._configurationService, timeoutMs); + return adoptLegacyCopilotCliResource(this.connection, resource, this._logService, this._configurationService, this._telemetryService, reason ?? 'open', timeoutMs); } constructor( @@ -113,6 +114,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide @ILanguageModelsService languageModelsService: ILanguageModelsService, @ILabelService private readonly _labelService: ILabelService, @IConfigurationService private readonly _configurationService: IConfigurationService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, @ILogService logService: ILogService, @IGitHubService gitHubService: IGitHubService, @IInstantiationService instantiationService: IInstantiationService, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLegacyMigration.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLegacyMigration.ts index 04eab395bda610..e7dc6e304d0fa0 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLegacyMigration.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLegacyMigration.ts @@ -8,6 +8,7 @@ import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { ChatConfiguration } from '../../../common/constants.js'; import { AgentSession, IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; @@ -27,6 +28,25 @@ import { COPILOT_CLI_AGENT_PROVIDER, getCopilotCliSessionRawId, migratedCopilotC export const LEGACY_MIGRATION_TIMEOUT_MS = 10_000; export const LEGACY_MIGRATION_RESTORE_TIMEOUT_MS = 60_000; +/** Where a probe was triggered from, so outcomes can be attributed per entry point. */ +export type LegacyMigrationProbeSource = 'open' | 'restore'; + +type LegacyMigrationProbeEvent = { + source: string; + outcome: 'adopted' | 'declined' | 'timedOut' | 'settingDisabled' | 'noConnection' | 'failed'; + durationMs: number; + timeoutMs: number; +}; + +type LegacyMigrationProbeClassification = { + source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Entry point that probed: open (user opened a session) or restore (startup/editor restore).' }; + outcome: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Probe outcome: adopted (the host migrated the session; the caller may still fall back if it does not surface — see agentHost.legacyCopilotCliMigrationOpen), declined (host refused, e.g. not an adoptable legacy chat), timedOut (no answer within the budget), settingDisabled, noConnection, or failed (probe threw).' }; + durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds spent probing before the outcome was known.' }; + timeoutMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'The probe budget that applied, so timeouts can be correlated with the entry point.' }; + owner: 'vijayupadya'; + comment: 'Counts adopt-on-open probe attempts for legacy extension-host Copilot CLI sessions. The host-side agentHost.legacyCopilotCliMigration event only fires once migration starts, so without this there is no denominator for a success rate and a silently-unmigrated open is indistinguishable from a user having no legacy sessions.'; +}; + /** * Redirects a legacy extension-host Copilot CLI resource to its agent-host twin, * adopting it on the way, or `undefined` to leave the caller's resource alone. @@ -47,10 +67,27 @@ export async function adoptLegacyCopilotCliResource( resource: URI, logService: ILogService, configurationService: IConfigurationService, + telemetryService: ITelemetryService, + source: LegacyMigrationProbeSource, timeoutMs: number = LEGACY_MIGRATION_TIMEOUT_MS, ): Promise { const twin = migratedCopilotCliResource(resource); - if (!twin || !connection) { + if (!twin) { + return undefined; + } + const startedAt = Date.now(); + // Reported only for resources that are actually legacy sessions, so the event + // counts migration opportunities rather than every open in the product. + const report = (outcome: LegacyMigrationProbeEvent['outcome']) => { + telemetryService.publicLog2('agentHost.legacyCopilotCliMigrationProbe', { + source, + outcome, + durationMs: Date.now() - startedAt, + timeoutMs, + }); + }; + if (!connection) { + report('noConnection'); return undefined; } // The host restores a session whether or not it adopts it, so a successful @@ -58,6 +95,7 @@ export async function adoptLegacyCopilotCliResource( // without it we would move sessions onto the agent host for users who never // opted in — including external ones, which are never adopted at all. if (configurationService.getValue(ChatConfiguration.MigrateLegacyCopilotCliSessions) !== true) { + report('settingDisabled'); return undefined; } const rawId = getCopilotCliSessionRawId(twin); @@ -67,18 +105,20 @@ export async function adoptLegacyCopilotCliResource( // AHP channels are backend session URIs (`:/`); the // `agent-host-` scheme is a client-side naming that the host does not know. const backendSession = AgentSession.uri(COPILOT_CLI_AGENT_PROVIDER, rawId); - const startedAt = Date.now(); const store = new DisposableStore(); try { const ref = store.add(connection.getSubscription(StateComponents.Session, backendSession, 'AgentHostLegacyMigration')); const settled = await raceTimeout(whenSubscriptionSettles(ref.object as IAgentSubscription, store), timeoutMs); if (settled === true) { - logService.trace(`[AgentHost] adopted legacy session ${resource.toString()} in ${Date.now() - startedAt}ms`); + report('adopted'); + logService.info(`[AgentHost] adopted legacy session ${resource.toString()} in ${Date.now() - startedAt}ms`); return twin; } + report(settled === false ? 'declined' : 'timedOut'); logService.info(`[AgentHost] legacy session ${resource.toString()} not adopted (${settled === false ? 'declined by host' : `no answer within ${timeoutMs}ms`}); opening it unmigrated`); return undefined; } catch (err) { + report('failed'); logService.warn(`[AgentHost] legacy migration probe failed for ${resource.toString()}`, err); return undefined; } finally { @@ -86,6 +126,27 @@ export async function adoptLegacyCopilotCliResource( } } +type LegacyMigrationOpenEvent = { + source: string; + surfaced: boolean; +}; + +type LegacyMigrationOpenClassification = { + source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Entry point that opened: open (user opened a session) or restore (startup/editor restore).' }; + surfaced: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the migrated session was found and opened. False means the open fell back to the legacy session the host had just migrated away from.' }; + owner: 'vijayupadya'; + comment: 'Reports whether an adopted legacy session was actually opened as its migrated agent-host session. The probe event only reports that the host adopted it, so without this a silent fallback to the legacy session is invisible.'; +}; + +/** + * Records whether an adopted session was opened as its migrated twin. Adoption + * succeeding does not mean the caller could open it, and that fallback is the + * failure this telemetry exists to catch. + */ +export function reportLegacyMigrationOpen(telemetryService: ITelemetryService, source: LegacyMigrationProbeSource, surfaced: boolean): void { + telemetryService.publicLog2('agentHost.legacyCopilotCliMigrationOpen', { source, surfaced }); +} + /** Resolves `true` once the subscription has state, `false` if it errors. */ function whenSubscriptionSettles(subscription: IAgentSubscription, store: DisposableStore): Promise { const current = subscription.value; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts index 03a352a3cd0fa9..05476439941e78 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts @@ -10,8 +10,10 @@ import { extUriBiasedIgnorePathCase } from '../../../../../../base/common/resour import { URI } from '../../../../../../base/common/uri.js'; import { AgentSession, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agentService.js'; import { ActionType, type IIsArchivedChangedAction, type IIsReadChangedAction, type INotification, type SessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import { readSessionEhcliAdoptable, readSessionMultiRootMetadata, SessionStatus, type SessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { readSessionMatchesByProjectRoot, readSessionMultiRootMetadata, SessionStatus, type SessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IWorkspaceContextService, type IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { Schemas } from '../../../../../../base/common/network.js'; /** * Minimal agent-host connection surface needed by the session list store. @@ -88,9 +90,13 @@ export class AgentHostSessionListStore extends Disposable { */ private _mutationGeneration = 0; + /** Sessions already reported as having an unusable (non-local) project root. */ + private readonly _reportedNonLocalProjects = new Set(); + constructor( private readonly _connection: IAgentHostSessionListConnection, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @ILogService private readonly _logService: ILogService, ) { super(); @@ -371,6 +377,19 @@ export class AgentHostSessionListStore extends Disposable { /** Uses workspace-file provenance for multi-root workspaces and path containment otherwise. */ private _isSessionInWorkspace(entry: IAgentHostSessionListEntry): boolean { + const inWorkspace = this._computeSessionInWorkspace(entry); + // A legacy session is matched by its repository root, which must be a local + // path; a remote project (e.g. an `https://` repo URL) silently matches + // nothing. Excluding one is legitimate, so only report the broken input, and + // only once — this runs for every session on every refresh. + if (!inWorkspace && readSessionMatchesByProjectRoot(entry.summary._meta) && entry.summary.project && URI.parse(entry.summary.project.uri).scheme !== Schemas.file && !this._reportedNonLocalProjects.has(entry.summary.resource)) { + this._reportedNonLocalProjects.add(entry.summary.resource); + this._logService.warn(`[AgentHost] legacy session ${entry.summary.resource} has a non-local project '${entry.summary.project.uri}' and cannot be matched to a workspace folder`); + } + return inWorkspace; + } + + private _computeSessionInWorkspace(entry: IAgentHostSessionListEntry): boolean { const workingDirectories = this._containmentCandidates(entry.summary); const workspace = this._workspaceContextService.getWorkspace(); const folders = workspace.folders; @@ -420,11 +439,18 @@ export class AgentHostSessionListStore extends Disposable { * server-owned project (repository) root. Those legacy sessions run out of a * `copilot-worktrees/` directory outside the repository, so working * directories alone would hide them from a window opened on that repository. + * The marker has to outlive adoption: a migrated session is still a legacy + * session and must not drop out of the list the moment it migrates. */ private _containmentCandidates(summary: SessionSummary): readonly URI[] { const candidates = summary.workingDirectories?.map(directory => URI.parse(directory)) ?? []; - if (summary.project?.uri && readSessionEhcliAdoptable(summary._meta)) { - candidates.push(URI.parse(summary.project.uri)); + if (summary.project?.uri && readSessionMatchesByProjectRoot(summary._meta)) { + const project = URI.parse(summary.project.uri); + // A project can be a remote (e.g. `https://github.com/owner/repo`), whose + // `fsPath` is not a location on disk and would silently never match. + if (project.scheme === Schemas.file) { + candidates.push(project); + } } return candidates; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts index f4369e66c2795f..43b8d7e95a1f5c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts @@ -22,7 +22,8 @@ import { URI } from '../../../../../base/common/uri.js'; import { IAgentSessionsService } from './agentSessionsService.js'; import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; -import { adoptLegacyCopilotCliResource } from './agentHost/agentHostLegacyMigration.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { adoptLegacyCopilotCliResource, reportLegacyMigrationOpen } from './agentHost/agentHostLegacyMigration.js'; //#region Session Opener Registry @@ -59,18 +60,46 @@ export const sessionOpenerRegistry = new SessionOpenerRegistry(); //#endregion +/** + * The agent-host session a legacy chat was just migrated into is not in the list + * until its provider is refreshed, so a lookup straight after adoption misses and + * the caller would fall back to opening the legacy session it just migrated away + * from. Refresh that one provider and look again. + */ +async function resolveMigratedSession(agentSessionsService: IAgentSessionsService, migrated: URI): Promise { + const existing = agentSessionsService.getSession(migrated); + if (existing) { + return existing; + } + await agentSessionsService.model.resolve(getChatSessionType(migrated)); + return agentSessionsService.getSession(migrated); +} + export async function openSessionByResource(accessor: ServicesAccessor, resource: URI, openOptions?: ISessionOpenOptions): Promise { const instantiationService = accessor.get(IInstantiationService); const logService = accessor.get(ILogService); + const agentSessionsService = accessor.get(IAgentSessionsService); + const telemetryService = accessor.get(ITelemetryService); // A superseded legacy resource is redirected (and adopted) before anything // looks it up, so opening by URI migrates instead of reaching the old provider. - resource = await adoptLegacyCopilotCliResource( + const migrated = await adoptLegacyCopilotCliResource( accessor.get(IAgentHostConnectionsService).ambientConnection, resource, logService, accessor.get(IConfigurationService), - ) ?? resource; + accessor.get(ITelemetryService), + 'open', + ); + if (migrated) { + const surfaced = await resolveMigratedSession(agentSessionsService, migrated); + reportLegacyMigrationOpen(telemetryService, 'open', !!surfaced); + if (surfaced) { + resource = migrated; + } else { + logService.warn(`[AgentHost] migrated ${resource.toString()} to ${migrated.toString()} but it is not in this window's list after refreshing provider '${getChatSessionType(migrated)}'; opening the legacy session instead.`); + } + } for (const participant of sessionOpenerRegistry.getParticipants()) { if (!participant.handleOpenSessionResource) { @@ -98,6 +127,8 @@ export async function openSessionByResource(accessor: ServicesAccessor, resource export async function openSession(accessor: ServicesAccessor, session: IAgentSession, openOptions?: ISessionOpenOptions, alreadyResolved?: boolean): Promise { const instantiationService = accessor.get(IInstantiationService); const logService = accessor.get(ILogService); + const agentSessionsService = accessor.get(IAgentSessionsService); + const telemetryService = accessor.get(ITelemetryService); logService.trace(`[AgentSessions] openSession start: ${session.resource.toString()}`); @@ -110,9 +141,17 @@ export async function openSession(accessor: ServicesAccessor, session: IAgentSes session.resource, logService, accessor.get(IConfigurationService), + accessor.get(ITelemetryService), + 'open', ); if (migrated) { - session = instantiationService.invokeFunction(accessor => accessor.get(IAgentSessionsService).getSession(migrated)) ?? session; + const migratedSession = await resolveMigratedSession(agentSessionsService, migrated); + reportLegacyMigrationOpen(telemetryService, 'open', !!migratedSession); + if (migratedSession) { + session = migratedSession; + } else { + logService.warn(`[AgentHost] migrated ${session.resource.toString()} to ${migrated.toString()} but it is not in this window's list after refreshing provider '${getChatSessionType(migrated)}'; opening the legacy session instead.`); + } } } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts index d1ddef2218cb01..8d7904b431a35b 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts @@ -17,6 +17,7 @@ import { ConfirmResult, IDialogService } from '../../../../../../platform/dialog import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; import { registerIcon } from '../../../../../../platform/theme/common/iconRegistry.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; @@ -76,6 +77,7 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @IAgentHostEnablementService private readonly agentHostEnablementService: IAgentHostEnablementService, @IAgentHostConnectionsService private readonly agentHostConnectionsService: IAgentHostConnectionsService, + @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(); @@ -250,6 +252,8 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler this._sessionResource, this.logService, this.configurationService, + this.telemetryService, + 'restore', LEGACY_MIGRATION_RESTORE_TIMEOUT_MS, ); if (migrated) { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index f277f5c50cf94b..939299775b45c9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -34,7 +34,7 @@ import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, toAgentSy import { ActionType, AuthRequiredReason, isSessionAction, isChatAction, NotificationType, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type ChatAction as AgentHostChatAction, type TerminalAction, type INotification, type IToolCallConfirmedAction, type ITurnStartedAction, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { ProtocolError, type IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { ChatInteractivity, ConfirmationOptionKind, CustomizationEnablementKind, CustomizationType, McpAuthRequiredReason, McpServerStatus, type AgentCustomization, type ClientPluginCustomization, type ProtectedResourceMetadata, type SessionActiveClient, type ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ChatOriginKind, SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, createSessionState, createChatState, createDefaultChatSummary, buildChatUri, buildDefaultChatUri, parseDefaultChatUri, isAhpChatChannel, createActiveTurn, isAhpRootChannel, PolicyState, ResponsePartKind, ROOT_STATE_URI, StateComponents, buildSubagentChatUri, ToolResultContentType, MessageAttachmentKind, MessageKind, PendingMessageKind, withSessionMultiRootMetadata, SESSION_META_EHCLI_ADOPTABLE_KEY, type SessionState, type SessionSummary, type ChatState, type ISessionWithDefaultChat, RootState, type ToolCallState, type AgentInfo, type MessageAttachment, type MessageChatAttachment } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ChatOriginKind, SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, createSessionState, createChatState, createDefaultChatSummary, buildChatUri, buildDefaultChatUri, parseDefaultChatUri, isAhpChatChannel, createActiveTurn, isAhpRootChannel, PolicyState, ResponsePartKind, ROOT_STATE_URI, StateComponents, buildSubagentChatUri, ToolResultContentType, MessageAttachmentKind, MessageKind, PendingMessageKind, withSessionMultiRootMetadata, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_EHCLI_ADOPTED_KEY, type SessionState, type SessionSummary, type ChatState, type ISessionWithDefaultChat, RootState, type ToolCallState, type AgentInfo, type MessageAttachment, type MessageChatAttachment } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { CompletionItemKind as AhpCompletionItemKind, type CompletionsParams, type CompletionsResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { sessionReducer, chatReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; @@ -2522,6 +2522,82 @@ suite('AgentHostChatContribution', () => { }); }); + test('a worktree session adopted mid-window survives the post-adoption summary change', async () => { + // Repro of the migration symptom: the session is listed while adoptable, + // the user opens it, adoption clears `ehcliAdoptable`, and the summary + // change that follows must not evict it from the window's list. + const { instantiationService, agentHostService } = createTestServices(disposables); + + const folder = URI.file('/src/repo'); + instantiationService.stub(IWorkspaceContextService, { + getWorkbenchState: () => WorkbenchState.FOLDER, + getWorkspace: () => ({ id: 'folder', folders: [{ uri: folder, name: 'repo', index: 0, toResource: () => folder }] }), + getWorkspaceFolder: () => null, + onDidChangeWorkspaceFolders: Event.None, + }); + + const backendSession = AgentSession.uri('copilot', 'adopted-midflight'); + agentHostService.addSession({ + session: backendSession, + startTime: 1000, + modifiedTime: 2000, + summary: 'Worktree session', + workingDirectories: [URI.file('/src/repo.worktrees/feature')], + project: { uri: folder, displayName: 'repo' }, + _meta: { [SESSION_META_EHCLI_ADOPTABLE_KEY]: true }, + }); + const listController = createSessionListController(disposables, instantiationService, agentHostService); + await listController.refresh(CancellationToken.None); + const beforeAdoption = listController.items.map(item => item.label); + + // Post-adoption the host reports the session with the durable adopted + // marker in place of the transient adoptable one, so it keeps matching + // its repository folder even though it runs out of a sibling worktree. + agentHostService.fireNotification({ + type: 'root/sessionSummaryChanged', + channel: ROOT_STATE_URI, + session: backendSession.toString(), + changes: { status: SessionStatus.Idle, _meta: { [SESSION_META_EHCLI_ADOPTED_KEY]: true } }, + }); + + assert.deepStrictEqual({ + beforeAdoption, + afterAdoption: listController.items.map(item => item.label), + }, { + beforeAdoption: ['Worktree session'], + afterAdoption: ['Worktree session'], + }); + }); + + test('a summary change still clears host-cleared markers on a non-legacy session', async () => { + // The adoption carry-forward must not turn `_meta` into an append-only bag: + // a session that was never adoptable keeps the host's replacement verbatim. + const { instantiationService, agentHostService } = createTestServices(disposables); + const backendSession = AgentSession.uri('copilot', 'clearable-meta'); + agentHostService.addSession({ + session: backendSession, + startTime: 1000, + modifiedTime: 2000, + summary: 'Plain session', + _meta: { workspaceless: true }, + }); + const listController = createSessionListController(disposables, instantiationService, agentHostService); + await listController.refresh(CancellationToken.None); + + agentHostService.fireNotification({ + type: 'root/sessionSummaryChanged', + channel: ROOT_STATE_URI, + session: backendSession.toString(), + changes: { _meta: {} }, + }); + + const store = (listController as unknown as { _sessionListStore: { getSessions(provider: string): readonly { summary: SessionSummary }[] } })._sessionListStore; + assert.deepStrictEqual( + store.getSessions('copilot').map(entry => entry.summary._meta), + [{}], + ); + }); + test('archive mutations dispatch through AHP and reconcile server summaries', async () => { const { instantiationService, agentHostService } = createTestServices(disposables); const backendSession = AgentSession.uri('copilot', 'archivable'); @@ -3436,6 +3512,35 @@ suite('AgentHostChatContribution', () => { assert.deepStrictEqual(listController.items.map(item => item.label), ['Legacy worktree session']); }); + test('a migrated legacy worktree session stays listed after adoption clears the adoptable marker', async () => { + const { instantiationService, agentHostService } = createTestServices(disposables); + + const folder = URI.file('/src/repo'); + instantiationService.stub(IWorkspaceContextService, { + getWorkbenchState: () => WorkbenchState.FOLDER, + getWorkspace: () => ({ id: 'folder', folders: [{ uri: folder, name: 'repo', index: 0, toResource: () => folder }] }), + getWorkspaceFolder: () => null, + onDidChangeWorkspaceFolders: Event.None, + }); + + // Adoption drops `ehcliAdoptable` and leaves the durable adopted marker + // behind; the session must not fall out of the list on migration. + agentHostService.addSession({ + session: AgentSession.uri('copilot', 'adopted-worktree'), + startTime: 1000, + modifiedTime: 2000, + summary: 'Adopted worktree session', + workingDirectories: [URI.file('/src/repo.worktrees/feature')], + project: { uri: folder, displayName: 'repo' }, + _meta: { [SESSION_META_EHCLI_ADOPTED_KEY]: true }, + }); + + const listController = createSessionListController(disposables, instantiationService, agentHostService); + await listController.refresh(CancellationToken.None); + + assert.deepStrictEqual(listController.items.map(item => item.label), ['Adopted worktree session']); + }); + test('sessionAdded notification filters out sessions outside the workspace', async () => { const { instantiationService, agentHostService } = createTestServices(disposables); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLegacyMigration.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLegacyMigration.test.ts index e3a17cc45d1faf..dac20b22bb5e70 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLegacyMigration.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLegacyMigration.test.ts @@ -15,6 +15,7 @@ import { mock } from '../../../../../../base/test/common/mock.js'; import { adoptLegacyCopilotCliResource } from '../../../browser/agentSessions/agentHost/agentHostLegacyMigration.js'; import { COPILOT_CLI_AGENT_PROVIDER, COPILOT_CLI_EH_SCHEME, COPILOT_CLI_LOCAL_AH_SCHEME } from '../../../browser/copilotCliEventsUri.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ChatConfiguration } from '../../../common/constants.js'; @@ -24,6 +25,17 @@ const migrationOn: IConfigurationService = new TestConfigurationService({ [ChatC suite('AgentHost legacy Copilot CLI migration', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + /** Records probe outcomes so each path's telemetry can be asserted. */ + let outcomes: string[]; + let telemetry: ITelemetryService; + setup(() => { + outcomes = []; + telemetry = new class extends mock() { + override publicLog2(_name: string, data?: E): void { + outcomes.push((data as { outcome: string }).outcome); + } + }; + }); const RAW_ID = 'sess-abc'; const legacyResource = URI.from({ scheme: COPILOT_CLI_EH_SCHEME, path: `/${RAW_ID}` }); const twinResource = URI.from({ scheme: COPILOT_CLI_LOCAL_AH_SCHEME, path: `/${RAW_ID}` }); @@ -58,11 +70,11 @@ suite('AgentHost legacy Copilot CLI migration', () => { test('redirects to the agent-host twin once the subscription carries state', async () => { const { connection, subscribed } = createConnection('adopted'); - const resolved = await adoptLegacyCopilotCliResource(connection, legacyResource, new NullLogService(), migrationOn); + const resolved = await adoptLegacyCopilotCliResource(connection, legacyResource, new NullLogService(), migrationOn, telemetry, 'open'); assert.deepStrictEqual( - { resolved: resolved?.toString(), subscribed: subscribed.map(s => s.toString()) }, - { resolved: twinResource.toString(), subscribed: [backendChannel.toString()] }, + { resolved: resolved?.toString(), subscribed: subscribed.map(s => s.toString()), outcomes }, + { resolved: twinResource.toString(), subscribed: [backendChannel.toString()], outcomes: ['adopted'] }, ); }); @@ -88,43 +100,44 @@ suite('AgentHost legacy Copilot CLI migration', () => { } }; - assert.strictEqual(await adoptLegacyCopilotCliResource(connection, legacyResource, new NullLogService(), migrationOn), undefined); + assert.strictEqual(await adoptLegacyCopilotCliResource(connection, legacyResource, new NullLogService(), migrationOn, telemetry, 'open'), undefined); }); test('retries after a refusal instead of pinning the session to the legacy path', async () => { const { connection, subscribed } = createConnection('refused'); - const first = await adoptLegacyCopilotCliResource(connection, legacyResource, new NullLogService(), migrationOn); - const second = await adoptLegacyCopilotCliResource(connection, legacyResource, new NullLogService(), migrationOn); + const first = await adoptLegacyCopilotCliResource(connection, legacyResource, new NullLogService(), migrationOn, telemetry, 'open'); + const second = await adoptLegacyCopilotCliResource(connection, legacyResource, new NullLogService(), migrationOn, telemetry, 'open'); // The host reports every restore failure as SessionNotFound, so a refusal // cannot be told apart from a transient one and must not be remembered. assert.deepStrictEqual( - { first, second, subscribes: subscribed.length }, - { first: undefined, second: undefined, subscribes: 2 }, + { first, second, subscribes: subscribed.length, outcomes }, + { first: undefined, second: undefined, subscribes: 2, outcomes: ['declined', 'declined'] }, ); }); test('never probes a resource that is not a legacy Copilot CLI session', async () => { const { connection, subscribed } = createConnection('adopted'); - const resolved = await adoptLegacyCopilotCliResource(connection, twinResource, new NullLogService(), migrationOn); + const resolved = await adoptLegacyCopilotCliResource(connection, twinResource, new NullLogService(), migrationOn, telemetry, 'open'); - assert.deepStrictEqual({ resolved, subscribed }, { resolved: undefined, subscribed: [] }); + // Not a migration opportunity at all, so it must not even be counted. + assert.deepStrictEqual({ resolved, subscribed, outcomes }, { resolved: undefined, subscribed: [], outcomes: [] }); }); test('does nothing while the migration setting is off', async () => { const { connection, subscribed } = createConnection('adopted'); const migrationOff: IConfigurationService = new TestConfigurationService(); - const resolved = await adoptLegacyCopilotCliResource(connection, legacyResource, new NullLogService(), migrationOff); + const resolved = await adoptLegacyCopilotCliResource(connection, legacyResource, new NullLogService(), migrationOff, telemetry, 'open'); // The host restores a session whether or not it adopts it, so without this // gate a user who never opted in would still be moved onto the agent host. - assert.deepStrictEqual({ resolved, subscribed }, { resolved: undefined, subscribed: [] }); + assert.deepStrictEqual({ resolved, subscribed, outcomes }, { resolved: undefined, subscribed: [], outcomes: ['settingDisabled'] }); }); test('declines without probing when there is no connection', async () => { - assert.strictEqual(await adoptLegacyCopilotCliResource(undefined, legacyResource, new NullLogService(), migrationOn), undefined); + assert.strictEqual(await adoptLegacyCopilotCliResource(undefined, legacyResource, new NullLogService(), migrationOn, telemetry, 'open'), undefined); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsOpener.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsOpener.test.ts index b3b7aeb7f45ee4..c1b7ccf18513e9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsOpener.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsOpener.test.ts @@ -4,14 +4,23 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Event } from '../../../../../../base/common/event.js'; +import { IReference } from '../../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { NullTelemetryService } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; +import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { ChatConfiguration } from '../../../common/constants.js'; import { IAgentSession } from '../../../browser/agentSessions/agentSessionsModel.js'; -import { openSessionByResource, ISessionOpenerParticipant, sessionOpenerRegistry } from '../../../browser/agentSessions/agentSessionsOpener.js'; +import { openSession, openSessionByResource, ISessionOpenerParticipant, sessionOpenerRegistry } from '../../../browser/agentSessions/agentSessionsOpener.js'; import { IAgentSessionsService } from '../../../browser/agentSessions/agentSessionsService.js'; suite('AgentSessionsOpener', () => { @@ -77,4 +86,106 @@ suite('AgentSessionsOpener', () => { assert.deepStrictEqual({ resolvedResource, handledSession }, { resolvedResource: resource, handledSession: session }); }); + + test('surfaces a just-migrated session before opening it', async () => { + // Adoption registers the twin with the host, but the list only learns about + // it on the next provider refresh — without that refresh the open reverts to + // the legacy session it just migrated away from. + const legacy = URI.parse('copilotcli:/sess-1'); + const twin = URI.parse('agent-host-copilotcli:/sess-1'); + const twinSession = upcastPartial({ resource: twin }); + + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(ITelemetryService, NullTelemetryService); + instantiationService.stub(IConfigurationService, new TestConfigurationService({ [ChatConfiguration.MigrateLegacyCopilotCliSessions]: true })); + // A host that answers the adoption probe with state, i.e. migration succeeded. + instantiationService.stub(IAgentHostConnectionsService, upcastPartial({ + ambientConnection: new class extends mock() { + override getSubscription(): IReference> { + return { + object: upcastPartial>({ value: {} as T, onDidChange: Event.None, onDidError: Event.None }), + dispose: () => { }, + }; + } + }, + })); + const resolvedProviders: (string | string[] | undefined)[] = []; + let surfaced = false; + instantiationService.stub(IAgentSessionsService, upcastPartial({ + getSession: candidate => (surfaced && candidate.toString() === twin.toString()) ? twinSession : undefined, + model: upcastPartial({ + resolve: async provider => { + resolvedProviders.push(provider); + surfaced = true; + }, + }), + })); + + let handledSession: IAgentSession | undefined; + const participant: ISessionOpenerParticipant = { + handleOpenSession: async (_accessor, candidate) => { + handledSession = candidate; + return true; + }, + handleOpenSessionResource: async () => false, + }; + const registration = sessionOpenerRegistry.registerParticipant(participant); + + try { + await instantiationService.invokeFunction(openSessionByResource, legacy); + } finally { + registration.dispose(); + } + + assert.deepStrictEqual( + { handled: handledSession?.resource.toString(), resolvedProviders }, + { handled: twin.toString(), resolvedProviders: ['agent-host-copilotcli'] }, + ); + }); + + test('a list click opens the migrated session, not the legacy one it came from', async () => { + // The path Rob hit: adoption succeeded, but the twin was not in the list yet, + // so the open silently reverted to the legacy session. + const legacy = URI.parse('copilotcli:/sess-2'); + const twin = URI.parse('agent-host-copilotcli:/sess-2'); + const legacySession = upcastPartial({ resource: legacy }); + const twinSession = upcastPartial({ resource: twin }); + + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(ITelemetryService, NullTelemetryService); + instantiationService.stub(IConfigurationService, new TestConfigurationService({ [ChatConfiguration.MigrateLegacyCopilotCliSessions]: true })); + instantiationService.stub(IAgentHostConnectionsService, upcastPartial({ + ambientConnection: new class extends mock() { + override getSubscription(): IReference> { + return { + object: upcastPartial>({ value: {} as T, onDidChange: Event.None, onDidError: Event.None }), + dispose: () => { }, + }; + } + }, + })); + let surfaced = false; + instantiationService.stub(IAgentSessionsService, upcastPartial({ + getSession: candidate => (surfaced && candidate.toString() === twin.toString()) ? twinSession : undefined, + model: upcastPartial({ resolve: async () => { surfaced = true; } }), + })); + + let handledSession: IAgentSession | undefined; + const registration = sessionOpenerRegistry.registerParticipant({ + handleOpenSession: async (_accessor, candidate) => { + handledSession = candidate; + return true; + }, + }); + + try { + await instantiationService.invokeFunction(openSession, legacySession); + } finally { + registration.dispose(); + } + + assert.strictEqual(handledSession?.resource.toString(), twin.toString()); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts index e56261daaea81a..1868cfb778851a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts @@ -18,6 +18,7 @@ import { IAgentHostConnectionsService } from '../../../../../../../platform/agen import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../../platform/log/common/log.js'; +import { NullTelemetryService } from '../../../../../../../platform/telemetry/common/telemetryUtils.js'; import { IStorageService } from '../../../../../../../platform/storage/common/storage.js'; import { IWorkspaceContextService } from '../../../../../../../platform/workspace/common/workspace.js'; import { isResourceEditorInput } from '../../../../../../common/editor.js'; @@ -71,6 +72,7 @@ suite('ChatEditorInput', () => { new TestContextService(), { _serviceBrand: undefined, enabled: constObservable(false), managedSandboxEnforced: constObservable(false) }, { ambientConnection: undefined } as unknown as IAgentHostConnectionsService, + NullTelemetryService, ); try { @@ -127,6 +129,7 @@ suite('ChatEditorInput', () => { new TestContextService(), { _serviceBrand: undefined, enabled: constObservable(false), managedSandboxEnforced: constObservable(false) }, { ambientConnection: undefined } as unknown as IAgentHostConnectionsService, + NullTelemetryService, ); try { From 771cd9d5d7583f7fa4686403d068b0075e6b3ca1 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Fri, 21 Aug 2026 12:06:36 -0700 Subject: [PATCH 11/21] Update chat in editor titles after rename tool (#331509) * chat: update editor titles after rename tool Fixes #331487 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: preserve single-chat rename titles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: ignore rejected title actions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: narrow editor title synchronization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: synchronize default chat and session titles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: preserve restored chat state during title snapshot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: align default chat rename flows Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: preserve default chat title provenance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: order default chat title snapshots Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/sessionDataService.ts | 6 + .../agentHost/node/agentHostStateManager.ts | 20 +- .../platform/agentHost/node/agentService.ts | 33 +-- .../agentHost/node/agentSideEffects.ts | 38 +++- .../node/localCommands/renameLocalCommand.ts | 10 +- .../agentHost/node/sessionDatabase.ts | 27 +++ .../node/shared/sessionServerTools.ts | 2 +- .../test/common/sessionTestHelpers.ts | 18 ++ .../test/node/agentHostStateManager.test.ts | 37 ++++ .../agentHost/test/node/agentService.test.ts | 61 +++++- .../test/node/agentSideEffects.test.ts | 170 ++++++++++++++ .../test/node/sessionDatabase.test.ts | 54 +++++ .../test/node/sessionServerTools.test.ts | 3 + .../agentHost/agentHostSessionHandler.ts | 39 +++- .../agentHostChatContribution.test.ts | 207 ++++++++++++++++++ 15 files changed, 677 insertions(+), 48 deletions(-) diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts index 171cc7d6ebf71a..7f78220de4d1c3 100644 --- a/src/vs/platform/agentHost/common/sessionDataService.ts +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -290,6 +290,12 @@ export interface ISessionDatabase extends IDisposable { */ setMetadataValues(values: Readonly>): Promise; + /** + * Atomically stores metadata values only when `key` is absent. Values named + * by `copies` are read from their source keys and copied when present. + */ + setMetadataValuesIfAbsent(key: string, values: Readonly>, copies?: Readonly>): Promise; + /** * Store or clear the draft for a chat in this session. */ diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index bf2863810ce395..d702f760906096 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -280,6 +280,8 @@ export class AgentHostStateManager extends Disposable { private readonly _onDidChangeSessionTitle = this._register(new Emitter<{ session: string; title: string }>()); readonly onDidChangeSessionTitle: Event<{ session: string; title: string }> = this._onDidChangeSessionTitle.event; + private readonly _onDidSnapshotDefaultChatTitle = this._register(new Emitter<{ session: string; chat: string; title: string }>()); + readonly onDidSnapshotDefaultChatTitle: Event<{ session: string; chat: string; title: string }> = this._onDidSnapshotDefaultChatTitle.event; private readonly _onDidChangeSessionConfig = this._register(new Emitter<{ session: URI; previous: SessionConfigState | undefined; current: SessionConfigState | undefined; clientContext?: IAgentHostClientTelemetryContext }>()); readonly onDidChangeSessionConfig: Event<{ session: URI; previous: SessionConfigState | undefined; current: SessionConfigState | undefined; clientContext?: IAgentHostClientTelemetryContext }> = this._onDidChangeSessionConfig.event; @@ -1025,11 +1027,7 @@ export class AgentHostStateManager extends Disposable { // titles become fully independent. Without this the default chat keeps // an empty title (= inherit the session title), so renaming the session // would also move the default chat tab and vice-versa. - const defaultChatUri = sessionState.defaultChat ?? buildDefaultChatUri(session); - const defaultEntry = sessionState.chats.find(c => c.resource === defaultChatUri); - if (defaultEntry && !defaultEntry.title && sessionState.title) { - this.updateChatTitle(session, defaultChatUri, sessionState.title); - } + this._snapshotDefaultChatTitle(session, sessionState); const chatSummary: ChatSummary = { ...createDefaultChatSummary(this._toSummary(session, entry), chatUri), @@ -1071,6 +1069,7 @@ export class AgentHostStateManager extends Disposable { } return existing; } + this._snapshotDefaultChatTitle(session, sessionState); const chatSummary: ChatSummary = { ...createDefaultChatSummary(this._toSummary(session, entry), chatUri), title: options.title ?? '', @@ -1081,7 +1080,7 @@ export class AgentHostStateManager extends Disposable { ...(options.origin ? { origin: options.origin } : {}), interactivity: options.interactivity, }; - sessionState.chats = [...sessionState.chats, chatSummary]; + entry.state.chats = [...entry.state.chats, chatSummary]; this._chatEntries.set(chatUri, { session, summary: chatSummary, @@ -1093,6 +1092,15 @@ export class AgentHostStateManager extends Disposable { return chatSummary; } + private _snapshotDefaultChatTitle(session: URI, state: SessionState): void { + const defaultChat = buildDefaultChatUri(session); + const summary = state.chats.find(chat => chat.resource === defaultChat); + if (summary && !summary.title && state.title) { + this.updateChatTitle(session, defaultChat, state.title); + this._onDidSnapshotDefaultChatTitle.fire({ session, chat: defaultChat, title: state.title }); + } + } + /** * Removes an additional chat from a session. Deletes its * {@link ChatState}, dispatches {@link ActionType.SessionChatRemoved}, and diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 6722bd8ed8d797..5064727c154cd5 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -1222,17 +1222,6 @@ export class AgentService extends Disposable implements IAgentService { private async _renameChatFromTool(session: URI, chat: URI, title: string): Promise { validateRenameTitle(title, SessionServerToolName.RenameChat); const isDefaultChat = isDefaultChatUri(chat.toString()); - if (isDefaultChat && await this._isOnlySessionChat(session)) { - await persistSessionMetadataValues(this._sessionDataService, session.toString(), { - [SESSION_CUSTOM_TITLE_KEY]: title, - [SESSION_CUSTOM_TITLE_SOURCE_KEY]: AGENT_HOST_TITLE_SOURCE_AGENT, - }); - if (this._stateManager.getSessionState(session.toString())?.title !== title) { - this._stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionTitleChanged, title }); - } - this._sideEffects.markTitleRenamed(session.toString()); - return { title }; - } if (!isDefaultChat && !await this._peerChatExists(session, chat)) { throw new Error(`Invalid ${SessionServerToolName.RenameChat} input: chat must match a known non-default chat.`); } @@ -1240,23 +1229,25 @@ export class AgentService extends Disposable implements IAgentService { await persistSessionMetadataValues(this._sessionDataService, session.toString(), { [customChatTitleMetadataKey(chat.toString())]: title, [customChatTitleSourceMetadataKey(chat.toString())]: AGENT_HOST_TITLE_SOURCE_AGENT, + ...(isDefaultChat ? { + [SESSION_CUSTOM_TITLE_KEY]: title, + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: AGENT_HOST_TITLE_SOURCE_AGENT, + } : {}), }); - if (this._stateManager.getSessionState(session.toString())) { + const state = this._stateManager.getSessionState(session.toString()); + if (state) { + if (isDefaultChat && state.title !== title) { + this._stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionTitleChanged, title }); + } this._stateManager.updateChatTitle(session.toString(), chat.toString(), title); } + if (isDefaultChat) { + this._sideEffects.markTitleRenamed(session.toString()); + } this._sideEffects.markTitleRenamed(session.toString(), chat.toString()); return { title }; } - private async _isOnlySessionChat(session: URI): Promise { - const state = this._stateManager.getSessionState(session.toString()); - if (state) { - return state.chats.length === 1; - } - const persisted = await this._readPersistedPeerChatCatalog(session); - return persisted?.length === 0; - } - private async _peerChatExists(session: URI, chat: URI): Promise { if (this._stateManager.getSessionState(session.toString())?.chats.some(candidate => candidate.resource === chat.toString())) { return true; diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index b140e4ff382a25..ca2f65b70fde5e 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -304,6 +304,7 @@ export class AgentSideEffects extends Disposable { copilotApiService: this._options.copilotApiService, isActiveAgentTitleGenerationEnabled: () => this._agentConfigService.getRootValue(platformRootSchema, AgentHostActiveAgentTitleGenerationConfigKey) === true, })); + this._register(this._stateManager.onDidSnapshotDefaultChatTitle(event => this._persistDefaultChatTitleSnapshot(event.session, event.chat, event.title))); this._localCommands = this._register(instantiationService.createInstance( AgentHostLocalCommands, this._stateManager, @@ -1720,13 +1721,16 @@ export class AgentSideEffects extends Disposable { } case ActionType.SessionTitleChanged: { if (chatChannel) { - // The rename targeted a specific chat (default or additional), - // not the whole session. Route it to a per-chat title update so - // the session title stays independent. this._stateManager.updateChatTitle(sessionChannel, chatChannel, action.title); this._persistSessionFlag(sessionChannel, customChatTitleMetadataKey(chatChannel), action.title); this._persistSessionFlag(sessionChannel, customChatTitleSourceMetadataKey(chatChannel), AGENT_HOST_TITLE_SOURCE_USER); this._titleController.markTitleRenamed(sessionChannel, chatChannel); + if (isDefaultChatUri(chatChannel)) { + this._stateManager.dispatchServerAction(sessionChannel, action); + this._persistSessionFlag(sessionChannel, SESSION_CUSTOM_TITLE_KEY, action.title); + this._persistSessionFlag(sessionChannel, SESSION_CUSTOM_TITLE_SOURCE_KEY, AGENT_HOST_TITLE_SOURCE_USER); + this._titleController.markTitleRenamed(sessionChannel); + } break; } this._persistSessionFlag(channel, SESSION_CUSTOM_TITLE_KEY, action.title); @@ -1927,6 +1931,34 @@ export class AgentSideEffects extends Disposable { persistSessionMetadata(this._options.sessionDataService, this._logService, session, key, value); } + private _persistDefaultChatTitleSnapshot(session: ProtocolURI, chat: ProtocolURI, title: string): void { + const ref = (() => { + try { + return this._options.sessionDataService.openDatabase(URI.parse(session)); + } catch (error) { + this._logService.warn('[AgentSideEffects] Failed to open session database for default chat title snapshot', error); + return undefined; + } + })(); + if (!ref) { + return; + } + const persist = async () => { + if (this._stateManager.getChatState(chat)?.title !== title) { + return; + } + const titleKey = customChatTitleMetadataKey(chat); + await ref.object.setMetadataValuesIfAbsent( + titleKey, + { [titleKey]: title }, + { [customChatTitleSourceMetadataKey(chat)]: SESSION_CUSTOM_TITLE_SOURCE_KEY }, + ); + }; + void persist().catch(error => { + this._logService.warn('[AgentSideEffects] Failed to persist default chat title snapshot', error); + }).finally(() => ref.dispose()); + } + /** * Persists the usage reported for a chat's turn. * diff --git a/src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts b/src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts index d3e4ea5cca2f1e..f3d222b79f4ba9 100644 --- a/src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts +++ b/src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts @@ -40,15 +40,19 @@ export class RenameLocalCommand extends Disposable implements ILocalChatCommand // completes the turn. return; } - const isAdditional = (uri: ProtocolURI): boolean => isAhpChatChannel(uri) && !isDefaultChatUri(uri); - const chatTarget = isAdditional(channel) ? channel : undefined; + const chatTarget = isAhpChatChannel(channel) ? channel : undefined; const sessionChannel = isAhpChatChannel(channel) ? parseRequiredSessionUriFromChatUri(channel) : channel; if (chatTarget) { - // Rename only this chat, independently of the session title. this._context.updateChatTitle(sessionChannel, chatTarget, title); this._context.markTitleRenamed(sessionChannel, chatTarget); this._context.persistSessionFlag(sessionChannel, customChatTitleMetadataKey(chatTarget), title); this._context.persistSessionFlag(sessionChannel, customChatTitleSourceMetadataKey(chatTarget), AGENT_HOST_TITLE_SOURCE_USER); + if (isDefaultChatUri(chatTarget)) { + this._context.dispatch(sessionChannel, { type: ActionType.SessionTitleChanged, title }); + this._context.markTitleRenamed(sessionChannel); + this._context.persistSessionFlag(sessionChannel, SESSION_CUSTOM_TITLE_KEY, title); + this._context.persistSessionFlag(sessionChannel, SESSION_CUSTOM_TITLE_SOURCE_KEY, AGENT_HOST_TITLE_SOURCE_USER); + } } else { this._context.dispatch(sessionChannel, { type: ActionType.SessionTitleChanged, title }); this._context.markTitleRenamed(sessionChannel); diff --git a/src/vs/platform/agentHost/node/sessionDatabase.ts b/src/vs/platform/agentHost/node/sessionDatabase.ts index b476bd8bb58be2..df9a7fa8708d13 100644 --- a/src/vs/platform/agentHost/node/sessionDatabase.ts +++ b/src/vs/platform/agentHost/node/sessionDatabase.ts @@ -700,6 +700,33 @@ export class SessionDatabase implements ISessionDatabase { })); } + setMetadataValuesIfAbsent(key: string, values: Readonly>, copies: Readonly> = {}): Promise { + return this._track(() => this._metadataSequencer.queue(async () => { + const db = await this._ensureDb(); + return this._transactionSequencer.queue(async () => { + await dbExec(db, 'BEGIN TRANSACTION'); + try { + const existing = await dbGet(db, 'SELECT 1 FROM session_metadata WHERE key = ?', [key]); + if (existing) { + await dbExec(db, 'COMMIT'); + return false; + } + for (const [targetKey, value] of Object.entries(values)) { + await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)', [targetKey, value]); + } + for (const [targetKey, sourceKey] of Object.entries(copies)) { + await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) SELECT ?, value FROM session_metadata WHERE key = ?', [targetKey, sourceKey]); + } + await dbExec(db, 'COMMIT'); + return true; + } catch (err) { + await dbExec(db, 'ROLLBACK'); + throw err; + } + }); + })); + } + setChatDraft(chat: URI, draft: Message | undefined): Promise { const chatUri = chat.toString(); return this._track(async () => { diff --git a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts index 045fd09c7378e5..4834fc54ef8605 100644 --- a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts @@ -169,7 +169,7 @@ export const sessionServerToolDefinitions: IAgentServerToolDefinition[] = [ { name: SessionServerToolName.RenameChat, title: 'Rename Chat', - description: 'Rename one specific chat so it is easy to find later. When a session has only its default chat, renaming that chat also names the session. Once the session has multiple chats, only the targeted chat is renamed. Use a short, human-friendly chat name in sentence case (1-4 words). Pass an `agent-host-session://` session or chat link to target another chat, or omit `chat` to rename the chat in which this tool is running. Name a fresh chat once its scope is clear, typically soon after `create_chat` or early in that chat. Call this tool again whenever the user explicitly asks to rename the chat; every invocation replaces the current title.', + description: 'Rename one specific chat so it is easy to find later. Renaming the default chat also names its owning session, while peer-chat titles remain independent. Use a short, human-friendly chat name in sentence case (1-4 words). Pass an `agent-host-session://` session or chat link to target another chat, or omit `chat` to rename the chat in which this tool is running. Name a fresh chat once its scope is clear, typically soon after `create_chat` or early in that chat. Call this tool again whenever the user explicitly asks to rename the chat; every invocation replaces the current title.', inputSchema: renameChatInputSchema, annotations: { readOnlyHint: false }, }, diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index 85aca98b3b31b8..206b8b62224e76 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -89,6 +89,24 @@ export class TestSessionDatabase implements ISessionDatabase { } } + async setMetadataValuesIfAbsent(key: string, values: Readonly>, copies: Readonly> = {}): Promise { + if (this._metadata.has(key)) { + return false; + } + for (const [targetKey, value] of Object.entries(values)) { + this.setMetadataCalls.push({ key: targetKey, value }); + this._metadata.set(targetKey, value); + } + for (const [targetKey, sourceKey] of Object.entries(copies)) { + const value = this._metadata.get(sourceKey); + if (value !== undefined) { + this.setMetadataCalls.push({ key: targetKey, value }); + this._metadata.set(targetKey, value); + } + } + return true; + } + async setChatDraft(chat: URI, draft: Message | undefined): Promise { const key = chat.toString(); if (draft) { diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 5092d017a0a12e..3f1a9c2b60353a 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -1156,6 +1156,43 @@ suite('AgentHostStateManager', () => { ); }); + test('restored peer chat snapshots the inherited default chat title', () => { + manager.restoreSession(makeSessionSummary(), []); + const defaultChat = buildDefaultChatUri(sessionUri); + const beforeRestore = manager.getSessionState(sessionUri)?.chats.find(chat => chat.resource === defaultChat)?.title; + + manager.registerRestoredChatSummary(sessionUri, peerChat, { title: 'Peer' }); + + assert.deepStrictEqual({ + beforeRestore, + afterRestore: manager.getSessionState(sessionUri)?.chats.find(chat => chat.resource === defaultChat)?.title, + }, { + beforeRestore: '', + afterRestore: 'Test', + }); + }); + + test('adding a chat snapshots the canonical default when routing defaults to a peer', () => { + manager.createSession(makeSessionSummary()); + const canonicalDefault = buildDefaultChatUri(sessionUri); + const peer2 = buildChatUri(sessionUri, 'peer-2'); + manager.addChat(sessionUri, peerChat, { title: 'Peer' }); + manager.updateChatTitle(sessionUri, canonicalDefault, ''); + manager.updateChatTitle(sessionUri, peerChat, ''); + manager.dispatchServerAction(sessionUri, { type: ActionType.SessionDefaultChatChanged, defaultChat: peerChat }); + + manager.addChat(sessionUri, peer2, { title: 'Peer 2' }); + + const state = manager.getSessionState(sessionUri); + assert.deepStrictEqual({ + canonicalDefaultTitle: state?.chats.find(chat => chat.resource === canonicalDefault)?.title, + routingDefaultTitle: state?.chats.find(chat => chat.resource === peerChat)?.title, + }, { + canonicalDefaultTitle: 'Test', + routingDefaultTitle: '', + }); + }); + test('addChat is idempotent for an existing chat URI', () => { manager.createSession(makeSessionSummary()); const first = manager.addChat(sessionUri, peerChat, { title: 'Peer' }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index aa5226380b3269..8193c6cb0f03ad 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -9886,6 +9886,16 @@ suite('AgentService (node dispatcher)', () => { return []; } + async function waitForMetadata(db: TestSessionDatabase, key: string, expected: string): Promise { + for (let i = 0; i < 50; i++) { + if (await db.getMetadata(key) === expected) { + return; + } + await timeout(0); + } + assert.fail(`Metadata '${key}' did not become '${expected}'`); + } + test('rolls back a new peer chat when its catalog entry cannot be persisted', async () => { class FailingPeerCatalogDatabase extends TestSessionDatabase { failPeerCatalogWrites = false; @@ -9968,6 +9978,39 @@ suite('AgentService (node dispatcher)', () => { assert.ok(!registered.includes(AgentSession.uri('copilot', 'restored-peer-backing-sdk-id').toString()), 'the backing session must not leak into the registered session list'); }); + test('restores the snapshotted default chat title after the session is renamed', async () => { + class MultiChatAgent extends MockAgent { + override async createChat(): Promise { } + } + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MultiChatAgent('copilot')); + localService.registerProvider(agent); + const session = await localService.createSession({ provider: 'copilot' }); + const sessionUri = session.toString(); + const defaultChat = buildDefaultChatUri(session); + const peerChat = URI.parse(buildChatUri(session, 'peer')); + localService.dispatchAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'Default A' }, 'test-client', 1); + await waitForMetadata(db, 'customTitle', 'Default A'); + + await localService.createChat(session, peerChat); + await waitForMetadata(db, `customChatTitle:${defaultChat}`, 'Default A'); + localService.dispatchAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'Session B' }, 'test-client', 2); + await waitForMetadata(db, 'customTitle', 'Session B'); + + localService.stateManager.deleteSession(sessionUri); + await localService.restoreSession(session); + + const restored = localService.stateManager.getSessionState(sessionUri); + assert.deepStrictEqual({ + sessionTitle: restored?.title, + defaultChatTitle: restored?.chats.find(chat => chat.resource === defaultChat)?.title, + }, { + sessionTitle: 'Session B', + defaultChatTitle: 'Default A', + }); + }); + test('restore registers peer-chat metadata in catalog order and loads history on first access', async () => { const calls: { call: string; uri: string; providerData?: string }[] = []; class MultiChatAgent extends MockAgent { @@ -11079,6 +11122,14 @@ suite('AgentService (node dispatcher)', () => { await db.setMetadata('customTitleSource', 'user'); await db.setMetadata(`customChatTitle:${peerChat}`, 'Previous peer title'); await db.setMetadata(`customChatTitleSource:${peerChat}`, 'user'); + await timeout(0); + localService.stateManager.prepareSessionSummariesForListing([localService.stateManager.getSessionSummary(sessionUri)!]); + const summaryTitleChanged = new DeferredPromise(); + disposables.add(localService.onDidNotification(notification => { + if (notification.type === NotificationType.SessionSummaryChanged && notification.changes.title) { + void summaryTitleChanged.complete(notification.changes.title); + } + })); const multiChatDefaultResult = await agent.serverToolHost!.executeTool(defaultChat, SessionServerToolName.RenameChat, { title: 'Complete replacement default chat title', @@ -11088,7 +11139,7 @@ suite('AgentService (node dispatcher)', () => { title: 'Complete replacement peer chat title', }); await db.finalRenamePersisted.p; - await timeout(0); + const summaryTitleChange = await summaryTitleChanged.p; assert.deepStrictEqual({ singleChatResult, @@ -11103,19 +11154,21 @@ suite('AgentService (node dispatcher)', () => { persistedDefaultChatSource: await db.getMetadata(`customChatTitleSource:${defaultChat}`), persistedChatTitle: await db.getMetadata(`customChatTitle:${peerChat}`), persistedChatSource: await db.getMetadata(`customChatTitleSource:${peerChat}`), + summaryTitleChange, }, { singleChatResult: 'Renamed chat to "Single-chat title".', multiChatDefaultResult: 'Renamed chat to "Complete replacement default chat title".', chatResult: 'Renamed chat to "Complete replacement peer chat title".', - liveSessionTitle: 'Multi-chat session title', + liveSessionTitle: 'Complete replacement default chat title', liveDefaultChatTitle: 'Complete replacement default chat title', liveChatTitle: 'Complete replacement peer chat title', - persistedSessionTitle: 'Multi-chat session title', - persistedSessionSource: 'user', + persistedSessionTitle: 'Complete replacement default chat title', + persistedSessionSource: 'agent', persistedDefaultChatTitle: 'Complete replacement default chat title', persistedDefaultChatSource: 'agent', persistedChatTitle: 'Complete replacement peer chat title', persistedChatSource: 'agent', + summaryTitleChange: 'Complete replacement default chat title', }); }); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index c9683ab91d07f3..2614d34e0dc217 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -50,6 +50,7 @@ import { withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; import { AgentHostCustomizationEnablementService, IAgentHostCustomizationEnablementService } from '../../node/agentHostCustomizationEnablementService.js'; import { AgentHostStorageService } from '../../node/agentHostStorageService.js'; import { applyMcpServerEnablement } from '../../node/shared/mcpCustomizationController.js'; +import { customChatTitleMetadataKey, customChatTitleSourceMetadataKey, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from '../../node/shared/persistSessionMetadata.js'; import { createNoopGitService, createNullSessionDataService, createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; import { MockAgent } from './mockAgent.js'; import { TestAgentHostTerminalManager } from './testAgentHostTerminalManager.js'; @@ -1593,6 +1594,30 @@ suite('AgentSideEffects', () => { assert.deepStrictEqual(agent.sendMessageCalls, []); const state = stateManager.getSessionState(sessionUri.toString()); assert.strictEqual(state?.title, 'Test'); + }); + + test('/rename updates both the session and default chat title once multi-chat', async () => { + setupSession(); + const renameSideEffects = createRenameSideEffects(); + stateManager.addChat(sessionUri.toString(), buildChatUri(sessionUri.toString(), 'peer'), { title: 'Peer' }); + const action: ChatAction = { + type: ActionType.ChatTurnStarted, + turnId: 'turn-rename', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: '/rename Renamed Default', origin: { kind: MessageKind.User } }, + }; + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + renameSideEffects.handleAction(defaultChatUri, action); + await timeout(10); + + const state = stateManager.getSessionState(sessionUri.toString()); + assert.deepStrictEqual({ + sessionTitle: state?.title, + defaultChatTitle: state?.chats.find(chat => chat.resource === defaultChatUri)?.title, + }, { + sessionTitle: 'Renamed Default', + defaultChatTitle: 'Renamed Default', + }); assert.strictEqual(stateManager.getActiveTurnId(sessionUri.toString()), undefined); }); @@ -4993,6 +5018,151 @@ suite('AgentSideEffects', () => { assert.strictEqual(await waitForMetadata('customTitle'), 'Custom Title'); }); + test('default chat title change updates and persists the session title', async () => { + const sessionDataService = createSessionDataService(sessionDb); + const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const localAgent = new MockAgent(); + disposables.add(toDisposable(() => localAgent.dispose())); + const localSideEffects = createTestSideEffects(disposables, localStateManager, { + getAgent: () => localAgent, + agents: observableValue('agents', [localAgent]), + sessionDataService, + onTurnComplete: () => { }, + }); + const defaultChat = buildDefaultChatUri(sessionUri); + localStateManager.createSession({ + resource: sessionUri.toString(), + provider: 'mock', + title: 'Initial', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }); + localStateManager.addChat(sessionUri.toString(), buildChatUri(sessionUri.toString(), 'peer'), { title: 'Peer' }); + + localSideEffects.handleAction(defaultChat, { + type: ActionType.SessionTitleChanged, + title: 'Renamed Default', + }); + + assert.deepStrictEqual({ + sessionTitle: localStateManager.getSessionState(sessionUri.toString())?.title, + defaultChatTitle: localStateManager.getChatState(defaultChat)?.title, + persistedSessionTitle: await waitForMetadata(SESSION_CUSTOM_TITLE_KEY), + persistedSessionSource: await waitForMetadata(SESSION_CUSTOM_TITLE_SOURCE_KEY), + persistedChatTitle: await waitForMetadata(customChatTitleMetadataKey(defaultChat)), + persistedChatSource: await waitForMetadata(customChatTitleSourceMetadataKey(defaultChat)), + }, { + sessionTitle: 'Renamed Default', + defaultChatTitle: 'Renamed Default', + persistedSessionTitle: 'Renamed Default', + persistedSessionSource: 'user', + persistedChatTitle: 'Renamed Default', + persistedChatSource: 'user', + }); + }); + + test('first peer persists the inherited default chat title and provenance', async () => { + await sessionDb.setMetadata(SESSION_CUSTOM_TITLE_KEY, 'Initial'); + await sessionDb.setMetadata(SESSION_CUSTOM_TITLE_SOURCE_KEY, 'auto'); + const sessionDataService = createSessionDataService(sessionDb); + const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const localAgent = new MockAgent(); + disposables.add(toDisposable(() => localAgent.dispose())); + createTestSideEffects(disposables, localStateManager, { + getAgent: () => localAgent, + agents: observableValue('agents', [localAgent]), + sessionDataService, + onTurnComplete: () => { }, + }); + const defaultChat = buildDefaultChatUri(sessionUri); + localStateManager.createSession({ + resource: sessionUri.toString(), + provider: 'mock', + title: 'Initial', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }); + + localStateManager.addChat(sessionUri.toString(), buildChatUri(sessionUri.toString(), 'peer'), { title: 'Peer' }); + + assert.deepStrictEqual({ + title: await waitForMetadata(customChatTitleMetadataKey(defaultChat)), + source: await waitForMetadata(customChatTitleSourceMetadataKey(defaultChat)), + }, { + title: 'Initial', + source: 'auto', + }); + }); + + test('default chat title snapshot does not overwrite an existing persisted title', async () => { + const defaultChat = buildDefaultChatUri(sessionUri); + await sessionDb.setMetadata(customChatTitleMetadataKey(defaultChat), 'Existing'); + const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const localAgent = new MockAgent(); + disposables.add(toDisposable(() => localAgent.dispose())); + createTestSideEffects(disposables, localStateManager, { + getAgent: () => localAgent, + agents: observableValue('agents', [localAgent]), + sessionDataService: createSessionDataService(sessionDb), + onTurnComplete: () => { }, + }); + localStateManager.createSession({ + resource: sessionUri.toString(), + provider: 'mock', + title: 'Initial', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }); + + localStateManager.addChat(sessionUri.toString(), buildChatUri(sessionUri.toString(), 'peer'), { title: 'Peer' }); + await timeout(10); + + assert.strictEqual(await sessionDb.getMetadata(customChatTitleMetadataKey(defaultChat)), 'Existing'); + }); + + test('a same-turn default chat rename wins after the inherited title snapshot', async () => { + const defaultChat = buildDefaultChatUri(sessionUri); + await sessionDb.setMetadata(SESSION_CUSTOM_TITLE_SOURCE_KEY, 'auto'); + const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const localAgent = new MockAgent(); + disposables.add(toDisposable(() => localAgent.dispose())); + const localSideEffects = createTestSideEffects(disposables, localStateManager, { + getAgent: () => localAgent, + agents: observableValue('agents', [localAgent]), + sessionDataService: createSessionDataService(sessionDb), + onTurnComplete: () => { }, + }); + localStateManager.createSession({ + resource: sessionUri.toString(), + provider: 'mock', + title: 'Initial', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }); + + localStateManager.addChat(sessionUri.toString(), buildChatUri(sessionUri.toString(), 'peer'), { title: 'Peer' }); + localSideEffects.handleAction(defaultChat, { + type: ActionType.SessionTitleChanged, + title: 'Newer', + }); + + assert.deepStrictEqual({ + chatTitle: await waitForMetadata(customChatTitleMetadataKey(defaultChat)), + chatSource: await waitForMetadata(customChatTitleSourceMetadataKey(defaultChat)), + sessionTitle: await waitForMetadata(SESSION_CUSTOM_TITLE_KEY), + sessionSource: await waitForMetadata(SESSION_CUSTOM_TITLE_SOURCE_KEY), + }, { + chatTitle: 'Newer', + chatSource: 'user', + sessionTitle: 'Newer', + sessionSource: 'user', + }); + }); + test('handleListSessions returns persisted custom title', async () => { const sessionDataService = createSessionDataService(sessionDb); const localAgent = new MockAgent(); diff --git a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts index 0629fda731fb41..94827add02e1ca 100644 --- a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts @@ -756,6 +756,60 @@ suite('SessionDatabase', () => { }); }); + test('setMetadataValuesIfAbsent atomically copies source metadata', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.setMetadata('customTitleSource', 'auto'); + + const stored = await db.setMetadataValuesIfAbsent('customChatTitle:default', { + 'customChatTitle:default': 'Inherited title', + }, { + 'customChatTitleSource:default': 'customTitleSource', + }); + + assert.deepStrictEqual({ + stored, + metadata: await db.getMetadataObject({ + 'customChatTitle:default': true, + 'customChatTitleSource:default': true, + }), + }, { + stored: true, + metadata: { + 'customChatTitle:default': 'Inherited title', + 'customChatTitleSource:default': 'auto', + }, + }); + }); + + test('setMetadataValuesIfAbsent preserves existing metadata', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.setMetadataValues({ + 'customChatTitle:default': 'Existing title', + 'customChatTitleSource:default': 'user', + customTitleSource: 'auto', + }); + + const stored = await db.setMetadataValuesIfAbsent('customChatTitle:default', { + 'customChatTitle:default': 'Replacement title', + }, { + 'customChatTitleSource:default': 'customTitleSource', + }); + + assert.deepStrictEqual({ + stored, + metadata: await db.getMetadataObject({ + 'customChatTitle:default': true, + 'customChatTitleSource:default': true, + }), + }, { + stored: false, + metadata: { + 'customChatTitle:default': 'Existing title', + 'customChatTitleSource:default': 'user', + }, + }); + }); + test('setMetadataValues serializes with turn ID remapping transactions', async () => { db = disposables.add(await SessionDatabase.open(':memory:')); await db.createTurn('old-1'); diff --git a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts index 89d98fca9d4b9c..a90ca274fa2300 100644 --- a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts @@ -95,6 +95,9 @@ suite('SessionServerTools', () => { assert.deepStrictEqual(sessionServerToolDefinitions.slice(4, 5).map(def => def.inputSchema?.properties?.title), [ { type: 'string', maxLength: 200, description: 'Short, descriptive chat title, ideally 1-4 words.' }, ]); + const renameDescription = sessionServerToolDefinitions.find(def => def.name === SessionServerToolName.RenameChat)?.description; + assert.ok(renameDescription?.includes('Renaming the default chat also names its owning session')); + assert.ok(renameDescription?.includes('peer-chat titles remain independent')); }); test('ephemeral sessions advertise no default session-management tools', () => { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 06514ea208d9ac..5550ca2b075660 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -46,7 +46,7 @@ import { CompletionItemKind as AhpCompletionItemKind, ContentEncoding, type Comp import { ConfirmationOptionKind, CustomizationType, JsonPrimitive, McpServerAuthRequiredState, McpServerStatus, SessionInputRequestKind, TerminalClaimKind, ToolCallContributorKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient, type SessionInputRequest, type SessionToolClientExecutionRequest } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, ChatTurnStartedAction, isChatAction, type ClientChatAction, type ClientSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { buildSubagentChatUri, ChatOriginKind, getInlineToolInput, getToolSubagentContent, isChatReadOnly, isMessageHiddenFromTranscript, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionStatus, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, readUsageInfoMeta, withMessageHiddenFromTranscript, type ChatState, type ISessionWithDefaultChat, type ICompletedToolCall, type InputRequestResponsePart, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageChatAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type PendingMessage, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type SessionState, type StringOrMarkdown, type ToolCallResponsePart, type ToolCallState, type ToolInput, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChatOriginKind, getInlineToolInput, getToolSubagentContent, isChatReadOnly, isDefaultChatUri, isMessageHiddenFromTranscript, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionStatus, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, readUsageInfoMeta, withMessageHiddenFromTranscript, type ChatState, type ISessionWithDefaultChat, type ICompletedToolCall, type InputRequestResponsePart, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageChatAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type PendingMessage, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type SessionState, type StringOrMarkdown, type ToolCallResponsePart, type ToolCallState, type ToolInput, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -611,6 +611,14 @@ function inputRequestResponsePartKey(part: InputRequestResponsePart): string { return `ir:${part.request.id}:${JSON.stringify({ ...part.request, answers: undefined })}`; } +function getChatTitle(state: Pick, chatURI: string): string | undefined { + const chat = state.chats.find(chat => chat.resource === chatURI); + if (!chat) { + return undefined; + } + return chat.title || (isDefaultChatUri(chatURI) ? state.title : undefined); +} + /** * The live invocation the reconnect snapshot emitted for this tool call, if * any. A tool call the snapshot rendered as a serialized part has no live @@ -1317,7 +1325,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC let initialProgress: IChatProgress[] | undefined; let initialResponsePartCount = 0; let activeTurnId: string | undefined; - let sessionTitle: string | undefined; + let chatTitle: string | undefined; let draftInputState: ISerializableChatModelInputState | undefined; let sessionSubscription: IAgentSubscription | undefined; let chatSubscription: IAgentSubscription | undefined; @@ -1361,7 +1369,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._logService.trace(`[AgentHost] provideChatSessionContent: chat state hydrated for ${chatURI}`); const sessionState = this._getSessionState(resolvedSession.toString(), chatURI); if (sessionState) { - sessionTitle = sessionState.title; + chatTitle = getChatTitle(sessionState, chatURI); const draft = sessionState.draft ?? emptyDraftFromLastTurn(sessionState); draftInputState = this._draftToInputState(sessionResource, draft); if (!sessionState.draft && draft) { @@ -1485,7 +1493,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC AgentHostChatSession, sessionResource, history, - sessionTitle, + chatTitle, sessionSubscription, chatSubscription, this._config.promptCacheNotification, @@ -1497,7 +1505,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return this._forkSession(sessionResource, resolvedSession, request, token); }, (title: string, _token: CancellationToken) => { - this._config.connection.dispatch(resolvedSession.toString(), { + this._config.connection.dispatch(this._getRenameChatURI(sessionResource, resolvedSession), { type: ActionType.SessionTitleChanged, title, }); @@ -2072,6 +2080,18 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return chatURI; } + private _getRenameChatURI(sessionResource: URI, session: URI): string { + const mapped = this._chatURIsBySessionResource.get(sessionResource); + if (mapped) { + return mapped; + } + if (!sessionResource.fragment) { + return buildDefaultChatUri(session); + } + const explicitChat = new URLSearchParams(sessionResource.query).get(CHAT_SUBAGENT_RESOURCE_QUERY_PARAM); + return explicitChat ?? buildChatUri(session, sessionResource.fragment); + } + private _getCurrentActiveClient(sessionResource: URI): SessionActiveClient { const entry = this._activeClientEntries.get(sessionResource); if (entry) { @@ -2154,7 +2174,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC let lastSeenTurnId: string | undefined = currentState?.activeTurn?.id; let previousQueuedIds: Set | undefined; let previousSteeringId: string | undefined = currentState?.steeringMessage?.id; - let previousTitle: string | undefined = currentState?.title; + let previousTitle: string | undefined = currentState ? getChatTitle(currentState, chatURI) : undefined; const disposables = new DisposableStore(); @@ -2164,9 +2184,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const sessionSub = this._ensureSessionSubscription(sessionStr); const chatSub = this._ensureChatSubscription(sessionStr, chatURI); - // Conversation contents now live on the default chat, while title and - // other session-scoped fields stay on the session. Re-evaluate on a - // change to either channel, reading the merged view. + // Conversation contents live on the chat, while its catalog title and + // other session-scoped fields live on the session. Re-evaluate on either. const onChange = () => { const state = this._getSessionState(sessionStr, chatURI); if (!state) { @@ -2184,7 +2203,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } previousSteeringId = currentSteeringId; - const currentTitle = e.state.title; + const currentTitle = getChatTitle(e.state, chatURI); if (currentTitle && currentTitle !== previousTitle) { this._chatService.setChatSessionTitle(sessionResource, currentTitle); } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 939299775b45c9..dd736140bda375 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -829,6 +829,10 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv removePendingRequest(sessionResource: URI, requestId: string) { this.removePendingRequestCalls.push({ sessionResource, requestId }); }, + setChatSessionTitleCalls: [] as { sessionResource: URI; title: string }[], + async setChatSessionTitle(sessionResource: URI, title: string) { + this.setChatSessionTitleCalls.push({ sessionResource, title }); + }, syncPendingRequestsFromRemoteCalls: [] as { sessionResource: URI; requests: readonly IRemotePendingRequest[] }[], /** Set by tests that want to mirror remote pending messages into their fake chat model. */ applyRemotePendingRequests: undefined as ((sessionResource: URI, requests: readonly IRemotePendingRequest[]) => void) | undefined, @@ -10354,6 +10358,209 @@ suite('AgentHostChatContribution', () => { }; } + test('uses independent default chat titles for the editor', async () => { + const { sessionHandler, agentHostService, chatService } = createContribution(disposables); + const backendSession = AgentSession.uri('copilot', 'independent-chat-title'); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/independent-chat-title' }); + const defaultChat = buildDefaultChatUri(backendSession.toString()); + const peerChat = buildChatUri(backendSession.toString(), 'peer'); + const summary: SessionSummary = { + resource: backendSession.toString(), + provider: 'copilot', + title: 'Session title', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }; + agentHostService.sessionStates.set(backendSession.toString(), { + ...createSessionState(summary), + lifecycle: SessionLifecycle.Ready, + defaultChat, + chats: [ + { ...createDefaultChatSummary(summary, defaultChat), title: 'Default chat title' }, + { ...createDefaultChatSummary(summary, peerChat), title: 'Peer chat title' }, + ], + }); + + const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => chatSession.dispose())); + + agentHostService.fireAction({ + channel: backendSession.toString(), + action: { + type: ActionType.SessionChatUpdated, + chat: defaultChat, + changes: { title: 'Renamed default chat' }, + }, + serverSeq: 1, + origin: undefined, + }); + + assert.deepStrictEqual({ + initialTitle: chatSession.title, + titleChanges: chatService.setChatSessionTitleCalls.map(call => ({ + sessionResource: call.sessionResource.toString(), + title: call.title, + })), + }, { + initialTitle: 'Default chat title', + titleChanges: [{ + sessionResource: sessionResource.toString(), + title: 'Renamed default chat', + }], + }); + }); + + test('inherits the session title for an untitled default chat in a multi-chat session', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + const backendSession = AgentSession.uri('copilot', 'inherited-default-chat-title'); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/inherited-default-chat-title' }); + const defaultChat = buildDefaultChatUri(backendSession.toString()); + const peerChat = buildChatUri(backendSession.toString(), 'peer'); + const summary: SessionSummary = { + resource: backendSession.toString(), + provider: 'copilot', + title: 'Session title', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }; + agentHostService.sessionStates.set(backendSession.toString(), { + ...createSessionState(summary), + lifecycle: SessionLifecycle.Ready, + defaultChat, + chats: [ + { ...createDefaultChatSummary(summary, defaultChat), title: '' }, + { ...createDefaultChatSummary(summary, peerChat), title: 'Peer chat title' }, + ], + }); + + const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => chatSession.dispose())); + + assert.strictEqual(chatSession.title, 'Session title'); + }); + + test('does not inherit the session title for an untitled peer selected as the routing default', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + const backendSession = AgentSession.uri('copilot', 'peer-routing-title'); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/peer-routing-title' }); + const canonicalDefaultChat = buildDefaultChatUri(backendSession.toString()); + const peerChat = buildChatUri(backendSession.toString(), 'peer'); + const summary: SessionSummary = { + resource: backendSession.toString(), + provider: 'copilot', + title: 'Session title', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }; + agentHostService.sessionStates.set(backendSession.toString(), { + ...createSessionState(summary), + lifecycle: SessionLifecycle.Ready, + defaultChat: peerChat, + chats: [ + { ...createDefaultChatSummary(summary, canonicalDefaultChat), title: 'Canonical default title' }, + { ...createDefaultChatSummary(summary, peerChat), title: '' }, + ], + }); + + const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => chatSession.dispose())); + + assert.strictEqual(chatSession.title, undefined); + }); + + test('routes editor renames through the addressed default and peer chat channels', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + const backendSession = AgentSession.uri('copilot', 'editor-rename-routing'); + const defaultChat = buildDefaultChatUri(backendSession.toString()); + const peerChat = buildChatUri(backendSession.toString(), 'peer'); + const summary: SessionSummary = { + resource: backendSession.toString(), + provider: 'copilot', + title: 'Session title', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }; + agentHostService.sessionStates.set(backendSession.toString(), { + ...createSessionState(summary), + lifecycle: SessionLifecycle.Ready, + defaultChat, + chats: [ + { ...createDefaultChatSummary(summary, defaultChat), title: 'Default title' }, + { ...createDefaultChatSummary(summary, peerChat), title: 'Peer title' }, + ], + }); + const defaultResource = URI.from({ scheme: 'agent-host-copilot', path: '/editor-rename-routing' }); + const peerResource = defaultResource.with({ fragment: 'peer' }); + const defaultSession = await sessionHandler.provideChatSessionContent(defaultResource, CancellationToken.None); + const peerSession = await sessionHandler.provideChatSessionContent(peerResource, CancellationToken.None); + disposables.add(toDisposable(() => defaultSession.dispose())); + disposables.add(toDisposable(() => peerSession.dispose())); + agentHostService.dispatchedActions.length = 0; + + await defaultSession.renameSession?.('Renamed default', CancellationToken.None); + await peerSession.renameSession?.('Renamed peer', CancellationToken.None); + + assert.deepStrictEqual(agentHostService.dispatchedActions.map(({ channel, action }) => ({ channel, action })), [{ + channel: defaultChat, + action: { type: ActionType.SessionTitleChanged, title: 'Renamed default' }, + }, { + channel: peerChat, + action: { type: ActionType.SessionTitleChanged, title: 'Renamed peer' }, + }]); + }); + + test('uses the session title for a sole default chat', async () => { + const { sessionHandler, agentHostService, chatService } = createContribution(disposables); + const backendSession = AgentSession.uri('copilot', 'sole-chat-title'); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/sole-chat-title' }); + const defaultChat = buildDefaultChatUri(backendSession.toString()); + const summary: SessionSummary = { + resource: backendSession.toString(), + provider: 'copilot', + title: 'Original session title', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }; + agentHostService.sessionStates.set(backendSession.toString(), { + ...createSessionState(summary), + lifecycle: SessionLifecycle.Ready, + defaultChat, + chats: [{ ...createDefaultChatSummary(summary, defaultChat), title: '' }], + }); + + const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => chatSession.dispose())); + + agentHostService.fireAction({ + channel: backendSession.toString(), + action: { + type: ActionType.SessionTitleChanged, + title: 'Renamed session', + }, + serverSeq: 1, + origin: undefined, + }); + + assert.deepStrictEqual({ + initialTitle: chatSession.title, + titleChanges: chatService.setChatSessionTitleCalls.map(call => ({ + sessionResource: call.sessionResource.toString(), + title: call.title, + })), + }, { + initialTitle: 'Original session title', + titleChanges: [{ + sessionResource: sessionResource.toString(), + title: 'Renamed session', + }], + }); + }); + test('syncs queued messages added to restored active sessions idempotently', async () => { const { sessionHandler, agentHostService, chatService } = createContribution(disposables); From 73ceca55cabd3b806979e4350660ac6c67869dba Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 21 Aug 2026 12:59:22 -0700 Subject: [PATCH 12/21] agentHost: fix Copilot chat fork leaking an in-flight turn (#332017) * agentHost: fix Copilot chat fork leaking an in-flight turn Fixes a race in Copilot chat forking. `_forkSdkChat` computed the SDK fork boundary from a locally-mirrored SQLite column that is only filled in after the next turn's `user.message` event streams back from the SDK. If a user forked a turn while the following turn was still in-flight, that column read as empty. The code treated an empty read as "there is no next turn" and omitted the fork boundary entirely, which silently copied the whole session, including the in-flight turn's skill invocation and partial work, into the fork. - Adds `CopilotAgentSession.waitForTurnEventId`, backed by a `DeferredPromise` on each `CopilotTurn`, so a fork can wait for the in-flight turn's event ID instead of skipping the boundary. - Makes `CopilotTurn` a `Disposable` and `_currentTurn` a `MutableDisposable`, so the turn's SDK event ID promise is rejected automatically whenever the turn ends, is superseded, or the session is disposed. This guarantees `waitForTurnEventId` always settles, so it needs no timeout. - Updates `_forkSdkChat` to detect an active next turn via `currentTurnId` and wait for its event ID before forking, failing the fork outright if that turn never produces one. (Commit message generated by Copilot) * agentHost: snapshot active fork boundary before database read Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/copilot/copilotAgent.ts | 10 +- .../node/copilot/copilotAgentSession.ts | 184 ++++++++++++------ .../agentHost/test/node/agentService.test.ts | 2 +- .../agentHost/test/node/copilotAgent.test.ts | 124 ++++++++++++ .../test/node/copilotAgentSession.test.ts | 57 ++++++ 5 files changed, 309 insertions(+), 68 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 772c3ed7d5cd63..3878885e04ce40 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -4052,9 +4052,13 @@ export class CopilotAgent extends Disposable implements IAgent { } const inheritedTurnIndex = sourceTurnIndex === -1 ? sourceTurns.length - 1 : sourceTurnIndex; const inheritedTurnId = sourceTurns[inheritedTurnIndex]?.id; - // toEventId is exclusive — events before it are included. If there's no - // next turn, omit it to include all events. - const toEventId = await sourceEntry.getNextTurnEventId(turnId); + // toEventId is exclusive; omitting it includes all events. + let toEventId: string | undefined; + try { + toEventId = await sourceEntry.getForkBoundaryEventId(turnId); + } catch (err) { + throw new Error(`[Copilot] fork: failed to resolve fork boundary for turn ${turnId} in source session ${sourceEntry.sessionId} because ${getErrorMessage(err)}`); + } const forkResult = await client.rpc.sessions.fork({ sessionId: sourceEntry.sessionId, ...(toEventId ? { toEventId } : {}), diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 775c30dc2747d3..8b08bebe1e6844 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -5,7 +5,7 @@ import type { CopilotSession, CurrentToolMetadata, ElicitationContext, ElicitationFieldValue, ElicitationResult, ElicitationSchema, ElicitationSchemaField, ExitPlanModeCompletedData, ExitPlanModeRequest, ExitPlanModeResult, JsonValue, McpServersLoadedServer, MessageOptions, PermissionAllowAllMode, PermissionAutoApproval, PermissionRequest, PermissionRequestResult, PermissionResult, SessionConfig, SessionHooks, SessionMode as CopilotSdkMode, Tool, ToolResultObject, McpServerStatus as SdkMcpServerStatus } from '@github/copilot-sdk'; import { cp, rm } from 'fs/promises'; -import { raceCancellation, RunOnceScheduler, Sequencer, SequencerByKey, Throttler } from '../../../../base/common/async.js'; +import { DeferredPromise, raceCancellation, RunOnceScheduler, Sequencer, SequencerByKey, Throttler } from '../../../../base/common/async.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Emitter } from '../../../../base/common/event.js'; @@ -503,7 +503,7 @@ interface IMcpLifecycleLogInfo { readonly pluginVersion?: string; } -class CopilotTurn { +class CopilotTurn extends Disposable { private _state: CopilotTurnState = 'pending'; private readonly _stopWatch = StopWatch.create(false); @@ -605,12 +605,26 @@ class CopilotTurn { /** Model of the most recent round, reported as the turn's model. */ lastModel: string | undefined; + private readonly _eventId = new DeferredPromise(); + + /** + * Resolves with this turn's SDK event id once recorded via + * {@link completeEventId}, or rejects on disposal if it never was. + */ + public get eventId() { + return this._eventId.p; + } + constructor( readonly id: string, readonly ordinal: number, readonly senderClientId: string | undefined, readonly clientContext: IAgentHostClientTelemetryContext, - ) { } + ) { + super(); + // Most turns are never waited on; avoid an uncaught rejection. + this._eventId.p.catch(() => { }); + } get clientType(): AgentHostClientType { return this.clientContext.clientType; } get state(): CopilotTurnState { return this._state; } @@ -625,8 +639,25 @@ class CopilotTurn { } } + /** Records this turn's SDK event id. Idempotent: only the first call (the root `user.message`) counts. */ + completeEventId(eventId: string): void { + if (!this._eventId.isSettled) { + this._eventId.complete(eventId); + } + } + markCompleted(): void { this._state = 'completed'; } markAborted(): void { this._state = 'aborted'; } + + /** + * Rejects {@link eventId} before disposal so pending fork-boundary checks do not hang. + */ + override dispose(): void { + if (!this._eventId.isSettled) { + this._eventId.error(new Error(`Turn ${this.id} was disposed before its SDK event id was recorded`)); + } + super.dispose(); + } } /** @@ -738,28 +769,28 @@ export class CopilotAgentSession extends Disposable { * when the session is idle (no active turn). Replaces the former set of * loosely-coupled per-turn fields (`_turnId`, usage counter, streaming * part-id maps) with a single object carrying an explicit - * {@link CopilotTurn.state} lifecycle. Created (`pending`) by - * {@link resetTurnState}, finalized by {@link _completeActiveTurn}. + * {@link CopilotTurn.state} lifecycle. A {@link MutableDisposable}: + * replacing or clearing it disposes the old turn. */ - private _currentTurn: CopilotTurn | undefined; + private readonly _currentTurn = this._register(new MutableDisposable()); /** Monotonic 0-based ordinal assigned to each turn as it starts, for numeric `turnIndex` telemetry parity. */ private _nextTurnOrdinal = 0; /** * Protocol turn ID of the active turn, or `''` when idle. Used by file * edit tracking and emitted on per-turn actions. */ - private get _turnId(): string { return this._currentTurn?.id ?? ''; } + private get _turnId(): string { return this._currentTurn.value?.id ?? ''; } /** 0-based ordinal of the active turn within the session, or `0` when idle. */ - private get _turnOrdinal(): number { return this._currentTurn?.ordinal ?? 0; } + private get _turnOrdinal(): number { return this._currentTurn.value?.ordinal ?? 0; } /** * Whether the session currently has an in-flight turn. Used by * non-destructive idle release to avoid disconnecting mid-turn. */ - get hasActiveTurn(): boolean { return this._currentTurn !== undefined; } + get hasActiveTurn(): boolean { return this._currentTurn.value !== undefined; } get chatUri(): URI { return this._chatChannelUri; } - get currentTurnId(): string | undefined { return this._currentTurn?.id; } - get currentTurnClientType(): AgentHostClientType { return this._currentTurn?.clientType ?? AgentHostClientType.Unknown; } - get currentTurnClientContext(): IAgentHostClientTelemetryContext | undefined { return this._currentTurn?.clientContext; } + get currentTurnId(): string | undefined { return this._currentTurn.value?.id; } + get currentTurnClientType(): AgentHostClientType { return this._currentTurn.value?.clientType ?? AgentHostClientType.Unknown; } + get currentTurnClientContext(): IAgentHostClientTelemetryContext | undefined { return this._currentTurn.value?.clientContext; } async collectDebugLogs(outputDirectory: URI, includeSessionLogs: boolean): Promise { const result = await this._wrapper.session.rpc.debug.collectLogs({ @@ -1100,9 +1131,10 @@ export class CopilotAgentSession extends Disposable { // `pending`, otherwise an abort during the steering turn would treat it // as a not-yet-started queued turn and leave it open. this.resetTurnState(newTurnId); - if (this._currentTurn) { - this._currentTurn.messageCharLen = steering.message.text.length; - this._currentTurn.markRunning(); + const turn = this._currentTurn.value; + if (turn) { + turn.messageCharLen = steering.message.text.length; + turn.markRunning(); } return newTurnId; } @@ -1203,7 +1235,7 @@ export class CopilotAgentSession extends Disposable { private _resolveClientToolOwner(toolName: string): string | undefined { const chat = this._chatChannelUri; const provides = (clientId: string) => this._activeClientToolSet.get(clientId).some(tool => tool.name === toolName); - const preferred = this._currentTurn?.senderClientId; + const preferred = this._currentTurn.value?.senderClientId; if (preferred && this._clientReachesChat(preferred, chat) && provides(preferred)) { return preferred; } @@ -1294,8 +1326,8 @@ export class CopilotAgentSession extends Disposable { private _beginToolCallRound(parentToolCallId: string | undefined): void { const scope = parentToolCallId ?? ''; - this._currentTurn?.markdownPartIds.delete(scope); - this._currentTurn?.reasoningPartIds.delete(scope); + this._currentTurn.value?.markdownPartIds.delete(scope); + this._currentTurn.value?.reasoningPartIds.delete(scope); } /** @@ -1306,7 +1338,7 @@ export class CopilotAgentSession extends Disposable { resetTurnState(turnId: string, senderClientId?: string, clientType = AgentHostClientType.Unknown, clientContext = createUnknownAgentHostClientTelemetryContext(clientType)): void { this._streamingToolCalls.clear(); this._streamingToolDisplaySchedulers.clearAndDisposeAll(); - this._currentTurn = new CopilotTurn(turnId, this._nextTurnOrdinal++, senderClientId, clientContext); + this._currentTurn.value = new CopilotTurn(turnId, this._nextTurnOrdinal++, senderClientId, clientContext); } async hasRunningDetachedShells(): Promise { @@ -1354,7 +1386,7 @@ export class CopilotAgentSession extends Disposable { * something has actually been billed. */ private _parentCopilotUsageMeta(): UsageInfoMeta['copilotUsage'] | undefined { - const turnNanoAiu = this._currentTurn?.copilotNanoAiu ?? 0; + const turnNanoAiu = this._currentTurn.value?.copilotNanoAiu ?? 0; if (!turnNanoAiu && !this._sessionTotalNanoAiu) { return undefined; } @@ -1384,7 +1416,7 @@ export class CopilotAgentSession extends Disposable { } private _completeActiveTurn(): void { - const turn = this._currentTurn; + const turn = this._currentTurn.value; if (!turn) { return; } @@ -1399,7 +1431,7 @@ export class CopilotAgentSession extends Disposable { } failActiveTurn(error: ErrorInfo): string | undefined { - const turn = this._currentTurn; + const turn = this._currentTurn.value; if (!turn) { return undefined; } @@ -1415,7 +1447,7 @@ export class CopilotAgentSession extends Disposable { } discardActiveTurn(): void { - if (this._currentTurn) { + if (this._currentTurn.value) { this._clearActiveTurn(); } } @@ -1427,7 +1459,7 @@ export class CopilotAgentSession extends Disposable { * is not stranded waiting on a turn that already ended. */ private _clearActiveTurn(): void { - this._currentTurn = undefined; + this._currentTurn.clear(); this._streamingToolCalls.clear(); this._streamingToolDisplaySchedulers.clearAndDisposeAll(); try { @@ -1473,7 +1505,7 @@ export class CopilotAgentSession extends Disposable { } const confirmKind = mapPermissionResultToConfirmKind(record?.resultKind, record?.resolvedByHook === true); this._telemetryReporter.toolApproval({ - clientContext: this._currentTurn?.clientContext, + clientContext: this._currentTurn.value?.clientContext, provider: this._ownerSessionUri.scheme, session: this.resourceUri.toString(), turnId: this._turnId, @@ -1531,7 +1563,7 @@ export class CopilotAgentSession extends Disposable { * markdown response part; subsequent deltas append to it. */ private _emitMarkdownDelta(content: string, parentToolCallId?: string): void { - const turn = this._currentTurn; + const turn = this._currentTurn.value; if (!turn) { // A markdown delta should only ever arrive while a turn is active. // Without a turn we can't persist the part id (so every delta would @@ -1562,7 +1594,7 @@ export class CopilotAgentSession extends Disposable { /** Emits a reasoning delta, similar to {@link _emitMarkdownDelta} but for reasoning parts. */ private _emitReasoningDelta(content: string, parentToolCallId?: string): void { - const turn = this._currentTurn; + const turn = this._currentTurn.value; if (!turn) { this._logService.error(`[Copilot:${this.sessionId}] Reasoning delta emitted with no active turn; dropping`); return; @@ -2129,16 +2161,17 @@ export class CopilotAgentSession extends Disposable { async send(prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, mode?: CopilotSdkMode, senderClientId?: string, clientType = AgentHostClientType.Unknown, hostInstructions?: readonly string[], clientContext = createUnknownAgentHostClientTelemetryContext(clientType)): Promise { this._resetAbortToken(); - if (turnId && this._currentTurn?.id !== turnId) { + if (turnId && this._currentTurn.value?.id !== turnId) { // Establish the `pending` turn for this message. Callers normally // call `resetTurnState` just before `send()`; this covers the // direct-send path and is a no-op when the turn already exists. this.resetTurnState(turnId, senderClientId, clientType, clientContext); } - if (this._currentTurn) { - this._currentTurn.messageCharLen = prompt.length; + const currentTurn = this._currentTurn.value; + if (currentTurn) { + currentTurn.messageCharLen = prompt.length; } - const turn = this._currentTurn; + const turn = this._currentTurn.value; this._hostInstructions = hostInstructions; this._pendingSnapshotReminder = this._snapshotReadonlyReminder(attachments); try { @@ -2150,7 +2183,7 @@ export class CopilotAgentSession extends Disposable { // so drop our handle to match: leaving it set makes the chat look // busy forever, which blocks idle eviction and parks any deferred // client restart for the rest of the process's life. - if (turn && this._currentTurn === turn) { + if (turn && this._currentTurn.value === turn) { this._clearActiveTurn(); } this._hostInstructions = undefined; @@ -2226,7 +2259,7 @@ export class CopilotAgentSession extends Disposable { const copilotUsage = this._parentCopilotUsageMeta(); // This emit replaces the turn's usage in the reducer, so carry the // whole-turn token totals accumulated so far too. - const turnTokenTotals = this._currentTurn?.tokenTotals; + const turnTokenTotals = this._currentTurn.value?.tokenTotals; const meta: UsageInfoMeta = { ...(copilotUsage ? { copilotUsage } : {}), ...(turnTokenTotals ? { turnTokenTotals } : {}), @@ -2370,7 +2403,7 @@ export class CopilotAgentSession extends Disposable { // `rpc.fleet.start` accepts only a prompt; fail loudly rather than silently dropping attachments. throw new Error(localize('copilotAgent.fleet.attachmentsUnsupported', "Attachments are not supported with the /fleet command.")); } - const startingTurn = this._currentTurn; + const startingTurn = this._currentTurn.value; // `abortToken` is captured by the caller before the dispatch await (slash-command // resolution), so it reliably reflects an abort that raced that await: an aborted // `session.idle` resets the live token, so reading `this._abortToken` here could @@ -2378,7 +2411,7 @@ export class CopilotAgentSession extends Disposable { await this._prepareSdkTurn(mode); // Preflight awaits several RPCs; if an abort or terminal idle raced it, do not // start the fleet loop at all — starting it would orphan an autonomous run. - if (!startingTurn || this._currentTurn !== startingTurn) { + if (!startingTurn || this._currentTurn.value !== startingTurn) { this._logService.warn(`[Copilot:${this.sessionId}] fleet turn ended during preflight; not starting fleet`); return; } @@ -2394,7 +2427,7 @@ export class CopilotAgentSession extends Disposable { } catch (err) { // A terminal `session.idle` already ended this turn while the RPC was in // flight — idle is authoritative, so never emit a second terminal action. - if (!startingTurn || this._currentTurn !== startingTurn) { + if (!startingTurn || this._currentTurn.value !== startingTurn) { this._logService.warn(`[Copilot:${this.sessionId}] rpc.fleet.start rejected after its turn already ended`, err); return; } @@ -2407,7 +2440,7 @@ export class CopilotAgentSession extends Disposable { } throw err; } - if (!startingTurn || this._currentTurn !== startingTurn) { + if (!startingTurn || this._currentTurn.value !== startingTurn) { // A terminal `session.idle` already ended this turn while the RPC was in flight. if (!result.started) { this._logService.warn(`[Copilot:${this.sessionId}] rpc.fleet.start returned started=false after its turn already ended`); @@ -4002,13 +4035,14 @@ export class CopilotAgentSession extends Disposable { return; } // First SDK event for the loop: promote the turn out of `pending`. - this._currentTurn?.markRunning(); + this._currentTurn.value?.markRunning(); const steering = this._takeMatchingPendingSteering(e.data.content); if (steering) { this._beginSteeringTurn(steering); } if (this._turnId) { this._databaseRef.object.setTurnEventId(this._turnId, e.id); + this._currentTurn.value?.completeEventId(e.id); } })); @@ -4040,7 +4074,7 @@ export class CopilotAgentSession extends Disposable { // Main agent only: `_appliedSnapshot.tools` is the session's tool set, which does not // describe a subagent's model call, so subagent messages (mapped or dropped) are skipped. if (!e.agentId) { - const clientType = this._currentTurn?.clientType ?? AgentHostClientType.Unknown; + const clientType = this._currentTurn.value?.clientType ?? AgentHostClientType.Unknown; void this._telemetryReporter.assistantMessageReceived(this.resourceUri.toString(), clientType, e.data.clientRequestId, this._appliedSnapshot.tools).catch(err => this._logService.trace(`[Copilot:${this.sessionId}] Telemetry emission failed: ${getErrorMessage(err)}`)); // Restricted `conversation.messageText` (source=model): the model's raw response text. void this._telemetryReporter.modelMessageText(this.resourceUri.toString(), clientType, e.data.content, this._turnOrdinal, e.data.clientRequestId).catch(err => this._logService.trace(`[Copilot:${this.sessionId}] Telemetry emission failed: ${getErrorMessage(err)}`)); @@ -4048,7 +4082,7 @@ export class CopilotAgentSession extends Disposable { // Every main-agent `assistant.message` is one model-call round (matches the extension's // `numRequests = toolCallRounds.length`, which counts the final tool-free response round // too); the tool-count stats only apply to rounds that carried tool requests. - const turn = this._currentTurn; + const turn = this._currentTurn.value; if (turn) { if (isCompleteModelCall && !turn.mainModelCallIds.has(modelCallId)) { turn.mainModelCallIds.add(modelCallId); @@ -4083,9 +4117,9 @@ export class CopilotAgentSession extends Disposable { return; } const markdownScope = parentToolCallId ?? ''; - if (e.data.content && !this._currentTurn?.markdownPartIds.has(markdownScope)) { + if (e.data.content && !this._currentTurn.value?.markdownPartIds.has(markdownScope)) { const partId = generateUuid(); - this._currentTurn?.markdownPartIds.set(markdownScope, partId); + this._currentTurn.value?.markdownPartIds.set(markdownScope, partId); this._emitAction({ type: ActionType.ChatResponsePart, turnId: this._turnId, @@ -4381,7 +4415,7 @@ export class CopilotAgentSession extends Disposable { const telemetrySession = parentToolCallId ? URI.parse(buildSubagentSessionUri(this._storageUri.toString(), parentToolCallId)) : this.resourceUri; - reportCopilotTodoStoreOperation(this._telemetryService, telemetrySession, e.data.toolCallId, tracked.toolName, tracked.parameters, this._currentTurn?.clientContext); + reportCopilotTodoStoreOperation(this._telemetryService, telemetrySession, e.data.toolCallId, tracked.toolName, tracked.parameters, this._currentTurn.value?.clientContext); } this._logService.info(`[Copilot:${sessionId}] Tool completed: ${e.data.toolCallId}`); this._reportToolApprovalIfNoPermission(e.data.toolCallId); @@ -4453,7 +4487,7 @@ export class CopilotAgentSession extends Disposable { const filePaths = isEditTool(tracked.toolName, command) ? this._getEditFilePaths(tracked.parameters) : []; for (const filePath of filePaths) { try { - const fileEdit = await this._editTracker.takeCompletedEdit(this._turnId, e.data.toolCallId, filePath, tracked.toolName, tracked.parameters, this._lastSeenModelId, this._currentTurn?.clientContext); + const fileEdit = await this._editTracker.takeCompletedEdit(this._turnId, e.data.toolCallId, filePath, tracked.toolName, tracked.parameters, this._lastSeenModelId, this._currentTurn.value?.clientContext); if (fileEdit) { content.push(fileEdit); } @@ -4493,7 +4527,7 @@ export class CopilotAgentSession extends Disposable { activity: undefined, }); } - const turn = this._currentTurn; + const turn = this._currentTurn.value; if (!turn) { return; } @@ -4542,7 +4576,7 @@ export class CopilotAgentSession extends Disposable { // Restricted `skillContentRead`: which skill file was loaded. Main-agent only, like the other restricted events. if (!e.agentId) { this._telemetryReporter.skillContentRead({ - clientType: this._currentTurn?.clientType ?? AgentHostClientType.Unknown, + clientType: this._currentTurn.value?.clientType ?? AgentHostClientType.Unknown, name: e.data.name, path: e.data.path, content: e.data.content, @@ -4606,20 +4640,21 @@ export class CopilotAgentSession extends Disposable { if (isCopilotSdkAuthRejection(e.data)) { this._onDidRequireAuth.fire(); } - reportCopilotSdkSessionError(this._telemetryService, e, createCopilotFailureCorrelation(this.resourceUri, this._chatChannelUri, this._turnId, this.sessionId, this._currentTurn?.clientContext)); - if (this._currentTurn) { - this._reportToolCallDetails(this._currentTurn, 'failed'); + reportCopilotSdkSessionError(this._telemetryService, e, createCopilotFailureCorrelation(this.resourceUri, this._chatChannelUri, this._turnId, this.sessionId, this._currentTurn.value?.clientContext)); + const turn = this._currentTurn.value; + if (turn) { + this._reportToolCallDetails(turn, 'failed'); } this._emitAction({ type: ActionType.ChatError, turnId: this._turnId, - duration: this._currentTurn?.duration ?? 0, + duration: turn?.duration ?? 0, error: buildChatErrorInfoFromCopilotSdkFields(e.data), }); })); this._register(wrapper.onModelCallFailure(e => { - reportCopilotModelCallFailure(this._telemetryService, e, createCopilotFailureCorrelation(this.resourceUri, this._chatChannelUri, this._turnId, this.sessionId, this._currentTurn?.clientContext)); + reportCopilotModelCallFailure(this._telemetryService, e, createCopilotFailureCorrelation(this.resourceUri, this._chatChannelUri, this._turnId, this.sessionId, this._currentTurn.value?.clientContext)); })); // Tracks the last parent-scope usage so the async attribution enrichment @@ -4639,7 +4674,7 @@ export class CopilotAgentSession extends Disposable { this._telemetryReporter.autoModeRouterDecision({ session: this.resourceUri.toString(), turnId, - clientType: this._currentTurn?.clientType ?? AgentHostClientType.Unknown, + clientType: this._currentTurn.value?.clientType ?? AgentHostClientType.Unknown, chosenModel: e.data.chosenModel, predictedLabel: e.data.predictedLabel, confidence: e.data.confidence, @@ -4699,7 +4734,7 @@ export class CopilotAgentSession extends Disposable { // present at runtime. Forward the per-category snapshots on `_meta` so the client can keep the // account quota UI current. Mirrors the extension-host CLI path, which feeds these into its quota service. const quotaSnapshots = normalizeQuotaSnapshots((e.data as unknown as Record).quotaSnapshots); - const turn = this._currentTurn; + const turn = this._currentTurn.value; if (typeof e.data.model === 'string' && e.data.model) { this._lastSeenModelId = e.data.model; @@ -4865,7 +4900,7 @@ export class CopilotAgentSession extends Disposable { return; } const copilotUsage = readCopilotUsage(e.data.compactionTokensUsed); - const turn = this._currentTurn; + const turn = this._currentTurn.value; const compactionTokens = e.data.compactionTokensUsed; turn?.addTokenTotals(compactionTokens?.model ?? this._lastSeenModelId, { inputTokens: compactionTokens?.inputTokens, @@ -4880,7 +4915,7 @@ export class CopilotAgentSession extends Disposable { const emitParentUsage = (): string | undefined => { const turnId = this._turnId; const parentCopilotUsage = this._parentCopilotUsageMeta(); - const turnTokenTotals = this._currentTurn?.tokenTotals; + const turnTokenTotals = this._currentTurn.value?.tokenTotals; if (!turnId || (!parentCopilotUsage && !turnTokenTotals)) { return undefined; } @@ -5203,7 +5238,7 @@ export class CopilotAgentSession extends Disposable { * `currentMode` so the model can continue with implementation. */ private async _handleExitPlanModeRequest(data: ExitPlanModeRequest, _invocation: { sessionId: string }): Promise { - const turnId = this._currentTurn?.id; + const turnId = this._currentTurn.value?.id; if (!turnId) { this._logService.warn(`[Copilot:${this.sessionId}] Rejecting plan review request without an active turn`); return { approved: false }; @@ -5219,7 +5254,7 @@ export class CopilotAgentSession extends Disposable { } catch (err) { this._logService.warn(`[Copilot:${this.sessionId}] rpc.plan.read failed for exit_plan_mode: ${err instanceof Error ? err.message : String(err)}`); } - if (this._currentTurn?.id !== turnId) { + if (this._currentTurn.value?.id !== turnId) { this._logService.warn(`[Copilot:${this.sessionId}] Rejecting plan review request after its turn ended`); return { approved: false }; } @@ -5327,7 +5362,7 @@ export class CopilotAgentSession extends Disposable { if (e.agentId || (e.data.source && e.data.source.toLowerCase() !== 'user')) { return; } - const clientContext = this._currentTurn?.clientContext; + const clientContext = this._currentTurn.value?.clientContext; void (async () => { let sources; try { @@ -5490,7 +5525,7 @@ export class CopilotAgentSession extends Disposable { // and SDK-injected synthetic messages (skill/harness injections carry a non-`user` source, // matching `isSyntheticUserMessage`) so injected content is not reported as the user's prompt. if (!e.agentId && (!e.data.source || e.data.source.toLowerCase() === 'user')) { - void this._telemetryReporter.userMessageText(this.resourceUri.toString(), this._currentTurn?.clientType ?? AgentHostClientType.Unknown, e.data.content, this._turnOrdinal).catch(err => this._logService.trace(`[Copilot:${this.sessionId}] Telemetry emission failed: ${getErrorMessage(err)}`)); + void this._telemetryReporter.userMessageText(this.resourceUri.toString(), this._currentTurn.value?.clientType ?? AgentHostClientType.Unknown, e.data.content, this._turnOrdinal).catch(err => this._logService.trace(`[Copilot:${this.sessionId}] Telemetry emission failed: ${getErrorMessage(err)}`)); } })); @@ -5499,10 +5534,10 @@ export class CopilotAgentSession extends Disposable { })); this._register(wrapper.onTurnStart(e => { - this._currentTurn?.markRunning(); + this._currentTurn.value?.markRunning(); this._logService.trace(`[Copilot:${sessionId}] Turn started: ${e.data.turnId}`); if (!e.agentId) { - const telemetryMessageId = this._currentTurn?.id ?? e.data.turnId; + const telemetryMessageId = this._currentTurn.value?.id ?? e.data.turnId; if (this._activeRepoInfoTurn?.telemetryMessageId === telemetryMessageId) { return; } @@ -5513,7 +5548,7 @@ export class CopilotAgentSession extends Disposable { begin: Promise.resolve(undefined), }; const isCurrent = () => !turn.cancelled && this._isLaunchTokenCurrent(); - turn.begin = this._beginRepoInfoTelemetry(telemetryMessageId, this._currentTurn?.clientType ?? AgentHostClientType.Unknown, isCurrent); + turn.begin = this._beginRepoInfoTelemetry(telemetryMessageId, this._currentTurn.value?.clientType ?? AgentHostClientType.Unknown, isCurrent); this._activeRepoInfoTurn = turn; } })); @@ -5542,8 +5577,9 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onAbort(e => { this._logService.trace(`[Copilot:${sessionId}] Aborted: ${e.data.reason}`); this._cancelActiveRepoInfoTelemetry(); - if (this._currentTurn?.isRunning) { - this._reportToolCallDetails(this._currentTurn, 'cancelled'); + const turn = this._currentTurn.value; + if (turn?.isRunning) { + this._reportToolCallDetails(turn, 'cancelled'); } })); @@ -5631,6 +5667,26 @@ export class CopilotAgentSession extends Disposable { return this._databaseRef.object.getNextTurnEventId(turnId); } + /** + * Resolves the exclusive SDK event boundary for a fork after {@link turnId}. + */ + async getForkBoundaryEventId(turnId: string): Promise { + const activeTurn = this._currentTurn.value; + const activeTurnId = activeTurn?.id; + const activeTurnEventId = activeTurnId !== turnId ? activeTurn?.eventId : undefined; + const persistedEventId = await this._databaseRef.object.getNextTurnEventId(turnId); + if (persistedEventId || !activeTurnEventId) { + return persistedEventId; + } + + this._logService.info(`[Copilot:${this.sessionId}] Fork boundary after turn ${turnId} is active turn ${activeTurnId}; waiting for its SDK event id`); + try { + return await activeTurnEventId; + } catch (err) { + throw new Error(`its next turn (${activeTurnId}) never produced an SDK event id: ${getErrorMessage(err)}`); + } + } + /** * Returns the SDK event ID associated with the given protocol turn. */ diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 8193c6cb0f03ad..f69081aa4a07fc 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -9983,7 +9983,7 @@ suite('AgentService (node dispatcher)', () => { override async createChat(): Promise { } } const db = new TestSessionDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MultiChatAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index b564e8b584d5a4..3264a6b52ecfb5 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -5912,6 +5912,130 @@ suite('CopilotAgent', () => { usage: {}, }; + suite('_forkSdkChat boundary', () => { + type ForkRequest = Parameters[0]; + type ForkSdkChatInternals = { + _forkSdkChat: (client: CopilotClient, sourceEntry: CopilotAgentSession, turnId: string, targetDbDir: URI) => Promise<{ sessionId: string; inheritedTurnId: string | undefined }>; + }; + + function makeForkClient(forkCalls: ForkRequest[]): CopilotClient { + return { + rpc: { + sessions: { + fork: async (params: ForkRequest) => { + forkCalls.push(params); + return { sessionId: 'forked-session' }; + }, + }, + }, + } as unknown as CopilotClient; + } + + function makeForkSource(options: { + readonly boundaryEventId?: string; + readonly getForkBoundaryEventId?: (turnId: string) => Promise; + }): { source: CopilotAgentSession; boundaryCalls: string[] } { + const boundaryCalls: string[] = []; + const source = { + sessionId: 'source-sdk-session', + sessionUri: AgentSession.uri('copilotcli', 'fork-sdk-source'), + getMessages: async (): Promise => [sourceTurn], + getForkBoundaryEventId: async (turnId: string): Promise => { + boundaryCalls.push(turnId); + return options.getForkBoundaryEventId + ? options.getForkBoundaryEventId(turnId) + : options.boundaryEventId; + }, + } as unknown as CopilotAgentSession; + return { source, boundaryCalls }; + } + + function forkSdkChat(agent: CopilotAgent, client: CopilotClient, source: CopilotAgentSession): Promise<{ sessionId: string; inheritedTurnId: string | undefined }> { + return (agent as unknown as ForkSdkChatInternals)._forkSdkChat(client, source, sourceTurn.id, URI.file('/fork-sdk-chat-target')); + } + + test('omits the SDK boundary when there is no next turn', async () => { + const agent = createTestAgent(disposables); + const forkCalls: ForkRequest[] = []; + const { source, boundaryCalls } = makeForkSource({}); + try { + await forkSdkChat(agent, makeForkClient(forkCalls), source); + + assert.deepStrictEqual({ forkCalls, boundaryCalls }, { + forkCalls: [{ sessionId: 'source-sdk-session' }], + boundaryCalls: ['source-turn'], + }); + } finally { + await disposeAgent(agent); + } + }); + + test('uses an already-resolved SDK boundary without waiting', async () => { + const agent = createTestAgent(disposables); + const forkCalls: ForkRequest[] = []; + const { source, boundaryCalls } = makeForkSource({ boundaryEventId: 'next-turn-event' }); + try { + await forkSdkChat(agent, makeForkClient(forkCalls), source); + + assert.deepStrictEqual({ forkCalls, boundaryCalls }, { + forkCalls: [{ sessionId: 'source-sdk-session', toEventId: 'next-turn-event' }], + boundaryCalls: ['source-turn'], + }); + } finally { + await disposeAgent(agent); + } + }); + + test('waits for the source session to resolve the SDK boundary before forking', async () => { + const agent = createTestAgent(disposables); + const forkCalls: ForkRequest[] = []; + const { source, boundaryCalls } = makeForkSource({ + getForkBoundaryEventId: async () => { + await timeout(5); + return 'active-next-turn-event'; + }, + }); + try { + await forkSdkChat(agent, makeForkClient(forkCalls), source); + + assert.deepStrictEqual({ forkCalls, boundaryCalls }, { + forkCalls: [{ sessionId: 'source-sdk-session', toEventId: 'active-next-turn-event' }], + boundaryCalls: ['source-turn'], + }); + } finally { + await disposeAgent(agent); + } + }); + + test('fails the fork when an active next turn never produces an SDK boundary', async () => { + const agent = createTestAgent(disposables); + const forkCalls: ForkRequest[] = []; + const { source, boundaryCalls } = makeForkSource({ + getForkBoundaryEventId: async () => { throw new Error('its next turn (active-next-turn) never produced an SDK event id: boom'); }, + }); + let error: Error | undefined; + try { + try { + await forkSdkChat(agent, makeForkClient(forkCalls), source); + } catch (err) { + error = err instanceof Error ? err : new Error(String(err)); + } + + assert.deepStrictEqual({ + error: error?.message, + forkCalls, + boundaryCalls, + }, { + error: '[Copilot] fork: failed to resolve fork boundary for turn source-turn in source session source-sdk-session because its next turn (active-next-turn) never produced an SDK event id: boom', + forkCalls: [], + boundaryCalls: ['source-turn'], + }); + } finally { + await disposeAgent(agent); + } + }); + }); + test('rejects a fork whose source is the chat being created', async () => { const client = new TestCopilotClient([]); const agent = createTestAgent(disposables, { copilotClient: client }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index bccda799ef3fc5..ddfe1994978738 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -7458,6 +7458,63 @@ suite('CopilotAgentSession', () => { }); }); + suite('getForkBoundaryEventId', () => { + test('resolves when the matching user message event arrives', async () => { + const { session, mockSession } = await createAgentSession(disposables); + session.resetTurnState('turn-waiting'); + + const eventIdPromise = session.getForkBoundaryEventId('previous-turn'); + mockSession.fire('user.message', { content: 'hello agent' } as SessionEventPayload<'user.message'>['data'], { id: 'sdk-event-waiting' }); + await timeout(0); + + assert.deepStrictEqual(await eventIdPromise, 'sdk-event-waiting'); + }); + + test('resolves from a user message event recorded before waiting', async () => { + const { session, mockSession } = await createAgentSession(disposables); + session.resetTurnState('turn-recorded'); + mockSession.fire('user.message', { content: 'hello agent' } as SessionEventPayload<'user.message'>['data'], { id: 'sdk-event-recorded' }); + await timeout(0); + + assert.deepStrictEqual(await session.getForkBoundaryEventId('previous-turn'), 'sdk-event-recorded'); + }); + + test('rejects when the turn ends before its user message event arrives', async () => { + const { session } = await createAgentSession(disposables); + session.resetTurnState('turn-ended'); + + const eventIdPromise = session.getForkBoundaryEventId('previous-turn'); + session.discardActiveTurn(); + + await assert.rejects(eventIdPromise, /its next turn \(turn-ended\) never produced an SDK event id: Turn turn-ended was disposed before its SDK event id was recorded/); + }); + + test('retains the active turn event promise when session.idle clears it during the database read', async () => { + const databaseRead = new DeferredPromise(); + const sessionDatabase = new TestSessionDatabase(); + sessionDatabase.getNextTurnEventId = () => databaseRead.p; + const { session, mockSession } = await createAgentSession(disposables, { sessionDatabase }); + session.resetTurnState('turn-ended-during-read'); + + const eventIdPromise = session.getForkBoundaryEventId('previous-turn'); + mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); + await timeout(0); + databaseRead.complete(undefined); + + await assert.rejects(eventIdPromise, /its next turn \(turn-ended-during-read\) never produced an SDK event id/); + }); + + test('rejects pending waiters when the session is disposed', async () => { + const { session } = await createAgentSession(disposables); + session.resetTurnState('turn-disposed'); + + const eventIdPromise = session.getForkBoundaryEventId('previous-turn'); + session.dispose(); + + await assert.rejects(eventIdPromise, /its next turn \(turn-disposed\) never produced an SDK event id: Turn turn-disposed was disposed before its SDK event id was recorded/); + }); + }); + // ---- user input handling ---- suite('user input handling', () => { From 26a6665d90c41f015204962baab6739c749d8332 Mon Sep 17 00:00:00 2001 From: roblourens Date: Fri, 21 Aug 2026 12:59:31 -0700 Subject: [PATCH 13/21] agentHost: Remove telemetry configuration service (#332007) * agentHost: remove telemetry configuration service Use the launch-resolved telemetry level when constructing Agent Host telemetry and preserve runtime consent updates through root config. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * telemetry: preserve strict DI constructor typing Keep the configuration-service constructor overload visible to strict instantiation while retaining the fixed-level Agent Host factory. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: preserve internal telemetry launch state Forward the parent-resolved internal telemetry status so removing the settings-backed configuration service preserves telemetry.internalTesting. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: drop internal testing forwarding Keep Agent Host internal classification based on the product domain check instead of adding a launch contract for the undocumented telemetry.internalTesting override. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: use test service factory Update the newly merged AgentService test to use the shared factory after the constructor refactor. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/agentHostTelemetryEnv.ts | 6 +++ .../node/agentHostTelemetryService.ts | 23 +++++----- .../node/sshRemoteAgentHostService.ts | 6 ++- .../node/wslRemoteAgentHostHelpers.ts | 3 +- .../agentHostProtocolClient.test.ts | 25 ++++++++++- .../telemetry/common/telemetryService.ts | 43 +++++++++++++------ .../test/browser/telemetryService.test.ts | 15 +++++++ 7 files changed, 91 insertions(+), 30 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostTelemetryEnv.ts b/src/vs/platform/agentHost/common/agentHostTelemetryEnv.ts index 705f17038336c8..26c54e7ab5b4c3 100644 --- a/src/vs/platform/agentHost/common/agentHostTelemetryEnv.ts +++ b/src/vs/platform/agentHost/common/agentHostTelemetryEnv.ts @@ -21,6 +21,12 @@ export const AgentHostMachineIdEnvKey = 'VSCODE_AGENT_HOST_MACHINE_ID'; export const AgentHostSqmIdEnvKey = 'VSCODE_AGENT_HOST_SQM_ID'; export const AgentHostDevDeviceIdEnvKey = 'VSCODE_AGENT_HOST_DEV_DEVICE_ID'; + +/** + * Fallback launch contract for custom SSH/WSL agent-host commands. Managed launchers pass + * `--telemetry-level`, but custom commands are executed verbatim and may not accept VS Code CLI + * arguments. The host accepts both channels and applies the more restrictive level. + */ export const AgentHostTelemetryLevelEnvKey = 'VSCODE_AGENT_HOST_TELEMETRY_LEVEL'; export interface IAgentHostForwardedTelemetryIds { diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryService.ts b/src/vs/platform/agentHost/node/agentHostTelemetryService.ts index 3a521f9d0bb19c..7b20e8f366e910 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryService.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryService.ts @@ -8,20 +8,18 @@ import { Disposable, isDisposable, toDisposable, type DisposableStore } from '.. import { joinPath } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import { getDevDeviceId, getMachineId, getSqmMachineId } from '../../../base/node/id.js'; -import { ConfigurationService } from '../../configuration/common/configurationService.js'; import { INativeEnvironmentService } from '../../environment/common/environment.js'; import { IFileService } from '../../files/common/files.js'; import { ILogService, ILoggerService } from '../../log/common/log.js'; -import { NullPolicyService } from '../../policy/common/policy.js'; import { IProductService } from '../../product/common/productService.js'; import { IRequestService } from '../../request/common/request.js'; import { OneDataSystemAppender } from '../../telemetry/node/1dsAppender.js'; -import { resolveCommonProperties } from '../../telemetry/common/commonProperties.js'; +import { resolveCommonProperties, verifyMicrosoftInternalDomain } from '../../telemetry/common/commonProperties.js'; import { ClassifiedEvent, IGDPRProperty, OmitMetadata, StrictPropertyCheck } from '../../telemetry/common/gdprTypings.js'; import { ITelemetryData, ITelemetryService, TelemetryLevel } from '../../telemetry/common/telemetry.js'; import { TelemetryLogAppender } from '../../telemetry/common/telemetryLogAppender.js'; import { TelemetryService } from '../../telemetry/common/telemetryService.js'; -import { getPiiPathsFromEnvironment, isInternalTelemetry, isLoggingOnly, NullTelemetryService, supportsTelemetry, type ITelemetryAppender } from '../../telemetry/common/telemetryUtils.js'; +import { getPiiPathsFromEnvironment, isLoggingOnly, NullTelemetryService, supportsTelemetry, type ITelemetryAppender } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostTelemetryLevelConfigKey, agentHostConfigValueToTelemetryLevel } from '../common/agentHostSchema.js'; import { AgentHostDevDeviceIdEnvKey, AgentHostMachineIdEnvKey, AgentHostSqmIdEnvKey, AgentHostTelemetryLevelEnvKey } from '../common/agentHostTelemetryEnv.js'; import { AgentHostRestrictedTelemetrySender, IAgentHostRestrictedTelemetry, IAgentHostInternalTelemetryContext, IAgentHostRestrictedTelemetryContext, TelemetryMeasurements, TelemetryProps } from './agentHostRestrictedTelemetry.js'; @@ -239,13 +237,15 @@ export async function createAgentHostTelemetryService(options: IAgentHostTelemet return disposables.add(new AgentHostTelemetryService(NullTelemetryService)); } - const configurationService = disposables.add(new ConfigurationService(joinPath(environmentService.appSettingsHome, 'settings.json'), fileService, new NullPolicyService(), logService)); - await configurationService.initialize(); + const initialTelemetryLevel = Math.min( + parseLaunchTelemetryLevel(environmentService.args?.['telemetry-level']), + parseLaunchTelemetryLevel((options.readTelemetryLevelEnvironment ?? (() => process.env[AgentHostTelemetryLevelEnvKey]))()), + ); + const internalTelemetry = verifyMicrosoftInternalDomain(productService.msftInternalDomains ?? []); const appenders: ITelemetryAppender[] = [ disposables.add(new TelemetryLogAppender('', false, loggerService, environmentService, productService)), ]; - const internalTelemetry = isInternalTelemetry(productService, configurationService); const loggingOnly = isLoggingOnly(productService, environmentService); if (!loggingOnly && productService.aiConfig?.ariaKey) { const collectorAppender = new OneDataSystemAppender(options.requestService, internalTelemetry, 'monacoworkbench', null, productService.aiConfig.ariaKey); @@ -265,21 +265,18 @@ export async function createAgentHostTelemetryService(options: IAgentHostTelemet const commonProperties = resolveCommonProperties(release(), hostname(), process.arch, productService.commit, productService.version, machineId, sqmId, devDeviceId, internalTelemetry, productService.date); - const telemetryService = new TelemetryService({ + const telemetryService = TelemetryService.createWithLevel({ appenders, sendErrorTelemetry: true, commonProperties, piiPaths: getPiiPathsFromEnvironment(environmentService), - }, configurationService, productService); + telemetryLevel: initialTelemetryLevel, + }, productService); const extensionVersion = loggingOnly ? undefined : await resolveCopilotExtensionVersion(environmentService, fileService, logService); const internalSender = loggingOnly ? undefined : disposables.add(new AgentHostInternalTelemetrySender({ requestService: options.requestService, commonProperties, extensionVersion })); const restricted = loggingOnly ? undefined : new AgentHostRestrictedTelemetrySender(commonProperties, logService, undefined, internalSender, options.fetchFn); - const initialTelemetryLevel = Math.min( - parseLaunchTelemetryLevel(environmentService.args?.['telemetry-level']), - parseLaunchTelemetryLevel((options.readTelemetryLevelEnvironment ?? (() => process.env[AgentHostTelemetryLevelEnvKey]))()), - ); return disposables.add(new AgentHostTelemetryService(telemetryService, restricted, productService.copilotVersions?.sdk, productService.copilotVersions?.runtime, initialTelemetryLevel)); } diff --git a/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts index 6442994a2e6e6e..1c166ccd805702 100644 --- a/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts +++ b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts @@ -920,8 +920,10 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem // Dev override: a custom command bypasses the shared endpoint // registry entirely — there is no resolved CLI binary to run // `agent endpoints` with, and the override command need not - // even be our CLI — so there is nothing to discover or offer a - // picker over. Always start a fresh process (requirement 6). + // even be our CLI. The command is executed verbatim with no + // arguments appended; launch restrictions such as telemetry + // level are supplied through its environment. Always start a + // fresh process (requirement 6). this._logService.info(`${LOG_PREFIX} Using custom agent host command: ${config.remoteAgentHostCommand}; skipping endpoint discovery/selection`); reportProgress(localize('sshProgressStartingAgent', "Starting remote agent host...")); const result = await this._startRemoteAgentHost(sshClient, undefined, undefined, config.remoteAgentHostCommand, this._effectiveTelemetryLevel); diff --git a/src/vs/platform/agentHost/node/wslRemoteAgentHostHelpers.ts b/src/vs/platform/agentHost/node/wslRemoteAgentHostHelpers.ts index c63d24300fdf7c..575c63377cc4d8 100644 --- a/src/vs/platform/agentHost/node/wslRemoteAgentHostHelpers.ts +++ b/src/vs/platform/agentHost/node/wslRemoteAgentHostHelpers.ts @@ -254,7 +254,7 @@ export interface IComposeAgentHostBootstrapScriptArgs { readonly os: string; readonly arch: string; readonly telemetryLevel?: TelemetryConfiguration; - /** Dev override; when set, returned verbatim and all CLI bootstrap is skipped. */ + /** Dev override; executed verbatim with no appended arguments. All CLI bootstrap is skipped. */ readonly remoteAgentHostCommand?: string; } @@ -276,6 +276,7 @@ export interface IComposeAgentHostBootstrapScriptArgs { export function composeAgentHostBootstrapScript(args: IComposeAgentHostBootstrapScriptArgs): string { const telemetryLevel = validateAgentHostTelemetryLevel(args.telemetryLevel ?? TelemetryConfiguration.OFF); if (args.remoteAgentHostCommand) { + // The override may not be the VS Code CLI, so pass launch restrictions out-of-band. return `export ${AgentHostTelemetryLevelEnvKey}=${telemetryLevel} && ${args.remoteAgentHostCommand}`; } const installRoot = getRemoteCLIInstallRoot(args.serverDataFolderName); diff --git a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index 8bf5e6a4ef19c1..54eef4d48d414e 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -30,7 +30,7 @@ import { mainWindow } from '../../../../base/browser/window.js'; import { buildDefaultChatUri, CustomizationType, MessageAttachmentKind, MessageKind, PendingMessageKind, readSessionExternal, readSessionWorkspaceless, ROOT_STATE_URI, SessionStatus, StateComponents, customizationId, withSessionExternal, withSessionWorkspaceless } from '../../common/state/sessionState.js'; import { NonReconnectableTransportError, type IClientTransport, type IProtocolTransport } from '../../common/state/sessionTransport.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; -import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; +import { ITelemetryService, TelemetryConfiguration, TelemetryLevel, TELEMETRY_SETTING_ID } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; import { AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostTelemetryLevelConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, telemetryLevelToAgentHostConfigValue, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, type AgentHostTerminalAutoApproveRules } from '../../common/agentHostSchema.js'; import { AgentHostMapLegacySettingsToManagedSettingsSettingId } from '../../common/agentHostManagedSettings.js'; @@ -1100,6 +1100,29 @@ suite('AgentHostProtocolClient', () => { ); }); + test('forwards telemetry setting changes to the local agent host after initialization', async () => { + const transport = disposables.add(new TestProtocolTransport(AgentHostClientConnectionKind.Local)); + const configurationService = new TestConfigurationService(); + const { client } = createClientForIdentity( + LOCAL_AGENT_HOST_RESOURCE_IDENTITY, + transport, + createPermissionService(), + undefined, + new NullLogService(), + configurationService, + undefined, + editorWindowAgentHostClientInfo, + new TestClientIdentityTelemetryService(), + ); + await connectClient(client, transport); + transport.sentMessages.length = 0; + + await configurationService.setUserConfiguration(TELEMETRY_SETTING_ID, TelemetryConfiguration.OFF); + fireConfigurationChange(configurationService, TELEMETRY_SETTING_ID); + + assert.strictEqual(findRootConfigValue(transport.sentMessages, AgentHostTelemetryLevelConfigKey), 'off'); + }); + test('forwards every setting declaring `agentHost` on connect and when one changes', async () => { const configurationService = new TestConfigurationService({ [SYNC_SETTING_A]: true, diff --git a/src/vs/platform/telemetry/common/telemetryService.ts b/src/vs/platform/telemetry/common/telemetryService.ts index c72b9f741803ed..62a840dd92b592 100644 --- a/src/vs/platform/telemetry/common/telemetryService.ts +++ b/src/vs/platform/telemetry/common/telemetryService.ts @@ -24,6 +24,11 @@ export interface ITelemetryServiceConfig { sendErrorTelemetry?: boolean; commonProperties?: ICommonProperties; piiPaths?: string[]; + /** + * A fixed telemetry level for processes that receive the resolved level from their launcher. + * When provided, the service does not read or observe telemetry settings. + */ + telemetryLevel?: TelemetryLevel; /** * If true, telemetry events will be buffered until setExperimentProperty is called * (up to 10 seconds) to ensure experiment context is attached to all events. @@ -74,9 +79,15 @@ export class TelemetryService implements ITelemetryService { private readonly _disposables = new DisposableStore(); private _cleanupPatterns: RegExp[] = []; + static createWithLevel(config: ITelemetryServiceConfig & { telemetryLevel: TelemetryLevel }, productService: IProductService): TelemetryService { + return new TelemetryService(config, undefined, productService); + } + + constructor(config: ITelemetryServiceConfig & { telemetryLevel: TelemetryLevel }, configurationService: undefined, productService: IProductService); + constructor(config: ITelemetryServiceConfig, configurationService: IConfigurationService, productService: IProductService); constructor( config: ITelemetryServiceConfig, - @IConfigurationService private _configurationService: IConfigurationService, + @IConfigurationService configurationService: IConfigurationService | undefined, @IProductService private _productService: IProductService ) { this._appenders = config.appenders; @@ -105,17 +116,24 @@ export class TelemetryService implements ITelemetryService { } } - this._updateTelemetryLevel(); - this._disposables.add(this._configurationService.onDidChangeConfiguration(e => { - // Check on the telemetry settings and update the state if changed - const affectsTelemetryConfig = - e.affectsConfiguration(TELEMETRY_SETTING_ID) - || e.affectsConfiguration(TELEMETRY_OLD_SETTING_ID) - || e.affectsConfiguration(TELEMETRY_CRASH_REPORTER_SETTING_ID); - if (affectsTelemetryConfig) { - this._updateTelemetryLevel(); + if (config.telemetryLevel !== undefined) { + this._updateTelemetryLevel(config.telemetryLevel); + } else { + if (!configurationService) { + throw new Error('TelemetryService requires a configuration service or a fixed telemetry level.'); } - })); + this._updateTelemetryLevel(getTelemetryLevel(configurationService)); + this._disposables.add(configurationService.onDidChangeConfiguration(e => { + // Check on the telemetry settings and update the state if changed + const affectsTelemetryConfig = + e.affectsConfiguration(TELEMETRY_SETTING_ID) + || e.affectsConfiguration(TELEMETRY_OLD_SETTING_ID) + || e.affectsConfiguration(TELEMETRY_CRASH_REPORTER_SETTING_ID); + if (affectsTelemetryConfig) { + this._updateTelemetryLevel(getTelemetryLevel(configurationService)); + } + })); + } // Buffer events until experiment properties are set (or timeout expires). // This ensures early events include experiment context when available. @@ -158,8 +176,7 @@ export class TelemetryService implements ITelemetryService { this._pendingEvents = []; } - private _updateTelemetryLevel(): void { - let level = getTelemetryLevel(this._configurationService); + private _updateTelemetryLevel(level: TelemetryLevel): void { const collectableTelemetry = this._productService.enabledTelemetryLevels; // Also ensure that error telemetry is respecting the product configuration for collectable telemetry if (collectableTelemetry) { diff --git a/src/vs/platform/telemetry/test/browser/telemetryService.test.ts b/src/vs/platform/telemetry/test/browser/telemetryService.test.ts index baf364cd13dae4..f3a563919c3957 100644 --- a/src/vs/platform/telemetry/test/browser/telemetryService.test.ts +++ b/src/vs/platform/telemetry/test/browser/telemetryService.test.ts @@ -137,6 +137,21 @@ suite('TelemetryService', () => { service.dispose(); })); + test('Fixed telemetry level does not require a configuration service', sinonTestFn(function () { + const testAppender = new TestTelemetryAppender(); + const service = TelemetryService.createWithLevel({ + appenders: [testAppender], + sendErrorTelemetry: true, + telemetryLevel: TelemetryLevel.ERROR, + }, TestProductService); + + service.publicLog('usageEvent'); + service.publicLogError('errorEvent'); + + assert.deepStrictEqual(testAppender.events.map(event => event.eventName), ['errorEvent']); + service.dispose(); + })); + test('Event with data', sinonTestFn(function () { const testAppender = new TestTelemetryAppender(); const service = new TelemetryService({ appenders: [testAppender] }, new TestConfigurationService(), TestProductService); From 2c8554af4f1dcf5b36b30122db1582cd4a92257c Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 21 Aug 2026 13:43:16 -0700 Subject: [PATCH 14/21] agentHost: make MCP SDK registration explicit (#332026) * agentHost: make MCP SDK registration explicit Separate Copilot MCP discovery routing from working-directory resolution so client-synced servers declare whether the SDK should discover them from a plugin or receive them in session config. Validate the client metadata that drives the decision and cover malformed and workspace-default cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: make MCP SDK registration explicit Make the Copilot MCP registration path observable at the SDK handoff. - Record whether each MCP server uses plugin discovery or session config. - Log the final configured and disabled server names at trace level. - Avoid logging server URLs, headers, commands, environment values, or paths. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: use the test service factory Update the restored-chat test to construct AgentService through the shared test factory. - Supplies the new service dependency through the existing test harness. - Restores the client typecheck and Compile & Hygiene CI job. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: test MCP projection trace privacy Cover the trace-level MCP launch projection contract. - Assert normal and ephemeral registration and disabled-server summaries. - Verify that URLs, headers, commands, environment values, and paths stay out of the trace. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../meta/clientPluginCustomizationMeta.ts | 25 ++-- .../agentHost/node/copilot/copilotAgent.ts | 49 +++++-- .../node/copilot/copilotAgentSession.ts | 2 +- .../node/copilot/copilotSessionLauncher.ts | 41 +++--- .../test/common/agentMetaReaders.test.ts | 6 +- .../test/node/copilotAgentSession.test.ts | 3 +- .../test/node/copilotSessionLauncher.test.ts | 127 +++++++++++++++--- 7 files changed, 195 insertions(+), 58 deletions(-) diff --git a/src/vs/platform/agentHost/common/meta/clientPluginCustomizationMeta.ts b/src/vs/platform/agentHost/common/meta/clientPluginCustomizationMeta.ts index 00ad73a2c80b8d..65724d18053e5a 100644 --- a/src/vs/platform/agentHost/common/meta/clientPluginCustomizationMeta.ts +++ b/src/vs/platform/agentHost/common/meta/clientPluginCustomizationMeta.ts @@ -22,27 +22,36 @@ function readClientPluginMcpDefaultCwds(customization: ClientPluginCustomization return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : undefined; } -export function hasClientPluginMcpDefaultCwds(customization: ClientPluginCustomization): boolean { - return readClientPluginMcpDefaultCwds(customization) !== undefined; -} +type ClientPluginMcpDefaultCwd = { readonly kind: 'primary' } | { readonly kind: 'uri'; readonly uri: URI }; -export function readClientPluginMcpDefaultCwd(customization: ClientPluginCustomization, serverName: string, primaryCwd: URI | undefined): URI | undefined { +function readClientPluginMcpDefaultCwdEntry(customization: ClientPluginCustomization, serverName: string): ClientPluginMcpDefaultCwd | undefined { const value = readClientPluginMcpDefaultCwds(customization); if (!value || !Object.hasOwn(value, serverName)) { return undefined; } - const cwd = value[serverName]; if (cwd === null) { - return primaryCwd; + return { kind: 'primary' }; } if (typeof cwd !== 'string') { return undefined; } - try { - return URI.parse(cwd, true); + return { kind: 'uri', uri: URI.parse(cwd, true) }; } catch { return undefined; } } + +export function hasClientPluginMcpDefaultCwds(customization: ClientPluginCustomization): boolean { + return readClientPluginMcpDefaultCwds(customization) !== undefined; +} + +export function hasClientPluginMcpDefaultCwd(customization: ClientPluginCustomization, serverName: string): boolean { + return readClientPluginMcpDefaultCwdEntry(customization, serverName) !== undefined; +} + +export function readClientPluginMcpDefaultCwd(customization: ClientPluginCustomization, serverName: string, primaryCwd: URI | undefined): URI | undefined { + const value = readClientPluginMcpDefaultCwdEntry(customization, serverName); + return value?.kind === 'primary' ? primaryCwd : value?.uri; +} diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 3878885e04ce40..23b0748d65d984 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -25,7 +25,7 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { StopWatch } from '../../../../base/common/stopwatch.js'; import { rgDiskPath } from '../../../../base/node/ripgrep.js'; import { localize } from '../../../../nls.js'; -import { IParsedAgent, IParsedPlugin, IParsedRule, IParsedSkill, parseAgentFile, parsePlugin, parseRuleFile, parseSkillFile, PluginFormat } from '../../../agentPlugins/common/pluginParsers.js'; +import { IParsedAgent, IParsedPlugin, IParsedRule, IParsedSkill, parseAgentFile, parsePlugin, parseRuleFile, parseSkillFile, PluginFormat, type IMcpServerDefinition } from '../../../agentPlugins/common/pluginParsers.js'; import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService, LogLevel } from '../../../log/common/log.js'; @@ -94,7 +94,7 @@ import { COPILOT_INTEGRATION_ID } from '../../../endpoint/common/licenseAgreemen import { getAppNodeModulesPath } from '../appNodeModules.js'; import { CopilotSlashCommandProvider } from './copilotSlashCommandProvider.js'; import { SessionMcpDiscovery } from '../shared/sessionMcpDiscovery.js'; -import { readClientPluginMcpDefaultCwd } from '../../common/meta/clientPluginCustomizationMeta.js'; +import { hasClientPluginMcpDefaultCwd, readClientPluginMcpDefaultCwd } from '../../common/meta/clientPluginCustomizationMeta.js'; import { classifyCopilotClientOperationFailure, CopilotClientStartupConfigChangedError, createCopilotFailureCorrelation, isRecognizedCopilotClientStartupFailure, reportCopilotClientOperationFailure, reportCopilotClientRecovery, reportCopilotClientRecoveryTurn, reportCopilotClientStartup, type CopilotClientOperation, type CopilotClientOperationFailureKind, type ICopilotFailureCorrelation } from './copilotFailureTelemetry.js'; interface ICopilotRuntimeManagedSettingsInput { @@ -238,12 +238,37 @@ async function resolveCopilotCliPath(nodeModulesUri: URI): Promise { throw new Error(`Unable to resolve @github/copilot CLI path. Tried: ${tried.join(', ')}`); } -export type ICopilotPluginInfo = IParsedPlugin & { +/** + * Selects the single Copilot SDK path that owns an MCP server definition. Plugin discovery is for servers declared by a materialized plugin; session config is for definitions Agent Host assembled from workspace or client-synced state. + */ +export type CopilotMcpServerSdkRegistration = 'pluginDiscovery' | 'sessionConfig'; + +export type ICopilotMcpServerInfo = IMcpServerDefinition & { + /** The SDK registration path chosen while resolving the session's AHP customizations. */ + readonly sdkRegistration: CopilotMcpServerSdkRegistration; +}; + +export type ICopilotPluginInfo = Omit & { + readonly mcpServers: readonly ICopilotMcpServerInfo[]; readonly pluginDir?: URI; readonly sourceUri?: URI; readonly disabledMcpServers?: readonly string[]; }; +/** + * Resolves a parsed MCP child into its Copilot launch contract. Client default-CWD metadata identifies servers synthesized outside the plugin, so they are projected through session config independently of the resolved CWD value. + */ +export function resolveCopilotMcpServerInfo(definition: IMcpServerDefinition, pluginDir: URI | undefined, input?: ClientPluginCustomization, primaryCwd?: URI): ICopilotMcpServerInfo { + const clientDefaultCwd = input ? readClientPluginMcpDefaultCwd(input, definition.name, primaryCwd) : undefined; + return { + ...definition, + defaultCwd: clientDefaultCwd ?? definition.defaultCwd, + sdkRegistration: input && hasClientPluginMcpDefaultCwd(input, definition.name) + ? 'sessionConfig' + : pluginDir?.scheme === Schemas.file ? 'pluginDiscovery' : 'sessionConfig', + }; +} + /** * In-memory chat reservation created by {@link IAgentChats.createChat} and * consumed by {@link CopilotAgent._materializeProvisional} on first send. @@ -5828,7 +5853,12 @@ class SessionPluginController extends Disposable { }; const discovered = entry?.currentCustomizations() ?? []; const sessionPlugin = discovered.some(isEnabledForSdk) ? mapToParsedPlugin(discovered) : undefined; - const sessionPlugins: IParsedPlugin[] = sessionPlugin ? [sessionPlugin] : []; + const withSdkRegistration = (plugin: IParsedPlugin, pluginDir: URI | undefined): ICopilotPluginInfo => ({ + ...plugin, + pluginDir, + mcpServers: plugin.mcpServers.map(definition => resolveCopilotMcpServerInfo(definition, pluginDir)), + }); + const sessionPlugins: ICopilotPluginInfo[] = sessionPlugin ? [withSdkRegistration(sessionPlugin, undefined)] : []; const primaryCwd = this._directory; const withClientDefaults = (item: IResolvedCustomization): ICopilotPluginInfo => { @@ -5836,12 +5866,7 @@ class SessionPluginController extends Disposable { return { ...plugin, pluginDir: item.pluginDir, - mcpServers: plugin.mcpServers.map(definition => ({ - ...definition, - defaultCwd: item.input - ? readClientPluginMcpDefaultCwd(item.input, definition.name, primaryCwd) ?? definition.defaultCwd - : definition.defaultCwd, - })), + mcpServers: plugin.mcpServers.map(definition => resolveCopilotMcpServerInfo(definition, item.pluginDir, item.input, primaryCwd)), }; }; const allWorkspaceDefinitions = mcpDiscovery?.definitions ?? []; @@ -5849,7 +5874,7 @@ class SessionPluginController extends Disposable { const workspaceMcp = allWorkspaceDefinitions.length ? [{ format: PluginFormat.Copilot, hooks: [], - mcpServers: workspaceDefinitions, + mcpServers: workspaceDefinitions.map(definition => resolveCopilotMcpServerInfo(definition, undefined)), disabledMcpServers: allWorkspaceDefinitions.filter(definition => !isEnabledForSdk(definition.customization)).map(definition => definition.name), skills: [], agents: [], @@ -5858,7 +5883,7 @@ class SessionPluginController extends Disposable { return [ ...workspaceMcp, ...host.filter(item => !!item.plugin && isEnabledForSdk(item.customization)) - .map(item => ({ ...item.plugin!, pluginDir: item.pluginDir, sourceUri: URI.parse(item.customization.uri), ...(disabledChildren(item.customization) ? { disabledMcpServers: disabledChildren(item.customization) } : {}) })), + .map(item => ({ ...withSdkRegistration(item.plugin!, item.pluginDir), sourceUri: URI.parse(item.customization.uri), ...(disabledChildren(item.customization) ? { disabledMcpServers: disabledChildren(item.customization) } : {}) })), ...this._flattenClientCustomizations().filter(item => !!item.plugin && isEnabledForSdk(item.customization)) .map(item => ({ ...withClientDefaults(item), sourceUri: URI.parse(item.customization.uri), ...(disabledChildren(item.customization) ? { disabledMcpServers: disabledChildren(item.customization) } : {}) })), ...sessionPlugins, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 8b08bebe1e6844..891c6b8999a9a1 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -1008,7 +1008,7 @@ export class CopilotAgentSession extends Disposable { ]); this._projectedMcpServerLaunchEnablement = new Map(this._appliedSnapshot.plugins.flatMap(plugin => plugin.mcpServers - .filter(server => isMcpServerExplicitlyProjected(plugin, server)) + .filter(isMcpServerExplicitlyProjected) .map(server => [server.name, !disabledMcpServers.has(server.name)] as const) )); this._appliedAdditionalDirectories = [...(this._launchPlan.additionalDirectories ?? [])]; diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index a12ca576a699ff..1ec052e0506b08 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -6,7 +6,6 @@ import type { ContextTier, CopilotClient, ElicitationContext, ElicitationResult, ExitPlanModeRequest, ExitPlanModeResult, ModelCapabilitiesOverride, NamedProviderConfig, PermissionRequest, PermissionRequestResult, ProviderModelConfig, ReasoningSummary, ResumeSessionConfig, SessionConfig, SessionHooks, Tool, Verbosity } from '@github/copilot-sdk'; import { coalesce } from '../../../../base/common/arrays.js'; import { Schemas } from '../../../../base/common/network.js'; -import { isEqual } from '../../../../base/common/resources.js'; import { isObject, isStringArray } from '../../../../base/common/types.js'; import { StopWatch } from '../../../../base/common/stopwatch.js'; import { URI } from '../../../../base/common/uri.js'; @@ -28,8 +27,7 @@ import { IAgentHostManagedSettingsService } from '../agentHostManagedSettingsSer import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; import { IByokLmBridgeRegistry } from '../byokLmBridgeRegistry.js'; import { IByokLmProxyService, type IByokLmProxyHandle } from './byokLmProxyService.js'; -import type { IMcpServerDefinition } from '../../../agentPlugins/common/pluginParsers.js'; -import type { ICopilotPluginInfo } from './copilotAgent.js'; +import type { ICopilotMcpServerInfo, ICopilotPluginInfo } from './copilotAgent.js'; import { toSdkHooks, toSdkInstructionDirectories, toSdkMcpServers, toSdkMcpServersFromConfigMap, toSdkSessionCustomAgents, toSdkSkillDirectories } from './copilotPluginConverters.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { ShellManager, createShellTools, type IUnsandboxedCommandConfirmationRequest } from './copilotShellTools.js'; @@ -76,10 +74,11 @@ function disabledMcpServersSessionOption(plugins: readonly ICopilotPluginInfo[], return disabledMcpServers.length > 0 ? { disabledMcpServers } : {}; } -export function isMcpServerExplicitlyProjected(plugin: ICopilotPluginInfo, server: IMcpServerDefinition): boolean { - return !plugin.pluginDir - || plugin.pluginDir.scheme !== Schemas.file - || server.defaultCwd !== undefined && !isEqual(server.defaultCwd, plugin.pluginDir); +/** + * Returns whether Agent Host must include the server in `SessionConfig.mcpServers` instead of leaving it to SDK plugin discovery. + */ +export function isMcpServerExplicitlyProjected(server: ICopilotMcpServerInfo): boolean { + return server.sdkRegistration === 'sessionConfig'; } /** @@ -753,7 +752,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { const pluginsWithoutDirs = plugins.filter(p => !p.pluginDir || p.pluginDir.scheme !== Schemas.file); const explicitMcpServers = plan.isEphemeral ? [] : plugins.flatMap(plugin => plugin.mcpServers.filter(server => !plugin.disabledMcpServers?.includes(server.name) - && isMcpServerExplicitlyProjected(plugin, server) + && isMcpServerExplicitlyProjected(server) )); // An ephemeral session skips the explicit enumeration (and its file I/O). The SDK can // still discover agents from `pluginDirectories`; suppressing that too would also drop @@ -816,21 +815,29 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { // summary at info for prompt observability; the full config at trace. const systemMessage = agentHostPromptRegistry.resolveSystemMessageConfig(effectiveModel, promptContext); this._logService.info(`[Copilot:${plan.sessionId}] Resolved system message: ${describeSystemMessageConfig(systemMessage)}`); + const additionalDisabledMcpServers = plan.isEphemeral ? [ + ...plugins.flatMap(plugin => plugin.mcpServers.map(server => server.name)), + ...Object.keys(plan.snapshot.mcpServers), + ] : undefined; + const disabledMcpServers = disabledMcpServersSessionOption(plugins, plan.disabledRootMcpServers, additionalDisabledMcpServers); + const mcpServers = plan.isEphemeral ? {} : { ...toSdkMcpServersFromConfigMap(plan.snapshot.mcpServers), ...toSdkMcpServers(explicitMcpServers) }; if (this._logService.getLevel() <= LogLevel.Trace) { // Guarded: a `replace`-mode prompt's content can be multiple KB, so only // serialize it when trace output is actually emitted. this._logService.trace(`[Copilot:${plan.sessionId}] System message config: ${JSON.stringify(systemMessage, (_key, value) => typeof value === 'function' ? '[transform fn]' : value)}`); + const sortedUnique = (names: readonly string[]) => [...new Set(names)].sort(); + this._logService.trace(`[Copilot:${plan.sessionId}] MCP launch projection: ${JSON.stringify({ + ephemeral: plan.isEphemeral === true, + pluginDiscovery: sortedUnique(plugins.flatMap(plugin => plugin.mcpServers.filter(server => server.sdkRegistration === 'pluginDiscovery').map(server => server.name))), + sessionConfig: sortedUnique(plugins.flatMap(plugin => plugin.mcpServers.filter(server => server.sdkRegistration === 'sessionConfig').map(server => server.name))), + rootConfig: Object.keys(plan.snapshot.mcpServers).sort(), + disabled: [...(disabledMcpServers.disabledMcpServers ?? [])].sort(), + finalSessionConfig: Object.keys(mcpServers).sort(), + })}`); } return { ...byok, - ...disabledMcpServersSessionOption( - plugins, - plan.disabledRootMcpServers, - plan.isEphemeral ? [ - ...plugins.flatMap(plugin => plugin.mcpServers.map(server => server.name)), - ...Object.keys(plan.snapshot.mcpServers), - ] : undefined, - ), + ...disabledMcpServers, clientName: AGENT_HOST_COPILOT_CLIENT_NAME, // Resume only: `_createSession` re-resolves the full effort for a create, // while a resumed session keeps the effort the runtime journaled unless @@ -851,7 +858,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { onPostToolUse: input => runtime.handlePostToolUse(input), onUserPromptSubmitted: () => runtime.handleUserPromptSubmitted(), }), - mcpServers: plan.isEphemeral ? {} : { ...toSdkMcpServersFromConfigMap(plan.snapshot.mcpServers), ...toSdkMcpServers(explicitMcpServers) }, + mcpServers, onExitPlanModeRequest: (request, invocation) => runtime.handleExitPlanModeRequest(request, invocation), workingDirectory: plan.workingDirectory?.fsPath, customAgents, diff --git a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts index a71b54093020e8..c1e5ba8d39d8c7 100644 --- a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts +++ b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts @@ -15,7 +15,7 @@ import type { SessionModelInfo, SimpleMessageAttachment } from '../../common/sta import { createAgentModelByokMeta, readAgentModelByokIdentifier } from '../../common/agentModelByokMeta.js'; import { createAgentModelSourceMeta, readAgentModelSourceId } from '../../common/agentModelSource.js'; import { URI } from '../../../../base/common/uri.js'; -import { hasClientPluginMcpDefaultCwds, readClientPluginMcpDefaultCwd, toClientPluginMcpDefaultCwdsMeta } from '../../common/meta/clientPluginCustomizationMeta.js'; +import { hasClientPluginMcpDefaultCwd, hasClientPluginMcpDefaultCwds, readClientPluginMcpDefaultCwd, toClientPluginMcpDefaultCwdsMeta } from '../../common/meta/clientPluginCustomizationMeta.js'; /** Wraps a `_meta` bag in a minimal {@link ToolCallState} so the reader sees the right source type. */ function toolCall(meta: Record | undefined): ToolCallState { @@ -382,6 +382,8 @@ suite('Agent host _meta readers', () => { const additionalCwd = URI.parse('vscode-remote://ssh-remote+host/workspace'); const meta = toClientPluginMcpDefaultCwdsMeta({ primary: null, additional: additionalCwd }); assert.strictEqual(hasClientPluginMcpDefaultCwds(plugin(meta)), true); + assert.strictEqual(hasClientPluginMcpDefaultCwd(plugin(meta), 'primary'), true); + assert.strictEqual(hasClientPluginMcpDefaultCwd(plugin(meta), 'missing'), false); assert.strictEqual(readClientPluginMcpDefaultCwd(plugin(meta), 'primary', primaryCwd), primaryCwd); assert.strictEqual(readClientPluginMcpDefaultCwd(plugin(meta), 'additional', primaryCwd)?.toString(), additionalCwd.toString()); }); @@ -391,6 +393,8 @@ suite('Agent host _meta readers', () => { assert.strictEqual(hasClientPluginMcpDefaultCwds(plugin(undefined)), false); assert.strictEqual(readClientPluginMcpDefaultCwd(plugin({ mcpDefaultCwds: { server: 42 } }), 'server', URI.file('/workspace')), undefined); assert.strictEqual(readClientPluginMcpDefaultCwd(plugin({ mcpDefaultCwds: { server: 'relative/path' } }), 'server', URI.file('/workspace')), undefined); + assert.strictEqual(hasClientPluginMcpDefaultCwd(plugin({ mcpDefaultCwds: { server: 42 } }), 'server'), false); + assert.strictEqual(hasClientPluginMcpDefaultCwd(plugin({ mcpDefaultCwds: { server: 'relative/path' } }), 'server'), false); }); }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index ddfe1994978738..43bed3490aafb7 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -9818,7 +9818,7 @@ suite('CopilotAgentSession', () => { assert.deepStrictEqual(mockSession.mcpEnableCalls, [{ serverName }]); }); - test('re-enabling a plugin server with an explicit cwd defers to a session refresh', async () => { + test('re-enabling an explicitly projected plugin server defers to a session refresh', async () => { const serverName = 'vscode_probe'; const pluginUri = 'https://bundle'; const pluginDir = URI.file('/bundle'); @@ -9847,6 +9847,7 @@ suite('CopilotAgentSession', () => { name: serverName, configuration: { type: McpServerType.LOCAL, command: 'node', args: ['server.js'] }, defaultCwd: URI.file('/workspace'), + sdkRegistration: 'sessionConfig', uri: URI.joinPath(pluginDir, '.mcp.json'), customization: child, }], diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index 8ed8504bd9b3b1..eda55a5888e262 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -9,20 +9,21 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { PluginFormat } from '../../../agentPlugins/common/pluginParsers.js'; +import { PluginFormat, type IMcpServerDefinition } from '../../../agentPlugins/common/pluginParsers.js'; import type { IFileService } from '../../../files/common/files.js'; import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; -import { ILogService, NullLogService } from '../../../log/common/log.js'; +import { ILogService, LogLevel, NullLogService } from '../../../log/common/log.js'; import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; import type { IByokLmBridgeConnection, IByokLmChatRequest, IByokLmChatResult, IByokLmModelInfo } from '../../common/agentHostByokLm.js'; import { AgentHostByokModelsEnabledConfigKey, type SchemaValues } from '../../common/agentHostSchema.js'; import type { IAgentHostManagedSettingsPermissions } from '../../common/agentHostManagedSettings.js'; +import { toClientPluginMcpDefaultCwdsMeta } from '../../common/meta/clientPluginCustomizationMeta.js'; import { CopilotCliConfigKey, copilotCliConfigSchema } from '../../common/copilotCliConfig.js'; import type { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { reasoningEffortLevels } from '../../common/reasoningEffort.js'; import { SEMANTIC_SEARCH_TOOL_NAME } from '../../common/semanticSearchConstants.js'; -import { CustomizationType, McpServerStatus, type ModelSelection } from '../../common/state/protocol/state.js'; +import { CustomizationType, McpServerStatus, type ClientPluginCustomization, type ModelSelection } from '../../common/state/protocol/state.js'; import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../common/toolSearchConstants.js'; import { ActiveClientToolSet } from '../../node/activeClientState.js'; import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; @@ -30,7 +31,7 @@ import { AgentHostManagedSettingsService, IAgentHostManagedSettingsService } fro import type { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; import { ByokLmBridgeRegistry, IByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js'; import { ByokLmProxyService, IByokLmProxyService, type IByokLmProxyHandle } from '../../node/copilot/byokLmProxyService.js'; -import type { ICopilotPluginInfo } from '../../node/copilot/copilotAgent.js'; +import { resolveCopilotMcpServerInfo, type ICopilotPluginInfo } from '../../node/copilot/copilotAgent.js'; import { CopilotSessionLauncher, filterClientToolNames, getCopilotReasoningEffort, isCopilotReasoningEffort, resolveByokSessionConfig, normalizeToolFilterPatterns, resolveConfiguredReasoningEffortOverride, resolveCopilotReasoningEffort, toSdkToolFilterPatterns, type CopilotSessionLaunchPlan, type ICopilotSessionRuntime } from '../../node/copilot/copilotSessionLauncher.js'; const testRuntime: ICopilotSessionRuntime = { @@ -49,7 +50,19 @@ const testRuntime: ICopilotSessionRuntime = { const testWorkingDirectory = URI.file(process.cwd()); -function createTestLauncher(managedSettingsPermissions?: IAgentHostManagedSettingsPermissions, rootValues: Partial> = {}): CopilotSessionLauncher { +class CapturingLogService extends NullLogService { + readonly traces: string[] = []; + + override getLevel(): LogLevel { + return LogLevel.Trace; + } + + override trace(message: string): void { + this.traces.push(message); + } +} + +function createTestLauncher(managedSettingsPermissions?: IAgentHostManagedSettingsPermissions, rootValues: Partial> = {}, logService: ILogService = new NullLogService()): CopilotSessionLauncher { const configurationService = { getRootValue: (_schema: unknown, key: CopilotCliConfigKey) => rootValues[key], } as Partial as IAgentConfigurationService; @@ -57,7 +70,7 @@ function createTestLauncher(managedSettingsPermissions?: IAgentHostManagedSettin configurationService, { permissions: managedSettingsPermissions ?? {} } as IAgentHostManagedSettingsService, {} as IAgentHostTerminalManager, - new NullLogService(), + logService, {} as IFileService, { _serviceBrand: undefined, start: async () => { throw new Error('Unexpected proxy start'); }, dispose: () => { } }, new ByokLmBridgeRegistry(), @@ -350,6 +363,46 @@ suite('CopilotSessionLauncher shared session config', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('derives explicit MCP registration from client metadata rather than cwd equality', () => { + const pluginDir = URI.file('/tmp/plugin'); + const workspace = URI.file('/workspace'); + const definition: IMcpServerDefinition = { + name: 'server', + configuration: { type: McpServerType.REMOTE, url: 'https://example.com/mcp' }, + defaultCwd: pluginDir, + uri: URI.joinPath(pluginDir, '.mcp.json'), + customization: { + type: CustomizationType.McpServer, + id: 'server', + uri: URI.joinPath(pluginDir, '.mcp.json').toString(), + name: 'server', + state: { kind: McpServerStatus.Stopped }, + }, + }; + const input = (meta: Record): ClientPluginCustomization => ({ + type: CustomizationType.Plugin, + id: 'plugin', + uri: 'file:///plugin', + name: 'Plugin', + _meta: meta, + }); + + assert.deepStrictEqual([ + resolveCopilotMcpServerInfo(definition, pluginDir), + resolveCopilotMcpServerInfo(definition, pluginDir, input(toClientPluginMcpDefaultCwdsMeta({ server: null })), workspace), + resolveCopilotMcpServerInfo(definition, pluginDir, input({ mcpDefaultCwds: { server: 42 } }), workspace), + resolveCopilotMcpServerInfo(definition, undefined), + ].map(server => ({ + defaultCwd: server.defaultCwd?.toString(), + sdkRegistration: server.sdkRegistration, + })), [ + { defaultCwd: pluginDir.toString(), sdkRegistration: 'pluginDiscovery' }, + { defaultCwd: workspace.toString(), sdkRegistration: 'sessionConfig' }, + { defaultCwd: pluginDir.toString(), sdkRegistration: 'pluginDiscovery' }, + { defaultCwd: pluginDir.toString(), sdkRegistration: 'sessionConfig' }, + ]); + }); + test('passes Agent Host defaults, managed permissions, and exit-plan handler to create and resume', async () => { const createConfigs: Parameters[0][] = []; const resumeConfigs: Parameters[1][] = []; @@ -372,7 +425,8 @@ suite('CopilotSessionLauncher shared session config', () => { disableBypassPermissionsMode: 'disable', ask: ['Shell'], }; - const launcher = createTestLauncher(managedSettingsPermissions); + const logService = new CapturingLogService(); + const launcher = createTestLauncher(managedSettingsPermissions, {}, logService); const pluginDir = URI.file('/tmp/synced-customizations'); const syntheticPluginDir = URI.file('/tmp/vscode-synced-customizations'); const skillUri = URI.joinPath(pluginDir, 'skills', 'user-skill', 'SKILL.md'); @@ -383,8 +437,9 @@ suite('CopilotSessionLauncher shared session config', () => { mcpServers: [{ name: 'native-plugin-server', uri: URI.joinPath(pluginDir, '.mcp.json'), - defaultCwd: pluginDir, - configuration: { type: McpServerType.LOCAL, command: 'native-plugin-server' }, + defaultCwd: testWorkingDirectory, + sdkRegistration: 'pluginDiscovery', + configuration: { type: McpServerType.LOCAL, command: '/sensitive/plugin-command', env: { API_TOKEN: 'sensitive-plugin-env' } }, customization: { type: CustomizationType.McpServer, id: 'native-plugin-server', @@ -414,7 +469,8 @@ suite('CopilotSessionLauncher shared session config', () => { name: 'synced-server', uri: URI.joinPath(syntheticPluginDir, '.mcp.json'), defaultCwd: testWorkingDirectory, - configuration: { type: McpServerType.LOCAL, command: 'synced-server' }, + sdkRegistration: 'sessionConfig', + configuration: { type: McpServerType.REMOTE, url: 'https://sensitive.example/mcp', headers: { Authorization: 'sensitive-header' } }, customization: { type: CustomizationType.McpServer, id: 'synced-server', @@ -480,17 +536,26 @@ suite('CopilotSessionLauncher shared session config', () => { ephemeralMcpServers: createConfigs[1].mcpServers, ephemeralDisabledMcpServers: createConfigs[1].disabledMcpServers, ephemeralExcludedTools: createConfigs[1].excludedTools, + mcpProjectionTraces: logService.traces.filter(message => message.includes('MCP launch projection:')).map(message => JSON.parse(message.slice(message.indexOf('{')))), + sensitiveProjectionValues: [ + '/sensitive/plugin-command', + 'sensitive-plugin-env', + 'https://sensitive.example/mcp', + 'sensitive-header', + pluginDir.fsPath, + syntheticPluginDir.fsPath, + testWorkingDirectory.fsPath, + ].filter(value => logService.traces.some(message => message.includes('MCP launch projection:') && message.includes(value))), }, { createClientName: 'vscode-agent-host', createGitHubMcpToolConfig: { disableFormDeferral: true }, createPluginDirectories: [pluginDir.fsPath, syntheticPluginDir.fsPath], createMcpServers: { 'synced-server': { - type: 'local', - command: 'synced-server', - args: [], + type: 'http', + url: 'https://sensitive.example/mcp', tools: ['*'], - cwd: testWorkingDirectory.fsPath, + headers: { Authorization: 'sensitive-header' }, }, }, createSkillDirectories: [], @@ -504,11 +569,10 @@ suite('CopilotSessionLauncher shared session config', () => { resumePluginDirectories: [pluginDir.fsPath, syntheticPluginDir.fsPath], resumeMcpServers: { 'synced-server': { - type: 'local', - command: 'synced-server', - args: [], + type: 'http', + url: 'https://sensitive.example/mcp', tools: ['*'], - cwd: testWorkingDirectory.fsPath, + headers: { Authorization: 'sensitive-header' }, }, }, resumeSkillDirectories: [], @@ -520,6 +584,33 @@ suite('CopilotSessionLauncher shared session config', () => { ephemeralMcpServers: {}, ephemeralDisabledMcpServers: ['azure', 'disabled-workspace-server', 'github', 'native-plugin-server', 'synced-server'], ephemeralExcludedTools: ['task', `builtin:${SEMANTIC_SEARCH_TOOL_NAME}`], + mcpProjectionTraces: [ + { + ephemeral: false, + pluginDiscovery: ['native-plugin-server'], + sessionConfig: ['synced-server'], + rootConfig: [], + disabled: ['azure', 'disabled-workspace-server', 'github'], + finalSessionConfig: ['synced-server'], + }, + { + ephemeral: false, + pluginDiscovery: ['native-plugin-server'], + sessionConfig: ['synced-server'], + rootConfig: [], + disabled: ['azure', 'disabled-workspace-server', 'github'], + finalSessionConfig: ['synced-server'], + }, + { + ephemeral: true, + pluginDiscovery: ['native-plugin-server'], + sessionConfig: ['synced-server'], + rootConfig: [], + disabled: ['azure', 'disabled-workspace-server', 'github', 'native-plugin-server', 'synced-server'], + finalSessionConfig: [], + }, + ], + sensitiveProjectionValues: [], }); } finally { sessions.dispose(); From 92eacc530918c4f2ee442b7ad5417cea7b2b6ca9 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:54:26 +0200 Subject: [PATCH 15/21] Add trace logging to the multi diff editor (#331992) * Add trace logging to the multi diff editor Adds a `MultiDiffEditorLogger` that traces the state transitions which are hard to reconstruct after the fact when investigating multi diff editor bugs such as the view jumping while scrolling or files being expanded even though they were collapsed: - view model/loading changes, collapsed state changes (no matter who caused them) and content height changes (annotated when they happen above the current scroll offset, which is what makes the view jump) - programmatic scrolling (reveal, restored scroll state, scroll adjustments coming from an embedded diff editor) - view state save/restore, including the active diff item, and editor set/clear input in both the multi diff editor and the sessions Changes editor - navigation (go to next/previous change) and editor template acquire/release The observers are only created while the log level is set to trace and are disposed again when it is lowered, so nothing is observed in the default case. Enable with `Developer: Set Log Level...` > `Window` > `Trace`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address multi diff editor logging review feedback - Log the decoded session resource instead of the synthetic changes-multi-diff-source: URI when the Changes editor input is set. - Fall back to the authority or scheme in ormatUri, so pathless URIs no longer produce a blank label. - Format diff item keys before logging them, so full URIs never leak into the trace when a persisted active item is missing. - Guard the doc state aggregation in getViewState/setViewState with isEnabled, so large inputs pay no logging-only cost when tracing is off. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../multiDiffEditor/multiDiffEditorLogging.ts | 185 ++++++++++++++++++ .../multiDiffEditorWidgetImpl.ts | 104 +++++++++- .../changes/browser/sessionChangesEditor.ts | 18 +- .../browser/multiDiffEditor.ts | 15 +- 4 files changed, 315 insertions(+), 7 deletions(-) create mode 100644 src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorLogging.ts diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorLogging.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorLogging.ts new file mode 100644 index 00000000000000..63004b22836252 --- /dev/null +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorLogging.ts @@ -0,0 +1,185 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, autorunWithStore, IObservable, observableFromEvent } from '../../../../base/common/observable.js'; +import { basename } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ILogService, LogLevel } from '../../../../platform/log/common/log.js'; +import { MultiDiffEditorViewModel } from './multiDiffEditorViewModel.js'; + +/** A diff item of the multi diff editor, as far as logging is concerned. */ +export interface ILoggedDiffItem { + getKey(): string; + /** Short, log friendly name of the item. */ + getLabel(): string; + readonly collapsed: IObservable; + readonly contentHeight: IObservable; +} + +/** The widget state traced by {@link MultiDiffEditorLogger.logStateChanges}. */ +export interface ILoggedEditorState { + readonly viewModel: IObservable; + readonly items: IObservable; + readonly spaceBetweenPx: number; + readonly getScrollTop: () => number; + readonly isPreserveFocusOnLoad: () => boolean; +} + +/** + * Trace logger for the multi diff editor. Logs the state transitions that are + * hard to observe from the outside (scroll offsets that are set programmatically, + * content height changes, collapsed state changes and view state save/restore), + * so bugs like "the editor jumped while scrolling" or "this file was expanded + * even though it was collapsed" can be reconstructed from the log. + * + * Enable with `Developer: Set Log Level...` > `Window` > `Trace`. + */ +export class MultiDiffEditorLogger extends Disposable { + /** Last logged collapsed state per diff item, to only log actual changes. */ + private readonly _lastLoggedCollapsed = new Map(); + /** Last logged content height per diff item, to only log actual changes. */ + private readonly _lastLoggedContentHeight = new Map(); + + private readonly _isEnabled: IObservable; + + constructor(private readonly _logService: ILogService) { + super(); + + this._isEnabled = observableFromEvent(this, this._logService.onDidChangeLogLevel, () => this._logService.getLevel() <= LogLevel.Trace); + } + + /** + * Whether trace logging is on. Check this before computing data that is only + * needed for logging. + */ + public get isEnabled(): boolean { + return this._isEnabled.get(); + } + + public log(message: string, data?: Record): void { + if (!this.isEnabled) { + return; + } + const formattedData = data ? Object.entries(data).map(([key, value]) => `${key}: ${formatValue(value)}`).join(', ') : undefined; + this._logService.trace(`[MultiDiffEditor] ${message}${formattedData ? ` (${formattedData})` : ''}`); + } + + /** + * Traces the state transitions that cannot be reconstructed after the fact: + * view model/loading changes, collapsed state changes (no matter who caused + * them) and content height changes, which are the usual cause of the view + * jumping while scrolling. Per-frame rendering is deliberately not traced. + * + * The observers only exist while trace logging is on, so nothing is observed + * (and no state is tracked) in the default case; they are recreated when the + * log level is raised to trace again. + */ + public logStateChanges(state: ILoggedEditorState): void { + this._register(autorunWithStore((reader, store) => { + if (!this._isEnabled.read(reader)) { + return; + } + + // Reset the tracked state when tracing is turned off, so the first logs + // after re-enabling report the current state instead of a diff against + // stale values. + store.add(toDisposable(() => { + this._lastLoggedCollapsed.clear(); + this._lastLoggedContentHeight.clear(); + })); + + store.add(autorun(reader => { + const viewModel = state.viewModel.read(reader); + this.log('view model changed', { + hasViewModel: !!viewModel, + isLoading: viewModel?.isLoading.read(reader), + preserveFocusOnLoad: state.isPreserveFocusOnLoad(), + }); + })); + + store.add(autorun(reader => { + const changed: string[] = []; + for (const item of state.items.read(reader)) { + const collapsed = item.collapsed.read(reader); + const key = item.getKey(); + if (this._lastLoggedCollapsed.get(key) !== collapsed) { + this._lastLoggedCollapsed.set(key, collapsed); + changed.push(`${item.getLabel()}=${collapsed}`); + } + } + if (changed.length > 0) { + this.log('collapsed state changed', { changed }); + } + })); + + store.add(autorun(reader => { + // Not read via the reader: this must not re-run on every scroll event. + const scrollTop = state.getScrollTop(); + const changed: string[] = []; + let totalHeight = 0; + for (const item of state.items.read(reader)) { + const contentHeight = item.contentHeight.read(reader); + const key = item.getKey(); + const lastContentHeight = this._lastLoggedContentHeight.get(key); + if (lastContentHeight !== contentHeight) { + // A height change above the current scroll offset shifts + // everything below it and makes the view jump. + const abovePosition = totalHeight < scrollTop ? ' (above scroll offset)' : ''; + changed.push(`${item.getLabel()}: ${lastContentHeight ?? '?'} -> ${contentHeight}${abovePosition}`); + this._lastLoggedContentHeight.set(key, contentHeight); + } + totalHeight += contentHeight + state.spaceBetweenPx; + } + if (changed.length > 0) { + this.log('content height changed', { changed, totalHeight, scrollTop }); + } + })); + })); + } +} + +/** Short, log friendly name of a diff item resource. */ +export function formatUri(uri: URI | undefined): string { + if (!uri) { + return ''; + } + // Pathless URIs (e.g. `changes-multi-diff-source:?`) have an empty + // basename, so fall back to something that still identifies the resource. + return basename(uri) || uri.authority || uri.scheme; +} + +/** Turns a {@link DocumentDiffItemViewModel.getKey} value into a short log label. */ +export function formatDiffItemKey(key: string | undefined): string { + if (key === undefined) { + return ''; + } + try { + const [original, modified] = JSON.parse(key) as (string | undefined)[]; + const uri = modified ?? original; + return uri ? formatUri(URI.parse(uri)) : ''; + } catch { + return key; + } +} + +function formatValue(value: unknown): string { + if (value === undefined) { + return ''; + } + if (URI.isUri(value)) { + return formatUri(value); + } + if (Array.isArray(value)) { + return `[${value.map(formatValue).join(', ')}]`; + } + if (typeof value === 'number') { + return String(Math.round(value * 100) / 100); + } + if (typeof value === 'object') { + return JSON.stringify(value); + } + return String(value); +} diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts index 002a646054cbfc..e9e650c460649a 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts @@ -17,6 +17,7 @@ import { ContextKeyValue, IContextKeyService } from '../../../../platform/contex import { ITextEditorOptions } from '../../../../platform/editor/common/editor.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { OffsetRange } from '../../../common/core/ranges/offsetRange.js'; import { IDiffEditorOptions } from '../../../common/config/editorOptions.js'; import { IRange } from '../../../common/core/range.js'; @@ -27,6 +28,7 @@ import { ICodeEditor } from '../../editorBrowser.js'; import { ObservableElementSizeObserver } from '../diffEditor/utils.js'; import { DiffEditorItemTemplate, TemplateData } from './diffEditorItemTemplate.js'; import { IDocumentDiffItem } from './model.js'; +import { formatDiffItemKey, formatUri, ILoggedDiffItem, MultiDiffEditorLogger } from './multiDiffEditorLogging.js'; import { DocumentDiffItemViewModel, MultiDiffEditorViewModel } from './multiDiffEditorViewModel.js'; import { RevealOptions } from './multiDiffEditorWidget.js'; import { ObjectPool } from './objectPool.js'; @@ -63,6 +65,8 @@ export class MultiDiffEditorWidgetImpl extends Disposable { private readonly _contextKeyService; private readonly _instantiationService; + private readonly _logger: MultiDiffEditorLogger; + /** * When `true`, the automatic "select the first change" initialization that * runs once the view model finishes loading does not move keyboard focus @@ -81,8 +85,10 @@ export class MultiDiffEditorWidgetImpl extends Disposable { private readonly _diffEditorOptions: IDiffEditorOptions | undefined, @IContextKeyService private readonly _parentContextKeyService: IContextKeyService, @IInstantiationService private readonly _parentInstantiationService: IInstantiationService, + @ILogService logService: ILogService, ) { super(); + this._logger = this._register(new MultiDiffEditorLogger(logService)); this._scrollableElements = h('div.scrollContent', [ h('div@content', { style: { @@ -131,12 +137,20 @@ export class MultiDiffEditorWidgetImpl extends Disposable { } const viewModels = vm.items.read(reader); const map = new Map(); + let restoredDocStates = 0; const items = viewModels.map(d => { const item = reader.store.add(new VirtualizedViewItem(d, this._objectPool, this.scrollLeft, delta => { - this._scrollableElement.setScrollPosition({ scrollTop: this._scrollableElement.getScrollPosition().scrollTop + delta }); - })); + const before = this._scrollableElement.getScrollPosition().scrollTop; + this._scrollableElement.setScrollPosition({ scrollTop: before + delta }); + this._logger.log('scroll adjusted by embedded editor', { + file: d.modifiedUri ?? d.originalUri, + delta, + scrollTop: `${before} -> ${this._scrollableElement.getScrollPosition().scrollTop}`, + }); + }, this._logger)); const data = this._lastDocStates?.[item.getKey()]; if (data) { + restoredDocStates++; transaction(tx => { item.setViewState(data, tx); }); @@ -144,6 +158,10 @@ export class MultiDiffEditorWidgetImpl extends Disposable { map.set(d, item); return item; }); + this._logger.log('view items updated', { + items: items.length, + restoredDocStates, + }); return { items, getItem: d => map.get(d)! }; } ); @@ -199,6 +217,14 @@ export class MultiDiffEditorWidgetImpl extends Disposable { this._sizeObserver.observe(dimension); })); + this._logger.logStateChanges({ + viewModel: this._viewModel, + items: this._viewItems, + spaceBetweenPx: this._spaceBetweenPx, + getScrollTop: () => this._scrollableElement.getScrollPosition().scrollTop, + isPreserveFocusOnLoad: () => this._preserveFocusOnLoad, + }); + const placeholderMessage = derived(reader => { const items = this._viewItems.read(reader); if (items.length > 0) { return undefined; } @@ -279,6 +305,8 @@ export class MultiDiffEditorWidgetImpl extends Disposable { return; } + this._logger.log('no active diff item after loading, selecting first change', { items: items.length }); + // Navigate to the first change using the existing navigation // logic. Whether this also moves keyboard focus into the editor // is driven by the last `setViewModel` call: an editor opened @@ -321,6 +349,11 @@ export class MultiDiffEditorWidgetImpl extends Disposable { if (topLanded && leftLanded) { this._pendingScrollState = undefined; } + this._logger.log('applied pending scroll state', { + requested: pending, + applied: { top: applied.scrollTop, left: applied.scrollLeft }, + landed: topLanded && leftLanded, + }); } /** @@ -329,6 +362,11 @@ export class MultiDiffEditorWidgetImpl extends Disposable { * previous model's state for overlapping diff keys. */ public clearPendingRestorationState(): void { + this._logger.log('cleared pending restoration state', { + hadDocStates: !!this._lastDocStates, + hadActiveDiffItemKey: !!this._lastActiveDiffItemKey, + hadScrollState: !!this._pendingScrollState, + }); this._lastDocStates = undefined; this._lastActiveDiffItemKey = undefined; this._pendingScrollState = undefined; @@ -371,6 +409,12 @@ export class MultiDiffEditorWidgetImpl extends Disposable { for (let i = 0; i < index; i++) { scrollTop += viewItems[i].contentHeight.get() + this._spaceBetweenPx; } + this._logger.log('reveal', { + file: viewItem.getLabel(), + index, + scrollTop: `${this._scrollableElement.getScrollPosition().scrollTop} -> ${scrollTop}`, + range: options?.range, + }); this._scrollableElement.setScrollPosition({ scrollTop }); const diffEditor = viewItem.template.get()?.editor; @@ -382,7 +426,7 @@ export class MultiDiffEditorWidgetImpl extends Disposable { } public getViewState(): IMultiDiffEditorViewState { - return { + const viewState: IMultiDiffEditorViewState = { scrollState: { top: this.scrollTop.get(), left: this.scrollLeft.get(), @@ -390,6 +434,17 @@ export class MultiDiffEditorWidgetImpl extends Disposable { docStates: Object.fromEntries(this._viewItems.get().map(i => [i.getKey(), i.getViewState()])), activeDiffItemKey: this._viewModel.get()?.activeDiffItem.get()?.getKey(), }; + if (this._logger.isEnabled) { + const docStates = Object.values(viewState.docStates ?? {}); + this._logger.log('get view state', { + scrollTop: viewState.scrollState.top, + scrollLeft: viewState.scrollState.left, + docStates: docStates.length, + collapsed: docStates.filter(s => s.collapsed).length, + activeDiffItem: formatDiffItemKey(viewState.activeDiffItemKey), + }); + } + return viewState; } /** This accounts for documents that are not loaded yet. */ @@ -406,6 +461,17 @@ export class MultiDiffEditorWidgetImpl extends Disposable { private _pendingScrollState: { top?: number; left?: number } | undefined; public setViewState(viewState: IMultiDiffEditorViewState, tx?: ITransaction): void { + if (this._logger.isEnabled) { + const docStates = Object.values(viewState.docStates ?? {}); + this._logger.log('set view state', { + scrollTop: viewState.scrollState.top, + scrollLeft: viewState.scrollState.left, + docStates: docStates.length, + collapsed: docStates.filter(s => s.collapsed).length, + activeDiffItem: formatDiffItemKey(viewState.activeDiffItemKey), + viewItems: this._viewItems.get().length, + }); + } this.setScrollState(viewState.scrollState); this._lastDocStates = viewState.docStates; @@ -450,8 +516,15 @@ export class MultiDiffEditorWidgetImpl extends Disposable { this._lastActiveDiffItemKey = undefined; const target = items.find(i => i.getKey() === key); if (!target) { + if (this._logger.isEnabled) { + this._logger.log('persisted active diff item not found', { + key: formatDiffItemKey(key), + availableKeys: items.map(i => formatDiffItemKey(i.getKey())), + }); + } return false; } + this._logger.log('restored active diff item', { file: target.modifiedUri ?? target.originalUri, preserveFocus: this._preserveFocusOnLoad }); viewModel.activeDiffItem.setCache(target, undefined); if (!this._preserveFocusOnLoad) { @@ -502,6 +575,8 @@ export class MultiDiffEditorWidgetImpl extends Disposable { const activeViewModel = this._viewModel.get()?.activeDiffItem.get(); const currentIndex = activeViewModel ? viewItems.findIndex(v => v.viewModel === activeViewModel) : -1; + this._logger.log('navigate to change', { direction, focusEditor, currentIndex, items: viewItems.length }); + // Start with first file if no active item if (currentIndex === -1) { this._goToFile(0, 'first', focusEditor); @@ -511,6 +586,7 @@ export class MultiDiffEditorWidgetImpl extends Disposable { // Try current file first - expand if collapsed const currentItem = viewItems[currentIndex]; if (currentItem.viewModel.collapsed.get()) { + this._logger.log('expanding collapsed item to navigate within it', { file: currentItem.getLabel() }); currentItem.viewModel.collapsed.set(false, undefined); } @@ -533,7 +609,9 @@ export class MultiDiffEditorWidgetImpl extends Disposable { private _goToFile(index: number, position: 'first' | 'last', focusEditor: boolean = true): void { const item = this._viewItems.get()[index]; - if (item.viewModel.collapsed.get()) { + const wasCollapsed = item.viewModel.collapsed.get(); + this._logger.log('go to file', { file: item.getLabel(), index, position, focusEditor, wasCollapsed }); + if (wasCollapsed) { item.viewModel.collapsed.set(false, undefined); } @@ -626,7 +704,7 @@ export interface IMultiDiffEditorOptionsViewState { export type IMultiDiffResourceId = { original: URI | undefined; modified: URI | undefined }; -class VirtualizedViewItem extends Disposable { +class VirtualizedViewItem extends Disposable implements ILoggedDiffItem { private readonly _templateRef = this._register(disposableObservableValue | undefined>(this, undefined)); public readonly contentHeight = derived(this, reader => @@ -638,6 +716,8 @@ class VirtualizedViewItem extends Disposable { public readonly template = derived(this, reader => this._templateRef.read(reader)?.object); private _isHidden = observableValue(this, false); + public get collapsed(): IObservable { return this.viewModel.collapsed; } + private readonly _isFocused = derived(this, reader => this.template.read(reader)?.isFocused.read(reader) ?? false); constructor( @@ -645,6 +725,7 @@ class VirtualizedViewItem extends Disposable { private readonly _objectPool: ObjectPool, private readonly _scrollLeft: IObservable, private readonly _deltaScrollVertical: (delta: number) => void, + private readonly _logger: MultiDiffEditorLogger, ) { super(); @@ -681,6 +762,11 @@ class VirtualizedViewItem extends Disposable { return this.viewModel.getKey(); } + /** Short, log friendly name of this item. */ + public getLabel(): string { + return formatUri(this.viewModel.modifiedUri ?? this.viewModel.originalUri); + } + public getViewState(): IMultiDiffDocState { transaction(tx => { this._updateTemplateData(tx); @@ -692,6 +778,7 @@ class VirtualizedViewItem extends Disposable { } public setViewState(viewState: IMultiDiffDocState, tx: ITransaction): void { + this._logger.log('item view state restored', { file: this.getLabel(), collapsed: viewState.collapsed, selections: viewState.selections?.length ?? 0 }); this.viewModel.collapsed.set(viewState.collapsed, tx); this._updateTemplateData(tx); @@ -721,6 +808,7 @@ class VirtualizedViewItem extends Disposable { private _clear(): void { const ref = this._templateRef.get(); if (!ref) { return; } + this._logger.log('releasing editor template', { file: this.getLabel(), contentHeight: ref.object.contentHeight.get() }); transaction(tx => { this._updateTemplateData(tx); ref.object.hide(); @@ -741,6 +829,12 @@ class VirtualizedViewItem extends Disposable { this._templateRef.set(ref, undefined); const selections = this.viewModel.lastTemplateData.get().selections; + this._logger.log('acquired editor template', { + file: this.getLabel(), + collapsed: this.viewModel.collapsed.get(), + expectedContentHeight: this.viewModel.lastTemplateData.get().contentHeight, + selections: selections?.length ?? 0, + }); if (selections) { ref.object.editor.setSelections(selections); } diff --git a/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts index 68c5a9996a09e7..29fb874cc16fde 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts @@ -19,6 +19,7 @@ import { ServiceCollection } from '../../../../platform/instantiation/common/ser import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { AbstractEditorWithViewState } from '../../../../workbench/browser/parts/editor/editorWithViewState.js'; @@ -31,6 +32,7 @@ import { IEditorService } from '../../../../workbench/services/editor/common/edi import { MultiDiffEditorWidget } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; import { MultiDiffEditorViewModel } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.js'; import { IMultiDiffEditorOptions, IMultiDiffEditorViewState } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; +import { MultiDiffEditorLogger } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorLogging.js'; import { IDiffEditorOptions } from '../../../../editor/common/config/editorOptions.js'; import { ITextResourceConfigurationService } from '../../../../editor/common/services/textResourceConfiguration.js'; import { IResourceLabel, IWorkbenchUIElementFactory, MultiDiffEditorItemLabelKind } from '../../../../editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.js'; @@ -188,6 +190,8 @@ export class SessionChangesEditor extends AbstractEditorWithViewState { await super.setInput(input, options, context, token); - this._inputSessionResource.set(this.sessionChangesService.getSessionResource(input.multiDiffSource), undefined); + const sessionResource = this.sessionChangesService.getSessionResource(input.multiDiffSource); + this._inputSessionResource.set(sessionResource, undefined); const viewModel = await input.getViewModel(); if (token.isCancellationRequested) { return; @@ -301,6 +309,11 @@ export class SessionChangesEditor extends AbstractEditorWithViewState { + this._logger.log('editor clear input'); await super.clearInput(); this._contentOverlay?.updateResource(undefined); this._multiDiffEditorWidget!.setViewModel(undefined); From d181be19f6bc9761cc98240eeabbf59636d5d950 Mon Sep 17 00:00:00 2001 From: TylerLeonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:40:03 -0700 Subject: [PATCH 16/21] sessions: stop picking the harness by which one has models (#332034) * sessions: stop picking the harness by which one has models The New Session composer replaced the user's harness with the first one usable without GitHub, which in practice meant the first one that had published models. Models arrive asynchronously, so a user who picked Copilot or Codex while Claude was the only harness with a catalog watched the pick snap back to Claude a moment later. Drop that substitution. The stored preference wins, and the first harness in the list is the default when there is no preference. The draft is also recreated on every session-type change while an explicit pick is set, even when the pick and the draft already agree. That was invisible before because the recreate landed on a different harness, and it is the churn that carried the snap-back. Give the pick branch the same match check the no-pick branch already has. Co-Authored-By: Claude Opus 5 * sessions: clear the upgrade watcher once the pick matches the draft Address PR feedback: - Once a servable pick already matches the draft, the watcher can no longer do anything, so clear it instead of leaving the listener registered holding the created session. This restores the lifetime the old fall-through to _createNewSession gave it. - Condense the inline explanation to one line. - Cover _createSessionNow's openNewSession arguments so a signed-out user's explicit pick cannot be substituted again unnoticed. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- .../contrib/chat/browser/newChatWidget.ts | 42 ++---- .../chat/test/browser/newChatWidget.test.ts | 136 ++++++++++++++++-- 2 files changed, 133 insertions(+), 45 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index 18c4f69c919ebf..89efa459c8a367 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -22,7 +22,7 @@ import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uri import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; import { localize } from '../../../../nls.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; -import { ISession, SESSION_WORKSPACE_GROUP_GITHUB, SessionTypeAuthRequirement } from '../../../services/sessions/common/session.js'; +import { ISession, SESSION_WORKSPACE_GROUP_GITHUB } from '../../../services/sessions/common/session.js'; import { IOpenNewSessionResult, ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { isAllowSignedOutWhenUsableEnabled, shouldShowGitHubWorkspaceGroupSignIn } from '../../../browser/sessionsAuthGate.js'; import { AGENTIC_SIGN_IN_COMMAND_ID } from '../../../common/sessionCommands.js'; @@ -603,19 +603,12 @@ export class NewChatWidget extends Disposable { const preferredPick = userPick && this._isPreferredServable(folderUri, userPick) ? userPick : this._newChatInput.sessionTypePicker.getPreferredSessionType(folderUri); - // A signed-out user (under the conditional-auth opt-in) can't run a type - // that requires GitHub, so default to the first offered type usable - // without it. No-op when signed in or the opt-in is off — today's behavior. - // TODO: reconsider silently switching away from the remembered selection; - // instead keep it and surface an inline "sign in for this type" affordance - // for GitHub-only types. - const effectivePick = this._preferUsableSessionTypeWhenSignedOut(folderUri, preferredPick); const fallbackProviderId = this._workspacePicker.selectedResolved?.providerId; try { return await this.sessionsService.openNewSession({ folderUri, - ...(effectivePick - ? { providerId: effectivePick.providerId, sessionTypeId: effectivePick.sessionTypeId } + ...(preferredPick + ? { providerId: preferredPick.providerId, sessionTypeId: preferredPick.sessionTypeId } : fallbackProviderId ? { providerId: fallbackProviderId } : undefined), @@ -626,29 +619,6 @@ export class NewChatWidget extends Disposable { } } - /** - * While the user is signed out and the conditional-auth opt-in is on, replace - * a pick that requires GitHub with the first offered session type usable - * without it. A no-op when signed in, when the opt-in is off (today's - * behavior), or when no offered type is usable — in which case the caller's - * existing fallbacks still apply. - */ - private _preferUsableSessionTypeWhenSignedOut(folderUri: URI, pick: IPreferredSessionType | undefined): IPreferredSessionType | undefined { - if (this.defaultAccountService.currentDefaultAccount !== null || !isAllowSignedOutWhenUsableEnabled(this.configurationService)) { - return pick; - } - const usable = this.sessionsManagementService.getSessionTypesForFolder(folderUri) - .filter(type => type.sessionType.authRequirement === SessionTypeAuthRequirement.None); - // Match on provider too when the pick names one: two providers can offer - // the same session type id, and only one of them may be usable. - const pickIsUsable = usable.some(type => type.sessionType.id === pick?.sessionTypeId - && (pick?.providerId === undefined || type.providerId === pick.providerId)); - if (usable.length === 0 || pickIsUsable) { - return pick; - } - return { providerId: usable[0].providerId, sessionTypeId: usable[0].sessionType.id }; - } - private _scheduleRecreateOnProviderChange(folderUri: URI, userPick: IPreferredSessionType | undefined, created: ISession | undefined, replayMissedChange: boolean): void { const store = new DisposableStore(); store.add(this.sessionsManagementService.onDidChangeSessionTypes(() => this._recreateOnProviderChange(folderUri, userPick, created))); @@ -668,6 +638,12 @@ export class NewChatWidget extends Disposable { if (!this._isPreferredServable(folderUri, userPick)) { return; // the preferred provider still cannot serve the folder } + // Already running the pick: nothing left to upgrade to, so stop watching. + if (userPick.sessionTypeId === active.sessionType + && (userPick.providerId === undefined || userPick.providerId === active.providerId)) { + this._pendingPreferredUpgrade.clear(); + return; + } } else { // No explicit pick: keep the draft on the preferred (first) // type. Recreate only when that preferred actually changed. diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts index b2723b59166a73..414ea9c6d1d449 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts @@ -7,31 +7,59 @@ import assert from 'assert'; import { DeferredPromise } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; -import { IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; -import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { extUri } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISession } from '../../../../services/sessions/common/session.js'; -import { IOpenNewSessionResult } from '../../../../services/sessions/browser/sessionsService.js'; +import { IOpenNewSessionOptions, IOpenNewSessionResult } from '../../../../services/sessions/browser/sessionsService.js'; import { IPreferredSessionType } from '../../browser/sessionTypePicker.js'; import { NewChatWidget } from '../../browser/newChatWidget.js'; -interface INewChatWidgetHarness { +/** The part of the active session `_recreateOnProviderChange` actually reads. */ +interface IActiveDraft { + readonly sessionId: string; + readonly isCreated: IObservable; + readonly providerId: string; + readonly sessionType: string; +} + +interface IRecreateHarness { readonly _pendingPreferredUpgrade: MutableDisposable; + readonly _session: IObservable; + readonly _newChatInput: { + readonly sessionTypePicker: { + getPreferredSessionType(folderUri: URI): IPreferredSessionType | undefined; + }; + }; + _isPreferredServable(folderUri: URI, pick: IPreferredSessionType): boolean; + _createNewSession(folderUri: URI): Promise; +} + +/** The collaborators `_createSessionNow` reads while assembling the `openNewSession` options. */ +interface ICreateSessionNowHarness { + readonly _newChatInput: { + readonly sessionTypePicker: { + getPreferredSessionType(folderUri: URI): IPreferredSessionType | undefined; + }; + }; + readonly _workspacePicker: { readonly selectedResolved: { readonly providerId: string } | undefined }; + readonly sessionsService: { openNewSession(options: IOpenNewSessionOptions, token: CancellationToken): Promise }; + readonly logService: { error(message: string, ...args: unknown[]): void }; + _isPreferredServable(folderUri: URI, pick: IPreferredSessionType): boolean; +} + +interface INewChatWidgetHarness extends IRecreateHarness { readonly _newSessionCreation: MutableDisposable; readonly sessionsManagementService: { readonly onDidChangeSessionTypes: Event }; - readonly _session: IObservable; readonly _newChatInput: { readonly sessionTypePicker: { getUserPickedSessionType(): IPreferredSessionType | undefined; getPreferredSessionType(folderUri: URI): IPreferredSessionType | undefined; }; }; - _isPreferredServable(folderUri: URI, pick: IPreferredSessionType): boolean; _createSessionNow(folderUri: URI, userPick: IPreferredSessionType | undefined, token: CancellationToken): Promise; - _createNewSession(folderUri: URI): Promise; _scheduleRecreateOnProviderChange(folderUri: URI, userPick: IPreferredSessionType | undefined, created: ISession | undefined, replayMissedChange: boolean): void; _recreateOnProviderChange(folderUri: URI, userPick: IPreferredSessionType | undefined, created: ISession | undefined): void; } @@ -40,8 +68,19 @@ const createNewSession = Reflect.get(NewChatWidget.prototype, '_createNewSession this: INewChatWidgetHarness, folderUri: URI, ) => Promise; +const createSessionNow = Reflect.get(NewChatWidget.prototype, '_createSessionNow') as ( + this: ICreateSessionNowHarness, + folderUri: URI, + userPick: IPreferredSessionType | undefined, + token: CancellationToken, +) => Promise; const scheduleRecreateOnProviderChange = Reflect.get(NewChatWidget.prototype, '_scheduleRecreateOnProviderChange') as INewChatWidgetHarness['_scheduleRecreateOnProviderChange']; -const recreateOnProviderChange = Reflect.get(NewChatWidget.prototype, '_recreateOnProviderChange') as INewChatWidgetHarness['_recreateOnProviderChange']; +const recreateOnProviderChange = Reflect.get(NewChatWidget.prototype, '_recreateOnProviderChange') as ( + this: IRecreateHarness, + folderUri: URI, + userPick: IPreferredSessionType | undefined, + created: { readonly sessionId: string } | undefined, +) => void; const handlePromptOptionsWorkspaceChange = Reflect.get(NewChatWidget.prototype, '_handlePromptOptionsWorkspaceChange') as (this: IPromptOptionsWorkspaceHarness, previousFolderUri: URI | undefined, folderUri: URI | undefined) => void; const hasEnoughSessionsForFirstRunNotices = Reflect.get(NewChatWidget.prototype, '_hasEnoughSessionsForFirstRunNotices') as (this: ISessionCountHarness) => boolean; @@ -59,13 +98,13 @@ function createHarness( pendingPreferredUpgrade: MutableDisposable, newSessionCreation: MutableDisposable, onDidChangeSessionTypes: Event, - createSessionNow: (token: CancellationToken) => Promise, + stubCreateSessionNow: (token: CancellationToken) => Promise, ): INewChatWidgetHarness { const harness: INewChatWidgetHarness = { _pendingPreferredUpgrade: pendingPreferredUpgrade, _newSessionCreation: newSessionCreation, sessionsManagementService: { onDidChangeSessionTypes }, - _session: observableValue('session', undefined), + _session: observableValue('session', undefined), _newChatInput: { sessionTypePicker: { getUserPickedSessionType: () => undefined, @@ -73,7 +112,7 @@ function createHarness( }, }, _isPreferredServable: () => false, - _createSessionNow: (_folderUri, _userPick, token) => createSessionNow(token), + _createSessionNow: (_folderUri, _userPick, token) => stubCreateSessionNow(token), _createNewSession: folderUri => createNewSession.call(harness, folderUri), _scheduleRecreateOnProviderChange: (folderUri, userPick, created, replayMissedChange) => scheduleRecreateOnProviderChange.call(harness, folderUri, userPick, created, replayMissedChange), _recreateOnProviderChange: (folderUri, userPick, created) => recreateOnProviderChange.call(harness, folderUri, userPick, created), @@ -145,6 +184,79 @@ suite('NewChatWidget', () => { assert.deepStrictEqual({ tokenCount: tokens.length, firstCancelledWhenSecondStarted }, { tokenCount: 2, firstCancelledWhenSecondStarted: true }); }); + test('sends the user pick to openNewSession, falling back to the preferred type', async () => { + const folder = URI.file('/project'); + const userPick: IPreferredSessionType = { providerId: 'agent-host', sessionTypeId: 'claude' }; + const preferredType: IPreferredSessionType = { providerId: 'copilot', sessionTypeId: 'copilot-cli' }; + const cases: { pick: IPreferredSessionType | undefined; servable: boolean; preferred: IPreferredSessionType | undefined }[] = [ + { pick: userPick, servable: true, preferred: preferredType }, + { pick: userPick, servable: false, preferred: preferredType }, + { pick: undefined, servable: true, preferred: preferredType }, + { pick: undefined, servable: true, preferred: undefined }, + ]; + + const requested = await Promise.all(cases.map(async ({ pick, servable, preferred }) => { + let options: IOpenNewSessionOptions | undefined; + await createSessionNow.call({ + _newChatInput: { sessionTypePicker: { getPreferredSessionType: () => preferred } }, + _workspacePicker: { selectedResolved: { providerId: 'workspace-provider' } }, + sessionsService: { + openNewSession: async opts => { + options = opts; + return { session: undefined, trustDeclined: false }; + }, + }, + logService: { error: () => { } }, + _isPreferredServable: () => servable, + }, folder, pick, CancellationToken.None); + return { providerId: options?.providerId, sessionTypeId: options?.sessionTypeId }; + })); + + assert.deepStrictEqual(requested, [ + { providerId: 'agent-host', sessionTypeId: 'claude' }, + { providerId: 'copilot', sessionTypeId: 'copilot-cli' }, + { providerId: 'copilot', sessionTypeId: 'copilot-cli' }, + { providerId: 'workspace-provider', sessionTypeId: undefined }, + ]); + }); + + test('a provider change only recreates the draft when the pick differs from it', () => { + const folder = URI.file('/project'); + const draft: IActiveDraft = { sessionId: 's1', isCreated: constObservable(false), providerId: 'agent-host', sessionType: 'claude' }; + const cases: { name: string; pick: IPreferredSessionType; servable: boolean }[] = [ + { name: 'pick matches the draft', pick: { providerId: 'agent-host', sessionTypeId: 'claude' }, servable: true }, + { name: 'pick names no provider, type matches', pick: { sessionTypeId: 'claude' }, servable: true }, + { name: 'pick names another provider', pick: { providerId: 'other', sessionTypeId: 'claude' }, servable: true }, + { name: 'pick names another type', pick: { providerId: 'agent-host', sessionTypeId: 'codex' }, servable: true }, + { name: 'pick cannot be served yet', pick: { providerId: 'other', sessionTypeId: 'codex' }, servable: false }, + ]; + + const outcomes = cases.map(({ name, pick, servable }) => { + let recreated = false; + const watcher = disposables.add(new MutableDisposable()); + watcher.value = toDisposable(() => { }); + recreateOnProviderChange.call({ + _pendingPreferredUpgrade: watcher, + _session: constObservable(draft), + _newChatInput: { sessionTypePicker: { getPreferredSessionType: () => undefined } }, + _isPreferredServable: () => servable, + _createNewSession: async () => { + recreated = true; + return { session: undefined, trustDeclined: false }; + }, + }, folder, pick, { sessionId: 's1' }); + return `${name}: ${recreated ? 'recreated' : watcher.value ? 'still watching' : 'settled'}`; + }); + + assert.deepStrictEqual(outcomes, [ + 'pick matches the draft: settled', + 'pick names no provider, type matches: settled', + 'pick names another provider: recreated', + 'pick names another type: recreated', + 'pick cannot be served yet: still watching', + ]); + }); + test('refreshes prompt options when the draft workspace changes', () => { const changes: string[] = []; const harness: IPromptOptionsWorkspaceHarness = { From eccac6a1a95129f3d39397f97bb369a57ac5669b Mon Sep 17 00:00:00 2001 From: joshspicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:43:39 -0700 Subject: [PATCH 17/21] Respect auto-approve policy for reusable confirmations (#332039) * chat: respect policy for reusable confirmations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: preserve contributed confirmation actions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: restrict approval preference management Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../languageModelToolsConfirmationService.ts | 36 +++++++ .../tools/languageModelToolsService.ts | 4 +- .../chatToolConfirmationSubPart.ts | 5 +- ...guageModelToolsConfirmationService.test.ts | 97 +++++++++++++++++++ .../tools/languageModelToolsService.test.ts | 42 +++++++- 5 files changed, 180 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts index 68a4e078d47591..680b687c564dd4 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts @@ -14,7 +14,9 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { IQuickInputButton, IQuickInputButtonWithToggle, IQuickInputService, IQuickTreeItem, QuickInputButtonLocation } from '../../../../../platform/quickinput/common/quickInput.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ConfirmedReason, ToolConfirmKind } from '../../common/chatService/chatService.js'; +import { isAutoApprovePolicyRestricted } from '../../common/agentHostConfigPolicy.js'; import { ILanguageModelToolConfirmationActions, ILanguageModelToolConfirmationContribution, ILanguageModelToolConfirmationContributionQuickTreeItem, ILanguageModelToolConfirmationRef, ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; import { IToolData, ToolDataSource } from '../../common/tools/languageModelToolsService.js'; @@ -231,6 +233,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements @IInstantiationService private readonly _instantiationService: IInstantiationService, @IQuickInputService private readonly _quickInputService: IQuickInputService, @IDialogService private readonly _dialogService: IDialogService, + @IConfigurationService private readonly _configurationService: IConfigurationService, ) { super(); @@ -256,6 +259,10 @@ export class LanguageModelToolsConfirmationService extends Disposable implements return undefined; } + if (isAutoApprovePolicyRestricted(this._configurationService)) { + return undefined; + } + // Check combination-level confirmation if (ref.combination) { const combinationResult = this._combinationConfirmStore.checkAutoConfirmation(ref.combination.key); @@ -296,6 +303,10 @@ export class LanguageModelToolsConfirmationService extends Disposable implements return undefined; } + if (isAutoApprovePolicyRestricted(this._configurationService)) { + return undefined; + } + // Check tool-level confirmation const toolResult = this._postExecutionToolConfirmStore.checkAutoConfirmation(ref.toolId); if (toolResult) { @@ -327,6 +338,10 @@ export class LanguageModelToolsConfirmationService extends Disposable implements return actions; } + if (isAutoApprovePolicyRestricted(this._configurationService)) { + return actions; + } + // Add combination-level actions when approveCombination is provided if (ref.combination) { const { label: combinationLabel, key: combinationKey, arguments: combinationArgs } = ref.combination; @@ -446,6 +461,10 @@ export class LanguageModelToolsConfirmationService extends Disposable implements return actions; } + if (isAutoApprovePolicyRestricted(this._configurationService)) { + return actions; + } + // Add default tool-level actions actions.push( { @@ -641,6 +660,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements // Helper function to build tree items based on current scope const buildTreeItems = (): IToolTreeItem[] => { const treeItems: IToolTreeItem[] = []; + const defaultApprovalsDisabled = isAutoApprovePolicyRestricted(this._configurationService); // Add server nodes for (const [serverId, serverInfo] of serversWithTools) { @@ -676,12 +696,14 @@ export class LanguageModelToolsConfirmationService extends Disposable implements type: 'tool-pre', toolId: tool.id, label: RUN_WITHOUT_APPROVAL, + disabled: defaultApprovalsDisabled, checked: this._preExecutionToolConfirmStore.getAutoConfirmationIn(tool.id, currentScope) }); toolChildren.push({ type: 'tool-post', toolId: tool.id, label: CONTINUE_WITHOUT_REVIEWING_RESULTS, + disabled: defaultApprovalsDisabled, checked: this._postExecutionToolConfirmStore.getAutoConfirmationIn(tool.id, currentScope) }); } @@ -695,6 +717,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements combinationKey: key, combinationArgs: args, label, + disabled: defaultApprovalsDisabled, checked: true, buttons: args ? [viewArgsButton] : undefined, }); @@ -733,6 +756,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements toolId: tool.id, label: tool.displayName || tool.id, description, + disabled: defaultApprovalsDisabled, checked, collapsed: true, children: toolChildren.length > 0 ? toolChildren : undefined @@ -747,6 +771,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements serverId, iconClass: ThemeIcon.asClassName(Codicon.play), label: localize('continueWithoutReviewing', "Continue without reviewing any tool results"), + disabled: defaultApprovalsDisabled, checked: serverPostConfirmed }); } @@ -756,6 +781,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements serverId, iconClass: ThemeIcon.asClassName(Codicon.play), label: localize('runToolsWithoutApproval', "Run any tool without approval"), + disabled: defaultApprovalsDisabled, checked: serverPreConfirmed }); } @@ -779,6 +805,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements type: 'server', serverId, label: serverInfo.label, + disabled: defaultApprovalsDisabled, checked: serverChecked, children: serverChildren, collapsed: existingItem ? quickTree.isCollapsed(existingItem) : true, @@ -825,12 +852,14 @@ export class LanguageModelToolsConfirmationService extends Disposable implements type: 'tool-pre', toolId: tool.id, label: RUN_WITHOUT_APPROVAL, + disabled: defaultApprovalsDisabled, checked: this._preExecutionToolConfirmStore.getAutoConfirmationIn(tool.id, currentScope) }); toolChildren.push({ type: 'tool-post', toolId: tool.id, label: CONTINUE_WITHOUT_REVIEWING_RESULTS, + disabled: defaultApprovalsDisabled, checked: this._postExecutionToolConfirmStore.getAutoConfirmationIn(tool.id, currentScope) }); } @@ -844,6 +873,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements combinationKey: key, combinationArgs: args, label, + disabled: defaultApprovalsDisabled, checked: true, buttons: args ? [viewArgsButton] : undefined, }); @@ -879,6 +909,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements toolId: tool.id, label: tool.displayName || tool.id, description, + disabled: defaultApprovalsDisabled && contributed?.canUseDefaultApprovals !== false, checked, pickable, collapsed: tools.length > 1, @@ -926,6 +957,11 @@ export class LanguageModelToolsConfirmationService extends Disposable implements quickTree.setItemTree(buildTreeItems()); disposables.add(quickTree.onDidChangeCheckboxState(item => { + if (isAutoApprovePolicyRestricted(this._configurationService) && item.type !== 'manage') { + quickTree.setItemTree(buildTreeItems()); + return; + } + const newState = item.checked ? currentScope : 'never'; if (item.type === 'server' && item.serverId) { diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 753429ff2156d7..cd5271d94133d8 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -1085,7 +1085,9 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo } if (prepared?.confirmationMessages?.title) { - if (prepared.toolSpecificData?.kind !== 'terminal' && prepared.confirmationMessages.allowAutoConfirm !== false) { + if (this._isAutoApprovePolicyRestricted()) { + prepared.confirmationMessages.allowAutoConfirm = false; + } else if (prepared.toolSpecificData?.kind !== 'terminal' && prepared.confirmationMessages.allowAutoConfirm !== false) { prepared.confirmationMessages.allowAutoConfirm = isEligibleForAutoApproval; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts index 9640ce25835655..9df90f3857df74 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts @@ -15,11 +15,13 @@ import { ElementSizeObserver } from '../../../../../../../editor/browser/config/ import { ILanguageService } from '../../../../../../../editor/common/languages/language.js'; import { localize } from '../../../../../../../nls.js'; import { ICommandService } from '../../../../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../../../../platform/keybinding/common/keybinding.js'; import { IMarkdownRenderer } from '../../../../../../../platform/markdown/browser/markdownRenderer.js'; import { IMarkerData, IMarkerService, MarkerSeverity } from '../../../../../../../platform/markers/common/markers.js'; +import { isAutoApprovePolicyRestricted } from '../../../../common/agentHostConfigPolicy.js'; import { IChatToolInvocation, ToolConfirmKind } from '../../../../common/chatService/chatService.js'; import { createToolSchemaUri, ILanguageModelToolsService, IToolConfirmationMessages } from '../../../../common/tools/languageModelToolsService.js'; import { ILanguageModelToolsConfirmationService } from '../../../../common/tools/languageModelToolsConfirmationService.js'; @@ -60,6 +62,7 @@ export class ToolConfirmationSubPart extends AbstractToolConfirmationSubPart { @IChatMarkdownAnchorService private readonly chatMarkdownAnchorService: IChatMarkdownAnchorService, @ILanguageModelToolsConfirmationService private readonly confirmationService: ILanguageModelToolsConfirmationService, @IChatToolRiskAssessmentService riskAssessmentService: IChatToolRiskAssessmentService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { const state = toolInvocation.state.get(); if (state.type !== IChatToolInvocation.StateKind.WaitingForConfirmation || !state.confirmationMessages?.title) { @@ -86,7 +89,7 @@ export class ToolConfirmationSubPart extends AbstractToolConfirmationSubPart { return actions; } - if (state.confirmationMessages?.allowAutoConfirm !== false) { + if (state.confirmationMessages?.allowAutoConfirm !== false || isAutoApprovePolicyRestricted(this.configurationService)) { // Get combination label and precomputed key if present const approveCombination = state.confirmationMessages?.approveCombination; const combination = approveCombination diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsConfirmationService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsConfirmationService.test.ts index 5fbdef75ca71b0..17253e0bea13d2 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsConfirmationService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsConfirmationService.test.ts @@ -5,22 +5,39 @@ import * as assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IConfigurationOverrides, IConfigurationValue, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { LanguageModelToolsConfirmationService } from '../../../browser/tools/languageModelToolsConfirmationService.js'; import { ToolConfirmKind } from '../../../common/chatService/chatService.js'; +import { ChatConfiguration } from '../../../common/constants.js'; import { computeCombinationKey, ILanguageModelToolConfirmationActions, ILanguageModelToolConfirmationContribution, ILanguageModelToolConfirmationRef } from '../../../common/tools/languageModelToolsConfirmationService.js'; import { ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; +class PolicyTestConfigurationService extends TestConfigurationService { + policyRestricted = false; + + override inspect(key: string, overrides?: IConfigurationOverrides): IConfigurationValue { + const result = super.inspect(key, overrides); + return key === ChatConfiguration.GlobalAutoApprove + ? { ...result, policyValue: this.policyRestricted ? false as T : undefined } + : result; + } +} + suite('LanguageModelToolsConfirmationService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); let service: LanguageModelToolsConfirmationService; let instantiationService: TestInstantiationService; + let configurationService: PolicyTestConfigurationService; setup(() => { instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(IStorageService, store.add(new InMemoryStorageService())); + configurationService = new PolicyTestConfigurationService(); + instantiationService.stub(IConfigurationService, configurationService); service = store.add(instantiationService.createInstance(LanguageModelToolsConfirmationService)); }); @@ -359,6 +376,86 @@ suite('LanguageModelToolsConfirmationService', () => { assert.strictEqual(result.type, ToolConfirmKind.UserAction); }); + test('policy restriction makes existing default grants dormant until removal', async () => { + const preRef = createToolRef('preTool'); + const postRef = createToolRef('postTool'); + const combinationRef = await createCombinationRef('combinationTool', { path: 'file.txt' }, 'Allow file.txt'); + const mcpRef = createMcpToolRef('mcpTool', 'serverId', 'Test Server'); + + await service.getPreConfirmActions(preRef).find(action => action.scope === 'workspace')!.select(); + await service.getPostConfirmActions(postRef).find(action => action.scope === 'profile')!.select(); + await service.getPreConfirmActions(combinationRef).find(action => action.scope === 'session' && action.label.includes('file.txt'))!.select(); + await service.getPreConfirmActions(mcpRef).find(action => action.scope === 'profile' && action.label.includes('Test Server'))!.select(); + await service.getPostConfirmActions(mcpRef).find(action => action.scope === 'workspace' && action.label.includes('Test Server'))!.select(); + + configurationService.policyRestricted = true; + assert.deepStrictEqual({ + pre: service.getPreConfirmAction(preRef), + post: service.getPostConfirmAction(postRef), + combination: service.getPreConfirmAction(combinationRef), + mcpPre: service.getPreConfirmAction(mcpRef), + mcpPost: service.getPostConfirmAction(mcpRef), + }, { + pre: undefined, + post: undefined, + combination: undefined, + mcpPre: undefined, + mcpPost: undefined, + }); + + configurationService.policyRestricted = false; + assert.deepStrictEqual({ + pre: service.getPreConfirmAction(preRef), + post: service.getPostConfirmAction(postRef), + combination: service.getPreConfirmAction(combinationRef), + mcpPre: service.getPreConfirmAction(mcpRef), + mcpPost: service.getPostConfirmAction(mcpRef), + }, { + pre: { type: ToolConfirmKind.LmServicePerTool, scope: 'workspace' }, + post: { type: ToolConfirmKind.LmServicePerTool, scope: 'profile' }, + combination: { type: ToolConfirmKind.LmServicePerTool, scope: 'session' }, + mcpPre: { type: ToolConfirmKind.LmServicePerTool, scope: 'profile' }, + mcpPost: { type: ToolConfirmKind.LmServicePerTool, scope: 'workspace' }, + }); + }); + + test('policy restriction suppresses default actions but preserves contribution decisions and actions', async () => { + const customActions: ILanguageModelToolConfirmationActions[] = [{ + label: 'Custom Pre Action', + select: async () => true, + }]; + const contribution: ILanguageModelToolConfirmationContribution = { + getPreConfirmAction: () => ({ type: ToolConfirmKind.UserAction }), + getPostConfirmAction: () => ({ type: ToolConfirmKind.UserAction }), + getPreConfirmActions: () => customActions, + getPostConfirmActions: () => [{ + label: 'Custom Post Action', + select: async () => true, + }], + }; + store.add(service.registerConfirmationContribution('customTool', contribution)); + const ref: ILanguageModelToolConfirmationRef = { + ...createMcpToolRef('customTool', 'serverId', 'Test Server'), + combination: { + label: 'Allow custom combination', + key: await computeCombinationKey('customTool', {}), + }, + }; + + configurationService.policyRestricted = true; + assert.deepStrictEqual({ + preDecision: service.getPreConfirmAction(ref), + postDecision: service.getPostConfirmAction(ref), + preActions: service.getPreConfirmActions(ref).map(action => action.label), + postActions: service.getPostConfirmActions(ref).map(action => action.label), + }, { + preDecision: { type: ToolConfirmKind.UserAction }, + postDecision: { type: ToolConfirmKind.UserAction }, + preActions: ['Custom Pre Action'], + postActions: ['Custom Post Action'], + }); + }); + test('contribution with canUseDefaultApprovals=false prevents default store checks', () => { const contribution: ILanguageModelToolConfirmationContribution = { canUseDefaultApprovals: false, diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts index 63a5e97ff4d4e8..a4c23be791c2b3 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts @@ -14,7 +14,7 @@ import { IAccessibilityService } from '../../../../../../platform/accessibility/ import { TestAccessibilityService } from '../../../../../../platform/accessibility/test/common/testAccessibilityService.js'; import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; -import { ConfigurationTarget, IConfigurationChangeEvent } from '../../../../../../platform/configuration/common/configuration.js'; +import { ConfigurationTarget, IConfigurationChangeEvent, IConfigurationOverrides, IConfigurationValue } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ContextKeyService } from '../../../../../../platform/contextkey/browser/contextKeyService.js'; import { ContextKeyEqualsExpr, ContextKeyExpr, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; @@ -167,6 +167,15 @@ async function waitForPublishedInvocation(capture: { invocation?: any }, tries = return capture.invocation; } +class AutoApprovePolicyTestConfigurationService extends TestConfigurationService { + override inspect(key: string, overrides?: IConfigurationOverrides): IConfigurationValue { + const result = super.inspect(key, overrides); + return key === ChatConfiguration.GlobalAutoApprove + ? { ...result, policyValue: false as T } + : result; + } +} + interface TestToolsServiceSetup { configurationService: TestConfigurationService; chatService: MockChatService; @@ -181,6 +190,7 @@ interface TestToolsServiceOptions { telemetryService?: Partial; commandService?: Partial; dialogService?: IDialogService; + configurationService?: TestConfigurationService; /** Called after configurationService is created but before the service is instantiated */ configureServices?: (config: TestConfigurationService) => void; } @@ -190,7 +200,7 @@ interface TestToolsServiceOptions { * Reduces boilerplate when tests need custom service configurations. */ function createTestToolsService(store: ReturnType, options?: TestToolsServiceOptions): TestToolsServiceSetup { - const configurationService = new TestConfigurationService(); + const configurationService = options?.configurationService ?? new TestConfigurationService(); configurationService.setUserConfiguration(ChatConfiguration.ExtensionToolsEnabled, true); // Allow tests to configure before service creation @@ -2365,6 +2375,34 @@ suite('LanguageModelToolsService', () => { assert.strictEqual(unspecifiedResult.content[0].value, 'unspecified defaults to eligible'); }); + test('auto-approve policy restriction disables reusable actions while allowing approval once', async () => { + const configurationService = new AutoApprovePolicyTestConfigurationService(); + const { service: testService, chatService: testChatService } = createTestToolsService(store, { configurationService }); + const tool = registerToolForTest(testService, store, 'policyRestrictedTool', { + prepareToolInvocation: async () => ({ + confirmationMessages: { + title: 'Confirm this action?', + message: 'This tool requires confirmation', + allowAutoConfirm: true, + }, + }), + invoke: async () => ({ content: [{ kind: 'text', value: 'approved once' }] }), + }); + const capture: { invocation?: any } = {}; + stubGetSession(testChatService, 'policy-restricted-session', { capture }); + + const invocation = testService.invokeTool( + tool.makeDto({}, { sessionId: 'policy-restricted-session' }), + async () => 0, + CancellationToken.None, + ); + const published = await waitForPublishedInvocation(capture); + assert.strictEqual(published.confirmationMessages?.allowAutoConfirm, false); + + IChatToolInvocation.confirmWith(published, { type: ToolConfirmKind.UserAction }); + assert.deepStrictEqual(await invocation, { content: [{ kind: 'text', value: 'approved once' }] }); + }); + test('tool content formatting with alwaysDisplayInputOutput', async () => { // Test ensureToolDetails, formatToolInput, and toolResultToIO const toolData: IToolData = { From 08d458056874dab42f3bcd81e88fe0e12ca9b4bc Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Fri, 21 Aug 2026 14:52:52 -0700 Subject: [PATCH 18/21] fix: memory leak in settings preview indicator (#331990) fix: dispose previous settings preview hover Co-authored-by: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> --- .../settingsEditorSettingIndicators.ts | 8 ++-- .../settingsEditorSettingIndicators.test.ts | 44 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 src/vs/workbench/contrib/preferences/test/browser/settingsEditorSettingIndicators.test.ts diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts index 39ee5f260ed834..6a44d5bdde0588 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts @@ -11,7 +11,7 @@ import { SimpleIconLabel } from '../../../../base/browser/ui/iconLabel/simpleIco import { Emitter } from '../../../../base/common/event.js'; import { IMarkdownString, MarkdownString, createMarkdownLink } from '../../../../base/common/htmlContent.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; -import { DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +import { DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { ILanguageService } from '../../../../editor/common/languages/language.js'; @@ -78,6 +78,7 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { private readonly parenthesizedIndicators: SettingIndicator[]; private readonly keybindingListeners: DisposableStore = new DisposableStore(); + private readonly previewHover = new MutableDisposable(); private focusedIndex = 0; constructor( @@ -311,10 +312,10 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { localize('experimentalLabel', "Experimental"); const content = isPreviewSetting ? PREVIEW_INDICATOR_DESCRIPTION : EXPERIMENTAL_INDICATOR_DESCRIPTION; - this.previewIndicator.disposables.add(this.hoverService.setupDelayedHover(this.previewIndicator.element, { + this.previewHover.value = this.hoverService.setupDelayedHover(this.previewIndicator.element, { ...this.defaultHoverOptions, content, - }, { setupKeyboardEvents: true })); + }, { setupKeyboardEvents: true }); this.render(); } @@ -338,6 +339,7 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { dispose() { this.keybindingListeners.dispose(); + this.previewHover.dispose(); for (const indicator of this.isolatedIndicators) { indicator.disposables.dispose(); } diff --git a/src/vs/workbench/contrib/preferences/test/browser/settingsEditorSettingIndicators.test.ts b/src/vs/workbench/contrib/preferences/test/browser/settingsEditorSettingIndicators.test.ts new file mode 100644 index 00000000000000..1e4606a8f3d9dd --- /dev/null +++ b/src/vs/workbench/contrib/preferences/test/browser/settingsEditorSettingIndicators.test.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { toDisposable } from '../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { SettingsTreeIndicatorsLabel } from '../../browser/settingsEditorSettingIndicators.js'; +import { SettingsTreeSettingElement } from '../../browser/settingsTreeModels.js'; + +suite('SettingsTreeIndicatorsLabel', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('replaces the preview hover when updated', () => { + const hoverDisposables: { disposed: boolean }[] = []; + const hoverService = { + setupDelayedHover: () => { + const entry = { disposed: false }; + hoverDisposables.push(entry); + return toDisposable(() => entry.disposed = true); + } + }; + const label = new SettingsTreeIndicatorsLabel( + document.createElement('div'), + undefined!, + hoverService as never, + undefined!, + undefined!, + undefined!, + ); + + label.updatePreviewIndicator({ tags: new Set(['preview']) } as unknown as SettingsTreeSettingElement); + const firstHover = hoverDisposables.at(-1)!; + label.updatePreviewIndicator({ tags: new Set(['experimental']) } as unknown as SettingsTreeSettingElement); + const secondHover = hoverDisposables.at(-1)!; + + assert.strictEqual(firstHover.disposed, true); + assert.strictEqual(secondHover.disposed, false); + + label.dispose(); + assert.strictEqual(secondHover.disposed, true); + }); +}); From e33d147d4c0fa65ce17cb73ec9d798f064b4bf1f Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:58:52 +0200 Subject: [PATCH 19/21] Agents - do not hide the action when showing the context menu (#332019) --- .../sessions/contrib/sessions/browser/views/sessionsList.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 18bcec1f5727f9..8fad3c22b43331 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -1003,12 +1003,12 @@ export class SessionSectionRenderer implements ITreeRenderer { - actionViewItemDisposables.clear(); - if (action.id !== NEW_SESSION_FOR_WORKSPACE_ACTION_ID || !(action instanceof MenuItemAction)) { return undefined; } + actionViewItemDisposables.clear(); + const dropdownActions = getFlatContextMenuActions(this.menuService.getMenuActions( Menus.SessionSectionNewSession, contextKeyService, From aa1d29a95140875e304e719f3bc2ff5dc81c12b3 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 21 Aug 2026 15:09:36 -0700 Subject: [PATCH 20/21] agentHost: adopt AHP 1.0.0 breaking changes (#331999) * agentHost: adopt AHP 1.0.0 breaking changes Syncs the generated protocol types to AHP 1.0.0 and adopts the breaking changes in the agent host, the workbench chat session handler, and the Agents window provider. - Removes session-level forking. `CreateSessionParams.fork` and `SessionForkSource` no longer exist, so the fork configuration, its service plumbing, and the protocol forwarding are deleted. The editor-window Fork Conversation gesture now forks into a peer chat of the same session, which is how the Agents window already behaved. Chat-level forking does not change. - Moves `ChatInputRequestPurpose` into the request `_meta` bag. The protocol no longer models the purpose, so a new helper writes and reads it. This keeps the ask-user telemetry and the elicitation classification. - Replaces the terminal `exitCode` field with an explicit running/exited lifecycle. An exit without an exit code is now correctly an exit. - Supplies the owning chat URI on each `TerminalSessionClaim`. The Copilot session runtime gives the chat URI to the shell tools, the non-pty output streams, and the local bang command, and the workbench records the owning chat for each observed terminal. - Renames `SessionLifecycle.CreationFailed` to `SessionLifecycle.Failed`. - Replaces the annotation `turnId` with an `origin` that holds the session, the chat, and the turn. Also corrects the persistence check, which discarded restored annotations. - Adds `MethodNotFound` handlers for the new automation commands. This host does not advertise the automation capability. - Corrects the test data that omitted the turn duration. The chat reducer now calculates `modifiedAt` from that duration instead of the local clock. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: correct the artifact location helper name The image carousel entries called rtifactLocation, but the helper is named sessionArtifactLocation. This broke the build on main. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: address review feedback on the AHP 1.0.0 adoption - Carries an explicit `hasExited` signal through `IChatTerminalOutputSource`. A command can now exit without an exit code, so chat must not read completion from the optional code. - Rejects `session/workingDirectoryReplaced`. The action is client-dispatchable, but no provider advertises `primaryReplacement` and the host applies no backend side effect. - Migrates annotations that were persisted before the origin change. Their records hold a top-level `turnId`, which the new check discarded as invalid. - Derives the owning session for a shell terminal claim from the chat URI. The shell manager is constructed with a chat URI for a peer chat, so its own scope URI is not the session. - Sends real timestamps from the end-to-end turn helpers. The chat reducer now calculates `modifiedAt` from the turn, so a fixed past `startedAt` made a peer chat look stale and removed its edits from the session changeset. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: correct turn data in the protocol integration tests The chat reducer now calculates `modifiedAt` from the turn action instead of the local clock, so turn data that was previously ignored must be correct. - Sends a real `startedAt` from the shared turn helper. A fixed past timestamp made a completed turn look older than the session that contains it. - Supplies the required `duration` when the cancellation test cancels a turn. Without it the reducer calculates an invalid date, throws, and the cancellation never applies. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: repair the merge of the AHP 1.0.0 adoption The merge of main dropped a test helper call and did not apply the terminal lifecycle change to the tests that main added, which broke the compile and one unit test. - Restores `createTestAgentService` in the peer chat title test. The merge replaced it with a direct constructor call, whose arguments no longer match. - Sets the terminal lifecycle on the two reconnect tests that main added. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentHostProtocolClient.ts | 1 - src/vs/platform/agentHost/common/agent.ts | 15 - .../platform/agentHost/common/changesetUri.ts | 2 +- .../common/meta/agentChatInputRequestMeta.ts | 53 +++ .../common/state/protocol/.ahp-version | 2 +- .../state/protocol/action-origin.generated.ts | 56 ++- .../common/state/protocol/actions.ts | 2 + .../protocol/channels-annotations/actions.ts | 10 +- .../protocol/channels-annotations/reducer.ts | 4 +- .../protocol/channels-annotations/state.ts | 33 +- .../channels-automation-run/actions.ts | 87 +++++ .../channels-automation-run/reducer.ts | 57 +++ .../protocol/channels-automation-run/state.ts | 244 +++++++++++++ .../protocol/channels-automation/actions.ts | 130 +++++++ .../protocol/channels-automation/commands.ts | 118 +++++++ .../protocol/channels-automation/reducer.ts | 48 +++ .../protocol/channels-automation/state.ts | 334 ++++++++++++++++++ .../protocol/channels-changeset/actions.ts | 2 - .../protocol/channels-changeset/reducer.ts | 7 +- .../state/protocol/channels-chat/reducer.ts | 10 +- .../state/protocol/channels-chat/state.ts | 18 +- .../state/protocol/channels-root/state.ts | 34 +- .../protocol/channels-session/actions.ts | 36 +- .../protocol/channels-session/commands.ts | 34 +- .../protocol/channels-session/reducer.ts | 34 +- .../state/protocol/channels-session/state.ts | 62 +++- .../protocol/channels-terminal/reducer.ts | 10 +- .../state/protocol/channels-terminal/state.ts | 49 ++- .../common/state/protocol/commands.ts | 1 + .../common/state/protocol/common/actions.ts | 26 +- .../common/state/protocol/common/commands.ts | 81 +++++ .../common/state/protocol/common/messages.ts | 4 + .../state/protocol/common/reducer-helpers.ts | 4 +- .../common/state/protocol/common/state.ts | 4 +- .../state/protocol/common/timestamps.ts | 11 + .../common/state/protocol/reducers.ts | 2 + .../agentHost/common/state/protocol/state.ts | 2 + .../common/state/protocol/version/registry.ts | 13 +- .../agentHost/common/state/sessionState.ts | 3 +- .../node/agentHostChangesetService.ts | 2 +- .../node/agentHostGitStateService.ts | 2 +- .../node/agentHostInputRequestTracker.ts | 7 +- .../node/agentHostTerminalManager.ts | 41 +-- .../platform/agentHost/node/agentService.ts | 193 +++++----- .../agentHost/node/claude/claudeCanUseTool.ts | 8 +- .../node/claude/claudeElicitation.ts | 11 +- .../node/codex/codexElicitationMapper.ts | 18 +- .../node/codex/codexUserInputMapper.ts | 8 +- .../node/copilot/copilotAgentSession.ts | 18 +- .../copilot/copilotNonPtyShellTerminals.ts | 10 +- .../node/copilot/copilotSessionLauncher.ts | 4 +- .../node/copilot/copilotShellTools.ts | 21 +- .../node/localCommands/bangLocalCommand.ts | 1 + .../agentHost/node/protocolServerHandler.ts | 31 +- .../node/shared/agentFeedbackServerTools.ts | 13 +- .../test/common/agentSubscription.test.ts | 5 +- .../agentHostProtocolClient.test.ts | 9 +- .../node/agentFeedbackServerTools.test.ts | 6 +- .../node/agentHostInputRequestTracker.test.ts | 30 +- .../test/node/agentHostStateManager.test.ts | 8 +- .../node/agentHostTerminalManager.test.ts | 48 ++- .../agentHost/test/node/agentService.test.ts | 166 ++------- .../test/node/agentSideEffects.test.ts | 17 +- .../test/node/claudeAgent.integrationTest.ts | 2 +- .../agentHost/test/node/claudeAgent.test.ts | 63 ++-- .../test/node/claudeElicitation.test.ts | 21 +- .../node/codex/codexElicitationMapper.test.ts | 7 +- .../node/codex/codexUserInputMapper.test.ts | 5 +- .../agentHost/test/node/copilotAgent.test.ts | 44 +-- .../test/node/copilotAgentSession.test.ts | 15 +- .../node/copilotNonPtyShellTerminals.test.ts | 4 +- .../test/node/copilotSessionLauncher.test.ts | 2 + .../test/node/copilotShellTools.test.ts | 87 ++--- .../e2e/harness/agentHostE2ETestHarness.ts | 4 +- .../copilotPromptsE2E.integrationTest.ts | 2 +- .../test/node/e2e/suites/annotationsSuite.ts | 41 ++- .../test/node/e2e/suites/changesetSuite.ts | 2 +- .../node/e2e/suites/copilotCoverageSuite.ts | 57 +-- .../test/node/e2e/suites/coreSuite.ts | 2 +- .../test/node/e2e/suites/multiChatSuite.ts | 2 +- .../node/e2e/suites/protocolContractsSuite.ts | 26 -- .../test/node/e2e/suites/serverToolsSuite.ts | 2 +- .../e2e/suites/sessionPersistenceSuite.ts | 2 +- .../node/e2e/suites/stateOperationsSuite.ts | 22 +- .../sessionFeatures.integrationTest.ts | 81 +---- .../protocol/turnExecution.integrationTest.ts | 2 +- .../test/node/protocolServerHandler.test.ts | 26 +- .../node/providerIntegrationTestHelpers.ts | 4 +- .../agentHost/test/node/reducers.test.ts | 42 +-- .../test/node/serverIntegrationTestHelpers.ts | 5 +- .../test/node/testAgentHostTerminalManager.ts | 1 - .../browser/agentFeedbackItemsBackend.ts | 2 +- .../agentHost/agentHostSessionHandler.ts | 103 ++++-- .../chatTerminalToolProgressPart.ts | 6 +- .../agentHostChatContribution.test.ts | 12 +- .../browser/agentHostOutputChannel.ts | 9 +- .../contrib/terminal/browser/terminal.ts | 6 + .../test/browser/agentHostPty.test.ts | 7 +- 98 files changed, 2109 insertions(+), 929 deletions(-) create mode 100644 src/vs/platform/agentHost/common/meta/agentChatInputRequestMeta.ts create mode 100644 src/vs/platform/agentHost/common/state/protocol/channels-automation-run/actions.ts create mode 100644 src/vs/platform/agentHost/common/state/protocol/channels-automation-run/reducer.ts create mode 100644 src/vs/platform/agentHost/common/state/protocol/channels-automation-run/state.ts create mode 100644 src/vs/platform/agentHost/common/state/protocol/channels-automation/actions.ts create mode 100644 src/vs/platform/agentHost/common/state/protocol/channels-automation/commands.ts create mode 100644 src/vs/platform/agentHost/common/state/protocol/channels-automation/reducer.ts create mode 100644 src/vs/platform/agentHost/common/state/protocol/channels-automation/state.ts create mode 100644 src/vs/platform/agentHost/common/state/protocol/common/timestamps.ts diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index fa167a26fcb7ff..8c9652ab8a179f 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -1024,7 +1024,6 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect _meta: config?._meta, provider, workingDirectories: config?.workingDirectories?.map(d => fromAgentHostUri(d).toString()), - fork: config?.fork ? { session: fromAgentHostUri(config.fork.session).toString(), turnId: config.fork.turnId } : undefined, config: config?.config, activeClient: config?.activeClient, progressToken: config?.progressToken, diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index 66f1500b352962..91e548bed76f45 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -362,21 +362,6 @@ export interface IAgentCreateSessionConfig { * connection's own `clientId`. */ readonly activeClient?: SessionActiveClient; - /** Fork from an existing session at a specific turn. */ - readonly fork?: { - readonly session: URI; - /** Exact source chat supplied transiently by the orchestrator. */ - readonly chat: URI; - readonly turnIndex: number; - readonly turnId: string; - /** - * Maps old protocol turn IDs to new protocol turn IDs. - * Populated by the service layer after generating fresh UUIDs - * for the forked session's turns. Used by the agent to remap - * per-turn data (e.g. SDK event ID mappings) in the session database. - */ - readonly turnIdMapping?: ReadonlyMap; - }; /** * Import an existing (e.g. local) conversation into a brand-new session as * real, editable turns. The provider translates {@link turns} into a diff --git a/src/vs/platform/agentHost/common/changesetUri.ts b/src/vs/platform/agentHost/common/changesetUri.ts index 1aaefca5028df2..db6c6cb99d8a31 100644 --- a/src/vs/platform/agentHost/common/changesetUri.ts +++ b/src/vs/platform/agentHost/common/changesetUri.ts @@ -294,7 +294,7 @@ export function parseCompareTurnsChangesetUri(uri: URI): { sessionUri: URI; orig */ export function buildDefaultChangesetCatalog(sessionUri: URI, state?: ISessionWithDefaultChat): Changeset[] { // Session that failed to create - if (!state || state.lifecycle === SessionLifecycle.CreationFailed) { + if (!state || state.lifecycle === SessionLifecycle.Failed) { return []; } diff --git a/src/vs/platform/agentHost/common/meta/agentChatInputRequestMeta.ts b/src/vs/platform/agentHost/common/meta/agentChatInputRequestMeta.ts new file mode 100644 index 00000000000000..a063066d41dd5f --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/agentChatInputRequestMeta.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 { ChatInputRequest } from '../state/protocol/channels-chat/state.js'; + +/** + * Why the agent requested chat input. + * + * AHP no longer models this on {@link ChatInputRequest}; VS Code keeps the + * classification for telemetry and UI by carrying it in the request's open + * metadata bag. + */ +export const enum ChatInputRequestPurpose { + AskUser = 'askUser', + Elicitation = 'elicitation', + PlanReview = 'planReview', +} + +/** + * {@link ChatInputRequest} has no declared `_meta` field. The request is + * carried verbatim through the protocol as JSON, so an extra bag survives the + * round-trip. + */ +type ChatInputRequestWithMeta = ChatInputRequest & { _meta?: Record }; + +const PURPOSE_META_KEY = 'purpose'; + +function isChatInputRequestPurpose(value: unknown): value is ChatInputRequestPurpose { + return value === ChatInputRequestPurpose.AskUser + || value === ChatInputRequestPurpose.Elicitation + || value === ChatInputRequestPurpose.PlanReview; +} + +/** Reads the purpose an input request was created with, if it was classified. */ +export function readChatInputRequestPurpose(request: ChatInputRequest): ChatInputRequestPurpose | undefined { + const meta = (request as ChatInputRequestWithMeta)._meta; + if (!meta) { + return undefined; + } + const purpose = meta[PURPOSE_META_KEY]; + return isChatInputRequestPurpose(purpose) ? purpose : undefined; +} + +/** Returns a copy of `request` classified with `purpose`. */ +export function withChatInputRequestPurpose(request: T, purpose: ChatInputRequestPurpose): T { + const meta = (request as ChatInputRequestWithMeta)._meta; + return { + ...request, + _meta: { ...meta, [PURPOSE_META_KEY]: purpose }, + }; +} diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index b8509767b3fa09..fc634635294cd2 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -bd27d354 +f770e26b diff --git a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts index f089bd62481c4a..b99a78eed64b60 100644 --- a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts +++ b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts @@ -9,7 +9,7 @@ // Generated from types/actions.ts — do not edit // Run `npm run generate` to regenerate. -import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionChatAddedAction, type SessionChatRemovedAction, type SessionChatUpdatedAction, type SessionDefaultChatChangedAction, type SessionTitleChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionWorkingDirectorySetAction, type SessionWorkingDirectoryRemovedAction, type SessionInputNeededSetAction, type SessionInputNeededRemovedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionMcpServerStateChangedAction, type SessionMcpServerStartRequestedAction, type SessionMcpServerStopRequestedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChatTurnStartedAction, type ChatDeltaAction, type ChatResponsePartAction, type ChatToolCallStartAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallConfirmedAction, type ChatToolCallCompleteAction, type ChatToolCallResultConfirmedAction, type ChatToolCallContentChangedAction, type ChatToolCallAuthRequiredAction, type ChatToolCallAuthResolvedAction, type ChatTurnCompleteAction, type ChatTurnCancelledAction, type ChatErrorAction, type ChatActivityChangedAction, type ChatWorkingDirectorySetAction, type ChatWorkingDirectoryRemovedAction, type ChatUsageAction, type ChatReasoningAction, type ChatPendingMessageSetAction, type ChatPendingMessageRemovedAction, type ChatQueuedMessagesReorderedAction, type ChatDraftChangedAction, type ChatInputRequestedAction, type ChatInputAnswerChangedAction, type ChatInputCompletedAction, type ChatTruncatedAction, type ChatTurnsLoadedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetFilesReviewChangedAction, type ChangesetContentChangedAction, type ChangesetOperationsChangedAction, type ChangesetOperationStatusChangedAction, type ChangesetClearedAction, type AnnotationsSetAction, type AnnotationsUpdatedAction, type AnnotationsRemovedAction, type AnnotationsEntrySetAction, type AnnotationsEntryRemovedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction, type ResourceWatchChangedAction } from './actions.js'; +import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionChatAddedAction, type SessionChatRemovedAction, type SessionChatUpdatedAction, type SessionDefaultChatChangedAction, type SessionTitleChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionWorkingDirectorySetAction, type SessionWorkingDirectoryRemovedAction, type SessionWorkingDirectoryReplacedAction, type SessionInputNeededSetAction, type SessionInputNeededRemovedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionMcpServerStateChangedAction, type SessionMcpServerStartRequestedAction, type SessionMcpServerStopRequestedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChatTurnStartedAction, type ChatDeltaAction, type ChatResponsePartAction, type ChatToolCallStartAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallConfirmedAction, type ChatToolCallCompleteAction, type ChatToolCallResultConfirmedAction, type ChatToolCallContentChangedAction, type ChatToolCallAuthRequiredAction, type ChatToolCallAuthResolvedAction, type ChatTurnCompleteAction, type ChatTurnCancelledAction, type ChatErrorAction, type ChatActivityChangedAction, type ChatWorkingDirectorySetAction, type ChatWorkingDirectoryRemovedAction, type ChatUsageAction, type ChatReasoningAction, type ChatPendingMessageSetAction, type ChatPendingMessageRemovedAction, type ChatQueuedMessagesReorderedAction, type ChatDraftChangedAction, type ChatInputRequestedAction, type ChatInputAnswerChangedAction, type ChatInputCompletedAction, type ChatTruncatedAction, type ChatTurnsLoadedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetFilesReviewChangedAction, type ChangesetContentChangedAction, type ChangesetOperationsChangedAction, type ChangesetOperationStatusChangedAction, type ChangesetClearedAction, type AnnotationsSetAction, type AnnotationsUpdatedAction, type AnnotationsRemovedAction, type AnnotationsEntrySetAction, type AnnotationsEntryRemovedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction, type ResourceWatchChangedAction, type AutomationCreateRequestedAction, type AutomationUpdateRequestedAction, type AutomationSetAction, type AutomationRemovedAction, type AutomationRunLifecycleChangedAction, type AutomationRunSessionSetAction, type AutomationRunSessionRemovedAction, type AutomationRunPrimarySessionChangedAction, type AutomationRunCancelRequestedAction } from './actions.js'; // ─── Root vs Session vs Chat vs Terminal vs Changeset Action Unions ───────────────── @@ -48,6 +48,7 @@ export type SessionAction = | SessionActiveClientRemovedAction | SessionWorkingDirectorySetAction | SessionWorkingDirectoryRemovedAction + | SessionWorkingDirectoryReplacedAction | SessionInputNeededSetAction | SessionInputNeededRemovedAction | SessionCustomizationsChangedAction @@ -72,6 +73,7 @@ export type ClientSessionAction = | SessionActiveClientRemovedAction | SessionWorkingDirectorySetAction | SessionWorkingDirectoryRemovedAction + | SessionWorkingDirectoryReplacedAction | SessionCustomizationToggledAction | SessionMcpServerStartRequestedAction | SessionMcpServerStopRequestedAction @@ -270,6 +272,48 @@ export type ServerResourceWatchAction = | ResourceWatchChangedAction ; +/** Union of all automation-scoped actions. */ +export type AutomationAction = + | AutomationCreateRequestedAction + | AutomationUpdateRequestedAction + | AutomationSetAction + | AutomationRemovedAction + ; + +/** Union of automation actions that clients may dispatch. */ +export type ClientAutomationAction = + | AutomationCreateRequestedAction + | AutomationUpdateRequestedAction + | AutomationRemovedAction + ; + +/** Union of automation actions that only the server may produce. */ +export type ServerAutomationAction = + | AutomationSetAction + ; + +/** Union of all automation-run-scoped actions. */ +export type AutomationRunAction = + | AutomationRunLifecycleChangedAction + | AutomationRunSessionSetAction + | AutomationRunSessionRemovedAction + | AutomationRunPrimarySessionChangedAction + | AutomationRunCancelRequestedAction + ; + +/** Union of automation-run actions that clients may dispatch. */ +export type ClientAutomationRunAction = + | AutomationRunCancelRequestedAction + ; + +/** Union of automation-run actions that only the server may produce. */ +export type ServerAutomationRunAction = + | AutomationRunLifecycleChangedAction + | AutomationRunSessionSetAction + | AutomationRunSessionRemovedAction + | AutomationRunPrimarySessionChangedAction + ; + // ─── Client-Dispatchable Map ───────────────────────────────────────────────── /** @@ -293,6 +337,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.SessionActiveClientRemoved]: true, [ActionType.SessionWorkingDirectorySet]: true, [ActionType.SessionWorkingDirectoryRemoved]: true, + [ActionType.SessionWorkingDirectoryReplaced]: true, [ActionType.SessionInputNeededSet]: false, [ActionType.SessionInputNeededRemoved]: false, [ActionType.SessionCustomizationsChanged]: false, @@ -362,4 +407,13 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.TerminalCommandExecuted]: false, [ActionType.TerminalCommandFinished]: false, [ActionType.ResourceWatchChanged]: false, + [ActionType.AutomationCreateRequested]: true, + [ActionType.AutomationUpdateRequested]: true, + [ActionType.AutomationSet]: false, + [ActionType.AutomationRemoved]: true, + [ActionType.AutomationRunLifecycleChanged]: false, + [ActionType.AutomationRunSessionSet]: false, + [ActionType.AutomationRunSessionRemoved]: false, + [ActionType.AutomationRunPrimarySessionChanged]: false, + [ActionType.AutomationRunCancelRequested]: true, }; diff --git a/src/vs/platform/agentHost/common/state/protocol/actions.ts b/src/vs/platform/agentHost/common/state/protocol/actions.ts index 133c434cdca2ae..445fa8cb797258 100644 --- a/src/vs/platform/agentHost/common/state/protocol/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/actions.ts @@ -14,3 +14,5 @@ export * from './channels-terminal/actions.js'; export * from './channels-changeset/actions.js'; export * from './channels-annotations/actions.js'; export * from './channels-resource-watch/actions.js'; +export * from './channels-automation/actions.js'; +export * from './channels-automation-run/actions.js'; diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-annotations/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-annotations/actions.ts index 7c8bc55b9e2155..6c62c129534432 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-annotations/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-annotations/actions.ts @@ -8,7 +8,7 @@ import { ActionType } from '../common/actions.js'; import type { URI, TextRange } from '../common/state.js'; -import type { AnnotationEntry, Annotation } from './state.js'; +import type { AnnotationEntry, Annotation, AnnotationOrigin } from './state.js'; // ─── Annotations Actions ───────────────────────────────────────────────────── @@ -62,12 +62,8 @@ export interface AnnotationsUpdatedAction { type: ActionType.AnnotationsUpdated; /** The {@link Annotation.id} of the annotation to update. */ annotationId: string; - /** - * Re-anchors the annotation to the file versions this turn produced. - * Matches a {@link Turn.id} on the owning session. Omit to leave the - * current {@link Annotation.turnId} unchanged. - */ - turnId?: string; + /** Replaces the annotation's provenance. Omit to leave it unchanged. */ + origin?: AnnotationOrigin; /** * Re-anchors the annotation to this file. Omit to leave the current * {@link Annotation.resource} unchanged. diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-annotations/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-annotations/reducer.ts index 7223ac5849e83d..634c8bc88864fd 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-annotations/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-annotations/reducer.ts @@ -45,8 +45,8 @@ export function annotationsReducer(state: AnnotationsState, action: AnnotationsA } const annotation = state.annotations[idx]; const updated: Annotation = { ...annotation }; - if (action.turnId !== undefined) { - updated.turnId = action.turnId; + if (action.origin !== undefined) { + updated.origin = action.origin; } if (action.resource !== undefined) { updated.resource = action.resource; diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-annotations/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-annotations/state.ts index 12e8a62cfdf730..1ae2ab97fe64b1 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-annotations/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-annotations/state.ts @@ -44,17 +44,29 @@ export interface AnnotationsState { annotations: Annotation[]; } +/** + * Provenance of the content an annotation is anchored to. + * + * @category Annotations + */ +export interface AnnotationOrigin { + /** Owning session URI. */ + session: URI; + /** Owning chat URI, when the annotation is scoped to a chat. */ + chat?: URI; + /** Turn identifier within {@link chat}, when the annotation is scoped to a turn. */ + turnId?: string; +} + // ─── Annotation ────────────────────────────────────────────────────────────── /** - * A conversation anchored to a specific file produced by a specific turn, - * optionally narrowed to a range within that file. + * A conversation anchored to a specific file in a session, optionally scoped + * to a chat and turn and narrowed to a range within that file. * - * {@link turnId} anchors the annotation to the file versions that turn - * produced, so a later turn that rewrites the same file does not silently - * invalidate the annotation's anchor — clients can resolve {@link resource} - * and {@link range} against the turn's changeset. When {@link range} is - * omitted the annotation is anchored to the entire file. + * {@link origin} identifies the owning session and, when available, the chat + * and turn that produced the file version. When {@link range} is omitted the + * annotation is anchored to the entire file. * * Every annotation MUST contain at least one {@link AnnotationEntry}. An * {@link AnnotationsSetAction} that creates an annotation therefore carries @@ -70,11 +82,8 @@ export interface Annotation { * that dispatches the creating {@link AnnotationsSetAction}. */ id: string; - /** - * Turn that produced the file versions this annotation is anchored to. - * Matches a {@link Turn.id} on the owning session. - */ - turnId: string; + /** Provenance of the content this annotation is anchored to. */ + origin: AnnotationOrigin; /** The file the annotation is anchored to. */ resource: URI; /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-automation-run/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-automation-run/actions.ts new file mode 100644 index 00000000000000..c7e4e0fd566ad2 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/channels-automation-run/actions.ts @@ -0,0 +1,87 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts + +import { ActionType } from '../common/actions.js'; +import type { URI } from '../common/state.js'; +import type { AutomationRunLifecycle, AutomationRunState } from './state.js'; + +/** + * Replace the run lifecycle. + * + * The host dispatches this action for every lifecycle transition. + * + * @category Automation Run Actions + * @version 1 + */ +export interface AutomationRunLifecycleChangedAction { + type: ActionType.AutomationRunLifecycleChanged; + /** Complete replacement {@link AutomationRunState.lifecycle}. */ + lifecycle: AutomationRunLifecycle; +} + +/** + * Add a session to {@link AutomationRunState.sessions}. + * + * Session URIs are unique. Setting an existing URI is a no-op. + * + * @category Automation Run Actions + * @version 1 + */ +export interface AutomationRunSessionSetAction { + type: ActionType.AutomationRunSessionSet; + /** Session URI to append to {@link AutomationRunState.sessions} when not already linked. */ + session: URI; +} + +/** + * Remove a linked session from the run. + * + * Removing the current primary session also clears + * {@link AutomationRunState.primarySession}. An unknown URI is a no-op. + * + * @category Automation Run Actions + * @version 1 + */ +export interface AutomationRunSessionRemovedAction { + type: ActionType.AutomationRunSessionRemoved; + /** Entry in {@link AutomationRunState.sessions} to remove. */ + session: URI; +} + +/** + * Select or clear the session clients should open first for this run. + * + * @category Automation Run Actions + * @version 1 + */ +export interface AutomationRunPrimarySessionChangedAction { + type: ActionType.AutomationRunPrimarySessionChanged; + /** New {@link AutomationRunState.primarySession}, or omitted to clear the selection. */ + primarySession?: URI; +} + +/** + * Ask the host to cancel this run. + * + * This is the only client-dispatchable automation-run action. It is a + * side-effect request and deliberately leaves optimistic state unchanged. The + * client may dispatch it only when the host advertises its `runCancellation` + * capability and the current lifecycle is `pending` or `running`. The host + * revalidates that the run is non-terminal. The authoritative outcome arrives + * later through + * {@link AutomationRunLifecycleChangedAction}: cancellation may transition to + * `cancelled`, or the run may complete or fail before cancellation takes + * effect. + * + * @category Automation Run Actions + * @version 1 + * @clientDispatchable + */ +export interface AutomationRunCancelRequestedAction { + type: ActionType.AutomationRunCancelRequested; +} diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-automation-run/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-automation-run/reducer.ts new file mode 100644 index 00000000000000..51b0e365ddd4ea --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/channels-automation-run/reducer.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts + +import type { AutomationRunAction } from '../action-origin.generated.js'; +import { ActionType } from '../common/actions.js'; +import { softAssertNever } from '../common/reducer-helpers.js'; +import type { AutomationRunState } from './state.js'; + +/** Pure reducer for automation-run state. */ +export function automationRunReducer(state: AutomationRunState, action: AutomationRunAction, log?: (msg: string) => void): AutomationRunState { + switch (action.type) { + case ActionType.AutomationRunLifecycleChanged: + return { ...state, lifecycle: action.lifecycle }; + + case ActionType.AutomationRunSessionSet: + if (state.sessions.includes(action.session)) { + return state; + } + return { ...state, sessions: [...state.sessions, action.session] }; + + case ActionType.AutomationRunSessionRemoved: { + const index = state.sessions.indexOf(action.session); + if (index < 0) { + return state; + } + const sessions = state.sessions.slice(); + sessions.splice(index, 1); + const next: AutomationRunState = { ...state, sessions }; + if (state.primarySession === action.session) { + delete next.primarySession; + } + return next; + } + + case ActionType.AutomationRunPrimarySessionChanged: { + const next: AutomationRunState = { ...state }; + if (action.primarySession === undefined) { + delete next.primarySession; + } else { + next.primarySession = action.primarySession; + } + return next; + } + + case ActionType.AutomationRunCancelRequested: + return state; + + default: + softAssertNever(action, log); + return state; + } +} diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-automation-run/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-automation-run/state.ts new file mode 100644 index 00000000000000..ddabb2e3297e7b --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/channels-automation-run/state.ts @@ -0,0 +1,244 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts + +import type { ErrorInfo, URI, UsageInfo } from '../common/state.js'; +import type { AutomationEventTrigger, AutomationMisfirePolicy, AutomationScheduleTrigger, AutomationState } from '../channels-automation/state.js'; +import type { RunAutomationParams } from '../channels-automation/commands.js'; +import type { SessionState } from '../channels-session/state.js'; + +/** + * Lifecycle status of one automation run. + * + * `completed`, `failed`, and `cancelled` are terminal. A run remains `running` + * while any linked session awaits input or client-side work; linked session + * state is authoritative for those interactions. + * + * @category Automation Run State + */ +export const enum AutomationRunStatus { + /** The durable run record exists but execution has not started. */ + Pending = 'pending', + /** One or more linked sessions are executing or awaiting interaction. */ + Running = 'running', + /** Execution finished successfully. */ + Completed = 'completed', + /** Execution ended with an error. */ + Failed = 'failed', + /** Execution ended because cancellation was accepted. */ + Cancelled = 'cancelled', +} + +/** + * Discriminant describing what created an automation run. + * + * @category Automation Run State + */ +export const enum AutomationRunOriginKind { + /** A client explicitly invoked {@link RunAutomationParams | runAutomation}. */ + Manual = 'manual', + /** An automatic schedule or event trigger fired. */ + Trigger = 'trigger', +} + +/** + * Origin recorded for a client-requested manual run. + * + * @category Automation Run State + */ +export interface AutomationManualRunOrigin { + kind: AutomationRunOriginKind.Manual; +} + +/** + * Origin recorded for a run created by one of the automation's triggers. + * + * @category Automation Run State + */ +export interface AutomationTriggeredRunOrigin { + kind: AutomationRunOriginKind.Trigger; + /** + * Matches the stable {@link AutomationScheduleTrigger.id} or + * {@link AutomationEventTrigger.id} in the definition. + */ + triggerId: string; + /** + * Intended schedule occurrence as an ISO 8601 timestamp. Present for + * schedule triggers and normally absent for event triggers. + */ + scheduledFor?: string; + /** + * `true` when this is a catch-up run created by + * {@link AutomationMisfirePolicy.RunOnce}. + */ + catchUp?: boolean; + /** + * Host-defined, non-secret event provenance suitable for display or audit. + * This is descriptive context, not an input that clients replay. + */ + event?: Record; +} + +/** + * Immutable provenance describing why a run was created. + * + * @category Automation Run State + */ +export type AutomationRunOrigin = + | AutomationManualRunOrigin + | AutomationTriggeredRunOrigin; + +/** + * A durable run exists but has not begun external execution. + * + * @category Automation Run State + */ +export interface AutomationPendingRunLifecycle { + status: AutomationRunStatus.Pending; + /** Run creation timestamp in ISO 8601 format. */ + createdAt: string; +} + +/** + * The run is executing linked sessions or awaiting interaction on them. + * + * Linked {@link SessionState.status} and {@link SessionState.inputNeeded} + * remain authoritative for whether user attention or client-side work is + * required. + * + * @category Automation Run State + */ +export interface AutomationRunningRunLifecycle { + status: AutomationRunStatus.Running; + /** Run creation timestamp in ISO 8601 format. */ + createdAt: string; + /** First execution start timestamp in ISO 8601 format. */ + startedAt: string; +} + +/** + * Terminal lifecycle for a successfully completed run. + * + * @category Automation Run State + */ +export interface AutomationCompletedRunLifecycle { + status: AutomationRunStatus.Completed; + /** Run creation timestamp in ISO 8601 format. */ + createdAt: string; + /** First execution start timestamp in ISO 8601 format. */ + startedAt: string; + /** Completion timestamp in ISO 8601 format. */ + completedAt: string; + /** Optional aggregate model usage across all linked sessions. */ + usage?: UsageInfo; +} + +/** + * Terminal lifecycle for a run that ended with an error. + * + * `startedAt` is absent when failure occurred before execution began, such as + * session-template validation or workspace preparation. + * + * @category Automation Run State + */ +export interface AutomationFailedRunLifecycle { + status: AutomationRunStatus.Failed; + /** Run creation timestamp in ISO 8601 format. */ + createdAt: string; + /** First execution start timestamp in ISO 8601 format, when execution began. */ + startedAt?: string; + /** Failure timestamp in ISO 8601 format. */ + completedAt: string; + /** Stable machine-readable and human-readable failure information. */ + error: ErrorInfo; +} + +/** + * Terminal lifecycle for a cancelled run. + * + * `startedAt` is absent when cancellation completed while the run was still + * pending. + * + * @category Automation Run State + */ +export interface AutomationCancelledRunLifecycle { + status: AutomationRunStatus.Cancelled; + /** Run creation timestamp in ISO 8601 format. */ + createdAt: string; + /** First execution start timestamp in ISO 8601 format, when execution began. */ + startedAt?: string; + /** Cancellation completion timestamp in ISO 8601 format. */ + completedAt: string; +} + +/** + * Discriminated lifecycle of an automation run. + * + * @category Automation Run State + */ +export type AutomationRunLifecycle = + | AutomationPendingRunLifecycle + | AutomationRunningRunLifecycle + | AutomationCompletedRunLifecycle + | AutomationFailedRunLifecycle + | AutomationCancelledRunLifecycle; + +/** + * Lightweight projection of a run retained in its automation's history. + * + * A summary contains enough information to render run history without + * subscribing to every `ahp-automation-run:` resource. + * + * @category Automation Run State + */ +export interface AutomationRunSummary { + /** Subscribable `ahp-automation-run:` URI matching {@link AutomationRunState.resource}. */ + resource: URI; + /** Owning `ahp-automation:` URI matching {@link AutomationRunState.automation}. */ + automation: URI; + /** Immutable provenance matching {@link AutomationRunState.origin}. */ + origin: AutomationRunOrigin; + /** Current or terminal lifecycle snapshot matching {@link AutomationRunState.lifecycle}. */ + lifecycle: AutomationRunLifecycle; + /** Session matching {@link AutomationRunState.primarySession}, when selected. */ + primarySession?: URI; + /** Number of entries in {@link AutomationRunState.sessions}. */ + sessionCount: number; + /** Opaque host-defined summary metadata. */ + _meta?: Record; +} + +/** + * Authoritative state of one subscribed `ahp-automation-run:` resource. + * + * The run channel owns task-level lifecycle, provenance, and linked-session + * membership. Linked session and chat channels remain authoritative for + * transcripts, tools, interaction requirements, changesets, and per-session + * lifecycle. + * + * @category Automation Run State + */ +export interface AutomationRunState { + /** URI of this automation-run channel. */ + resource: URI; + /** Owning `ahp-automation:` URI matching {@link AutomationState.resource}. */ + automation: URI; + /** Immutable provenance describing how this run was created. */ + origin: AutomationRunOrigin; + /** Current or terminal lifecycle. */ + lifecycle: AutomationRunLifecycle; + /** + * Ordered, unique session URIs belonging to this run, each matching + * {@link SessionState.resource}. Entries may represent retries, parallel + * workers, or delegated attempts. + */ + sessions: URI[]; + /** Member of {@link AutomationRunState.sessions} that the host recommends opening first. */ + primarySession?: URI; + /** Opaque host-defined run metadata. */ + _meta?: Record; +} diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-automation/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-automation/actions.ts new file mode 100644 index 00000000000000..9dbafdb743c147 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/channels-automation/actions.ts @@ -0,0 +1,130 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts + +import { ActionType } from '../common/actions.js'; +import type { Message } from '../channels-chat/state.js'; +import type { URI } from '../common/state.js'; +import type { AutomationCatalogState, AutomationDefinition, AutomationOperation, AutomationSessionTemplate, AutomationState, AutomationTrigger } from './state.js'; + +/** + * Partial replacement of editable {@link AutomationDefinition} fields. + * + * Omitted fields are unchanged. Supplied arrays and objects replace their + * corresponding values in full; they are not merged recursively. + * + * @category Automation Actions + */ +export interface AutomationDefinitionPatch { + /** Replacement {@link AutomationDefinition.title}. */ + title?: string; + /** Replacement {@link AutomationDefinition.message}. */ + message?: Message; + /** + * Replacement {@link AutomationDefinition.session}. The host revalidates + * affected event triggers when their discovery context changes. + */ + session?: AutomationSessionTemplate; + /** Replacement {@link AutomationDefinition.enabled}. */ + enabled?: boolean; + /** + * Complete replacement {@link AutomationDefinition.triggers}. The host + * validates event ids and normalizes event-trigger titles and descriptions. + */ + triggers?: AutomationTrigger[]; + /** Complete replacement {@link AutomationDefinition._meta}. */ + _meta?: Record; +} + +/** + * Ask the host to create a durable automation at a client-chosen resource. + * + * Clients may dispatch this action only when the host advertises its `create` + * automation capability. {@link AutomationCreateRequestedAction.resource | + * `resource`} MUST use the `ahp-automation:` scheme and MUST NOT already + * identify an unrelated automation. + * + * This side-effect request leaves optimistic catalogue state unchanged. The + * host validates trigger ids and configuration, normalizes event-trigger + * titles and descriptions, persists the definition, then publishes the + * authoritative result with {@link AutomationSetAction | `automation/set`}. + * Rejections leave the catalogue unchanged. + * + * @category Automation Actions + * @version 1 + * @clientDispatchable + */ +export interface AutomationCreateRequestedAction { + type: ActionType.AutomationCreateRequested; + /** Client-chosen `ahp-automation:` URI that becomes {@link AutomationState.resource}. */ + resource: URI; + /** Complete initial {@link AutomationState.definition}. */ + definition: AutomationDefinition; +} + +/** + * Ask the host to update editable fields of an existing automation. + * + * Clients may dispatch this action only while the target advertises + * {@link AutomationOperation.Update}. The host revalidates that operation and + * the client's authorization. + * + * This side-effect request leaves optimistic catalogue state unchanged. The + * host applies accepted patches to its current authoritative definition in + * action order, revalidates and normalizes affected event triggers, then + * publishes the result with + * {@link AutomationSetAction | `automation/set`}. Omitted fields remain + * unchanged; when accepted actions replace the same field, the later action in + * server order wins. + * + * @category Automation Actions + * @version 1 + * @clientDispatchable + */ +export interface AutomationUpdateRequestedAction { + type: ActionType.AutomationUpdateRequested; + /** Target {@link AutomationState.resource}. */ + resource: URI; + /** Editable {@link AutomationDefinition} fields to replace. */ + changes: AutomationDefinitionPatch; +} + +/** + * Add or replace one full automation state in + * {@link AutomationCatalogState.automations}. + * + * Existing entries are matched by {@link AutomationState.resource} and + * replaced in place. A previously unseen resource is appended. + * + * @category Automation Actions + * @version 1 + */ +export interface AutomationSetAction { + type: ActionType.AutomationSet; + /** Full new or replacement automation state. */ + automation: AutomationState; +} + +/** + * Remove one automation from {@link AutomationCatalogState.automations}. + * + * Clients may dispatch this action only while the target advertises + * {@link AutomationOperation.Remove}. The host revalidates that operation + * before permanently deleting the automation. A rejected action leaves the + * authoritative catalogue and durable definition unchanged. + * + * Removing an unknown resource is a no-op. + * + * @category Automation Actions + * @version 1 + * @clientDispatchable + */ +export interface AutomationRemovedAction { + type: ActionType.AutomationRemoved; + /** {@link AutomationState.resource} to remove. */ + resource: URI; +} diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-automation/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-automation/commands.ts new file mode 100644 index 00000000000000..5e56622dab4b10 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/channels-automation/commands.ts @@ -0,0 +1,118 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts + +import type { BaseParams } from '../common/commands.js'; +import type { URI } from '../common/state.js'; +import type { AutomationRunState } from '../channels-automation-run/state.js'; +import type { AgentInfo } from '../channels-root/state.js'; +import type { AutomationSetAction } from './actions.js'; +import type { AutomationDefinition, AutomationSessionTemplate, AutomationState, AutomationTriggerDefinition } from './state.js'; + +/** + * Discover event-trigger types available for a prospective session template. + * + * Hosts may vary definitions by provider, workspace, and session + * configuration. Schedule triggers are protocol-defined and therefore do not + * appear in this result. The result describes current authoring and validation + * choices. Saved {@link AutomationEventTrigger} values retain their selected + * event descriptors for display but do not establish current availability. + * + * @category Commands + * @method listAutomationTriggerDefinitions + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface ListAutomationTriggerDefinitionsParams extends BaseParams { + /** Trigger definitions are discovered from the root channel. */ + channel: 'ahp-root://'; + /** Prospective provider id matching {@link AgentInfo.provider}, or omitted for the host default. */ + provider?: string; + /** Prospective {@link AutomationSessionTemplate.workingDirectories}. */ + workingDirectories?: URI[]; + /** Prospective resolved {@link AutomationSessionTemplate.config}. */ + sessionConfig?: Record; +} + +/** + * Host-defined event trigger types available for the supplied context. + * + * @category Commands + */ +export interface ListAutomationTriggerDefinitionsResult { + /** Available event trigger definitions. */ + items: AutomationTriggerDefinition[]; +} + +/** + * Start a manual run of an automation. + * + * Manual execution is independent of {@link AutomationDefinition.enabled}. + * The host persists the run before beginning session side effects. + * + * @category Commands + * @method runAutomation + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface RunAutomationParams extends BaseParams { + /** Manual runs are scoped to the catalogue channel. */ + channel: 'ahp-automations://'; + /** Target {@link AutomationState.resource}. */ + automation: URI; + /** + * Durable client-generated idempotency key. Retrying with the same key and + * automation MUST return the original run URI rather than create another + * run. + */ + requestId: string; +} + +/** + * Result identifying the existing or newly created run. + * + * @category Commands + */ +export interface RunAutomationResult { + /** Subscribable `ahp-automation-run:` URI matching {@link AutomationRunState.resource}. */ + resource: URI; +} + +/** + * Load one older page into a catalogued automation's run-history state. + * + * The response only acknowledges the request. The updated full state arrives + * through {@link AutomationSetAction | `automation/set`} on the + * `ahp-automations://` channel, keeping all catalogue subscribers synchronized + * through the normal action stream. + * + * @category Commands + * @method fetchAutomationRuns + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface FetchAutomationRunsParams extends BaseParams { + /** Run-history loading is scoped to the catalogue channel. */ + channel: 'ahp-automations://'; + /** Target {@link AutomationState.resource}. */ + automation: URI; + /** + * Cursor previously received as {@link AutomationState.runsNextCursor}. + * Omit to request the first page not already included by the snapshot. + */ + cursor?: string; +} + +/** + * Empty acknowledgement; the updated automation state is delivered by action. + * + * @category Commands + */ +export interface FetchAutomationRunsResult { } diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-automation/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-automation/reducer.ts new file mode 100644 index 00000000000000..a02efe823b1e45 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/channels-automation/reducer.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts + +import type { AutomationAction } from '../action-origin.generated.js'; +import { ActionType } from '../common/actions.js'; +import { softAssertNever } from '../common/reducer-helpers.js'; +import type { AutomationCatalogState } from './state.js'; + +/** Pure reducer for automation catalogue state. */ +export function automationReducer(state: AutomationCatalogState, action: AutomationAction, log?: (msg: string) => void): AutomationCatalogState { + switch (action.type) { + case ActionType.AutomationCreateRequested: + case ActionType.AutomationUpdateRequested: + return state; + + case ActionType.AutomationSet: { + const idx = state.automations.findIndex(automation => automation.resource === action.automation.resource); + if (idx < 0) { + return { + ...state, + automations: [...state.automations, action.automation], + }; + } + const automations = state.automations.slice(); + automations[idx] = action.automation; + return { ...state, automations }; + } + + case ActionType.AutomationRemoved: { + const idx = state.automations.findIndex(automation => automation.resource === action.resource); + if (idx < 0) { + return state; + } + const automations = state.automations.slice(); + automations.splice(idx, 1); + return { ...state, automations }; + } + + default: + softAssertNever(action, log); + return state; + } +} diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-automation/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-automation/state.ts new file mode 100644 index 00000000000000..97cd10f2c4e923 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/channels-automation/state.ts @@ -0,0 +1,334 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts + +import type { Message, MessageKind } from '../channels-chat/state.js'; +import type { ConfigSchema, URI } from '../common/state.js'; +import type { AutomationRunSummary, AutomationTriggeredRunOrigin } from '../channels-automation-run/state.js'; +import type { ResolveSessionConfigResult } from '../channels-root/commands.js'; +import type { AgentInfo, ModelSelection, SessionModelInfo } from '../channels-root/state.js'; +import type { CreateSessionParams } from '../channels-session/commands.js'; +import type { AgentSelection } from '../channels-session/state.js'; +import type { AutomationRemovedAction, AutomationSetAction, AutomationUpdateRequestedAction } from './actions.js'; +import type { FetchAutomationRunsParams, ListAutomationTriggerDefinitionsParams, RunAutomationParams } from './commands.js'; + +/** + * Operations the host currently permits for an automation. + * + * The list on {@link AutomationState.operations} is authoritative and may + * change over time. Clients MUST NOT infer permission from capabilities alone: + * capabilities describe what the host implementation can support, while + * operations describe what is allowed for this particular automation now. + * + * @category Automation State + */ +export const enum AutomationOperation { + /** Replace editable fields using {@link AutomationUpdateRequestedAction | `automation/updateRequested`}. */ + Update = 'update', + /** Permanently remove the automation using {@link AutomationRemovedAction | `automation/removed`}. */ + Remove = 'remove', + /** Start a manual run using {@link RunAutomationParams | runAutomation}. */ + Run = 'run', +} + +/** + * A portable recurring schedule evaluated in a named time zone. + * + * The expression uses exactly five whitespace-separated fields, in this + * order: + * + * | Field | Values | + * | --- | --- | + * | minute | `0`–`59` | + * | hour | `0`–`23` | + * | day of month | `1`–`31` | + * | month | `1`–`12` or `JAN`–`DEC` | + * | day of week | `0`–`7` or `SUN`–`SAT`; both `0` and `7` mean Sunday | + * + * Month and weekday names are ASCII and case-insensitive. Each field accepts + * `*`, a single value, an inclusive range (`1-5`), a comma-separated list of + * values or ranges (`1,3,8-10`), or a step applied to `*` or a range (for + * example, */15 or `1-30/2`). A step MUST be a positive integer. AHP does + * not support seconds, years, macros such as `@daily`, or Quartz extensions + * such as `?`, `L`, `W`, and `#`. + * + * Minute, hour, and month must all match. When both day-of-month and + * day-of-week are restricted (not `*`), an occurrence matches when either day + * field matches, following Unix cron semantics. + * + * @example + * `30 9 * * 1-5` runs at 09:30 every weekday. + * + * @category Automation State + */ +export interface AutomationSchedule { + /** Five-field AHP cron expression described by {@link AutomationSchedule}. */ + expression: string; + /** + * IANA Time Zone Database identifier used to interpret the expression, for + * example `"UTC"` or `"Europe/Berlin"`. + */ + timeZone: string; +} + +/** + * How a host handles schedule occurrences missed while automatic execution was + * unavailable. + * + * @category Automation State + */ +export const enum AutomationMisfirePolicy { + /** Discard missed occurrences and wait for the next future occurrence. */ + Skip = 'skip', + /** + * Start at most one catch-up run when execution becomes available, regardless + * of how many occurrences were missed. + */ + RunOnce = 'runOnce', +} + +/** + * Discriminant for automatic trigger definitions. + * + * @category Automation State + */ +export const enum AutomationTriggerKind { + /** A portable recurring {@link AutomationSchedule}. */ + Schedule = 'schedule', + /** A host-defined external event discovered from trigger definitions. */ + Event = 'event', +} + +/** + * Starts runs from a recurring cron schedule evaluated by the host. + * + * @category Automation State + */ +export interface AutomationScheduleTrigger { + /** + * Identifier unique and stable within this automation definition. Recorded in + * {@link AutomationTriggeredRunOrigin.triggerId} when this trigger creates a + * run. + */ + id: string; + kind: AutomationTriggerKind.Schedule; + /** Recurrence and time zone evaluated by the host. */ + schedule: AutomationSchedule; + /** + * Policy for missed occurrences. Omission is equivalent to + * {@link AutomationMisfirePolicy.RunOnce}. + */ + misfirePolicy?: AutomationMisfirePolicy; +} + +/** + * Starts runs from events understood by the owning host. + * + * Event trigger types, events, and configuration are discovered through + * {@link ListAutomationTriggerDefinitionsParams | + * listAutomationTriggerDefinitions}. The saved trigger includes the matching + * human-readable metadata so it remains displayable without repeating + * discovery. + * + * @category Automation State + */ +export interface AutomationEventTrigger { + /** + * Identifier unique and stable within this automation definition. Recorded in + * {@link AutomationTriggeredRunOrigin.triggerId} when this trigger creates a + * run. + */ + id: string; + kind: AutomationTriggerKind.Event; + /** Matches {@link AutomationTriggerDefinition.type}. */ + type: string; + /** Host-normalized human-readable trigger type name. */ + title: string; + /** Optional host-normalized explanation of the trigger source. */ + description?: string; + /** + * Selected events for this trigger type. + * + * Event ids carry the trigger semantics. Titles and descriptions are + * last-known display metadata and do not indicate current availability. + */ + events: AutomationTriggerEventDefinition[]; + /** + * Values described by {@link AutomationTriggerDefinition.configSchema}. + * Clients MUST preserve unknown entries when editing other fields. + */ + config?: Record; +} + +/** + * An automatic trigger that can create runs for an enabled automation. + * + * Manual execution is not represented as a trigger. An empty trigger list + * therefore means the automation is manual-only. + * + * @category Automation State + */ +export type AutomationTrigger = + | AutomationScheduleTrigger + | AutomationEventTrigger; + +/** + * Describes one host-defined trigger event. + * + * @category Automation State + */ +export interface AutomationTriggerEventDefinition { + /** Stable event id. */ + id: string; + /** Human-readable event name. */ + title: string; + /** Optional longer explanation of when this event fires. */ + description?: string; +} + +/** + * Describes one host-defined event trigger type available for a prospective + * automation session template. + * + * Trigger definitions are discovery metadata, not durable automation state. + * Hosts may return different definitions for different providers, working + * directories, or session configuration. + * + * @category Automation State + */ +export interface AutomationTriggerDefinition { + /** Stable type id stored in {@link AutomationEventTrigger.type}. */ + type: string; + /** Human-readable trigger type name. */ + title: string; + /** Optional longer explanation of the trigger source. */ + description?: string; + /** Events available for selection. Saved triggers retain their selected event descriptors. */ + events: AutomationTriggerEventDefinition[]; + /** Optional schema for {@link AutomationEventTrigger.config}. */ + configSchema?: ConfigSchema; +} + +/** + * Template from which the host creates a fresh session for each automation run. + * + * The host revalidates every selection when the run starts. Definitions never + * carry credentials, confirmation decisions, or durable permission grants. + * + * @category Automation State + */ +export interface AutomationSessionTemplate { + /** Provider id matching {@link AgentInfo.provider}. Omit to use the host's default provider. */ + provider?: string; + /** + * Optional model selection resolved when a run starts. Its + * {@link ModelSelection.id} matches a {@link SessionModelInfo.id} advertised + * by the selected provider. + */ + model?: ModelSelection; + /** Optional custom agent selection identified by {@link AgentSelection.uri}. */ + agent?: AgentSelection; + /** + * Ordered working-directory URIs for each created session, equivalent to + * {@link CreateSessionParams.workingDirectories}. Absence means a + * workspace-less session. + */ + workingDirectories?: URI[]; + /** + * Session configuration values equivalent to + * {@link CreateSessionParams.config}, normally obtained from + * {@link ResolveSessionConfigResult.values}. + */ + config?: Record; +} + +/** + * Durable, client-editable definition of an automation. + * + * A definition combines the initial automation message, the session template + * used for each run, and zero or more automatic triggers. Run history, + * timestamps, and currently allowed operations live on + * {@link AutomationState} rather than in the definition. + * + * @category Automation State + */ +export interface AutomationDefinition { + /** Human-readable automation name. */ + title: string; + /** + * Initial message sent to every newly created run session. Its + * {@link Message.origin} kind MUST be {@link MessageKind.Automation}. + */ + message: Message; + /** Template used to create fresh sessions for each run. */ + session: AutomationSessionTemplate; + /** + * Whether automatic triggers may create runs. Manual runs remain available + * whenever {@link AutomationOperation.Run} is advertised. + */ + enabled: boolean; + /** Automatic triggers. An empty list means manual-only. */ + triggers: AutomationTrigger[]; + /** + * Opaque implementation-defined metadata. Clients MUST preserve unknown + * entries when updating the definition. + */ + _meta?: Record; +} + +/** + * Authoritative state of one automation in the + * {@link AutomationCatalogState.automations} catalogue. + * + * The host owns trigger evaluation, run claims, run retention, and operation + * availability. Clients render this state and submit actions or commands; they + * never run a fallback scheduler for a host-owned definition. + * + * @category Automation State + */ +export interface AutomationState { + /** Stable `ahp-automation:/` resource identifier. */ + resource: URI; + /** Current durable definition. */ + definition: AutomationDefinition; + /** Earliest schedule occurrence awaiting evaluation, as an ISO 8601 timestamp. It may be in the past while catch-up is pending. */ + nextRunAt?: string; + /** + * Newest-first retained run summaries. This is a bounded window; use + * {@link FetchAutomationRunsParams | fetchAutomationRuns} when + * {@link AutomationState.runsNextCursor} is present. + */ + runs: AutomationRunSummary[]; + /** Opaque cursor passed as {@link FetchAutomationRunsParams.cursor} for the next older run-history page. */ + runsNextCursor?: string; + /** Operations currently permitted for this automation. */ + operations: AutomationOperation[]; + /** Creation timestamp in ISO 8601 format. */ + createdAt: string; + /** Last definition modification timestamp in ISO 8601 format. */ + modifiedAt: string; + /** Opaque host-defined state metadata. */ + _meta?: Record; +} + +/** + * Authoritative automation catalogue exposed on the `ahp-automations://` + * channel. + * + * A subscription snapshot contains every automation visible to the client. + * Subsequent {@link AutomationSetAction | `automation/set`} and + * {@link AutomationRemovedAction | `automation/removed`} actions keep the + * catalogue synchronized and participate in normal reconnect replay. + * + * @category Automation State + */ +export interface AutomationCatalogState { + /** Full automation states keyed by {@link AutomationState.resource}. */ + automations: AutomationState[]; + /** Opaque host-defined catalogue metadata. */ + _meta?: Record; +} diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-changeset/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-changeset/actions.ts index 77f2a1f7a85ece..06be614ec66a00 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-changeset/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-changeset/actions.ts @@ -109,8 +109,6 @@ export interface ChangesetContentChangedAction { files: ChangesetFile[]; /** Full replacement operation list. Omit when operations are unchanged. */ operations?: ChangesetOperation[]; - /** Error information, if the changeset content change failed. */ - error?: ErrorInfo; } /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-changeset/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-changeset/reducer.ts index 69e690193ce0c4..316c0fd8adca97 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-changeset/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-changeset/reducer.ts @@ -67,14 +67,9 @@ export function changesetReducer(state: ChangesetState, action: ChangesetAction, } case ActionType.ChangesetContentChanged: { - const next = action.operations === undefined + return action.operations === undefined ? { ...state, files: action.files } : { ...state, files: action.files, operations: action.operations }; - if (action.error === undefined) { - const { error: _ignored, ...rest } = next; - return rest; - } - return { ...next, error: action.error }; } case ActionType.ChangesetOperationsChanged: { diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts index 4c08450d970b0c..c9806db7e8a9ef 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts @@ -11,6 +11,7 @@ import { TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancella import { SessionStatus } from '../channels-session/state.js'; import type { ChatAction } from '../action-origin.generated.js'; import { softAssertNever } from '../common/reducer-helpers.js'; +import { addMillisecondsToTimestamp } from '../common/timestamps.js'; // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -197,7 +198,7 @@ function endTurn( ...state, turns: [...state.turns, turn], activeTurn: undefined, - modifiedAt: new Date(Date.now()).toISOString(), + modifiedAt: addMillisecondsToTimestamp(active.startedAt, turn.duration ?? 0), }; return { ...next, @@ -232,7 +233,7 @@ function upsertInputRequestPart(state: ChatState, request: InputRequestResponseP responseParts, }, }; - return { ...next, status: withStatusFlag(summaryStatus(next), SessionStatus.IsRead, false), modifiedAt: new Date(Date.now()).toISOString() }; + return { ...next, status: withStatusFlag(summaryStatus(next), SessionStatus.IsRead, false) }; } /** @@ -338,7 +339,7 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st next = { ...next, status: withStatusFlag(summaryStatus(next), SessionStatus.IsRead, false), - modifiedAt: new Date(Date.now()).toISOString(), + modifiedAt: action.startedAt, }; // If this turn was auto-started from a pending message, remove it @@ -702,7 +703,6 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st ...state, turns, activeTurn: undefined, - modifiedAt: new Date(Date.now()).toISOString(), }; if (action.turnId === undefined) { delete next.turnsNextCursor; @@ -758,7 +758,6 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st ...activeTurn, responseParts, }, - modifiedAt: new Date(Date.now()).toISOString(), }; } @@ -791,7 +790,6 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st return { ...next, status: summaryStatus(next), - modifiedAt: new Date(Date.now()).toISOString(), }; } diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts index 10f96f94a07a8b..d3604a541d0f77 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts @@ -392,17 +392,6 @@ export type ChatInputQuestion = ChatInputTextQuestion | ChatInputSingleSelectQuestion | ChatInputMultiSelectQuestion; -/** - * Why the agent requested chat input. - * - * @category Chat Input Types - */ -export const enum ChatInputRequestPurpose { - AskUser = 'askUser', - Elicitation = 'elicitation', - PlanReview = 'planReview', -} - /** * The request payload carried by an {@link InputRequestResponsePart}. * @@ -415,8 +404,6 @@ export const enum ChatInputRequestPurpose { export interface ChatInputRequest { /** Stable request identifier */ id: string; - /** Input lifecycle classification. Missing for requests from older clients or persisted sessions. */ - purpose?: ChatInputRequestPurpose; /** Display message for the request as a whole */ message?: string; /** URL the user should review or open, for URL-style elicitations */ @@ -614,6 +601,8 @@ export enum MessageKind { * worker chat whose first message carries a seed prompt. */ Tool = 'tool', + /** Emitted automatically when an automation run starts a session. */ + Automation = 'automation', /** A system-generated notification rather than a direct user message. */ SystemNotification = 'systemNotification', } @@ -632,7 +621,8 @@ export interface MessageOrigin { /** * A message that initiates or steers a turn. Messages can originate from the - * user, the agent, a tool, or be system-generated (see {@link MessageOrigin}). + * user, the agent, a tool, an automation, or be system-generated (see + * {@link MessageOrigin}). * * Attachments MAY be referenced inside {@link Message.text} via their * {@link MessageAttachmentBase.range} field. Attachments without a range are diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts index 1dcd635f796b9a..1fc514410696af 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts @@ -112,8 +112,8 @@ export interface AgentCapabilities { /** * The session's agent can be granted tool access to more than one working * directory. The directories are treated as equal peers except where the - * agent advertises {@link MultipleWorkingDirectoriesCapability.immutablePrimary} - * (some backends pin their first directory as a fixed process root). + * agent advertises a protected primary-slot option (some backends pin or + * replace their first directory as a process root). * * When absent, clients MUST NOT mutate a session's or chat's working-directory * set and MUST NOT set more than one entry in @@ -159,16 +159,34 @@ export interface MultipleWorkingDirectoriesCapability { /** * The agent's **first** working directory (index `0` of * {@link CreateSessionParams.workingDirectories}) is an immutable primary: - * it is fixed for the lifetime of the session — clients MUST NOT remove or - * reorder it. Additional directories after it remain equal peers that can be - * added and removed freely. + * its URI is fixed for the lifetime of the session — clients MUST NOT remove, + * reorder, or replace it. Additional directories after it remain equal peers + * that can be added and removed freely. When + * {@link primaryReplacement} is also `true`, clients that recognize that + * capability MUST instead treat the primary as protected and replaceable. * * Advertised by backends whose agent process is rooted at a single directory - * that cannot change once the session has started (e.g. the SDK's primary - * `workingDirectory`). When absent or `false`, all directories are equal - * peers and any of them may be removed. + * that cannot change once the session has started. A backend MAY also + * advertise this with {@link primaryReplacement} for compatibility with + * clients that do not recognize the newer capability: those clients retain + * the safe immutable-primary behavior, while newer clients allow only the + * targeted replacement action. When both are absent or `false`, all + * directories are equal peers. */ immutablePrimary?: boolean; + /** + * The agent's first working-directory slot (index `0`) is a protected primary + * whose URI can be atomically replaced with + * `session/workingDirectoryReplaced`. Clients MUST NOT remove that slot with + * generic membership actions; additional directories remain equal peers. + * + * Backends use this when their cwd-bearing directory can move during a + * session. It MAY be `true` together with {@link immutablePrimary}; this + * preserves the immutable-primary guarantee for older clients that do not + * recognize this capability. Clients that recognize this capability MUST + * allow a targeted replacement even when `immutablePrimary` is also `true`. + */ + primaryReplacement?: boolean; } /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts index afcaede9991313..cc01a0fcdc87de 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts @@ -280,7 +280,10 @@ export interface SessionWorkingDirectorySetAction { * reduced set — so this action is safe to model as idempotent. A host MAY * decline to apply the removal (e.g. an immutable primary directory, see * {@link MultipleWorkingDirectoriesCapability.immutablePrimary}); it then leaves - * the set unchanged. + * the set unchanged. When the agent advertises + * {@link MultipleWorkingDirectoriesCapability.primaryReplacement}, clients MUST + * NOT use this generic membership action to remove index `0`; the host MUST + * reject such a removal, leaving the protected slot intact. * * @category Session Actions * @version 1 @@ -292,6 +295,37 @@ export interface SessionWorkingDirectoryRemovedAction { directory: URI; } +/** + * Atomically replaces one of the session's working directories. + * + * This is a targeted compare-and-swap: the reducer is a no-op when + * {@link SessionState.workingDirectories} does not contain `directory`. + * Otherwise it replaces that entry with `replacement` and deduplicates the + * result, preserving every other directory's relative order. When + * `replacement` occurs after the target, it moves to the target's position; + * for example, `[A, B, C]` with `B → C` becomes `[A, C]`. When it occurs + * before the target, it retains its earlier position and the target is removed; + * `[A, B, C]` with `C → A` becomes `[A, B]`. + * + * Only valid when the agent advertises + * {@link AgentCapabilities.multipleWorkingDirectories}. Replacing index `0` + * additionally requires + * {@link MultipleWorkingDirectoriesCapability.primaryReplacement}; clients + * MUST NOT target an immutable primary. The host MUST validate and apply its + * backend side effect before broadcasting an accepted action, or reject it. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface SessionWorkingDirectoryReplacedAction { + type: ActionType.SessionWorkingDirectoryReplaced; + /** URI of the existing entry to replace. */ + directory: URI; + /** URI to place in the replaced entry's position. */ + replacement: URI; +} + // ─── Input Needed Actions ──────────────────────────────────────────────────── /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts index bb3acc187c3c39..47f50f9ecbdb30 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts @@ -44,20 +44,6 @@ import type { MessageAttachment } from '../channels-chat/state.js'; * { "jsonrpc": "2.0", "id": 2, "error": { "code": -32003, "message": "Session already exists" } } * ``` */ -/** - * Identifies a source session and turn to fork from. - * - * When provided in `createSession`, the server populates the new session with - * content from the source session up to and including the response of the - * specified turn. - */ -export interface SessionForkSource { - /** URI of the existing session to fork from */ - session: URI; - /** Turn ID in the source session; content up to and including this turn's response is copied */ - turnId: string; -} - export interface CreateSessionParams extends BaseParams { /** Session URI (client-chosen, e.g. `ahp-session:/`) */ channel: URI; @@ -66,26 +52,20 @@ export interface CreateSessionParams extends BaseParams { /** * The working directories the session's agent is granted tool access to. * A session may span multiple directories; they are equal peers except when - * the agent advertises - * {@link MultipleWorkingDirectoriesCapability.immutablePrimary} (in which case - * the first entry is a fixed process root). + * the agent advertises a protected-primary capability. An + * {@link MultipleWorkingDirectoriesCapability.immutablePrimary | immutable + * primary} is fixed, while a + * {@link MultipleWorkingDirectoriesCapability.primaryReplacement | replaceable + * primary} is changed only with `session/workingDirectoryReplaced`. * * A client MUST NOT supply more than one entry unless the agent advertises * {@link AgentCapabilities.multipleWorkingDirectories}; a server without that * capability treats only the first entry as the session's working directory - * and ignores the rest. Dispatch `session/workingDirectorySet` / - * `session/workingDirectoryRemoved` to change the set after the session has - * started. + * and ignores the rest. Dispatch working-directory actions to change the set + * after the session has started. * - * Ignored for forked sessions — a fork inherits its working directories - * from the source session identified by `fork`. */ workingDirectories?: URI[]; - /** - * Fork from an existing session. The new session is populated with content - * from the source session up to and including the specified turn's response. - */ - fork?: SessionForkSource; /** * Agent-specific configuration values collected via `resolveSessionConfig`. * Keys and values correspond to the schema returned by the server. diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts index a0b0bd5f0527c8..b1a1b3c2c7aab7 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts @@ -7,7 +7,7 @@ // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts import { ActionType } from '../common/actions.js'; -import { SessionLifecycle, SessionStatus, SessionInputRequestKind, CustomizationType, McpServerStatus, type ChildCustomization, type Customization, type CustomizationEnablement, type SessionState, type SessionInputRequest, type McpServerCustomization } from './state.js'; +import { SessionLifecycle, SessionStatus, SessionInputRequestKind, CustomizationType, McpServerStatus, type SessionState, type SessionInputRequest, type ChildCustomization, type Customization, type CustomizationEnablement, type McpServerCustomization } from './state.js'; import type { SessionAction } from '../action-origin.generated.js'; import { softAssertNever } from '../common/reducer-helpers.js'; @@ -100,7 +100,9 @@ function updateMcpServerCustomization( * Replaces explicit decisions for plugins and MCP servers; other customizations * retain their legacy `enabled` field, derived from the incoming decisions. */ -function applyCustomizationEnablement(customization: T, enablement: readonly CustomizationEnablement[]): T { +function applyCustomizationEnablement(customization: Customization, enablement: readonly CustomizationEnablement[]): Customization; +function applyCustomizationEnablement(customization: ChildCustomization, enablement: readonly CustomizationEnablement[]): ChildCustomization; +function applyCustomizationEnablement(customization: Customization | ChildCustomization, enablement: readonly CustomizationEnablement[]): Customization | ChildCustomization { switch (customization.type) { case CustomizationType.Plugin: case CustomizationType.McpServer: { @@ -108,7 +110,7 @@ function applyCustomizationEnablement= 0 && replacementIdx < idx) { + return { + ...state, + workingDirectories: list.filter((_, index) => index !== idx), + }; + } + return { + ...state, + workingDirectories: list + .map((directory, index) => (index === idx ? action.replacement : directory)) + .filter((directory, index) => index === idx || directory !== action.replacement), + }; + } + // ── Input Needed ──────────────────────────────────────────────────── case ActionType.SessionInputNeededSet: { diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts index 657873bb40d18c..a9105b124beef6 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts @@ -9,6 +9,8 @@ import type { Changeset } from '../channels-changeset/state.js'; import type { AnnotationsSummary } from '../channels-annotations/state.js'; import type { ChatSummary, ChatInputRequest, ToolCallConfirmationState, ToolCallState, ToolCallAuthRequiredState } from '../channels-chat/state.js'; +import type { AutomationRunState } from '../channels-automation-run/state.js'; +import type { AutomationState } from '../channels-automation/state.js'; import type { ConfigPropertySchema, ErrorInfo, Icon, ProtectedResourceMetadata, TextRange, URI } from '../common/state.js'; // ─── Session State ─────────────────────────────────────────────────────────── @@ -21,7 +23,7 @@ import type { ConfigPropertySchema, ErrorInfo, Icon, ProtectedResourceMetadata, export const enum SessionLifecycle { Creating = 'creating', Ready = 'ready', - CreationFailed = 'creationFailed', + Failed = 'failed', } /** @@ -48,6 +50,40 @@ export const enum SessionStatus { IsArchived = 1 << 6, } +/** + * Discriminant describing the durable provenance of a session. + * + * @category Session State + */ +export const enum SessionOriginKind { + /** The session was created as part of an automation run. */ + Automation = 'automation', +} + +/** + * Provenance recorded on a session created for an automation run. + * + * The links let clients navigate from an ordinary session to the task-level + * run and its durable definition. The session channel remains authoritative + * for this session's transcript, tools, confirmations, and changes. + * + * @category Session State + */ +export interface AutomationSessionOrigin { + kind: SessionOriginKind.Automation; + /** Owning {@link AutomationState.resource}. */ + automation: URI; + /** Owning {@link AutomationRunState.resource}. */ + run: URI; +} + +/** + * Durable provenance for sessions created by a higher-level AHP workflow. + * + * @category Session State + */ +export type SessionOrigin = AutomationSessionOrigin; + /** * Metadata shared between the full {@link SessionState} (delivered when a * client subscribes to a session's URI) and the lightweight @@ -70,18 +106,21 @@ export interface SessionMetadata { status: SessionStatus; /** Human-readable description of what the session is currently doing */ activity?: string; + /** Durable {@link AutomationSessionOrigin}, when an automation run created this session. */ + origin?: SessionOrigin; /** Server-owned project for this session */ project?: ProjectInfo; /** * The working directories the session's agent has tool access to, as - * maintained by the `session/workingDirectorySet` / - * `session/workingDirectoryRemoved` actions. Directories are equal peers - * except when the agent advertises - * {@link MultipleWorkingDirectoriesCapability.immutablePrimary} (the first - * entry is then a fixed process root). Individual chats MAY restrict to a - * subset via {@link ChatSummary.workingDirectories | their own - * `workingDirectories`}; a chat that sets none operates against this full - * set. + * maintained by working-directory actions. Directories are equal peers except + * when the agent advertises + * {@link MultipleWorkingDirectoriesCapability.immutablePrimary} without + * {@link MultipleWorkingDirectoriesCapability.primaryReplacement} (the first + * entry is then a fixed process root), or advertises `primaryReplacement` + * (the first entry is a protected, replaceable primary slot). Individual chats + * MAY restrict to a subset via + * {@link ChatSummary.workingDirectories | their own `workingDirectories`}; a + * chat that sets none operates against this full set. */ workingDirectories?: URI[]; /** @@ -315,6 +354,11 @@ export interface SessionToolConfirmationRequest extends SessionInputRequestBase * `chat/toolCallContentChanged`) to {@link SessionInputRequestBase.chat | * `chat`}, keyed by `turnId` and `toolCall.toolCallId`. * + * Unlike the other variants this does **not** raise + * {@link SessionStatus.InputNeeded}: the call has already cleared its + * confirmation gate and is merely executing elsewhere, so the session stays + * {@link SessionStatus.InProgress} while it runs. + * * @category Session Input Types */ export interface SessionToolClientExecutionRequest extends SessionInputRequestBase { diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-terminal/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-terminal/reducer.ts index b74d88f0f26bdf..a707a9c785a1c6 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-terminal/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-terminal/reducer.ts @@ -7,7 +7,7 @@ // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts import { ActionType } from '../common/actions.js'; -import type { TerminalState, TerminalContentPart } from './state.js'; +import { TerminalLifecycleStatus, type TerminalState, type TerminalContentPart } from './state.js'; import type { TerminalAction } from '../action-origin.generated.js'; import { softAssertNever } from '../common/reducer-helpers.js'; @@ -47,7 +47,13 @@ export function terminalReducer(state: TerminalState, action: TerminalAction, lo return { ...state, cwd: action.cwd }; case ActionType.TerminalExited: - return { ...state, exitCode: action.exitCode }; + return { + ...state, + lifecycle: { + status: TerminalLifecycleStatus.Exited, + exitCode: action.exitCode, + }, + }; case ActionType.TerminalCleared: return { ...state, content: [] }; diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-terminal/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-terminal/state.ts index 4c6f5afa74157d..238319265d5dfd 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-terminal/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-terminal/state.ts @@ -22,10 +22,49 @@ export interface TerminalInfo { title: string; /** Who currently holds this terminal */ claim: TerminalClaim; - /** Process exit code, if the terminal process has exited */ + /** Current terminal process lifecycle. */ + lifecycle: TerminalLifecycleState; +} + +/** + * Lifecycle status of a terminal process. + * + * @category Terminal Types + */ +export const enum TerminalLifecycleStatus { + Running = 'running', + Exited = 'exited', +} + +/** + * A terminal process that is still running. + * + * @category Terminal Types + */ +export interface TerminalRunningLifecycleState { + status: TerminalLifecycleStatus.Running; +} + +/** + * A terminal process that has exited. + * + * @category Terminal Types + */ +export interface TerminalExitedLifecycleState { + status: TerminalLifecycleStatus.Exited; + /** Process exit code, if the runtime reported one. */ exitCode?: number; } +/** + * Current lifecycle of a terminal process. + * + * @category Terminal Types + */ +export type TerminalLifecycleState = + | TerminalRunningLifecycleState + | TerminalExitedLifecycleState; + /** * Discriminant for terminal claim kinds. * @@ -58,7 +97,9 @@ export interface TerminalSessionClaim { kind: TerminalClaimKind.Session; /** Session URI that claimed the terminal */ session: URI; - /** Optional turn identifier within the session */ + /** Chat URI that claimed the terminal. */ + chat: URI; + /** Optional turn identifier within the chat. */ turnId?: string; /** Optional tool call identifier within the turn */ toolCallId?: string; @@ -95,8 +136,8 @@ export interface TerminalState { * Consumers that need command boundaries can filter by part type. */ content: TerminalContentPart[]; - /** Process exit code, set when the terminal process exits */ - exitCode?: number; + /** Current terminal process lifecycle. */ + lifecycle: TerminalLifecycleState; /** Who currently holds this terminal */ claim: TerminalClaim; /** diff --git a/src/vs/platform/agentHost/common/state/protocol/commands.ts b/src/vs/platform/agentHost/common/state/protocol/commands.ts index 8d8a2b657a3f32..619fb6a4255c36 100644 --- a/src/vs/platform/agentHost/common/state/protocol/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/commands.ts @@ -13,3 +13,4 @@ export * from './channels-chat/commands.js'; export * from './channels-terminal/commands.js'; export * from './channels-changeset/commands.js'; export * from './channels-resource-watch/commands.js'; +export * from './channels-automation/commands.js'; diff --git a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts index 7e37f9223a5073..939f01868f9378 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts @@ -10,7 +10,7 @@ import type { URI } from './state.js'; import type { RootAgentsChangedAction, RootActiveSessionsChangedAction, RootTerminalsChangedAction, RootConfigChangedAction } from '../channels-root/actions.js'; -import type { SessionReadyAction, SessionCreationFailedAction, SessionChatAddedAction, SessionChatRemovedAction, SessionChatUpdatedAction, SessionDefaultChatChangedAction, SessionTitleChangedAction, SessionServerToolsChangedAction, SessionActiveClientSetAction, SessionActiveClientRemovedAction, SessionWorkingDirectorySetAction, SessionWorkingDirectoryRemovedAction, SessionInputNeededSetAction, SessionInputNeededRemovedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, SessionCustomizationRemovedAction, SessionMcpServerStateChangedAction, SessionMcpServerStartRequestedAction, SessionMcpServerStopRequestedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction } from '../channels-session/actions.js'; +import type { SessionReadyAction, SessionCreationFailedAction, SessionChatAddedAction, SessionChatRemovedAction, SessionChatUpdatedAction, SessionDefaultChatChangedAction, SessionTitleChangedAction, SessionServerToolsChangedAction, SessionActiveClientSetAction, SessionActiveClientRemovedAction, SessionWorkingDirectorySetAction, SessionWorkingDirectoryRemovedAction, SessionWorkingDirectoryReplacedAction, SessionInputNeededSetAction, SessionInputNeededRemovedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, SessionCustomizationRemovedAction, SessionMcpServerStateChangedAction, SessionMcpServerStartRequestedAction, SessionMcpServerStopRequestedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction } from '../channels-session/actions.js'; import type { ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallConfirmedAction, ChatToolCallCompleteAction, ChatToolCallResultConfirmedAction, ChatToolCallContentChangedAction, ChatToolCallAuthRequiredAction, ChatToolCallAuthResolvedAction, ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, ChatActivityChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, ChatDraftChangedAction, ChatInputRequestedAction, ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction, ChatTurnsLoadedAction } from '../channels-chat/actions.js'; @@ -21,6 +21,8 @@ import type { AnnotationsSetAction, AnnotationsUpdatedAction, AnnotationsRemoved import type { TerminalDataAction, TerminalInputAction, TerminalResizedAction, TerminalClaimedAction, TerminalTitleChangedAction, TerminalCwdChangedAction, TerminalExitedAction, TerminalClearedAction, TerminalCommandDetectionAvailableAction, TerminalCommandExecutedAction, TerminalCommandFinishedAction } from '../channels-terminal/actions.js'; import type { ResourceWatchChangedAction } from '../channels-resource-watch/actions.js'; +import type { AutomationCreateRequestedAction, AutomationRemovedAction, AutomationSetAction, AutomationUpdateRequestedAction } from '../channels-automation/actions.js'; +import type { AutomationRunLifecycleChangedAction, AutomationRunSessionSetAction, AutomationRunSessionRemovedAction, AutomationRunPrimarySessionChangedAction, AutomationRunCancelRequestedAction } from '../channels-automation-run/actions.js'; // ─── Action Type Enum ──────────────────────────────────────────────────────── @@ -64,6 +66,7 @@ export const enum ActionType { SessionActiveClientRemoved = 'session/activeClientRemoved', SessionWorkingDirectorySet = 'session/workingDirectorySet', SessionWorkingDirectoryRemoved = 'session/workingDirectoryRemoved', + SessionWorkingDirectoryReplaced = 'session/workingDirectoryReplaced', SessionInputNeededSet = 'session/inputNeededSet', SessionInputNeededRemoved = 'session/inputNeededRemoved', ChatPendingMessageSet = 'chat/pendingMessageSet', @@ -115,6 +118,15 @@ export const enum ActionType { TerminalCommandExecuted = 'terminal/commandExecuted', TerminalCommandFinished = 'terminal/commandFinished', ResourceWatchChanged = 'resourceWatch/changed', + AutomationCreateRequested = 'automation/createRequested', + AutomationUpdateRequested = 'automation/updateRequested', + AutomationSet = 'automation/set', + AutomationRemoved = 'automation/removed', + AutomationRunLifecycleChanged = 'automationRun/lifecycleChanged', + AutomationRunSessionSet = 'automationRun/sessionSet', + AutomationRunSessionRemoved = 'automationRun/sessionRemoved', + AutomationRunPrimarySessionChanged = 'automationRun/primarySessionChanged', + AutomationRunCancelRequested = 'automationRun/cancelRequested', } // ─── Action Envelope ───────────────────────────────────────────────────────── @@ -167,6 +179,7 @@ export type StateAction = | SessionActiveClientRemovedAction | SessionWorkingDirectorySetAction | SessionWorkingDirectoryRemovedAction + | SessionWorkingDirectoryReplacedAction | SessionInputNeededSetAction | SessionInputNeededRemovedAction | SessionCustomizationsChangedAction @@ -235,4 +248,13 @@ export type StateAction = | TerminalCommandDetectionAvailableAction | TerminalCommandExecutedAction | TerminalCommandFinishedAction - | ResourceWatchChangedAction; + | ResourceWatchChangedAction + | AutomationCreateRequestedAction + | AutomationUpdateRequestedAction + | AutomationSetAction + | AutomationRemovedAction + | AutomationRunLifecycleChangedAction + | AutomationRunSessionSetAction + | AutomationRunSessionRemovedAction + | AutomationRunPrimarySessionChangedAction + | AutomationRunCancelRequestedAction; diff --git a/src/vs/platform/agentHost/common/state/protocol/common/commands.ts b/src/vs/platform/agentHost/common/state/protocol/common/commands.ts index 5ba8c64025041d..da4e14e6140752 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/commands.ts @@ -8,6 +8,9 @@ import type { URI, Snapshot } from './state.js'; import type { ActionEnvelope, StateAction } from './actions.js'; +import type { AutomationRunCancelRequestedAction } from '../channels-automation-run/actions.js'; +import type { AutomationCreateRequestedAction } from '../channels-automation/actions.js'; +import type { AutomationSchedule, AutomationScheduleTrigger, AutomationCatalogState, AutomationState } from '../channels-automation/state.js'; import type { TelemetryCapabilities } from '../channels-otlp/state.js'; // ─── BaseParams ────────────────────────────────────────────────────────────── @@ -265,8 +268,86 @@ export interface InitializeResult { * @see {@link /specification/telemetry-channel | Telemetry Channel} */ telemetry?: TelemetryCapabilities; + /** + * Host-owned automation support. Presence means clients may subscribe to + * `ahp-automations://` for {@link AutomationCatalogState}; absence means the + * host does not expose an automation catalogue or automation commands. + * + * @see {@link /guide/automations | Automations Guide} + */ + automations?: AutomationCapabilities; +} + +/** + * Automation features supported by this host authority. + * + * The presence of this object advertises the baseline `ahp-automations://` + * catalogue. Optional fields describe additional host features and + * restrictions. + * + * Capabilities describe implementation support. + * {@link AutomationState.operations} remains authoritative for which + * definition mutations are currently allowed on a particular automation. + * + * @category Commands + */ +export interface AutomationCapabilities { + /** Present when clients may dispatch {@link AutomationCreateRequestedAction}. */ + create?: AutomationCreateCapability; + /** Present when definitions may contain {@link AutomationScheduleTrigger | schedule triggers}. */ + schedules?: AutomationScheduleCapabilities; + /** + * Present when clients may request cancellation of `pending` or `running` + * automation runs. + */ + runCancellation?: AutomationRunCancellationCapability; + /** + * Maximum terminal entries retained in {@link AutomationState.runs}. Active + * runs are not counted toward the limit. Absence means the retention limit is + * implementation-defined. + */ + runHistoryLimit?: number; +} + +/** + * Presence capability for {@link AutomationCreateRequestedAction | + * `automation/createRequested`}. + * + * The empty object means "supported"; fields are reserved for future + * create-specific options. + * + * @category Commands + */ +export interface AutomationCreateCapability { } + +/** + * Host restrictions on portable {@link AutomationSchedule} triggers. + * + * The cron grammar itself is fixed by AHP. Hosts MUST accept every expression + * in that grammar unless it violates an advertised interval restriction. + * + * @category Commands + */ +export interface AutomationScheduleCapabilities { + /** + * Smallest permitted interval between consecutive occurrences produced by + * {@link AutomationSchedule.expression}. Omission means no restriction beyond + * the cron format's one-minute resolution. + */ + minIntervalMinutes?: number; } +/** + * Presence capability for {@link AutomationRunCancelRequestedAction | + * `automationRun/cancelRequested`}. + * + * The empty object means "supported." Clients may dispatch the action for + * `pending` or `running` runs; terminal runs cannot be cancelled. + * + * @category Commands + */ +export interface AutomationRunCancellationCapability { } + // ─── ping ──────────────────────────────────────────────────────────────────── /** diff --git a/src/vs/platform/agentHost/common/state/protocol/common/messages.ts b/src/vs/platform/agentHost/common/state/protocol/common/messages.ts index 82764b3f711b88..93f06505ff6662 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/messages.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/messages.ts @@ -13,6 +13,7 @@ import type { CreateChatParams, DisposeChatParams } from '../channels-chat/comma import type { CreateTerminalParams, DisposeTerminalParams } from '../channels-terminal/commands.js'; import type { CreateResourceWatchParams, CreateResourceWatchResult } from '../channels-resource-watch/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../channels-changeset/commands.js'; +import type { ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult, FetchAutomationRunsParams, FetchAutomationRunsResult } from '../channels-automation/commands.js'; import type { ActionEnvelope } from './actions.js'; import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams, ProgressParams } from '../channels-root/notifications.js'; @@ -108,6 +109,9 @@ export interface CommandMap { 'sessionConfigCompletions': { params: SessionConfigCompletionsParams; result: SessionConfigCompletionsResult }; 'completions': { params: CompletionsParams; result: CompletionsResult }; 'invokeChangesetOperation': { params: InvokeChangesetOperationParams; result: InvokeChangesetOperationResult }; + 'listAutomationTriggerDefinitions': { params: ListAutomationTriggerDefinitionsParams; result: ListAutomationTriggerDefinitionsResult }; + 'runAutomation': { params: RunAutomationParams; result: RunAutomationResult }; + 'fetchAutomationRuns': { params: FetchAutomationRunsParams; result: FetchAutomationRunsResult }; } /** diff --git a/src/vs/platform/agentHost/common/state/protocol/common/reducer-helpers.ts b/src/vs/platform/agentHost/common/state/protocol/common/reducer-helpers.ts index 568e7bce470727..4ca1bc28cec505 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/reducer-helpers.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/reducer-helpers.ts @@ -6,7 +6,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import { IS_CLIENT_DISPATCHABLE, type RootAction, type ClientRootAction, type SessionAction, type ClientSessionAction, type TerminalAction, type ClientTerminalAction, type ChangesetAction, type ClientChangesetAction, type AnnotationsAction, type ClientAnnotationsAction } from '../action-origin.generated.js'; +import { IS_CLIENT_DISPATCHABLE, type RootAction, type ClientRootAction, type SessionAction, type ClientSessionAction, type TerminalAction, type ClientTerminalAction, type ChangesetAction, type ClientChangesetAction, type AnnotationsAction, type ClientAnnotationsAction, type AutomationAction, type ClientAutomationAction, type AutomationRunAction, type ClientAutomationRunAction } from '../action-origin.generated.js'; /** * Soft assertion for exhaustiveness checking. Place in the `default` branch of @@ -29,6 +29,6 @@ export function softAssertNever(value: never, log?: (msg: string) => void): void * Servers SHOULD call this to validate incoming `dispatchAction` requests * and reject any action the client is not allowed to originate. */ -export function isClientDispatchable(action: RootAction | SessionAction | TerminalAction | ChangesetAction | AnnotationsAction): action is ClientRootAction | ClientSessionAction | ClientTerminalAction | ClientChangesetAction | ClientAnnotationsAction { +export function isClientDispatchable(action: RootAction | SessionAction | TerminalAction | ChangesetAction | AnnotationsAction | AutomationAction | AutomationRunAction): action is ClientRootAction | ClientSessionAction | ClientTerminalAction | ClientChangesetAction | ClientAnnotationsAction | ClientAutomationAction | ClientAutomationRunAction { return IS_CLIENT_DISPATCHABLE[action.type]; } diff --git a/src/vs/platform/agentHost/common/state/protocol/common/state.ts b/src/vs/platform/agentHost/common/state/protocol/common/state.ts index 4a446797559d8f..d8b8db833146c5 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/state.ts @@ -13,6 +13,8 @@ import type { ChangesetState } from '../channels-changeset/state.js'; import type { ResourceWatchState } from '../channels-resource-watch/state.js'; import type { AnnotationsState } from '../channels-annotations/state.js'; import type { ChatState } from '../channels-chat/state.js'; +import type { AutomationCatalogState } from '../channels-automation/state.js'; +import type { AutomationRunState } from '../channels-automation-run/state.js'; // ─── Type Aliases ──────────────────────────────────────────────────────────── @@ -332,7 +334,7 @@ export interface Snapshot { /** The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`) */ resource: URI; /** The current state of the resource */ - state: RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState; + state: RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationCatalogState | AutomationRunState; /** The `serverSeq` at which this snapshot was taken. Subsequent actions will have `serverSeq > fromSeq`. */ fromSeq: number; } diff --git a/src/vs/platform/agentHost/common/state/protocol/common/timestamps.ts b/src/vs/platform/agentHost/common/state/protocol/common/timestamps.ts new file mode 100644 index 00000000000000..43a32e436a28ec --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/common/timestamps.ts @@ -0,0 +1,11 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts + +export function addMillisecondsToTimestamp(timestamp: string, duration: number): string { + return new Date(Date.parse(timestamp) + duration).toISOString(); +} diff --git a/src/vs/platform/agentHost/common/state/protocol/reducers.ts b/src/vs/platform/agentHost/common/state/protocol/reducers.ts index b1997b6ac0a132..8004b19cf7899f 100644 --- a/src/vs/platform/agentHost/common/state/protocol/reducers.ts +++ b/src/vs/platform/agentHost/common/state/protocol/reducers.ts @@ -13,4 +13,6 @@ export { terminalReducer } from './channels-terminal/reducer.js'; export { changesetReducer } from './channels-changeset/reducer.js'; export { annotationsReducer } from './channels-annotations/reducer.js'; export { resourceWatchReducer } from './channels-resource-watch/reducer.js'; +export { automationReducer } from './channels-automation/reducer.js'; +export { automationRunReducer } from './channels-automation-run/reducer.js'; export { softAssertNever, isClientDispatchable } from './common/reducer-helpers.js'; diff --git a/src/vs/platform/agentHost/common/state/protocol/state.ts b/src/vs/platform/agentHost/common/state/protocol/state.ts index e9ed876f2f2540..1c2205dffb7a54 100644 --- a/src/vs/platform/agentHost/common/state/protocol/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/state.ts @@ -15,3 +15,5 @@ export * from './channels-changeset/state.js'; export * from './channels-annotations/state.js'; export * from './channels-otlp/state.js'; export * from './channels-resource-watch/state.js'; +export * from './channels-automation/state.js'; +export * from './channels-automation-run/state.js'; diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index a32aa3118994b5..c1a5afe50bfe64 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -16,7 +16,7 @@ import type { ServerNotificationMap } from '../messages.js'; * * Formatted as a [SemVer](https://semver.org) `MAJOR.MINOR.PATCH` string. */ -export const PROTOCOL_VERSION = '0.8.0'; +export const PROTOCOL_VERSION = '1.0.0'; /** * Every protocol version a client built from this source tree is willing @@ -35,6 +35,7 @@ export const PROTOCOL_VERSION = '0.8.0'; * `scripts/verify-release-metadata.ts`. */ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ + '1.0.0', '0.8.0', '0.7.0', '0.6.0', @@ -94,6 +95,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.SessionActiveClientRemoved]: '0.5.0', [ActionType.SessionWorkingDirectorySet]: '0.7.0', [ActionType.SessionWorkingDirectoryRemoved]: '0.7.0', + [ActionType.SessionWorkingDirectoryReplaced]: '0.8.0', [ActionType.SessionInputNeededSet]: '0.5.1', [ActionType.SessionInputNeededRemoved]: '0.5.1', [ActionType.SessionCustomizationsChanged]: '0.1.0', @@ -165,6 +167,15 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.TerminalCommandExecuted]: '0.1.0', [ActionType.TerminalCommandFinished]: '0.1.0', [ActionType.ResourceWatchChanged]: '0.2.0', + [ActionType.AutomationCreateRequested]: '0.8.0', + [ActionType.AutomationUpdateRequested]: '0.8.0', + [ActionType.AutomationSet]: '0.8.0', + [ActionType.AutomationRemoved]: '0.8.0', + [ActionType.AutomationRunLifecycleChanged]: '0.8.0', + [ActionType.AutomationRunSessionSet]: '0.8.0', + [ActionType.AutomationRunSessionRemoved]: '0.8.0', + [ActionType.AutomationRunPrimarySessionChanged]: '0.8.0', + [ActionType.AutomationRunCancelRequested]: '0.8.0', }; /** diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index bb012f799c26e5..17eabfcd70971a 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -60,14 +60,13 @@ export { ChatInputAnswerState as SessionInputAnswerState, ChatInputAnswerValueKind as SessionInputAnswerValueKind, ChatInputQuestionKind as SessionInputQuestionKind, - ChatInputRequestPurpose, ChatInputResponseKind as SessionInputResponseKind, ChatInteractivity, ChatOriginKind, SessionLifecycle, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolResultContentType, - TurnState, type ActiveTurn, type AgentCustomization, type AgentCapabilities, type AgentInfo, type AgentSelection, type Annotation, type AnnotationEntry, type AnnotationsState, type AnnotationsSummary, type Changeset, type ChangesetFile, + TurnState, type ActiveTurn, type AgentCustomization, type AgentCapabilities, type AgentInfo, type AgentSelection, type Annotation, type AnnotationEntry, type AnnotationOrigin, type AnnotationsState, type AnnotationsSummary, type Changeset, type ChangesetFile, type ChangesetOperation, type ChangesetState, type ChatState, type ChatSummary, type ChatOrigin, type ChildCustomization, type ClientPluginCustomization, type ConfigPropertySchema, type ConfigSchema, type ContentRef, type Customization, type CustomizationDegradedState, diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index b875b508f47499..37f938bed3ff93 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -379,7 +379,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC refreshChangesetCatalog(session: ProtocolURI): void { const state = this._stateManager.getSessionState(session); - if (!state || state?.lifecycle === SessionLifecycle.CreationFailed) { + if (!state || state?.lifecycle === SessionLifecycle.Failed) { return; } diff --git a/src/vs/platform/agentHost/node/agentHostGitStateService.ts b/src/vs/platform/agentHost/node/agentHostGitStateService.ts index 94f7b37405326f..78a9ea9df656e2 100644 --- a/src/vs/platform/agentHost/node/agentHostGitStateService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitStateService.ts @@ -237,7 +237,7 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi async refreshSessionGitState(sessionKey: string, workingDirectory: URI | undefined): Promise { const sessionState = this._stateManager.getSessionState(sessionKey); - if (sessionState?.lifecycle === SessionLifecycle.CreationFailed) { + if (sessionState?.lifecycle === SessionLifecycle.Failed) { return; } diff --git a/src/vs/platform/agentHost/node/agentHostInputRequestTracker.ts b/src/vs/platform/agentHost/node/agentHostInputRequestTracker.ts index 02fb95eff77c2a..721728d30aa42b 100644 --- a/src/vs/platform/agentHost/node/agentHostInputRequestTracker.ts +++ b/src/vs/platform/agentHost/node/agentHostInputRequestTracker.ts @@ -5,8 +5,9 @@ import { StopWatch } from '../../../base/common/stopwatch.js'; import type { IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; +import { ChatInputRequestPurpose, readChatInputRequestPurpose } from '../common/meta/agentChatInputRequestMeta.js'; import type { ChatInputCompletedAction } from '../common/state/sessionActions.js'; -import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputRequestPurpose, ChatInputResponseKind, ResponsePartKind, isAhpChatChannel, parseRequiredSessionUriFromChatUri, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type ChatState } from '../common/state/sessionState.js'; +import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ResponsePartKind, isAhpChatChannel, parseRequiredSessionUriFromChatUri, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type ChatState } from '../common/state/sessionState.js'; import type { AgentHostTelemetryReporter } from './agentHostTelemetryReporter.js'; interface IInputRequestTiming { @@ -32,7 +33,7 @@ export class AgentHostInputRequestTracker { inputRequested(provider: string, session: string, turnId: string, request: ChatInputRequest): void { const key = this._key(session, request.id); - if (request.purpose !== ChatInputRequestPurpose.AskUser) { + if (readChatInputRequestPurpose(request) !== ChatInputRequestPurpose.AskUser) { this._pending.delete(key); return; } @@ -69,7 +70,7 @@ export class AgentHostInputRequestTracker { && part.request.id === action.requestId && part.response === ChatInputResponseKind.Accept ); - if (!part || part.kind !== ResponsePartKind.InputRequest || part.request.purpose !== ChatInputRequestPurpose.AskUser) { + if (!part || part.kind !== ResponsePartKind.InputRequest || readChatInputRequestPurpose(part.request) !== ChatInputRequestPurpose.AskUser) { return; } diff --git a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts index e05167a55653bd..9d56fb9c0868a2 100644 --- a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts +++ b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts @@ -20,7 +20,7 @@ import { getShellIntegrationInjection } from '../../terminal/node/terminalEnviro import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../common/agentHostCustomizationConfig.js'; import { ActionType } from '../common/state/protocol/actions.js'; import type { CreateTerminalParams } from '../common/state/protocol/commands.js'; -import { TerminalClaim, TerminalContentPart, TerminalInfo, TerminalState, TerminalClaimKind } from '../common/state/protocol/state.js'; +import { TerminalClaim, TerminalContentPart, TerminalInfo, TerminalState, TerminalClaimKind, TerminalLifecycleStatus } from '../common/state/protocol/state.js'; import { isTerminalAction } from '../common/state/sessionActions.js'; import { ROOT_STATE_URI } from '../common/state/sessionState.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; @@ -130,7 +130,6 @@ export interface IAgentHostTerminalManager { getContent(uri: string): string | undefined; getClaim(uri: string): TerminalClaim | undefined; hasTerminal(uri: string): boolean; - getExitCode(uri: string): number | undefined; supportsCommandDetection(uri: string): boolean; disposeTerminal(uri: string): void; getTerminalInfos(): TerminalInfo[]; @@ -178,7 +177,7 @@ interface IManagedTerminal { content: TerminalContentPart[]; contentSize: number; claim: TerminalClaim; - exitCode?: number; + lifecycle: TerminalState['lifecycle']; commandTracker?: ICommandTracker; headlessTerminal?: AgentHostHeadlessTerminal; terminalQueryFilterState: ITerminalQueryFilterState; @@ -194,7 +193,7 @@ interface IOutputTerminal { content: TerminalContentPart[]; contentSize: number; claim: TerminalClaim; - exitCode?: number; + lifecycle: TerminalState['lifecycle']; } /** @@ -252,7 +251,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe resource: t.uri, title: t.title, claim: t.claim, - exitCode: t.exitCode, + lifecycle: t.lifecycle, })); } @@ -263,7 +262,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe return { title: outputTerminal.title, content: outputTerminal.content, - exitCode: outputTerminal.exitCode, + lifecycle: outputTerminal.lifecycle, claim: outputTerminal.claim, isPty: false, }; @@ -278,7 +277,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe cols: terminal.cols, rows: terminal.rows, content: terminal.content, - exitCode: terminal.exitCode, + lifecycle: terminal.lifecycle, claim: terminal.claim, supportsCommandDetection: terminal.commandTracker?.detectionAvailableEmitted, isPty: true, @@ -427,6 +426,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe content: [], contentSize: 0, claim, + lifecycle: { status: TerminalLifecycleStatus.Running }, commandTracker, headlessTerminal, terminalQueryFilterState: { pendingData: '' }, @@ -456,7 +456,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe store.add(toDisposable(() => dataListener.dispose())); const exitListener = ptyProcess.onExit(e => { - managed.exitCode = e.exitCode; + managed.lifecycle = { status: TerminalLifecycleStatus.Exited, exitCode: e.exitCode }; managed.onExitEmitter.fire(e.exitCode); onFirstData.complete(); this._stateManager.dispatchServerAction(uri, { @@ -501,7 +501,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe /** Send input data to a terminal's PTY process. */ writeInput(uri: string, data: string): void { const terminal = this._terminals.get(uri); - if (terminal && terminal.exitCode === undefined) { + if (terminal?.lifecycle.status === TerminalLifecycleStatus.Running) { terminal.pty.write(data); } } @@ -586,15 +586,10 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe return terminal?.commandTracker?.detectionAvailableEmitted ?? false; } - /** Get the exit code for a terminal, or undefined if still running. */ - getExitCode(uri: string): number | undefined { - return this._terminals.get(uri)?.exitCode; - } - /** Resize a terminal. */ private _resize(uri: string, cols: number, rows: number): void { const terminal = this._terminals.get(uri); - if (terminal && terminal.exitCode === undefined) { + if (terminal?.lifecycle.status === TerminalLifecycleStatus.Running) { terminal.cols = cols; terminal.rows = rows; terminal.pty.resize(cols, rows); @@ -849,6 +844,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe content: [], contentSize: 0, claim: options.claim, + lifecycle: { status: TerminalLifecycleStatus.Running }, }); } @@ -882,16 +878,15 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe /** Record the command's exit on an output-only terminal and notify subscribers. */ finalizeOutputTerminal(uri: string, exitCode: number | undefined): void { const terminal = this._outputTerminals.get(uri); - if (!terminal || terminal.exitCode !== undefined) { + if (!terminal || terminal.lifecycle.status === TerminalLifecycleStatus.Exited) { return; } - if (exitCode !== undefined) { - terminal.exitCode = exitCode; - this._stateManager.dispatchServerAction(uri, { - type: ActionType.TerminalExited, - exitCode, - }); - } + terminal.lifecycle = exitCode === undefined + ? { status: TerminalLifecycleStatus.Exited } + : { status: TerminalLifecycleStatus.Exited, exitCode }; + this._stateManager.dispatchServerAction(uri, exitCode === undefined + ? { type: ActionType.TerminalExited } + : { type: ActionType.TerminalExited, exitCode }); } /** Dispose a terminal: kill the process and remove it. */ diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 5064727c154cd5..3eb51d638e0117 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -33,7 +33,7 @@ import { resolveSessionWorkingDirectoryAction } from '../common/state/sessionWor import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult, SessionConfigPropertySchema } from '../common/state/protocol/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; -import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type Annotation, type AnnotationEntry, type AnnotationsState, type ChatOrigin, type Customization, type Message, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js'; +import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type Annotation, type AnnotationEntry, type AnnotationOrigin, type AnnotationsState, type ChatOrigin, type Customization, type Message, type MessageAttachment, type MessageResourceAttachment, type TextRange } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js'; import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_ORCHESTRATION_DB_KEY, readSessionSpawnDepth, parseSessionOrchestration, withSessionSpawnDepth, withSessionOrchestration, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn, type UsageInfo, chatStorageUri, hasReportedUsage } from '../common/state/sessionState.js'; import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js'; @@ -217,21 +217,75 @@ function isPersistedAnnotationEntry(value: unknown): value is AnnotationEntry { || (isRecord(value.text) && typeof value.text.markdown === 'string'); } -function isPersistedAnnotation(value: unknown): value is Annotation { +function isPersistedAnnotationOrigin(value: unknown): value is AnnotationOrigin { return isRecord(value) - && typeof value.id === 'string' - && typeof value.turnId === 'string' - && typeof value.resource === 'string' - && typeof value.resolved === 'boolean' - && Array.isArray(value.entries) - && value.entries.length > 0 - && value.entries.every(isPersistedAnnotationEntry); + && typeof value.session === 'string' + && (value.chat === undefined || typeof value.chat === 'string') + && (value.turnId === undefined || typeof value.turnId === 'string'); } -function isPersistedAnnotationsState(value: unknown): value is AnnotationsState { +function isPersistedTextRange(value: unknown): value is TextRange { return isRecord(value) - && Array.isArray(value.annotations) - && value.annotations.every(isPersistedAnnotation); + && isRecord(value.start) && typeof value.start.line === 'number' && typeof value.start.character === 'number' + && isRecord(value.end) && typeof value.end.line === 'number' && typeof value.end.character === 'number'; +} + +/** + * Reads one persisted annotation, migrating the pre-`origin` shape. Releases + * before the annotation origin recorded a top-level `turnId` and no owning + * session, so the session that is being restored supplies the origin. + */ +function readPersistedAnnotation(value: unknown, session: string): Annotation | undefined { + if (!isRecord(value) + || typeof value.id !== 'string' + || typeof value.resource !== 'string' + || typeof value.resolved !== 'boolean' + || !Array.isArray(value.entries) + || value.entries.length === 0 + || !value.entries.every(isPersistedAnnotationEntry)) { + return undefined; + } + let origin: AnnotationOrigin; + if (isPersistedAnnotationOrigin(value.origin)) { + origin = value.origin; + } else if (value.origin === undefined) { + origin = { session, ...(typeof value.turnId === 'string' && value.turnId ? { turnId: value.turnId } : {}) }; + } else { + return undefined; + } + const annotation: Annotation = { + id: value.id, + origin, + resource: value.resource, + resolved: value.resolved, + entries: value.entries, + }; + if (isPersistedTextRange(value.range)) { + annotation.range = value.range; + } + if (isRecord(value._meta)) { + annotation._meta = value._meta; + } + return annotation; +} + +/** + * Reads a persisted annotations state, migrating any legacy annotation into the + * current shape. Returns `undefined` when the payload is not a valid state. + */ +function readPersistedAnnotationsState(value: unknown, session: string): AnnotationsState | undefined { + if (!isRecord(value) || !Array.isArray(value.annotations)) { + return undefined; + } + const annotations: Annotation[] = []; + for (const entry of value.annotations) { + const annotation = readPersistedAnnotation(entry, session); + if (!annotation) { + return undefined; + } + annotations.push(annotation); + } + return { annotations }; } /** Opaque provider data for the session's default chat. */ @@ -2452,39 +2506,6 @@ export class AgentService extends Disposable implements IAgentService { } } - // When forking, build the old→new turn ID mapping before creating the - // session so the agent can use it to remap per-turn data. If the - // source has no turns to copy (e.g. a still-provisional session), a - // "fork" is indistinguishable from a fresh session, so we drop the - // fork parameter and fall through to the regular create path. - if (config?.fork) { - const sourceState = this._stateManager.getSessionState(config.fork.session.toString()); - const sourceTurns = sourceState?.turns.slice(0, config.fork.turnIndex + 1) ?? []; - if (sourceTurns.length === 0) { - config = { ...config, fork: undefined }; - } else { - const turnIdMapping = new Map(); - for (const t of sourceTurns) { - turnIdMapping.set(t.id, generateUuid()); - } - // The SDK fork boundary must be a concrete (SDK-backed) turn. - // When the client forked at a host-injected local turn - // (`/rename` / `!command`), redirect the agent to the preceding - // concrete turn while still seeding the local turns up to the - // fork point into the new session's protocol state below. - const concreteForkTurnId = this._localTurns.resolveConcreteTurnId(buildDefaultChatUri(config.fork.session).toString(), config.fork.turnId); - config = { - ...config, - fork: { - ...config.fork, - chat: URI.parse(buildDefaultChatUri(config.fork.session)), - turnIdMapping, - ...(concreteForkTurnId !== undefined ? { turnId: concreteForkTurnId } : {}), - }, - }; - } - } - // When importing a conversation, assign fresh UUID turn ids up front so // the provider seeds an event log whose ids match the protocol turns we // seed below — keeping edit / fork / truncate addressable at the SDK @@ -2500,7 +2521,7 @@ export class AgentService extends Disposable implements IAgentService { // materializing in the picked folder before the host creates the worktree. const initializeSideEffects = this._sideEffects.initialize(); const sessionConfig = await this._resolveCreatedSessionConfig(provider, config); - const deferWorktreeCreation = sessionConfig?.values?.[SessionConfigKey.Isolation] === 'worktree' && !config?.fork && !config?.importConversation; + const deferWorktreeCreation = sessionConfig?.values?.[SessionConfigKey.Isolation] === 'worktree' && !config?.importConversation; this._logService.trace(`[AgentService] createSession: initializing auto-approver and creating session...`); const [, created] = await Promise.all([ @@ -2566,7 +2587,7 @@ export class AgentService extends Disposable implements IAgentService { // updates while resolving that snapshot; without a state entry those // actions are rejected as targeting an unknown session and custom agents // can disappear from the picker permanently. - const provisionalState = created.provisional && !config?.fork && !config?.importConversation + const provisionalState = created.provisional && !config?.importConversation ? (() => { const summary = this._buildInitialSummary(provider, session, config, created, ''); const state = this._stateManager.createSession(summary, { emitNotification: false }); @@ -2596,9 +2617,9 @@ export class AgentService extends Disposable implements IAgentService { }), // The harness owns the Folder-picker decision (it is provider-specific), // derived from the ordered working-directory set. Only meaningful for a - // fresh (non-fork, non-import) multi-root session — the picker never + // fresh (non-import) multi-root session — the picker never // shows with a single folder — and seeded into `_meta` below. - workingDirectories && workingDirectories.length > 1 && !config?.fork && !config?.importConversation && provider.computeFolderPickerDecision + workingDirectories && workingDirectories.length > 1 && !config?.importConversation && provider.computeFolderPickerDecision ? provider.computeFolderPickerDecision(workingDirectories).catch(err => { // Fail open: on an indeterminate scan error, show the picker rather // than silently hiding it and pinning the default (index 0) folder. @@ -2608,47 +2629,7 @@ export class AgentService extends Disposable implements IAgentService { : Promise.resolve(undefined), ]); - // When forking, populate the new session's protocol state with - // the source session's turns so the client sees the forked history. - if (config?.fork) { - const sourceState = this._stateManager.getSessionState(config.fork.session.toString()); - const sourceChatUri = buildDefaultChatUri(config.fork.session).toString(); - const newChatUri = buildDefaultChatUri(session).toString(); - let sourceTurns: Turn[] = []; - if (sourceState && config.fork.turnIdMapping) { - const originalSlice = sourceState.turns.slice(0, config.fork.turnIndex + 1); - const mapping = config.fork.turnIdMapping; - sourceTurns = originalSlice.map(t => ({ ...t, id: mapping.get(t.id) ?? generateUuid() })); - // Re-persist forked local turns (`/rename`, `!command`) under the - // new session's default chat. `record` (keyed by turn id) - // overwrites any rows a DB copy carried with the SOURCE chat URI, - // and seeds the in-memory index for same-process fork/truncate. - this._persistForkedLocalTurns(session.toString(), sourceChatUri, newChatUri, originalSlice, sourceTurns, mapping); - } - - // Prefix the forked session's title so consumers (sidebar, chat - // model) can distinguish it from the source without each surface - // reinventing the convention. Avoid double-prefixing when a user - // forks an already-forked session. - const forkedTitlePrefix = localize('agentHost.forkedTitlePrefix', "Forked: "); - const sourceTitle = sourceState?.title; - const forkedTitle = sourceTitle - ? (sourceTitle.startsWith(forkedTitlePrefix) ? sourceTitle : `${forkedTitlePrefix}${sourceTitle}`) - : localize('agentHost.forkedSessionFallback', "Forked Session"); - const summary = this._buildInitialSummary(provider, session, config, created, forkedTitle); - const state = this._stateManager.createSession(summary); - state.config = sessionConfig; - this._stateManager.seedDefaultChatTurns(summary.resource, sourceTurns); - state.activeClients = config.activeClient ? [config.activeClient] : []; - - // Refine the forked session's placeholder `Forked: …` title into one - // derived from the inherited chat. Forks seed pre-existing - // turns, so the normal first-message/first-turn title generation - // never fires for them — this is the fork-time equivalent. - if (sourceTurns.length > 0) { - this._sideEffects.generateForkedTitle(summary.resource, undefined, sourceTurns, forkedTitle, sourceTitle); - } - } else if (config?.importConversation) { + if (config?.importConversation) { // An imported conversation arrives with pre-existing turns (assigned // fresh UUID ids above). Seed them into the new session's protocol // state so the client renders the imported history immediately; the @@ -2663,7 +2644,7 @@ export class AgentService extends Disposable implements IAgentService { state.activeClients = config.activeClient ? [config.activeClient] : []; // Refine the placeholder title into one generated from the imported - // conversation, mirroring forks. Imports seed pre-existing turns, so + // conversation. Imports seed pre-existing turns, so // the normal first-message title generation never fires; without this // the session would keep showing the raw first-message clip while // sibling sessions show clean generated titles — making imports look @@ -3290,16 +3271,8 @@ export class AgentService extends Disposable implements IAgentService { ...(config.workingDirectories ? { workingDirectories: config.workingDirectories } : {}), ...(config.config ? { config: config.config } : {}), ...(config.activeClient ? { activeClient: config.activeClient } : {}), - ...(!config.fork && !config.importConversation ? { deferBacking: true } : {}), + ...(!config.importConversation ? { deferBacking: true } : {}), ...(config.importConversation ? { importConversation: config.importConversation } : {}), - ...(config.fork ? { - fork: { - source: config.fork.chat, - turnIndex: config.fork.turnIndex, - turnId: config.fork.turnId, - turnIdMapping: config.fork.turnIdMapping, - }, - } : {}), }; } @@ -3350,15 +3323,12 @@ export class AgentService extends Disposable implements IAgentService { const now = new Date().toISOString(); const explicitGitHubState = readSessionGitHubState(config?._meta); const explicitMultiRoot = readSessionMultiRootMetadata(config?._meta); - const inheritedMultiRoot = config?.fork - ? readSessionMultiRootMetadata(this._stateManager.getSessionSummary(config.fork.session.toString())?._meta) - : undefined; let _meta = withSessionGitHubState(undefined, explicitGitHubState); - _meta = withSessionMultiRootMetadata(_meta, explicitMultiRoot ?? inheritedMultiRoot); + _meta = withSessionMultiRootMetadata(_meta, explicitMultiRoot); _meta = withEphemeralSessionMeta(_meta, config ? readEphemeralSessionMeta(config).isEphemeral : undefined); _meta = withChatSurfaceMeta(_meta, readChatSurfaceMeta(config ?? {})); _meta = withSessionExternal(_meta, false); - _meta = !config?.fork && !config?.workingDirectories + _meta = !config?.workingDirectories ? withSessionWorkspaceless(_meta, true) : _meta; return { @@ -4455,6 +4425,14 @@ export class AgentService extends Disposable implements IAgentService { action = this._withPreservedHostWrittenSessionConfig(sessionChannel, configAction); } } + // `session/workingDirectoryReplaced` is client-dispatchable in the + // protocol, but no provider advertises `primaryReplacement` and the host + // has no backend side effect for it. Reject it rather than let the + // reducer apply an unvalidated, uncanonicalized mutation. + if (action.type === ActionType.SessionWorkingDirectoryReplaced) { + this._stateManager.rejectClientAction(channel, action, origin, 'Session working-directory replacement is not supported.'); + return; + } if (action.type === ActionType.SessionWorkingDirectorySet || action.type === ActionType.SessionWorkingDirectoryRemoved) { if (clientContext.clientType !== AgentHostClientType.EditorWindow) { this._stateManager.rejectClientAction(channel, action, origin, 'Session working-directory actions require an Editor Window client.'); @@ -4951,10 +4929,11 @@ export class AgentService extends Disposable implements IAgentService { return; } const state: unknown = JSON.parse(raw); - if (!isPersistedAnnotationsState(state)) { + const annotations = readPersistedAnnotationsState(state, session.toString()); + if (!annotations) { throw new Error('Invalid annotations state'); } - this._stateManager.restoreAnnotations(session.toString(), state); + this._stateManager.restoreAnnotations(session.toString(), annotations); } finally { ref.dispose(); } diff --git a/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts b/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts index f8891919efca14..19312781aba307 100644 --- a/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts +++ b/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts @@ -7,7 +7,8 @@ import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-ag import { URI } from '../../../../base/common/uri.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { ClaudePermissionMode, ClaudeSessionConfigKey } from '../../common/claudeSessionConfigKeys.js'; -import { ChatInputRequestPurpose, ChatInputResponseKind, ToolCallPendingConfirmationState, ToolCallStatus } from '../../common/state/protocol/state.js'; +import { ChatInputRequestPurpose, withChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js'; +import { ChatInputResponseKind, ToolCallPendingConfirmationState, ToolCallStatus } from '../../common/state/protocol/state.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { ClaudeAgentSession } from './claudeAgentSession.js'; import { extractServerToolName } from './claudeServerToolMcpServer.js'; @@ -284,11 +285,10 @@ async function handleAskUserQuestion( } const parentToolCallId = resolveSubagentParent(session, options); - const answer = await session.requestUserInput({ + const answer = await session.requestUserInput(withChatInputRequestPurpose({ id: toolUseID, - purpose: ChatInputRequestPurpose.AskUser, questions: buildAskUserSessionInputQuestions(askInput), - }, parentToolCallId); + }, ChatInputRequestPurpose.AskUser), parentToolCallId); if (answer.response !== ChatInputResponseKind.Accept || !answer.answers) { return { behavior: 'deny', message: CLAUDE_QUESTION_CANCELLED_MESSAGE }; } diff --git a/src/vs/platform/agentHost/node/claude/claudeElicitation.ts b/src/vs/platform/agentHost/node/claude/claudeElicitation.ts index fea594f2fcce6f..a593eec0e7f953 100644 --- a/src/vs/platform/agentHost/node/claude/claudeElicitation.ts +++ b/src/vs/platform/agentHost/node/claude/claudeElicitation.ts @@ -7,7 +7,8 @@ import type { ElicitationRequest, ElicitationResult } from '@anthropic-ai/claude import type { PrimitiveSchemaDefinition } from '@modelcontextprotocol/sdk/types.js'; import { isObject, isString } from '../../../../base/common/types.js'; import { vArray, vNumber, vObj, vOptionalProp, vString, vUnknown, type ValidatorType } from '../../../../base/common/validation.js'; -import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputRequestPurpose, ChatInputResponseKind, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest } from '../../common/state/sessionState.js'; +import { ChatInputRequestPurpose, withChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js'; +import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest } from '../../common/state/sessionState.js'; /** * Pure projections between the Claude SDK's MCP elicitation request/response @@ -135,18 +136,18 @@ function parseElicitationSchema(schema: unknown): IParsedElicitationSchema | und */ export function buildElicitationRequest(requestId: string, request: ElicitationRequest): ChatInputRequest { if (request.mode === 'url') { - const result: ChatInputRequest = { id: requestId, purpose: ChatInputRequestPurpose.Elicitation, message: request.message }; + const result: ChatInputRequest = { id: requestId, message: request.message }; if (request.url) { result.url = request.url; } - return result; + return withChatInputRequestPurpose(result, ChatInputRequestPurpose.Elicitation); } const schema = parseElicitationSchema(request.requestedSchema); if (!schema || schema.fields.length === 0) { - return { id: requestId, purpose: ChatInputRequestPurpose.Elicitation, message: request.message }; + return withChatInputRequestPurpose({ id: requestId, message: request.message }, ChatInputRequestPurpose.Elicitation); } const questions = schema.fields.map(([name, field]) => elicitationFieldToQuestion(name, field, schema.required.has(name))); - return { id: requestId, purpose: ChatInputRequestPurpose.Elicitation, message: request.message, questions }; + return withChatInputRequestPurpose({ id: requestId, message: request.message, questions }, ChatInputRequestPurpose.Elicitation); } /** diff --git a/src/vs/platform/agentHost/node/codex/codexElicitationMapper.ts b/src/vs/platform/agentHost/node/codex/codexElicitationMapper.ts index e339c6126a9df9..5b9fea6d1b6b86 100644 --- a/src/vs/platform/agentHost/node/codex/codexElicitationMapper.ts +++ b/src/vs/platform/agentHost/node/codex/codexElicitationMapper.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { hasKey } from '../../../../base/common/types.js'; -import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputRequestPurpose, ChatInputResponseKind, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest } from '../../common/state/sessionState.js'; +import { ChatInputRequestPurpose, withChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js'; +import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest } from '../../common/state/sessionState.js'; import type { JsonValue } from './protocol/generated/serde_json/JsonValue.js'; import type { McpElicitationPrimitiveSchema } from './protocol/generated/v2/McpElicitationPrimitiveSchema.js'; import type { McpServerElicitationRequestParams } from './protocol/generated/v2/McpServerElicitationRequestParams.js'; @@ -30,17 +31,17 @@ import type { McpServerElicitationRequestResponse } from './protocol/generated/v */ export function buildElicitationRequest(requestId: string, params: McpServerElicitationRequestParams): ChatInputRequest { if (params.mode === 'url') { - const request: ChatInputRequest = { id: requestId, purpose: ChatInputRequestPurpose.Elicitation, message: params.message }; + const request: ChatInputRequest = { id: requestId, message: params.message }; if (params.url) { request.url = params.url; } - return request; + return withChatInputRequestPurpose(request, ChatInputRequestPurpose.Elicitation); } if (params.mode !== 'form') { // `openai/form` carries an opaque, OpenAI-specific schema we cannot // project into typed questions; surface the message only so the user // can still accept or decline. - return { id: requestId, purpose: ChatInputRequestPurpose.Elicitation, message: params.message }; + return withChatInputRequestPurpose({ id: requestId, message: params.message }, ChatInputRequestPurpose.Elicitation); } const required = new Set(params.requestedSchema.required ?? []); const questions: ChatInputQuestion[] = []; @@ -49,9 +50,12 @@ export function buildElicitationRequest(requestId: string, params: McpServerElic questions.push(elicitationFieldToQuestion(name, field, required.has(name))); } } - return questions.length > 0 - ? { id: requestId, purpose: ChatInputRequestPurpose.Elicitation, message: params.message, questions } - : { id: requestId, purpose: ChatInputRequestPurpose.Elicitation, message: params.message }; + return withChatInputRequestPurpose( + questions.length > 0 + ? { id: requestId, message: params.message, questions } + : { id: requestId, message: params.message }, + ChatInputRequestPurpose.Elicitation, + ); } /** diff --git a/src/vs/platform/agentHost/node/codex/codexUserInputMapper.ts b/src/vs/platform/agentHost/node/codex/codexUserInputMapper.ts index 4c4a2b550168d2..962f0247a401ef 100644 --- a/src/vs/platform/agentHost/node/codex/codexUserInputMapper.ts +++ b/src/vs/platform/agentHost/node/codex/codexUserInputMapper.ts @@ -3,7 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputRequestPurpose, ChatInputResponseKind, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest } from '../../common/state/sessionState.js'; +import { ChatInputRequestPurpose, withChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js'; +import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest } from '../../common/state/sessionState.js'; import type { ToolRequestUserInputAnswer } from './protocol/generated/v2/ToolRequestUserInputAnswer.js'; import type { ToolRequestUserInputQuestion } from './protocol/generated/v2/ToolRequestUserInputQuestion.js'; import type { ToolRequestUserInputResponse } from './protocol/generated/v2/ToolRequestUserInputResponse.js'; @@ -16,9 +17,8 @@ import type { ToolRequestUserInputResponse } from './protocol/generated/v2/ToolR * the option label doubles as the id. */ export function buildUserInputRequest(requestId: string, questions: readonly ToolRequestUserInputQuestion[]): ChatInputRequest { - return { + return withChatInputRequestPurpose({ id: requestId, - purpose: ChatInputRequestPurpose.AskUser, questions: questions.map((q): ChatInputQuestion => { if (q.options && q.options.length > 0) { return { @@ -39,7 +39,7 @@ export function buildUserInputRequest(requestId: string, questions: readonly Too required: true, }; }), - }; + }, ChatInputRequestPurpose.AskUser); } /** diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 891c6b8999a9a1..d217da37335309 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -31,6 +31,7 @@ import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { getCopilotHomePath } from '../../common/copilotHome.js'; import { CopilotCliConfigKey, copilotCliConfigSchema } from '../../common/copilotCliConfig.js'; import type { ChatInputRequestWithPlanReview, IAgentHostPlanReviewAction } from '../../common/agentHostPlanReview.js'; +import { ChatInputRequestPurpose, withChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js'; import { gitHubMcpServerUrl } from '../../common/githubEndpoints.js'; import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; import { AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyAnswer, AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js'; @@ -50,7 +51,7 @@ import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataS import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { MessageAttachmentKind, ToolCallContributorKind, type FileEdit, type MessageAttachment, type ToolCallContributor } from '../../common/state/protocol/state.js'; import { ActionType, isChatAction, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js'; -import { MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputRequestPurpose, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolResultContentType, buildSubagentSessionUri, isSubagentSession, type Customization, type Message, type PendingMessage, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest, type ToolCallResult, type ToolResultContent, type ToolResultTerminalContent, type Turn, type ITurnTokenTotal, type UsageInfo, type UsageInfoMeta, type IContextAttributionData, type ISessionPromptCacheState } from '../../common/state/sessionState.js'; +import { MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolResultContentType, buildSubagentSessionUri, isSubagentSession, type Customization, type Message, type PendingMessage, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest, type ToolCallResult, type ToolResultContent, type ToolResultTerminalContent, type Turn, type ITurnTokenTotal, type UsageInfo, type UsageInfoMeta, type IContextAttributionData, type ISessionPromptCacheState } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { clientToolNamesFromSnapshot, isMcpServerExplicitlyProjected, type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from './copilotSessionLauncher.js'; @@ -991,7 +992,7 @@ export class CopilotAgentSession extends Disposable { this._isLaunchTokenStillCurrent = options.isLaunchTokenCurrent ?? (() => true); this._onTurnEnded = options.onTurnEnded ?? (() => { }); this._shellManager = options.shellManager; - this._nonPtyShellTerminals = this._register(this._instantiationService.createInstance(NonPtyShellTerminalStreams, options.sessionUri)); + this._nonPtyShellTerminals = this._register(this._instantiationService.createInstance(NonPtyShellTerminalStreams, options.sessionUri, options.chatChannelUri)); this._workingDirectory = options.workingDirectory; this._customizationDirectory = options.customizationDirectory; this._serverToolHost = options.serverToolHost; @@ -1964,6 +1965,7 @@ export class CopilotAgentSession extends Disposable { private _createRuntimeAdapter(): ICopilotSessionRuntime { return { + chatUri: this._chatChannelUri, handlePermissionRequest: this._guarded(request => this._handlePermissionRequest(request), { kind: 'reject' } satisfies PermissionRequestResult, 'permission'), handleExitPlanModeRequest: this._guarded((request, invocation) => this._handleExitPlanModeRequest(request, invocation), { approved: false } satisfies CopilotExitPlanModeResponse, 'exit-plan-mode'), handleUserInputRequest: this._guarded((request, invocation) => this._handleUserInputRequest(request, invocation), { answer: '', wasFreeform: true } satisfies UserInputResponse, 'user-input'), @@ -3626,7 +3628,7 @@ export class CopilotAgentSession extends Disposable { this._emitAction({ type: ActionType.ChatInputRequested, - request: { ...inputRequest, purpose: ChatInputRequestPurpose.AskUser }, + request: withChatInputRequestPurpose(inputRequest, ChatInputRequestPurpose.AskUser), }); const result = await pendingInput; @@ -3696,13 +3698,12 @@ export class CopilotAgentSession extends Disposable { const pendingElicitation = this._pendingElicitations.register(requestId, { schema }); - const inputRequest: ChatInputRequest = { + const inputRequest = withChatInputRequestPurpose({ id: requestId, - purpose: ChatInputRequestPurpose.Elicitation, message: context.message, ...(context.mode === 'url' && context.url ? { url: context.url } : {}), ...(questions && questions.length > 0 ? { questions } : {}), - }; + }, ChatInputRequestPurpose.Elicitation); this._emitAction({ type: ActionType.ChatInputRequested, @@ -5276,9 +5277,8 @@ export class CopilotAgentSession extends Disposable { ...(option.recommended ? { default: true } : {}), })); - const inputRequest: ChatInputRequestWithPlanReview = { + const inputRequest: ChatInputRequestWithPlanReview = withChatInputRequestPurpose({ id: requestId, - purpose: ChatInputRequestPurpose.PlanReview, planReview: { title: localize('agentHost.planReview.title', "Review Plan"), content: data.summary || localize('agentHost.planReview.fallbackSummary', "A plan is ready for review."), @@ -5296,7 +5296,7 @@ export class CopilotAgentSession extends Disposable { options, allowFreeformInput: true, }], - }; + }, ChatInputRequestPurpose.PlanReview); const pendingPlanReview = this._pendingPlanReviews.register(requestId, { actions: data.actions, diff --git a/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts b/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts index 08d5af2cb13384..e1d172d6033f52 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts @@ -90,7 +90,7 @@ export interface INonPtyShellToolCompletion { * under the emit cap, a rolling tail past the large-output threshold); this * class preserves the streamed transcript across those lossy rewrites. * - * Created once per session and disposed with it, matching the pty-backed + * Created once per chat and disposed with it, matching the pty-backed * `ShellManager` lifecycle. */ export class NonPtyShellTerminalStreams extends Disposable { @@ -99,6 +99,7 @@ export class NonPtyShellTerminalStreams extends Disposable { constructor( private readonly _sessionUri: URI, + private readonly _chatUri: URI, @IAgentHostTerminalManager private readonly _terminalManager: IAgentHostTerminalManager, ) { super(); @@ -210,9 +211,7 @@ export class NonPtyShellTerminalStreams extends Disposable { } } } - if (result.exitCode !== undefined) { - this._finalize(stream, result.exitCode); - } + this._finalize(stream, result.exitCode); return { uri: stream.uri, result, @@ -235,7 +234,7 @@ export class NonPtyShellTerminalStreams extends Disposable { } } - private _finalize(stream: INonPtyShellStream, exitCode: number): void { + private _finalize(stream: INonPtyShellStream, exitCode: number | undefined): void { if (stream.finalized) { return; } @@ -256,6 +255,7 @@ export class NonPtyShellTerminalStreams extends Disposable { const claim: TerminalSessionClaim = { kind: TerminalClaimKind.Session, session: this._sessionUri.toString(), + chat: this._chatUri.toString(), toolCallId, }; this._terminalManager.createOutputTerminal(stream.uri, { title: stream.title, claim }); diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 1ec052e0506b08..e0c2a9b3b920be 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -181,6 +181,8 @@ export function toSdkToolFilterPatterns(patterns: readonly string[] | undefined) } export interface ICopilotSessionRuntime { + /** Chat channel that owns this session's turns, used to attribute terminal claims. */ + readonly chatUri: URI; handlePermissionRequest(request: PermissionRequest): Promise; handleExitPlanModeRequest(request: ExitPlanModeRequest, invocation: { sessionId: string }): Promise; handleUserInputRequest(request: UserInputRequest, invocation: UserInputInvocation): Promise; @@ -743,7 +745,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { if (!plan.shellManager) { throw new Error(`ShellManager is required to launch Copilot session '${plan.sessionId}'`); } - shellTools = await createShellTools(plan.shellManager, this._terminalManager, this._logService, request => runtime.requestUnsandboxedCommandConfirmation(request)); + shellTools = await createShellTools(plan.shellManager, runtime.chatUri, this._terminalManager, this._logService, request => runtime.requestUnsandboxedCommandConfirmation(request)); } // Rely on the SDK to discover most agents/skills/etc. from `pluginDirectories` // instead of feeding them explicitly, to avoid duplicates. Custom agents are the diff --git a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts index 433d82b066cc88..9c7e3e75b5f7f2 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts @@ -15,7 +15,8 @@ import { IProductService } from '../../../product/common/productService.js'; import { ISandboxHelperService } from '../../../sandbox/common/sandboxHelperService.js'; import type { ITerminalSandboxResolvedNetworkDomains } from '../../../sandbox/common/terminalSandboxService.js'; import { TerminalSandboxEngine } from '../../../sandbox/common/terminalSandboxEngine.js'; -import { TerminalClaimKind, type TerminalSessionClaim } from '../../common/state/protocol/state.js'; +import { TerminalClaimKind, TerminalLifecycleStatus, type TerminalSessionClaim } from '../../common/state/protocol/state.js'; +import { parseRequiredSessionUriFromChatUri } from '../../common/state/sessionState.js'; import { isZsh } from '../agentHostShellUtils.js'; import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; import { createAgentHostSandboxEngine } from './agentHostSandboxEngine.js'; @@ -145,6 +146,7 @@ export class ShellManager extends Disposable { */ async getOrCreateShell( shellType: ShellType, + chat: URI, turnId: string, toolCallId: string, cwd?: string, @@ -153,8 +155,8 @@ export class ShellManager extends Disposable { if (shell.shellType !== shellType || !this._terminalManager.hasTerminal(shell.terminalUri)) { continue; } - const exitCode = this._terminalManager.getExitCode(shell.terminalUri); - if (exitCode !== undefined) { + const lifecycle = this._terminalManager.getTerminalState(shell.terminalUri)?.lifecycle; + if (lifecycle?.status === TerminalLifecycleStatus.Exited) { this._shells.delete(shell.id); continue; } @@ -173,7 +175,10 @@ export class ShellManager extends Disposable { const claim: TerminalSessionClaim = { kind: TerminalClaimKind.Session, - session: this._sessionUri.toString(), + // The chat URI is authoritative: this manager's own scope URI is the + // chat for a peer chat, so the owning session comes from the chat. + session: parseRequiredSessionUriFromChatUri(chat), + chat: chat.toString(), turnId, toolCallId, }; @@ -384,6 +389,7 @@ interface IShutdownShellArgs { */ export async function createShellTools( shellManager: ShellManager, + chat: URI, terminalManager: IAgentHostTerminalManager, logService: ILogService, confirmUnsandboxedExecution?: UnsandboxedCommandConfirmationHandler, @@ -423,6 +429,7 @@ export async function createShellTools( const timeoutMs = args.timeout ?? DEFAULT_SHELL_COMMAND_TIMEOUT_MS; const ref = await shellManager.getOrCreateShell( shellType, + chat, invocation.toolCallId, invocation.toolCallId, ); @@ -604,8 +611,10 @@ export async function createShellTools( return makeSuccessResult('No active shells.'); } const descriptions = shells.map(s => { - const exitCode = terminalManager.getExitCode(s.terminalUri); - const status = exitCode !== undefined ? `exited (${exitCode})` : 'running'; + const lifecycle = terminalManager.getTerminalState(s.terminalUri)?.lifecycle; + const status = lifecycle?.status === TerminalLifecycleStatus.Exited + ? lifecycle.exitCode === undefined ? 'exited' : `exited (${lifecycle.exitCode})` + : 'running'; return `- ${s.id}: ${s.shellType} [${status}]`; }); return makeSuccessResult(descriptions.join('\n')); diff --git a/src/vs/platform/agentHost/node/localCommands/bangLocalCommand.ts b/src/vs/platform/agentHost/node/localCommands/bangLocalCommand.ts index 78370088964da5..bf03e420d26549 100644 --- a/src/vs/platform/agentHost/node/localCommands/bangLocalCommand.ts +++ b/src/vs/platform/agentHost/node/localCommands/bangLocalCommand.ts @@ -84,6 +84,7 @@ export class BangLocalCommand extends Disposable implements ILocalChatCommand { const claim: TerminalSessionClaim = { kind: TerminalClaimKind.Session, session: sessionChannel, + chat: turnChannel, turnId, toolCallId, }; diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index d97a4551f52e4b..fa2c0501d4ad05 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -1341,24 +1341,6 @@ export class ProtocolServerHandler extends Disposable { }, createSession: async (_client, params) => { let createdSession: URI; - // Resolve fork turnId to a 0-based index using the source session's - // turn list in the state manager. - let fork: { session: URI; chat: URI; turnIndex: number; turnId: string } | undefined; - if (params.fork) { - if (URI.parse(params.fork.session).toString() === URI.parse(params.channel).toString()) { - throw new ProtocolError(AhpErrorCodes.SessionAlreadyExists, `Fork target session must differ from source session: ${params.channel}`); - } - const sourceState = this._stateManager.getSessionState(params.fork.session); - if (!sourceState) { - throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Fork source session not found: ${params.fork.session}`); - } - const turnIndex = sourceState.turns.findIndex(t => t.id === params.fork!.turnId); - if (turnIndex < 0) { - throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Fork turn ID ${params.fork.turnId} not found in session ${params.fork.session}`); - } - const sourceSession = URI.parse(params.fork.session); - fork = { session: sourceSession, chat: URI.parse(buildDefaultChatUri(sourceSession)), turnIndex, turnId: params.fork.turnId }; - } // If the client eagerly claimed the active client role, validate // the clientId matches the connection before forwarding. if (params.activeClient && params.activeClient.clientId !== _client.clientId) { @@ -1370,7 +1352,6 @@ export class ProtocolServerHandler extends Disposable { _meta: params._meta, workingDirectories: params.workingDirectories?.map(d => URI.parse(d)), session: URI.parse(params.channel), - fork, config: params.config, activeClient: params.activeClient, progressToken: params.progressToken, @@ -1547,6 +1528,18 @@ export class ProtocolServerHandler extends Disposable { invokeChangesetOperation: async (_client, params) => { return this._agentService.invokeChangesetOperation(params); }, + // Automations are declared by the protocol but not implemented by this + // host: `initialize` never advertises the `automations` capability, so + // a conforming client does not reach these methods. + listAutomationTriggerDefinitions: async () => { + throw new ProtocolError(JsonRpcErrorCodes.MethodNotFound, 'Automations are not supported by this agent host'); + }, + runAutomation: async () => { + throw new ProtocolError(JsonRpcErrorCodes.MethodNotFound, 'Automations are not supported by this agent host'); + }, + fetchAutomationRuns: async () => { + throw new ProtocolError(JsonRpcErrorCodes.MethodNotFound, 'Automations are not supported by this agent host'); + }, }; diff --git a/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts b/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts index 9a954efb246a85..37c591e843f1ab 100644 --- a/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts @@ -10,7 +10,7 @@ import { buildAnnotationsUri } from '../../common/annotationsUri.js'; import type { IAgentServerToolDefinition } from '../../common/agentServerTools.js'; import type { AnnotationsAction } from '../../common/state/sessionActions.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; -import { parseChatUri, type Annotation, type AnnotationsState, type StringOrMarkdown, type TextRange, type ToolDefinition } from '../../common/state/sessionState.js'; +import { parseChatUri, type Annotation, type AnnotationOrigin, type AnnotationsState, type StringOrMarkdown, type TextRange, type ToolDefinition } from '../../common/state/sessionState.js'; import type { AgentHostStateManager } from '../agentHostStateManager.js'; import type { IServerToolDisplay, IServerToolDisplayResult, IServerToolGroup } from './agentServerToolHost.js'; @@ -454,7 +454,7 @@ export interface IFeedbackToolOutcome { * * @throws if {@link toolName} is unknown or the arguments are invalid. */ -export function applyFeedbackTool(state: AnnotationsState, sessionResource: string, toolName: string, rawArgs: unknown): IFeedbackToolOutcome { +export function applyFeedbackTool(state: AnnotationsState, sessionResource: string, toolName: string, rawArgs: unknown, origin: AnnotationOrigin = { session: sessionResource }): IFeedbackToolOutcome { switch (toolName) { case addCommentToolName: { const { resourceUri, range, text } = getAddCommentArgs(rawArgs); @@ -464,7 +464,7 @@ export function applyFeedbackTool(state: AnnotationsState, sessionResource: stri const meta: IFeedbackAnnotationMeta = { kind: 'codeReview', state: 'created', sessionResource }; const annotation: Annotation = { id, - turnId: '', + origin, resource: resourceUri, range: toTextRange(range), resolved: false, @@ -665,7 +665,12 @@ export const feedbackServerToolGroup: IServerToolGroup = { }, execute(stateManager, context, toolName, rawArgs): string { const { mainSessionUri, annotationsUri, state } = getFeedbackToolState(stateManager, context.chatUri); - const outcome = applyFeedbackTool(state, mainSessionUri, toolName, rawArgs); + const turnId = stateManager.getChatState(context.chatUri)?.activeTurn?.id; + const outcome = applyFeedbackTool(state, mainSessionUri, toolName, rawArgs, { + session: mainSessionUri, + chat: context.chatUri, + ...(turnId ? { turnId } : {}), + }); for (const action of outcome.actions) { stateManager.dispatchServerAction(annotationsUri, action); } diff --git a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts index 10a7c0343c09ce..5b8ba8cb6be79b 100644 --- a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts +++ b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts @@ -9,7 +9,7 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { buildAnnotationsUri } from '../../common/annotationsUri.js'; import { ActionType, type ActionEnvelope, type ClientChangesetAction } from '../../common/state/sessionActions.js'; -import { ChangesetStatus, MessageKind, SessionLifecycle, SessionStatus, TerminalClaimKind, TurnState, type AnnotationsState, type ChangesetState, type RootState, type SessionState, type SessionSummary, type TerminalState } from '../../common/state/protocol/state.js'; +import { ChangesetStatus, MessageKind, SessionLifecycle, SessionStatus, TerminalClaimKind, TerminalLifecycleStatus, TurnState, type AnnotationsState, type ChangesetState, type RootState, type SessionState, type SessionSummary, type TerminalState } from '../../common/state/protocol/state.js'; import { buildDefaultChatUri, createChatState, createDefaultChatSummary, ROOT_STATE_URI, StateComponents, type ChatState } from '../../common/state/sessionState.js'; import { AgentSubscriptionManager, ChangesetStateSubscription, ChatStateSubscription, isActionEnvelopeRelevantToSubscriptionUris, RootStateSubscription, SessionStateSubscription, TerminalStateSubscription } from '../../common/state/agentSubscription.js'; @@ -61,6 +61,7 @@ function makeTerminalState(overrides?: Partial): TerminalState { title: 'bash', content: [], claim: { kind: TerminalClaimKind.Client, clientId: 'c1' }, + lifecycle: { status: TerminalLifecycleStatus.Running }, ...overrides, }; } @@ -1059,7 +1060,7 @@ suite('AgentSubscriptionManager', () => { await new Promise(r => setTimeout(r, 0)); const annotation = { id: 'feedback-1', - turnId: 'turn-1', + origin: { session: sessionUri, chat: chatUri, turnId: 'turn-1' }, resource: 'file:///reviewed.ts', resolved: false, entries: [{ id: 'feedback-1:0', text: 'Please revisit this.' }], diff --git a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index 54eef4d48d414e..626226f39519fb 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -27,7 +27,7 @@ import { ActionType, type ChatTurnStartedAction, type SessionActiveClientSetActi import { ProtocolError, type AhpServerNotification, type JsonRpcNotification, type JsonRpcRequest, type JsonRpcResponse, type ProtocolMessage } from '../../common/state/sessionProtocol.js'; import { hasKey } from '../../../../base/common/types.js'; import { mainWindow } from '../../../../base/browser/window.js'; -import { buildDefaultChatUri, CustomizationType, MessageAttachmentKind, MessageKind, PendingMessageKind, readSessionExternal, readSessionWorkspaceless, ROOT_STATE_URI, SessionStatus, StateComponents, customizationId, withSessionExternal, withSessionWorkspaceless } from '../../common/state/sessionState.js'; +import { CustomizationType, MessageAttachmentKind, MessageKind, PendingMessageKind, readSessionExternal, readSessionWorkspaceless, ROOT_STATE_URI, SessionStatus, StateComponents, customizationId, withSessionExternal, withSessionWorkspaceless } from '../../common/state/sessionState.js'; import { NonReconnectableTransportError, type IClientTransport, type IProtocolTransport } from '../../common/state/sessionTransport.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; import { ITelemetryService, TelemetryConfiguration, TelemetryLevel, TELEMETRY_SETTING_ID } from '../../../telemetry/common/telemetry.js'; @@ -616,16 +616,14 @@ suite('AgentHostProtocolClient', () => { await Promise.all([completionTriggerCharacters, connectError]); }); - test('maps protocol-supported create session fork and progress token', async () => { + test('maps create session metadata and progress token', async () => { const { client, transport } = createClient(); await connectClient(client, transport); const session = URI.parse('ahp-session:/new'); - const source = URI.parse('ahp-session:/source'); const creation = client.createSession({ provider: 'copilot', session, _meta: { multiRoot: { workspaceFile: 'file:///demo.code-workspace' } }, - fork: { session: source, chat: URI.parse(buildDefaultChatUri(source)), turnIndex: 2, turnId: 'turn-2' }, progressToken: 'progress-token', }); @@ -636,7 +634,6 @@ suite('AgentHostProtocolClient', () => { _meta: { multiRoot: { workspaceFile: 'file:///demo.code-workspace' } }, provider: 'copilot', workingDirectories: undefined, - fork: { session: source.toString(), turnId: 'turn-2' }, config: undefined, activeClient: undefined, progressToken: 'progress-token', @@ -2401,7 +2398,7 @@ suite('AgentHostProtocolClient', () => { type: ActionType.AnnotationsSet, annotation: { id: 'feedback-1', - turnId: 'turn-after-restart', + origin: { session: sessionUri.toString(), turnId: 'turn-after-restart' }, resource: 'file:///reviewed.ts', resolved: false, entries: [{ id: 'feedback-1:0', text: 'Please revisit this.' }], diff --git a/src/vs/platform/agentHost/test/node/agentFeedbackServerTools.test.ts b/src/vs/platform/agentHost/test/node/agentFeedbackServerTools.test.ts index 42e1aec7d4148a..e86cc360425d89 100644 --- a/src/vs/platform/agentHost/test/node/agentFeedbackServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/agentFeedbackServerTools.test.ts @@ -34,7 +34,7 @@ suite('AgentFeedbackServerTools', () => { function annotation(id: string, state: string, resolved = false, text = 'comment', kind = 'codeReview', pendingAgentReveal = false): Annotation { return { id, - turnId: '', + origin: { session: sessionResource }, resource: fileUri, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 4 } }, resolved, @@ -72,7 +72,7 @@ suite('AgentFeedbackServerTools', () => { test('listComments reports unknown provenance rather than assuming the user', () => { const orphan: Annotation = { id: 'a', - turnId: '', + origin: { session: sessionResource }, resource: fileUri, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 4 } }, resolved: false, @@ -329,7 +329,7 @@ suite('AgentFeedbackServerTools', () => { // than mutating it. const foreign: Annotation = { id: 'foreign', - turnId: '', + origin: { session: sessionResource }, resource: fileUri, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 4 } }, resolved: false, diff --git a/src/vs/platform/agentHost/test/node/agentHostInputRequestTracker.test.ts b/src/vs/platform/agentHost/test/node/agentHostInputRequestTracker.test.ts index 88862cd427c90d..43e75a71bcb490 100644 --- a/src/vs/platform/agentHost/test/node/agentHostInputRequestTracker.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostInputRequestTracker.test.ts @@ -9,8 +9,9 @@ import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/tel import { AgentSession } from '../../common/agent.js'; import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, type IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; +import { ChatInputRequestPurpose, withChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js'; import { ActionType, type ChatInputCompletedAction } from '../../common/state/sessionActions.js'; -import { buildDefaultChatUri, buildSubagentChatUri, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputRequestPurpose, ChatInputResponseKind, ChatOriginKind, MessageKind, ResponsePartKind, SessionStatus, type ChatInputAnswer, type ChatInputRequest, type ChatState } from '../../common/state/sessionState.js'; +import { buildDefaultChatUri, buildSubagentChatUri, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ChatOriginKind, MessageKind, ResponsePartKind, SessionStatus, type ChatInputAnswer, type ChatInputRequest, type ChatState } from '../../common/state/sessionState.js'; import { AgentHostInputRequestTracker } from '../../node/agentHostInputRequestTracker.js'; import { AgentHostTelemetryReporter } from '../../node/agentHostTelemetryReporter.js'; @@ -85,9 +86,8 @@ suite('AgentHostInputRequestTracker', () => { machineId: 'client-machine-id', devDeviceId: 'client-dev-device-id', }); - const request: ChatInputRequest = { + const request: ChatInputRequest = withChatInputRequestPurpose({ id: 'request-1', - purpose: ChatInputRequestPurpose.AskUser, questions: [ { id: 'text', kind: ChatInputQuestionKind.Text, message: 'Text?' }, { id: 'selected', kind: ChatInputQuestionKind.SingleSelect, message: 'Select?', options: [{ id: 'recommended', label: 'Recommended', recommended: true }] }, @@ -97,7 +97,7 @@ suite('AgentHostInputRequestTracker', () => { { id: 'skipped', kind: ChatInputQuestionKind.Text, message: 'Skip?' }, { id: 'missing', kind: ChatInputQuestionKind.Text, message: 'Missing?' }, ], - }; + }, ChatInputRequestPurpose.AskUser); const answers: Record = { text: { state: ChatInputAnswerState.Submitted, value: { kind: ChatInputAnswerValueKind.Text, value: 'value' } }, selected: { state: ChatInputAnswerState.Submitted, value: { kind: ChatInputAnswerValueKind.Selected, value: 'recommended' } }, @@ -141,19 +141,17 @@ suite('AgentHostInputRequestTracker', () => { const startedAt = now; return { elapsed: () => now - startedAt }; }); - const initial: ChatInputRequest = { + const initial: ChatInputRequest = withChatInputRequestPurpose({ id: 'request-1', - purpose: ChatInputRequestPurpose.AskUser, questions: [{ id: 'old', kind: ChatInputQuestionKind.Text, message: 'Old?' }], - }; - const replacement: ChatInputRequest = { + }, ChatInputRequestPurpose.AskUser); + const replacement: ChatInputRequest = withChatInputRequestPurpose({ id: 'request-1', - purpose: ChatInputRequestPurpose.AskUser, questions: [ { id: 'new-1', kind: ChatInputQuestionKind.Text, message: 'New?' }, { id: 'new-2', kind: ChatInputQuestionKind.Text, message: 'Another?' }, ], - }; + }, ChatInputRequestPurpose.AskUser); tracker.inputRequested('mock', rootChat, 'turn-1', initial); now = 5; @@ -181,16 +179,16 @@ suite('AgentHostInputRequestTracker', () => { test('decline, cancellation, non-ask purposes, missing active turns, and duplicate completion do not emit', () => { const { telemetry, tracker } = createTracker(); - const ask: ChatInputRequest = { id: 'ask', purpose: ChatInputRequestPurpose.AskUser, questions: [] }; + const ask: ChatInputRequest = withChatInputRequestPurpose({ id: 'ask', questions: [] }, ChatInputRequestPurpose.AskUser); const state = completedState(rootChat, 'turn-1', ask); tracker.inputRequested('mock', rootChat, 'turn-1', ask); tracker.inputCompleted(rootChat, { ...accept(ask.id), response: ChatInputResponseKind.Decline }, state); tracker.inputRequested('mock', rootChat, 'turn-1', { ...ask, id: 'cancel' }); tracker.inputCompleted(rootChat, { ...accept('cancel'), response: ChatInputResponseKind.Cancel }, state); - tracker.inputRequested('mock', rootChat, 'turn-1', { ...ask, id: 'elicitation', purpose: ChatInputRequestPurpose.Elicitation }); - tracker.inputRequested('mock', rootChat, 'turn-1', { ...ask, id: 'plan', purpose: ChatInputRequestPurpose.PlanReview }); - tracker.inputRequested('mock', rootChat, 'turn-1', { ...ask, id: 'legacy', purpose: undefined }); + tracker.inputRequested('mock', rootChat, 'turn-1', withChatInputRequestPurpose({ ...ask, id: 'elicitation' }, ChatInputRequestPurpose.Elicitation)); + tracker.inputRequested('mock', rootChat, 'turn-1', withChatInputRequestPurpose({ ...ask, id: 'plan' }, ChatInputRequestPurpose.PlanReview)); + tracker.inputRequested('mock', rootChat, 'turn-1', { id: 'legacy', questions: [] }); tracker.inputRequested('mock', rootChat, 'turn-1', { ...ask, id: 'missing-turn' }); tracker.inputCompleted(rootChat, accept('missing-turn'), { ...state, activeTurn: undefined }); tracker.inputRequested('mock', rootChat, 'turn-1', { ...ask, id: 'duplicate' }); @@ -202,7 +200,7 @@ suite('AgentHostInputRequestTracker', () => { test('turn, session, and tracker cleanup drop pending requests', () => { const { telemetry, tracker } = createTracker(); - const request: ChatInputRequest = { id: 'request-1', purpose: ChatInputRequestPurpose.AskUser, questions: [] }; + const request: ChatInputRequest = withChatInputRequestPurpose({ id: 'request-1', questions: [] }, ChatInputRequestPurpose.AskUser); tracker.inputRequested('mock', rootChat, 'turn-1', request); tracker.clearTurn(rootChat, 'turn-1'); @@ -225,7 +223,7 @@ suite('AgentHostInputRequestTracker', () => { test('emits subagent identifiers', () => { const { telemetry, tracker } = createTracker(); - const request: ChatInputRequest = { id: 'request-1', purpose: ChatInputRequestPurpose.AskUser, questions: [] }; + const request: ChatInputRequest = withChatInputRequestPurpose({ id: 'request-1', questions: [] }, ChatInputRequestPurpose.AskUser); tracker.inputRequested('mock', subagentChat, 'turn-1', request); tracker.inputCompleted(subagentChat, accept(request.id), completedState(subagentChat, 'turn-1', request)); diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 3f1a9c2b60353a..f250d5e427218a 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -10,13 +10,14 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { NullLogService } from '../../../log/common/log.js'; import { ActionType, NotificationType, type ActionEnvelope, type INotification } from '../../common/state/sessionActions.js'; -import { ChatInputQuestionKind, ChatInputRequestPurpose, ChatInputResponseKind, MessageKind, SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, buildSubagentSessionUriPrefix, isSubagentSession, mergeSessionWithDefaultChat, parseSubagentSessionUri, readHostBuildInfo, readSessionEhcliAdoptable, withSessionEhcliAdoptable, type ChatState, type MarkdownResponsePart, type SessionState, type Turn } from '../../common/state/sessionState.js'; +import { ChatInputQuestionKind, ChatInputResponseKind, MessageKind, SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, buildSubagentSessionUriPrefix, isSubagentSession, mergeSessionWithDefaultChat, parseSubagentSessionUri, readHostBuildInfo, readSessionEhcliAdoptable, withSessionEhcliAdoptable, type ChatState, type MarkdownResponsePart, type SessionState, type Turn } from '../../common/state/sessionState.js'; import { type SessionSummaryChangedParams } from '../../common/state/protocol/notifications.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { buildChangesetUri, buildSessionChangesetUri } from '../../common/changesetUri.js'; import { withAgentCustomizationSettings } from '../../common/agentCustomizationSettings.js'; import { buildAnnotationsUri } from '../../common/annotationsUri.js'; import { withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; +import { ChatInputRequestPurpose, withChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js'; suite('AgentHostStateManager', () => { @@ -1578,11 +1579,10 @@ suite('AgentHostStateManager', () => { }); manager.dispatchServerAction(defaultChat, { type: ActionType.ChatInputRequested, - request: { + request: withChatInputRequestPurpose({ id: 'request', - purpose: ChatInputRequestPurpose.AskUser, questions: [{ kind: ChatInputQuestionKind.Text, id: 'question', message: 'Continue?' }], - }, + }, ChatInputRequestPurpose.AskUser), }); manager.dispatchServerAction(defaultChat, { type: ActionType.ChatInputCompleted, diff --git a/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts index 0067d3b7c29659..a351a29f663bc7 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts @@ -12,7 +12,8 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; import { ActionType, StateAction } from '../../common/state/protocol/actions.js'; -import { TerminalClaimKind, TerminalContentPart, type TerminalClaim } from '../../common/state/protocol/state.js'; +import { TerminalClaimKind, TerminalContentPart, TerminalLifecycleStatus, type TerminalClaim } from '../../common/state/protocol/state.js'; +import { buildDefaultChatUri } from '../../common/state/sessionState.js'; import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentHostTerminalManager, formatTerminalText, removeTerminalQueriesSuppressedFromClient, type ITerminalQueryFilterState } from '../../node/agentHostTerminalManager.js'; @@ -390,6 +391,7 @@ suite('AgentHostTerminalManager – command detection integration', () => { const zshSessionManager = await createTestTerminal('zsh-session-fixups', '/bin/zsh', { kind: TerminalClaimKind.Session, session: 'copilot:/session-1', + chat: buildDefaultChatUri('copilot:/session-1'), turnId: 'turn-1', toolCallId: 'tool-1', }, { preventShellHistory: true }); @@ -405,6 +407,7 @@ suite('AgentHostTerminalManager – command detection integration', () => { const bashSessionManager = await createTestTerminal('bash-session-history', '/bin/bash', { kind: TerminalClaimKind.Session, session: 'copilot:/session-1', + chat: buildDefaultChatUri('copilot:/session-1'), turnId: 'turn-1', toolCallId: 'tool-2', }, { preventShellHistory: true, nonInteractive: true }); @@ -861,7 +864,12 @@ suite('AgentHostTerminalManager – output-only terminals', () => { test('streams appended data, snapshots state with isPty false, and records the exit', () => { const { manager, stateManager } = createManager(); const uri = 'agenthost-terminal://shell/copilotNonPtyShells/tc-1'; - const claim: TerminalClaim = { kind: TerminalClaimKind.Session, session: 'agent-session://copilot/s1', toolCallId: 'tc-1' }; + const claim: TerminalClaim = { + kind: TerminalClaimKind.Session, + session: 'agent-session://copilot/s1', + chat: buildDefaultChatUri('agent-session://copilot/s1'), + toolCallId: 'tc-1', + }; const dispatched: StateAction[] = []; disposables.add(stateManager.onDidEmitEnvelope(envelope => { if (envelope.channel === uri) { @@ -878,7 +886,7 @@ suite('AgentHostTerminalManager – output-only terminals', () => { assert.deepStrictEqual(manager.getTerminalState(uri), { title: 'Run Shell Command', content: [{ type: 'unclassified', value: 'tick 1\ntick 2\n' }], - exitCode: 0, + lifecycle: { status: TerminalLifecycleStatus.Exited, exitCode: 0 }, claim, isPty: false, }); @@ -902,7 +910,14 @@ suite('AgentHostTerminalManager – output-only terminals', () => { } })); - manager.createOutputTerminal(uri, { title: 'Bash', claim: { kind: TerminalClaimKind.Session, session: 'agent-session://copilot/s1' } }); + manager.createOutputTerminal(uri, { + title: 'Bash', + claim: { + kind: TerminalClaimKind.Session, + session: 'agent-session://copilot/s1', + chat: buildDefaultChatUri('agent-session://copilot/s1'), + }, + }); manager.appendOutputTerminalData(uri, 'old output'); manager.resetOutputTerminal(uri); manager.appendOutputTerminalData(uri, 'fresh output'); @@ -914,4 +929,29 @@ suite('AgentHostTerminalManager – output-only terminals', () => { assert.strictEqual(manager.hasTerminal(uri), false); assert.strictEqual(manager.getTerminalState(uri), undefined); }); + + test('records an output-only terminal exit without an exit code', () => { + const { manager, stateManager } = createManager(); + const uri = 'agenthost-terminal://shell/copilotNonPtyShells/tc-3'; + const dispatched: StateAction[] = []; + disposables.add(stateManager.onDidEmitEnvelope(envelope => { + if (envelope.channel === uri) { + dispatched.push(envelope.action); + } + })); + + manager.createOutputTerminal(uri, { + title: 'Bash', + claim: { kind: TerminalClaimKind.Client, clientId: 'test-client' }, + }); + manager.finalizeOutputTerminal(uri, undefined); + + assert.deepStrictEqual({ + lifecycle: manager.getTerminalState(uri)?.lifecycle, + dispatched, + }, { + lifecycle: { status: TerminalLifecycleStatus.Exited }, + dispatched: [{ type: ActionType.TerminalExited }], + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index f69081aa4a07fc..4c6be256778411 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -137,16 +137,8 @@ function sessionConfigToChatOptions(config: IAgentCreateSessionConfig): IAgentCr workingDirectories: config.workingDirectories, config: config.config, activeClient: config.activeClient, - deferBacking: !config.fork && !config.importConversation, + deferBacking: !config.importConversation, importConversation: config.importConversation, - ...(config.fork ? { - fork: { - source: config.fork.chat, - turnIndex: config.fork.turnIndex, - turnId: config.fork.turnId, - turnIdMapping: config.fork.turnIdMapping, - }, - } : {}), }; } @@ -919,7 +911,7 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('createSession validates, exposes, persists, and inherits multi-root metadata', async () => { + test('createSession validates, exposes, and persists multi-root metadata', async () => { const db = new TestSessionDatabase(); const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = new MockAgent('copilot'); @@ -939,43 +931,23 @@ suite('AgentService (node dispatcher)', () => { workingDirectories: [URI.file('/workspace/one'), URI.file('/workspace/two')], _meta: { github, multiRoot, ignored: 'client value' }, }); - const sourceChat = buildDefaultChatUri(session.toString()); - localService.dispatchAction(sourceChat, { - type: ActionType.ChatTurnStarted, - turnId: 'source-turn', - startedAt: new Date().toISOString(), - message: { text: 'hello', origin: { kind: MessageKind.User } }, - }, 'test-client', 1); - localService.dispatchAction(sourceChat, { - type: ActionType.ChatTurnComplete, - turnId: 'source-turn', - duration: 0, - }, 'test-client', 2); - const inherited = await localService.createSession({ - provider: agent.id, - _meta: { multiRoot: { workspaceFile: 'relative.code-workspace' } }, - fork: { session, chat: URI.parse(sourceChat), turnIndex: 0, turnId: 'source-turn' }, - }); const override = { workspaceFile: 'file:///work/override.code-workspace', }; const overridden = await localService.createSession({ provider: agent.id, _meta: { multiRoot: override }, - fork: { session, chat: URI.parse(sourceChat), turnIndex: 0, turnId: 'source-turn' }, }); assert.deepStrictEqual({ state: localService.stateManager.getSessionState(session.toString())?._meta, persisted: await db.getMetadata(SESSION_META_MULTI_ROOT_KEY), github: readSessionGitHubState(localService.stateManager.getSessionState(session.toString())?._meta), - inherited: readSessionMultiRootMetadata(localService.stateManager.getSessionState(inherited.toString())?._meta), overridden: readSessionMultiRootMetadata(localService.stateManager.getSessionState(overridden.toString())?._meta), }, { state: { github, multiRoot }, persisted: JSON.stringify(override), github, - inherited: multiRoot, overridden: override, }); }); @@ -2095,17 +2067,6 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(copilotApiService.utilityCalls.length, 0); await waitForCondition(async () => await db.getMetadata(SESSION_CUSTOM_TITLE_SOURCE_KEY) === AGENT_HOST_TITLE_SOURCE_AUTO, 'active-agent fallback provenance should be persisted'); - svc.dispatchAction( - buildDefaultChatUri(session.toString()), - { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1 }, - 'test-client', 2, - ); - const forked = await svc.createSession({ - provider: 'copilot', - fork: { session, chat: URI.parse(buildDefaultChatUri(session)), turnIndex: 0, turnId: 'turn-1' }, - }); - assert.strictEqual(svc.stateManager.getSessionState(forked.toString())?.title, `Forked: ${title}`); - assert.strictEqual(copilotApiService.utilityCalls.length, 0); }); test('leaves fallback title when AI title generation fails', async () => { @@ -2189,51 +2150,6 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('generates an AI title for forked sessions from the forked chat', async () => { - const copilotApiService = new TestCopilotApiService(); - copilotApiService.response = 'Source generated title'; - const { svc, session: sourceSession } = await setupTitleGeneration(copilotApiService); - - svc.dispatchAction( - buildDefaultChatUri(sourceSession.toString()), - { type: ActionType.ChatTurnStarted, turnId: 'source-turn', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'Seed fork title', origin: { kind: MessageKind.User } } }, - 'test-client', 1, - ); - await waitForCondition(() => svc.stateManager.getSessionState(sourceSession.toString())?.title === 'Source generated title', 'source generated title should be applied'); - svc.dispatchAction( - buildDefaultChatUri(sourceSession.toString()), - { type: ActionType.ChatTurnComplete, turnId: 'source-turn', duration: 1000 }, - 'test-client', 2, - ); - await waitForCondition(() => (svc.stateManager.getSessionState(sourceSession.toString())?.turns.length ?? 0) === 1, 'source turn should be complete before forking'); - - // The fork inherits a `Forked: …` placeholder, then regenerates a - // content-derived title from the copied chat. - copilotApiService.response = 'Forked branch title'; - const forkedSession = await svc.createSession({ - provider: 'copilot', - fork: { - session: sourceSession, - chat: URI.parse(buildDefaultChatUri(sourceSession)), - turnIndex: 0, - turnId: 'source-turn', - }, - }); - await waitForCondition(() => svc.stateManager.getSessionState(forkedSession.toString())?.title === 'Forked branch title', 'forked session should get a content-generated title'); - - const forkedCall = copilotApiService.utilityCalls[copilotApiService.utilityCalls.length - 1]; - const userMessage = forkedCall.request.messages.find(message => message.role === 'user')?.content ?? ''; - assert.deepStrictEqual({ - title: svc.stateManager.getSessionState(forkedSession.toString())?.title, - utilityCalls: copilotApiService.utilityCalls.length, - includesForkedChat: userMessage.includes('Seed fork title'), - }, { - title: 'Forked branch title', - utilityCalls: 2, - includesForkedChat: true, - }); - }); - test('generates a utility title for imported conversations when active-agent naming is disabled', async () => { const copilotApiService = new TestCopilotApiService(); copilotApiService.response = 'Imported conversation title'; @@ -6017,7 +5933,7 @@ suite('AgentService (node dispatcher)', () => { const annotationsUri = buildAnnotationsUri(session.toString()); const annotation = { id: 'feedback-1', - turnId: 'turn-1', + origin: { session: session.toString(), turnId: 'turn-1' }, resource: URI.file('/workspace/reviewed.ts').toString(), resolved: false, entries: [{ id: 'feedback-1:0', text: 'Please revisit this.' }], @@ -6035,6 +5951,40 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual(restored.state, { annotations: [annotation] }); }); + test('annotations persisted before the origin migration are restored', async () => { + const sessionData = createPerSessionDataService(); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = new MockAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + localService.registerProvider(agent); + const session = await localService.createSession({ provider: 'copilot' }); + const annotationsUri = buildAnnotationsUri(session.toString()); + // The shape written before annotations carried an origin: a + // top-level `turnId` and no owning session. + await sessionData.database(session).setMetadata('annotations', JSON.stringify({ + annotations: [{ + id: 'feedback-1', + turnId: 'turn-1', + resource: URI.file('/workspace/reviewed.ts').toString(), + resolved: false, + entries: [{ id: 'feedback-1:0', text: 'Please revisit this.' }], + }], + })); + localService.stateManager.deleteSession(session.toString()); + + const restored = await localService.subscribe(URI.parse(annotationsUri), 'client-after-upgrade'); + + assert.deepStrictEqual(restored.state, { + annotations: [{ + id: 'feedback-1', + origin: { session: session.toString(), turnId: 'turn-1' }, + resource: URI.file('/workspace/reviewed.ts').toString(), + resolved: false, + entries: [{ id: 'feedback-1:0', text: 'Please revisit this.' }], + }], + }); + }); + test('annotations subscribe concurrent with session restore returns persisted feedback', async () => { const sessionData = createPerSessionDataService(); const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); @@ -6045,7 +5995,7 @@ suite('AgentService (node dispatcher)', () => { const annotationsUri = buildAnnotationsUri(session.toString()); const annotation = { id: 'feedback-1', - turnId: 'turn-1', + origin: { session: session.toString(), turnId: 'turn-1' }, resource: URI.file('/workspace/reviewed.ts').toString(), resolved: false, entries: [{ id: 'feedback-1:0', text: 'Please revisit this.' }], @@ -6091,7 +6041,7 @@ suite('AgentService (node dispatcher)', () => { type: ActionType.AnnotationsSet, annotation: { id: 'feedback-1', - turnId: 'turn-1', + origin: { session: subagent, turnId: 'turn-1' }, resource: URI.file('/workspace/reviewed.ts').toString(), resolved: false, entries: [{ id: 'feedback-1:0', text: 'Please revisit this.' }], @@ -12894,44 +12844,6 @@ suite('AgentService (node dispatcher)', () => { assertBackingChangesetsComputing(service.stateManager, sessionStr); }); - test('forked createSession seeds both halves on the forked session', async () => { - service.registerProvider(copilotAgent); - - // Set up a source session with at least one completed turn. The - // fork path at agentService.ts:493-504 intentionally drops - // `config.fork` when the source has zero turns and falls through - // to the non-fork create path; without this prelude the test - // would silently exercise the non-fork branch and pass vacuously. - const sourceSession = await service.createSession({ provider: 'copilot' }); - const sourceState = service.stateManager.getSessionState(sourceSession.toString())!; - const sourceTurnId = 'turn-src-1'; - sourceState.turns = [{ - id: sourceTurnId, - state: TurnState.Complete, - message: { text: 'hi', origin: { kind: MessageKind.User } }, - responseParts: [], - usage: undefined, - }]; - - const forked = await service.createSession({ - provider: 'copilot', - fork: { session: sourceSession, chat: URI.parse(buildDefaultChatUri(sourceSession)), turnIndex: 0, turnId: sourceTurnId }, - }); - assert.notStrictEqual(forked.toString(), sourceSession.toString(), 'fork should produce a distinct session URI'); - const forkedStr = forked.toString(); - assert.strictEqual(copilotAgent.lastCreateSessionConfig?.fork?.chat?.toString(), buildDefaultChatUri(sourceSession)); - - const forkedState = service.stateManager.getSessionState(forkedStr); - assert.ok(forkedState); - assert.deepStrictEqual(forkedState!.changesets, defaultCatalogue(forkedStr)); - // Note: source-session turn was seeded directly on state, so the - // reducer never saw a ChatTurnStarted/Complete pair for it; - // the fork branch (agentService.ts:548 path) is still exercised - // because `config.fork` survives the L493-504 turn-count check. - assert.ok(forkedState!.turns.length > 0, 'forked session should carry copied turns'); - assertBackingChangesetsComputing(service.stateManager, forkedStr); - }); - test('provisional session materialization preserves both halves', async () => { // Custom mock that returns `provisional: true` and exposes a hook // to fire `onDidMaterializeChat` later, simulating the diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 2614d34e0dc217..663af6eb74a750 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -28,7 +28,7 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import type { RootConfigChangedAction } from '../../common/state/protocol/actions.js'; import { ChangesSummary, ChatOriginKind, CustomizationEnablementKind, CustomizationType, McpAuthRequiredReason, McpServerStatus, SessionInputRequestKind } from '../../common/state/protocol/state.js'; import { ActionType, ActionEnvelope, AuthRequiredReason, type ChatAction, type INotification, type SessionAction } from '../../common/state/sessionActions.js'; -import { buildSubagentChatUri, buildChatUri, buildDefaultChatUri, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputRequestPurpose, ChatInteractivity, CustomizationLoadStatus, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ROOT_STATE_URI, SessionInputResponseKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, customizationId, type ChatInputRequest, type ClientPluginCustomization, type Customization, type PluginCustomization, type Turn } from '../../common/state/sessionState.js'; +import { buildSubagentChatUri, buildChatUri, buildDefaultChatUri, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInteractivity, CustomizationLoadStatus, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ROOT_STATE_URI, SessionInputResponseKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, customizationId, type ChatInputRequest, type ClientPluginCustomization, type Customization, type PluginCustomization, type Turn } from '../../common/state/sessionState.js'; import { IProductService } from '../../../product/common/productService.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; @@ -47,6 +47,7 @@ import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.j import { SessionDatabase } from '../../node/sessionDatabase.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; +import { ChatInputRequestPurpose, withChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js'; import { AgentHostCustomizationEnablementService, IAgentHostCustomizationEnablementService } from '../../node/agentHostCustomizationEnablementService.js'; import { AgentHostStorageService } from '../../node/agentHostStorageService.js'; import { applyMcpServerEnablement } from '../../node/shared/mcpCustomizationController.js'; @@ -1271,7 +1272,7 @@ suite('AgentSideEffects', () => { }, { chatError: true, creationFailed: true, - lifecycle: SessionLifecycle.CreationFailed, + lifecycle: SessionLifecycle.Failed, sessionAddedWithError: true, }); }); @@ -1308,7 +1309,7 @@ suite('AgentSideEffects', () => { }, { chatError: true, creationFailed: true, - lifecycle: SessionLifecycle.CreationFailed, + lifecycle: SessionLifecycle.Failed, sendMessageCalls: [], }); }); @@ -6149,11 +6150,10 @@ suite('AgentSideEffects', () => { stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatInputRequested, - request: { + request: withChatInputRequestPurpose({ id: 'req-1', - purpose: ChatInputRequestPurpose.AskUser, questions: [{ kind: ChatInputQuestionKind.Text, id: 'question-1', message: 'Which value?' }], - }, + }, ChatInputRequestPurpose.AskUser), }); stateManager.dispatchClientAction(defaultChatUri, { type: ActionType.ChatInputAnswerChanged, @@ -6200,11 +6200,10 @@ suite('AgentSideEffects', () => { setupSession(); startTurn('turn-1'); - const request: ChatInputRequest = { + const request: ChatInputRequest = withChatInputRequestPurpose({ id: 'req-1', - purpose: ChatInputRequestPurpose.AskUser, questions: [{ kind: ChatInputQuestionKind.Text, id: 'question-1', message: 'Which value?' }], - }; + }, ChatInputRequestPurpose.AskUser); stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatInputRequested, request, diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts index 1ba7a034e411ff..5347137a6f951e 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts @@ -669,7 +669,7 @@ async function createSession(agent: ClaudeAgent, config: IAgentCreateSessionConf workingDirectories: config.workingDirectories, config: config.config, activeClient: config.activeClient, - deferBacking: !config.fork && !config.importConversation, + deferBacking: !config.importConversation, importConversation: config.importConversation, }); if (!created?.backingSession) { diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index c969f4f486a1cf..87d4340d0603cb 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -49,13 +49,14 @@ import { IActiveClient, IAgent, IAgentChatContext, IAgentChatDataChange, IAgentC import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostClaudeMultiRootEnabledConfigKey, AgentHostGitHubMcpServerEnabledConfigKey } from '../../common/agentHostSchema.js'; import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js'; import { AgentFeedbackAttachmentDisplayKind } from '../../common/meta/agentFeedbackAttachments.js'; +import { ChatInputRequestPurpose, readChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js'; import { toClientPluginMcpDefaultCwdsMeta } from '../../common/meta/clientPluginCustomizationMeta.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { CustomizationLoadStatus, CustomizationType, MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputResponseKind, SessionStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isDefaultChatUri, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, type ClientPluginCustomization, type Customization, type PluginCustomization } from '../../common/state/sessionState.js'; import { McpServerStatus as McpCustomizationServerStatus, type ChildCustomization, type CustomizationEnablement, type McpServerCustomization } from '../../common/state/protocol/channels-session/state.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; -import { ChatOriginKind, CustomizationEnablementKind, ProtectedResourceMetadata, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputRequestPurpose, ToolCallStatus, type SessionConfigState, type ChatInputRequest, type ToolDefinition } from '../../common/state/protocol/state.js'; +import { ChatOriginKind, CustomizationEnablementKind, ProtectedResourceMetadata, ChatInputAnswerState, ChatInputAnswerValueKind, ToolCallStatus, type SessionConfigState, type ChatInputRequest, type ToolDefinition } from '../../common/state/protocol/state.js'; import { IAgentHostGitService } from '../../common/agentHostGitService.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; import { IAgentServerToolHost } from '../../common/agentServerTools.js'; @@ -172,10 +173,10 @@ function chatContext(chat: URI, overrides?: Partial): IAgentC * conversation id the provider bound to that chat — independent of the AH * session id — which tests need to drive the fake SDK. */ -async function createSession(agent: ClaudeAgent, config: IAgentCreateSessionConfig = {}): Promise { +async function createSession(agent: ClaudeAgent, config: IAgentCreateSessionConfig = {}, chatOptions?: IAgentCreateChatOptions): Promise { const session = config.session ?? AgentSession.uri('claude', generateUuid()); const chat = defaultChatUri(session); - const created = await createProviderSession(agent, chat, chatContext(chat), { ...config, session }); + const created = await createProviderSession(agent, chat, chatContext(chat), { ...config, session }, chatOptions); return { ...created, sdkSessionId: AgentSession.id(created.chat!.backingSession!) }; } @@ -185,23 +186,16 @@ async function createSession(agent: ClaudeAgent, config: IAgentCreateSessionConf * and the host — not the provider — assembles the session-level result around * the flat chat result the provider returns. */ -async function createProviderSession(agent: ClaudeAgent, chat: URI, context: IAgentChatContext, config: IAgentCreateSessionConfig): Promise { +async function createProviderSession(agent: ClaudeAgent, chat: URI, context: IAgentChatContext, config: IAgentCreateSessionConfig, chatOptions?: IAgentCreateChatOptions): Promise { const result = await agent.chats.createChat(chat, context, { model: config.model, agent: config.agent, workingDirectories: config.workingDirectories, config: config.config, activeClient: config.activeClient, - deferBacking: !config.fork && !config.importConversation, + deferBacking: !chatOptions?.fork && !config.importConversation, importConversation: config.importConversation, - ...(config.fork ? { - fork: { - source: config.fork.chat, - turnIndex: config.fork.turnIndex, - turnId: config.fork.turnId, - turnIdMapping: config.fork.turnIdMapping, - }, - } : {}), + ...chatOptions, }); if (!result) { throw new Error('Expected chat backing metadata'); @@ -2441,7 +2435,7 @@ suite('ClaudeAgent', () => { // The fork binds the exact target chat directly, so the new AH session // id stays independent of the forked SDK conversation id. - const result = await createSession(agent, { fork: { session: sourceUri, chat: defaultChatUri(sourceUri), turnIndex: 0, turnId: 'u1' } }); + const result = await createSession(agent, {}, { fork: { source: defaultChatUri(sourceUri), turnId: 'u1' } }); const newUri = result.session; // Snapshot fork-time state: file written, no Query, no materialize event. @@ -2508,8 +2502,7 @@ suite('ClaudeAgent', () => { const forked = await createSession(agent, { workingDirectories: [requestedPrimary, requestedAdditional], - fork: { session: source.session, chat: defaultChatUri(source.session), turnIndex: 0, turnId: 'u1' }, - }); + }, { fork: { source: defaultChatUri(source.session), turnId: 'u1' } }); sdk.nextQueryMessages = [makeSystemInitMessage('forked-1'), makeResultSuccess('forked-1')]; await agent.chats.sendMessage(defaultChatUri(forked.session), 'continue', undefined, undefined, 'turn-fork', undefined, undefined, chatContext(defaultChatUri(forked.session))); @@ -2533,7 +2526,7 @@ suite('ClaudeAgent', () => { const source = AgentSession.uri('claude', sourceId); await bindDefaultChat(agent, source); - await createSession(agent, { fork: { session: source, chat: defaultChatUri(source), turnIndex: 1, turnId: 'u2' } }); + await createSession(agent, {}, { fork: { source: defaultChatUri(source), turnId: 'u2' } }); assert.deepStrictEqual(sdk.forkSessionCalls[0], { sessionId: sourceId, options: { upToMessageId: 'a2' } }); }); @@ -2753,7 +2746,7 @@ suite('ClaudeAgent', () => { sdk.sessionList = [{ sessionId: 'forked-1', summary: 'fork', lastModified: 1, cwd: URI.file('/work').fsPath }]; await bindDefaultChat(agent, sourceUri); - const result = await createSession(agent, { fork: { session: sourceUri, chat: defaultChatUri(sourceUri), turnIndex: 0, turnId: 'u1' } }); + const result = await createSession(agent, {}, { fork: { source: defaultChatUri(sourceUri), turnId: 'u1' } }); await bindDefaultChat(agent, result.session); // Fork defers the Query; materialize it via the first send. The resume @@ -2775,9 +2768,8 @@ suite('ClaudeAgent', () => { await bindDefaultChat(agent, AgentSession.uri('claude', sourceId)); const result = await createSession(agent, { - fork: { session: AgentSession.uri('claude', sourceId), chat: defaultChatUri(AgentSession.uri('claude', sourceId)), turnIndex: 0, turnId: 'u1' }, model: { id: 'claude-opus-4.6' }, - }); + }, { fork: { source: defaultChatUri(AgentSession.uri('claude', sourceId)), turnId: 'u1' } }); // The fork's model override is no longer surfaced on metadata; its // observable effect is the model the forked session's SDK query is @@ -2806,8 +2798,7 @@ suite('ClaudeAgent', () => { const created = await createSession(agent, { workingDirectories: [URI.file('/work')], - fork: { session: AgentSession.uri('claude', sourceId), chat: defaultChatUri(AgentSession.uri('claude', sourceId)), turnIndex: 9, turnId: 'no-such-turn' }, - }); + }, { fork: { source: defaultChatUri(AgentSession.uri('claude', sourceId)), turnId: 'no-such-turn' } }); assert.deepStrictEqual({ forkCalls: sdk.forkSessionCalls.length, @@ -2834,7 +2825,7 @@ suite('ClaudeAgent', () => { // undefined (no cwd), and no `config.workingDirectories` is supplied. // Fail fast here rather than at the first `sendMessage`. await assert.rejects( - createSession(agent, { fork: { session: AgentSession.uri('claude', sourceId), chat: defaultChatUri(AgentSession.uri('claude', sourceId)), turnIndex: 0, turnId: 'u1' } }), + createSession(agent, {}, { fork: { source: defaultChatUri(AgentSession.uri('claude', sourceId)), turnId: 'u1' } }), /no working directory/, ); }); @@ -2846,8 +2837,7 @@ suite('ClaudeAgent', () => { const subagentUri = URI.parse(buildSubagentSessionUri(AgentSession.uri('claude', 'parent').toString(), 'tool-call-1')); const created = await createSession(agent, { workingDirectories: [URI.file('/work')], - fork: { session: subagentUri, chat: defaultChatUri(subagentUri), turnIndex: 0, turnId: 'u1' }, - }); + }, { fork: { source: defaultChatUri(subagentUri), turnId: 'u1' } }); assert.deepStrictEqual({ provisional: created.provisional, getMessages: sdk.getSessionMessagesCalls.length, @@ -2866,8 +2856,7 @@ suite('ClaudeAgent', () => { const created = await createSession(agent, { workingDirectories: [URI.file('/src')], - fork: { session: provisional.session, chat: defaultChatUri(provisional.session), turnIndex: 0, turnId: 'u1' }, - }); + }, { fork: { source: defaultChatUri(provisional.session), turnId: 'u1' } }); assert.deepStrictEqual({ forkCalls: sdk.forkSessionCalls.length, @@ -7272,7 +7261,7 @@ suite('ClaudeAgent (Phase 7 §3.5 — INTERACTIVE_CLAUDE_TOOLS)', () => { await tick(); const inputRequest = inputRequests.at(-1)!; - assert.strictEqual(inputRequest.purpose, ChatInputRequestPurpose.AskUser); + assert.strictEqual(readChatInputRequestPurpose(inputRequest), ChatInputRequestPurpose.AskUser); ctx.agent.respondToUserInputRequest('tu_ask', ChatInputResponseKind.Accept, { q1: { state: ChatInputAnswerState.Submitted, @@ -7653,7 +7642,7 @@ suite('ClaudeAgent (Phase 10.6 — MCP elicitation translation)', () => { await tick(); const inputRequest = inputRequests.at(-1)!; - assert.strictEqual(inputRequest.purpose, ChatInputRequestPurpose.Elicitation); + assert.strictEqual(readChatInputRequestPurpose(inputRequest), ChatInputRequestPurpose.Elicitation); ctx.agent.respondToUserInputRequest(inputRequest.id, ChatInputResponseKind.Accept, { side: { state: ChatInputAnswerState.Submitted, value: { kind: ChatInputAnswerValueKind.Text, value: 'left' } }, }); @@ -9622,8 +9611,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const created = await createProviderSession(agent, targetChat, targetContext, { session: targetSession, - fork: { session: sourceUri, chat: defaultChatUri(sourceUri), turnIndex: 0, turnId: 'u1' }, - }); + }, { fork: { source: defaultChatUri(sourceUri), turnId: 'u1' } }); // No `bindSessionChat` call anywhere above: the fork already bound the // exact target chat, so the first send must resume the forked SDK @@ -9642,9 +9630,8 @@ suite('ClaudeAgent — Phase 11 customizations', () => { ahSessionId: 'ah-target', sdkSessionId: 'forked-1', startupResume: 'forked-1', - // No live session object is registered for a fork (materialization - // stays deferred to the first send) — matches the legacy - // `createSession({ fork })` return shape, which also omits it. + // No live session object is registered for a fork; materialization + // stays deferred to the first send. provisional: undefined, providerData: { sdkSessionId: 'forked-1' }, }); @@ -9677,10 +9664,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const targetContext = { configurationResource: targetSession, resource: targetSession }; const created = await createProviderSession(agent, targetChat, targetContext, { session: targetSession, - // `fork.session`/`fork.chat` route to the source's exact chat, not - // its AH session id, exercising source-side exact-chat resolution. - fork: { session: sourceSession, chat: sourceChat, turnIndex: 0, turnId: 'u1' }, - }); + }, { fork: { source: sourceChat, turnId: 'u1' } }); assert.deepStrictEqual({ forkCall: sdk.forkSessionCalls.at(-1), @@ -9714,8 +9698,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const targetContext = { configurationResource: targetSession, resource: targetSession }; const created = await createProviderSession(agent, targetChat, targetContext, { session: targetSession, - fork: { session: sourceUri, chat: defaultChatUri(sourceUri), turnIndex: 0, turnId: 'u1' }, - }); + }, { fork: { source: defaultChatUri(sourceUri), turnId: 'u1' } }); // No `bindSessionChat`: the overlay must already be keyed to the exact // target chat's own AH session so the first send resumes with the diff --git a/src/vs/platform/agentHost/test/node/claudeElicitation.test.ts b/src/vs/platform/agentHost/test/node/claudeElicitation.test.ts index 80de09c7a993d6..62b30bcc076faf 100644 --- a/src/vs/platform/agentHost/test/node/claudeElicitation.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeElicitation.test.ts @@ -6,7 +6,8 @@ import assert from 'assert'; import type { ElicitationRequest } from '@anthropic-ai/claude-agent-sdk'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputRequestPurpose, ChatInputResponseKind, type ChatInputAnswer } from '../../common/state/sessionState.js'; +import { ChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js'; +import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, type ChatInputAnswer } from '../../common/state/sessionState.js'; import { buildElicitationRequest, cancelledElicitationResult, elicitationResultFromAnswers } from '../../node/claude/claudeElicitation.js'; import { handleElicitation } from '../../node/claude/claudeElicitationBridge.js'; @@ -43,7 +44,7 @@ suite('claudeElicitation', () => { test('buildElicitationRequest (form) projects every primitive field kind', () => { assert.deepStrictEqual(buildElicitationRequest('req-1', formRequest), { id: 'req-1', - purpose: ChatInputRequestPurpose.Elicitation, + _meta: { purpose: ChatInputRequestPurpose.Elicitation }, message: 'Please configure', questions: [ { kind: ChatInputQuestionKind.Text, id: 'name', title: 'Name', message: 'Your name', required: true, format: undefined, min: 1, max: undefined, defaultValue: undefined }, @@ -59,7 +60,7 @@ suite('claudeElicitation', () => { test('buildElicitationRequest (url) surfaces the url with no questions', () => { assert.deepStrictEqual(buildElicitationRequest('req-2', urlRequest), { id: 'req-2', - purpose: ChatInputRequestPurpose.Elicitation, + _meta: { purpose: ChatInputRequestPurpose.Elicitation }, message: 'Authorize', url: 'https://example.com/auth', }); @@ -72,7 +73,7 @@ suite('claudeElicitation', () => { mode: 'form', requestedSchema: { type: 'object', properties: 'not-an-object' as unknown as Record }, }; - assert.deepStrictEqual(buildElicitationRequest('req-3', malformed), { id: 'req-3', purpose: ChatInputRequestPurpose.Elicitation, message: 'Broken' }); + assert.deepStrictEqual(buildElicitationRequest('req-3', malformed), { id: 'req-3', _meta: { purpose: ChatInputRequestPurpose.Elicitation }, message: 'Broken' }); }); test('buildElicitationRequest drops a field that fails validation but keeps valid siblings', () => { @@ -92,7 +93,7 @@ suite('claudeElicitation', () => { }; assert.deepStrictEqual(buildElicitationRequest('req-4', mixed), { id: 'req-4', - purpose: ChatInputRequestPurpose.Elicitation, + _meta: { purpose: ChatInputRequestPurpose.Elicitation }, message: 'Mixed', questions: [ { kind: ChatInputQuestionKind.Text, id: 'ok', title: 'Ok', message: 'Ok', required: false, format: undefined, min: undefined, max: undefined, defaultValue: undefined }, @@ -123,7 +124,7 @@ suite('claudeElicitation', () => { }; assert.deepStrictEqual(buildElicitationRequest('req-5', variants), { id: 'req-5', - purpose: ChatInputRequestPurpose.Elicitation, + _meta: { purpose: ChatInputRequestPurpose.Elicitation }, message: 'Variants', questions: [ { kind: ChatInputQuestionKind.Number, id: 'ratio', title: 'Ratio', message: 'Ratio', required: false, min: 0, max: 1, defaultValue: 0.5 }, @@ -148,10 +149,10 @@ suite('claudeElicitation', () => { formAllInvalid: buildElicitationRequest('d', { serverName: 'srv', message: 'AllBad', mode: 'form', requestedSchema: { type: 'object', properties: { a: { type: 'string', enum: 123 }, b: { minimum: 'nope' } } } }), }; assert.deepStrictEqual(cases, { - urlNoUrl: { id: 'a', purpose: ChatInputRequestPurpose.Elicitation, message: 'NoUrl' }, - formNoSchema: { id: 'b', purpose: ChatInputRequestPurpose.Elicitation, message: 'NoSchema' }, - formEmptyProps: { id: 'c', purpose: ChatInputRequestPurpose.Elicitation, message: 'Empty' }, - formAllInvalid: { id: 'd', purpose: ChatInputRequestPurpose.Elicitation, message: 'AllBad' }, + urlNoUrl: { id: 'a', _meta: { purpose: ChatInputRequestPurpose.Elicitation }, message: 'NoUrl' }, + formNoSchema: { id: 'b', _meta: { purpose: ChatInputRequestPurpose.Elicitation }, message: 'NoSchema' }, + formEmptyProps: { id: 'c', _meta: { purpose: ChatInputRequestPurpose.Elicitation }, message: 'Empty' }, + formAllInvalid: { id: 'd', _meta: { purpose: ChatInputRequestPurpose.Elicitation }, message: 'AllBad' }, }); }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexElicitationMapper.test.ts b/src/vs/platform/agentHost/test/node/codex/codexElicitationMapper.test.ts index 4f4cd404c5302e..2965056010d7bd 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexElicitationMapper.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexElicitationMapper.test.ts @@ -5,7 +5,8 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputRequestPurpose, ChatInputResponseKind, type ChatInputAnswer } from '../../../common/state/sessionState.js'; +import { ChatInputRequestPurpose } from '../../../common/meta/agentChatInputRequestMeta.js'; +import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, type ChatInputAnswer } from '../../../common/state/sessionState.js'; import { buildElicitationRequest, elicitationResponseFromAnswers } from '../../../node/codex/codexElicitationMapper.js'; import type { McpServerElicitationRequestParams } from '../../../node/codex/protocol/generated/v2/McpServerElicitationRequestParams.js'; @@ -38,7 +39,7 @@ suite('codexElicitationMapper', () => { test('buildElicitationRequest (form) projects every primitive field kind', () => { assert.deepStrictEqual(buildElicitationRequest('req-1', formParams), { id: 'req-1', - purpose: ChatInputRequestPurpose.Elicitation, + _meta: { purpose: ChatInputRequestPurpose.Elicitation }, message: 'Please configure', questions: [ { kind: ChatInputQuestionKind.Text, id: 'name', title: 'Name', message: 'Your name', required: true, format: undefined, min: 1, max: undefined, defaultValue: undefined }, @@ -53,7 +54,7 @@ suite('codexElicitationMapper', () => { test('buildElicitationRequest (url) surfaces the url with no questions', () => { assert.deepStrictEqual(buildElicitationRequest('req-2', urlParams), { - id: 'req-2', purpose: ChatInputRequestPurpose.Elicitation, message: 'Authorize', url: 'https://example.com/auth', + id: 'req-2', _meta: { purpose: ChatInputRequestPurpose.Elicitation }, message: 'Authorize', url: 'https://example.com/auth', }); }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexUserInputMapper.test.ts b/src/vs/platform/agentHost/test/node/codex/codexUserInputMapper.test.ts index 39855872963926..478506f51301a6 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexUserInputMapper.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexUserInputMapper.test.ts @@ -5,7 +5,8 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputRequestPurpose, ChatInputResponseKind, type ChatInputAnswer } from '../../../common/state/sessionState.js'; +import { ChatInputRequestPurpose } from '../../../common/meta/agentChatInputRequestMeta.js'; +import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, type ChatInputAnswer } from '../../../common/state/sessionState.js'; import { answerStrings, buildUserInputRequest, emptyUserInputResponse, userInputResponseFromAnswers } from '../../../node/codex/codexUserInputMapper.js'; import type { ToolRequestUserInputQuestion } from '../../../node/codex/protocol/generated/v2/ToolRequestUserInputQuestion.js'; @@ -24,7 +25,7 @@ suite('codexUserInputMapper', () => { test('buildUserInputRequest maps select and text questions', () => { assert.deepStrictEqual(buildUserInputRequest('req-1', [selectQuestion, textQuestion]), { id: 'req-1', - purpose: ChatInputRequestPurpose.AskUser, + _meta: { purpose: ChatInputRequestPurpose.AskUser }, questions: [ { kind: ChatInputQuestionKind.SingleSelect, diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 3264a6b52ecfb5..aa3a2df8e10375 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -123,7 +123,7 @@ function exactChatContext(session: URI, chat: URI, resource: URI = chat): IAgent * call addressed to the session's first chat, with the owning session as the * persistence scope, so the creation also stands the session's runtime up. */ -async function provisionSession(agent: CopilotAgent, config: IAgentCreateSessionConfig & { readonly session: URI }): Promise { +async function provisionSession(agent: CopilotAgent, config: IAgentCreateSessionConfig & { readonly session: URI }, chatOptions?: IAgentCreateChatOptions): Promise { const chat = defaultChatUri(config.session); const result = await agent.chats.createChat(chat, exactChatContext(config.session, chat, config.session), { model: config.model, @@ -131,16 +131,9 @@ async function provisionSession(agent: CopilotAgent, config: IAgentCreateSession workingDirectories: config.workingDirectories, config: config.config, activeClient: config.activeClient, - deferBacking: !config.fork && !config.importConversation, + deferBacking: !chatOptions?.fork && !config.importConversation, importConversation: config.importConversation, - ...(config.fork ? { - fork: { - source: config.fork.chat, - turnIndex: config.fork.turnIndex, - turnId: config.fork.turnId, - turnIdMapping: config.fork.turnIdMapping, - }, - } : {}), + ...chatOptions, }); // The provider contract no longer echoes `session` back; the test already // knows it from `config`, so augment locally for test ergonomics only. @@ -335,7 +328,6 @@ class TestAgentHostTerminalManager implements IAgentHostTerminalManager { getContent(): string | undefined { return undefined; } getClaim(): undefined { return undefined; } hasTerminal(): boolean { return false; } - getExitCode(): number | undefined { return undefined; } supportsCommandDetection(): boolean { return false; } disposeTerminal(): void { } getTerminalInfos(): [] { return []; } @@ -6040,11 +6032,11 @@ suite('CopilotAgent', () => { const client = new TestCopilotClient([]); const agent = createTestAgent(disposables, { copilotClient: client }); const session = AgentSession.uri('copilotcli', 'same-session'); + const chat = defaultChatUri(session); try { - await assert.rejects(() => provisionSession(agent, { - session, - fork: { session, chat: defaultChatUri(session), turnIndex: 0, turnId: 'turn-1' }, + await assert.rejects(() => agent.chats.createChat(chat, exactChatContext(session, chat), { + fork: { source: chat, turnId: 'turn-1' }, }), /Cannot fork Copilot chat .* onto itself/); assert.strictEqual(client.startCallCount, 0); } finally { @@ -6069,10 +6061,9 @@ suite('CopilotAgent', () => { const result = await provisionSession(agent, { session: target, workingDirectories: [URI.file('/ignored-client-workspace')], + }, { fork: { - session: source, - chat: defaultChatUri(source), - turnIndex: 0, + source: defaultChatUri(source), turnId: sourceTurn.id, turnIdMapping: new Map([[sourceTurn.id, forkedTurnId]]), }, @@ -6080,10 +6071,9 @@ suite('CopilotAgent', () => { const retried = await provisionSession(agent, { session: target, workingDirectories: [URI.file('/different-retry-workspace')], + }, { fork: { - session: source, - chat: defaultChatUri(source), - turnIndex: 0, + source: defaultChatUri(source), turnId: sourceTurn.id, }, }); @@ -6142,11 +6132,10 @@ suite('CopilotAgent', () => { try { await agent.authenticate('https://api.github.com', 'token'); - // A fork that stands a session up stores into the session scope; - // a fork into an existing session's own chat stores into the chat. await provisionSession(agent, { session: target, - fork: { session: source, chat: defaultChatUri(source), turnIndex: 0, turnId: sourceTurn.id }, + }, { + fork: { source: defaultChatUri(source), turnId: sourceTurn.id }, }); await agent.chats.createChat(peerChat, exactChatContext(target, peerChat), { workingDirectories: [URI.file('/target-workspace')], @@ -6175,10 +6164,9 @@ suite('CopilotAgent', () => { await agent.authenticate('https://api.github.com', 'token'); const result = await provisionSession(agent, { session: target, + }, { fork: { - session: source, - chat: defaultChatUri(source), - turnIndex: 0, + source: defaultChatUri(source), turnId: sourceTurn.id, }, }); @@ -7543,10 +7531,6 @@ suite('CopilotAgent', () => { } }); - // Forking a provisional session is no longer a special case: the agent - // service drops `config.fork` for sources with no turns, so the call - // reduces to a plain new-session create. - test('materialization passes VS Code-specific system message to the SDK', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); const client = new TestCopilotClient([]); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 43bed3490aafb7..772723af116d61 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -31,12 +31,13 @@ import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind } from '../../common/agentHostTelemetry.js'; import type { ChatInputRequestWithPlanReview } from '../../common/agentHostPlanReview.js'; import { AgentFeedbackAttachmentDisplayKind } from '../../common/meta/agentFeedbackAttachments.js'; +import { ChatInputRequestPurpose, readChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js'; import { readToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; import { IDiffComputeService } from '../../common/diffComputeService.js'; import { ISessionDataService, type ISessionDatabase } from '../../common/sessionDataService.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { ActionType, type ChatDeltaAction, type ChatErrorAction, type ChatInputRequestedAction, type ChatResponsePartAction, type ChatToolCallCompleteAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallStartAction, type ChatTurnCompleteAction, type ChatUsageAction, type SessionAction, type StateAction } from '../../common/state/sessionActions.js'; -import { MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputRequestPurpose, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, createSessionState, getInlineToolInput, mergeSessionWithDefaultChat, readSessionPromptCacheState, readUsageInfoMeta, SessionStatus, withSessionPromptCacheState, type ToolResultContent, type ToolResultFileEditContent, type ToolResultTerminalContent, type UsageInfoMeta } from '../../common/state/sessionState.js'; +import { MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, createSessionState, getInlineToolInput, mergeSessionWithDefaultChat, readSessionPromptCacheState, readUsageInfoMeta, SessionStatus, withSessionPromptCacheState, type ToolResultContent, type ToolResultFileEditContent, type ToolResultTerminalContent, type UsageInfoMeta } from '../../common/state/sessionState.js'; import { TerminalClaimKind } from '../../common/state/protocol/state.js'; import { toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { STREAMING_TOOL_DISPLAY_INTERVAL_MS } from '../../common/streamingToolCallDisplay.js'; @@ -5708,7 +5709,7 @@ suite('CopilotAgentSession', () => { assert.deepStrictEqual(terminalManager.outputTerminalsCreated, [{ uri: terminalUri, title: 'Run Shell Command', - claim: { kind: TerminalClaimKind.Session, session: AgentSession.uri('copilot', 'test-session-1').toString(), toolCallId: 'tc-stream' }, + claim: { kind: TerminalClaimKind.Session, session: AgentSession.uri('copilot', 'test-session-1').toString(), chat: buildDefaultChatUri(AgentSession.uri('copilot', 'test-session-1')), toolCallId: 'tc-stream' }, }]); assert.deepStrictEqual(terminalManager.outputTerminalData, [ { uri: terminalUri, data: 'tick 1\n' }, @@ -7533,7 +7534,7 @@ suite('CopilotAgentSession', () => { assert.strictEqual(signals.length, 1); const request = getInputRequest(signals[0]); const requestId = request.id; - assert.strictEqual(request.purpose, ChatInputRequestPurpose.AskUser); + assert.strictEqual(readChatInputRequestPurpose(request), ChatInputRequestPurpose.AskUser); assert.ok(request.questions); assert.strictEqual(request.questions[0].message, 'What is your name?'); const questionId = request.questions[0].id; @@ -7677,7 +7678,7 @@ suite('CopilotAgentSession', () => { ActionType.ChatInputCompleted, ]); const requested = getActions(signals)[0]; - assert.strictEqual(requested.type === ActionType.ChatInputRequested ? requested.request.purpose : undefined, undefined); + assert.strictEqual(requested.type === ActionType.ChatInputRequested ? readChatInputRequestPurpose(requested.request) : undefined, undefined); const completed = getActions(signals)[1]; assert.deepStrictEqual(completed.type === ActionType.ChatInputCompleted ? Object.values(completed.answers ?? {}) : [], [{ state: ChatInputAnswerState.Submitted, @@ -7728,7 +7729,7 @@ suite('CopilotAgentSession', () => { ActionType.ChatInputCompleted, ]); const requested = getActions(signals)[0]; - assert.strictEqual(requested.type === ActionType.ChatInputRequested ? requested.request.purpose : undefined, undefined); + assert.strictEqual(requested.type === ActionType.ChatInputRequested ? readChatInputRequestPurpose(requested.request) : undefined, undefined); const completed = getActions(signals)[1]; assert.deepStrictEqual(completed.type === ActionType.ChatInputCompleted ? Object.values(completed.answers ?? {}) : [], [{ state: ChatInputAnswerState.Submitted, @@ -7767,7 +7768,7 @@ suite('CopilotAgentSession', () => { assert.strictEqual(signals.length, 1); const request = getInputRequest(signals[0]); - assert.strictEqual(request.purpose, ChatInputRequestPurpose.Elicitation); + assert.strictEqual(readChatInputRequestPurpose(request), ChatInputRequestPurpose.Elicitation); assert.strictEqual(request.message, 'Configure deployment'); assert.ok(request.questions); assert.deepStrictEqual(request.questions.map(q => ({ id: q.id, kind: q.kind, required: q.required })), [ @@ -9252,7 +9253,7 @@ suite('CopilotAgentSession', () => { const signal = await waitForSignal(s => isAction(s, ActionType.ChatInputRequested)); const request = getInputRequest(signal); - assert.strictEqual(request.purpose, ChatInputRequestPurpose.PlanReview); + assert.strictEqual(readChatInputRequestPurpose(request), ChatInputRequestPurpose.PlanReview); const planReview = (request as ChatInputRequestWithPlanReview).planReview; assert.deepStrictEqual(planReview, { diff --git a/src/vs/platform/agentHost/test/node/copilotNonPtyShellTerminals.test.ts b/src/vs/platform/agentHost/test/node/copilotNonPtyShellTerminals.test.ts index 37f1723c3b70ea..54158b45f02ddf 100644 --- a/src/vs/platform/agentHost/test/node/copilotNonPtyShellTerminals.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotNonPtyShellTerminals.test.ts @@ -7,17 +7,19 @@ import { deepStrictEqual, ok, strictEqual } from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NonPtyShellTerminalStreams } from '../../node/copilot/copilotNonPtyShellTerminals.js'; +import { buildDefaultChatUri } from '../../common/state/sessionState.js'; import { TestAgentHostTerminalManager } from './testAgentHostTerminalManager.js'; suite('NonPtyShellTerminalStreams', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + const sessionUri = URI.parse('agenthost-session://test/session-1'); let manager: TestAgentHostTerminalManager; let streams: NonPtyShellTerminalStreams; setup(() => { manager = store.add(new TestAgentHostTerminalManager()); - streams = store.add(new NonPtyShellTerminalStreams(URI.parse('agenthost-session://test/session-1'), manager)); + streams = store.add(new NonPtyShellTerminalStreams(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), manager)); }); function channelContent(): string { diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index eda55a5888e262..17dab7ea6bddb4 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -33,8 +33,10 @@ import { ByokLmBridgeRegistry, IByokLmBridgeRegistry } from '../../node/byokLmBr import { ByokLmProxyService, IByokLmProxyService, type IByokLmProxyHandle } from '../../node/copilot/byokLmProxyService.js'; import { resolveCopilotMcpServerInfo, type ICopilotPluginInfo } from '../../node/copilot/copilotAgent.js'; import { CopilotSessionLauncher, filterClientToolNames, getCopilotReasoningEffort, isCopilotReasoningEffort, resolveByokSessionConfig, normalizeToolFilterPatterns, resolveConfiguredReasoningEffortOverride, resolveCopilotReasoningEffort, toSdkToolFilterPatterns, type CopilotSessionLaunchPlan, type ICopilotSessionRuntime } from '../../node/copilot/copilotSessionLauncher.js'; +import { buildDefaultChatUri } from '../../common/state/sessionState.js'; const testRuntime: ICopilotSessionRuntime = { + chatUri: URI.parse(buildDefaultChatUri('copilot:/sess-1')), handlePermissionRequest: async () => { throw new Error('Unexpected permission request'); }, handleExitPlanModeRequest: async () => { throw new Error('Unexpected exit plan mode request'); }, handleUserInputRequest: async () => { throw new Error('Unexpected user input request'); }, diff --git a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts index 130ccbaf73f9e3..2b0116023c7a6b 100644 --- a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts @@ -26,9 +26,13 @@ import { AgentSandboxEnabledValue } from '../../../sandbox/common/settings.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; import type { CreateTerminalParams } from '../../common/state/protocol/commands.js'; import { TerminalClaimKind, type TerminalClaim, type TerminalInfo } from '../../common/state/protocol/state.js'; +import { buildDefaultChatUri } from '../../common/state/sessionState.js'; import { formatTerminalText, IAgentHostTerminalManager, type ICommandFinishedEvent, type ISendTextOptions } from '../../node/agentHostTerminalManager.js'; import { createShellTools, type IUnsandboxedCommandConfirmationRequest, isMultilineCommand, ShellManager, prefixForHistorySuppression, shellTypeForExecutable } from '../../node/copilot/copilotShellTools.js'; +/** Chat that owns the terminals created by the shells under test. */ +const TEST_CHAT_URI = URI.parse(buildDefaultChatUri('copilot:/session-1')); + class TestAgentHostTerminalManager implements IAgentHostTerminalManager { declare readonly _serviceBrand: undefined; @@ -82,7 +86,6 @@ class TestAgentHostTerminalManager implements IAgentHostTerminalManager { getContent(): string | undefined { return this._content; } getClaim(): TerminalClaim | undefined { return undefined; } hasTerminal(uri: string): boolean { return this.existingTerminalUris.has(uri); } - getExitCode(): number | undefined { return undefined; } supportsCommandDetection(): boolean { return this.commandDetectionSupported; } disposeTerminal(): void { } getTerminalInfos(): TerminalInfo[] { return []; } @@ -247,8 +250,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')).dispose(); - (await shellManager.getOrCreateShell('bash', 'turn-2', 'tool-2', explicitCwd)).dispose(); + (await shellManager.getOrCreateShell('bash', TEST_CHAT_URI, 'turn-1', 'tool-1')).dispose(); + (await shellManager.getOrCreateShell('bash', TEST_CHAT_URI, 'turn-2', 'tool-2', explicitCwd)).dispose(); assert.deepStrictEqual(terminalManager.created.map(c => c.params.cwd), [ worktreePath, @@ -260,7 +263,7 @@ suite('CopilotShellTools', () => { const { instantiationService, terminalManager } = createServices(); const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); - await shellManager.getOrCreateShell('bash', 'turn-1', 'tool-1'); + await shellManager.getOrCreateShell('bash', TEST_CHAT_URI, 'turn-1', 'tool-1'); assert.strictEqual(terminalManager.created.length, 1); assert.strictEqual(terminalManager.created[0].options?.preventShellHistory, true); @@ -272,7 +275,7 @@ suite('CopilotShellTools', () => { terminalManager.defaultShell = '/custom/path/to/pwsh'; const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); - await shellManager.getOrCreateShell('powershell', 'turn-1', 'tool-1'); + await shellManager.getOrCreateShell('powershell', TEST_CHAT_URI, 'turn-1', 'tool-1'); assert.strictEqual(terminalManager.created[0].options?.shell, '/custom/path/to/pwsh'); }); @@ -300,7 +303,7 @@ suite('CopilotShellTools', () => { const { instantiationService, terminalManager } = createServices(); terminalManager.defaultShell = '/bin/zsh'; const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); - const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); + const tools = await createShellTools(shellManager, TEST_CHAT_URI, terminalManager, new NullLogService()); const bashTool = tools.find(tool => tool.name === 'bash'); assert.ok(bashTool); @@ -327,9 +330,9 @@ suite('CopilotShellTools', () => { 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 first = await shellManager.getOrCreateShell('bash', TEST_CHAT_URI, 'turn-1', 'tool-1'); first.dispose(); - const second = await shellManager.getOrCreateShell('bash', 'turn-2', 'tool-2'); + const second = await shellManager.getOrCreateShell('bash', TEST_CHAT_URI, 'turn-2', 'tool-2'); assert.strictEqual(second.object.id, first.object.id, 'should reuse idle shell'); assert.strictEqual(terminalManager.created.length, 1); @@ -347,8 +350,8 @@ suite('CopilotShellTools', () => { 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'); + const first = await shellManager.getOrCreateShell('bash', TEST_CHAT_URI, 'turn-1', 'tool-1'); + const second = await shellManager.getOrCreateShell('bash', TEST_CHAT_URI, '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); @@ -364,7 +367,7 @@ suite('CopilotShellTools', () => { // which breaks interactive shell flows. const { instantiationService, terminalManager } = createServices(); const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); - const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); + const tools = await createShellTools(shellManager, TEST_CHAT_URI, terminalManager, new NullLogService()); const skipPermissionByName = Object.fromEntries(tools.map(t => [t.name, t.skipPermission ?? false])); assert.deepStrictEqual(skipPermissionByName, { @@ -380,7 +383,7 @@ suite('CopilotShellTools', () => { test('primary shell tool normalizes multiline command input', async () => { const { instantiationService, terminalManager } = createServices(); const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); - const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); + const tools = await createShellTools(shellManager, TEST_CHAT_URI, terminalManager, new NullLogService()); const bashTool = tools.find(tool => tool.name === 'bash'); assert.ok(bashTool); @@ -403,7 +406,7 @@ suite('CopilotShellTools', () => { const { instantiationService, terminalManager } = createServices(); const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); - const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); + const tools = await createShellTools(shellManager, TEST_CHAT_URI, terminalManager, new NullLogService()); const bashTool = tools.find(tool => tool.name === 'bash'); assert.ok(bashTool); @@ -438,7 +441,7 @@ suite('CopilotShellTools', () => { const { instantiationService, terminalManager } = createServices(); terminalManager.commandDetectionSupported = true; const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); - const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); + const tools = await createShellTools(shellManager, TEST_CHAT_URI, terminalManager, new NullLogService()); const bashTool = tools.find(tool => tool.name === 'bash'); assert.ok(bashTool); @@ -463,7 +466,7 @@ suite('CopilotShellTools', () => { const { instantiationService, terminalManager } = createServices(); terminalManager.commandDetectionSupported = true; const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); - const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); + const tools = await createShellTools(shellManager, TEST_CHAT_URI, terminalManager, new NullLogService()); const bashTool = tools.find(tool => tool.name === 'bash'); assert.ok(bashTool); @@ -486,7 +489,7 @@ suite('CopilotShellTools', () => { test('primary shell tool returns alternateBuffer when sentinel fallback enters alt buffer', async () => { const { instantiationService, terminalManager } = createServices(); const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); - const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); + const tools = await createShellTools(shellManager, TEST_CHAT_URI, terminalManager, new NullLogService()); const bashTool = tools.find(tool => tool.name === 'bash'); assert.ok(bashTool); @@ -510,7 +513,7 @@ suite('CopilotShellTools', () => { const { instantiationService, terminalManager } = createServices(); terminalManager.commandDetectionSupported = true; const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); - const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); + const tools = await createShellTools(shellManager, TEST_CHAT_URI, terminalManager, new NullLogService()); const bashTool = tools.find(tool => tool.name === 'bash'); assert.ok(bashTool); @@ -529,7 +532,7 @@ suite('CopilotShellTools', () => { const shell = shellManager.listShells()[0]; terminalManager.fireCommandFinished({ commandId: 'cmd-1', exitCode: 0, command: 'vim README.md', output: '' }); - const next = await shellManager.getOrCreateShell('bash', 'turn-2', 'tool-2'); + const next = await shellManager.getOrCreateShell('bash', TEST_CHAT_URI, 'turn-2', 'tool-2'); assert.strictEqual(next.object.id, shell.id); assert.strictEqual(terminalManager.created.length, 1); @@ -540,7 +543,7 @@ suite('CopilotShellTools', () => { const { instantiationService, terminalManager } = createServices(); terminalManager.commandDetectionSupported = true; const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); - const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); + const tools = await createShellTools(shellManager, TEST_CHAT_URI, terminalManager, new NullLogService()); const bashTool = tools.find(tool => tool.name === 'bash'); assert.ok(bashTool); @@ -558,7 +561,7 @@ suite('CopilotShellTools', () => { markCreatedTerminalsExist(terminalManager); const shell = shellManager.listShells()[0]; - const next = await shellManager.getOrCreateShell('bash', 'turn-2', 'tool-2'); + const next = await shellManager.getOrCreateShell('bash', TEST_CHAT_URI, 'turn-2', 'tool-2'); assert.notStrictEqual(next.object.id, shell.id); assert.strictEqual(terminalManager.created.length, 2); @@ -569,7 +572,7 @@ suite('CopilotShellTools', () => { const { instantiationService, terminalManager } = createServices(); terminalManager.commandDetectionSupported = true; const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); - const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); + const tools = await createShellTools(shellManager, TEST_CHAT_URI, terminalManager, new NullLogService()); const bashTool = tools.find(tool => tool.name === 'bash'); assert.ok(bashTool); @@ -581,14 +584,14 @@ suite('CopilotShellTools', () => { }; const resultPromise = bashTool.handler!({ command: 'sleep 100', timeout: 1000 }, invocation) as Promise; await waitForSentTexts(terminalManager, 1); - terminalManager.fireClaimChanged({ kind: TerminalClaimKind.Session, session: 'copilot:/session-1', turnId: 'turn-1' }); + terminalManager.fireClaimChanged({ kind: TerminalClaimKind.Session, session: 'copilot:/session-1', chat: buildDefaultChatUri('copilot:/session-1'), turnId: 'turn-1' }); const result = await resultPromise; assert.strictEqual(result.resultType, 'success'); assert.match(result.textResultForLlm, /continue this command in the background/); markCreatedTerminalsExist(terminalManager); const shell = shellManager.listShells()[0]; - const next = await shellManager.getOrCreateShell('bash', 'turn-2', 'tool-2'); + const next = await shellManager.getOrCreateShell('bash', TEST_CHAT_URI, 'turn-2', 'tool-2'); assert.notStrictEqual(next.object.id, shell.id); assert.strictEqual(terminalManager.created.length, 2); @@ -599,7 +602,7 @@ suite('CopilotShellTools', () => { const { instantiationService, terminalManager } = createServices(); terminalManager.commandDetectionSupported = true; const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); - const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); + const tools = await createShellTools(shellManager, TEST_CHAT_URI, terminalManager, new NullLogService()); const bashTool = tools.find(tool => tool.name === 'bash'); assert.ok(bashTool); @@ -611,14 +614,14 @@ suite('CopilotShellTools', () => { }; const resultPromise = bashTool.handler!({ command: 'sleep 100', timeout: 1000 }, invocation) as Promise; await waitForSentTexts(terminalManager, 1); - terminalManager.fireClaimChanged({ kind: TerminalClaimKind.Session, session: 'copilot:/session-1', turnId: 'turn-1' }); + terminalManager.fireClaimChanged({ kind: TerminalClaimKind.Session, session: 'copilot:/session-1', chat: buildDefaultChatUri('copilot:/session-1'), turnId: 'turn-1' }); const result = await resultPromise; assert.strictEqual(result.resultType, 'success'); markCreatedTerminalsExist(terminalManager); const shell = shellManager.listShells()[0]; terminalManager.fireCommandFinished({ commandId: 'cmd-1', exitCode: 0, command: 'sleep 100', output: '' }); - const next = await shellManager.getOrCreateShell('bash', 'turn-2', 'tool-2'); + const next = await shellManager.getOrCreateShell('bash', TEST_CHAT_URI, 'turn-2', 'tool-2'); assert.strictEqual(next.object.id, shell.id); assert.strictEqual(terminalManager.created.length, 1); @@ -629,7 +632,7 @@ suite('CopilotShellTools', () => { const { instantiationService, terminalManager } = createServices(); terminalManager.commandDetectionSupported = true; const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); - const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); + const tools = await createShellTools(shellManager, TEST_CHAT_URI, terminalManager, new NullLogService()); const bashTool = tools.find(tool => tool.name === 'bash'); assert.ok(bashTool); @@ -641,14 +644,14 @@ suite('CopilotShellTools', () => { }; const resultPromise = bashTool.handler!({ command: 'sleep 100', timeout: 1000 }, invocation) as Promise; await waitForSentTexts(terminalManager, 1); - terminalManager.fireClaimChanged({ kind: TerminalClaimKind.Session, session: 'copilot:/session-1', turnId: 'turn-1' }); + terminalManager.fireClaimChanged({ kind: TerminalClaimKind.Session, session: 'copilot:/session-1', chat: buildDefaultChatUri('copilot:/session-1'), turnId: 'turn-1' }); const result = await resultPromise; assert.strictEqual(result.resultType, 'success'); markCreatedTerminalsExist(terminalManager); const shell = shellManager.listShells()[0]; terminalManager.fireExit(0); - const next = await shellManager.getOrCreateShell('bash', 'turn-2', 'tool-2'); + const next = await shellManager.getOrCreateShell('bash', TEST_CHAT_URI, 'turn-2', 'tool-2'); assert.strictEqual(next.object.id, shell.id); assert.strictEqual(terminalManager.created.length, 1); @@ -658,7 +661,7 @@ suite('CopilotShellTools', () => { test('primary shell tool only forces bracketed paste for single-line commands on macOS', async () => { const { instantiationService, terminalManager } = createServices(); const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); - const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); + const tools = await createShellTools(shellManager, TEST_CHAT_URI, terminalManager, new NullLogService()); const bashTool = tools.find(tool => tool.name === 'bash'); assert.ok(bashTool); @@ -684,9 +687,9 @@ suite('CopilotShellTools', () => { test('write shell tool normalizes input without appending enter', async () => { const { instantiationService, terminalManager } = createServices(); const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); - const shellRef = await shellManager.getOrCreateShell('bash', 'turn-1', 'tool-1'); + const shellRef = await shellManager.getOrCreateShell('bash', TEST_CHAT_URI, 'turn-1', 'tool-1'); terminalManager.existingTerminalUris.add(shellRef.object.terminalUri); - const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); + const tools = await createShellTools(shellManager, TEST_CHAT_URI, terminalManager, new NullLogService()); const writeTool = tools.find(tool => tool.name === 'write_bash'); assert.ok(writeTool); @@ -718,7 +721,7 @@ suite('CopilotShellTools', () => { test('primary shell tool schema only exposes requestUnsandboxedExecution params when the sandbox is enabled', async () => { const enabled = createServices({ sandboxEnabled: true }); const enabledShell = disposables.add(enabled.instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-enabled'), undefined)); - const enabledTools = await createShellTools(enabledShell, enabled.terminalManager, new NullLogService()); + const enabledTools = await createShellTools(enabledShell, TEST_CHAT_URI, enabled.terminalManager, new NullLogService()); const enabledPrimary = enabledTools[0] as Tool; const enabledSchema = enabledPrimary.parameters as { properties: Record }; const enabledPropertyNames = Object.keys(enabledSchema.properties); @@ -728,7 +731,7 @@ suite('CopilotShellTools', () => { const disabled = createServices(); const disabledShell = disposables.add(disabled.instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-disabled'), undefined)); - const disabledTools = await createShellTools(disabledShell, disabled.terminalManager, new NullLogService()); + const disabledTools = await createShellTools(disabledShell, TEST_CHAT_URI, disabled.terminalManager, new NullLogService()); const disabledPrimary = disabledTools[0] as Tool; const disabledSchema = disabledPrimary.parameters as { properties: Record }; const disabledPropertyNames = Object.keys(disabledSchema.properties); @@ -740,7 +743,7 @@ suite('CopilotShellTools', () => { test('primary shell tool sends commands unwrapped when the sandbox is disabled', async () => { const { instantiationService, terminalManager } = createServices(); const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); - const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); + const tools = await createShellTools(shellManager, TEST_CHAT_URI, terminalManager, new NullLogService()); const bashTool = tools.find(tool => tool.name === 'bash'); assert.ok(bashTool); @@ -760,7 +763,7 @@ suite('CopilotShellTools', () => { test('primary shell tool wraps commands through the sandbox engine when the sandbox is enabled', async function () { const { instantiationService, terminalManager } = createServices({ sandboxEnabled: true }); const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); - const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); + const tools = await createShellTools(shellManager, TEST_CHAT_URI, terminalManager, new NullLogService()); const bashTool = tools.find(tool => tool.name === 'bash'); assert.ok(bashTool); @@ -793,7 +796,7 @@ suite('CopilotShellTools', () => { const workingDirectory = URI.file('/workspace/test-workspace'); const { instantiationService, terminalManager } = createServices({ sandboxEnabled: true, createdFiles }); const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), workingDirectory)); - const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); + const tools = await createShellTools(shellManager, TEST_CHAT_URI, terminalManager, new NullLogService()); const bashTool = tools.find(tool => tool.name === 'bash'); assert.ok(bashTool); @@ -826,7 +829,7 @@ suite('CopilotShellTools', () => { const { instantiationService, terminalManager, agentConfigurationService } = createServices({ sandboxEnabled: true, createdFiles }); agentConfigurationService.setSandboxValue(fileSystemKey, { allowRead: [configuredReadPath] }); const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), URI.file('/workspace/test-workspace'))); - const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); + const tools = await createShellTools(shellManager, TEST_CHAT_URI, terminalManager, new NullLogService()); const bashTool = tools.find(tool => tool.name === 'bash'); assert.ok(bashTool); @@ -859,7 +862,7 @@ suite('CopilotShellTools', () => { terminalManager.commandDetectionSupported = true; const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); const confirmationRequests: IUnsandboxedCommandConfirmationRequest[] = []; - const tools = await createShellTools(shellManager, terminalManager, new NullLogService(), async request => { + const tools = await createShellTools(shellManager, TEST_CHAT_URI, terminalManager, new NullLogService(), async request => { confirmationRequests.push(request); return true; }); @@ -898,7 +901,7 @@ suite('CopilotShellTools', () => { const { instantiationService, terminalManager, agentConfigurationService } = createServices({ sandboxEnabled: true }); agentConfigurationService.setSandboxValue(AgentHostSandboxKey.AllowUnsandboxedCommands, true); const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); - const tools = await createShellTools(shellManager, terminalManager, new NullLogService(), async () => false); + const tools = await createShellTools(shellManager, TEST_CHAT_URI, terminalManager, new NullLogService(), async () => false); const bashTool = tools.find(tool => tool.name === 'bash'); assert.ok(bashTool); @@ -921,7 +924,7 @@ suite('CopilotShellTools', () => { agentConfigurationService.setSandboxValue(AgentHostSandboxKey.AllowUnsandboxedCommands, true); const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); const confirmationRequests: IUnsandboxedCommandConfirmationRequest[] = []; - const tools = await createShellTools(shellManager, terminalManager, new NullLogService(), async request => { + const tools = await createShellTools(shellManager, TEST_CHAT_URI, terminalManager, new NullLogService(), async request => { confirmationRequests.push(request); return false; }); @@ -959,7 +962,7 @@ suite('CopilotShellTools', () => { // must surface a dedicated failure instead. const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); const confirmationRequests: IUnsandboxedCommandConfirmationRequest[] = []; - const tools = await createShellTools(shellManager, terminalManager, new NullLogService(), async request => { + const tools = await createShellTools(shellManager, TEST_CHAT_URI, terminalManager, new NullLogService(), async request => { confirmationRequests.push(request); return true; }); diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts index 23b99e64a02b5c..622501ce6bd762 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts @@ -521,7 +521,7 @@ export async function driveChatTurnToCompletion(c: TestProtocolClient, chat: str action: { type: ActionType.ChatTurnStarted, turnId, - startedAt: '2025-01-01T00:00:00.000Z', + startedAt: new Date().toISOString(), message: { text, origin: { kind: MessageKind.User } }, }, })); @@ -538,7 +538,7 @@ export async function driveTurnWithModelToCompletion(c: TestProtocolClient, sess action: { type: ActionType.ChatTurnStarted, turnId, - startedAt: '2025-01-01T00:00:00.000Z', + startedAt: new Date().toISOString(), message: { text, origin: { kind: MessageKind.User }, model: { id: model } }, }, })); diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/copilotPromptsE2E.integrationTest.ts b/src/vs/platform/agentHost/test/node/e2e/providers/copilotPromptsE2E.integrationTest.ts index 60234720badfa3..fc4f75920995fd 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/copilotPromptsE2E.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/copilotPromptsE2E.integrationTest.ts @@ -163,7 +163,7 @@ async function driveTurnWithModel(c: TestProtocolClient, sessionUri: string, mod action: { type: ActionType.ChatTurnStarted, turnId: `turn-${model}`, - startedAt: '2025-01-01T00:00:00.000Z', + startedAt: new Date().toISOString(), message: { text: 'Say exactly "ok"', origin: { kind: MessageKind.User }, diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/annotationsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/annotationsSuite.ts index 160709a9663c68..0b93a3f407d5e4 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/annotationsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/annotationsSuite.ts @@ -25,6 +25,7 @@ import { generateUuid } from '../../../../../../base/common/uuid.js'; import type { SubscribeResult } from '../../../../common/state/protocol/commands.js'; import { ActionType } from '../../../../common/state/sessionActions.js'; import { buildAnnotationsUri } from '../../../../common/annotationsUri.js'; +import { buildDefaultChatUri, type AnnotationsState, type StringOrMarkdown } from '../../../../common/state/sessionState.js'; import { createRealSession } from '../harness/agentHostE2ETestHarness.js'; import { getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; import { conformanceTest, type IAgentHostE2ETestContext } from './e2eTestContext.js'; @@ -32,9 +33,13 @@ import { conformanceTest, type IAgentHostE2ETestContext } from './e2eTestContext /** The subset of `Annotation` these tests assert on. */ interface IObservedAnnotation { readonly id: string; - readonly turnId: string; + readonly origin: { + readonly session: string; + readonly chat?: string; + readonly turnId?: string; + }; readonly resolved: boolean; - readonly entries: readonly { readonly id: string; readonly text: string }[]; + readonly entries: readonly { readonly id: string; readonly text: StringOrMarkdown }[]; } export function defineAnnotationsTests(context: IAgentHostE2ETestContext): void { @@ -75,18 +80,22 @@ export function defineAnnotationsTests(context: IAgentHostE2ETestContext): void 30_000, ); const subscribed = await context.client.call('subscribe', { channel }); - return (subscribed.snapshot!.state as { annotations: IObservedAnnotation[] }).annotations; + const state = subscribed.snapshot!.state; + if (!isAnnotationsState(state)) { + throw new Error(`Expected annotations state for ${channel}`); + } + return state.annotations; } conformanceTest(context, 'an annotation dispatched by a client is applied to the channel', async function () { - const { annotationsUri, resource } = await createAnnotatedSession('annotations-set'); + const { sessionUri, annotationsUri, resource } = await createAnnotatedSession('annotations-set'); const annotationId = generateUuid(); dispatchAnnotationAction(annotationsUri, { type: ActionType.AnnotationsSet, annotation: { id: annotationId, - turnId: 'turn-annotate', + origin: { session: sessionUri, chat: buildDefaultChatUri(sessionUri), turnId: 'turn-annotate' }, resource, resolved: false, entries: [{ id: `${annotationId}:0`, text: 'needs a second look' }], @@ -97,31 +106,31 @@ export function defineAnnotationsTests(context: IAgentHostE2ETestContext): void assert.deepStrictEqual(annotations.map(annotation => ({ id: annotation.id, - turnId: annotation.turnId, + origin: annotation.origin, resolved: annotation.resolved, entries: annotation.entries.map(entry => entry.text), })), [{ id: annotationId, - turnId: 'turn-annotate', + origin: { session: sessionUri, chat: buildDefaultChatUri(sessionUri), turnId: 'turn-annotate' }, resolved: false, entries: ['needs a second look'], }]); }); conformanceTest(context, 'an annotation can be resolved without resending its entries', async function () { - const { annotationsUri, resource } = await createAnnotatedSession('annotations-resolve'); + const { sessionUri, annotationsUri, resource } = await createAnnotatedSession('annotations-resolve'); const annotationId = generateUuid(); dispatchAnnotationAction(annotationsUri, { type: ActionType.AnnotationsSet, - annotation: { id: annotationId, turnId: 'turn-resolve', resource, resolved: false, entries: [{ id: `${annotationId}:0`, text: 'why this branch?' }] }, + annotation: { id: annotationId, origin: { session: sessionUri, chat: buildDefaultChatUri(sessionUri), turnId: 'turn-resolve' }, resource, resolved: false, entries: [{ id: `${annotationId}:0`, text: 'why this branch?' }] }, }); await annotationsAfter(annotationsUri, 'annotations/set'); // `annotations/updated` carries only the fields that change, so // resolving must not disturb the entries already on the annotation. context.client.clearReceived(); - dispatchAnnotationAction(annotationsUri, { type: ActionType.AnnotationsUpdated, annotationId, resolved: true }); + dispatchAnnotationAction(annotationsUri, { type: ActionType.AnnotationsUpdated, annotationId, resolved: true, origin: { session: sessionUri, chat: buildDefaultChatUri(sessionUri), turnId: 'turn-resolve' } }); const annotations = await annotationsAfter(annotationsUri, 'annotations/updated'); @@ -135,13 +144,13 @@ export function defineAnnotationsTests(context: IAgentHostE2ETestContext): void }); conformanceTest(context, 'entries can be added to and removed from an annotation', async function () { - const { annotationsUri, resource } = await createAnnotatedSession('annotations-entries'); + const { sessionUri, annotationsUri, resource } = await createAnnotatedSession('annotations-entries'); const annotationId = generateUuid(); const replyId = `${annotationId}:1`; dispatchAnnotationAction(annotationsUri, { type: ActionType.AnnotationsSet, - annotation: { id: annotationId, turnId: 'turn-entries', resource, resolved: false, entries: [{ id: `${annotationId}:0`, text: 'original' }] }, + annotation: { id: annotationId, origin: { session: sessionUri, chat: buildDefaultChatUri(sessionUri), turnId: 'turn-entries' }, resource, resolved: false, entries: [{ id: `${annotationId}:0`, text: 'original' }] }, }); await annotationsAfter(annotationsUri, 'annotations/set'); @@ -163,12 +172,12 @@ export function defineAnnotationsTests(context: IAgentHostE2ETestContext): void }); conformanceTest(context, 'removing an annotation clears it from the channel', async function () { - const { annotationsUri, resource } = await createAnnotatedSession('annotations-remove'); + const { sessionUri, annotationsUri, resource } = await createAnnotatedSession('annotations-remove'); const annotationId = generateUuid(); dispatchAnnotationAction(annotationsUri, { type: ActionType.AnnotationsSet, - annotation: { id: annotationId, turnId: 'turn-remove', resource, resolved: false, entries: [{ id: `${annotationId}:0`, text: 'transient' }] }, + annotation: { id: annotationId, origin: { session: sessionUri, chat: buildDefaultChatUri(sessionUri), turnId: 'turn-remove' }, resource, resolved: false, entries: [{ id: `${annotationId}:0`, text: 'transient' }] }, }); await annotationsAfter(annotationsUri, 'annotations/set'); @@ -178,3 +187,7 @@ export function defineAnnotationsTests(context: IAgentHostE2ETestContext): void assert.deepStrictEqual(await annotationsAfter(annotationsUri, 'annotations/removed'), []); }); } + +function isAnnotationsState(state: NonNullable['state']): state is AnnotationsState { + return 'annotations' in state; +} diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts index e6eb9d769d1888..d6dc271bbf6585 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts @@ -1413,7 +1413,7 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { action: { type: ActionType.ChatTurnStarted, turnId: 'turn-provider-peer-edit', - startedAt: '2025-01-01T00:00:00.000Z', + startedAt: new Date().toISOString(), message: { text: 'Create peer-provider.txt containing exactly PEER_PROVIDER using your file creation tool; do not run a shell command. Then reply exactly "created".', origin: { kind: MessageKind.User }, diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/copilotCoverageSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/copilotCoverageSuite.ts index af420fcab3c7ea..2a7bac3ea25801 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/copilotCoverageSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/copilotCoverageSuite.ts @@ -16,7 +16,7 @@ import { AgentHostConfigKey } from '../../../../common/agentHostCustomizationCon import { AgentHostAutoReplyEnabledConfigKey } from '../../../../common/agentHostSchema.js'; import { buildUncommittedChangesetUri } from '../../../../common/changesetUri.js'; import { CopilotCliConfigKey } from '../../../../common/copilotCliConfig.js'; -import { CompletionItemKind, type CompletionsResult, type ListSessionsResult, type SubscribeResult } from '../../../../common/state/protocol/commands.js'; +import { CompletionItemKind, type CompletionsResult, type SubscribeResult } from '../../../../common/state/protocol/commands.js'; import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; import { ActionType, type ChatErrorAction, type ChatToolCallCompleteAction, type ChatToolCallContentChangedAction, type ChatToolCallReadyAction, type ChatToolCallStartAction } from '../../../../common/state/sessionActions.js'; import { buildDefaultChatUri, MessageKind, ResponsePartKind, ROOT_STATE_URI, ToolCallStatus, ToolResultContentType, type ChangesetState, type SessionState } from '../../../../common/state/sessionState.js'; @@ -108,7 +108,7 @@ export function defineCopilotCoverageTests(context: IAgentHostE2ETestContext): v action: { type: ActionType.ChatTurnStarted, turnId, - startedAt: '2025-01-01T00:00:00.000Z', + startedAt: new Date().toISOString(), message: { text: 'Search for the get_magic_word tool before using it. Call get_magic_word exactly once, then reply with only its result.', origin: { kind: MessageKind.User }, @@ -170,28 +170,6 @@ export function defineCopilotCoverageTests(context: IAgentHostE2ETestContext): v return { toolNames: [...starts.values()], responseText: getMarkdownResponseText(context.client) }; } - async function createFork(sourceSessionUri: string, sourceTurnId: string): Promise { - const forkUri = URI.from({ scheme: config.scheme, path: `/${generateUuid()}` }).toString(); - await context.client.call('createSession', { - channel: forkUri, - provider: config.provider, - fork: { session: sourceSessionUri, turnId: sourceTurnId }, - config: { isolation: 'folder' }, - }, 90_000); - createdSessions.push(forkUri); - await context.client.call('subscribe', { channel: forkUri }); - await context.client.call('subscribe', { channel: buildDefaultChatUri(forkUri) }); - context.client.clearReceived(); - return forkUri; - } - - async function assertSessionListed(sessionUri: string): Promise { - await retry(async () => { - const listed = await context.client.call('listSessions', { channel: ROOT_STATE_URI }); - assert.ok(listed.items.some(session => session.resource === sessionUri)); - }, 100, 100); - } - // Windows retains the provider scratch directory after session disposal. (context.isWindows ? test.skip : test)('workspaceless session uses and cleans up a provider scratch directory', async function () { this.timeout(180_000); @@ -423,37 +401,6 @@ export function defineCopilotCoverageTests(context: IAgentHostE2ETestContext): v } }); - test('session fork inherits provider history through the selected source turn', async function () { - this.timeout(240_000); - const { sessionUri, workspace } = await createWorkspaceSession('session-fork-history'); - await driveTurnToCompletion(context.client, sessionUri, 'turn-fork-alpha', 'Remember FORK_ALPHA. Reply exactly "ready".', 1); - await assertSessionListed(sessionUri); - const forkUri = await createFork(sessionUri, 'turn-fork-alpha'); - - await context.restartServer(); - await initialize('session-fork-history-restored-client', workspace); - await context.client.call('subscribe', { channel: forkUri }); - await context.client.call('subscribe', { channel: buildDefaultChatUri(forkUri) }); - const restored = await fetchSessionWithChat(context.client, forkUri); - assert.deepStrictEqual(restored.turns.map(turn => turn.message.text), ['Remember FORK_ALPHA. Reply exactly "ready".']); - - const reforkUri = await createFork(forkUri, restored.turns[0].id); - const result = await driveTurnToCompletion(context.client, reforkUri, 'turn-fork-followup', 'Reply with only the code word you were asked to remember.', 10); - assert.ok(result.responseText.includes('FORK_ALPHA')); - }); - - test('session fork excludes provider history after the selected source turn', async function () { - this.timeout(240_000); - const { sessionUri } = await createWorkspaceSession('session-fork-bounded'); - await driveTurnToCompletion(context.client, sessionUri, 'turn-fork-first', 'Remember FORK_FIRST. Reply exactly "ready".', 1); - await driveTurnToCompletion(context.client, sessionUri, 'turn-fork-later', 'Now remember FORK_LATER too. Reply exactly "ready".', 10); - await assertSessionListed(sessionUri); - const forkUri = await createFork(sessionUri, 'turn-fork-first'); - - const result = await driveTurnToCompletion(context.client, forkUri, 'turn-fork-bounded-followup', 'Reply exactly "bounded" if you remember FORK_FIRST but not FORK_LATER.', 20); - assert.strictEqual(result.responseText.trim(), 'bounded'); - }); - test('view range returns only the requested workspace lines', async function () { this.timeout(180_000); const { sessionUri, workspace } = await createWorkspaceSession('view-range'); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/coreSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/coreSuite.ts index 5c16728f3cd701..4721eb2960ecf5 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/coreSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/coreSuite.ts @@ -692,7 +692,7 @@ export function defineCoreTests(context: IAgentHostE2ETestContext): void { action: { type: ActionType.ChatTurnStarted, turnId, - startedAt: '2025-01-01T00:00:00.000Z', + startedAt: new Date().toISOString(), message: { text: 'This turn must fail before contacting a model.', origin: { kind: MessageKind.User }, diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts index 8a0c18181121f8..8a07b9d83dd9f5 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts @@ -195,7 +195,7 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void { action: { type: ActionType.ChatTurnStarted, turnId, - startedAt: '2025-01-01T00:00:00.000Z', + startedAt: new Date().toISOString(), message: { text, origin: { kind: MessageKind.User }, ...(attachments ? { attachments: [...attachments] } : {}) }, }, }); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/protocolContractsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/protocolContractsSuite.ts index d7e00e06f63fef..7b5a740b34af92 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/protocolContractsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/protocolContractsSuite.ts @@ -1192,32 +1192,6 @@ export function defineProtocolContractTests(context: IAgentHostE2ETestContext): }), { code: AhpErrorCodes.SessionAlreadyExists }); }, context.runHostOnlyKnownIssueTests); - conformanceTest(context, 'a session cannot fork onto its own resource', async function () { - const { sessionUri } = await createSession('self-fork'); - - await assert.rejects(context.client.call('createSession', { - channel: sessionUri, - provider: config.provider, - fork: { session: sessionUri, turnId: 'irrelevant' }, - }), { code: AhpErrorCodes.SessionAlreadyExists }); - }); - - conformanceTest(context, 'forking from a missing session is rejected', async function () { - const target = URI.from({ scheme: config.scheme, path: `/${generateUuid()}` }).toString(); - const missingSource = URI.from({ scheme: config.scheme, path: `/${generateUuid()}` }).toString(); - await context.client.call('initialize', { - channel: ROOT_STATE_URI, - protocolVersions: [PROTOCOL_VERSION], - clientId: `missing-fork-source-${config.provider}`, - }); - - await assert.rejects(context.client.call('createSession', { - channel: target, - provider: config.provider, - fork: { session: missingSource, turnId: 'missing-turn' }, - }), { code: AhpErrorCodes.SessionNotFound }); - }); - conformanceTest(context, 'createSession rejects an active client owned by another connection', async function () { const client = await context.connectClient(); try { diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts index f0f0a3c6196de8..677ee91138fb56 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts @@ -179,7 +179,7 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void type: ActionType.AnnotationsSet, annotation: { id: options.id, - turnId: 'seed-feedback', + origin: { session: sessionUri, chat: buildDefaultChatUri(sessionUri), turnId: 'seed-feedback' }, resource: options.resource, range: { start: { line: 1, character: 2 }, end: { line: 1, character: 8 } }, resolved: options.resolved ?? false, diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts index 6d6dc47c2cb152..1e6b5ffeeb2bbd 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts @@ -152,7 +152,7 @@ export function defineSessionPersistenceTests(context: IAgentHostE2ETestContext) action: { type: ActionType.ChatTurnStarted, turnId: 'turn-peer-local', - startedAt: '2025-01-01T00:00:00.000Z', + startedAt: new Date().toISOString(), message: { text: '/rename Rehydrated Peer', origin: { kind: MessageKind.User } }, }, }); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/stateOperationsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/stateOperationsSuite.ts index 50b05e209afd93..79128f2d2c0e82 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/stateOperationsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/stateOperationsSuite.ts @@ -12,7 +12,7 @@ import { generateUuid } from '../../../../../../base/common/uuid.js'; import { SessionConfigKey } from '../../../../common/sessionConfigKeys.js'; import { ActionType, type StateAction } from '../../../../common/state/sessionActions.js'; import type { SubscribeResult } from '../../../../common/state/protocol/commands.js'; -import { TerminalClaimKind, type TerminalClaim } from '../../../../common/state/protocol/state.js'; +import { TerminalClaimKind, TerminalLifecycleStatus, type TerminalClaim } from '../../../../common/state/protocol/state.js'; import { buildDefaultChatUri, MessageAttachmentKind, @@ -492,7 +492,7 @@ export function defineStateOperationsTests(context: IAgentHostE2ETestContext): v await withTerminal('terminal-claim-metadata', async ({ sessionUri, terminalUri, workspace }) => { await dispatchAndWait(terminalUri, 1, { type: ActionType.TerminalClaimed, - claim: { kind: TerminalClaimKind.Session, session: sessionUri }, + claim: { kind: TerminalClaimKind.Session, session: sessionUri, chat: buildDefaultChatUri(sessionUri) }, }); const state = await terminalState(terminalUri); @@ -527,7 +527,7 @@ export function defineStateOperationsTests(context: IAgentHostE2ETestContext): v conformanceTest(context, 'terminal claim can transfer from the client to the session', async function () { await withTerminal('terminal-claim', async ({ sessionUri, terminalUri }) => { - const claim: TerminalClaim = { kind: TerminalClaimKind.Session, session: sessionUri }; + const claim: TerminalClaim = { kind: TerminalClaimKind.Session, session: sessionUri, chat: buildDefaultChatUri(sessionUri) }; await dispatchAndWait(terminalUri, 1, { type: ActionType.TerminalClaimed, claim }); assert.deepStrictEqual((await terminalState(terminalUri)).claim, claim); }); @@ -537,7 +537,7 @@ export function defineStateOperationsTests(context: IAgentHostE2ETestContext): v await withTerminal('terminal-claim-return', async ({ sessionUri, terminalUri, clientId }) => { await dispatchAndWait(terminalUri, 1, { type: ActionType.TerminalClaimed, - claim: { kind: TerminalClaimKind.Session, session: sessionUri }, + claim: { kind: TerminalClaimKind.Session, session: sessionUri, chat: buildDefaultChatUri(sessionUri) }, }); const clientClaim: TerminalClaim = { kind: TerminalClaimKind.Client, clientId }; @@ -552,6 +552,7 @@ export function defineStateOperationsTests(context: IAgentHostE2ETestContext): v const claim: TerminalClaim = { kind: TerminalClaimKind.Session, session: sessionUri, + chat: buildDefaultChatUri(sessionUri), turnId: 'turn-claim', toolCallId: 'tool-claim', }; @@ -680,9 +681,10 @@ export function defineStateOperationsTests(context: IAgentHostE2ETestContext): v // The exit code itself is the shell's, not the host's, so only its // presence and its arrival in state are contractual. const action = getActionEnvelope(exited).action as { exitCode?: number }; + const lifecycle = (await terminalState(terminalUri)).lifecycle; assert.deepStrictEqual({ reportedExitCode: typeof action.exitCode, - stateMatchesNotification: (await terminalState(terminalUri)).exitCode === action.exitCode, + stateMatchesNotification: lifecycle.status === TerminalLifecycleStatus.Exited && lifecycle.exitCode === action.exitCode, }, { reportedExitCode: 'number', stateMatchesNotification: true, @@ -761,7 +763,12 @@ export function defineStateOperationsTests(context: IAgentHostE2ETestContext): v conformanceTest(context, 'root terminal metadata reflects claim transfers', async function () { await withTerminal('terminal-root-claim', async ({ sessionUri, terminalUri }) => { await context.client.call('subscribe', { channel: ROOT_STATE_URI }); - const claim: TerminalClaim = { kind: TerminalClaimKind.Session, session: sessionUri, turnId: 'turn-root-claim' }; + const claim: TerminalClaim = { + kind: TerminalClaimKind.Session, + session: sessionUri, + chat: buildDefaultChatUri(sessionUri), + turnId: 'turn-root-claim', + }; context.client.clearReceived(); context.client.dispatch({ channel: terminalUri, @@ -802,10 +809,11 @@ export function defineStateOperationsTests(context: IAgentHostE2ETestContext): v const root = await context.client.call('subscribe', { channel: ROOT_STATE_URI }); const terminal = (root.snapshot!.state as RootState).terminals?.find(terminal => terminal.resource === terminalUri); + const lifecycle = terminal?.lifecycle; assert.deepStrictEqual({ listed: terminal !== undefined, reportedExitCode: typeof exitCode, - stateMatchesNotification: terminal?.exitCode === exitCode, + stateMatchesNotification: lifecycle?.status === TerminalLifecycleStatus.Exited && lifecycle.exitCode === exitCode, }, { listed: true, reportedExitCode: 'number', diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts index d9eaef8de1459e..6effc6997a0445 100644 --- a/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts @@ -6,8 +6,7 @@ import assert from 'assert'; import { timeout } from '../../../../../base/common/async.js'; import { SubscribeResult } from '../../../common/state/protocol/commands.js'; -import { ActionType, type IResponsePartAction, type ITurnStartedAction, type SessionAddedParams, type ITitleChangedAction } from '../../../common/state/sessionActions.js'; -import { PROTOCOL_VERSION } from '../../../common/state/protocol/version/registry.js'; +import { ActionType, type IResponsePartAction, type ITurnStartedAction, type ITitleChangedAction } from '../../../common/state/sessionActions.js'; import type { ListSessionsResult } from '../../../common/state/sessionProtocol.js'; import { MessageKind, PendingMessageKind, ResponsePartKind, ROOT_STATE_URI, type ISessionWithDefaultChat } from '../../../common/state/sessionState.js'; import { MOCK_AUTO_TITLE } from '../mockAgent.js'; @@ -20,7 +19,6 @@ import { getActionEnvelope, isActionNotification, IServerHandle, - nextSessionUri, startServer, stopServer, TestProtocolClient, @@ -174,7 +172,7 @@ suite('Protocol WebSocket — Session Features', function () { action: { type: ActionType.ChatTurnStarted, turnId: 'turn-model', - startedAt: '2025-01-01T00:00:00.000Z', + startedAt: new Date().toISOString(), message: { text: 'hello', origin: { kind: MessageKind.User }, model: { id: 'mock-model' } }, }, }); @@ -438,79 +436,4 @@ suite('Protocol WebSocket — Session Features', function () { assert.strictEqual(state.turns[1].id, 'turn-tr3'); }); - // ---- Fork ----------------------------------------------------------------- - - test('fork creates a new session with source history', async function () { - this.timeout(15_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-fork'); - - // Create two turns - dispatchTurnStarted(client, sessionUri, 'turn-f1', 'hello', 1); - await client.waitForNotification(n => isActionNotification(n, 'chat/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-f1'); - - client.clearReceived(); - dispatchTurnStarted(client, sessionUri, 'turn-f2', 'hello', 2); - await client.waitForNotification(n => isActionNotification(n, 'chat/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-f2'); - - client.clearReceived(); - - // Fork at turn-f1 (keep turns up to and including turn-f1) - const forkedSessionUri = nextSessionUri(); - await client.call('createSession', { - channel: forkedSessionUri, - provider: 'mock', - fork: { session: sessionUri, turnId: 'turn-f1' }, - }); - - const addedNotif = await client.waitForNotification(n => - n.method === 'root/sessionAdded' - ); - const addedSession = addedNotif.params as SessionAddedParams; - - // Subscribe — forked session should have 1 turn - const state = await fetchSessionWithChat(client, addedSession.summary.resource); - assert.strictEqual(state.lifecycle, 'ready'); - assert.strictEqual(state.turns.length, 1, 'forked session should have 1 turn'); - - // Source session should be unaffected - const sourceState = await fetchSessionWithChat(client, sessionUri); - assert.strictEqual(sourceState.turns.length, 2); - }); - - test('fork with invalid turn ID returns error', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-fork-invalid'); - - let gotError = false; - try { - await client.call('createSession', { - channel: nextSessionUri(), - provider: 'mock', - fork: { session: sessionUri, turnId: 'nonexistent-turn' }, - }); - } catch { - gotError = true; - } - assert.ok(gotError, 'should get error for invalid fork turn ID'); - }); - - test('fork with invalid source session returns error', async function () { - this.timeout(10_000); - - await client.call('initialize', { channel: ROOT_STATE_URI, protocolVersions: [PROTOCOL_VERSION], clientId: 'test-fork-no-source' }); - - let gotError = false; - try { - await client.call('createSession', { - channel: nextSessionUri(), - provider: 'mock', - fork: { session: 'mock://nonexistent-session', turnId: 'turn-1' }, - }); - } catch { - gotError = true; - } - assert.ok(gotError, 'should get error for invalid fork source session'); - }); }); diff --git a/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts index 42ff64074f45aa..c69187668fa465 100644 --- a/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts @@ -107,7 +107,7 @@ suite('Protocol WebSocket — Turn Execution', function () { client.notify('dispatchAction', { channel: defaultChatChannel(sessionUri), clientSeq: 2, - action: { type: 'chat/turnCancelled', turnId: 'turn-cancel' }, + action: { type: 'chat/turnCancelled', turnId: 'turn-cancel', duration: 10 }, }); await client.waitForNotification(n => isActionNotification(n, 'chat/turnCancelled')); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 00fa11c237796a..7e0392424bea76 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -1113,7 +1113,7 @@ suite('ProtocolServerHandler', () => { .map(message => (message.params as SessionSummaryChangedParams).changes); assert.deepStrictEqual({ listedMeta, summaryChanges }, { listedMeta: { providerOnly: true, live: 'current' }, - summaryChanges: [{ status: SessionStatus.InProgress }], + summaryChanges: [{ modifiedAt: startedAt, status: SessionStatus.InProgress }], }); }); }); @@ -1354,30 +1354,6 @@ suite('ProtocolServerHandler', () => { }); }); - test('createSession rejects a fork targeting its source session', async () => { - const transport = connectClient('client-self-fork'); - transport.sent.length = 0; - const responsePromise = waitForResponse(transport, 2); - const session = URI.parse('copilot:///same-session').toString(); - - transport.simulateMessage(request(2, 'createSession', { - channel: session, - provider: 'copilot', - fork: { session, turnId: 'turn-1' }, - })); - const response = await responsePromise as { error?: { code: number; message: string } }; - - assert.deepStrictEqual({ - errorCode: response.error?.code, - errorMessage: response.error?.message, - createCalls: agentService.createSessionConfigs.length, - }, { - errorCode: AhpErrorCodes.SessionAlreadyExists, - errorMessage: `Fork target session must differ from source session: ${session}`, - createCalls: 0, - }); - }); - test('whenIdle waits for in-flight protocol requests after disposal', async () => { const transport = connectClient('client-drain'); agentService.createSessionBarrier = new DeferredPromise(); diff --git a/src/vs/platform/agentHost/test/node/providerIntegrationTestHelpers.ts b/src/vs/platform/agentHost/test/node/providerIntegrationTestHelpers.ts index a04ad370e0478e..0edf3cead3ebc9 100644 --- a/src/vs/platform/agentHost/test/node/providerIntegrationTestHelpers.ts +++ b/src/vs/platform/agentHost/test/node/providerIntegrationTestHelpers.ts @@ -54,7 +54,7 @@ export function dispatchTurn(client: TestProtocolClient, session: string, turnId action: { type: ActionType.ChatTurnStarted, turnId, - startedAt: '2025-01-01T00:00:00.000Z', + startedAt: new Date().toISOString(), message: { text, origin: { kind: MessageKind.User } }, }, }); @@ -67,7 +67,7 @@ export function dispatchTurnWithAttachments(client: TestProtocolClient, session: action: { type: ActionType.ChatTurnStarted, turnId, - startedAt: '2025-01-01T00:00:00.000Z', + startedAt: new Date().toISOString(), message: { text, origin: { kind: MessageKind.User }, attachments: [...attachments] }, }, }); diff --git a/src/vs/platform/agentHost/test/node/reducers.test.ts b/src/vs/platform/agentHost/test/node/reducers.test.ts index ce518892abcfcf..a44e41c1abe318 100644 --- a/src/vs/platform/agentHost/test/node/reducers.test.ts +++ b/src/vs/platform/agentHost/test/node/reducers.test.ts @@ -7,8 +7,9 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { sortCustomizationEnablement, withCustomizationEnablement } from '../../common/customizationEnablement.js'; import { changesetReducer, chatReducer, sessionReducer } from '../../common/state/protocol/reducers.js'; +import { ChatInputRequestPurpose, withChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { ChangesetStatus, ChangesetOperationStatus, CustomizationLoadStatus, MessageKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputRequestPurpose, ChatInputResponseKind, ChatOriginKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ResponsePartKind, ToolCallStatus, TurnState, type AgentCustomization, type ChangesetState, type Customization, type PluginCustomization, type ChatState, type SessionState } from '../../common/state/sessionState.js'; +import { ChangesetStatus, ChangesetOperationStatus, CustomizationLoadStatus, MessageKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ChatOriginKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ResponsePartKind, ToolCallStatus, TurnState, type AgentCustomization, type ChangesetState, type Customization, type PluginCustomization, type ChatState, type SessionState } from '../../common/state/sessionState.js'; import { CustomizationEnablementKind, CustomizationType, McpServerStatus, ToolCallContributorKind, type ToolCallContributor } from '../../common/state/protocol/state.js'; function makeSession(): SessionState { @@ -180,17 +181,16 @@ suite('chatReducer – summaryStatus with tool call confirmations and input requ state = chatReducer(state, { type: ActionType.ChatInputRequested, - request: { + request: withChatInputRequestPurpose({ id: 'req-1', - purpose: ChatInputRequestPurpose.AskUser, message: 'What is your name?', questions: [{ kind: ChatInputQuestionKind.Text, id: 'q-1', message: 'What is your name?', required: true - }] - }, + }], + }, ChatInputRequestPurpose.AskUser), }); assert.deepStrictEqual({ @@ -200,9 +200,8 @@ suite('chatReducer – summaryStatus with tool call confirmations and input requ status: SessionStatus.InputNeeded, responsePart: { kind: ResponsePartKind.InputRequest, - request: { + request: withChatInputRequestPurpose({ id: 'req-1', - purpose: ChatInputRequestPurpose.AskUser, message: 'What is your name?', questions: [{ kind: ChatInputQuestionKind.Text, @@ -210,7 +209,7 @@ suite('chatReducer – summaryStatus with tool call confirmations and input requ message: 'What is your name?', required: true, }], - }, + }, ChatInputRequestPurpose.AskUser), }, }); }); @@ -219,11 +218,10 @@ suite('chatReducer – summaryStatus with tool call confirmations and input requ let state = withActiveTurnAndToolCall(makeChat()); state = chatReducer(state, { type: ActionType.ChatInputRequested, - request: { + request: withChatInputRequestPurpose({ id: 'req-1', - purpose: ChatInputRequestPurpose.AskUser, questions: [{ kind: ChatInputQuestionKind.Text, id: 'q-1', message: 'First?' }], - }, + }, ChatInputRequestPurpose.AskUser), }); state = chatReducer(state, { type: ActionType.ChatInputAnswerChanged, @@ -233,11 +231,10 @@ suite('chatReducer – summaryStatus with tool call confirmations and input requ }); state = chatReducer(state, { type: ActionType.ChatInputRequested, - request: { + request: withChatInputRequestPurpose({ id: 'req-1', - purpose: ChatInputRequestPurpose.AskUser, questions: [{ kind: ChatInputQuestionKind.Text, id: 'q-1', message: 'Updated?' }], - }, + }, ChatInputRequestPurpose.AskUser), }); state = chatReducer(state, { type: ActionType.ChatInputCompleted, @@ -247,14 +244,13 @@ suite('chatReducer – summaryStatus with tool call confirmations and input requ assert.deepStrictEqual(state.activeTurn?.responseParts.at(-1), { kind: ResponsePartKind.InputRequest, - request: { + request: withChatInputRequestPurpose({ id: 'req-1', - purpose: ChatInputRequestPurpose.AskUser, questions: [{ kind: ChatInputQuestionKind.Text, id: 'q-1', message: 'Updated?' }], answers: { 'q-1': { state: ChatInputAnswerState.Submitted, value: { kind: ChatInputAnswerValueKind.Text, value: 'answer' } }, }, - }, + }, ChatInputRequestPurpose.AskUser), response: ChatInputResponseKind.Accept, }); }); @@ -280,17 +276,16 @@ suite('chatReducer – summaryStatus with tool call confirmations and input requ // Add an input request state = chatReducer(state, { type: ActionType.ChatInputRequested, - request: { + request: withChatInputRequestPurpose({ id: 'req-1', - purpose: ChatInputRequestPurpose.AskUser, message: 'What is your name?', questions: [{ kind: ChatInputQuestionKind.Text, id: 'q-1', message: 'What is your name?', required: true - }] - }, + }], + }, ChatInputRequestPurpose.AskUser), }); assert.strictEqual(state.status, SessionStatus.InputNeeded); @@ -309,9 +304,8 @@ suite('chatReducer – summaryStatus with tool call confirmations and input requ status: SessionStatus.InProgress, responsePart: { kind: ResponsePartKind.InputRequest, - request: { + request: withChatInputRequestPurpose({ id: 'req-1', - purpose: ChatInputRequestPurpose.AskUser, message: 'What is your name?', questions: [{ kind: ChatInputQuestionKind.Text, @@ -325,7 +319,7 @@ suite('chatReducer – summaryStatus with tool call confirmations and input requ value: { kind: ChatInputAnswerValueKind.Text, value: 'Alice' }, }, }, - }, + }, ChatInputRequestPurpose.AskUser), response: ChatInputResponseKind.Accept, }, }); diff --git a/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts b/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts index 308abd7dbc5eda..1f626bee574505 100644 --- a/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts +++ b/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts @@ -1011,7 +1011,10 @@ export function dispatchTurnStarted(c: TestProtocolClient, session: string, turn action: { type: ActionType.ChatTurnStarted, turnId, - startedAt: '2025-01-01T00:00:00.000Z', + // A real timestamp, because the chat reducer derives `modifiedAt` + // from the turn: a fixed past `startedAt` would make a completed + // turn look older than the session it belongs to. + startedAt: new Date().toISOString(), message: { text, origin: { kind: MessageKind.User } }, }, }); diff --git a/src/vs/platform/agentHost/test/node/testAgentHostTerminalManager.ts b/src/vs/platform/agentHost/test/node/testAgentHostTerminalManager.ts index bdfce7fb3e6458..7f68cfedc5107a 100644 --- a/src/vs/platform/agentHost/test/node/testAgentHostTerminalManager.ts +++ b/src/vs/platform/agentHost/test/node/testAgentHostTerminalManager.ts @@ -57,7 +57,6 @@ export class TestAgentHostTerminalManager extends Disposable implements IAgentHo getContent(): string | undefined { return undefined; } getClaim(): TerminalClaim | undefined { return undefined; } hasTerminal(): boolean { return false; } - getExitCode(): number | undefined { return undefined; } supportsCommandDetection(): boolean { return this.commandDetectionSupported; } disposeTerminal(uri: string): void { this.disposedTerminals.push(uri); } getTerminalInfos(): TerminalInfo[] { return []; } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackItemsBackend.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackItemsBackend.ts index 4ae0b0269306ef..9354a74e71950f 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackItemsBackend.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackItemsBackend.ts @@ -267,7 +267,7 @@ function feedbackToAnnotation(feedback: IAgentFeedback): Annotation { }; return { id: feedback.id, - turnId: '', + origin: { session: feedback.sessionResource.toString() }, resource: feedback.resourceUri.toString(), range: toTextRange(feedback.range), resolved: feedback.state === AgentFeedbackState.Resolved, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 5550ca2b075660..da4eeff398e6ea 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -46,7 +46,7 @@ import { CompletionItemKind as AhpCompletionItemKind, ContentEncoding, type Comp import { ConfirmationOptionKind, CustomizationType, JsonPrimitive, McpServerAuthRequiredState, McpServerStatus, SessionInputRequestKind, TerminalClaimKind, ToolCallContributorKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient, type SessionInputRequest, type SessionToolClientExecutionRequest } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, ChatTurnStartedAction, isChatAction, type ClientChatAction, type ClientSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChatOriginKind, getInlineToolInput, getToolSubagentContent, isChatReadOnly, isDefaultChatUri, isMessageHiddenFromTranscript, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionStatus, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, readUsageInfoMeta, withMessageHiddenFromTranscript, type ChatState, type ISessionWithDefaultChat, type ICompletedToolCall, type InputRequestResponsePart, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageChatAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type PendingMessage, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type SessionState, type StringOrMarkdown, type ToolCallResponsePart, type ToolCallState, type ToolInput, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChatOriginKind, getInlineToolInput, getToolSubagentContent, isChatReadOnly, isDefaultChatUri, isMessageHiddenFromTranscript, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionStatus, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, readUsageInfoMeta, withMessageHiddenFromTranscript, type ChatState, type ISessionWithDefaultChat, type ICompletedToolCall, type InputRequestResponsePart, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageChatAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type PendingMessage, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type ChatSummary, type SessionState, type StringOrMarkdown, type ToolCallResponsePart, type ToolCallState, type ToolInput, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -1048,6 +1048,14 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC */ private readonly _additionalChatSubscriptions = new Map>>(); + /** + * Chat channel URI that owns each observed terminal, keyed by terminal URI. + * Recorded while observing a terminal tool call so a later claim (e.g. + * "Continue in Background") can attribute itself to the chat the terminal + * actually belongs to rather than assuming the session's default chat. + */ + private readonly _terminalChatURIs = new Map(); + /** * Backend session URIs with an in-flight {@link provideChatSessionContent} * call, keyed by session URI string with a refcount value. While a chat is @@ -1101,6 +1109,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } this._inputNeededWatchers.clear(); this._inputNeededWatcherBackends.clear(); + this._terminalChatURIs.clear(); })); // Drop MCP servers from the per-session surfaced set once they reach the // running state so a later auth requirement for the same server prompts @@ -1122,12 +1131,21 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (!parsed) { return; } + // The claim identifies the owning chat. The terminal tool session ID + // only carries the session, so use the chat recorded while observing + // the terminal's tool call. + const chat = this._terminalChatURIs.get(parsed.terminal); + if (!chat) { + this._logService.warn(`[AgentHost] Continue in background: unknown owning chat for terminal=${parsed.terminal}`); + return; + } this._logService.info(`[AgentHost] Continue in background: terminal=${parsed.terminal}, session=${parsed.session}`); this._config.connection.dispatch(parsed.terminal, { type: ActionType.TerminalClaimed, claim: { kind: TerminalClaimKind.Session, session: parsed.session, + chat, }, }); })); @@ -1727,7 +1745,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC await this._createAndSubscribe( request.sessionResource, model, - undefined, Object.keys(initialConfig).length > 0 ? initialConfig : undefined, imported ? { turns: imported.turns, model: imported.model } : undefined, stage => failureStage = stage, @@ -3791,7 +3808,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (invocationMessage !== undefined) { invocation.invocationMessage = invocationMessage; } - this._reviveTerminalIfNeeded(invocation, tc, opts.backendSession, outputTerminalAttachment); + this._reviveTerminalIfNeeded(invocation, tc, opts.backendSession, opts.chatURI, outputTerminalAttachment); updateRunningToolSpecificData(invocation, tc, opts.backendSession, this._config.connectionAuthority); if (invocationMessageChanged) { invocation.notifyToolSpecificDataChanged(); @@ -3805,7 +3822,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (status === ToolCallStatus.Completed) { this._ensureLeftStreaming(invocation, tc, opts); } - this._reviveTerminalIfNeeded(invocation, tc, opts.backendSession, outputTerminalAttachment); + this._reviveTerminalIfNeeded(invocation, tc, opts.backendSession, opts.chatURI, outputTerminalAttachment); const fileEdits = finalizeToolInvocation(invocation, tc, opts.backendSession, this._config.connectionAuthority); if (fileEdits.length > 0) { opts.onFileEdits?.(tc, fileEdits); @@ -4333,6 +4350,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC invocation: ChatToolInvocation, tc: ToolCallState, backendSession: URI, + chatURI: string, outputTerminalAttachment: IOutputTerminalAttachment, ): void { // content is only present on Running/Completed/PendingResultConfirmation. @@ -4346,6 +4364,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (!terminalContent || !terminalUri || !toolInput) { return; } + this._terminalChatURIs.set(terminalUri, chatURI); invocation.presentation = undefined; const sessionId = makeAhpTerminalToolSessionId(terminalUri, backendSession); const terminalCommandUri = URI.parse(terminalUri); @@ -4938,9 +4957,10 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } /** - * Forks a session at the given request point by creating a new backend - * session with the `fork` parameter. Returns an {@link IChatSessionItem} - * pointing to the newly created session. + * Forks the conversation at the given request point into a new peer chat + * of the same session. AHP models forking at the chat level only, so the + * fork stays inside the source session and is addressed by a chat + * fragment on the session resource. */ private async _forkSession( sessionResource: URI, @@ -4952,12 +4972,25 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC throw new Error('Cancelled'); } - // Determine the turn index to fork at. If a specific request is - // provided, fork BEFORE it (keeping turns up to the previous one). - // This matches the non-contributed path in ForkConversationAction - // which uses `requestIndex - 1`. If no request is provided, fork - // the entire session. - const protocolState = this._getSessionState(backendSession.toString()); + const agentInfo = this._getRootState()?.agents.find(a => a.provider === this._config.provider); + if (!agentInfo?.capabilities?.multipleChats?.fork) { + throw new Error(`Provider ${this._config.provider} does not support forking`); + } + + const sessionUri = backendSession.toString(); + const rawSessionState = this._getRawSessionState(sessionUri); + if (!rawSessionState) { + throw new Error(`Cannot fork: session state is not hydrated for ${sessionUri}`); + } + // Fork the chat the gesture came from — a peer chat when the resource + // carries a chat fragment, else the session's default chat. + const sourceChat = this._resolveChatUriFromState(sessionResource, rawSessionState); + + // Determine the turn to fork at. If a specific request is provided, + // fork BEFORE it (keeping turns up to the previous one). This matches + // the non-contributed path in ForkConversationAction which uses + // `requestIndex - 1`. If no request is provided, fork the whole chat. + const protocolState = this._getSessionState(sessionUri, sourceChat); let turnIndex: number | undefined; if (request) { const requestIdx = protocolState?.turns.findIndex(t => t.id === request.id); @@ -4978,24 +5011,30 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } const turnId = protocolState!.turns[turnIndex].id; - if (!protocolState!.defaultChat) { - throw new Error('Cannot fork: source session has no default chat'); - } const chatModel = this._chatService.getSession(sessionResource); - const forkedSession = await this._createAndSubscribe(sessionResource, lastTurnModelSelection(protocolState), { - session: backendSession, - chat: URI.parse(protocolState!.defaultChat), - turnIndex, - turnId, + const forkedChatId = generateUuid(); + const forkedChat = URI.parse(buildChatUri(backendSession, forkedChatId)); + await this._config.connection.createChat(backendSession, forkedChat, { + model: lastTurnModelSelection(protocolState), + fork: { source: URI.parse(sourceChat), turnId }, }); - const forkedRawId = AgentSession.id(forkedSession); - const forkedResource = URI.from({ scheme: this._config.sessionType, path: `/${forkedRawId}` }); - const now = Date.now(); + // The chat is only addressable once the host has published it in the + // session's chat catalog; hydrating the returned item before then + // fails to resolve the fragment. + const sessionSubscription = this._ensureSessionSubscription(sessionUri); + const forkedSummary = await waitForState( + observableFromSubscription(this, sessionSubscription).map(state => + state?.chats.find(summary => parseChatUri(summary.resource)?.chatId === forkedChatId)), + (summary): summary is ChatSummary => !!summary, + undefined, + token, + ); - const forkedTitle = this._getSessionState(forkedSession.toString())?.title; - const forkedLabel = forkedTitle || chatModel?.title || localize('agentHost.forkedSessionLabel', "Forked Session"); + const forkedResource = URI.from({ scheme: this._config.sessionType, path: sessionResource.path, fragment: forkedChatId }); + const now = Date.now(); + const forkedLabel = forkedSummary.title || chatModel?.title || localize('agentHost.forkedSessionLabel', "Forked Session"); return { resource: forkedResource, @@ -5019,12 +5058,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } /** Creates a new backend session and subscribes to its state. */ - private async _createAndSubscribe(sessionResource: URI, model: ModelSelection | undefined, fork?: { session: URI; chat: URI; turnIndex: number; turnId: string }, config?: Record, importConversation?: { readonly turns: readonly Turn[]; readonly model?: ModelSelection }, onFailureStage?: (stage: AgentHostInvocationFailureStage) => void): Promise { + private async _createAndSubscribe(sessionResource: URI, model: ModelSelection | undefined, config?: Record, importConversation?: { readonly turns: readonly Turn[]; readonly model?: ModelSelection }, onFailureStage?: (stage: AgentHostInvocationFailureStage) => void): Promise { const workingDirectories = this._resolveRequestedWorkingDirectories(sessionResource); - const requestedSession = fork ? undefined : this._resolveSessionUri(sessionResource); + const requestedSession = this._resolveSessionUri(sessionResource); const meta = this._provisionalService.getInitialSessionMetadata(sessionResource); - this._logService.trace(`[AgentHost] Creating new session, model=${model?.id ?? '(default)'}, provider=${this._config.provider}${fork ? `, fork from ${fork.session.toString()} at index ${fork.turnIndex}` : ''}`); + this._logService.trace(`[AgentHost] Creating new session, model=${model?.id ?? '(default)'}, provider=${this._config.provider}`); onFailureStage?.('authentication'); const protectedResources = await this._ensureRequiredAuthentication(model); @@ -5050,7 +5089,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC model, provider: this._config.provider, workingDirectories, - fork, config, importConversation, activeClient, @@ -5070,7 +5108,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC model, provider: this._config.provider, workingDirectories, - fork, config, importConversation, activeClient, @@ -5113,9 +5150,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const chatURI = this._resolveChatUriFromState(sessionResource, rawState); this._setChatURI(sessionResource, chatURI); const chatSub = this._ensureChatSubscription(session.toString(), chatURI); - if (!fork) { - this._activeSessions.get(sessionResource)?.setStateSubscriptions(newSub, chatSub); - } + this._activeSessions.get(sessionResource)?.setStateSubscriptions(newSub, chatSub); // Start syncing the chat model's pending requests to the protocol this._ensurePendingMessageSubscription(sessionResource, session); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index a7d27bc196e5fe..ceed10c82c8a54 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -1113,14 +1113,14 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart this._decoration.update(); this._updateToolbarContextKeys(undefined, this._terminalData.terminalToolSessionId); void this._outputView.refresh(); - if (source.exitCode !== undefined) { + if (source.hasExited) { onCommandFinished.fire(); this.markCollapsibleWrapperComplete(); } })); this._outputSourceListener.value = store; onCommandExecuted.fire(); - if (source.exitCode !== undefined) { + if (source.hasExited) { onCommandFinished.fire(); } this._decoration.update(); @@ -1518,7 +1518,7 @@ export class ChatTerminalToolOutputSection extends Disposable { this._disposeLiveMirror(); if (outputSource.output) { await this._renderSnapshotOutput({ text: outputSource.output }); - } else if (outputSource.exitCode === undefined) { + } else if (!outputSource.hasExited) { this._hideEmptyMessage(); this._layoutOutput(0); } else { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index dd736140bda375..99d4e357d531fd 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -4018,7 +4018,7 @@ suite('AgentHostChatContribution', () => { assert.ok(dispatch, 'turn must start after the subscription errors; handler hung waiting on onDidChange (issue #5242)'); const action = dispatch.action as ITurnStartedAction; agentHostService.fireAction({ channel: dispatch.channel.toString(), action: dispatch.action, serverSeq: 1, origin: { clientId: agentHostService.clientId, clientSeq: dispatch.clientSeq } }); - agentHostService.fireAction({ channel: dispatch.channel.toString(), action: { type: 'chat/turnComplete', turnId: action.turnId } as ChatAction, serverSeq: 2, origin: undefined }); + agentHostService.fireAction({ channel: dispatch.channel.toString(), action: { type: 'chat/turnComplete', turnId: action.turnId, endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction, serverSeq: 2, origin: undefined }); await turnPromise; assert.deepStrictEqual(agentHostService.turnActions.map(d => (d.action as ITurnStartedAction).message.text), ['Hello']); @@ -5159,7 +5159,7 @@ suite('AgentHostChatContribution', () => { completions: 0, }); - fire({ type: ActionType.ChatTurnComplete, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', turnId, endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction); await turnPromise; })); @@ -5435,7 +5435,7 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(carousel.answeredExternally, true, 'accepted input without answers should be marked answered'); assert.ok(carousel instanceof ChatQuestionCarouselData, 'AgentHost input request should use runtime carousel data'); - fire({ type: ActionType.ChatTurnComplete, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', turnId, endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction); await turnPromise; })); @@ -9597,7 +9597,7 @@ suite('AgentHostChatContribution', () => { assert.ok(turnDispatch); const turnAction = turnDispatch.action as ITurnStartedAction; agentHostService.fireAction({ channel: turnDispatch.channel.toString(), action: turnDispatch.action, serverSeq: 1, origin: { clientId: agentHostService.clientId, clientSeq: turnDispatch.clientSeq } }); - agentHostService.fireAction({ channel: turnDispatch.channel.toString(), action: { type: 'chat/turnComplete', turnId: turnAction.turnId } as ChatAction, serverSeq: 2, origin: undefined }); + agentHostService.fireAction({ channel: turnDispatch.channel.toString(), action: { type: 'chat/turnComplete', turnId: turnAction.turnId, endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction, serverSeq: 2, origin: undefined }); await turnPromise; assert.deepStrictEqual({ @@ -10289,7 +10289,7 @@ suite('AgentHostChatContribution', () => { // Clean up the awaitConfirmation promise before teardown. agentHostService.fireAction({ - channel: sessionUri.toString(), action: { type: 'chat/turnComplete', turnId: 'turn-active' } as ChatAction, + channel: sessionUri.toString(), action: { type: 'chat/turnComplete', turnId: 'turn-active', endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction, serverSeq: 2, origin: undefined, }); @@ -12692,7 +12692,7 @@ suite('AgentHostChatContribution', () => { const promptParts = collected.flat().filter((p): p is IChatMcpAuthenticationRequired => p.kind === 'mcpAuthenticationRequired'); - agentHostService.fireAction({ channel: dispatch.channel.toString(), action: { type: 'chat/turnComplete', turnId } as ChatAction, serverSeq: seq.v++, origin: undefined }); + agentHostService.fireAction({ channel: dispatch.channel.toString(), action: { type: 'chat/turnComplete', turnId, endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction, serverSeq: seq.v++, origin: undefined }); await turnPromise; return promptParts; } diff --git a/src/vs/workbench/contrib/terminal/browser/agentHostOutputChannel.ts b/src/vs/workbench/contrib/terminal/browser/agentHostOutputChannel.ts index ff29cbf6f6ff6e..a6f098c1376b67 100644 --- a/src/vs/workbench/contrib/terminal/browser/agentHostOutputChannel.ts +++ b/src/vs/workbench/contrib/terminal/browser/agentHostOutputChannel.ts @@ -7,7 +7,7 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { IAgentConnection } from '../../../../platform/agentHost/common/agentService.js'; -import type { TerminalState } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import { TerminalLifecycleStatus, type TerminalState } from '../../../../platform/agentHost/common/state/protocol/state.js'; import { StateComponents } from '../../../../platform/agentHost/common/state/sessionState.js'; import type { IChatTerminalOutputSource } from './terminal.js'; @@ -23,6 +23,9 @@ export class AgentHostOutputChannel extends Disposable implements IChatTerminalO private _output = ''; get output(): string { return this._output; } + private _hasExited = false; + get hasExited(): boolean { return this._hasExited; } + private _exitCode: number | undefined; get exitCode(): number | undefined { return this._exitCode; } @@ -41,7 +44,9 @@ export class AgentHostOutputChannel extends Disposable implements IChatTerminalO .map(part => part.type === 'command' ? part.output : part.value) .join('') .replace(/\r?\n/g, '\r\n'); - this._exitCode = state.exitCode; + const lifecycle = state.lifecycle; + this._hasExited = lifecycle.status === TerminalLifecycleStatus.Exited; + this._exitCode = lifecycle.status === TerminalLifecycleStatus.Exited ? lifecycle.exitCode : undefined; this._onDidChange.fire(); } } diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index a3588ed4ae5817..593e875a60dab4 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -135,6 +135,12 @@ export interface IChatTerminalToolProgressPart { export interface IChatTerminalOutputSource { readonly onDidChange: Event; readonly output: string; + /** + * Whether the underlying command has finished. A command can exit without + * reporting an {@link exitCode}, so completion must be read from here + * rather than inferred from the code being present. + */ + readonly hasExited: boolean; readonly exitCode: number | undefined; } diff --git a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts index 338b63b6a135df..7fb043a85016f3 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts @@ -13,7 +13,7 @@ import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelSc import { constObservable, IObservable } from '../../../../../base/common/observable.js'; import { AgentHostDebugLogsArtifactKind, IAgentConnection, IAgentCreateSessionConfig, IAgentHostDebugLogsArtifact, IAgentHostDebugLogsChunk, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../../../../../platform/agentHost/common/agentService.js'; import { ActionType, StateAction } from '../../../../../platform/agentHost/common/state/protocol/actions.js'; -import { RootState, TerminalClaimKind, type TerminalState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { RootState, TerminalClaimKind, TerminalLifecycleStatus, type TerminalState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import type { ActionEnvelope, IRootConfigChangedAction, SessionAction, TerminalAction, INotification, ClientAnnotationsAction } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWriteParams, ResourceWriteResult, CreateResourceWatchParams, CreateResourceWatchResult, ResourceMkdirParams, ResourceMkdirResult } from '../../../../../platform/agentHost/common/state/sessionProtocol.js'; @@ -48,6 +48,7 @@ class MockAgentConnection implements IAgentConnection { private _terminalState: TerminalState = { title: 'Test Terminal', content: [], claim: { kind: TerminalClaimKind.Client, clientId: 'test-client' }, + lifecycle: { status: TerminalLifecycleStatus.Running }, }; constructor(initialState?: Partial) { @@ -848,7 +849,7 @@ suite('AgentHostPty', () => { }); const reconnect = pty.reconnect(conn2); - hydration.state = { title: 'Reconnected', content: [], claim: { kind: TerminalClaimKind.Client, clientId: 'test-client' } }; + hydration.state = { title: 'Reconnected', content: [], claim: { kind: TerminalClaimKind.Client, clientId: 'test-client' }, lifecycle: { status: TerminalLifecycleStatus.Running } }; onDidChange.fire(hydration.state); assert.strictEqual(await reconnect, true); dataReceived.length = 0; // drop the replayed clear sequence @@ -900,7 +901,7 @@ suite('AgentHostPty', () => { const reconnect = pty.reconnect(conn2); pty.shutdown(false); const result = await reconnect; - onDidChange.fire({ title: 'Late', content: [{ type: 'unclassified', value: 'late data' }], claim: { kind: TerminalClaimKind.Client, clientId: 'test-client' } }); + onDidChange.fire({ title: 'Late', content: [{ type: 'unclassified', value: 'late data' }], claim: { kind: TerminalClaimKind.Client, clientId: 'test-client' }, lifecycle: { status: TerminalLifecycleStatus.Running } }); onDidApplyAction.fire({ channel: terminalUri.toString(), action: { type: ActionType.TerminalData, data: 'late action' }, serverSeq: 1, origin: undefined }); await Promise.resolve(); From 52131bf1b89556a09a6287f7a07bbdd8a585d514 Mon Sep 17 00:00:00 2001 From: roblourens Date: Fri, 21 Aug 2026 15:14:32 -0700 Subject: [PATCH 21/21] Simplify AgentService composition (#332035) Narrow internal service dependencies, move runtime collaborators out of AgentService, and replace two-phase initialization with a constructor-complete composition. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/AGENTS.md | 6 +- .../platform/agentHost/common/agentService.ts | 5 +- .../agentHost/node/agentHostBootstrap.ts | 28 +- .../node/agentHostCommitOperationHandler.ts | 6 +- .../node/agentHostGitStateService.ts | 6 +- .../platform/agentHost/node/agentHostMain.ts | 28 +- .../agentHostPullRequestOperationHandler.ts | 8 +- .../agentHost/node/agentHostServerMain.ts | 11 +- .../platform/agentHost/node/agentService.ts | 280 +++++------------- .../agentHost/node/agentServiceComposition.ts | 179 ++++++++--- .../agentHostCommitOperationHandler.test.ts | 11 +- .../node/agentHostGitStateService.test.ts | 21 +- ...entHostPullRequestOperationHandler.test.ts | 11 +- .../agentHost/test/node/agentService.test.ts | 135 +++++---- .../test/node/agentServiceTestUtils.ts | 18 +- .../test/node/protocolServerHandler.test.ts | 1 - 16 files changed, 392 insertions(+), 362 deletions(-) diff --git a/src/vs/platform/agentHost/AGENTS.md b/src/vs/platform/agentHost/AGENTS.md index 7d8ad2078ddb5c..8546d1fe00f70d 100644 --- a/src/vs/platform/agentHost/AGENTS.md +++ b/src/vs/platform/agentHost/AGENTS.md @@ -700,9 +700,9 @@ resource. `AgentSideEffects` does not enumerate chats or fan config values through provider hooks. Both `IAgentHostPromptCache` and `IAgentHostSessionTitleSignal` are constructed -by `AgentService`, exposed as `agentService.promptCache` / -`agentService.sessionTitleSignal`, and registered in the `agentHostMain` / -`agentHostServerMain` DI containers next to `IAgentHostStateManager`. +and registered by `createAgentServiceComposition`. Consumers resolve their +service identifiers through constructor injection; `AgentService` neither owns +nor exposes them. ### 8g. Seam → provider read it replaces diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 3e028dea418d03..68b3618ba3988b 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -22,7 +22,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, ChatAction, TerminalAction, ClientAnnotationsAction, ClientChangesetAction } from './state/sessionActions.js'; import type { ContentEncoding, ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMkdirParams, ResourceMkdirResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWatchState, ResourceWriteParams, ResourceWriteResult, CreateResourceWatchParams, CreateResourceWatchResult, IStateSnapshot } from './state/sessionProtocol.js'; import { ComponentToState, StateComponents, type RootState } from './state/sessionState.js'; -import { type AgentProvider, CLAUDE_AGENT_PROVIDER_ID, CODEX_AGENT_PROVIDER_ID, type AuthenticateParams, type AuthenticateResult, type IAgentHostAuthTokenRequest, type IAgentCreateChatOptions, type IAgentCreateSessionConfig, type IAgentSessionMetadata, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IMcpNotification, type IAgentHostNetworkEndpoint, type IAgentHostManagedSettingsSnapshot } from './agent.js'; +import { type AgentProvider, CLAUDE_AGENT_PROVIDER_ID, CODEX_AGENT_PROVIDER_ID, type AuthenticateParams, type AuthenticateResult, type IAgentCreateChatOptions, type IAgentCreateSessionConfig, type IAgentSessionMetadata, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IMcpNotification, type IAgentHostNetworkEndpoint, type IAgentHostManagedSettingsSnapshot } from './agent.js'; // ---- Provider-model re-exports (compatibility) ------------------------------ // New provider code imports these from agent.ts. @@ -809,9 +809,6 @@ export interface IAgentService { */ authenticate(params: AuthenticateParams): Promise; - /** Return a bearer token previously supplied via {@link authenticate}. */ - getAuthToken(request: IAgentHostAuthTokenRequest): string | undefined; - /** List all available sessions from the Copilot CLI. */ listSessions(): Promise; diff --git a/src/vs/platform/agentHost/node/agentHostBootstrap.ts b/src/vs/platform/agentHost/node/agentHostBootstrap.ts index a90826809bd493..5a25a0938989da 100644 --- a/src/vs/platform/agentHost/node/agentHostBootstrap.ts +++ b/src/vs/platform/agentHost/node/agentHostBootstrap.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { DisposableStore } from '../../../base/common/lifecycle.js'; -import { Event } from '../../../base/common/event.js'; +import type { Event } from '../../../base/common/event.js'; +import type { IObservable } from '../../../base/common/observable.js'; import { joinPath } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import { Schemas } from '../../../base/common/network.js'; @@ -28,14 +29,19 @@ import { IAgentEditAttributionService } from '../common/fileEditAttribution.js'; import { IAgentHostGitService } from '../common/agentHostGitService.js'; import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js'; import { ISessionDataService } from '../common/sessionDataService.js'; +import type { IAgent } from '../common/agent.js'; import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js'; import { AgentHostGitService } from './agentHostGitService.js'; import { AgentHostOTelService } from './otel/agentHostOTelService.js'; import { AgentHostProxyResolver, IAgentHostProxyResolver } from './agentHostProxyResolver.js'; import { AgentHostRequestService } from './agentHostRequestService.js'; import { createAgentHostTelemetryService, IAgentHostTelemetryService } from './agentHostTelemetryService.js'; +import { IAgentConfigurationService } from './agentConfigurationService.js'; +import { IAgentHostCompletions } from './agentHostCompletions.js'; +import { IAgentHostCustomizationEnablementService } from './agentHostCustomizationEnablementService.js'; +import { AgentHostStateManager } from './agentHostStateManager.js'; import { AgentService, IAgentServiceOptions } from './agentService.js'; -import { createAgentService } from './agentServiceComposition.js'; +import { createAgentServiceComposition } from './agentServiceComposition.js'; import { INetworkDiagnosticsService, NetworkDiagnosticsService } from './networkDiagnosticsService.js'; import { AgentPluginManager } from './agentPluginManager.js'; import { NodeWorkerDiffComputeService } from './diffComputeService.js'; @@ -79,6 +85,12 @@ export interface ICreateAgentHostRuntimeOptions { export interface IAgentHostRuntime { readonly instantiationService: IInstantiationService; readonly agentService: AgentService; + readonly configurationService: IAgentConfigurationService; + readonly stateManager: AgentHostStateManager; + readonly customizationEnablementService: IAgentHostCustomizationEnablementService; + readonly completions: IAgentHostCompletions; + readonly agents: IObservable; + readonly onDidStartTurn: Event; readonly fileService: IFileService; readonly sessionDataService: ISessionDataService; readonly proxyResolver: IAgentHostProxyResolver; @@ -157,8 +169,10 @@ export async function createAgentHostRuntime(options: ICreateAgentHostRuntimeOpt tmpDir: environmentService.tmpDir, }, }; - agentService = createAgentService(agentServiceOptions, services, instantiationService, fetchFn, logService, productService); - proxyResolver.bindConfigurationService(agentService.configurationService, options.transientProxyConfiguration); + const agentServiceComposition = createAgentServiceComposition(agentServiceOptions, services, instantiationService, fetchFn, logService, productService, sessionDataService); + agentService = agentServiceComposition.agentService; + const { configurationService } = agentServiceComposition; + proxyResolver.bindConfigurationService(configurationService, options.transientProxyConfiguration); const networkDiagnosticsService = instantiationService.createInstance(NetworkDiagnosticsService); services.set(INetworkDiagnosticsService, networkDiagnosticsService); agentService.setNetworkDiagnosticsService(networkDiagnosticsService); @@ -190,6 +204,12 @@ export async function createAgentHostRuntime(options: ICreateAgentHostRuntimeOpt return { instantiationService, agentService, + configurationService, + stateManager: agentServiceComposition.stateManager, + customizationEnablementService: agentServiceComposition.customizationEnablementService, + completions: agentServiceComposition.completions, + agents: agentServiceComposition.agents, + onDidStartTurn: agentServiceComposition.onDidStartTurn, fileService, sessionDataService, proxyResolver, diff --git a/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts index 7132ea2545dafb..251b3d8b506705 100644 --- a/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts @@ -7,7 +7,7 @@ import { basename } from '../../../base/common/resources.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; -import { IAgentService } from '../common/agentService.js'; +import { IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; import { parseChangesetUri } from '../common/changesetUri.js'; import { type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; @@ -27,7 +27,7 @@ export class AgentHostCommitOperationHandler implements IChangesetOperationHandl constructor( private readonly _getSessionState: (sessionKey: string) => SessionState | undefined, private readonly _onCommitted: (sessionKey: string) => Promise, - @IAgentService private readonly _agentService: IAgentService, + @IAgentHostAuthenticationService private readonly _authenticationService: IAgentHostAuthenticationService, @IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService, @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, @@ -78,7 +78,7 @@ export class AgentHostCommitOperationHandler implements IChangesetOperationHandl this._throwIfCancelled(token); const copilotResource = this._gitHubEndpointService.getCopilotResource(); - const authToken = this._agentService.getAuthToken({ + const authToken = this._authenticationService.getAuthToken({ resource: copilotResource.resource, scopes: copilotResource.scopes_supported, }); diff --git a/src/vs/platform/agentHost/node/agentHostGitStateService.ts b/src/vs/platform/agentHost/node/agentHostGitStateService.ts index 78a9ea9df656e2..90640ebe3717a1 100644 --- a/src/vs/platform/agentHost/node/agentHostGitStateService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitStateService.ts @@ -16,13 +16,13 @@ import { IAgentHostGitService, META_DIFF_BASE_BRANCH, parseUpstreamBranchName, r import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; import { ISessionDataService } from '../common/sessionDataService.js'; import { CreatedPullRequest, IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js'; -import { IAgentService } from '../common/agentService.js'; import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; import { CancellationTokenSource } from '../../../base/common/cancellation.js'; import { ThrottlerByKey, SequencerByKey, timeout } from '../../../base/common/async.js'; import { isCancellationError } from '../../../base/common/errors.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; +import { IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; const PULL_REQUEST_CREATION_CLOCK_SKEW_MS = 5 * 60_000; @@ -50,7 +50,7 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @IAgentHostOctoKitService private readonly _octoKitService: IAgentHostOctoKitService, - @IAgentService private readonly _agentService: IAgentService, + @IAgentHostAuthenticationService private readonly _authenticationService: IAgentHostAuthenticationService, @IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService, @ILogService private readonly _logService: ILogService, @ISessionDataService private readonly _sessionDataService: ISessionDataService, @@ -110,7 +110,7 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi try { const repoResource = this._gitHubEndpointService.getRepoResource(); - const authToken = this._agentService.getAuthToken({ + const authToken = this._authenticationService.getAuthToken({ resource: repoResource.resource, scopes: repoResource.scopes_supported, }); diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index af19c42c4dcfd5..4cc4df6171b123 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -19,6 +19,7 @@ import { AgentHostClaudeAgentEnabledEnvVar, AgentHostCodexAgentEnabledEnvVar, Ag import { AgentHostCodexEnabledConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; import { AgentModelRefreshScheduler, MODEL_REFRESH_INTERVAL_MS } from './agentModelRefreshScheduler.js'; import { AgentService } from './agentService.js'; +import { AgentHostStateManager } from './agentHostStateManager.js'; import { CopilotAgent } from './copilot/copilotAgent.js'; import { ClaudeAgent } from './claude/claudeAgent.js'; import { ClaudeSdkPackage } from './claude/claudeAgentSdkService.js'; @@ -46,7 +47,7 @@ import { IProductService } from '../../product/common/productService.js'; import { localize } from '../../../nls.js'; import { IFileService } from '../../files/common/files.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; -import { createAgentHostRuntime } from './agentHostBootstrap.js'; +import { createAgentHostRuntime, type IAgentHostRuntime } from './agentHostBootstrap.js'; import { BANG_COMMAND_PREFIX } from './agentHostBangCommand.js'; import { AgentHostClientFileSystemProvider } from '../common/agentHostClientFileSystemProvider.js'; import { AGENT_CLIENT_SCHEME } from '../common/agentClientUri.js'; @@ -101,6 +102,7 @@ async function startAgentHost(): Promise { logService.info('Agent Host process started successfully'); // Create the real service implementation that lives in this process + let runtime!: IAgentHostRuntime; let agentService: AgentService; let instantiationService!: IInstantiationService; let fileService!: IFileService; @@ -113,7 +115,7 @@ async function startAgentHost(): Promise { const connectionTelemetryTracker = disposables.add(new AgentHostClientConnectionTelemetryTracker()); try { byokLmBridgeRegistry = new ByokLmBridgeRegistry(); - const runtime = await createAgentHostRuntime({ + runtime = await createAgentHostRuntime({ environmentService, productService, logService, @@ -125,6 +127,7 @@ async function startAgentHost(): Promise { byok: { kind: 'renderer', bridgeRegistry: byokLmBridgeRegistry }, }); agentService = runtime.agentService; + const agentConfigurationService = runtime.configurationService; instantiationService = runtime.instantiationService; fileService = runtime.fileService; proxyResolver = runtime.proxyResolver; @@ -152,7 +155,6 @@ async function startAgentHost(): Promise { // or the renderer-forwarded `codexAgentEnabled` root config enables it. // Disabling requires an agent host restart. if (!environmentService.isBuilt || agentSdkDownloader.isAvailable(CodexSdkPackage)) { - const agentConfigurationService = agentService.configurationService; let codexRegistered = false; const registerCodexIfEnabled = () => { if (codexRegistered) { @@ -181,7 +183,7 @@ async function startAgentHost(): Promise { // lifetime, rather than inside `AgentHostService`: a service that arms a // recurring timer in its constructor is one that no faked-timer unit test // can ever drain. - disposables.add(instantiationService.createInstance(AgentModelRefreshScheduler, agentService.agents, agentService.onDidStartTurn, MODEL_REFRESH_INTERVAL_MS)); + disposables.add(instantiationService.createInstance(AgentModelRefreshScheduler, runtime.agents, runtime.onDidStartTurn, MODEL_REFRESH_INTERVAL_MS)); // Surface agent-SDK download progress to clients as generic `progress` // notifications. The downloader fires process-global frames keyed by package @@ -222,7 +224,7 @@ async function startAgentHost(): Promise { hostLaunchKind, connectionTelemetryTracker, defaultDirectory: URI.file(os.homedir()).toString(), - completionTriggerCharacters: agentService.completionTriggerCharacters, + completionTriggerCharacters: runtime.completions.triggerCharacters, terminalCommandPrefix: BANG_COMMAND_PREFIX, otlpLogEmitter, allowExtensionMethods: false, @@ -232,7 +234,7 @@ async function startAgentHost(): Promise { const messagePortProtocolHandler = localDataPlaneDisposables.add(instantiationService.createInstance( ProtocolServerHandler, agentService, - agentService.stateManager, + runtime.stateManager, messagePortProtocolServer, localProtocolHandlerConfig, clientFileSystemProvider, @@ -305,7 +307,7 @@ async function startAgentHost(): Promise { const localEndpointProtocolHandler = localDataPlaneDisposables.add(instantiationService.createInstance( ProtocolServerHandler, agentService, - agentService.stateManager, + runtime.stateManager, localEndpoint.server, localProtocolHandlerConfig, clientFileSystemProvider, @@ -362,13 +364,13 @@ async function startAgentHost(): Promise { const protocolHandler = protocolIngressDisposables.add(instantiationService.createInstance( ProtocolServerHandler, agentService, - agentService.stateManager, + runtime.stateManager, wsServer, { hostLaunchKind, connectionTelemetryTracker, defaultDirectory: URI.file(os.homedir()).toString(), - completionTriggerCharacters: agentService.completionTriggerCharacters, + completionTriggerCharacters: runtime.completions.triggerCharacters, terminalCommandPrefix: BANG_COMMAND_PREFIX, otlpLogEmitter, }, @@ -445,6 +447,8 @@ async function startAgentHost(): Promise { // raw WebSocket streams and cannot carry the local endpoint's bearer token. const configuredWebSocketServerStart = startWebSocketServer( agentService, + runtime.stateManager, + runtime.completions.triggerCharacters, clientFileSystemProvider, instantiationService, environmentService.logsHome, @@ -538,6 +542,8 @@ function cleanupLocalAgentHostEndpoint( */ async function startWebSocketServer( agentService: AgentService, + stateManager: AgentHostStateManager, + completionTriggerCharacters: readonly string[], clientFileSystemProvider: AgentHostClientFileSystemProvider, instantiationService: IInstantiationService, logsHome: URI, @@ -586,13 +592,13 @@ async function startWebSocketServer( const protocolHandler = disposables.add(instantiationService.createInstance( ProtocolServerHandler, agentService, - agentService.stateManager, + stateManager, wsServer, { hostLaunchKind, connectionTelemetryTracker, defaultDirectory: URI.file(os.homedir()).toString(), - completionTriggerCharacters: agentService.completionTriggerCharacters, + completionTriggerCharacters, terminalCommandPrefix: BANG_COMMAND_PREFIX, otlpLogEmitter, }, diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts index 8bef4a7dd6b6fa..5c485d5e0b698f 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts @@ -6,7 +6,7 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; -import { IAgentService } from '../common/agentService.js'; +import { IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; import { parseChangesetUri } from '../common/changesetUri.js'; import { AHP_AUTH_REQUIRED, AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; @@ -73,7 +73,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation private readonly _getSessionState: (sessionKey: string) => ISessionWithDefaultChat | undefined, private readonly _resolveBaseBranchName: (sessionKey: string) => Promise, private readonly _onPullRequestCreated: (event: PullRequestCreatedEvent) => void, - @IAgentService private readonly _agentService: IAgentService, + @IAgentHostAuthenticationService private readonly _authenticationService: IAgentHostAuthenticationService, @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @IAgentHostOctoKitService private readonly _octoKitService: IAgentHostOctoKitService, @IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService, @@ -136,7 +136,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation const base = baseBranchName; const repoResource = this._gitHubEndpointService.getRepoResource(); - const authToken = this._agentService.getAuthToken({ + const authToken = this._authenticationService.getAuthToken({ resource: repoResource.resource, scopes: repoResource.scopes_supported, }); @@ -360,7 +360,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation token: CancellationToken, ): Promise<{ title: string; description: string } | undefined> { const copilotResource = this._gitHubEndpointService.getCopilotResource(); - const authToken = this._agentService.getAuthToken({ + const authToken = this._authenticationService.getAuthToken({ resource: copilotResource.resource, scopes: copilotResource.scopes_supported, }); diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index 3162e25175def1..bd1cfa766acfbe 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -196,7 +196,7 @@ async function main(): Promise { providerConfigurations: [createCodexProviderConfiguration(environmentService.userHome)], byok: { kind: 'unavailable' }, }); - const { agentService, instantiationService, fileService, sessionDataService } = runtime; + const { agentService, configurationService: agentConfigurationService, instantiationService, fileService, sessionDataService } = runtime; disposables.add(agentService); errorTelemetry.value = new ErrorTelemetry(runtime.telemetryService); @@ -227,7 +227,6 @@ async function main(): Promise { log('ClaudeAgent registered'); } if (!environmentService.isBuilt || agentSdkDownloader.isAvailable(CodexSdkPackage)) { - const agentConfigurationService = agentService.configurationService; let codexRegistered = false; const registerCodexIfEnabled = () => { if (codexRegistered) { @@ -281,7 +280,7 @@ async function main(): Promise { // lifetime, rather than inside `AgentHostService`: a service that arms a // recurring timer in its constructor is one that no faked-timer unit test // can ever drain. - disposables.add(instantiationService.createInstance(AgentModelRefreshScheduler, agentService.agents, agentService.onDidStartTurn, MODEL_REFRESH_INTERVAL_MS)); + disposables.add(instantiationService.createInstance(AgentModelRefreshScheduler, runtime.agents, runtime.onDidStartTurn, MODEL_REFRESH_INTERVAL_MS)); // WebSocket server const wsServer = disposables.add(await WebSocketProtocolServer.create({ @@ -301,13 +300,13 @@ async function main(): Promise { disposables.add(instantiationService.createInstance( ProtocolServerHandler, agentService, - agentService.stateManager, + runtime.stateManager, wsServer, { hostLaunchKind: AgentHostLaunchKind.VSCodeCLI, connectionTelemetryTracker, defaultDirectory: URI.file(os.homedir()).toString(), - completionTriggerCharacters: agentService.completionTriggerCharacters, + completionTriggerCharacters: runtime.completions.triggerCharacters, terminalCommandPrefix: BANG_COMMAND_PREFIX, otlpLogEmitter, }, @@ -369,7 +368,7 @@ async function main(): Promise { // SIGTERM arriving during a session or agent-host storage write can // drop the latest decision. // Capped so a stuck write cannot hang shutdown indefinitely. - await raceTimeout(Promise.all([sessionDataService.whenIdle(), agentService.customizationEnablementService.whenIdle()]), 3000, () => { + await raceTimeout(Promise.all([sessionDataService.whenIdle(), runtime.customizationEnablementService.whenIdle()]), 3000, () => { logService.warn('[AgentHostServer] Timed out waiting for persistence writes to flush; exiting anyway.'); }); disposables.dispose(); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 3eb51d638e0117..9336c79e51c54e 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -7,12 +7,12 @@ import { open, unlink, type FileHandle } from 'fs/promises'; import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; import { DeferredPromise, disposableTimeout, Limiter, Promises, ResourceQueue } from '../../../base/common/async.js'; import { toErrorMessage } from '../../../base/common/errorMessage.js'; -import { Emitter, type Event } from '../../../base/common/event.js'; +import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableResourceMap, DisposableStore, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../base/common/map.js'; import { getExtensionForMimeType, getMediaMime, getMediaOrTextMime } from '../../../base/common/mime.js'; import { Schemas } from '../../../base/common/network.js'; -import { IObservable, observableValue } from '../../../base/common/observable.js'; +import { ISettableObservable } from '../../../base/common/observable.js'; import { dirname as resourcesDirname, extname as resourcesExtname, extUriBiasedIgnorePathCase, isEqual, isEqualOrParent, joinPath } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; @@ -20,7 +20,7 @@ import { hasKey } from '../../../base/common/types.js'; import { localize } from '../../../nls.js'; import { FileChangeType, FileOperationResult, IFileChange, IFileService, toFileOperationResult, type FileChangesEvent } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentProvider, AgentSession, AgentSignal, IAgent, type IAgentAdoptedWorktree, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateChatSideChatSelection, IAgentCreateChatSideChatSource, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDiscoveredChat, IAgentHostAuthTokenRequest, IAgentHostNetworkEndpoint, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentChatAdoptionResult, type AgentChatAdoptionReason, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, IMcpNotification, SubagentChatSignal, subagentChatTitle } from '../common/agent.js'; +import { AgentProvider, AgentSession, AgentSignal, IAgent, type IAgentAdoptedWorktree, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateChatSideChatSelection, IAgentCreateChatSideChatSource, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDiscoveredChat, IAgentHostNetworkEndpoint, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentChatAdoptionResult, type AgentChatAdoptionReason, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, IMcpNotification, SubagentChatSignal, subagentChatTitle } from '../common/agent.js'; import { AgentHostSessionReleaseGraceMsEnvVar, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentService } from '../common/agentService.js'; import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; import { IAgentEditAttributionService, ICancelEditAttributionFlushParams, ICommitEditAttributionFlushParams, IEditAttributionFlushResult, IPrepareEditAttributionFlushParams, IPreparedEditAttributionFlush, parseEditAttributionResource } from '../common/fileEditAttribution.js'; @@ -41,17 +41,14 @@ import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../commo import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js'; import { readChatSurfaceMeta, withChatSurfaceMeta } from '../common/meta/agentChatSurfaceMeta.js'; import { buildBoundedSideChatSourceContext, getSideChatPartialResponse } from './agentPeerChats.js'; -import { AgentConfigurationService, getEffectiveWorkingDirectories, IAgentConfigurationService } from './agentConfigurationService.js'; -import { IAgentHostManagedSettingsService } from './agentHostManagedSettingsService.js'; -import { AgentHostTerminalManager, IAgentHostTerminalManager } from './agentHostTerminalManager.js'; +import { AgentConfigurationService, getEffectiveWorkingDirectories } from './agentConfigurationService.js'; +import { AgentHostTerminalManager } from './agentHostTerminalManager.js'; import { ISessionDbUriFields, parseSessionDbUri } from '../common/sessionDbUri.js'; import { IGitBlobUriFields, parseGitBlobUri } from './gitDiffContent.js'; import { resolveSessionRepositories } from './agentHostSessionRepositories.js'; import { findDeepestContainingWorkingDirectory, isMultiRootSession } from '../common/agentHostWorkingDirectories.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; import { createAgentChatContext } from './agentChatContext.js'; -import { IAgentHostPromptCache } from './agentHostPromptCache.js'; -import { IAgentHostSessionTitleSignal } from './agentHostSessionTitleSignal.js'; import { AgentHostDebugLogsCollector, type IAgentHostDebugLogsEnvironment } from './agentHostDebugLogs.js'; import { IAgentHostDatabase } from './agentHostDatabase.js'; import { AgentSessionRegistry, IRegisteredSession, IStoredRegisteredSession } from './agentSessionRegistry.js'; @@ -81,13 +78,11 @@ import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointServic import { AgentMergeController, type IAgentMergeControllerOptions } from './agentMergeController.js'; import { AgentMergeConfigKey, agentMergeRootConfigSchema, readAgentMergeSessionState } from '../common/agentMerge.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; -import { AgentHostAuthenticationService, type IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; +import { AgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostArtifactToolsConfigKey, AgentHostEditTelemetryEnabledConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostShowExternalSessionsConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; -import { AgentHostCustomizationEnablementService, IAgentHostCustomizationEnablementService } from './agentHostCustomizationEnablementService.js'; -import { AgentHostStorageService, IAgentHostStorageService } from './agentHostStorageService.js'; +import { AgentHostCustomizationEnablementService } from './agentHostCustomizationEnablementService.js'; import { SessionCoordinationService } from './sessionCoordination.js'; -import { IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js'; import { IAgentHostChangesetService, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js'; import { GIT_DB_METADATA_KEYS, IAgentHostGitStateService, META_GIT_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../common/agentHostGitStateService.js'; import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; @@ -366,39 +361,30 @@ export interface IAgentServiceOptions { readonly debugLogsEnvironment?: IAgentHostDebugLogsEnvironment; } -/** Core state and callbacks exposed only to the Agent Host composition root. */ -export interface IAgentServiceCompositionContext { - readonly stateManager: AgentHostStateManager; - readonly configurationService: AgentConfigurationService; - readonly storageService: AgentHostStorageService; - readonly managedSettingsService: IAgentHostManagedSettingsService; - readonly sessionDataService: ISessionDataService; - readonly agents: IObservable; - readonly hostLaunchKind: AgentHostLaunchKind; - readonly copilotApiServiceOverride: ICopilotApiService | undefined; - readonly getAuthToken: (request: IAgentHostAuthTokenRequest) => string | undefined; - readonly createAgentMergeControllerOptions: () => IAgentMergeControllerOptions; - readonly createSideEffectsOptions: (services: { - readonly localTurns: AgentHostLocalTurns; - readonly copilotApiService: ICopilotApiService; - readonly octoKitService: IAgentHostOctoKitService; - readonly gitStateService: IAgentHostGitStateService; - }) => IAgentSideEffectsOptions; +export interface IAgentServiceCallbacks { + readonly canEvictChangeset: (changeset: string) => boolean; + readonly startAgentMergeTurn: IAgentMergeControllerOptions['startTurn']; + readonly cancelAgentMergeTurn: IAgentMergeControllerOptions['cancelTurn']; + readonly getAutonomousSessionConfig: IAgentMergeControllerOptions['getAutonomousSessionConfig']; + readonly getAgent: IAgentSideEffectsOptions['getAgent']; + readonly resolveWorkingDirectoryBeforeSend: NonNullable; + readonly resolveChatAttachmentTurns: NonNullable; readonly getSessionMetadata: (session: URI) => Promise; readonly restoreSession: (session: URI) => Promise; - readonly createSessionServerToolAccessor: () => ISessionServerToolAccessor; - readonly createArtifactServerToolAccessor: () => IArtifactServerToolAccessor; + readonly sessionServerToolAccessor: ISessionServerToolAccessor; + readonly artifactServerToolAccessor: IArtifactServerToolAccessor; +} + +export interface IAgentServiceCallbackBinder { + bind(callbacks: IAgentServiceCallbacks): void; } -/** Collaborators constructed by the composition root after registering {@link IAgentService}. */ -export interface IAgentServiceInitialization { +export interface IAgentServiceCollaborators { readonly gitHubEndpointService: IAgentHostGitHubEndpointService; readonly customizationEnablementService: AgentHostCustomizationEnablementService; readonly gitStateService: IAgentHostGitStateService; readonly agentMergeController: AgentMergeController; readonly checkpointService: IAgentHostCheckpointService; - readonly promptCache: IAgentHostPromptCache; - readonly sessionTitleSignal: IAgentHostSessionTitleSignal; readonly changesetOperationService: IAgentHostChangesetOperationService; readonly reviewService: IAgentHostReviewService; readonly changesets: IAgentHostChangesetService; @@ -420,10 +406,8 @@ export interface IAgentServiceCore { readonly sessionRegistry: AgentSessionRegistry; readonly stateManager: AgentHostStateManager; readonly configurationService: AgentConfigurationService; - readonly storageService: AgentHostStorageService; - readonly managedSettingsService: IAgentHostManagedSettingsService; - readonly hostLaunchKind: AgentHostLaunchKind; - readonly copilotApiServiceOverride: ICopilotApiService | undefined; + readonly agents: ISettableObservable; + readonly callbackBinder: IAgentServiceCallbackBinder; } /** @@ -450,8 +434,7 @@ export class AgentService extends Disposable implements IAgentService { /** Authoritative state manager for the sessions process protocol. */ private readonly _stateManager: AgentHostStateManager; - private _sessionCoordination!: SessionCoordinationService; - private readonly _managedSettingsService: IAgentHostManagedSettingsService; + private readonly _sessionCoordination: SessionCoordinationService; /** * Orchestrator-owned durable index of known sessions. Populated alongside @@ -477,32 +460,8 @@ export class AgentService extends Disposable implements IAgentService { */ private readonly _unpersistedChatBackings = new Set(); - /** Exposes the state manager for co-hosting a WebSocket protocol server. */ get stateManager(): AgentHostStateManager { return this._stateManager; } - /** Exposes the configuration service so agent providers can share root config plumbing. */ - get configurationService(): IAgentConfigurationService { return this._configurationService; } - - /** Exposes host-owned persistent storage to process-level DI. */ - get storageService(): IAgentHostStorageService { return this._storageService; } - - /** Exposes customization enablement to process-level DI. */ - get customizationEnablementService(): IAgentHostCustomizationEnablementService { return this._customizationEnablementService; } - - get managedSettingsService(): IAgentHostManagedSettingsService { return this._managedSettingsService; } - - /** Exposes the GitHub endpoint service so agent providers share GitHub (Enterprise) resource resolution. */ - get gitHubEndpointService(): IAgentHostGitHubEndpointService { return this._gitHubEndpointService; } - - /** Exposes the checkpoint service so agent providers can capture session baselines. */ - get checkpointService(): IAgentHostCheckpointService { return this._checkpointService; } - - /** Exposes prompt-cache metadata without exposing the whole state manager. */ - get promptCache(): IAgentHostPromptCache { return this._promptCache; } - - /** Exposes host-owned session-title changes without exposing the whole state manager. */ - get sessionTitleSignal(): IAgentHostSessionTitleSignal { return this._sessionTitleSignal; } - /** Registered providers keyed by their {@link AgentProvider} id. */ private readonly _providers = new Map(); /** Maps each active session URI (toString) to its owning provider. */ @@ -532,38 +491,34 @@ export class AgentService extends Disposable implements IAgentService { private readonly _disposingPeerChats = new Set(); private readonly _defaultChatBackingWrites = new Map>(); private readonly _authService: AgentHostAuthenticationService; - get authenticationService(): IAgentHostAuthenticationService { return this._authService; } /** Default provider used when no explicit provider is specified. */ private _defaultProvider: AgentProvider | undefined; /** Observable registered agents, drives `root/agentsChanged` via {@link AgentSideEffects}. */ - private readonly _agents = observableValue('agents', []); + private readonly _agents: ISettableObservable; /** Shared side-effect handler for action dispatch and session lifecycle. */ - private _sideEffects!: AgentSideEffects; - private _agentMergeController!: AgentMergeController; + private readonly _sideEffects: AgentSideEffects; + private readonly _agentMergeController: AgentMergeController; /** Owns static / per-turn changeset compute, publish, persist, restore. */ - private _changesets!: IAgentHostChangesetService; + private readonly _changesets: IAgentHostChangesetService; /** Shared active changeset subscription registry. */ /** Owns changeset operation contributions and handler activation. */ - private _changesetOperationService!: IAgentHostChangesetOperationService; - private _reviewService!: IAgentHostReviewService; + private readonly _changesetOperationService: IAgentHostChangesetOperationService; + private readonly _reviewService: IAgentHostReviewService; /** Owns AgentService-side orchestration of the changeset feature. */ - private _changesetCoordinator!: AgentHostChangesetCoordinator; + private readonly _changesetCoordinator: AgentHostChangesetCoordinator; /** Owns session git-state probing and git-backed catalogue decoration. */ - private _gitStateService!: IAgentHostGitStateService; + private readonly _gitStateService: IAgentHostGitStateService; /** Manages PTY-backed terminals for the agent host protocol. */ - private _terminalManager!: AgentHostTerminalManager; + private readonly _terminalManager: AgentHostTerminalManager; /** Persists host-injected `/rename` / `!command` turns for restore & fork/truncate. */ - private _localTurns!: AgentHostLocalTurns; + private readonly _localTurns: AgentHostLocalTurns; /** Server-side host for the agent host's server tools. */ - private _serverToolHost!: AgentServerToolHost; + private readonly _serverToolHost: AgentServerToolHost; private readonly _debugLogsCollector: AgentHostDebugLogsCollector | undefined; private readonly _configurationService: AgentConfigurationService; - private readonly _storageService: AgentHostStorageService; - private _customizationEnablementService!: AgentHostCustomizationEnablementService; + private readonly _customizationEnablementService: AgentHostCustomizationEnablementService; /** Captures baseline / per-turn git checkpoints backing the changeset pipeline. */ - private _checkpointService!: IAgentHostCheckpointService; - private _promptCache!: IAgentHostPromptCache; - private _sessionTitleSignal!: IAgentHostSessionTitleSignal; + private readonly _checkpointService: IAgentHostCheckpointService; /** * Host-owned worktree isolation controller. Set post-construction via * {@link setWorktreeIsolation} after host startup constructs the Copilot API @@ -574,16 +529,13 @@ export class AgentService extends Disposable implements IAgentService { */ private _worktree: WorktreeIsolation | undefined; /** Single source of truth for GitHub (Enterprise) endpoints and protected resources. */ - private _gitHubEndpointService!: IAgentHostGitHubEndpointService; + private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService; /** Pluggable completion item providers (e.g. workspace file completions, agent-specific @-mentions). */ - private _completions!: IAgentHostCompletions; - private _initialized = false; + private readonly _completions: IAgentHostCompletions; private _skillCompletionProviderRegistered = false; /** Backs {@link getNetworkDiagnosticsInfo} / {@link diagnosticsFetch}; wired via {@link setNetworkDiagnosticsService}. */ private _networkDiagnostics: INetworkDiagnosticsService | undefined; private _editAttributionService: IAgentEditAttributionService | undefined; - private readonly _hostLaunchKind: AgentHostLaunchKind; - private readonly _copilotApiServiceOverride: ICopilotApiService | undefined; /** * Authoritative server-side per-resource subscription refcount, keyed by @@ -653,20 +605,9 @@ export class AgentService extends Disposable implements IAgentService { */ private readonly _resourceWatches = this._register(new DisposableMap()); - /** Exposes the terminal manager for use by agent providers. */ - get terminalManager(): IAgentHostTerminalManager { return this._terminalManager; } - - /** Exposes the completions service for use by agent providers (e.g. to register agent-scoped completion item providers). */ - get completionsService(): IAgentHostCompletions { return this._completions; } - - /** - * Trigger characters announced to clients via `InitializeResult.completionTriggerCharacters`. - * Aggregated from all registered {@link IAgentHostCompletionItemProvider}s. - */ - get completionTriggerCharacters(): readonly string[] { return this._completions.triggerCharacters; } - constructor( core: IAgentServiceCore, + collaborators: IAgentServiceCollaborators, @ILogService private readonly _logService: ILogService, @IFileService private readonly _fileService: IFileService, @ISessionDataService private readonly _sessionDataService: ISessionDataService, @@ -675,14 +616,42 @@ export class AgentService extends Disposable implements IAgentService { ) { super(); this._register(core.disposables); - this._hostLaunchKind = core.hostLaunchKind; - this._copilotApiServiceOverride = core.copilotApiServiceOverride; - this._logService.info('AgentService initialized'); this._authService = core.authenticationService; this._orchestratorDatabase = core.orchestratorDatabase; this._debugLogsCollector = core.debugLogsCollector; this._sessionRegistry = core.sessionRegistry; this._stateManager = core.stateManager; + this._configurationService = core.configurationService; + this._agents = core.agents; + this._gitHubEndpointService = collaborators.gitHubEndpointService; + this._customizationEnablementService = collaborators.customizationEnablementService; + this._gitStateService = collaborators.gitStateService; + this._agentMergeController = collaborators.agentMergeController; + this._checkpointService = collaborators.checkpointService; + this._changesetOperationService = collaborators.changesetOperationService; + this._reviewService = collaborators.reviewService; + this._changesets = collaborators.changesets; + this._changesetCoordinator = collaborators.changesetCoordinator; + this._completions = collaborators.completions; + this._terminalManager = collaborators.terminalManager; + this._localTurns = collaborators.localTurns; + this._sideEffects = collaborators.sideEffects; + this._sessionCoordination = collaborators.sessionCoordination; + this._serverToolHost = collaborators.serverToolHost; + core.callbackBinder.bind({ + canEvictChangeset: changeset => this._canEvictChangeset(changeset), + startAgentMergeTurn: (session, turnId, prompt) => this._startAgentMergePrompt(session, turnId, prompt), + cancelAgentMergeTurn: (session, turnId) => this._cancelAgentMergePrompt(session, turnId), + getAutonomousSessionConfig: (session, config) => this._findProviderForSession(session)?.getAutonomousSessionConfig?.(config), + getAgent: session => this._findProviderForSession(session), + resolveWorkingDirectoryBeforeSend: params => this._resolveWorkingDirectoryBeforeSend(params), + resolveChatAttachmentTurns: resource => this._resolveChatAttachmentTurns(resource), + getSessionMetadata: session => this._getSessionMetadata(session), + restoreSession: session => this.restoreSession(session), + sessionServerToolAccessor: this._createSessionServerToolAccessor(), + artifactServerToolAccessor: this._createArtifactServerToolAccessor(), + }); + this._logService.info('AgentService initialized'); this._register(this._stateManager.onDidEmitEnvelope(e => this._onDidAction.fire(e))); this._register(this._stateManager.onDidEmitEnvelope(e => this._trackPendingSubagentChatFromEnvelope(e))); this._register(this._stateManager.onDidEmitEnvelope(e => this._persistAnnotations(e))); @@ -703,86 +672,7 @@ export class AgentService extends Disposable implements IAgentService { this._queueSessionListReconciliation(); } })); - this._configurationService = core.configurationService; - this._storageService = core.storageService; - this._managedSettingsService = core.managedSettingsService; updateAgentHostTelemetryLevelFromConfig(this._telemetryService, this._stateManager.rootState.config?.values); - } - - /** Returns the narrow state and callback surface needed to compose collaborators. */ - getCompositionContext(): IAgentServiceCompositionContext { - return { - stateManager: this._stateManager, - configurationService: this._configurationService, - storageService: this._storageService, - managedSettingsService: this._managedSettingsService, - sessionDataService: this._sessionDataService, - agents: this._agents, - hostLaunchKind: this._hostLaunchKind, - copilotApiServiceOverride: this._copilotApiServiceOverride, - getAuthToken: request => this._authService.getAuthToken(request), - createAgentMergeControllerOptions: () => ({ - startTurn: (session, turnId, prompt) => this._startAgentMergePrompt(session, turnId, prompt), - cancelTurn: (session, turnId) => this._cancelAgentMergePrompt(session, turnId), - getAutonomousSessionConfig: (session, config) => this._findProviderForSession(session)?.getAutonomousSessionConfig?.(config), - }), - createSideEffectsOptions: services => ({ - getAgent: session => this._findProviderForSession(session), - sessionDataService: this._sessionDataService, - localTurns: services.localTurns, - agents: this._agents, - hostLaunchKind: this._hostLaunchKind, - copilotApiService: services.copilotApiService, - getGitHubCopilotToken: () => { - const resource = this._gitHubEndpointService.getCopilotResource(); - return this._authService.getAuthToken({ resource: resource.resource, scopes: resource.scopes_supported }); - }, - getGitHubToken: () => { - const resource = this._gitHubEndpointService.getRepoResource(); - return this._authService.getAuthToken({ resource: resource.resource, scopes: resource.scopes_supported }); - }, - getGitHubHost: () => this._gitHubEndpointService.getEnterpriseHost() ?? 'github.com', - octoKitService: services.octoKitService, - resolveWorkingDirectoryBeforeSend: params => this._resolveWorkingDirectoryBeforeSend(params), - resolveChatAttachmentTurns: resource => this._resolveChatAttachmentTurns(resource), - onTurnComplete: session => { - const workingDirStr = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; - void services.gitStateService.attachSessionGitHubPullRequest(session, workingDirStr ? URI.parse(workingDirStr) : undefined); - }, - onUserMessage: (session, text) => { - void services.gitStateService.attachSessionGitHubReferences(session.toString(), text); - }, - }), - getSessionMetadata: session => this._getSessionMetadata(session), - restoreSession: session => this.restoreSession(session), - createSessionServerToolAccessor: () => this._createSessionServerToolAccessor(), - createArtifactServerToolAccessor: () => this._createArtifactServerToolAccessor(), - }; - } - - /** Completes the one-time wiring of collaborators that depend on {@link IAgentService}. */ - initialize(initialization: IAgentServiceInitialization): void { - if (this._initialized) { - throw new Error('AgentService has already been initialized'); - } - this._initialized = true; - this._gitHubEndpointService = initialization.gitHubEndpointService; - this._customizationEnablementService = initialization.customizationEnablementService; - this._gitStateService = initialization.gitStateService; - this._agentMergeController = initialization.agentMergeController; - this._checkpointService = initialization.checkpointService; - this._promptCache = initialization.promptCache; - this._sessionTitleSignal = initialization.sessionTitleSignal; - this._changesetOperationService = initialization.changesetOperationService; - this._reviewService = initialization.reviewService; - this._changesets = initialization.changesets; - this._changesetCoordinator = initialization.changesetCoordinator; - this._completions = initialization.completions; - this._terminalManager = initialization.terminalManager; - this._localTurns = initialization.localTurns; - this._sideEffects = initialization.sideEffects; - this._sessionCoordination = initialization.sessionCoordination; - this._serverToolHost = initialization.serverToolHost; this._register(this._stateManager.onDidChangeSessionConfig(({ session, previous, current }) => this._syncAgentMergeIndex(URI.parse(session), previous, current))); this._register(this._agentMergeController.onDidReleaseHold(session => { const resource = URI.parse(session); @@ -827,24 +717,6 @@ export class AgentService extends Disposable implements IAgentService { this._scheduleExternalSessionPrune(); } - /** - * The registered providers. Exposed so process-lifetime background jobs - * (notably {@link AgentModelRefreshScheduler}) can observe registrations - * without this service owning an ambient recurring timer of its own. - */ - get agents(): IObservable { - return this._agents; - } - - /** - * Fires with the provider id whenever a turn starts. Exposed alongside - * {@link agents} so {@link AgentModelRefreshScheduler} can gate its periodic - * refresh on real agent usage rather than polling an idle host. - */ - get onDidStartTurn(): Event { - return this._sideEffects.onDidStartTurn; - } - private _scheduleExternalSessionPrune(): void { this._register(disposableTimeout(() => { void this._pruneStaleExternalSessions().catch(error => { @@ -1005,7 +877,7 @@ export class AgentService extends Disposable implements IAgentService { workingDirectory: pickedFolderUri, config: this._configurationService.getSessionConfigValues(params.session), prompt: params.prompt, - githubToken: this.getAuthToken({ + githubToken: this._authService.getAuthToken({ resource: this._gitHubEndpointService.getCopilotResource().resource, scopes: this._gitHubEndpointService.getCopilotResource().scopes_supported, }), @@ -1117,10 +989,6 @@ export class AgentService extends Disposable implements IAgentService { return result; } - getAuthToken(request: IAgentHostAuthTokenRequest): string | undefined { - return this._authService.getAuthToken(request); - } - // ---- Changeset operation handlers -------------------------------------- async invokeChangesetOperation(params: InvokeChangesetOperationParams): Promise { @@ -4229,7 +4097,7 @@ export class AgentService extends Disposable implements IAgentService { } /** Returns true when a changeset is safe to drop from the in-memory cache. */ - canEvictChangeset(changeset: string): boolean { + private _canEvictChangeset(changeset: string): boolean { const changesetUri = URI.parse(changeset); // A direct changeset subscriber is rendering this expanded URI. Keep // the state alive so future envelopes still target an existing object. diff --git a/src/vs/platform/agentHost/node/agentServiceComposition.ts b/src/vs/platform/agentHost/node/agentServiceComposition.ts index 2895a90c533ffe..756ed692021f8d 100644 --- a/src/vs/platform/agentHost/node/agentServiceComposition.ts +++ b/src/vs/platform/agentHost/node/agentServiceComposition.ts @@ -3,8 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import type { Event } from '../../../base/common/event.js'; import { DisposableStore, type IDisposable } from '../../../base/common/lifecycle.js'; +import { observableValue, type IObservable } from '../../../base/common/observable.js'; import { dirname, joinPath } from '../../../base/common/resources.js'; +import { URI } from '../../../base/common/uri.js'; import { GitHubService, IGitHubService } from '../../github/common/githubService.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; @@ -16,8 +19,9 @@ import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChang import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js'; import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js'; import { IAgentHostReviewService } from '../common/agentHostReviewService.js'; -import { IAgentService } from '../common/agentService.js'; import { AgentHostLaunchKind } from '../common/agentHostTelemetry.js'; +import type { IAgent } from '../common/agent.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js'; import { AgentHostAuthenticationService, IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { AgentHostChangesetCoordinator } from './agentHostChangesetCoordinator.js'; @@ -50,27 +54,87 @@ import { AgentHostTerminalManager, IAgentHostTerminalManager } from './agentHost import { AgentHostWorkspaceFiles } from './agentHostWorkspaceFiles.js'; import { AgentMergeController } from './agentMergeController.js'; import { AgentMergeTools } from './agentMergeTools.js'; -import { AgentService, type IAgentServiceCore, type IAgentServiceOptions } from './agentService.js'; +import { AgentService, type IAgentServiceCallbacks, type IAgentServiceCollaborators, type IAgentServiceCore, type IAgentServiceOptions } from './agentService.js'; import { AgentSessionRegistry } from './agentSessionRegistry.js'; import { AgentSideEffects } from './agentSideEffects.js'; import { CodexCompactCompletionProvider } from './codexCompactCommand.js'; import { SessionCoordinationService } from './sessionCoordination.js'; import { AgentServerToolHost } from './shared/agentServerToolHost.js'; import { buildServerToolGroups } from './shared/serverToolGroups.js'; +import type { ISessionServerToolAccessor } from './shared/sessionServerTools.js'; +import type { IArtifactServerToolAccessor } from './shared/artifactServerTools.js'; import { AgentHostOctoKitService, IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js'; import { CopilotApiService, ICopilotApiService } from './shared/copilotApiService.js'; import { hostBuildInfoFromProduct } from '../common/state/sessionState.js'; -/** Constructs, registers, and initializes the complete {@link AgentService} collaborator graph. */ -export function createAgentService( +export interface IAgentServiceComposition { + readonly agentService: AgentService; + readonly authenticationService: IAgentHostAuthenticationService; + readonly configurationService: IAgentConfigurationService; + readonly stateManager: AgentHostStateManager; + readonly customizationEnablementService: IAgentHostCustomizationEnablementService; + readonly checkpointService: IAgentHostCheckpointService; + readonly completions: IAgentHostCompletions; + readonly agents: IObservable; + readonly onDidStartTurn: Event; +} + +class AgentServiceCallbackAdapter { + private callbacks: IAgentServiceCallbacks | undefined; + + readonly sessionServerToolAccessor: ISessionServerToolAccessor = { + isActiveAgentTitleGenerationEnabled: () => this.value.sessionServerToolAccessor.isActiveAgentTitleGenerationEnabled(), + listSessions: () => this.value.sessionServerToolAccessor.listSessions(), + getSession: session => this.value.sessionServerToolAccessor.getSession(session), + createSession: config => this.value.sessionServerToolAccessor.createSession(config), + getModels: () => this.value.sessionServerToolAccessor.getModels(), + getCreationDefaults: source => this.value.sessionServerToolAccessor.getCreationDefaults(source), + startPrompt: (session, chat, prompt) => this.value.sessionServerToolAccessor.startPrompt(session, chat, prompt), + createChat: (session, chat, options) => this.value.sessionServerToolAccessor.createChat(session, chat, options), + renameChat: (session, chat, title) => this.value.sessionServerToolAccessor.renameChat(session, chat, title), + reportToolError: (toolName, error) => this.value.sessionServerToolAccessor.reportToolError(toolName, error), + deleteSession: session => this.value.sessionServerToolAccessor.deleteSession(session), + getChatContext: (session, chatId) => this.value.sessionServerToolAccessor.getChatContext(session, chatId), + getSessionSpawnDepth: session => this.value.sessionServerToolAccessor.getSessionSpawnDepth(session), + setSessionSpawnDepth: (session, depth) => this.value.sessionServerToolAccessor.setSessionSpawnDepth(session, depth), + setSessionOrchestration: (session, orchestration) => this.value.sessionServerToolAccessor.setSessionOrchestration(session, orchestration), + }; + + readonly artifactServerToolAccessor: IArtifactServerToolAccessor = { + isEnabled: () => this.value.artifactServerToolAccessor.isEnabled(), + persist: (session, artifacts) => this.value.artifactServerToolAccessor.persist(session, artifacts), + }; + + bind(callbacks: IAgentServiceCallbacks): void { + if (this.callbacks) { + throw new Error('AgentService callbacks have already been bound'); + } + this.callbacks = callbacks; + } + + canEvictChangeset(changeset: string): boolean { + return this.callbacks?.canEvictChangeset(changeset) ?? false; + } + + get value(): IAgentServiceCallbacks { + if (!this.callbacks) { + throw new Error('AgentService callbacks have not been bound'); + } + return this.callbacks; + } +} + +/** Constructs and registers the complete {@link AgentService} collaborator graph. */ +export function createAgentServiceComposition( options: IAgentServiceOptions, services: ServiceCollection, instantiationService: IInstantiationService, fetchFn: typeof globalThis.fetch, logService: ILogService, productService: IProductService, + sessionDataService: ISessionDataService, additionalDisposables: readonly IDisposable[] = [], -): AgentService { +): IAgentServiceComposition { const owned = new DisposableStore(); let agentService: AgentService | undefined; try { @@ -84,11 +148,13 @@ export function createAgentService( const debugLogsCollector = options.debugLogsEnvironment ? owned.add(new AgentHostDebugLogsCollector(options.debugLogsEnvironment, logService)) : undefined; + const callbackAdapter = new AgentServiceCallbackAdapter(); + const agents = observableValue(callbackAdapter, []); const sessionRegistry = owned.add(new AgentSessionRegistry(orchestratorDatabase)); const stateManager = owned.add(new AgentHostStateManager(logService, { hostBuildInfo: hostBuildInfoFromProduct(productService), changesetStateRetention: { - canEvict: changeset => agentService?.canEvictChangeset(changeset) ?? false, + canEvict: changeset => callbackAdapter.canEvictChangeset(changeset), }, })); const configurationService = owned.add(new AgentConfigurationService( @@ -107,20 +173,16 @@ export function createAgentService( sessionRegistry, stateManager, configurationService, - storageService, - managedSettingsService, - hostLaunchKind: options.hostLaunchKind ?? AgentHostLaunchKind.Unknown, - copilotApiServiceOverride: options.copilotApiService, + agents, + callbackBinder: callbackAdapter, }; - agentService = instantiationService.createInstance(AgentService, core); - const context = agentService.getCompositionContext(); - services.set(IAgentService, agentService); services.set(IAgentHostAuthenticationService, core.authenticationService); - services.set(IAgentConfigurationService, context.configurationService); - services.set(IAgentHostStateManager, context.stateManager); - services.set(IAgentHostStorageService, context.storageService); - services.set(IAgentHostManagedSettingsService, context.managedSettingsService); + services.set(IAgentConfigurationService, configurationService); + services.set(IAgentHostStateManager, stateManager); + services.set(IAgentHostStorageService, storageService); + services.set(IAgentHostManagedSettingsService, managedSettingsService); + // AgentService subscribes after this graph is complete, so collaborator constructors must not emit state-manager events. const gitHubEndpointService = owned.add(instantiationService.createInstance(AgentHostGitHubEndpointService)); services.set(IAgentHostGitHubEndpointService, gitHubEndpointService); const octoKitService = instantiationService.createInstance(AgentHostOctoKitService, fetchFn); @@ -130,19 +192,23 @@ export function createAgentService( tokenProvider: { getToken: () => { const resource = gitHubEndpointService.getRepoResource(); - return context.getAuthToken({ resource: resource.resource, scopes: resource.scopes_supported }); + return core.authenticationService.getAuthToken({ resource: resource.resource, scopes: resource.scopes_supported }); }, }, fetch: fetchFn, })); services.set(IGitHubService, gitHubService); - const copilotApiService = context.copilotApiServiceOverride ?? instantiationService.createInstance(CopilotApiService, fetchFn); + const copilotApiService = options.copilotApiService ?? instantiationService.createInstance(CopilotApiService, fetchFn); services.set(ICopilotApiService, copilotApiService); const customizationEnablementService = owned.add(instantiationService.createInstance(AgentHostCustomizationEnablementService)); services.set(IAgentHostCustomizationEnablementService, customizationEnablementService); const gitStateService = owned.add(instantiationService.createInstance(AgentHostGitStateService)); services.set(IAgentHostGitStateService, gitStateService); - const agentMergeController = owned.add(instantiationService.createInstance(AgentMergeController, context.createAgentMergeControllerOptions())); + const agentMergeController = owned.add(instantiationService.createInstance(AgentMergeController, { + startTurn: (session, turnId, prompt) => callbackAdapter.value.startAgentMergeTurn(session, turnId, prompt), + cancelTurn: (session, turnId) => callbackAdapter.value.cancelAgentMergeTurn(session, turnId), + getAutonomousSessionConfig: (session, config) => callbackAdapter.value.getAutonomousSessionConfig(session, config), + })); const checkpointService = owned.add(instantiationService.createInstance(AgentHostCheckpointService)); services.set(IAgentHostCheckpointService, checkpointService); const promptCache = instantiationService.createInstance(AgentHostPromptCache); @@ -158,7 +224,7 @@ export function createAgentService( const changesets = owned.add(instantiationService.createInstance(AgentHostChangesetService)); services.set(IAgentHostChangesetService, changesets); const changesetCoordinator = owned.add(instantiationService.createInstance(AgentHostChangesetCoordinator)); - owned.add(context.stateManager.onDidChangeSessionActiveTurn(event => changesetCoordinator.onSessionTurnActiveChanged(event.session, event.active))); + owned.add(stateManager.onDidChangeSessionActiveTurn(event => changesetCoordinator.onSessionTurnActiveChanged(event.session, event.active))); owned.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostCommitOperationContribution))); owned.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostPullRequestOperationContribution))); owned.add(changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostMergeOperationContribution))); @@ -168,31 +234,57 @@ export function createAgentService( const completions = owned.add(instantiationService.createInstance(AgentHostCompletions)); services.set(IAgentHostCompletions, completions); const workspaceFiles = owned.add(instantiationService.createInstance(AgentHostWorkspaceFiles)); - owned.add(completions.registerProvider(new AgentHostFileCompletionProvider(context.stateManager, workspaceFiles, logService))); - owned.add(completions.registerProvider(new AgentHostChatCompletionProvider(context.stateManager))); + owned.add(completions.registerProvider(new AgentHostFileCompletionProvider(stateManager, workspaceFiles, logService))); + owned.add(completions.registerProvider(new AgentHostChatCompletionProvider(stateManager))); owned.add(completions.registerProvider(new AgentHostRenameCompletionProvider( - session => (context.stateManager.getSessionState(session)?.turns.length ?? 0) > 0, + session => (stateManager.getSessionState(session)?.turns.length ?? 0) > 0, ))); owned.add(completions.registerProvider(new CodexCompactCompletionProvider( - session => (context.stateManager.getSessionState(session)?.turns.length ?? 0) > 0, + session => (stateManager.getSessionState(session)?.turns.length ?? 0) > 0, ))); const terminalManager = owned.add(instantiationService.createInstance(AgentHostTerminalManager)); services.set(IAgentHostTerminalManager, terminalManager); - const localTurns = new AgentHostLocalTurns(context.sessionDataService, logService); + const localTurns = new AgentHostLocalTurns(sessionDataService, logService); const sideEffects = owned.add(instantiationService.createInstance( AgentSideEffects, - context.stateManager, + stateManager, customizationEnablementService, - context.createSideEffectsOptions({ localTurns, copilotApiService, octoKitService, gitStateService }), + { + getAgent: session => callbackAdapter.value.getAgent(session), + sessionDataService, + localTurns, + agents, + hostLaunchKind: options.hostLaunchKind ?? AgentHostLaunchKind.Unknown, + copilotApiService, + getGitHubCopilotToken: () => { + const resource = gitHubEndpointService.getCopilotResource(); + return core.authenticationService.getAuthToken({ resource: resource.resource, scopes: resource.scopes_supported }); + }, + getGitHubToken: () => { + const resource = gitHubEndpointService.getRepoResource(); + return core.authenticationService.getAuthToken({ resource: resource.resource, scopes: resource.scopes_supported }); + }, + getGitHubHost: () => gitHubEndpointService.getEnterpriseHost() ?? 'github.com', + octoKitService, + resolveWorkingDirectoryBeforeSend: params => callbackAdapter.value.resolveWorkingDirectoryBeforeSend(params), + resolveChatAttachmentTurns: resource => callbackAdapter.value.resolveChatAttachmentTurns(resource), + onTurnComplete: session => { + const workingDirStr = stateManager.getSessionState(session)?.workingDirectories?.[0]; + void gitStateService.attachSessionGitHubPullRequest(session, workingDirStr ? URI.parse(workingDirStr) : undefined); + }, + onUserMessage: (session, text) => { + void gitStateService.attachSessionGitHubReferences(session.toString(), text); + }, + }, )); const sessionCoordination = owned.add(new SessionCoordinationService( - context.stateManager, - context.sessionDataService, + stateManager, + sessionDataService, logService, { - getSessionMetadata: context.getSessionMetadata, - restoreSession: context.restoreSession, + getSessionMetadata: session => callbackAdapter.value.getSessionMetadata(session), + restoreSession: session => callbackAdapter.value.restoreSession(session), handleAction: (chat, action) => sideEffects.handleAction(chat, action), }, )); @@ -202,18 +294,16 @@ export function createAgentService( session => agentMergeController.getTurnContext(session), ); const serverToolHost = new AgentServerToolHost( - context.stateManager, - buildServerToolGroups(context.createSessionServerToolAccessor(), agentMergeTools, context.createArtifactServerToolAccessor()), + stateManager, + buildServerToolGroups(callbackAdapter.sessionServerToolAccessor, agentMergeTools, callbackAdapter.artifactServerToolAccessor), ); - agentService.initialize({ + const collaborators: IAgentServiceCollaborators = { gitHubEndpointService, customizationEnablementService, gitStateService, agentMergeController, checkpointService, - promptCache, - sessionTitleSignal, changesetOperationService, reviewService, changesets, @@ -224,8 +314,19 @@ export function createAgentService( sideEffects, sessionCoordination, serverToolHost, - }); - return agentService; + }; + agentService = instantiationService.createInstance(AgentService, core, collaborators); + return { + agentService, + authenticationService: core.authenticationService, + configurationService, + stateManager, + customizationEnablementService, + checkpointService, + completions, + agents, + onDidStartTurn: sideEffects.onDidStartTurn, + }; } catch (error) { if (agentService) { agentService.dispose(); diff --git a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts index f926bedd2e4c97..a99716866c3155 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import type Anthropic from '@anthropic-ai/sdk'; import type { CCAModel } from '@vscode/copilot-api'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { Event } from '../../../../base/common/event.js'; import type { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; @@ -19,10 +20,10 @@ import { createTestGitHubEndpointService } from './testGitHubEndpointService.js' import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { CopilotApiError, type ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest } from '../../node/shared/copilotApiService.js'; import { GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../common/agent.js'; -import { IAgentService } from '../../common/agentService.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; import { ChangesSummary } from '../../common/state/protocol/state.js'; import type { IAgentHostChangesetService, IPersistedChangesetMetadata, IRestoredChangesetDiffs, StaticChangesetKind } from '../../common/agentHostChangesetService.js'; +import type { IAgentHostAuthenticationService } from '../../node/agentHostAuthenticationService.js'; class TestGitService implements IAgentHostGitService { declare readonly _serviceBrand: undefined; @@ -139,10 +140,12 @@ class TestChangesetService implements IAgentHostChangesetService { onSessionTruncated(_session: string): void { } } -function createAgentService(token: string | undefined): IAgentService { +function createAuthenticationService(token: string | undefined): IAgentHostAuthenticationService { return { + _serviceBrand: undefined, + onDidChangeAuthToken: Event.None, getAuthToken: () => token, - } as Partial as IAgentService; + }; } function setup(disposables: Pick, gitService: TestGitService, copilotApiService: TestCopilotApiService, changesets: TestChangesetService, options?: { readonly onCommittedError?: Error }): { handler: AgentHostCommitOperationHandler; session: URI; committedSessions: string[] } { @@ -169,7 +172,7 @@ function setup(disposables: Pick, gitService: TestGitSer if (options?.onCommittedError) { throw options.onCommittedError; } - }, createAgentService('gh-repo-token'), createTestGitHubEndpointService(), gitService, copilotApiService, new NullLogService()), + }, createAuthenticationService('gh-repo-token'), createTestGitHubEndpointService(), gitService, copilotApiService, new NullLogService()), session, committedSessions, }; diff --git a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts index 0e46fd3295991f..a736679553c2ee 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts @@ -4,12 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Event } from '../../../../base/common/event.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { NullLogService } from '../../../log/common/log.js'; import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js'; -import type { IAgentService } from '../../common/agentService.js'; import { getSessionRelatedPullRequestUrls, hasSessionPullRequestForBranch, readSessionGitHubState, readSessionGitState, readSessionSourceControlState, SESSION_META_GITHUB_KEY, SessionSourceControlOutcome, withInitialSessionPullRequest, withMostRecentRelatedSessionPullRequest, withMostRecentSessionPullRequest, withSessionGitHubState, withSessionGitState, SessionStatus, type ISessionGitHubState, type ISessionGitState, type SessionSummary } from '../../common/state/sessionState.js'; import { META_GIT_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../../common/agentHostGitStateService.js'; import { AgentHostGitStateService } from '../../node/agentHostGitStateService.js'; @@ -18,6 +18,7 @@ import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import type { CreatedPullRequest, IAgentHostOctoKitService } from '../../node/shared/agentHostOctoKitService.js'; import { TestSessionDatabase, createNoopGitService, createSessionDataService } from '../common/sessionTestHelpers.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +import type { IAgentHostAuthenticationService } from '../../node/agentHostAuthenticationService.js'; const SESSION = 'mock:/session-1'; const WORKING_DIRECTORY = 'file:///wd'; @@ -135,7 +136,7 @@ suite('AgentHostGitStateService', () => { ]); }); - function createHarness(options?: { octoKitService?: IAgentHostOctoKitService; agentService?: IAgentService; enterpriseUri?: string }) { + function createHarness(options?: { octoKitService?: IAgentHostOctoKitService; authenticationService?: IAgentHostAuthenticationService; enterpriseUri?: string }) { const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const db = new TestSessionDatabase(); const sessionDataService = createSessionDataService(db); @@ -174,13 +175,17 @@ suite('AgentHostGitStateService', () => { return pullRequestsBySha.get(sha); }, } as unknown as IAgentHostOctoKitService; - const agentService = { getAuthToken: () => 'token' } as unknown as IAgentService; + const authenticationService: IAgentHostAuthenticationService = { + _serviceBrand: undefined, + onDidChangeAuthToken: Event.None, + getAuthToken: () => 'token', + }; const service = disposables.add(new AgentHostGitStateService( stateManager, gitService, options?.octoKitService ?? octoKitService, - options?.agentService ?? agentService, + options?.authenticationService ?? authenticationService, createTestGitHubEndpointService(options?.enterpriseUri), new NullLogService(), sessionDataService, @@ -487,8 +492,12 @@ suite('AgentHostGitStateService', () => { return { url: 'https://github.com/microsoft/vscode/pull/1', number: 1 }; }, } as unknown as IAgentHostOctoKitService; - const agentService = { getAuthToken: () => 'token' } as unknown as IAgentService; - const h = createHarness({ octoKitService, agentService }); + const authenticationService: IAgentHostAuthenticationService = { + _serviceBrand: undefined, + onDidChangeAuthToken: Event.None, + getAuthToken: () => 'token', + }; + const h = createHarness({ octoKitService, authenticationService }); seedSession(h.stateManager, { workingDirectory: WORKING_DIRECTORY, gitState: { diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts index d18955ee60e200..005d8f058bb857 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts @@ -5,12 +5,12 @@ import assert from 'assert'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { Event } from '../../../../base/common/event.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 { NullLogService } from '../../../log/common/log.js'; import { GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE } from '../../common/agent.js'; -import { type IAgentService } from '../../common/agentService.js'; import { buildSessionChangesetUri } from '../../common/changesetUri.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { withSessionGitHubState, withSessionGitState, type ISessionFileDiff, type ISessionGitState, MessageKind, ResponsePartKind, SessionStatus, TurnState, type Turn } from '../../common/state/sessionState.js'; @@ -22,6 +22,7 @@ import type { AutoMergeMethod, CreatedPullRequest, GitHubIssueOrPullRequest, IAg import type { ICopilotApiService, ICopilotApiServiceRequestOptions, ICopilotUtilityChatCompletionRequest } from '../../node/shared/copilotApiService.js'; import type Anthropic from '@anthropic-ai/sdk'; import type { CCAModel } from '@vscode/copilot-api'; +import type { IAgentHostAuthenticationService } from '../../node/agentHostAuthenticationService.js'; class TestCopilotApiService implements ICopilotApiService { declare readonly _serviceBrand: undefined; @@ -167,8 +168,10 @@ class TestOctoKitService implements IAgentHostOctoKitService { } } -function createAgentService(withCopilotToken = false): IAgentService { +function createAuthenticationService(withCopilotToken = false): IAgentHostAuthenticationService { return { + _serviceBrand: undefined, + onDidChangeAuthToken: Event.None, getAuthToken: resource => { if (resource.resource === GITHUB_REPO_PROTECTED_RESOURCE.resource) { return 'gh-token'; @@ -178,7 +181,7 @@ function createAgentService(withCopilotToken = false): IAgentService { } return undefined; }, - } as IAgentService; + }; } function setup(disposables: Pick, gitService: TestGitService, octoKitService: TestOctoKitService, options?: { copilotApiService?: TestCopilotApiService; withCopilotToken?: boolean; turns?: Turn[]; draft?: boolean; autoMergeMethod?: AutoMergeMethod; baseBranch?: string }): { handler: AgentHostPullRequestOperationHandler; session: URI; createdEvents: string[]; copilotApiService: TestCopilotApiService } { @@ -229,7 +232,7 @@ function setup(disposables: Pick, gitService: TestGitSer }, async () => options?.baseBranch ?? 'main', event => createdEvents.push(`${event.sessionKey}:${event.pullRequestUrl}`), - createAgentService(options?.withCopilotToken), gitService, octoKitService, createTestGitHubEndpointService(), copilotApiService, new NullLogService()), + createAuthenticationService(options?.withCopilotToken), gitService, octoKitService, createTestGitHubEndpointService(), copilotApiService, new NullLogService()), session, createdEvents, copilotApiService, diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 4c6be256778411..b3234280e9272a 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -65,7 +65,7 @@ import { SessionServerToolName } from '../../common/serverToolNames.js'; import { buildMcpChannel } from '../../node/shared/mcpCustomizationController.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; import { readChatSurfaceMeta, withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; -import { createTestAgentService } from './agentServiceTestUtils.js'; +import { createTestAgentService, getTestAgentServiceComposition } from './agentServiceTestUtils.js'; /** * Replace individual operations on an agent's chat surface, delegating every @@ -84,6 +84,18 @@ function getChatSurface(agent: IAgent): IAgentChats { return agent.chats; } +function getConfigurationService(service: AgentService) { + return getTestAgentServiceComposition(service).configurationService; +} + +function getAuthenticationService(service: AgentService) { + return getTestAgentServiceComposition(service).authenticationService; +} + +function getCheckpointService(service: AgentService) { + return getTestAgentServiceComposition(service).checkpointService; +} + /** * Provision a session directly on an agent through the exact-chat seam * an initializing {@link IAgentChats.createChat} call, mirroring what @@ -869,7 +881,7 @@ suite('AgentService (node dispatcher)', () => { override readonly chats: IAgentChats = withChatOverrides(getChatSurface(this), base => ({ createChat: async (chat, context, options) => { const { configurationResource } = resolveAgentChatContext(context, chat); - pendingDuringCreate.push(localService.configurationService.isWorkingDirectoryPending(configurationResource.toString())); + pendingDuringCreate.push(getConfigurationService(localService).isWorkingDirectoryPending(configurationResource.toString())); providerCreateConfigs.push(options?.config); if (failCreate) { throw new Error('create failed'); @@ -901,8 +913,8 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ pendingDuringCreate, providerCreateConfigs, - pendingAfterCreate: localService.configurationService.isWorkingDirectoryPending(session.toString()), - pendingAfterFailure: localService.configurationService.isWorkingDirectoryPending(failedSession.toString()), + pendingAfterCreate: getConfigurationService(localService).isWorkingDirectoryPending(session.toString()), + pendingAfterFailure: getConfigurationService(localService).isWorkingDirectoryPending(failedSession.toString()), }, { pendingDuringCreate: [true, true], providerCreateConfigs: [{}, {}], @@ -1037,7 +1049,7 @@ suite('AgentService (node dispatcher)', () => { // Reopen: a fresh service on the same DB rediscovers the provider-native // session and must restore the persisted decision into `_meta`. const reopened = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - reopened.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + getConfigurationService(reopened).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const reopenedAgent = new MockAgent('copilot'); disposables.add(toDisposable(() => reopenedAgent.dispose())); (reopenedAgent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(session), session); @@ -1095,7 +1107,7 @@ suite('AgentService (node dispatcher)', () => { await timeout(0); const reopened = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - reopened.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + getConfigurationService(reopened).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const reopenedAgent = new MockAgent('copilot'); disposables.add(toDisposable(() => reopenedAgent.dispose())); (reopenedAgent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(session), session); @@ -1218,8 +1230,8 @@ suite('AgentService (node dispatcher)', () => { workingDirectories: [URI.file('/workspace/repo')], config: { [SessionConfigKey.Isolation]: 'folder' }, }); - const creatingInitially = localService.configurationService.isWorkingDirectoryPending(creatingSession.toString()); - const readyInitially = localService.configurationService.isWorkingDirectoryPending(readySession.toString()); + const creatingInitially = getConfigurationService(localService).isWorkingDirectoryPending(creatingSession.toString()); + const readyInitially = getConfigurationService(localService).isWorkingDirectoryPending(readySession.toString()); const creatingLifecycle = localService.stateManager.getSessionState(creatingSession.toString())?.lifecycle; const readyLifecycle = localService.stateManager.getSessionState(readySession.toString())?.lifecycle; @@ -1227,19 +1239,19 @@ suite('AgentService (node dispatcher)', () => { type: ActionType.SessionConfigChanged, config: { [SessionConfigKey.Isolation]: 'worktree' }, }, 'test-client', 1); - const creatingAfterWorktree = localService.configurationService.isWorkingDirectoryPending(creatingSession.toString()); + const creatingAfterWorktree = getConfigurationService(localService).isWorkingDirectoryPending(creatingSession.toString()); localService.dispatchAction(creatingSession.toString(), { type: ActionType.SessionConfigChanged, config: { [SessionConfigKey.Isolation]: 'folder' }, }, 'test-client', 2); - const creatingAfterFolder = localService.configurationService.isWorkingDirectoryPending(creatingSession.toString()); + const creatingAfterFolder = getConfigurationService(localService).isWorkingDirectoryPending(creatingSession.toString()); localService.dispatchAction(readySession.toString(), { type: ActionType.SessionConfigChanged, config: { [SessionConfigKey.Isolation]: 'worktree' }, }, 'test-client', 3); - const readyAfterWorktree = localService.configurationService.isWorkingDirectoryPending(readySession.toString()); + const readyAfterWorktree = getConfigurationService(localService).isWorkingDirectoryPending(readySession.toString()); assert.deepStrictEqual({ creatingInitially, @@ -1528,7 +1540,7 @@ suite('AgentService (node dispatcher)', () => { undefined, copilotApiService, )); - svc.configurationService.updateRootConfig({ [AgentHostActiveAgentTitleGenerationConfigKey]: activeAgentTitleGeneration }); + getConfigurationService(svc).updateRootConfig({ [AgentHostActiveAgentTitleGenerationConfigKey]: activeAgentTitleGeneration }); const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); svc.registerProvider(agent); @@ -2011,7 +2023,7 @@ suite('AgentService (node dispatcher)', () => { // Drain any in-flight root-config write so its file handle is // closed before we delete the temp directory. - await svc.configurationService.whenIdle(); + await getConfigurationService(svc).whenIdle(); } finally { localDisposables.dispose(); await rm(tempDir.fsPath, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); @@ -2958,7 +2970,7 @@ suite('AgentService (node dispatcher)', () => { test('listSessions discovers provider-native sessions as external and restore preserves provenance', async () => { const db = new TestSessionDatabase(); const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); @@ -3507,7 +3519,7 @@ suite('AgentService (node dispatcher)', () => { const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MockAgent('copilot')); svc.registerProvider(agent); - svc.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); await svc.listSessions(); const session = AgentSession.uri('copilot', 'provider-announced'); @@ -3538,7 +3550,7 @@ suite('AgentService (node dispatcher)', () => { const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MockAgent('copilot')); svc.registerProvider(agent); - svc.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); await svc.listSessions(); const session = AgentSession.uri('copilot', 'toggled-adoptable'); @@ -3549,13 +3561,13 @@ suite('AgentService (node dispatcher)', () => { } const afterFirstEnable = !!svc.stateManager.getSurfacedSessionSummary(session.toString()); - svc.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: false }); + getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: false }); await timeout(0); const whileDisabled = !!svc.stateManager.getSurfacedSessionSummary(session.toString()); // Discovery skips chats already in the registry, so re-enabling must restore // them from the registry rather than waiting for another discovery pass. - svc.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); for (let i = 0; i < 50 && !svc.stateManager.getSurfacedSessionSummary(session.toString()); i++) { await timeout(0); } @@ -3972,7 +3984,7 @@ suite('AgentService (node dispatcher)', () => { } } const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const agent = disposables.add(new GatedListAgent('copilot')); svc.registerProvider(agent); const legacy = AgentSession.uri('copilot', 'legacy-concurrent'); @@ -4022,7 +4034,7 @@ suite('AgentService (node dispatcher)', () => { } const db = new TestSessionDatabase(); const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const agent = disposables.add(new TransientListFailureAgent('copilot')); svc.registerProvider(agent); const legacy = AgentSession.uri('copilot', 'legacy-session'); @@ -4043,7 +4055,7 @@ suite('AgentService (node dispatcher)', () => { test('a late-registered provider gets its own native discovery pass', async () => { const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const early = disposables.add(new MockAgent('copilot')); svc.registerProvider(early); @@ -4124,7 +4136,7 @@ suite('AgentService (node dispatcher)', () => { (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(legacy), legacy); agent.sessionMetadataOverrides = { _meta: withSessionEhcliAdoptable(undefined) }; svc.registerProvider(agent); - svc.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); await svc.listSessions(); const surfaced = svc.stateManager.getSurfacedSessionSummary(legacy.toString()); @@ -4181,7 +4193,7 @@ suite('AgentService (node dispatcher)', () => { } } const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const providerA = disposables.add(new CountingAgent('copilot')); const providerB = disposables.add(new FailingThenRecoveringAgent('other')); @@ -4232,7 +4244,7 @@ suite('AgentService (node dispatcher)', () => { } } const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const agent = disposables.add(new NotYetEnumerableAgent('copilot')); const originalListExternalChats = agent.listExternalChats.bind(agent); (agent as unknown as { listExternalChats: () => Promise }).listExternalChats = async () => { @@ -4270,7 +4282,7 @@ suite('AgentService (node dispatcher)', () => { await db.registerSession(existing.toString(), { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); const writesBeforeUnavailable = db.registryWriteAttempts; const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const agent = disposables.add(new NotYetMigratableAgent('copilot')); const legacy = AgentSession.uri('copilot', 'legacy-migration-not-ready'); (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(legacy), legacy); @@ -4360,8 +4372,8 @@ suite('AgentService (node dispatcher)', () => { } const db = new TransientRegistryWriteDatabase(); const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); - svc.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); const copilot = disposables.add(new CatalogAgent('copilot')); const claude = disposables.add(new CatalogAgent('claude')); const copilotSession = AgentSession.uri('copilot', 'complete-provider'); @@ -4614,7 +4626,7 @@ suite('AgentService (node dispatcher)', () => { // Simulate an old database whose legacy one-time marker is set. await db.markSessionRegistryBackfilled(); const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const agent = disposables.add(new CountingAgent('copilot')); const legacy = AgentSession.uri('copilot', 'old-db-native-session'); (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(legacy), legacy); @@ -4828,7 +4840,7 @@ suite('AgentService (node dispatcher)', () => { (agent as unknown as { _sessions: Map })._sessions.set(sessionId, sessionUri); const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.registerProvider(agent); const sessions = await svc.listSessions(); @@ -4874,7 +4886,7 @@ suite('AgentService (node dispatcher)', () => { (agent as unknown as { _sessions: Map })._sessions.set(sessionId, sessionUri); const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.registerProvider(agent); const sessions = await svc.listSessions(); @@ -4891,7 +4903,7 @@ suite('AgentService (node dispatcher)', () => { disposables.add(toDisposable(() => agent.dispose())); (agent as unknown as { _sessions: Map })._sessions.set(sessionId, sessionUri); const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.registerProvider(agent); const sessions = await svc.listSessions(); @@ -4916,7 +4928,7 @@ suite('AgentService (node dispatcher)', () => { }; (agent as unknown as { _sessions: Map })._sessions.set(sessionId, sessionUri); const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.registerProvider(agent); const sessions = await svc.listSessions(); @@ -4937,7 +4949,7 @@ suite('AgentService (node dispatcher)', () => { disposables.add(toDisposable(() => agent.dispose())); (agent as unknown as { _sessions: Map })._sessions.set(sessionId, sessionUri); const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.registerProvider(agent); const sessions = await svc.listSessions(); @@ -4955,7 +4967,7 @@ suite('AgentService (node dispatcher)', () => { }; (agent as unknown as { _sessions: Map })._sessions.set(sessionId, sessionUri); const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.registerProvider(agent); const sessions = await svc.listSessions(); @@ -4981,7 +4993,7 @@ suite('AgentService (node dispatcher)', () => { return []; }; const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, gitService)); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.registerProvider(agent); const sessions = await svc.listSessions(); @@ -5016,7 +5028,7 @@ suite('AgentService (node dispatcher)', () => { gitService.getWorktreeRoots = async () => [primaryRoot, linkedCheckout, sessionWorktree]; const sessionDataService = createSessionDataService(db); const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.setWorktreeIsolation(disposables.add(new WorktreeIsolation( { generateBranchName: async () => 'agents/test' }, gitService, @@ -5063,7 +5075,7 @@ suite('AgentService (node dispatcher)', () => { gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'main' }); const sessionDataService = createSessionDataService(db); const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); - svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.setWorktreeIsolation(disposables.add(new WorktreeIsolation( { generateBranchName: async () => 'agents/test' }, gitService, @@ -6136,7 +6148,7 @@ suite('AgentService (node dispatcher)', () => { const result = await service.authenticate({ resource: 'https://unknown.example.com', token: 'tok' }); - assert.deepStrictEqual({ result, token: service.getAuthToken({ resource: 'https://unknown.example.com' }), authenticateCalls: copilotAgent.authenticateCalls }, { + assert.deepStrictEqual({ result, token: getAuthenticationService(service).getAuthToken({ resource: 'https://unknown.example.com' }), authenticateCalls: copilotAgent.authenticateCalls }, { result: { authenticated: false }, token: undefined, authenticateCalls: [], @@ -6146,11 +6158,11 @@ suite('AgentService (node dispatcher)', () => { test('stores GitHub Copilot token for operation handlers', async () => { service.registerProvider(copilotAgent); const changes: { resource: string; token: string | undefined }[] = []; - disposables.add(service.authenticationService.onDidChangeAuthToken(event => changes.push({ resource: event.resource, token: event.token }))); + disposables.add(getAuthenticationService(service).onDidChangeAuthToken(event => changes.push({ resource: event.resource, token: event.token }))); const result = await service.authenticate({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, token: 'copilot-token' }); - assert.deepStrictEqual({ result, token: service.getAuthToken({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, scopes: GITHUB_COPILOT_PROTECTED_RESOURCE.scopes_supported }), authenticateCalls: copilotAgent.authenticateCalls, changes }, { + assert.deepStrictEqual({ result, token: getAuthenticationService(service).getAuthToken({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, scopes: GITHUB_COPILOT_PROTECTED_RESOURCE.scopes_supported }), authenticateCalls: copilotAgent.authenticateCalls, changes }, { result: { authenticated: true }, token: 'copilot-token', authenticateCalls: [{ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, token: 'copilot-token' }], @@ -6166,7 +6178,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ result, - token: service.getAuthToken({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource }), + token: getAuthenticationService(service).getAuthToken({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource }), authenticateCalls: copilotAgent.authenticateCalls, }, { result: { authenticated: true }, @@ -6192,7 +6204,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ result, - token: service.getAuthToken({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource }), + token: getAuthenticationService(service).getAuthToken({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource }), lateAuthenticateCalls: lateAgent.authenticateCalls, }, { result: { authenticated: false }, @@ -6208,9 +6220,9 @@ suite('AgentService (node dispatcher)', () => { await service.authenticate({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, scopes: ['read:user', 'user:email'], token: 'profile-token' }); assert.deepStrictEqual({ - readToken: service.getAuthToken({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, scopes: ['read:user'] }), - profileToken: service.getAuthToken({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, scopes: ['user:email', 'read:user'] }), - supersetToken: service.getAuthToken({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, scopes: ['user:email'] }), + readToken: getAuthenticationService(service).getAuthToken({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, scopes: ['read:user'] }), + profileToken: getAuthenticationService(service).getAuthToken({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, scopes: ['user:email', 'read:user'] }), + supersetToken: getAuthenticationService(service).getAuthToken({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, scopes: ['user:email'] }), }, { readToken: 'read-token', profileToken: 'profile-token', @@ -7264,7 +7276,7 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(agent.adoptCalls, 0); // Migrate setting on: opening adopts in place. - localService.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + getConfigurationService(localService).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); await localService.restoreSession(session); assert.deepStrictEqual( @@ -7294,7 +7306,7 @@ suite('AgentService (node dispatcher)', () => { const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new AdoptThenFailAgent()); localService.registerProvider(agent); - localService.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + getConfigurationService(localService).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); const session = AgentSession.uri('copilot', 'adopted-restore-fails'); (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(session), session); @@ -7321,7 +7333,7 @@ suite('AgentService (node dispatcher)', () => { const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new AdoptAgent()); localService.registerProvider(agent); - localService.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + getConfigurationService(localService).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); const session = AgentSession.uri('copilot', 'adopted-registration-fails'); (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(session), session); const registry = (localService as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry; @@ -7344,7 +7356,7 @@ suite('AgentService (node dispatcher)', () => { const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(disposables.add(new NotAdoptableAgent())); - localService.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + getConfigurationService(localService).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); const session = AgentSession.uri('copilot', 'external-chat'); await assert.rejects(() => localService.restoreSession(session), /not an adoptable legacy chat/); @@ -7410,7 +7422,7 @@ suite('AgentService (node dispatcher)', () => { localService.registerProvider(agent); // Setting on, then surface an adoptable legacy session. - localService.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + getConfigurationService(localService).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); const session = AgentSession.uri('copilot', 'surfaced-legacy-unsurface'); const sessionStr = session.toString(); localService.stateManager.announceSurfacedSession({ @@ -7432,7 +7444,7 @@ suite('AgentService (node dispatcher)', () => { })); // Turn the setting off: the un-opened surfaced entry is dropped. - localService.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: false }); + getConfigurationService(localService).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: false }); assert.deepStrictEqual( { surfaced: localService.stateManager.getSurfacedSessionSummary(sessionStr), removed }, @@ -7452,7 +7464,7 @@ suite('AgentService (node dispatcher)', () => { const shouldInclude = (localService as unknown as { _shouldIncludeSession(s: IAgentSessionMetadata): boolean })._shouldIncludeSession.bind(localService); const includedWhileOff = shouldInclude(adoptable); - localService.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + getConfigurationService(localService).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); const includedWhileOn = shouldInclude(adoptable); assert.deepStrictEqual({ includedWhileOff, includedWhileOn }, { includedWhileOff: false, includedWhileOn: true }); @@ -8204,9 +8216,10 @@ suite('AgentService (node dispatcher)', () => { const session = await service.createSession({ provider: 'copilot' }); const chatUri = URI.parse(buildChatUri(session, 'peer-1')); await service.createChat(session, chatUri); - const originalDiscard = service.checkpointService.discardChatTurnStartCheckpoints.bind(service.checkpointService); - disposables.add(toDisposable(() => service.checkpointService.discardChatTurnStartCheckpoints = originalDiscard)); - service.checkpointService.discardChatTurnStartCheckpoints = async (checkpointSession, checkpointChat) => { + const checkpointService = getCheckpointService(service); + const originalDiscard = checkpointService.discardChatTurnStartCheckpoints.bind(checkpointService); + disposables.add(toDisposable(() => checkpointService.discardChatTurnStartCheckpoints = originalDiscard)); + checkpointService.discardChatTurnStartCheckpoints = async (checkpointSession, checkpointChat) => { assert.deepStrictEqual({ session: checkpointSession.toString(), chat: checkpointChat.toString(), @@ -11050,7 +11063,7 @@ suite('AgentService (node dispatcher)', () => { const db = new RecordingTitleDatabase(); const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - localService.configurationService.updateRootConfig({ [AgentHostActiveAgentTitleGenerationConfigKey]: true }); + getConfigurationService(localService).updateRootConfig({ [AgentHostActiveAgentTitleGenerationConfigKey]: true }); const agent = disposables.add(new ServerToolAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -11147,7 +11160,7 @@ suite('AgentService (node dispatcher)', () => { const db = new FailingTitleDatabase(); const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - localService.configurationService.updateRootConfig({ [AgentHostActiveAgentTitleGenerationConfigKey]: true }); + getConfigurationService(localService).updateRootConfig({ [AgentHostActiveAgentTitleGenerationConfigKey]: true }); const agent = disposables.add(new ServerToolAgent('copilot')); localService.registerProvider(agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -12920,7 +12933,7 @@ suite('AgentService (node dispatcher)', () => { { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, orchestratorDb, )); - localService.configurationService.updateRootConfig({ [AgentMergeConfigKey.Enabled]: true }); + getConfigurationService(localService).updateRootConfig({ [AgentMergeConfigKey.Enabled]: true }); return localService; } @@ -12939,7 +12952,7 @@ suite('AgentService (node dispatcher)', () => { ]; const sessionResource = (await localAgent.listSessions())[0].session; await localService.restoreSession(sessionResource); - localService.configurationService.updateSessionConfig(sessionResource.toString(), { [SessionConfigKey.AgentMerge]: { enabled: true } }); + getConfigurationService(localService).updateSessionConfig(sessionResource.toString(), { [SessionConfigKey.AgentMerge]: { enabled: true } }); await localService.whenAgentMergeSessionsRestored(); return { localService, localAgent, sessionResource }; } @@ -12955,7 +12968,7 @@ suite('AgentService (node dispatcher)', () => { await new Promise(resolve => setTimeout(resolve, 60_000)); const residentWhileEnabled = localService.stateManager.getSessionState(sessionStr) !== undefined; - localService.configurationService.updateSessionConfig(sessionStr, { [SessionConfigKey.AgentMerge]: { enabled: false } }); + getConfigurationService(localService).updateSessionConfig(sessionStr, { [SessionConfigKey.AgentMerge]: { enabled: false } }); await new Promise(resolve => setTimeout(resolve, 60_000)); assert.deepStrictEqual({ @@ -12999,7 +13012,7 @@ suite('AgentService (node dispatcher)', () => { // Nothing ever subscribed, so only the monitoring pin is holding // this session resident; disabling must let it go. - restarted.configurationService.updateSessionConfig(sessionStr, { [SessionConfigKey.AgentMerge]: { enabled: false } }); + getConfigurationService(restarted).updateSessionConfig(sessionStr, { [SessionConfigKey.AgentMerge]: { enabled: false } }); await new Promise(resolve => setTimeout(resolve, 60_000)); assert.deepStrictEqual({ diff --git a/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts b/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts index 633deb82c32a41..c54ddb226f769f 100644 --- a/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts +++ b/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts @@ -21,9 +21,19 @@ import { IAgentHostDatabase } from '../../node/agentHostDatabase.js'; import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from '../../node/agentHostFileMonitorService.js'; import { IAgentHostProxyResolver } from '../../node/agentHostProxyResolver.js'; import { AgentService } from '../../node/agentService.js'; -import { createAgentService } from '../../node/agentServiceComposition.js'; +import { createAgentServiceComposition, type IAgentServiceComposition } from '../../node/agentServiceComposition.js'; import { ICopilotApiService } from '../../node/shared/copilotApiService.js'; +const compositions = new WeakMap(); + +export function getTestAgentServiceComposition(agentService: AgentService): IAgentServiceComposition { + const composition = compositions.get(agentService); + if (!composition) { + throw new Error('AgentService was not created by createTestAgentService'); + } + return composition; +} + export function createTestAgentService( logService: ILogService, fileService: IFileService, @@ -70,14 +80,16 @@ export function createTestAgentService( storageResource, orchestratorDatabase, }; - const service = createAgentService( + const composition = createAgentServiceComposition( options, services, instantiationService, fetchFn, logService, productService, + sessionDataService, fileMonitorService ? [instantiationService] : [effectiveFileMonitorService, instantiationService], ); - return service; + compositions.set(composition.agentService, composition); + return composition.agentService; } diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 7e0392424bea76..de853adb602d2c 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -233,7 +233,6 @@ class MockAgentService implements IAgentService { return { kind, resource: URI.file('/tmp/agent-host-debug.zip'), providerLogsIncluded: true, size: 1024, uncompressedSize: 2048, entries: [{ path: 'agenthost.log', size: 2048 }] }; } async authenticate(_params: AuthenticateParams): Promise { return { authenticated: true }; } - getAuthToken(): string | undefined { return undefined; } async resourceWrite(_params: ResourceWriteParams): Promise { return {}; } async resourceList(uri: URI): Promise { this.browsedUris.push(uri);