From cf92c7da1cad9231e35d3b027068257de3fed464 Mon Sep 17 00:00:00 2001 From: Yogeshwaran C <84272111+yogeshwaran-c@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:03:18 +0530 Subject: [PATCH 01/15] Show breakpoint widget on Alt+click in gutter (#308687) feat: show breakpoint widget on Alt+click in gutter Add Alt+click handling in the editor gutter to quickly add or edit conditional breakpoints. When Alt+clicking on a line without breakpoints, the breakpoint widget opens in conditional breakpoint mode. When Alt+clicking on a line with existing breakpoints, the breakpoint widget opens for editing the first breakpoint. Fixes #203259 Co-authored-by: Rob Lourens --- .../debug/browser/breakpointEditorContribution.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/debug/browser/breakpointEditorContribution.ts b/src/vs/workbench/contrib/debug/browser/breakpointEditorContribution.ts index aac4fe7083ea1..6ee10c8b3f260 100644 --- a/src/vs/workbench/contrib/debug/browser/breakpointEditorContribution.ts +++ b/src/vs/workbench/contrib/debug/browser/breakpointEditorContribution.ts @@ -281,9 +281,13 @@ export class BreakpointEditorContribution implements IBreakpointEditorContributi if (breakpoints.length) { const isShiftPressed = e.event.shiftKey; + const isAltPressed = e.event.altKey; const enabled = breakpoints.some(bp => bp.enabled); - if (isShiftPressed) { + if (isAltPressed) { + // Alt+click on existing breakpoint opens the breakpoint widget for editing + this.showBreakpointWidget(breakpoints[0].lineNumber, breakpoints[0].column); + } else if (isShiftPressed) { breakpoints.forEach(bp => this.debugService.enableOrDisableBreakpoints(!enabled, bp)); } else if (!env.isLinux && breakpoints.some(bp => !!bp.condition || !!bp.logMessage || !!bp.hitCondition || !!bp.triggeredBy)) { // Show the dialog if there is a potential condition to be accidently lost. @@ -327,7 +331,10 @@ export class BreakpointEditorContribution implements IBreakpointEditorContributi } } } else if (canSetBreakpoints) { - if (e.event.middleButton) { + if (e.event.altKey) { + // Alt+click on empty gutter opens the breakpoint widget for adding a conditional breakpoint + this.showBreakpointWidget(lineNumber, undefined, BreakpointWidgetContext.CONDITION); + } else if (e.event.middleButton) { const action = this.configurationService.getValue('debug').gutterMiddleClickAction; if (action !== 'none') { let context: BreakpointWidgetContext; From dcacca1b398b50d69a518c13ccce732c6b2823b9 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 10 Apr 2026 14:49:37 +1000 Subject: [PATCH 02/15] refactor: streamline session option group selection logic and improve test coverage (#308743) * refactor: streamline session option group selection logic and improve test coverage * refactor: enhance chat session initialization with new options structure and improve input state handling * Update extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Updates * Fixes * Fixes * Updates * Updates * More updates * Fix tests --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- extensions/copilot/package.json | 2 +- .../copilotCLIChatSessionInitializer.ts | 64 +- .../vscode-node/copilotCLIChatSessions.ts | 67 +- .../copilotCLIChatSessionsContribution.ts | 3 +- .../folderRepositoryManagerImpl.ts | 2 +- .../vscode-node/sessionOptionGroupBuilder.ts | 260 +-- .../test/chatSessionInitializer.spec.ts | 58 +- .../test/copilotCLIChatSessions.spec.ts | 8 - .../test/sessionOptionGroupBuilder.spec.ts | 2056 +++++++++-------- .../vscode.proposed.chatSessionsProvider.d.ts | 9 + 10 files changed, 1327 insertions(+), 1202 deletions(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 7d07f0bf94f62..e5f7629bd5bdf 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -6404,7 +6404,7 @@ "node-gyp": "npm:node-gyp@10.3.1", "zod": "3.25.76" }, - "vscodeCommit": "eb014b61a9ac4d91acc39984167e2ca84c03b758", + "vscodeCommit": "afba0a4a1fc1e34dae9073d6787b6b541bda23eb", "__metadata": { "id": "7ec7d6e6-b89e-4cc5-a59b-d6c4d238246f", "publisherId": { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionInitializer.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionInitializer.ts index e73f6720ad1b6..efd6a0461a197 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionInitializer.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionInitializer.ts @@ -23,13 +23,20 @@ import { COPILOT_CLI_REASONING_EFFORT_PROPERTY, ICopilotCLIAgents, ICopilotCLIMo import { ICopilotCLISession } from '../copilotcli/node/copilotcliSession'; import { ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionService'; import { buildMcpServerMappings, McpServerMappings } from '../copilotcli/node/mcpHandler'; -import { BRANCH_OPTION_ID, ISOLATION_OPTION_ID, REPOSITORY_OPTION_ID } from './sessionOptionGroupBuilder'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; function isReasoningEffortFeatureEnabled(configurationService: IConfigurationService): boolean { return configurationService.getConfig(ConfigKey.Advanced.CLIThinkingEffortEnabled); } +export interface SessionInitOptions { + isolation?: IsolationMode; + branch?: string; + folder?: vscode.Uri; + newBranch?: Promise; + stream: vscode.ChatResponseStream; +} + export interface ICopilotCLIChatSessionInitializer { readonly _serviceBrand: undefined; @@ -41,9 +48,8 @@ export interface ICopilotCLIChatSessionInitializer { */ getOrCreateSession( request: vscode.ChatRequest, - chatSessionContext: vscode.ChatSessionContext, - stream: vscode.ChatResponseStream, - options: { branchName: Promise }, + chatResource: vscode.Uri, + options: SessionInitOptions, disposables: DisposableStore, token: vscode.CancellationToken ): Promise<{ session: IReference | undefined; isNewSession: boolean; model: { model: string; reasoningEffort?: string } | undefined; agent: SweCustomAgent | undefined; trusted: boolean }>; @@ -53,10 +59,8 @@ export interface ICopilotCLIChatSessionInitializer { * Used for both normal requests and delegation flows. */ initializeWorkingDirectory( - chatSessionContext: vscode.ChatSessionContext | undefined, - isolation: IsolationMode | undefined, - branchName: Promise | undefined, - stream: vscode.ChatResponseStream, + chatResource: vscode.Uri | undefined, + options: SessionInitOptions, toolInvocationToken: vscode.ChatParticipantToolToken, token: vscode.CancellationToken ): Promise<{ workspaceInfo: IWorkspaceInfo; cancelled: boolean; trusted: boolean }>; @@ -94,18 +98,17 @@ export class CopilotCLIChatSessionInitializer implements ICopilotCLIChatSessionI async getOrCreateSession( request: vscode.ChatRequest, - chatSessionContext: vscode.ChatSessionContext, - stream: vscode.ChatResponseStream, - options: { branchName: Promise }, + chatResource: vscode.Uri, + options: SessionInitOptions, disposables: DisposableStore, token: vscode.CancellationToken ): Promise<{ session: IReference | undefined; isNewSession: boolean; model: { model: string; reasoningEffort?: string } | undefined; agent: SweCustomAgent | undefined; trusted: boolean }> { - const { resource } = chatSessionContext.chatSessionItem; - const sessionId = SessionIdForCLI.parse(resource); + const sessionId = SessionIdForCLI.parse(chatResource); const isNewSession = this.sessionService.isNewSessionId(sessionId); + const { stream } = options; const [{ workspaceInfo, cancelled, trusted }, model, agent] = await Promise.all([ - this.initializeWorkingDirectory(chatSessionContext, undefined, options.branchName, stream, request.toolInvocationToken, token), + this.initializeWorkingDirectory(chatResource, options, request.toolInvocationToken, token), this.resolveModel(request, token), this.resolveAgent(request, token), ]); @@ -144,46 +147,35 @@ export class CopilotCLIChatSessionInitializer implements ICopilotCLIChatSessionI } async initializeWorkingDirectory( - chatSessionContext: vscode.ChatSessionContext | undefined, - isolation: IsolationMode | undefined, - branchName: Promise | undefined, - stream: vscode.ChatResponseStream, + chatResource: vscode.Uri | undefined, + options: SessionInitOptions, toolInvocationToken: vscode.ChatParticipantToolToken, token: vscode.CancellationToken ): Promise<{ workspaceInfo: IWorkspaceInfo; cancelled: boolean; trusted: boolean }> { let folderInfo: FolderRepositoryInfo; - let folder: undefined | vscode.Uri = undefined; + const { stream } = options; + let folder: undefined | vscode.Uri = options?.folder; const workspaceFolders = this.workspaceService.getWorkspaceFolders(); - if (workspaceFolders.length === 1) { + if (workspaceFolders.length === 1 && !folder) { folder = workspaceFolders[0]; } - if (chatSessionContext) { - const sessionId = SessionIdForCLI.parse(chatSessionContext.chatSessionItem.resource); + if (chatResource) { + const sessionId = SessionIdForCLI.parse(chatResource); const isNewSession = this.sessionService.isNewSessionId(sessionId); if (isNewSession) { - let isolation = IsolationMode.Workspace; - let branch: string | undefined = undefined; - for (const opt of (chatSessionContext.initialSessionOptions || [])) { - const value = typeof opt.value === 'string' ? opt.value : opt.value.id; - if (opt.optionId === REPOSITORY_OPTION_ID && value) { - folder = vscode.Uri.file(value); - } else if (opt.optionId === BRANCH_OPTION_ID && value) { - branch = value; - } else if (opt.optionId === ISOLATION_OPTION_ID && value) { - isolation = value as IsolationMode; - } - } + const isolation = options?.isolation ?? IsolationMode.Workspace; + const branch = options?.branch; // Use FolderRepositoryManager to initialize folder/repository with worktree creation - folderInfo = await this.folderRepositoryManager.initializeFolderRepository(sessionId, { stream, toolInvocationToken, branch, isolation, folder, newBranch: branchName }, token); + folderInfo = await this.folderRepositoryManager.initializeFolderRepository(sessionId, { stream, toolInvocationToken, branch, isolation, folder, newBranch: options?.newBranch }, token); } else { // Existing session - use getFolderRepository for resolution with trust check folderInfo = await this.folderRepositoryManager.getFolderRepository(sessionId, { promptForTrust: true, stream }, token); } } else { // No chat session context (e.g., delegation) - initialize with active repository - folderInfo = await this.folderRepositoryManager.initializeFolderRepository(undefined, { stream, toolInvocationToken, isolation, folder }, token); + folderInfo = await this.folderRepositoryManager.initializeFolderRepository(undefined, { stream, toolInvocationToken, isolation: options?.isolation, folder }, token); } if (folderInfo.trusted === false || folderInfo.cancelled) { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index 45a06f73bb24f..30bb414410ea7 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -43,13 +43,14 @@ import { builtinSlashSCommands, CopilotCLICommand, copilotCLICommands, ICopilotC import { ICopilotCLISessionItem, ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionService'; import { buildMcpServerMappings } from '../copilotcli/node/mcpHandler'; import { ICopilotCLISessionTracker } from '../copilotcli/vscode-node/copilotCLISessionTracker'; -import { ICopilotCLIChatSessionInitializer } from './copilotCLIChatSessionInitializer'; +import { ICopilotCLIChatSessionInitializer, SessionInitOptions } from './copilotCLIChatSessionInitializer'; import { convertReferenceToVariable } from './copilotCLIPromptReferences'; import { ICopilotCLITerminalIntegration, TerminalOpenLocation } from './copilotCLITerminalIntegration'; import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider'; import { IPullRequestDetectionService } from './pullRequestDetectionService'; -import { ISessionOptionGroupBuilder, OPEN_REPOSITORY_COMMAND_ID, toRepositoryOptionItem, toWorkspaceFolderOptionItem } from './sessionOptionGroupBuilder'; +import { getSelectedSessionOptions, ISessionOptionGroupBuilder, OPEN_REPOSITORY_COMMAND_ID, toRepositoryOptionItem, toWorkspaceFolderOptionItem } from './sessionOptionGroupBuilder'; import { ISessionRequestLifecycle } from './sessionRequestLifecycle'; +import { UNTRUSTED_FOLDER_MESSAGE } from './folderRepositoryManagerImpl'; /** * ODO: @@ -267,19 +268,22 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements const newInputStates: WeakRef[] = []; controller.getChatSessionInputState = async (sessionResource, context, token) => { - const groups = sessionResource ? await this._optionGroupBuilder.buildExistingSessionInputStateGroups(sessionResource, token) : await this._optionGroupBuilder.provideChatSessionProviderOptionGroups(context.previousInputState); - const state = controller.createChatSessionInputState(groups); - if (!sessionResource) { + const isExistingSession = sessionResource && !this.sessionService.isNewSessionId(SessionIdForCLI.parse(sessionResource)); + if (isExistingSession) { + const groups = await this._optionGroupBuilder.buildExistingSessionInputStateGroups(sessionResource, token); + return controller.createChatSessionInputState(groups); + } else { + const groups = await this._optionGroupBuilder.provideChatSessionProviderOptionGroups(context.previousInputState); + const state = controller.createChatSessionInputState(groups); // Only wire dynamic updates for new sessions (existing sessions are fully locked). // Note: don't use the getChatSessionInputState token here — it's a one-shot token // that may be disposed by the time the user interacts with the dropdowns. newInputStates.push(new WeakRef(state)); - state.onDidChange(() => { void this._optionGroupBuilder.handleInputStateChange(state); }); + return state; } - return state; }; // Refresh new-session dropdown groups when git or workspace state changes @@ -304,8 +308,9 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements this._register(this._workspaceService.onDidChangeWorkspaceFolders(refreshActiveInputState)); } - provideHandleOptionsChange() { - // This is required for Controller.createChatSessionInputState.onDidChange event to work. + public async updateInputStateAfterFolderSelection(inputState: vscode.ChatSessionInputState, folderUri: vscode.Uri): Promise { + this._optionGroupBuilder.setNewFolderForInputState(inputState, folderUri); + await this._optionGroupBuilder.rebuildInputState(inputState, folderUri); } public async refreshSession(refreshOptions: { reason: 'update'; sessionId: string } | { reason: 'update'; sessionIds: string[] } | { reason: 'delete'; sessionId: string }): Promise { @@ -485,7 +490,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements requestHandler: undefined, title: session.label, activeResponseCallback: undefined, - options: {}, }; } else { this.newSessions.delete(resource); @@ -503,15 +507,23 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements this._prDetectionService.detectPullRequest(copilotcliSessionId); const folderRepo = await this.folderRepositoryManager.getFolderRepository(copilotcliSessionId, undefined, token); - const [history, title] = await Promise.all([ + const [history, title, optionGroups] = await Promise.all([ this.getSessionHistory(copilotcliSessionId, folderRepo, token), this.customSessionTitleService.getCustomSessionTitle(copilotcliSessionId), + this._optionGroupBuilder.buildExistingSessionInputStateGroups(resource, token), ]); + const options: Record = {}; + for (const group of optionGroups) { + if (group.selected) { + options[group.id] = { ...group.selected, locked: true }; + } + } + return { title, history, - activeResponseCallback: undefined, + options, requestHandler: undefined, }; } @@ -536,9 +548,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements } } - public async updateInputStateAfterFolderSelection(inputState: vscode.ChatSessionInputState, folderUri: vscode.Uri): Promise { - return this._optionGroupBuilder.updateInputStateAfterFolderSelection(inputState, folderUri); - } } export class CopilotCLIChatSessionParticipant extends Disposable { @@ -721,7 +730,8 @@ export class CopilotCLIChatSessionParticipant extends Disposable { }; const branchNamePromise = (isNewSession && request.prompt && this.branchNameGenerator) ? this.branchNameGenerator.generateBranchName(fakeContext, token) : Promise.resolve(undefined); - const sessionResult = await this.getOrCreateSession(request, chatSessionContext, stream, { branchName: branchNamePromise }, disposables, token); + const selectedOptions = getSelectedSessionOptions(chatSessionContext.inputState); + const sessionResult = await this.getOrCreateSession(request, chatSessionContext.chatSessionItem.resource, { ...selectedOptions, newBranch: branchNamePromise, stream }, disposables, token); ({ session } = sessionResult); const { model } = sessionResult; if (!session || token.isCancellationRequested) { @@ -759,8 +769,8 @@ export class CopilotCLIChatSessionParticipant extends Disposable { } } - private async getOrCreateSession(request: vscode.ChatRequest, chatSessionContext: vscode.ChatSessionContext, stream: vscode.ChatResponseStream, options: { branchName: Promise }, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference | undefined; isNewSession: boolean; model: { model: string; reasoningEffort?: string } | undefined; trusted: boolean }> { - const result = await this.sessionInitializer.getOrCreateSession(request, chatSessionContext, stream, options, disposables, token); + private async getOrCreateSession(request: vscode.ChatRequest, chatResource: vscode.Uri, options: SessionInitOptions, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference | undefined; isNewSession: boolean; model: { model: string; reasoningEffort?: string } | undefined; trusted: boolean }> { + const result = await this.sessionInitializer.getOrCreateSession(request, chatResource, options, disposables, token); const { session, isNewSession, model, trusted } = result; if (!session || token.isCancellationRequested) { return { session: undefined, isNewSession, model, trusted }; @@ -816,7 +826,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { return summary ? `${userPrompt}\n${summary}` : userPrompt; })(); - const { workspaceInfo, cancelled } = await this.sessionInitializer.initializeWorkingDirectory(undefined, undefined, undefined, stream, request.toolInvocationToken, token); + const { workspaceInfo, cancelled } = await this.sessionInitializer.initializeWorkingDirectory(undefined, { stream }, request.toolInvocationToken, token); if (cancelled || token.isCancellationRequested) { stream.markdown(l10n.t('Copilot CLI delegation cancelled.')); @@ -1107,10 +1117,7 @@ export function registerCLIChatCommands( } // Command handler receives `{ inputState, sessionResource }` context args (new API) - disposableStore.add(vscode.commands.registerCommand(OPEN_REPOSITORY_COMMAND_ID, async (contextArg?: { inputState: vscode.ChatSessionInputState; sessionResource: vscode.Uri | undefined } | vscode.Uri) => { - // Support both new API shape and legacy Uri shape for backward compat - const inputState = contextArg && !isUri(contextArg) ? contextArg.inputState : undefined; - + disposableStore.add(vscode.commands.registerCommand(OPEN_REPOSITORY_COMMAND_ID, async ({ inputState }: { inputState: vscode.ChatSessionInputState; sessionResource: vscode.Uri | undefined }) => { let selectedFolderUri: Uri | undefined = undefined; const mruItems = await copilotCLIFolderMruService.getRecentlyUsedFolders(CancellationToken.None); @@ -1202,11 +1209,15 @@ export function registerCLIChatCommands( return; } - // // We need to check trust now, as we need to determine whether this is a Git repo or not. - // // Using the relevant services to check if its a git repo result in checking trust as well, might as well check now instead of complicating code later to handle both trusted and untrusted cases. - // if (!(await vscode.workspace.isResourceTrusted(selectedFolderUri))) { - // return; - // } + // First check if user trusts the folder. + const trusted = await vscode.workspace.requestResourceTrust({ + uri: selectedFolderUri, + message: UNTRUSTED_FOLDER_MESSAGE + }); + if (!trusted) { + return; + } + // Update inputState groups with newly selected folder and reload branches if (inputState) { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index a9dedf3daa49d..daa601e5bdb7f 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -1256,7 +1256,8 @@ export class CopilotCLIChatSessionParticipant extends Disposable { resource: request.sessionResource, }, isUntitled: false, - initialSessionOptions: undefined + initialSessionOptions: undefined, + inputState: undefined as unknown as vscode.ChatSessionInputState }; context = { chatSessionContext, diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts index 58cc6cb503b81..f63ac1bb7ec5e 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts @@ -33,7 +33,7 @@ import { ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionS /** * Message shown when user needs to trust a folder to continue. */ -const UNTRUSTED_FOLDER_MESSAGE = l10n.t('The selected folder is not trusted. Please trust the folder to continue with the {0}.', 'Copilot CLI'); +export const UNTRUSTED_FOLDER_MESSAGE = l10n.t('The selected folder is not trusted. Please trust the folder to continue with the {0}.', 'Copilot CLI'); // #region FolderRepositoryManager (abstract base) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts index 54f6965dd9d40..ced3dab0e42e4 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts @@ -172,10 +172,62 @@ const LAST_USED_ISOLATION_OPTION_KEY = 'github.copilot.cli.lastUsedIsolationOpti const MAX_MRU_ENTRIES = 10; const COPILOT_WORKTREE_PATTERN = 'copilot-worktree-'; +function optionItemsEqual(a: vscode.ChatSessionProviderOptionItem | undefined, b: vscode.ChatSessionProviderOptionItem | undefined): boolean { + if (a === b) { + return true; + } + if (!a || !b) { + return false; + } + return a.id === b.id && a.locked === b.locked; +} + +function optionGroupsEqual( + oldGroups: readonly vscode.ChatSessionProviderOptionGroup[], + newGroups: readonly vscode.ChatSessionProviderOptionGroup[], +): boolean { + if (oldGroups.length !== newGroups.length) { + return false; + } + for (let i = 0; i < oldGroups.length; i++) { + const oldGroup = oldGroups[i]; + const newGroup = newGroups[i]; + if (oldGroup.id !== newGroup.id) { + return false; + } + if (!optionItemsEqual(oldGroup.selected, newGroup.selected)) { + return false; + } + if (oldGroup.items.length !== newGroup.items.length) { + return false; + } + for (let j = 0; j < oldGroup.items.length; j++) { + if (!optionItemsEqual(oldGroup.items[j], newGroup.items[j])) { + return false; + } + } + } + return true; +} + export function getSelectedOption(groups: readonly vscode.ChatSessionProviderOptionGroup[], groupId: string): vscode.ChatSessionProviderOptionItem | undefined { return groups.find(g => g.id === groupId)?.selected; } +/** + * Extract the selected repository, branch, and isolation values from an input state. + */ +export function getSelectedSessionOptions(inputState: vscode.ChatSessionInputState): { folder?: vscode.Uri; branch?: string; isolation?: IsolationMode } { + const repoId = getSelectedOption(inputState.groups, REPOSITORY_OPTION_ID)?.id; + const branch = getSelectedOption(inputState.groups, BRANCH_OPTION_ID)?.id; + const isolationId = getSelectedOption(inputState.groups, ISOLATION_OPTION_ID)?.id; + return { + folder: repoId ? vscode.Uri.file(repoId) : undefined, + branch: branch || undefined, + isolation: (isolationId === IsolationMode.Workspace || isolationId === IsolationMode.Worktree) ? isolationId : undefined, + }; +} + export function isBranchOptionFeatureEnabled(configurationService: IConfigurationService): boolean { return configurationService.getConfig(ConfigKey.Advanced.CLIBranchSupport); } @@ -263,22 +315,23 @@ export function folderMRUToChatProviderOptions(mruItems: FolderRepositoryMRUEntr */ export interface ISessionOptionGroupBuilder { readonly _serviceBrand: undefined; + setNewFolderForInputState(inputState: vscode.ChatSessionInputState, folderUri: vscode.Uri): void; provideChatSessionProviderOptionGroups(previousInputState: vscode.ChatSessionInputState | undefined): Promise; buildBranchOptionGroup(branches: vscode.ChatSessionProviderOptionItem[], headBranchName: string | undefined, isolationEnabled: boolean, currentIsolation: IsolationMode | undefined, previousSelection: vscode.ChatSessionProviderOptionItem | undefined): vscode.ChatSessionProviderOptionGroup | undefined; handleInputStateChange(state: vscode.ChatSessionInputState): Promise; - rebuildInputState(state: vscode.ChatSessionInputState): Promise; + rebuildInputState(state: vscode.ChatSessionInputState, selectedFolderUri?: vscode.Uri): Promise; buildExistingSessionInputStateGroups(resource: vscode.Uri, token: vscode.CancellationToken): Promise; getBranchOptionItemsForRepository(repoUri: Uri, headBranchName: string | undefined): Promise; getRepositoryOptionItems(): vscode.ChatSessionProviderOptionItem[]; - updateInputStateAfterFolderSelection(inputState: vscode.ChatSessionInputState, folderUri: vscode.Uri): Promise; } export const ISessionOptionGroupBuilder = createServiceIdentifier('ISessionOptionGroupBuilder'); export class SessionOptionGroupBuilder implements ISessionOptionGroupBuilder { declare readonly _serviceBrand: undefined; - private _lastUsedFolderIdInUntitledWorkspace?: { kind: 'folder' | 'repo'; uri: vscode.Uri; lastAccessed: number }; private readonly _getBranchOptionItemsForRepositorySequencer = new SequencerByKey(); - + private readonly _pendingBuildGroups = new WeakMap>(); + // Keeps track of the new folders selected by user, by using folder dialog to select a new folder. + private readonly _inputStateNewFolders = new WeakMap(); constructor( @IGitService private readonly gitService: IGitService, @IConfigurationService private readonly configurationService: IConfigurationService, @@ -290,6 +343,10 @@ export class SessionOptionGroupBuilder implements ISessionOptionGroupBuilder { @IFolderRepositoryManager private readonly folderRepositoryManager: IFolderRepositoryManager, ) { } + + setNewFolderForInputState(inputState: vscode.ChatSessionInputState, folderUri: vscode.Uri): void { + this._inputStateNewFolders.set(inputState, folderUri); + } /** * Return the git repository for a URI only if the folder is trusted. * Untrusted folders are treated as non-git. @@ -305,7 +362,7 @@ export class SessionOptionGroupBuilder implements ISessionOptionGroupBuilder { return this.gitService.getRepository(uri, discover); } - async provideChatSessionProviderOptionGroups(previousInputState: vscode.ChatSessionInputState | undefined): Promise { + async provideChatSessionProviderOptionGroups(previousInputState: vscode.ChatSessionInputState | undefined, selectedFolderUri?: vscode.Uri): Promise { const optionGroups: vscode.ChatSessionProviderOptionGroup[] = []; const isolationEnabled = isIsolationOptionFeatureEnabled(this.configurationService); const previouslySelectedIsolationOption = previousInputState ? getSelectedOption(previousInputState.groups, ISOLATION_OPTION_ID) : undefined; @@ -330,7 +387,11 @@ export class SessionOptionGroupBuilder implements ISessionOptionGroupBuilder { } // Handle repository options based on workspace type - let defaultRepoUri = !isWelcomeView(this.workspaceService) && !this.agentSessionsWorkspace.isAgentSessionsWorkspace && this.workspaceService.getWorkspaceFolders()?.length === 1 ? this.workspaceService.getWorkspaceFolders()![0] : undefined; + const folders = this.workspaceService.getWorkspaceFolders(); + const isSingleFolderWorkspace = !isWelcomeView(this.workspaceService) + && !this.agentSessionsWorkspace.isAgentSessionsWorkspace + && folders?.length === 1; + let defaultRepoUri = selectedFolderUri ?? (isSingleFolderWorkspace ? folders![0] : undefined); if (isWelcomeView(this.workspaceService)) { const commands: vscode.Command[] = []; const previouslySelected = previousInputState ? getSelectedOption(previousInputState.groups, REPOSITORY_OPTION_ID) : undefined; @@ -338,40 +399,45 @@ export class SessionOptionGroupBuilder implements ISessionOptionGroupBuilder { // For untitled workspaces, show last used repositories and "Open Repository..." command const repositories = await this.copilotCLIFolderMruService.getRecentlyUsedFolders(CancellationToken.None); + const newFolder = previousInputState ? this._inputStateNewFolders.get(previousInputState) : undefined; items = folderMRUToChatProviderOptions(repositories); items.splice(MAX_MRU_ENTRIES); // Limit to max entries - if (this._lastUsedFolderIdInUntitledWorkspace) { - const folder = this._lastUsedFolderIdInUntitledWorkspace.uri; - const isRepo = this._lastUsedFolderIdInUntitledWorkspace.kind === 'repo'; - const lastAccessed = this._lastUsedFolderIdInUntitledWorkspace.lastAccessed; - const id = folder.fsPath; - if (!items.find(item => item.id === id)) { - const lastUsedEntry = folderMRUToChatProviderOptions([{ - folder, - repository: isRepo ? folder : undefined, - lastAccessed - }])[0]; - items.unshift(lastUsedEntry); - } + if (newFolder) { + const newFolderRepo = await this.getTrustedRepository(newFolder, true); + const newFolderItem = newFolderRepo + ? toRepositoryOptionItem(newFolderRepo.rootUri) + : toWorkspaceFolderOptionItem(newFolder, newFolder.path.split('/').pop() ?? newFolder.fsPath); + // Remove duplicate if already in the list, then add to top + items = items.filter(item => item.id !== newFolderItem.id); + items.unshift(newFolderItem); } commands.push({ command: OPEN_REPOSITORY_COMMAND_ID, title: l10n.t('Browse folders...') }); + const selectedFolderItem = selectedFolderUri ? items.find(i => i.id === selectedFolderUri.fsPath) : undefined; + const selectedItem = selectedFolderItem + ?? (previouslySelected + ? items.find(i => i.id === previouslySelected.id) ?? items[0] + : items[0]); + if (selectedItem) { + defaultRepoUri = vscode.Uri.file(selectedItem.id); + } optionGroups.push({ id: REPOSITORY_OPTION_ID, name: l10n.t('Folder'), description: l10n.t('Pick Folder'), items, - selected: previouslySelected, + selected: selectedItem, commands }); } else { const repositories = this.getRepositoryOptionItems(); if (repositories.length > 1) { const previouslySelected = previousInputState ? getSelectedOption(previousInputState.groups, REPOSITORY_OPTION_ID) : undefined; - const selectedRepository = previouslySelected ? repositories.find(repository => repository.id === previouslySelected.id) ?? repositories[0] : repositories[0]; + const selectedFolderRepo = selectedFolderUri ? repositories.find(repository => repository.id === selectedFolderUri.fsPath) : undefined; + const selectedRepository = selectedFolderRepo ?? (previouslySelected ? repositories.find(repository => repository.id === previouslySelected.id) ?? repositories[0] : repositories[0]); defaultRepoUri = selectedRepository.id ? vscode.Uri.file(selectedRepository.id) : defaultRepoUri; optionGroups.push({ id: REPOSITORY_OPTION_ID, @@ -418,14 +484,20 @@ export class SessionOptionGroupBuilder implements ISessionOptionGroupBuilder { if (branches.length === 0) { return undefined; } - const selectedItem = resolveBranchSelection(branches, headBranchName, previousSelection); + // BUG: Work around for https://github.com/microsoft/vscode/issues/288457#issuecomment-4157935788 + // Locked doesn't work, once locked, we cannot unlock. const { locked } = resolveBranchLockState(isolationEnabled, currentIsolation); + // const locked = false; + // When locked (workspace isolation), ignore the previous selection so we + // always snap back to the active branch instead of keeping a stale pick. + const selectedItem = resolveBranchSelection(branches, headBranchName, locked ? undefined : previousSelection); + const lockedSelected = selectedItem && locked ? { ...selectedItem, locked } : undefined; return { id: BRANCH_OPTION_ID, name: l10n.t('Branch'), description: l10n.t('Pick Branch'), - items: locked ? branches.map(b => ({ ...b, locked: true })) : branches, - selected: selectedItem && locked ? { ...selectedItem, locked: true } : selectedItem, + items: lockedSelected ? [lockedSelected] : locked ? branches.map(b => ({ ...b, locked })) : branches, + selected: lockedSelected ?? selectedItem, }; } @@ -435,57 +507,16 @@ export class SessionOptionGroupBuilder implements ISessionOptionGroupBuilder { * property to determine the current state and update accordingly. */ async handleInputStateChange(state: vscode.ChatSessionInputState): Promise { - const currentIsolation = getSelectedOption(state.groups, ISOLATION_OPTION_ID)?.id as IsolationMode | undefined; - const currentRepoId = getSelectedOption(state.groups, REPOSITORY_OPTION_ID)?.id; - const previousBranchSelection = getSelectedOption(state.groups, BRANCH_OPTION_ID); - const isolationEnabled = isIsolationOptionFeatureEnabled(this.configurationService); - // Persist the user's isolation choice so it's remembered across sessions + const currentIsolation = getSelectedOption(state.groups, ISOLATION_OPTION_ID)?.id as IsolationMode | undefined; if (currentIsolation) { void this.context.globalState.update(LAST_USED_ISOLATION_OPTION_KEY, currentIsolation); } - // Remove existing branch group, rebuild from scratch - const groups = [...state.groups.filter(g => g.id !== BRANCH_OPTION_ID)]; - - // Determine the repo URI — from the dropdown if present, or from the - // single workspace folder when no repo dropdown is shown. - let repoUri: vscode.Uri | undefined; - if (currentRepoId) { - repoUri = vscode.Uri.file(currentRepoId); - } else if (!isWelcomeView(this.workspaceService)) { - const repositories = this.getRepositoryOptionItems(); - if (repositories.length === 1) { - repoUri = vscode.Uri.file(repositories[0].id); - } - } - - const repo = await this.getTrustedRepository(repoUri); - - // When the selected folder is not a git repo (or untrusted), lock - // isolation to workspace and keep the branch dropdown hidden. - // When it is a git repo, unlock isolation. - if (repoUri && !repo) { - forceWorkspaceIsolation(groups); - } else if (repo) { - resetIsolationLock(groups); - } - - if (repo && isBranchOptionFeatureEnabled(this.configurationService)) { - let branches: vscode.ChatSessionProviderOptionItem[] = []; - try { - branches = await this.getBranchOptionItemsForRepository(repo.rootUri, repo.headBranchName); - } catch { - // On failure, branches remain empty — dropdown will be hidden - } - - const branchGroup = this.buildBranchOptionGroup(branches, repo.headBranchName, isolationEnabled, currentIsolation, previousBranchSelection); - if (branchGroup) { - groups.push(branchGroup); - } + const newGroups = await this._buildGroupsOnce(state); + if (!optionGroupsEqual(state.groups, newGroups)) { + state.groups = newGroups; } - - state.groups = groups; } /** @@ -494,9 +525,27 @@ export class SessionOptionGroupBuilder implements ISessionOptionGroupBuilder { * git repos discovered/closed) that may require adding or removing * entire dropdown groups — not just updating branch/isolation. */ - async rebuildInputState(state: vscode.ChatSessionInputState): Promise { - const groups = await this.provideChatSessionProviderOptionGroups(state); - state.groups = groups; + async rebuildInputState(state: vscode.ChatSessionInputState, selectedFolderUri?: vscode.Uri): Promise { + const newGroups = await this._buildGroupsOnce(state, selectedFolderUri); + if (!optionGroupsEqual(state.groups, newGroups)) { + state.groups = newGroups; + } + } + + /** + * Deduplicate concurrent builds for the same state object. + * If a build is already in-flight for this state, return the same promise. + */ + private _buildGroupsOnce(state: vscode.ChatSessionInputState, selectedFolderUri?: vscode.Uri): Promise { + const pending = this._pendingBuildGroups.get(state); + if (pending) { + return pending; + } + const promise = this.provideChatSessionProviderOptionGroups(state, selectedFolderUri).finally(() => { + this._pendingBuildGroups.delete(state); + }); + this._pendingBuildGroups.set(state, promise); + return promise; } async buildExistingSessionInputStateGroups(resource: vscode.Uri, token: vscode.CancellationToken): Promise { @@ -654,71 +703,4 @@ export class SessionOptionGroupBuilder implements ISessionOptionGroupBuilder { return repoItems.sort((a, b) => a.name.localeCompare(b.name)); } - - /** - * After a folder is selected via "Browse folders..." command, - * update the repo group's selected item and rebuild the branch group. - */ - async updateInputStateAfterFolderSelection(inputState: vscode.ChatSessionInputState, folderUri: vscode.Uri): Promise { - const repo = await this.getTrustedRepository(folderUri, true); - // Untrusted folders return undefined — treated as non-git below. - // We still update the dropdown so the user sees their selection. - // Update MRU tracking for untitled workspaces - if (isWelcomeView(this.workspaceService)) { - if (repo) { - this._lastUsedFolderIdInUntitledWorkspace = { kind: 'repo', uri: repo.rootUri, lastAccessed: Date.now() }; - } else { - this._lastUsedFolderIdInUntitledWorkspace = { kind: 'folder', uri: folderUri, lastAccessed: Date.now() }; - } - } - - - const repoItem = repo - ? toRepositoryOptionItem(repo.rootUri) - : toWorkspaceFolderOptionItem(folderUri, folderUri.path.split('/').pop() ?? folderUri.fsPath); - - // Update repo group's selected item - const groups = [...inputState.groups]; - const repoGroupIdx = groups.findIndex(g => g.id === REPOSITORY_OPTION_ID); - if (repoGroupIdx !== -1) { - const repoGroup = groups[repoGroupIdx]; - const items = repoGroup.items.find(i => i.id === repoItem.id) - ? [...repoGroup.items] - : [repoItem, ...repoGroup.items]; - groups[repoGroupIdx] = { ...repoGroup, items, selected: repoItem }; - } - - // Remove existing branch group, rebuild - const previousBranchSelection = getSelectedOption(inputState.groups, BRANCH_OPTION_ID); - const branchIdx = groups.findIndex(g => g.id === BRANCH_OPTION_ID); - if (branchIdx !== -1) { - groups.splice(branchIdx, 1); - } - - // When the folder is not a git repo, lock isolation to workspace. - // When it is a git repo, unlock isolation (e.g. user switched folders). - if (!repo) { - forceWorkspaceIsolation(groups); - } else { - resetIsolationLock(groups); - } - - if (repo && isBranchOptionFeatureEnabled(this.configurationService)) { - let branches: vscode.ChatSessionProviderOptionItem[] = []; - try { - branches = await this.getBranchOptionItemsForRepository(repo.rootUri, repo.headBranchName); - } catch { - // branches remain empty - } - const isolationEnabled = isIsolationOptionFeatureEnabled(this.configurationService); - const currentIsolation = getSelectedOption(inputState.groups, ISOLATION_OPTION_ID)?.id as IsolationMode | undefined; - // Preserve previous branch selection if the same branch exists in the new repo - const branchGroup = this.buildBranchOptionGroup(branches, repo.headBranchName, isolationEnabled, currentIsolation, previousBranchSelection); - if (branchGroup) { - groups.push(branchGroup); - } - } - - inputState.groups = groups; - } } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionInitializer.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionInitializer.spec.ts index a3069904ffb4d..fc0f0503ac0cc 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionInitializer.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionInitializer.spec.ts @@ -141,14 +141,8 @@ function makeStream(): vscode.ChatResponseStream { } as unknown as vscode.ChatResponseStream; } -function makeChatSessionContext(sessionId: string = 'untitled:new-session', initialOptions?: { optionId: string; value: string }[]): vscode.ChatSessionContext { - return { - chatSessionItem: { - resource: URI.from({ scheme: 'copilotcli', path: `/${sessionId}` }) as unknown as vscode.Uri, - label: 'Test', - }, - initialSessionOptions: initialOptions ?? [], - } as unknown as vscode.ChatSessionContext; +function makeChatResource(sessionId: string = 'untitled:new-session'): vscode.Uri { + return URI.from({ scheme: 'copilotcli', path: `/${sessionId}` }) as unknown as vscode.Uri; } function createInitializer(overrides?: { @@ -326,10 +320,9 @@ describe('ChatSessionInitializer', () => { const sessionService = new TestSessionService(); sessionService.isNewSessionId.mockReturnValue(true); const { initializer, folderRepoManager } = createInitializer({ sessionService }); - const context = makeChatSessionContext('untitled:new'); const result = await initializer.initializeWorkingDirectory( - context, undefined, undefined, makeStream(), + makeChatResource('untitled:new'), { stream: makeStream() }, {} as vscode.ChatParticipantToolToken, CancellationToken.None ); @@ -343,10 +336,9 @@ describe('ChatSessionInitializer', () => { const sessionService = new TestSessionService(); sessionService.isNewSessionId.mockReturnValue(false); const { initializer, folderRepoManager } = createInitializer({ sessionService }); - const context = makeChatSessionContext('existing-session'); const result = await initializer.initializeWorkingDirectory( - context, undefined, undefined, makeStream(), + makeChatResource('existing-session'), { stream: makeStream() }, {} as vscode.ChatParticipantToolToken, CancellationToken.None ); @@ -358,7 +350,7 @@ describe('ChatSessionInitializer', () => { const { initializer, folderRepoManager } = createInitializer(); const result = await initializer.initializeWorkingDirectory( - undefined, undefined, undefined, makeStream(), + undefined, { stream: makeStream() }, {} as vscode.ChatParticipantToolToken, CancellationToken.None ); @@ -381,7 +373,7 @@ describe('ChatSessionInitializer', () => { const { initializer } = createInitializer({ folderRepoManager }); const result = await initializer.initializeWorkingDirectory( - undefined, undefined, undefined, makeStream(), + undefined, { stream: makeStream() }, {} as vscode.ChatParticipantToolToken, CancellationToken.None ); @@ -403,7 +395,7 @@ describe('ChatSessionInitializer', () => { const { initializer } = createInitializer({ folderRepoManager }); const result = await initializer.initializeWorkingDirectory( - undefined, undefined, undefined, makeStream(), + undefined, { stream: makeStream() }, {} as vscode.ChatParticipantToolToken, CancellationToken.None ); @@ -416,15 +408,14 @@ describe('ChatSessionInitializer', () => { sessionService.isNewSessionId.mockReturnValue(true); const { initializer, folderRepoManager } = createInitializer({ sessionService }); - const options = [ - { optionId: 'repository', value: '/selected-repo' }, - { optionId: 'branch', value: 'feature-branch' }, - { optionId: 'isolation', value: IsolationMode.Worktree }, - ]; - const context = makeChatSessionContext('untitled:new', options); - await initializer.initializeWorkingDirectory( - context, undefined, undefined, makeStream(), + makeChatResource('untitled:new'), + { + folder: URI.file('/selected-repo') as unknown as vscode.Uri, + branch: 'feature-branch', + isolation: IsolationMode.Worktree, + stream: makeStream(), + }, {} as vscode.ChatParticipantToolToken, CancellationToken.None ); @@ -447,8 +438,7 @@ describe('ChatSessionInitializer', () => { const stream = makeStream(); const result = await initializer.getOrCreateSession( - makeRequest(), makeChatSessionContext(), stream, - { branchName: Promise.resolve(undefined) }, + makeRequest(), makeChatResource(), { stream }, disposables, CancellationToken.None ); @@ -468,8 +458,7 @@ describe('ChatSessionInitializer', () => { const disposables = new DisposableStore(); const result = await initializer.getOrCreateSession( - makeRequest(), makeChatSessionContext('existing-session'), makeStream(), - { branchName: Promise.resolve(undefined) }, + makeRequest(), makeChatResource('existing-session'), { stream: makeStream() }, disposables, CancellationToken.None ); @@ -491,8 +480,7 @@ describe('ChatSessionInitializer', () => { const disposables = new DisposableStore(); const result = await initializer.getOrCreateSession( - makeRequest(), makeChatSessionContext(), makeStream(), - { branchName: Promise.resolve(undefined) }, + makeRequest(), makeChatResource(), { stream: makeStream() }, disposables, CancellationToken.None ); @@ -510,8 +498,7 @@ describe('ChatSessionInitializer', () => { const stream = makeStream(); const result = await initializer.getOrCreateSession( - makeRequest(), makeChatSessionContext('missing'), stream, - { branchName: Promise.resolve(undefined) }, + makeRequest(), makeChatResource('missing'), { stream }, disposables, CancellationToken.None ); @@ -543,8 +530,7 @@ describe('ChatSessionInitializer', () => { const disposables = new DisposableStore(); await initializer.getOrCreateSession( - makeRequest(), makeChatSessionContext(), makeStream(), - { branchName: Promise.resolve(undefined) }, + makeRequest(), makeChatResource(), { stream: makeStream() }, disposables, CancellationToken.None ); @@ -562,8 +548,7 @@ describe('ChatSessionInitializer', () => { const disposables = new DisposableStore(); await initializer.getOrCreateSession( - makeRequest(), makeChatSessionContext(), makeStream(), - { branchName: Promise.resolve(undefined) }, + makeRequest(), makeChatResource(), { stream: makeStream() }, disposables, CancellationToken.None ); @@ -576,8 +561,7 @@ describe('ChatSessionInitializer', () => { const disposables = new DisposableStore(); await initializer.getOrCreateSession( - makeRequest(), makeChatSessionContext(), makeStream(), - { branchName: Promise.resolve(undefined) }, + makeRequest(), makeChatResource(), { stream: makeStream() }, disposables, CancellationToken.None ); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts index 76d5562d545f5..b514fbb418783 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts @@ -190,7 +190,6 @@ function createProvider() { override buildExistingSessionInputStateGroups = vi.fn(async () => []); override getBranchOptionItemsForRepository = vi.fn(async () => []); override getRepositoryOptionItems = vi.fn(() => []); - override updateInputStateAfterFolderSelection = vi.fn(async () => { }); }(); const provider = new CopilotCLIChatSessionContentProvider( sessionService, @@ -378,13 +377,6 @@ describe('CopilotCLIChatSessionContentProvider (additional)', () => { expect(item.label).toBe('Test Session'); }); - it('delegates updateInputStateAfterFolderSelection to option group builder', async () => { - const { provider } = createProvider(); - const state = { groups: [] } as any; - // Should not throw - await provider.updateInputStateAfterFolderSelection(state, URI.file('/folder') as any); - }); - it('does not call refreshSession when PR detection finds no update', async () => { const { provider, prDetectionService, worktreeService } = createProvider(); const refreshSpy = vi.spyOn(provider, 'refreshSession').mockResolvedValue(); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts index eb450d4ad0f93..3db300834b09a 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts @@ -27,6 +27,7 @@ import { SessionOptionGroupBuilder, folderMRUToChatProviderOptions, getSelectedOption, + getSelectedSessionOptions, isBranchOptionFeatureEnabled, isIsolationOptionFeatureEnabled, resolveBranchLockState, @@ -104,1136 +105,1289 @@ function makeRef(name: string, type: number = 0 /* Head */): { name: string; typ } // ─── Pure function tests ───────────────────────────────────────── +describe('SessionOptionGroupBuilder', () => { -describe('getSelectedOption', () => { - it('returns selected from matching group', () => { - const selected = { id: 'main', name: 'main' }; - const groups: vscode.ChatSessionProviderOptionGroup[] = [ - { id: 'branch', name: 'Branch', description: '', items: [selected], selected }, - ]; - expect(getSelectedOption(groups, 'branch')).toBe(selected); - }); - - it('returns undefined when group not found', () => { - expect(getSelectedOption([], 'branch')).toBeUndefined(); - }); - - it('returns undefined when group has no selection', () => { - const groups: vscode.ChatSessionProviderOptionGroup[] = [ - { id: 'branch', name: 'Branch', description: '', items: [] }, - ]; - expect(getSelectedOption(groups, 'branch')).toBeUndefined(); - }); -}); - -describe('isBranchOptionFeatureEnabled / isIsolationOptionFeatureEnabled', () => { - it('reads CLIBranchSupport config key', () => { - const configService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService()); - // Default value should be whatever the config default is - const result = isBranchOptionFeatureEnabled(configService); - expect(typeof result).toBe('boolean'); - }); - - it('reads CLIIsolationOption config key', () => { - const configService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService()); - const result = isIsolationOptionFeatureEnabled(configService); - expect(typeof result).toBe('boolean'); - }); -}); - -describe('toRepositoryOptionItem', () => { - it('creates option item from RepoContext', () => { - const repo = makeRepo('/workspace/my-project'); - const item = toRepositoryOptionItem(repo); - expect(item.id).toBe(URI.file('/workspace/my-project').fsPath); - expect(item.name).toBe('my-project'); - }); - - it('uses repo icon for repository kind', () => { - const repo = makeRepo('/repo', 'repository'); - const item = toRepositoryOptionItem(repo); - expect(item.icon).toBeDefined(); - }); - - it('creates option item from Uri', () => { - const uri = URI.file('/some/folder'); - const item = toRepositoryOptionItem(uri as any); - expect(item.id).toBe(uri.fsPath); - expect(item.name).toBe('folder'); - }); - - it('marks item as default when isDefault is true', () => { - const repo = makeRepo('/repo'); - const item = toRepositoryOptionItem(repo, true); - expect(item.default).toBe(true); - }); -}); - -describe('toWorkspaceFolderOptionItem', () => { - it('creates option item with folder icon', () => { - const uri = URI.file('/workspace/my-folder'); - const item = toWorkspaceFolderOptionItem(uri, 'My Folder'); - expect(item.id).toBe(uri.fsPath); - expect(item.name).toBe('My Folder'); - expect(item.icon).toBeDefined(); - }); -}); - -describe('folderMRUToChatProviderOptions', () => { - it('converts MRU entries with repositories to repo option items', () => { - const uri = URI.file('/my-repo'); - const entries: FolderRepositoryMRUEntry[] = [ - { folder: uri, repository: uri, lastAccessed: 100 }, - ]; - const items = folderMRUToChatProviderOptions(entries); - expect(items).toHaveLength(1); - expect(items[0].id).toBe(uri.fsPath); - }); - - it('converts MRU entries without repositories to folder option items', () => { - const uri = URI.file('/my-folder'); - const entries: FolderRepositoryMRUEntry[] = [ - { folder: uri, repository: undefined, lastAccessed: 100 }, - ]; - const items = folderMRUToChatProviderOptions(entries); - expect(items).toHaveLength(1); - expect(items[0].id).toBe(uri.fsPath); - }); - - it('returns empty array for empty input', () => { - expect(folderMRUToChatProviderOptions([])).toEqual([]); - }); -}); - -describe('resolveBranchSelection', () => { - const main = { id: 'main', name: 'main' }; - const dev = { id: 'dev', name: 'dev' }; - const featureX = { id: 'feature-x', name: 'feature-x' }; - const branches = [main, dev, featureX]; - - it('returns previous selection if it still exists in the branch list', () => { - expect(resolveBranchSelection(branches, 'main', dev)?.id).toBe('dev'); - }); - - it('falls back to active (HEAD) branch when previous selection is no longer in list', () => { - const stale = { id: 'deleted-branch', name: 'deleted-branch' }; - expect(resolveBranchSelection(branches, 'main', stale)?.id).toBe('main'); - }); + describe('getSelectedOption', () => { + it('returns selected from matching group', () => { + const selected = { id: 'main', name: 'main' }; + const groups: vscode.ChatSessionProviderOptionGroup[] = [ + { id: 'branch', name: 'Branch', description: '', items: [selected], selected }, + ]; + expect(getSelectedOption(groups, 'branch')).toBe(selected); + }); - it('preserves stale previous selection when no active branch matches either', () => { - const stale = { id: 'deleted-branch', name: 'deleted-branch' }; - expect(resolveBranchSelection(branches, undefined, stale)?.id).toBe('deleted-branch'); - }); + it('returns undefined when group not found', () => { + expect(getSelectedOption([], 'branch')).toBeUndefined(); + }); - it('returns active branch when there is no previous selection', () => { - expect(resolveBranchSelection(branches, 'dev', undefined)?.id).toBe('dev'); + it('returns undefined when group has no selection', () => { + const groups: vscode.ChatSessionProviderOptionGroup[] = [ + { id: 'branch', name: 'Branch', description: '', items: [] }, + ]; + expect(getSelectedOption(groups, 'branch')).toBeUndefined(); + }); }); - it('returns undefined when no branches, no active, no previous', () => { - expect(resolveBranchSelection([], undefined, undefined)).toBeUndefined(); - }); + describe('getSelectedSessionOptions', () => { + it('extracts folder, branch, and isolation from input state groups', () => { + const inputState: vscode.ChatSessionInputState = { + onDidChange: Event.None, + groups: [ + { id: REPOSITORY_OPTION_ID, name: 'Folder', items: [{ id: '/my-repo', name: 'my-repo' }], selected: { id: '/my-repo', name: 'my-repo' } }, + { id: BRANCH_OPTION_ID, name: 'Branch', items: [{ id: 'main', name: 'main' }], selected: { id: 'main', name: 'main' } }, + { id: ISOLATION_OPTION_ID, name: 'Isolation', items: [{ id: IsolationMode.Worktree, name: 'Worktree' }], selected: { id: IsolationMode.Worktree, name: 'Worktree' } }, + ], + }; + const result = getSelectedSessionOptions(inputState); + expect(result.folder?.fsPath).toBe(URI.file('/my-repo').fsPath); + expect(result.branch).toBe('main'); + expect(result.isolation).toBe(IsolationMode.Worktree); + }); - it('returns undefined when branches exist but no active and no previous', () => { - expect(resolveBranchSelection(branches, undefined, undefined)).toBeUndefined(); - }); -}); + it('returns undefined values when no groups are present', () => { + const inputState: vscode.ChatSessionInputState = { + onDidChange: Event.None, + groups: [], + }; + const result = getSelectedSessionOptions(inputState); + expect(result.folder).toBeUndefined(); + expect(result.branch).toBeUndefined(); + expect(result.isolation).toBeUndefined(); + }); -describe('resolveBranchLockState', () => { - it('locked when isolation is enabled and Workspace is selected', () => { - const result = resolveBranchLockState(true, IsolationMode.Workspace); - expect(result.locked).toBe(true); + it('returns undefined values when groups have no selection', () => { + const inputState: vscode.ChatSessionInputState = { + onDidChange: Event.None, + groups: [ + { id: REPOSITORY_OPTION_ID, name: 'Folder', items: [] }, + { id: BRANCH_OPTION_ID, name: 'Branch', items: [] }, + { id: ISOLATION_OPTION_ID, name: 'Isolation', items: [] }, + ], + }; + const result = getSelectedSessionOptions(inputState); + expect(result.folder).toBeUndefined(); + expect(result.branch).toBeUndefined(); + expect(result.isolation).toBeUndefined(); + }); }); - it('editable when isolation is enabled and Worktree is selected', () => { - const result = resolveBranchLockState(true, IsolationMode.Worktree); - expect(result.locked).toBe(false); - }); + describe('isBranchOptionFeatureEnabled / isIsolationOptionFeatureEnabled', () => { + it('reads CLIBranchSupport config key', () => { + const configService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService()); + // Default value should be whatever the config default is + const result = isBranchOptionFeatureEnabled(configService); + expect(typeof result).toBe('boolean'); + }); - it('locked when isolation feature is disabled', () => { - const result = resolveBranchLockState(false, undefined); - expect(result.locked).toBe(true); + it('reads CLIIsolationOption config key', () => { + const configService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService()); + const result = isIsolationOptionFeatureEnabled(configService); + expect(typeof result).toBe('boolean'); + }); }); - it('locked when isolation is disabled even if isolation value is worktree', () => { - const result = resolveBranchLockState(false, IsolationMode.Worktree); - expect(result.locked).toBe(true); - }); -}); + describe('toRepositoryOptionItem', () => { + it('creates option item from RepoContext', () => { + const repo = makeRepo('/workspace/my-project'); + const item = toRepositoryOptionItem(repo); + expect(item.id).toBe(URI.file('/workspace/my-project').fsPath); + expect(item.name).toBe('my-project'); + }); -describe('resolveIsolationSelection', () => { - it('uses previous selection when it is a valid isolation mode', () => { - expect(resolveIsolationSelection(IsolationMode.Worktree, IsolationMode.Workspace)).toBe(IsolationMode.Workspace); - expect(resolveIsolationSelection(IsolationMode.Workspace, IsolationMode.Worktree)).toBe(IsolationMode.Worktree); - }); + it('uses repo icon for repository kind', () => { + const repo = makeRepo('/repo', 'repository'); + const item = toRepositoryOptionItem(repo); + expect(item.icon).toBeDefined(); + }); - it('falls back to lastUsed when there is no previous selection', () => { - expect(resolveIsolationSelection(IsolationMode.Worktree, undefined)).toBe(IsolationMode.Worktree); - }); + it('creates option item from Uri', () => { + const uri = URI.file('/some/folder'); + const item = toRepositoryOptionItem(uri as any); + expect(item.id).toBe(uri.fsPath); + expect(item.name).toBe('folder'); + }); - it('falls back to lastUsed when previous selection is not a valid isolation mode', () => { - expect(resolveIsolationSelection(IsolationMode.Workspace, 'invalid-value')).toBe(IsolationMode.Workspace); + it('marks item as default when isDefault is true', () => { + const repo = makeRepo('/repo'); + const item = toRepositoryOptionItem(repo, true); + expect(item.default).toBe(true); + }); }); -}); - -// ─── SessionOptionGroupBuilder class tests ─────────────────────── -describe('SessionOptionGroupBuilder', () => { - let gitService: TestGitService; - let configurationService: InMemoryConfigurationService; - let context: IVSCodeExtensionContext; - let workspaceService: NullWorkspaceService; - let folderMruService: TestFolderMruService; - let agentSessionsWorkspace: IAgentSessionsWorkspace; - let worktreeService: TestWorktreeService; - let folderRepositoryManager: TestFolderRepositoryManager; - let builder: SessionOptionGroupBuilder; - - beforeEach(async () => { - vi.restoreAllMocks(); - gitService = new TestGitService(); - configurationService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService()); - context = createInMemoryContext(); - workspaceService = new NullWorkspaceService([URI.file('/workspace')]); - folderMruService = new TestFolderMruService(); - agentSessionsWorkspace = { _serviceBrand: undefined, isAgentSessionsWorkspace: false }; - worktreeService = new TestWorktreeService(); - folderRepositoryManager = new TestFolderRepositoryManager(); - - builder = new SessionOptionGroupBuilder( - gitService, - configurationService, - context, - workspaceService, - folderMruService, - agentSessionsWorkspace, - worktreeService, - folderRepositoryManager, - ); + describe('toWorkspaceFolderOptionItem', () => { + it('creates option item with folder icon', () => { + const uri = URI.file('/workspace/my-folder'); + const item = toWorkspaceFolderOptionItem(uri, 'My Folder'); + expect(item.id).toBe(uri.fsPath); + expect(item.name).toBe('My Folder'); + expect(item.icon).toBeDefined(); + }); }); - describe('getRepositoryOptionItems', () => { - it('returns empty array when no repositories', () => { - gitService.repositories = []; - const items = builder.getRepositoryOptionItems(); - // Should still return workspace folder as non-git folder - expect(items.length).toBeGreaterThanOrEqual(0); + describe('folderMRUToChatProviderOptions', () => { + it('converts MRU entries with repositories to repo option items', () => { + const uri = URI.file('/my-repo'); + const entries: FolderRepositoryMRUEntry[] = [ + { folder: uri, repository: uri, lastAccessed: 100 }, + ]; + const items = folderMRUToChatProviderOptions(entries); + expect(items).toHaveLength(1); + expect(items[0].id).toBe(uri.fsPath); }); - it('excludes worktree repositories', () => { - gitService.repositories = [ - makeRepo('/repo', 'repository'), - makeRepo('/worktree', 'worktree'), + it('converts MRU entries without repositories to folder option items', () => { + const uri = URI.file('/my-folder'); + const entries: FolderRepositoryMRUEntry[] = [ + { folder: uri, repository: undefined, lastAccessed: 100 }, ]; - const items = builder.getRepositoryOptionItems(); - expect(items.find(i => i.id === URI.file('/worktree').fsPath)).toBeUndefined(); + const items = folderMRUToChatProviderOptions(entries); + expect(items).toHaveLength(1); + expect(items[0].id).toBe(uri.fsPath); }); - it('includes repositories that belong to workspace folders', () => { - const repoUri = URI.file('/workspace'); - gitService.repositories = [makeRepo('/workspace')]; - const items = builder.getRepositoryOptionItems(); - expect(items.find(i => i.id === repoUri.fsPath)).toBeDefined(); + it('returns empty array for empty input', () => { + expect(folderMRUToChatProviderOptions([])).toEqual([]); }); + }); - it('includes workspace folders without git repos in multi-root', () => { - workspaceService = new NullWorkspaceService([URI.file('/workspace'), URI.file('/other-folder')]); - builder = new SessionOptionGroupBuilder( - gitService, configurationService, context, workspaceService, - folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, - ); - // Only one repo under /workspace - gitService.repositories = [makeRepo('/workspace')]; - const items = builder.getRepositoryOptionItems(); - // Should include the repo and the non-git folder - expect(items.length).toBe(2); - expect(items.find(i => i.id === URI.file('/other-folder').fsPath)).toBeDefined(); - }); + describe('resolveBranchSelection', () => { + const main = { id: 'main', name: 'main' }; + const dev = { id: 'dev', name: 'dev' }; + const featureX = { id: 'feature-x', name: 'feature-x' }; + const branches = [main, dev, featureX]; - it('sorts items alphabetically by name', () => { - // NullWorkspaceService.getWorkspaceFolderName returns 'default', so we use git repos - // which derive their name from the URI path - workspaceService = new NullWorkspaceService([URI.file('/z-repo'), URI.file('/a-repo')]); - builder = new SessionOptionGroupBuilder( - gitService, configurationService, context, workspaceService, - folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, - ); - gitService.repositories = [makeRepo('/z-repo'), makeRepo('/a-repo')]; - const items = builder.getRepositoryOptionItems(); - expect(items.length).toBe(2); - expect(items[0].name).toBe('a-repo'); - expect(items[1].name).toBe('z-repo'); + it('returns previous selection if it still exists in the branch list', () => { + expect(resolveBranchSelection(branches, 'main', dev)?.id).toBe('dev'); }); - }); - describe('buildBranchOptionGroup', () => { - it('returns undefined when no branches', () => { - const result = builder.buildBranchOptionGroup([], 'main', false, undefined, undefined); - expect(result).toBeUndefined(); + it('falls back to active (HEAD) branch when previous selection is no longer in list', () => { + const stale = { id: 'deleted-branch', name: 'deleted-branch' }; + expect(resolveBranchSelection(branches, 'main', stale)?.id).toBe('main'); }); - it('returns branch group with items', () => { - const branches = [ - { id: 'main', name: 'main', icon: {} as any }, - { id: 'dev', name: 'dev', icon: {} as any }, - ]; - const result = builder.buildBranchOptionGroup(branches, 'main', false, undefined, undefined); - expect(result).toBeDefined(); - expect(result!.id).toBe(BRANCH_OPTION_ID); - expect(result!.items).toHaveLength(2); + it('preserves stale previous selection when no active branch matches either', () => { + const stale = { id: 'deleted-branch', name: 'deleted-branch' }; + expect(resolveBranchSelection(branches, undefined, stale)?.id).toBe('deleted-branch'); }); - it('selects HEAD branch when no previous selection', () => { - const branches = [ - { id: 'main', name: 'main', icon: {} as any }, - { id: 'dev', name: 'dev', icon: {} as any }, - ]; - const result = builder.buildBranchOptionGroup(branches, 'main', false, undefined, undefined); - expect(result!.selected?.id).toBe('main'); + it('returns active branch when there is no previous selection', () => { + expect(resolveBranchSelection(branches, 'dev', undefined)?.id).toBe('dev'); }); - it('locks items when isolation is disabled', () => { - const branches = [{ id: 'main', name: 'main', icon: {} as any }]; - const result = builder.buildBranchOptionGroup(branches, 'main', false, undefined, undefined); - expect(result!.items[0].locked).toBe(true); + it('returns undefined when no branches, no active, no previous', () => { + expect(resolveBranchSelection([], undefined, undefined)).toBeUndefined(); }); - it('locks items when isolation is enabled but Workspace is selected', () => { - const branches = [{ id: 'main', name: 'main', icon: {} as any }]; - const result = builder.buildBranchOptionGroup(branches, 'main', true, IsolationMode.Workspace, undefined); - expect(result!.items[0].locked).toBe(true); + it('returns undefined when branches exist but no active and no previous', () => { + expect(resolveBranchSelection(branches, undefined, undefined)).toBeUndefined(); }); + }); - it('does not lock items when isolation is enabled and Worktree is selected', () => { - const branches = [{ id: 'main', name: 'main', icon: {} as any }]; - const result = builder.buildBranchOptionGroup(branches, 'main', true, IsolationMode.Worktree, undefined); - expect(result!.items[0].locked).toBeUndefined(); + describe('resolveBranchLockState', () => { + it('locked when isolation is enabled and Workspace is selected', () => { + const result = resolveBranchLockState(true, IsolationMode.Workspace); + expect(result.locked).toBe(true); }); - }); - describe('getBranchOptionItemsForRepository', () => { - it('returns branch items sorted with HEAD first', async () => { - const repoUri = URI.file('/repo'); - gitService.getRefs.mockResolvedValue([ - makeRef('feature'), - makeRef('main'), - makeRef('dev'), - ]); - const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main'); - expect(items[0].id).toBe('main'); + it('editable when isolation is enabled and Worktree is selected', () => { + const result = resolveBranchLockState(true, IsolationMode.Worktree); + expect(result.locked).toBe(false); }); - it('puts main/master branch second after HEAD', async () => { - const repoUri = URI.file('/repo'); - gitService.getRefs.mockResolvedValue([ - makeRef('feature'), - makeRef('main'), - makeRef('dev'), - ]); - // HEAD is 'dev' - const items = await builder.getBranchOptionItemsForRepository(repoUri, 'dev'); - expect(items[0].id).toBe('dev'); // HEAD first - expect(items[1].id).toBe('main'); // main/master second + it('locked when isolation feature is disabled', () => { + const result = resolveBranchLockState(false, undefined); + expect(result.locked).toBe(true); }); - it('filters out copilot-worktree branches', async () => { - const repoUri = URI.file('/repo'); - gitService.getRefs.mockResolvedValue([ - makeRef('main'), - makeRef('copilot-worktree-abc123'), - ]); - const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main'); - expect(items).toHaveLength(1); - expect(items[0].id).toBe('main'); + it('locked when isolation is disabled even if isolation value is worktree', () => { + const result = resolveBranchLockState(false, IsolationMode.Worktree); + expect(result.locked).toBe(true); }); + }); - it('filters out non-local branches (remote refs)', async () => { - const repoUri = URI.file('/repo'); - gitService.getRefs.mockResolvedValue([ - makeRef('main'), - { name: 'origin/main', type: 1 }, // RefType.Remote - ]); - const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main'); - expect(items).toHaveLength(1); + describe('resolveIsolationSelection', () => { + it('uses previous selection when it is a valid isolation mode', () => { + expect(resolveIsolationSelection(IsolationMode.Worktree, IsolationMode.Workspace)).toBe(IsolationMode.Workspace); + expect(resolveIsolationSelection(IsolationMode.Workspace, IsolationMode.Worktree)).toBe(IsolationMode.Worktree); }); - it('returns empty array when no refs', async () => { - const repoUri = URI.file('/repo'); - gitService.getRefs.mockResolvedValue([]); - const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main'); - expect(items).toHaveLength(0); + it('falls back to lastUsed when there is no previous selection', () => { + expect(resolveIsolationSelection(IsolationMode.Worktree, undefined)).toBe(IsolationMode.Worktree); }); - it('skips refs with no name', async () => { - const repoUri = URI.file('/repo'); - gitService.getRefs.mockResolvedValue([ - { name: undefined, type: 0 }, - makeRef('main'), - ]); - const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main'); - expect(items).toHaveLength(1); + it('falls back to lastUsed when previous selection is not a valid isolation mode', () => { + expect(resolveIsolationSelection(IsolationMode.Workspace, 'invalid-value')).toBe(IsolationMode.Workspace); }); }); - describe('provideChatSessionProviderOptionGroups', () => { - it('returns repository group for multi-repo workspaces', async () => { - workspaceService = new NullWorkspaceService([URI.file('/repo1'), URI.file('/repo2')]); + // ─── SessionOptionGroupBuilder class tests ─────────────────────── + + describe('SessionOptionGroupBuilder Class', () => { + let gitService: TestGitService; + let configurationService: InMemoryConfigurationService; + let context: IVSCodeExtensionContext; + let workspaceService: NullWorkspaceService; + let folderMruService: TestFolderMruService; + let agentSessionsWorkspace: IAgentSessionsWorkspace; + let worktreeService: TestWorktreeService; + let folderRepositoryManager: TestFolderRepositoryManager; + let builder: SessionOptionGroupBuilder; + + beforeEach(async () => { + vi.restoreAllMocks(); + gitService = new TestGitService(); + configurationService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService()); + context = createInMemoryContext(); + workspaceService = new NullWorkspaceService([URI.file('/workspace')]); + folderMruService = new TestFolderMruService(); + agentSessionsWorkspace = { _serviceBrand: undefined, isAgentSessionsWorkspace: false }; + worktreeService = new TestWorktreeService(); + folderRepositoryManager = new TestFolderRepositoryManager(); + builder = new SessionOptionGroupBuilder( - gitService, configurationService, context, workspaceService, - folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + gitService, + configurationService, + context, + workspaceService, + folderMruService, + agentSessionsWorkspace, + worktreeService, + folderRepositoryManager, ); - gitService.repositories = [makeRepo('/repo1'), makeRepo('/repo2')]; - - const groups = await builder.provideChatSessionProviderOptionGroups(undefined); - const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); - expect(repoGroup).toBeDefined(); - expect(repoGroup!.items.length).toBe(2); }); - it('does not include repository group for single-repo workspace', async () => { - gitService.repositories = [makeRepo('/workspace')]; - await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); - await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false); + describe('getRepositoryOptionItems', () => { + it('returns empty array when no repositories', () => { + gitService.repositories = []; + const items = builder.getRepositoryOptionItems(); + // Should still return workspace folder as non-git folder + expect(items.length).toBeGreaterThanOrEqual(0); + }); - const groups = await builder.provideChatSessionProviderOptionGroups(undefined); - const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); - expect(repoGroup).toBeUndefined(); - }); + it('excludes worktree repositories', () => { + gitService.repositories = [ + makeRepo('/repo', 'repository'), + makeRepo('/worktree', 'worktree'), + ]; + const items = builder.getRepositoryOptionItems(); + expect(items.find(i => i.id === URI.file('/worktree').fsPath)).toBeUndefined(); + }); - it('does not include repository group for single folder with no git repos', async () => { - gitService.repositories = []; - gitService.getRepository.mockResolvedValue(undefined); - await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); - await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false); + it('includes repositories that belong to workspace folders', () => { + const repoUri = URI.file('/workspace'); + gitService.repositories = [makeRepo('/workspace')]; + const items = builder.getRepositoryOptionItems(); + expect(items.find(i => i.id === repoUri.fsPath)).toBeDefined(); + }); - const groups = await builder.provideChatSessionProviderOptionGroups(undefined); - expect(groups.find(g => g.id === REPOSITORY_OPTION_ID)).toBeUndefined(); - }); + it('includes workspace folders without git repos in multi-root', () => { + workspaceService = new NullWorkspaceService([URI.file('/workspace'), URI.file('/other-folder')]); + builder = new SessionOptionGroupBuilder( + gitService, configurationService, context, workspaceService, + folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + ); + // Only one repo under /workspace + gitService.repositories = [makeRepo('/workspace')]; + const items = builder.getRepositoryOptionItems(); + // Should include the repo and the non-git folder + expect(items.length).toBe(2); + expect(items.find(i => i.id === URI.file('/other-folder').fsPath)).toBeDefined(); + }); - it('includes isolation group when feature is enabled', async () => { - await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); - const groups = await builder.provideChatSessionProviderOptionGroups(undefined); - const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID); - expect(isolationGroup).toBeDefined(); - expect(isolationGroup!.items).toHaveLength(2); + it('sorts items alphabetically by name', () => { + // NullWorkspaceService.getWorkspaceFolderName returns 'default', so we use git repos + // which derive their name from the URI path + workspaceService = new NullWorkspaceService([URI.file('/z-repo'), URI.file('/a-repo')]); + builder = new SessionOptionGroupBuilder( + gitService, configurationService, context, workspaceService, + folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + ); + gitService.repositories = [makeRepo('/z-repo'), makeRepo('/a-repo')]; + const items = builder.getRepositoryOptionItems(); + expect(items.length).toBe(2); + expect(items[0].name).toBe('a-repo'); + expect(items[1].name).toBe('z-repo'); + }); }); - it('does not include isolation group when feature is disabled', async () => { - await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false); - const groups = await builder.provideChatSessionProviderOptionGroups(undefined); - const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID); - expect(isolationGroup).toBeUndefined(); - }); + describe('buildBranchOptionGroup', () => { + it('returns undefined when no branches', () => { + const result = builder.buildBranchOptionGroup([], 'main', false, undefined, undefined); + expect(result).toBeUndefined(); + }); - it('includes branch group when feature is enabled and repo exists', async () => { - await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true); - const repo = makeRepo('/workspace'); - gitService.repositories = [repo]; - gitService.getRepository.mockResolvedValue(repo); - gitService.getRefs.mockResolvedValue([makeRef('main')]); + it('returns branch group with items', () => { + const branches = [ + { id: 'main', name: 'main', icon: {} as any }, + { id: 'dev', name: 'dev', icon: {} as any }, + ]; + const result = builder.buildBranchOptionGroup(branches, 'main', false, undefined, undefined); + expect(result).toBeDefined(); + expect(result!.id).toBe(BRANCH_OPTION_ID); + expect(result!.items).toHaveLength(1); + }); - const groups = await builder.provideChatSessionProviderOptionGroups(undefined); - const branchGroup = groups.find(g => g.id === BRANCH_OPTION_ID); - expect(branchGroup).toBeDefined(); - }); + it('selects HEAD branch when no previous selection', () => { + const branches = [ + { id: 'main', name: 'main', icon: {} as any }, + { id: 'dev', name: 'dev', icon: {} as any }, + ]; + const result = builder.buildBranchOptionGroup(branches, 'main', false, undefined, undefined); + expect(result!.selected?.id).toBe('main'); + }); - it('does not include branch group when feature is disabled', async () => { - await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); - const groups = await builder.provideChatSessionProviderOptionGroups(undefined); - const branchGroup = groups.find(g => g.id === BRANCH_OPTION_ID); - expect(branchGroup).toBeUndefined(); - }); + it('locks items when isolation is disabled', () => { + const branches = [{ id: 'main', name: 'main', icon: {} as any }]; + const result = builder.buildBranchOptionGroup(branches, 'main', false, undefined, undefined); + expect(result!.items[0].locked).toBe(true); + }); - it('preserves previous isolation selection', async () => { - await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); - const repo = makeRepo('/workspace'); - gitService.repositories = [repo]; - gitService.getRepository.mockResolvedValue(repo); + it('locks items when isolation is enabled but Workspace is selected', () => { + const branches = [{ id: 'main', name: 'main', icon: {} as any }]; + const result = builder.buildBranchOptionGroup(branches, 'main', true, IsolationMode.Workspace, undefined); + expect(result!.items[0].locked).toBe(true); + }); - const previousState: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [{ - id: ISOLATION_OPTION_ID, - name: 'Isolation', - description: '', - items: [], - selected: { id: IsolationMode.Worktree, name: 'Worktree' }, - }], - }; + it('does not lock items when isolation is enabled and Worktree is selected', () => { + const branches = [{ id: 'main', name: 'main', icon: {} as any }]; + const result = builder.buildBranchOptionGroup(branches, 'main', true, IsolationMode.Worktree, undefined); + expect(result!.items[0].locked).toBeUndefined(); + }); - const groups = await builder.provideChatSessionProviderOptionGroups(previousState); - const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID); - expect(isolationGroup!.selected?.id).toBe(IsolationMode.Worktree); + it('resets to HEAD branch when locked with workspace isolation even if previous selection was different', () => { + const branches = [ + { id: 'main', name: 'main', icon: {} as any }, + { id: 'hello', name: 'hello', icon: {} as any }, + ]; + const previousSelection = { id: 'hello', name: 'hello', icon: {} as any }; + const result = builder.buildBranchOptionGroup(branches, 'main', true, IsolationMode.Workspace, previousSelection); + expect(result!.selected?.id).toBe('main'); + expect(result!.selected?.locked).toBe(true); + }); }); - it('shows MRU items for welcome view (empty workspace)', async () => { - workspaceService = new NullWorkspaceService([]); - builder = new SessionOptionGroupBuilder( - gitService, configurationService, context, workspaceService, - folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, - ); - const mruUri = URI.file('/recent-repo'); - folderMruService.getRecentlyUsedFolders.mockResolvedValue([ - { folder: mruUri, repository: mruUri, lastAccessed: Date.now() }, - ]); - - const groups = await builder.provideChatSessionProviderOptionGroups(undefined); - const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); - expect(repoGroup).toBeDefined(); - expect(repoGroup!.items).toHaveLength(1); - expect(repoGroup!.items[0].id).toBe(mruUri.fsPath); - // Should have a command for browsing folders - expect(repoGroup!.commands).toBeDefined(); - expect(repoGroup!.commands!.length).toBeGreaterThan(0); - }); + describe('getBranchOptionItemsForRepository', () => { + it('returns branch items sorted with HEAD first', async () => { + const repoUri = URI.file('/repo'); + gitService.getRefs.mockResolvedValue([ + makeRef('feature'), + makeRef('main'), + makeRef('dev'), + ]); + const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main'); + expect(items[0].id).toBe('main'); + }); - it('caps MRU items at 10 entries in welcome view', async () => { - workspaceService = new NullWorkspaceService([]); - builder = new SessionOptionGroupBuilder( - gitService, configurationService, context, workspaceService, - folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, - ); - const entries = Array.from({ length: 15 }, (_, i) => { - const uri = URI.file(`/repo-${i}`); - return { folder: uri, repository: uri, lastAccessed: i } as FolderRepositoryMRUEntry; + it('puts main/master branch second after HEAD', async () => { + const repoUri = URI.file('/repo'); + gitService.getRefs.mockResolvedValue([ + makeRef('feature'), + makeRef('main'), + makeRef('dev'), + ]); + // HEAD is 'dev' + const items = await builder.getBranchOptionItemsForRepository(repoUri, 'dev'); + expect(items[0].id).toBe('dev'); // HEAD first + expect(items[1].id).toBe('main'); // main/master second }); - folderMruService.getRecentlyUsedFolders.mockResolvedValue(entries); - const groups = await builder.provideChatSessionProviderOptionGroups(undefined); - const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); - expect(repoGroup!.items).toHaveLength(10); - }); - }); + it('filters out copilot-worktree branches', async () => { + const repoUri = URI.file('/repo'); + gitService.getRefs.mockResolvedValue([ + makeRef('main'), + makeRef('copilot-worktree-abc123'), + ]); + const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main'); + expect(items).toHaveLength(1); + expect(items[0].id).toBe('main'); + }); - describe('handleInputStateChange', () => { - it('rebuilds branch group when repo changes', async () => { - await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true); - const repo = makeRepo('/new-repo'); - gitService.getRepository.mockResolvedValue(repo); - gitService.getRefs.mockResolvedValue([makeRef('main'), makeRef('develop')]); + it('filters out non-local branches (remote refs)', async () => { + const repoUri = URI.file('/repo'); + gitService.getRefs.mockResolvedValue([ + makeRef('main'), + { name: 'origin/main', type: 1 }, // RefType.Remote + ]); + const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main'); + expect(items).toHaveLength(1); + }); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [ - { - id: REPOSITORY_OPTION_ID, - name: 'Folder', - description: '', - items: [], - selected: { id: URI.file('/new-repo').fsPath, name: 'new-repo' }, - }, - { - id: BRANCH_OPTION_ID, - name: 'Branch', - description: '', - items: [{ id: 'old-branch', name: 'old-branch' }], - selected: { id: 'old-branch', name: 'old-branch' }, - }, - ], - }; + it('returns empty array when no refs', async () => { + const repoUri = URI.file('/repo'); + gitService.getRefs.mockResolvedValue([]); + const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main'); + expect(items).toHaveLength(0); + }); - await builder.handleInputStateChange(state); - const branchGroup = state.groups.find(g => g.id === BRANCH_OPTION_ID); - expect(branchGroup).toBeDefined(); - expect(branchGroup!.items.length).toBe(2); + it('skips refs with no name', async () => { + const repoUri = URI.file('/repo'); + gitService.getRefs.mockResolvedValue([ + { name: undefined, type: 0 }, + makeRef('main'), + ]); + const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main'); + expect(items).toHaveLength(1); + }); }); - it('removes branch group when repo has no branches', async () => { - await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true); - gitService.getRepository.mockResolvedValue(makeRepo('/repo')); - gitService.getRefs.mockResolvedValue([]); + describe('provideChatSessionProviderOptionGroups', () => { + it('returns repository group for multi-repo workspaces', async () => { + workspaceService = new NullWorkspaceService([URI.file('/repo1'), URI.file('/repo2')]); + builder = new SessionOptionGroupBuilder( + gitService, configurationService, context, workspaceService, + folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + ); + gitService.repositories = [makeRepo('/repo1'), makeRepo('/repo2')]; + + const groups = await builder.provideChatSessionProviderOptionGroups(undefined); + const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); + expect(repoGroup).toBeDefined(); + expect(repoGroup!.items.length).toBe(2); + }); + + it('pre-selects selectedFolderUri in multi-repo workspace', async () => { + workspaceService = new NullWorkspaceService([URI.file('/repo1'), URI.file('/repo2')]); + builder = new SessionOptionGroupBuilder( + gitService, configurationService, context, workspaceService, + folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + ); + gitService.repositories = [makeRepo('/repo1'), makeRepo('/repo2')]; + gitService.getRepository.mockResolvedValue(makeRepo('/repo2')); + await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); + + const groups = await builder.provideChatSessionProviderOptionGroups(undefined, URI.file('/repo2') as any); + const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); + expect(repoGroup).toBeDefined(); + expect(repoGroup!.selected?.id).toBe(URI.file('/repo2').fsPath); + }); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [ - { + it('pre-selects selectedFolderUri over previous selection in multi-repo workspace', async () => { + workspaceService = new NullWorkspaceService([URI.file('/repo1'), URI.file('/repo2')]); + builder = new SessionOptionGroupBuilder( + gitService, configurationService, context, workspaceService, + folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + ); + gitService.repositories = [makeRepo('/repo1'), makeRepo('/repo2')]; + gitService.getRepository.mockResolvedValue(makeRepo('/repo2')); + await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); + + const previousState: vscode.ChatSessionInputState = { + onDidChange: Event.None, + groups: [{ id: REPOSITORY_OPTION_ID, name: 'Folder', description: '', items: [], - selected: { id: URI.file('/repo').fsPath, name: 'repo' }, - }, - { - id: BRANCH_OPTION_ID, - name: 'Branch', - description: '', - items: [{ id: 'old', name: 'old' }], - }, - ], - }; + selected: { id: URI.file('/repo1').fsPath, name: 'repo1' }, + }], + }; - await builder.handleInputStateChange(state); - const branchGroup = state.groups.find(g => g.id === BRANCH_OPTION_ID); - expect(branchGroup).toBeUndefined(); - }); + const groups = await builder.provideChatSessionProviderOptionGroups(previousState, URI.file('/repo2') as any); + const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); + expect(repoGroup!.selected?.id).toBe(URI.file('/repo2').fsPath); + }); - it('does not add branch group when branch feature is disabled', async () => { - await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); + it('does not include repository group for single-repo workspace', async () => { + gitService.repositories = [makeRepo('/workspace')]; + await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); + await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [{ - id: REPOSITORY_OPTION_ID, - name: 'Folder', - description: '', - items: [], - selected: { id: URI.file('/repo').fsPath, name: 'repo' }, - }], - }; + const groups = await builder.provideChatSessionProviderOptionGroups(undefined); + const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); + expect(repoGroup).toBeUndefined(); + }); - await builder.handleInputStateChange(state); - expect(state.groups.find(g => g.id === BRANCH_OPTION_ID)).toBeUndefined(); - }); + it('does not include repository group for single folder with no git repos', async () => { + gitService.repositories = []; + gitService.getRepository.mockResolvedValue(undefined); + await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); + await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false); - it('persists isolation selection to global state', async () => { - await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); - gitService.getRepository.mockResolvedValue(makeRepo('/workspace')); + const groups = await builder.provideChatSessionProviderOptionGroups(undefined); + expect(groups.find(g => g.id === REPOSITORY_OPTION_ID)).toBeUndefined(); + }); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [{ - id: ISOLATION_OPTION_ID, - name: 'Isolation', - description: '', - items: [], - selected: { id: IsolationMode.Worktree, name: 'Worktree' }, - }], - }; + it('includes isolation group when feature is enabled', async () => { + await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); + const groups = await builder.provideChatSessionProviderOptionGroups(undefined); + const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID); + expect(isolationGroup).toBeDefined(); + expect(isolationGroup!.items).toHaveLength(2); + }); - await builder.handleInputStateChange(state); - expect(context.globalState.get('github.copilot.cli.lastUsedIsolationOption')).toBe(IsolationMode.Worktree); - }); + it('does not include isolation group when feature is disabled', async () => { + await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false); + const groups = await builder.provideChatSessionProviderOptionGroups(undefined); + const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID); + expect(isolationGroup).toBeUndefined(); + }); - it('forces workspace isolation when selected folder is not a git repo', async () => { - await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); - gitService.getRepository.mockResolvedValue(undefined); + it('includes branch group when feature is enabled and repo exists', async () => { + await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true); + const repo = makeRepo('/workspace'); + gitService.repositories = [repo]; + gitService.getRepository.mockResolvedValue(repo); + gitService.getRefs.mockResolvedValue([makeRef('main')]); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [ - { + const groups = await builder.provideChatSessionProviderOptionGroups(undefined); + const branchGroup = groups.find(g => g.id === BRANCH_OPTION_ID); + expect(branchGroup).toBeDefined(); + }); + + it('does not include branch group when feature is disabled', async () => { + await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); + const groups = await builder.provideChatSessionProviderOptionGroups(undefined); + const branchGroup = groups.find(g => g.id === BRANCH_OPTION_ID); + expect(branchGroup).toBeUndefined(); + }); + + it('preserves previous isolation selection', async () => { + await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); + const repo = makeRepo('/workspace'); + gitService.repositories = [repo]; + gitService.getRepository.mockResolvedValue(repo); + + const previousState: vscode.ChatSessionInputState = { + onDidChange: Event.None, + groups: [{ id: ISOLATION_OPTION_ID, name: 'Isolation', description: '', - items: [ - { id: IsolationMode.Workspace, name: 'Workspace' }, - { id: IsolationMode.Worktree, name: 'Worktree' }, - ], + items: [], selected: { id: IsolationMode.Worktree, name: 'Worktree' }, - }, - { + }], + }; + + const groups = await builder.provideChatSessionProviderOptionGroups(previousState); + const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID); + expect(isolationGroup!.selected?.id).toBe(IsolationMode.Worktree); + }); + + it('shows MRU items for welcome view (empty workspace)', async () => { + workspaceService = new NullWorkspaceService([]); + builder = new SessionOptionGroupBuilder( + gitService, configurationService, context, workspaceService, + folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + ); + const mruUri = URI.file('/recent-repo'); + folderMruService.getRecentlyUsedFolders.mockResolvedValue([ + { folder: mruUri, repository: mruUri, lastAccessed: Date.now() }, + ]); + + const groups = await builder.provideChatSessionProviderOptionGroups(undefined); + const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); + expect(repoGroup).toBeDefined(); + expect(repoGroup!.items).toHaveLength(1); + expect(repoGroup!.items[0].id).toBe(mruUri.fsPath); + // First item should be auto-selected when no previous selection + expect(repoGroup!.selected?.id).toBe(mruUri.fsPath); + // Should have a command for browsing folders + expect(repoGroup!.commands).toBeDefined(); + expect(repoGroup!.commands!.length).toBeGreaterThan(0); + }); + + it('caps MRU items at 10 entries in welcome view', async () => { + workspaceService = new NullWorkspaceService([]); + builder = new SessionOptionGroupBuilder( + gitService, configurationService, context, workspaceService, + folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + ); + const entries = Array.from({ length: 15 }, (_, i) => { + const uri = URI.file(`/repo-${i}`); + return { folder: uri, repository: uri, lastAccessed: i } as FolderRepositoryMRUEntry; + }); + folderMruService.getRecentlyUsedFolders.mockResolvedValue(entries); + + const groups = await builder.provideChatSessionProviderOptionGroups(undefined); + const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); + expect(repoGroup!.items).toHaveLength(10); + }); + + it('pre-selects selectedFolderUri in welcome view', async () => { + workspaceService = new NullWorkspaceService([]); + builder = new SessionOptionGroupBuilder( + gitService, configurationService, context, workspaceService, + folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + ); + const mruUri1 = URI.file('/repo-a'); + const mruUri2 = URI.file('/repo-b'); + folderMruService.getRecentlyUsedFolders.mockResolvedValue([ + { folder: mruUri1, repository: mruUri1, lastAccessed: Date.now() }, + { folder: mruUri2, repository: mruUri2, lastAccessed: Date.now() - 1000 }, + ]); + + const groups = await builder.provideChatSessionProviderOptionGroups(undefined, mruUri2 as any); + const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); + expect(repoGroup!.selected?.id).toBe(mruUri2.fsPath); + }); + + it('pre-selects selectedFolderUri over previous selection in welcome view', async () => { + workspaceService = new NullWorkspaceService([]); + builder = new SessionOptionGroupBuilder( + gitService, configurationService, context, workspaceService, + folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + ); + const mruUri1 = URI.file('/repo-a'); + const mruUri2 = URI.file('/repo-b'); + folderMruService.getRecentlyUsedFolders.mockResolvedValue([ + { folder: mruUri1, repository: mruUri1, lastAccessed: Date.now() }, + { folder: mruUri2, repository: mruUri2, lastAccessed: Date.now() - 1000 }, + ]); + + const previousState: vscode.ChatSessionInputState = { + onDidChange: Event.None, + groups: [{ id: REPOSITORY_OPTION_ID, name: 'Folder', description: '', items: [], - selected: { id: URI.file('/non-git').fsPath, name: 'non-git' }, - }, - ], - }; + selected: { id: mruUri1.fsPath, name: 'repo-a' }, + }], + }; - await builder.handleInputStateChange(state); + const groups = await builder.provideChatSessionProviderOptionGroups(previousState, mruUri2 as any); + const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); + expect(repoGroup!.selected?.id).toBe(mruUri2.fsPath); + }); - const isolationGroup = state.groups.find(g => g.id === ISOLATION_OPTION_ID); - expect(isolationGroup!.selected?.id).toBe(IsolationMode.Workspace); - expect(isolationGroup!.selected?.locked).toBe(true); - }); + it('shows branch dropdown in welcome view when first MRU item is a git repo', async () => { + workspaceService = new NullWorkspaceService([]); + await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true); + await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); + await context.globalState.update('github.copilot.cli.lastUsedIsolationOption', IsolationMode.Worktree); + builder = new SessionOptionGroupBuilder( + gitService, configurationService, context, workspaceService, + folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + ); + const mruUri = URI.file('/recent-repo'); + folderMruService.getRecentlyUsedFolders.mockResolvedValue([ + { folder: mruUri, repository: mruUri, lastAccessed: Date.now() }, + ]); + const repo = makeRepo(mruUri.fsPath); + gitService.getRepository.mockResolvedValue(repo); + gitService.getRefs.mockResolvedValue([makeRef('main'), makeRef('develop')]); + + const groups = await builder.provideChatSessionProviderOptionGroups(undefined); + const branchGroup = groups.find(g => g.id === BRANCH_OPTION_ID); + expect(branchGroup).toBeDefined(); + expect(branchGroup!.items.length).toBe(2); + }); - it('unlocks isolation when selected folder is a git repo', async () => { - await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); - gitService.getRepository.mockResolvedValue(makeRepo('/workspace')); + it('selects no repo in welcome view when MRU is empty', async () => { + workspaceService = new NullWorkspaceService([]); + builder = new SessionOptionGroupBuilder( + gitService, configurationService, context, workspaceService, + folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + ); + folderMruService.getRecentlyUsedFolders.mockResolvedValue([]); + + const groups = await builder.provideChatSessionProviderOptionGroups(undefined); + const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); + expect(repoGroup).toBeDefined(); + expect(repoGroup!.items).toHaveLength(0); + expect(repoGroup!.selected).toBeUndefined(); + }); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [ - { - id: ISOLATION_OPTION_ID, - name: 'Isolation', - description: '', - items: [ - { id: IsolationMode.Workspace, name: 'Workspace', locked: true }, - { id: IsolationMode.Worktree, name: 'Worktree', locked: true }, - ], - selected: { id: IsolationMode.Workspace, name: 'Workspace', locked: true }, - }, - { + it('falls back to first item when previous selection is no longer in welcome view MRU', async () => { + workspaceService = new NullWorkspaceService([]); + builder = new SessionOptionGroupBuilder( + gitService, configurationService, context, workspaceService, + folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + ); + const currentUri = URI.file('/current-repo'); + folderMruService.getRecentlyUsedFolders.mockResolvedValue([ + { folder: currentUri, repository: currentUri, lastAccessed: Date.now() }, + ]); + + const previousState: vscode.ChatSessionInputState = { + onDidChange: Event.None, + groups: [{ id: REPOSITORY_OPTION_ID, name: 'Folder', description: '', items: [], - selected: { id: URI.file('/workspace').fsPath, name: 'workspace' }, - }, - ], - }; + selected: { id: URI.file('/removed-repo').fsPath, name: 'removed-repo' }, + }], + }; - await builder.handleInputStateChange(state); + const groups = await builder.provideChatSessionProviderOptionGroups(previousState); + const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); + expect(repoGroup!.selected?.id).toBe(currentUri.fsPath); + }); - const isolationGroup = state.groups.find(g => g.id === ISOLATION_OPTION_ID); - expect(isolationGroup!.selected?.locked).toBeUndefined(); - expect(isolationGroup!.items.every(i => !('locked' in i))).toBe(true); - }); - }); + it('adds new folder (git repo) to top of items in welcome view', async () => { + workspaceService = new NullWorkspaceService([]); + builder = new SessionOptionGroupBuilder( + gitService, configurationService, context, workspaceService, + folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + ); + const mruUri = URI.file('/existing-repo'); + folderMruService.getRecentlyUsedFolders.mockResolvedValue([ + { folder: mruUri, repository: mruUri, lastAccessed: Date.now() }, + ]); + const newFolderUri = URI.file('/new-git-folder'); + const newRepo = makeRepo(newFolderUri.fsPath); + gitService.getRepository.mockResolvedValue(newRepo); + + const previousState: vscode.ChatSessionInputState = { + onDidChange: Event.None, + groups: [], + }; + builder.setNewFolderForInputState(previousState, newFolderUri as any); - describe('buildExistingSessionInputStateGroups', () => { - it('returns locked groups for existing session', async () => { - folderRepositoryManager.getFolderRepository.mockResolvedValue({ - folder: URI.file('/workspace'), - repository: URI.file('/workspace'), - worktree: undefined, - worktreeProperties: undefined, - trusted: true, - } as any); - worktreeService.getWorktreeProperties.mockResolvedValue(undefined); - - const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' }); - const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None); - - const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); - expect(repoGroup).toBeDefined(); - expect(repoGroup!.selected?.locked).toBe(true); - }); + const groups = await builder.provideChatSessionProviderOptionGroups(previousState); + const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); + expect(repoGroup).toBeDefined(); + expect(repoGroup!.items[0].id).toBe(newFolderUri.fsPath); + }); - it('includes worktree branch for worktree sessions', async () => { - const worktreeProps: ChatSessionWorktreeProperties = { - version: 2, - baseCommit: 'abc', - baseBranchName: 'main', - branchName: 'copilot/feature', - repositoryPath: '/repo', - worktreePath: '/wt', - }; - folderRepositoryManager.getFolderRepository.mockResolvedValue({ - folder: URI.file('/repo'), - repository: URI.file('/repo'), - worktree: undefined, - worktreeProperties: worktreeProps, - trusted: true, - } as any); - worktreeService.getWorktreeProperties.mockResolvedValue(worktreeProps); - - const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' }); - const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None); - - const branchGroup = groups.find(g => g.id === BRANCH_OPTION_ID); - expect(branchGroup).toBeDefined(); - expect(branchGroup!.selected?.id).toBe('copilot/feature'); - expect(branchGroup!.selected?.locked).toBe(true); - }); + it('adds new folder (non-git) to top of items in welcome view', async () => { + workspaceService = new NullWorkspaceService([]); + builder = new SessionOptionGroupBuilder( + gitService, configurationService, context, workspaceService, + folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + ); + const mruUri = URI.file('/existing-repo'); + folderMruService.getRecentlyUsedFolders.mockResolvedValue([ + { folder: mruUri, repository: mruUri, lastAccessed: Date.now() }, + ]); + const newFolderUri = URI.file('/new-plain-folder'); + gitService.getRepository.mockResolvedValue(undefined); + + const previousState: vscode.ChatSessionInputState = { + onDidChange: Event.None, + groups: [], + }; + builder.setNewFolderForInputState(previousState, newFolderUri as any); - it('includes repository branch for non-worktree sessions', async () => { - folderRepositoryManager.getFolderRepository.mockResolvedValue({ - folder: URI.file('/workspace'), - repository: URI.file('/workspace'), - repositoryProperties: { - repositoryPath: '/workspace', - branchName: 'main', - baseBranchName: 'origin/main', - }, - trusted: true, - } as any); - worktreeService.getWorktreeProperties.mockResolvedValue(undefined); - - const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' }); - const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None); - - const branchGroup = groups.find(g => g.id === BRANCH_OPTION_ID); - expect(branchGroup).toBeDefined(); - expect(branchGroup!.selected?.id).toBe('main'); - expect(branchGroup!.selected?.locked).toBe(true); - expect(branchGroup!.when).toBeUndefined(); - }); + const groups = await builder.provideChatSessionProviderOptionGroups(previousState); + const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); + expect(repoGroup).toBeDefined(); + expect(repoGroup!.items[0].id).toBe(newFolderUri.fsPath); + }); - it('includes isolation group when feature is enabled and session is worktree', async () => { - await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); - const worktreeProps: ChatSessionWorktreeProperties = { - version: 2, - baseCommit: 'abc', - baseBranchName: 'main', - branchName: 'copilot/feature', - repositoryPath: '/repo', - worktreePath: '/wt', - }; - folderRepositoryManager.getFolderRepository.mockResolvedValue({ - folder: URI.file('/repo'), - repository: URI.file('/repo'), - trusted: true, - } as any); - worktreeService.getWorktreeProperties.mockResolvedValue(worktreeProps); - - const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' }); - const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None); - - const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID); - expect(isolationGroup).toBeDefined(); - expect(isolationGroup!.selected?.id).toBe(IsolationMode.Worktree); - expect(isolationGroup!.selected?.locked).toBe(true); - }); + it('deduplicates new folder if already in MRU list', async () => { + workspaceService = new NullWorkspaceService([]); + builder = new SessionOptionGroupBuilder( + gitService, configurationService, context, workspaceService, + folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + ); + const sharedUri = URI.file('/shared-repo'); + folderMruService.getRecentlyUsedFolders.mockResolvedValue([ + { folder: sharedUri, repository: sharedUri, lastAccessed: Date.now() }, + ]); + const newRepo = makeRepo(sharedUri.fsPath); + gitService.getRepository.mockResolvedValue(newRepo); + + const previousState: vscode.ChatSessionInputState = { + onDidChange: Event.None, + groups: [], + }; + builder.setNewFolderForInputState(previousState, sharedUri as any); + + const groups = await builder.provideChatSessionProviderOptionGroups(previousState); + const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); + expect(repoGroup).toBeDefined(); + // Should not have duplicates + const matchingItems = repoGroup!.items.filter(i => i.id === sharedUri.fsPath); + expect(matchingItems).toHaveLength(1); + // And it should be at the top + expect(repoGroup!.items[0].id).toBe(sharedUri.fsPath); + }); - it('shows Workspace isolation for non-worktree sessions', async () => { - await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); - folderRepositoryManager.getFolderRepository.mockResolvedValue({ - folder: URI.file('/workspace'), - repository: URI.file('/workspace'), - trusted: true, - } as any); - worktreeService.getWorktreeProperties.mockResolvedValue(undefined); + it('does not add new folder when no previousInputState', async () => { + workspaceService = new NullWorkspaceService([]); + builder = new SessionOptionGroupBuilder( + gitService, configurationService, context, workspaceService, + folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + ); + folderMruService.getRecentlyUsedFolders.mockResolvedValue([]); + + const groups = await builder.provideChatSessionProviderOptionGroups(undefined); + const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); + expect(repoGroup!.items).toHaveLength(0); + }); + }); - const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' }); - const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None); + describe('handleInputStateChange', () => { + it('rebuilds branch group when repo changes', async () => { + await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true); + await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); + const repo = makeRepo('/new-repo'); + gitService.getRepository.mockResolvedValue(repo); + gitService.getRefs.mockResolvedValue([makeRef('main'), makeRef('develop')]); - const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID); - expect(isolationGroup!.selected?.id).toBe(IsolationMode.Workspace); - }); + const state: vscode.ChatSessionInputState = { + onDidChange: Event.None, + groups: [ + { + id: ISOLATION_OPTION_ID, + name: 'Isolation', + description: '', + items: [], + selected: { id: IsolationMode.Worktree, name: 'Worktree' }, + }, + { + id: REPOSITORY_OPTION_ID, + name: 'Folder', + description: '', + items: [], + selected: { id: URI.file('/new-repo').fsPath, name: 'new-repo' }, + }, + { + id: BRANCH_OPTION_ID, + name: 'Branch', + description: '', + items: [{ id: 'old-branch', name: 'old-branch' }], + selected: { id: 'old-branch', name: 'old-branch' }, + }, + ], + }; - it('omits isolation group when feature is disabled for existing session', async () => { - await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false); - folderRepositoryManager.getFolderRepository.mockResolvedValue({ - folder: URI.file('/workspace'), - repository: URI.file('/workspace'), - trusted: true, - } as any); - worktreeService.getWorktreeProperties.mockResolvedValue(undefined); + await builder.handleInputStateChange(state); + const branchGroup = state.groups.find(g => g.id === BRANCH_OPTION_ID); + expect(branchGroup).toBeDefined(); + expect(branchGroup!.items.length).toBe(2); + }); - const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' }); - const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None); + it('removes branch group when repo has no branches', async () => { + await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true); + gitService.getRepository.mockResolvedValue(makeRepo('/repo')); + gitService.getRefs.mockResolvedValue([]); - expect(groups.find(g => g.id === ISOLATION_OPTION_ID)).toBeUndefined(); - }); + const state: vscode.ChatSessionInputState = { + onDidChange: Event.None, + groups: [ + { + id: REPOSITORY_OPTION_ID, + name: 'Folder', + description: '', + items: [], + selected: { id: URI.file('/repo').fsPath, name: 'repo' }, + }, + { + id: BRANCH_OPTION_ID, + name: 'Branch', + description: '', + items: [{ id: 'old', name: 'old' }], + }, + ], + }; - it('omits branch group when session has no branch name', async () => { - folderRepositoryManager.getFolderRepository.mockResolvedValue({ - folder: URI.file('/workspace'), - repository: undefined, - repositoryProperties: undefined, - trusted: true, - } as any); - worktreeService.getWorktreeProperties.mockResolvedValue(undefined); + await builder.handleInputStateChange(state); + const branchGroup = state.groups.find(g => g.id === BRANCH_OPTION_ID); + expect(branchGroup).toBeUndefined(); + }); - const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' }); - const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None); + it('does not add branch group when branch feature is disabled', async () => { + await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); - expect(groups.find(g => g.id === BRANCH_OPTION_ID)).toBeUndefined(); - }); - }); + const state: vscode.ChatSessionInputState = { + onDidChange: Event.None, + groups: [{ + id: REPOSITORY_OPTION_ID, + name: 'Folder', + description: '', + items: [], + selected: { id: URI.file('/repo').fsPath, name: 'repo' }, + }], + }; - describe('updateInputStateAfterFolderSelection', () => { - it('updates repo group selected item', async () => { - await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); - const repo = makeRepo('/new-folder'); - gitService.getRepository.mockResolvedValue(repo); + await builder.handleInputStateChange(state); + expect(state.groups.find(g => g.id === BRANCH_OPTION_ID)).toBeUndefined(); + }); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [{ - id: REPOSITORY_OPTION_ID, - name: 'Folder', - description: '', - items: [{ id: URI.file('/old-folder').fsPath, name: 'old-folder' }], - selected: { id: URI.file('/old-folder').fsPath, name: 'old-folder' }, - }], - }; + it('persists isolation selection to global state', async () => { + await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); + gitService.getRepository.mockResolvedValue(makeRepo('/workspace')); - await builder.updateInputStateAfterFolderSelection(state, URI.file('/new-folder') as any); + const state: vscode.ChatSessionInputState = { + onDidChange: Event.None, + groups: [{ + id: ISOLATION_OPTION_ID, + name: 'Isolation', + description: '', + items: [], + selected: { id: IsolationMode.Worktree, name: 'Worktree' }, + }], + }; - const repoGroup = state.groups.find(g => g.id === REPOSITORY_OPTION_ID); - expect(repoGroup!.selected!.id).toBe(URI.file('/new-folder').fsPath); - }); + await builder.handleInputStateChange(state); + expect(context.globalState.get('github.copilot.cli.lastUsedIsolationOption')).toBe(IsolationMode.Worktree); + }); - it('rebuilds branch group when new folder is a git repo', async () => { - await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true); - const repo = makeRepo('/new-repo'); - gitService.getRepository.mockResolvedValue(repo); - gitService.getRefs.mockResolvedValue([makeRef('main'), makeRef('feature')]); + it('forces workspace isolation when selected folder is not a git repo', async () => { + await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); + gitService.getRepository.mockResolvedValue(undefined); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [{ - id: REPOSITORY_OPTION_ID, - name: 'Folder', - description: '', - items: [], - }], - }; + const state: vscode.ChatSessionInputState = { + onDidChange: Event.None, + groups: [ + { + id: ISOLATION_OPTION_ID, + name: 'Isolation', + description: '', + items: [ + { id: IsolationMode.Workspace, name: 'Workspace' }, + { id: IsolationMode.Worktree, name: 'Worktree' }, + ], + selected: { id: IsolationMode.Worktree, name: 'Worktree' }, + }, + { + id: REPOSITORY_OPTION_ID, + name: 'Folder', + description: '', + items: [], + selected: { id: URI.file('/non-git').fsPath, name: 'non-git' }, + }, + ], + }; - await builder.updateInputStateAfterFolderSelection(state, URI.file('/new-repo') as any); + await builder.handleInputStateChange(state); - const branchGroup = state.groups.find(g => g.id === BRANCH_OPTION_ID); - expect(branchGroup).toBeDefined(); - expect(branchGroup!.items.length).toBe(2); - }); + const isolationGroup = state.groups.find(g => g.id === ISOLATION_OPTION_ID); + expect(isolationGroup!.selected?.id).toBe(IsolationMode.Workspace); + expect(isolationGroup!.selected?.locked).toBe(true); + }); - it('removes branch group when new folder is not a git repo', async () => { - await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true); - gitService.getRepository.mockResolvedValue(undefined); + it('unlocks isolation when selected folder is a git repo', async () => { + await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); + gitService.getRepository.mockResolvedValue(makeRepo('/workspace')); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [ - { - id: REPOSITORY_OPTION_ID, - name: 'Folder', - description: '', - items: [], - }, - { - id: BRANCH_OPTION_ID, - name: 'Branch', - description: '', - items: [{ id: 'main', name: 'main' }], - }, - ], - }; + const state: vscode.ChatSessionInputState = { + onDidChange: Event.None, + groups: [ + { + id: ISOLATION_OPTION_ID, + name: 'Isolation', + description: '', + items: [ + { id: IsolationMode.Workspace, name: 'Workspace', locked: true }, + { id: IsolationMode.Worktree, name: 'Worktree', locked: true }, + ], + selected: { id: IsolationMode.Workspace, name: 'Workspace', locked: true }, + }, + { + id: REPOSITORY_OPTION_ID, + name: 'Folder', + description: '', + items: [], + selected: { id: URI.file('/workspace').fsPath, name: 'workspace' }, + }, + ], + }; - await builder.updateInputStateAfterFolderSelection(state, URI.file('/non-git-folder') as any); + await builder.handleInputStateChange(state); - const branchGroup = state.groups.find(g => g.id === BRANCH_OPTION_ID); - expect(branchGroup).toBeUndefined(); + const isolationGroup = state.groups.find(g => g.id === ISOLATION_OPTION_ID); + expect(isolationGroup!.selected?.locked).toBeUndefined(); + expect(isolationGroup!.items.every(i => !('locked' in i))).toBe(true); + }); }); - it('adds new folder to items if not already present', async () => { - await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); - gitService.getRepository.mockResolvedValue(undefined); + describe('buildExistingSessionInputStateGroups', () => { + it('returns locked groups for existing session', async () => { + folderRepositoryManager.getFolderRepository.mockResolvedValue({ + folder: URI.file('/workspace'), + repository: URI.file('/workspace'), + worktree: undefined, + worktreeProperties: undefined, + trusted: true, + } as any); + worktreeService.getWorktreeProperties.mockResolvedValue(undefined); + + const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' }); + const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None); + + const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); + expect(repoGroup).toBeDefined(); + expect(repoGroup!.selected?.locked).toBe(true); + }); - const existingItem = { id: URI.file('/old').fsPath, name: 'old' }; - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [{ - id: REPOSITORY_OPTION_ID, - name: 'Folder', - description: '', - items: [existingItem], - }], - }; + it('includes worktree branch for worktree sessions', async () => { + const worktreeProps: ChatSessionWorktreeProperties = { + version: 2, + baseCommit: 'abc', + baseBranchName: 'main', + branchName: 'copilot/feature', + repositoryPath: '/repo', + worktreePath: '/wt', + }; + folderRepositoryManager.getFolderRepository.mockResolvedValue({ + folder: URI.file('/repo'), + repository: URI.file('/repo'), + worktree: undefined, + worktreeProperties: worktreeProps, + trusted: true, + } as any); + worktreeService.getWorktreeProperties.mockResolvedValue(worktreeProps); + + const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' }); + const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None); + + const branchGroup = groups.find(g => g.id === BRANCH_OPTION_ID); + expect(branchGroup).toBeDefined(); + expect(branchGroup!.selected?.id).toBe('copilot/feature'); + expect(branchGroup!.selected?.locked).toBe(true); + }); - await builder.updateInputStateAfterFolderSelection(state, URI.file('/new-folder') as any); + it('includes repository branch for non-worktree sessions', async () => { + folderRepositoryManager.getFolderRepository.mockResolvedValue({ + folder: URI.file('/workspace'), + repository: URI.file('/workspace'), + repositoryProperties: { + repositoryPath: '/workspace', + branchName: 'main', + baseBranchName: 'origin/main', + }, + trusted: true, + } as any); + worktreeService.getWorktreeProperties.mockResolvedValue(undefined); + + const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' }); + const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None); + + const branchGroup = groups.find(g => g.id === BRANCH_OPTION_ID); + expect(branchGroup).toBeDefined(); + expect(branchGroup!.selected?.id).toBe('main'); + expect(branchGroup!.selected?.locked).toBe(true); + expect(branchGroup!.when).toBeUndefined(); + }); - const repoGroup = state.groups.find(g => g.id === REPOSITORY_OPTION_ID); - expect(repoGroup!.items.length).toBe(2); - }); + it('includes isolation group when feature is enabled and session is worktree', async () => { + await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); + const worktreeProps: ChatSessionWorktreeProperties = { + version: 2, + baseCommit: 'abc', + baseBranchName: 'main', + branchName: 'copilot/feature', + repositoryPath: '/repo', + worktreePath: '/wt', + }; + folderRepositoryManager.getFolderRepository.mockResolvedValue({ + folder: URI.file('/repo'), + repository: URI.file('/repo'), + trusted: true, + } as any); + worktreeService.getWorktreeProperties.mockResolvedValue(worktreeProps); + + const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' }); + const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None); + + const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID); + expect(isolationGroup).toBeDefined(); + expect(isolationGroup!.selected?.id).toBe(IsolationMode.Worktree); + expect(isolationGroup!.selected?.locked).toBe(true); + }); - it('treats untrusted folder as non-git and updates state', async () => { - await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); - gitService.getRepository.mockResolvedValue(undefined); + it('shows Workspace isolation for non-worktree sessions', async () => { + await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); + folderRepositoryManager.getFolderRepository.mockResolvedValue({ + folder: URI.file('/workspace'), + repository: URI.file('/workspace'), + trusted: true, + } as any); + worktreeService.getWorktreeProperties.mockResolvedValue(undefined); - // Override isResourceTrusted to return false for this test - const origWorkspace = (vscodeShim as Record).workspace; - (vscodeShim as Record).workspace = { - ...(origWorkspace as object), - isResourceTrusted: async () => false, - }; - try { - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [{ - id: REPOSITORY_OPTION_ID, - name: 'Folder', - description: '', - items: [{ id: URI.file('/old').fsPath, name: 'old' }], - selected: { id: URI.file('/old').fsPath, name: 'old' }, - }], - }; + const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' }); + const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None); - await builder.updateInputStateAfterFolderSelection(state, URI.file('/untrusted') as any); + const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID); + expect(isolationGroup!.selected?.id).toBe(IsolationMode.Workspace); + }); - // Dropdown updates to show the new folder, treated as non-git - const repoGroup = state.groups.find(g => g.id === REPOSITORY_OPTION_ID); - expect(repoGroup!.selected!.id).toBe(URI.file('/untrusted').fsPath); - // getRepository should not have been called - expect(gitService.getRepository).not.toHaveBeenCalled(); - } finally { - (vscodeShim as Record).workspace = origWorkspace; - } - }); + it('omits isolation group when feature is disabled for existing session', async () => { + await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false); + folderRepositoryManager.getFolderRepository.mockResolvedValue({ + folder: URI.file('/workspace'), + repository: URI.file('/workspace'), + trusted: true, + } as any); + worktreeService.getWorktreeProperties.mockResolvedValue(undefined); - it('tracks MRU in welcome view when updating folder selection', async () => { - // Use empty workspace to trigger welcome view - workspaceService = new NullWorkspaceService([]); - builder = new SessionOptionGroupBuilder( - gitService, configurationService, context, workspaceService, - folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, - ); - await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); - const repo = makeRepo('/my-repo'); - gitService.getRepository.mockResolvedValue(repo); + const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' }); + const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [{ - id: REPOSITORY_OPTION_ID, - name: 'Folder', - description: '', - items: [], - }], - }; + expect(groups.find(g => g.id === ISOLATION_OPTION_ID)).toBeUndefined(); + }); + + it('omits branch group when session has no branch name', async () => { + folderRepositoryManager.getFolderRepository.mockResolvedValue({ + folder: URI.file('/workspace'), + repository: undefined, + repositoryProperties: undefined, + trusted: true, + } as any); + worktreeService.getWorktreeProperties.mockResolvedValue(undefined); - await builder.updateInputStateAfterFolderSelection(state, URI.file('/my-repo') as any); + const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' }); + const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None); - // Verify the last used folder appears in subsequent option group builds - folderMruService.getRecentlyUsedFolders.mockResolvedValue([]); - const groups = await builder.provideChatSessionProviderOptionGroups(undefined); - const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); - expect(repoGroup!.items.find(i => i.id === URI.file('/my-repo').fsPath)).toBeDefined(); + expect(groups.find(g => g.id === BRANCH_OPTION_ID)).toBeUndefined(); + }); }); - }); - describe('rebuildInputState', () => { - it('adds folder dropdown when a second workspace folder appears', async () => { - // Start with single workspace folder — no folder dropdown - gitService.repositories = [makeRepo('/workspace')]; - gitService.getRepository.mockResolvedValue(makeRepo('/workspace')); - await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); - await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false); + describe('rebuildInputState', () => { + it('adds folder dropdown when a second workspace folder appears', async () => { + // Start with single workspace folder — no folder dropdown + gitService.repositories = [makeRepo('/workspace')]; + gitService.getRepository.mockResolvedValue(makeRepo('/workspace')); + await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); + await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false); - const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined); - expect(initialGroups.find(g => g.id === REPOSITORY_OPTION_ID)).toBeUndefined(); + const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined); + expect(initialGroups.find(g => g.id === REPOSITORY_OPTION_ID)).toBeUndefined(); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: initialGroups, - }; + const state: vscode.ChatSessionInputState = { + onDidChange: Event.None, + groups: initialGroups, + }; - // Simulate adding a second workspace folder - workspaceService = new NullWorkspaceService([URI.file('/workspace'), URI.file('/workspace2')]); - builder = new SessionOptionGroupBuilder( - gitService, configurationService, context, workspaceService, - folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, - ); - gitService.repositories = [makeRepo('/workspace'), makeRepo('/workspace2')]; + // Simulate adding a second workspace folder + workspaceService = new NullWorkspaceService([URI.file('/workspace'), URI.file('/workspace2')]); + builder = new SessionOptionGroupBuilder( + gitService, configurationService, context, workspaceService, + folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + ); + gitService.repositories = [makeRepo('/workspace'), makeRepo('/workspace2')]; - await builder.rebuildInputState(state); + await builder.rebuildInputState(state); - const repoGroup = state.groups.find(g => g.id === REPOSITORY_OPTION_ID); - expect(repoGroup).toBeDefined(); - expect(repoGroup!.items.length).toBe(2); - }); + const repoGroup = state.groups.find(g => g.id === REPOSITORY_OPTION_ID); + expect(repoGroup).toBeDefined(); + expect(repoGroup!.items.length).toBe(2); + }); - it('removes folder dropdown when going from two workspace folders to one', async () => { - // Start with two workspace folders — folder dropdown shown - workspaceService = new NullWorkspaceService([URI.file('/repo1'), URI.file('/repo2')]); - builder = new SessionOptionGroupBuilder( - gitService, configurationService, context, workspaceService, - folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, - ); - gitService.repositories = [makeRepo('/repo1'), makeRepo('/repo2')]; - gitService.getRepository.mockResolvedValue(makeRepo('/repo1')); - await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); - await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false); + it('removes folder dropdown when going from two workspace folders to one', async () => { + // Start with two workspace folders — folder dropdown shown + workspaceService = new NullWorkspaceService([URI.file('/repo1'), URI.file('/repo2')]); + builder = new SessionOptionGroupBuilder( + gitService, configurationService, context, workspaceService, + folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + ); + gitService.repositories = [makeRepo('/repo1'), makeRepo('/repo2')]; + gitService.getRepository.mockResolvedValue(makeRepo('/repo1')); + await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); + await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false); + + const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined); + expect(initialGroups.find(g => g.id === REPOSITORY_OPTION_ID)).toBeDefined(); - const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined); - expect(initialGroups.find(g => g.id === REPOSITORY_OPTION_ID)).toBeDefined(); + const state: vscode.ChatSessionInputState = { + onDidChange: Event.None, + groups: initialGroups, + }; - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: initialGroups, - }; + // Simulate removing a workspace folder + workspaceService = new NullWorkspaceService([URI.file('/repo1')]); + builder = new SessionOptionGroupBuilder( + gitService, configurationService, context, workspaceService, + folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + ); + gitService.repositories = [makeRepo('/repo1')]; - // Simulate removing a workspace folder - workspaceService = new NullWorkspaceService([URI.file('/repo1')]); - builder = new SessionOptionGroupBuilder( - gitService, configurationService, context, workspaceService, - folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, - ); - gitService.repositories = [makeRepo('/repo1')]; + await builder.rebuildInputState(state); - await builder.rebuildInputState(state); + expect(state.groups.find(g => g.id === REPOSITORY_OPTION_ID)).toBeUndefined(); + }); - expect(state.groups.find(g => g.id === REPOSITORY_OPTION_ID)).toBeUndefined(); - }); + it('adds branch dropdown after git init in single folder workspace', async () => { + // Start with non-git folder — no branch dropdown + gitService.repositories = []; + gitService.getRepository.mockResolvedValue(undefined); + await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true); + await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false); - it('adds branch dropdown after git init in single folder workspace', async () => { - // Start with non-git folder — no branch dropdown - gitService.repositories = []; - gitService.getRepository.mockResolvedValue(undefined); - await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true); - await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false); + const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined); + expect(initialGroups.find(g => g.id === BRANCH_OPTION_ID)).toBeUndefined(); - const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined); - expect(initialGroups.find(g => g.id === BRANCH_OPTION_ID)).toBeUndefined(); + const state: vscode.ChatSessionInputState = { + onDidChange: Event.None, + groups: initialGroups, + }; - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: initialGroups, - }; + // Simulate git init — repo now discovered + const repo = makeRepo('/workspace'); + gitService.repositories = [repo]; + gitService.getRepository.mockResolvedValue(repo); + gitService.getRefs.mockResolvedValue([makeRef('main')]); - // Simulate git init — repo now discovered - const repo = makeRepo('/workspace'); - gitService.repositories = [repo]; - gitService.getRepository.mockResolvedValue(repo); - gitService.getRefs.mockResolvedValue([makeRef('main')]); + await builder.rebuildInputState(state); - await builder.rebuildInputState(state); + const branchGroup = state.groups.find(g => g.id === BRANCH_OPTION_ID); + expect(branchGroup).toBeDefined(); + expect(branchGroup!.items.length).toBe(1); + expect(branchGroup!.items[0].id).toBe('main'); + }); - const branchGroup = state.groups.find(g => g.id === BRANCH_OPTION_ID); - expect(branchGroup).toBeDefined(); - expect(branchGroup!.items.length).toBe(1); - expect(branchGroup!.items[0].id).toBe('main'); - }); + it('preserves selected folder across rebuild', async () => { + workspaceService = new NullWorkspaceService([URI.file('/repo1'), URI.file('/repo2')]); + builder = new SessionOptionGroupBuilder( + gitService, configurationService, context, workspaceService, + folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + ); + gitService.repositories = [makeRepo('/repo1'), makeRepo('/repo2')]; + gitService.getRepository.mockResolvedValue(makeRepo('/repo2')); + await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); + await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false); + + // User selects /repo2 + const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined); + const repoGroupIndex = initialGroups.findIndex(g => g.id === REPOSITORY_OPTION_ID); + const repoGroup = initialGroups[repoGroupIndex]; + initialGroups[repoGroupIndex] = { ...repoGroup, selected: repoGroup.items.find(i => i.id === URI.file('/repo2').fsPath) }; - it('preserves selected folder across rebuild', async () => { - workspaceService = new NullWorkspaceService([URI.file('/repo1'), URI.file('/repo2')]); - builder = new SessionOptionGroupBuilder( - gitService, configurationService, context, workspaceService, - folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, - ); - gitService.repositories = [makeRepo('/repo1'), makeRepo('/repo2')]; - gitService.getRepository.mockResolvedValue(makeRepo('/repo2')); - await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); - await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false); - - // User selects /repo2 - const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined); - const repoGroupIndex = initialGroups.findIndex(g => g.id === REPOSITORY_OPTION_ID); - const repoGroup = initialGroups[repoGroupIndex]; - initialGroups[repoGroupIndex] = { ...repoGroup, selected: repoGroup.items.find(i => i.id === URI.file('/repo2').fsPath) }; - - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: initialGroups, - }; + const state: vscode.ChatSessionInputState = { + onDidChange: Event.None, + groups: initialGroups, + }; - // Add a third folder - workspaceService = new NullWorkspaceService([URI.file('/repo1'), URI.file('/repo2'), URI.file('/repo3')]); - builder = new SessionOptionGroupBuilder( - gitService, configurationService, context, workspaceService, - folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, - ); - gitService.repositories = [makeRepo('/repo1'), makeRepo('/repo2'), makeRepo('/repo3')]; + // Add a third folder + workspaceService = new NullWorkspaceService([URI.file('/repo1'), URI.file('/repo2'), URI.file('/repo3')]); + builder = new SessionOptionGroupBuilder( + gitService, configurationService, context, workspaceService, + folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + ); + gitService.repositories = [makeRepo('/repo1'), makeRepo('/repo2'), makeRepo('/repo3')]; - await builder.rebuildInputState(state); + await builder.rebuildInputState(state); - const newRepoGroup = state.groups.find(g => g.id === REPOSITORY_OPTION_ID)!; - expect(newRepoGroup.items.length).toBe(3); - // Previous selection preserved - expect(newRepoGroup.selected?.id).toBe(URI.file('/repo2').fsPath); - }); + const newRepoGroup = state.groups.find(g => g.id === REPOSITORY_OPTION_ID)!; + expect(newRepoGroup.items.length).toBe(3); + // Previous selection preserved + expect(newRepoGroup.selected?.id).toBe(URI.file('/repo2').fsPath); + }); - it('unlocks isolation after git init for non-git folder', async () => { - // Start with non-git folder — isolation locked - gitService.repositories = []; - gitService.getRepository.mockResolvedValue(undefined); - await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); - await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); + it('unlocks isolation after git init for non-git folder', async () => { + // Start with non-git folder — isolation locked + gitService.repositories = []; + gitService.getRepository.mockResolvedValue(undefined); + await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); + await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); - const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined); - const isolationGroup = initialGroups.find(g => g.id === ISOLATION_OPTION_ID); - expect(isolationGroup).toBeDefined(); - // Should be locked to workspace for non-git - expect(isolationGroup!.selected?.locked).toBe(true); + const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined); + const isolationGroup = initialGroups.find(g => g.id === ISOLATION_OPTION_ID); + expect(isolationGroup).toBeDefined(); + // Should be locked to workspace for non-git + expect(isolationGroup!.selected?.locked).toBe(true); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: initialGroups, - }; + const state: vscode.ChatSessionInputState = { + onDidChange: Event.None, + groups: initialGroups, + }; - // Simulate git init - const repo = makeRepo('/workspace'); - gitService.repositories = [repo]; - gitService.getRepository.mockResolvedValue(repo); + // Simulate git init + const repo = makeRepo('/workspace'); + gitService.repositories = [repo]; + gitService.getRepository.mockResolvedValue(repo); - await builder.rebuildInputState(state); + await builder.rebuildInputState(state); - const newIsolationGroup = state.groups.find(g => g.id === ISOLATION_OPTION_ID); - expect(newIsolationGroup).toBeDefined(); - // Should be unlocked after git init - expect(newIsolationGroup!.selected?.locked).toBeUndefined(); + const newIsolationGroup = state.groups.find(g => g.id === ISOLATION_OPTION_ID); + expect(newIsolationGroup).toBeDefined(); + // Should be unlocked after git init + expect(newIsolationGroup!.selected?.locked).toBeUndefined(); + }); }); }); }); diff --git a/extensions/copilot/src/extension/vscode.proposed.chatSessionsProvider.d.ts b/extensions/copilot/src/extension/vscode.proposed.chatSessionsProvider.d.ts index 06d0649b18481..6a394c158b2ae 100644 --- a/extensions/copilot/src/extension/vscode.proposed.chatSessionsProvider.d.ts +++ b/extensions/copilot/src/extension/vscode.proposed.chatSessionsProvider.d.ts @@ -594,8 +594,15 @@ declare module 'vscode' { /** * The initial option selections for the session, provided with the first request. * Contains the options the user selected (or defaults) before the session was created. + * + * @deprecated Use `inputState` instead */ readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string | ChatSessionProviderOptionItem }>; + + /** + * The current input state of the chat session. + */ + readonly inputState: ChatSessionInputState; } export interface ChatSessionCapabilities { @@ -692,6 +699,8 @@ declare module 'vscode' { * * These commands will be displayed at the bottom of the group. * + * For extensions using the legacy `commands` API, these commands are passed the sessionResource as the first argument. + * * For extensions that use the new `provideChatSessionInputState` API, these commands are passed a context object * `{ inputState: ChatSessionInputState; sessionResource: Uri | undefined }` that they can use to determine which session and options they are being invoked for. */ From a4f5119796984330f866b3dd6b2ce0694ff5c814 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Thu, 9 Apr 2026 21:56:36 -0700 Subject: [PATCH 03/15] agentHost: subagents (#308592) * agentHost: subagents * agentHost: remove _meta.parentToolCallId dependency, subscribe to child sessions instead Inner tool calls from subagent sessions are no longer stored in the parent turn with _meta.parentToolCallId. Instead: - Server: _buildTurnsFromMessages skips inner events (parentToolCallId), _restoreSubagentSession builds child session turns from raw messages - Client: _enrichHistoryWithSubagentCalls subscribes to child sessions during history restore, injects serialized inner tool calls with subAgentInvocationId set Also fixes hygiene: replace 'in' operator with hasKey in agentSideEffects.test.ts, exclude .jsonl from copyright filter. * fix: set terminalCommandUri from terminal content blocks in stateToProgressAdapter completedToolCallToSerialized and toolCallStateToInvocation were not detecting terminal tools via ToolResultContentType.Terminal content blocks or setting terminalCommandUri/terminalToolSessionId, causing 6 test failures in CI. * comments Co-authored-by: Copilot * revert diff --------- Co-authored-by: Copilot --- build/filters.ts | 1 + .../platform/agentHost/common/agentService.ts | 18 +- .../agentHost/common/state/sessionReducers.ts | 19 ++ .../agentHost/common/state/sessionState.ts | 58 ++++ .../agentHost/node/agentEventMapper.ts | 18 +- .../agentHost/node/agentHostStateManager.ts | 14 + .../platform/agentHost/node/agentService.ts | 274 ++++++++++++++++- .../agentHost/node/agentSideEffects.ts | 276 ++++++++++++++++++ .../agentHost/node/copilot/copilotAgent.ts | 4 +- .../node/copilot/copilotAgentSession.ts | 16 +- .../node/copilot/copilotToolDisplay.ts | 10 +- .../node/copilot/mapSessionEvents.ts | 28 +- .../test/node/agentEventMapper.test.ts | 19 ++ .../test/node/agentHostStateManager.test.ts | 31 +- .../agentHost/test/node/agentService.test.ts | 172 ++++++++++- .../test/node/agentSideEffects.test.ts | 214 +++++++++++++- .../test/node/mapSessionEvents.test.ts | 27 ++ .../platform/agentHost/test/node/mockAgent.ts | 16 +- .../node/test-cases/subagent-session.jsonl | 26 ++ .../agentHost/agentHostSessionHandler.ts | 246 +++++++++++++++- .../agentHost/stateToProgressAdapter.ts | 220 ++++++++++++-- .../chatSubagentContentPart.ts | 18 +- .../chatProgressTypes/chatToolInvocation.ts | 10 + .../stateToProgressAdapter.test.ts | 142 ++++++++- 24 files changed, 1802 insertions(+), 75 deletions(-) create mode 100644 src/vs/platform/agentHost/test/node/test-cases/subagent-session.jsonl diff --git a/build/filters.ts b/build/filters.ts index 9f6e34cf11298..d4ea9c8db730d 100644 --- a/build/filters.ts +++ b/build/filters.ts @@ -162,6 +162,7 @@ export const copyrightFilter = Object.freeze([ '**', '!**/*.desktop', '!**/*.json', + '!**/*.jsonl', '!**/*.html', '!**/*.template', '!**/*.md', diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index efa5bfaf3a79b..dafad724ec647 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -169,8 +169,8 @@ export interface IAgentToolStartEvent extends IAgentProgressEventBase { readonly invocationMessage: string; /** A representative input string for display in the UI (e.g., the shell command). */ readonly toolInput?: string; - /** Hint for the renderer about how to display this tool (e.g., 'terminal' for shell commands). */ - readonly toolKind?: 'terminal'; + /** Hint for the renderer about how to display this tool (e.g., 'terminal' for shell commands, 'subagent' for subagent-spawning tools). */ + readonly toolKind?: 'terminal' | 'subagent'; /** Language identifier for syntax highlighting (e.g., 'shellscript', 'powershell'). Used with toolKind 'terminal'. */ readonly language?: string; /** Serialized JSON of the tool arguments, if available. */ @@ -252,6 +252,15 @@ export interface IAgentUserInputRequestEvent extends IAgentProgressEventBase { readonly request: ISessionInputRequest; } +/** A subagent has been spawned by a tool call. */ +export interface IAgentSubagentStartedEvent extends IAgentProgressEventBase { + readonly type: 'subagent_started'; + readonly toolCallId: string; + readonly agentName: string; + readonly agentDisplayName: string; + readonly agentDescription?: string; +} + export type IAgentProgressEvent = | IAgentDeltaEvent | IAgentMessageEvent @@ -264,7 +273,8 @@ export type IAgentProgressEvent = | IAgentUsageEvent | IAgentReasoningEvent | IAgentSteeringConsumedEvent - | IAgentUserInputRequestEvent; + | IAgentUserInputRequestEvent + | IAgentSubagentStartedEvent; // ---- Session URI helpers ---------------------------------------------------- @@ -328,7 +338,7 @@ export interface IAgent { setPendingMessages?(session: URI, steeringMessage: IPendingMessage | undefined, queuedMessages: readonly IPendingMessage[]): void; /** Retrieve all session events/messages for reconstruction. */ - getSessionMessages(session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]>; + getSessionMessages(session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent | IAgentSubagentStartedEvent)[]>; /** Dispose a session, freeing resources. */ disposeSession(session: URI): Promise; diff --git a/src/vs/platform/agentHost/common/state/sessionReducers.ts b/src/vs/platform/agentHost/common/state/sessionReducers.ts index 5c9142e7a949d..270302de527de 100644 --- a/src/vs/platform/agentHost/common/state/sessionReducers.ts +++ b/src/vs/platform/agentHost/common/state/sessionReducers.ts @@ -8,3 +8,22 @@ // Re-export reducers from the protocol layer export { rootReducer, sessionReducer, softAssertNever, isClientDispatchable } from './protocol/reducers.js'; + +import type { ICompletedToolCall, IToolCallState } from './sessionState.js'; + +/** + * Extracts the VS Code-specific `toolKind` hint from a tool call's `_meta` + * bag. This is not part of the protocol and is injected by the agent adapter + * (e.g. `copilotEventMapper`). + */ +export function getToolKind(tc: IToolCallState | ICompletedToolCall): 'terminal' | 'subagent' | undefined { + return tc._meta?.toolKind as 'terminal' | 'subagent' | undefined; +} + +/** + * Extracts the VS Code-specific `language` hint from a tool call's `_meta` + * bag. Used for syntax-highlighting terminal tool output. + */ +export function getToolLanguage(tc: IToolCallState | ICompletedToolCall): string | undefined { + return tc._meta?.language as string | undefined; +} diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 781a806dcbe33..7bf960d501068 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -23,6 +23,8 @@ import { type IToolCallCompletedState, type IToolCallResult, type IToolCallState, + type IToolResultContent, + type IToolResultSubagentContent, type IToolResultTextContent, type IUserMessage, ITerminalState, @@ -62,6 +64,7 @@ export { type IToolResultEmbeddedResourceContent as IToolResultBinaryContent, type IToolResultContent, type IToolResultFileEditContent, + type IToolResultSubagentContent, type IToolResultTextContent, type ITurn, type IUsageInfo, @@ -166,6 +169,61 @@ export function getToolFileEdits(result: IToolCallResult): IToolResultFileEditCo return edits; } +/** + * Extracts the first subagent content entry from a tool call's `content` array. + * Works with both completed tool call results and running tool call states. + * Returns `undefined` if there are no subagent content parts. + */ +export function getToolSubagentContent(result: { content?: readonly IToolResultContent[] }): IToolResultSubagentContent | undefined { + if (!result.content || result.content.length === 0) { + return undefined; + } + for (const c of result.content) { + if (hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent) { + return c as IToolResultSubagentContent; + } + } + return undefined; +} + +// ---- Subagent URI helpers --------------------------------------------------- + +/** + * Builds a subagent session URI from a parent session URI and tool call ID. + * Convention: `{parentSessionUri}/subagent/{toolCallId}` + */ +export function buildSubagentSessionUri(parentSession: string, toolCallId: string): string { + // Normalize: strip trailing slash from parent to avoid double-slash in URI + const parent = parentSession.endsWith('/') ? parentSession.slice(0, -1) : parentSession; + return `${parent}/subagent/${toolCallId}`; +} + +/** + * Parses a subagent session URI into its parent session URI and tool call ID. + * Returns `undefined` if the URI does not follow the subagent convention. + */ +export function parseSubagentSessionUri(uri: string): { parentSession: string; toolCallId: string } | undefined { + const idx = uri.lastIndexOf('/subagent/'); + if (idx < 0) { + return undefined; + } + const toolCallId = uri.substring(idx + '/subagent/'.length); + if (!toolCallId) { + return undefined; + } + return { + parentSession: uri.substring(0, idx), + toolCallId, + }; +} + +/** + * Returns whether a session URI represents a subagent session. + */ +export function isSubagentSession(uri: string): boolean { + return uri.includes('/subagent/'); +} + // ---- Factory helpers -------------------------------------------------------- export function createRootState(): IRootState { diff --git a/src/vs/platform/agentHost/node/agentEventMapper.ts b/src/vs/platform/agentHost/node/agentEventMapper.ts index 8dcf9d6b4ea59..5fb6cb15390db 100644 --- a/src/vs/platform/agentHost/node/agentEventMapper.ts +++ b/src/vs/platform/agentHost/node/agentEventMapper.ts @@ -92,6 +92,22 @@ export class AgentEventMapper { // We emit both toolCallStart (streaming → created) and toolCallReady // (params complete → running with auto-confirm) as a pair. const e = event as IAgentToolStartEvent; + const meta: Record = { toolKind: e.toolKind, language: e.language }; + + // For subagent tools, extract agent metadata from tool arguments + // so the renderer can display the name/description immediately. + if (e.toolKind === 'subagent' && e.toolArguments) { + try { + const args = JSON.parse(e.toolArguments) as Record; + if (typeof args.description === 'string') { + meta.subagentDescription = args.description; + } + if (typeof args.agentName === 'string') { + meta.subagentAgentName = args.agentName; + } + } catch { /* ignore parse errors */ } + } + const startAction: IToolCallStartAction = { type: ActionType.SessionToolCallStart, session, @@ -99,7 +115,7 @@ export class AgentEventMapper { toolCallId: e.toolCallId, toolName: e.toolName, displayName: e.displayName, - _meta: { toolKind: e.toolKind, language: e.language }, + _meta: meta, }; const readyAction: IToolCallReadyAction = { type: ActionType.SessionToolCallReady, diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 2e8bd24b6a704..d32378a06a3a8 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -67,6 +67,20 @@ export class AgentHostStateManager extends Disposable { return this._serverSeq; } + /** + * Returns all session URIs whose keys start with the given prefix. + * Used to discover subagent sessions for a given parent. + */ + getSessionUrisWithPrefix(prefix: string): string[] { + const result: string[] = []; + for (const key of this._sessionStates.keys()) { + if (key.startsWith(prefix)) { + result.push(key); + } + } + return result; + } + // ---- Snapshots ---------------------------------------------------------- /** diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 9faf1690cb29e..ab1f9d9c74fa3 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -11,12 +11,12 @@ import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCode } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentMessageEvent, IAgentService, IAgentSessionMetadata, IAgentToolCompleteEvent, IAgentToolStartEvent, IAuthenticateParams, IAuthenticateResult } from '../common/agentService.js'; +import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentMessageEvent, IAgentService, IAgentSessionMetadata, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent, IAuthenticateParams, IAuthenticateResult } from '../common/agentService.js'; import { ISessionDataService } from '../common/sessionDataService.js'; import { ActionType, IActionEnvelope, INotification, ISessionAction, ITerminalAction, isSessionAction } from '../common/state/sessionActions.js'; import type { ICreateTerminalParams } from '../common/state/protocol/commands.js'; import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, type IDirectoryEntry, type IResourceCopyParams, type IResourceCopyResult, type IResourceDeleteParams, type IResourceDeleteResult, type IResourceListResult, type IResourceMoveParams, type IResourceMoveResult, type IResourceReadResult, type IResourceWriteParams, type IResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; -import { ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, TurnState, type IResponsePart, type ISessionFileDiff, type ISessionSummary, type IToolCallCompletedState, type ITurn } from '../common/state/sessionState.js'; +import { ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, parseSubagentSessionUri, type IResponsePart, type ISessionFileDiff, type ISessionSummary, type IToolCallCompletedState, type IToolResultSubagentContent, type ITurn } from '../common/state/sessionState.js'; import { AgentSideEffects } from './agentSideEffects.js'; import { AgentHostTerminalManager, type IAgentHostTerminalManager } from './agentHostTerminalManager.js'; import { ISessionDbUriFields, parseSessionDbUri } from './copilot/fileEditTracker.js'; @@ -27,6 +27,25 @@ import { AgentHostStateManager } from './agentHostStateManager.js'; * process. Dispatches to registered {@link IAgent} instances based * on the provider identifier in the session configuration. */ +/** + * Extracts subagent metadata from a tool start event's arguments, + * matching the event mapper's extraction for the eager toolKind path. + */ +function extractSubagentMeta(start: IAgentToolStartEvent | undefined): { subagentDescription?: string; subagentAgentName?: string } { + if (!start?.toolKind || start.toolKind !== 'subagent' || !start.toolArguments) { + return {}; + } + try { + const args = JSON.parse(start.toolArguments) as Record; + return { + subagentDescription: typeof args.description === 'string' && args.description.length > 0 ? args.description : undefined, + subagentAgentName: typeof args.agentName === 'string' && args.agentName.length > 0 ? args.agentName : undefined, + }; + } catch { + return {}; + } +} + export class AgentService extends Disposable implements IAgentService { declare readonly _serviceBrand: undefined; @@ -238,6 +257,8 @@ export class AgentService extends Disposable implements IAgentService { await provider.disposeSession(session); this._sessionToProvider.delete(session.toString()); } + // Remove all subagent sessions for this parent + this._sideEffects.removeSubagentSessions(session.toString()); this._stateManager.deleteSession(session.toString()); } @@ -263,7 +284,13 @@ export class AgentService extends Disposable implements IAgentService { let snapshot = this._stateManager.getSnapshot(resourceStr); if (!snapshot) { - await this.restoreSession(resource); + // Try subagent restore before regular session restore + const parsed = parseSubagentSessionUri(resourceStr); + if (parsed) { + await this._restoreSubagentSession(resourceStr, parsed.parentSession, parsed.toolCallId); + } else { + await this.restoreSession(resource); + } snapshot = this._stateManager.getSnapshot(resourceStr); } if (!snapshot) { @@ -517,9 +544,12 @@ export class AgentService extends Disposable implements IAgentService { * closes it. */ private _buildTurnsFromMessages( - messages: readonly (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[], + messages: readonly (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent | IAgentSubagentStartedEvent)[], ): ITurn[] { const turns: ITurn[] = []; + // Track subagent metadata by parent tool call ID so we can inject + // IToolResultSubagentContent into the parent tool call's completion content + const subagentsByToolCallId = new Map(); let currentTurn: { id: string; userMessage: { text: string }; @@ -551,6 +581,12 @@ export class AgentService extends Disposable implements IAgentService { } currentTurn = startTurn(msg.messageId, msg.content); } else if (msg.type === 'message' && msg.role === 'assistant') { + // Skip inner assistant messages from subagent sessions. + // These have parentToolCallId set and belong to the child + // session, not the parent turn. + if (msg.parentToolCallId) { + continue; + } if (!currentTurn) { currentTurn = startTurn(msg.messageId, ''); } @@ -567,13 +603,38 @@ export class AgentService extends Disposable implements IAgentService { finalizeTurn(currentTurn, TurnState.Complete); currentTurn = undefined; } + } else if (msg.type === 'subagent_started') { + subagentsByToolCallId.set(msg.toolCallId, msg); } else if (msg.type === 'tool_start') { + // Skip inner tool calls from subagent sessions — they belong + // to the child session, not the parent turn. + if (msg.parentToolCallId) { + continue; + } currentTurn?.pendingTools.set(msg.toolCallId, msg); } else if (msg.type === 'tool_complete') { + // Skip inner tool completions from subagent sessions. + if (msg.parentToolCallId) { + continue; + } if (currentTurn) { const start = currentTurn.pendingTools.get(msg.toolCallId); currentTurn.pendingTools.delete(msg.toolCallId); + // Inject subagent content if this tool call spawned a subagent + const subagentEvent = subagentsByToolCallId.get(msg.toolCallId); + const contentWithSubagent = msg.result.content ? [...msg.result.content] : []; + if (subagentEvent) { + const parentSessionStr = msg.session.toString(); + contentWithSubagent.push({ + type: ToolResultContentType.Subagent, + resource: buildSubagentSessionUri(parentSessionStr, msg.toolCallId), + title: subagentEvent.agentDisplayName, + agentName: subagentEvent.agentName, + description: subagentEvent.agentDescription, + }); + } + const tc: IToolCallCompletedState = { status: ToolCallStatus.Completed, toolCallId: msg.toolCallId, @@ -583,13 +644,14 @@ export class AgentService extends Disposable implements IAgentService { toolInput: start?.toolInput, success: msg.result.success, pastTenseMessage: msg.result.pastTenseMessage, - content: msg.result.content, + content: contentWithSubagent.length > 0 ? contentWithSubagent : undefined, error: msg.result.error, confirmed: ToolCallConfirmationReason.NotNeeded, - _meta: start ? { - toolKind: start.toolKind, - language: start.language, - } : undefined, + _meta: { + toolKind: start?.toolKind, + language: start?.language, + ...extractSubagentMeta(start), + }, }; currentTurn.responseParts.push({ kind: ResponsePartKind.ToolCall, @@ -606,6 +668,117 @@ export class AgentService extends Disposable implements IAgentService { return turns; } + /** + * Builds turns for a subagent child session by extracting events + * from the parent session's messages that have the matching + * `parentToolCallId`. Creates a single turn containing all inner + * tool calls. + */ + private _buildSubagentTurns( + parentMessages: readonly (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent | IAgentSubagentStartedEvent)[], + parentToolCallId: string, + childSessionUri: string, + ): ITurn[] { + // Collect all inner tool call IDs that belong to this subagent + const innerToolCallIds = new Set(); + for (const msg of parentMessages) { + if ((msg.type === 'tool_start' || msg.type === 'tool_complete') && msg.parentToolCallId === parentToolCallId) { + innerToolCallIds.add(msg.toolCallId); + } + } + + // Collect subagent_started events for nested subagents spawned by + // inner tool calls of this child session + const subagentsByToolCallId = new Map(); + for (const msg of parentMessages) { + if (msg.type === 'subagent_started' && innerToolCallIds.has(msg.toolCallId)) { + subagentsByToolCallId.set(msg.toolCallId, msg); + } + } + + // Filter for events belonging to this subagent + const innerMessages = parentMessages.filter(msg => { + if (msg.type === 'tool_start' || msg.type === 'tool_complete') { + return msg.parentToolCallId === parentToolCallId; + } + if (msg.type === 'message') { + return msg.parentToolCallId === parentToolCallId; + } + return false; + }); + + if (innerMessages.length === 0) { + return []; + } + + // Build a single turn with all inner tool calls + const responseParts: IResponsePart[] = []; + const pendingTools = new Map(); + + for (const msg of innerMessages) { + if (msg.type === 'tool_start') { + pendingTools.set(msg.toolCallId, msg); + } else if (msg.type === 'tool_complete') { + const start = pendingTools.get(msg.toolCallId); + pendingTools.delete(msg.toolCallId); + + // Inject nested subagent content if applicable + const subagentEvent = subagentsByToolCallId.get(msg.toolCallId); + const contentWithSubagent = msg.result.content ? [...msg.result.content] : []; + if (subagentEvent) { + contentWithSubagent.push({ + type: ToolResultContentType.Subagent, + resource: buildSubagentSessionUri(childSessionUri, msg.toolCallId), + title: subagentEvent.agentDisplayName, + agentName: subagentEvent.agentName, + description: subagentEvent.agentDescription, + }); + } + + const tc: IToolCallCompletedState = { + status: ToolCallStatus.Completed, + toolCallId: msg.toolCallId, + toolName: start?.toolName ?? 'unknown', + displayName: start?.displayName ?? 'Unknown Tool', + invocationMessage: start?.invocationMessage ?? '', + toolInput: start?.toolInput, + success: msg.result.success, + pastTenseMessage: msg.result.pastTenseMessage, + content: contentWithSubagent.length > 0 ? contentWithSubagent : undefined, + error: msg.result.error, + confirmed: ToolCallConfirmationReason.NotNeeded, + _meta: { + toolKind: start?.toolKind, + language: start?.language, + ...extractSubagentMeta(start), + }, + }; + responseParts.push({ + kind: ResponsePartKind.ToolCall, + toolCall: tc, + }); + } else if (msg.type === 'message' && msg.role === 'assistant' && msg.content) { + responseParts.push({ + kind: ResponsePartKind.Markdown, + id: generateUuid(), + content: msg.content, + }); + } + } + + if (responseParts.length === 0) { + return []; + } + + return [{ + id: generateUuid(), + userMessage: { text: '' }, + responseParts, + usage: undefined, + state: TurnState.Complete, + }]; + } + private async _fetchSessionDbContent(fields: ISessionDbUriFields): Promise { const sessionUri = URI.parse(fields.sessionUri); const ref = this._sessionDataService.openDatabase(sessionUri); @@ -628,6 +801,89 @@ export class AgentService extends Disposable implements IAgentService { } } + /** + * Restores a subagent session from its parent session's event history. + * Loads the parent's raw messages, filters for events belonging to + * the subagent (by `parentToolCallId`), and builds the child session's + * turns from those events. + */ + private async _restoreSubagentSession(subagentUri: string, parentSession: string, toolCallId: string): Promise { + // Ensure the parent session is loaded first + const parentUri = URI.parse(parentSession); + if (!this._stateManager.getSessionState(parentSession)) { + try { + await this.restoreSession(parentUri); + } catch { + this._logService.warn(`[AgentService] Cannot restore parent session for subagent: ${parentSession}`); + return; + } + } + + const parentState = this._stateManager.getSessionState(parentSession); + if (!parentState) { + return; + } + + // Search completed turns and active turn for the subagent content metadata + const allTurns = [...parentState.turns]; + if (parentState.activeTurn) { + allTurns.push(parentState.activeTurn as ITurn); + } + + let subagentContent: IToolResultSubagentContent | undefined; + for (const turn of allTurns) { + for (const part of turn.responseParts) { + if (part.kind === ResponsePartKind.ToolCall) { + const tc = part.toolCall; + // Check both completed and running tool calls — running + // tool calls receive subagent content via ContentChanged + const content = tc.status === ToolCallStatus.Completed + ? tc.content + : (tc.status === ToolCallStatus.Running ? tc.content : undefined); + if (content) { + for (const c of content) { + if (c.type === ToolResultContentType.Subagent && c.resource === subagentUri) { + subagentContent = c; + break; + } + } + } + } + } + if (subagentContent) { + break; + } + } + + // Load parent's raw messages and extract inner events for this subagent + let childTurns: ITurn[] = []; + const agent = this._findProviderForSession(parentUri); + if (agent) { + try { + const messages = await agent.getSessionMessages(parentUri); + childTurns = this._buildSubagentTurns(messages, toolCallId, subagentUri); + } catch (err) { + this._logService.warn(`[AgentService] Failed to load parent messages for subagent restore: ${subagentUri}`, err); + } + } + + // Use metadata from subagent content if available, otherwise synthesize + const title = subagentContent?.title ?? 'Subagent'; + + this._stateManager.restoreSession( + { + resource: subagentUri, + provider: 'subagent', + title, + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + }, + childTurns, + ); + this._logService.info(`[AgentService] Restored subagent session: ${subagentUri} with ${childTurns.length} turn(s)`); + } + private _findProviderForSession(session: URI | string): IAgent | undefined { const key = typeof session === 'string' ? session : session.toString(); const providerId = this._sessionToProvider.get(key); diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 5d2b28702e78e..5f2301f4e8ce1 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -8,6 +8,7 @@ import { match as globMatch } from '../../../base/common/glob.js'; import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { autorun, IObservable } from '../../../base/common/observable.js'; import { extUriBiasedIgnorePathCase, normalizePath } from '../../../base/common/resources.js'; +import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { ILogService } from '../../log/common/log.js'; @@ -18,8 +19,15 @@ import { ActionType, ISessionAction } from '../common/state/sessionActions.js'; import { CustomizationStatus, PendingMessageKind, + ResponsePartKind, + SessionStatus, + ToolCallStatus, + ToolResultContentType, + buildSubagentSessionUri, type ISessionCustomization, type ISessionModelInfo, + type ISessionState, + type IToolResultContent, type URI as ProtocolURI, } from '../common/state/sessionState.js'; import { AgentEventMapper } from './agentEventMapper.js'; @@ -63,6 +71,12 @@ export class AgentSideEffects extends Disposable { /** Serializes per-session diff computations to avoid races with stale previousDiffs. */ private readonly _diffComputationSequencer = new SequencerByKey(); + /** + * Maps `parentSession:toolCallId` → subagent session URI. + * Used to route events with `parentToolCallId` to the correct subagent. + */ + private readonly _subagentSessions = new Map(); + constructor( private readonly _stateManager: AgentHostStateManager, private readonly _options: IAgentSideEffectsOptions, @@ -214,6 +228,53 @@ export class AgentSideEffects extends Disposable { } const sessionKey = e.session.toString(); + + // Handle subagent_started: create the subagent session + if (e.type === 'subagent_started') { + this._handleSubagentStarted(sessionKey, e.toolCallId, e.agentName, e.agentDisplayName, e.agentDescription); + return; + } + + // Route events with parentToolCallId to the subagent session + const parentToolCallId = this._getParentToolCallId(e); + if (parentToolCallId) { + const subagentKey = `${sessionKey}:${parentToolCallId}`; + const subagentSession = this._subagentSessions.get(subagentKey); + if (subagentSession) { + // Track tool calls in subagent context for confirmation routing + if (e.type === 'tool_start') { + this._toolCallAgents.set(`${subagentSession}:${e.toolCallId}`, agent.id); + } + const subTurnId = this._stateManager.getActiveTurnId(subagentSession); + if (subTurnId) { + if (e.type === 'tool_ready') { + if (this._tryAutoApproveToolReady(e, subagentSession, agent)) { + return; + } + } + this._dispatchProgressActions(agentMapper, e, subagentSession, subTurnId); + } + return; + } + } + + // Route tool_ready events for tools inside subagent sessions + // (tool_ready lacks parentToolCallId, but the tool was previously + // registered under its subagent session key in _toolCallAgents) + if (e.type === 'tool_ready') { + const subagentSession = this._findSubagentSessionForToolCall(sessionKey, e.toolCallId); + if (subagentSession) { + const subTurnId = this._stateManager.getActiveTurnId(subagentSession); + if (subTurnId) { + if (this._tryAutoApproveToolReady(e, subagentSession, agent)) { + return; + } + this._dispatchProgressActions(agentMapper, e, subagentSession, subTurnId); + } + return; + } + } + const turnId = this._stateManager.getActiveTurnId(sessionKey); if (turnId) { // Auto-approve tool_ready events synchronously before dispatching. @@ -224,7 +285,31 @@ export class AgentSideEffects extends Disposable { } } + // When a parent tool call has an associated subagent session, + // preserve the subagent content metadata in the completion + // result. The SDK's tool_complete provides its own content + // which would overwrite the IToolResultSubagentContent that + // was set via SessionToolCallContentChanged while running. + if (e.type === 'tool_complete') { + const subagentKey = `${sessionKey}:${e.toolCallId}`; + const subagentUri = this._subagentSessions.get(subagentKey); + if (subagentUri) { + const parentState = this._stateManager.getSessionState(sessionKey); + const runningContent = this._getRunningToolCallContent(parentState, turnId, e.toolCallId); + const subagentEntry = runningContent.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent); + if (subagentEntry) { + const mergedContent = [...(e.result.content ?? []), subagentEntry]; + e = { ...e, result: { ...e.result, content: mergedContent } }; + } + } + } + this._dispatchProgressActions(agentMapper, e, sessionKey, turnId); + + // When a parent tool call completes, complete any associated subagent session + if (e.type === 'tool_complete') { + this.completeSubagentSession(sessionKey, e.toolCallId); + } } // After a turn completes (idle event), compute session diffs and @@ -247,6 +332,195 @@ export class AgentSideEffects extends Disposable { return disposables; } + // ---- Subagent session management ---------------------------------------- + + /** + * Creates a subagent session in response to a `subagent_started` event. + * The subagent session is created silently (no `sessionAdded` notification) + * and immediately transitioned to ready with an active turn. + */ + private _handleSubagentStarted( + parentSession: ProtocolURI, + toolCallId: string, + agentName: string, + agentDisplayName: string, + agentDescription?: string, + ): void { + const subagentSessionUri = buildSubagentSessionUri(parentSession, toolCallId); + const subagentKey = `${parentSession}:${toolCallId}`; + + // Already tracking this subagent + if (this._subagentSessions.has(subagentKey)) { + return; + } + + this._logService.info(`[AgentSideEffects] Creating subagent session: ${subagentSessionUri} (parent=${parentSession}, toolCallId=${toolCallId})`); + + // Create the subagent session silently (restoreSession skips notification) + this._stateManager.restoreSession( + { + resource: subagentSessionUri, + provider: 'subagent', + title: agentDisplayName, + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + }, + [], + ); + + // Start a turn on the subagent session + const turnId = generateUuid(); + this._stateManager.dispatchServerAction({ + type: ActionType.SessionTurnStarted, + session: subagentSessionUri, + turnId, + userMessage: { text: '' }, + }); + + this._subagentSessions.set(subagentKey, subagentSessionUri); + + // Dispatch content on the parent tool call so clients discover the subagent. + // Merge with any existing content to avoid dropping prior content blocks. + const parentTurnId = this._stateManager.getActiveTurnId(parentSession); + if (parentTurnId) { + const parentState = this._stateManager.getSessionState(parentSession); + const existingContent = this._getRunningToolCallContent(parentState, parentTurnId, toolCallId); + const mergedContent = [ + ...existingContent, + { + type: ToolResultContentType.Subagent as const, + resource: subagentSessionUri, + title: agentDisplayName, + agentName, + description: agentDescription, + }, + ]; + this._stateManager.dispatchServerAction({ + type: ActionType.SessionToolCallContentChanged, + session: parentSession, + turnId: parentTurnId, + toolCallId, + content: mergedContent, + }); + } + } + + /** + * Gets the current content array from a running tool call, if any. + */ + private _getRunningToolCallContent( + state: ISessionState | undefined, + turnId: string, + toolCallId: string, + ): IToolResultContent[] { + if (!state?.activeTurn || state.activeTurn.id !== turnId) { + return []; + } + for (const rp of state.activeTurn.responseParts) { + if (rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === toolCallId && rp.toolCall.status === ToolCallStatus.Running) { + return rp.toolCall.content ? [...rp.toolCall.content] : []; + } + } + return []; + } + + /** + * Cancels all active subagent sessions for a given parent session. + */ + cancelSubagentSessions(parentSession: ProtocolURI): void { + for (const [key, subagentUri] of this._subagentSessions) { + if (key.startsWith(`${parentSession}:`)) { + const turnId = this._stateManager.getActiveTurnId(subagentUri); + if (turnId) { + this._stateManager.dispatchServerAction({ + type: ActionType.SessionTurnCancelled, + session: subagentUri, + turnId, + }); + } + this._subagentSessions.delete(key); + } + } + } + + /** + * Completes all active subagent sessions for a given parent session. + * Called when a parent tool call completes. + */ + completeSubagentSession(parentSession: ProtocolURI, toolCallId: string): void { + const key = `${parentSession}:${toolCallId}`; + const subagentUri = this._subagentSessions.get(key); + if (!subagentUri) { + return; + } + + const turnId = this._stateManager.getActiveTurnId(subagentUri); + if (turnId) { + this._stateManager.dispatchServerAction({ + type: ActionType.SessionTurnComplete, + session: subagentUri, + turnId, + }); + } + this._subagentSessions.delete(key); + } + + /** + * Removes all subagent sessions for a given parent session from + * the state manager. Called when the parent session is disposed. + */ + removeSubagentSessions(parentSession: ProtocolURI): void { + const toRemove: string[] = []; + for (const [key, subagentUri] of this._subagentSessions) { + if (key.startsWith(`${parentSession}:`)) { + this._stateManager.removeSession(subagentUri); + toRemove.push(key); + } + } + for (const key of toRemove) { + this._subagentSessions.delete(key); + } + + // Also clean up any subagent sessions that are in the state manager + // but not tracked (e.g. restored sessions) + const prefix = `${parentSession}/subagent/`; + for (const uri of this._stateManager.getSessionUrisWithPrefix(prefix)) { + this._stateManager.removeSession(uri); + } + } + + /** + * Extracts the `parentToolCallId` from a progress event, if present. + */ + private _getParentToolCallId(e: IAgentProgressEvent): string | undefined { + switch (e.type) { + case 'delta': + case 'message': + case 'tool_start': + case 'tool_complete': + return e.parentToolCallId; + default: + return undefined; + } + } + + /** + * Finds the subagent session that owns a given tool call by checking + * whether the tool call was previously registered under a subagent + * session key in `_toolCallAgents`. Scoped to subagent sessions owned + * by the given parent to avoid cross-session collisions. + */ + private _findSubagentSessionForToolCall(parentSession: ProtocolURI, toolCallId: string): ProtocolURI | undefined { + const prefix = `${parentSession}:`; + for (const [key, subagentUri] of this._subagentSessions) { + if (key.startsWith(prefix) && this._toolCallAgents.has(`${subagentUri}:${toolCallId}`)) { + return subagentUri; + } + } + return undefined; + } + // ---- Side-effect handlers -------------------------------------------------- private _dispatchProgressActions(mapper: AgentEventMapper, e: IAgentProgressEvent, sessionKey: ProtocolURI, turnId: string): void { @@ -313,6 +587,8 @@ export class AgentSideEffects extends Disposable { break; } case ActionType.SessionTurnCancelled: { + // Cancel all subagent sessions for this parent + this.cancelSubagentSessions(action.session); const agent = this._options.getAgent(action.session); agent?.abortSession(URI.parse(action.session)).catch(err => { this._logService.error('[AgentSideEffects] abortSession failed', err); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 3a7df54dff5bd..bcbd31a1ac972 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -17,7 +17,7 @@ import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentSessionMetadata, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; +import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentSessionMetadata, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { CustomizationStatus, ICustomizationRef, SessionInputResponseKind, type ISessionInputAnswer, type IPendingMessage, type PolicyState } from '../../common/state/sessionState.js'; import { CopilotAgentSession, SessionWrapperFactory } from './copilotAgentSession.js'; @@ -297,7 +297,7 @@ export class CopilotAgent extends Disposable implements IAgent { // No SDK-level enqueue is needed. } - async getSessionMessages(session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]> { + async getSessionMessages(session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent | IAgentSubagentStartedEvent)[]> { const sessionId = AgentSession.id(session); const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId).catch(() => undefined); if (!entry) { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index f532e4d2bd1a8..e4e4203d861db 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -13,7 +13,7 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; import { localize } from '../../../../nls.js'; -import { IAgentAttachment, IAgentMessageEvent, IAgentProgressEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; +import { IAgentAttachment, IAgentMessageEvent, IAgentProgressEvent, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType, type ISessionInputAnswer, type ISessionInputRequest, type IPendingMessage, type IToolResultContent } from '../../common/state/sessionState.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; @@ -264,7 +264,7 @@ export class CopilotAgentSession extends Disposable { } } - async getMessages(): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]> { + async getMessages(): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent | IAgentSubagentStartedEvent)[]> { const events = await this._wrapper.session.getMessages(); let db: ISessionDatabase | undefined; try { @@ -570,6 +570,18 @@ export class CopilotAgentSession extends Disposable { this._onDidSessionProgress.fire({ session, type: 'idle' }); })); + this._register(wrapper.onSubagentStarted(e => { + this._logService.info(`[Copilot:${sessionId}] Subagent started: toolCallId=${e.data.toolCallId}, agent=${e.data.agentName}`); + this._onDidSessionProgress.fire({ + session, + type: 'subagent_started', + toolCallId: e.data.toolCallId, + agentName: e.data.agentName, + agentDisplayName: e.data.agentDisplayName, + agentDescription: e.data.agentDescription, + }); + })); + this._register(wrapper.onSessionError(e => { this._logService.error(`[Copilot:${sessionId}] Session error: ${e.data.errorType} - ${e.data.message}`); this._onDidSessionProgress.fire({ diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts index 756c38ec967ad..5c7a27c4b1feb 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -108,6 +108,11 @@ const SHELL_TOOL_NAMES: ReadonlySet = new Set([ CopilotToolName.PowerShell, ]); +/** Set of tool names that spawn subagent sessions. */ +const SUBAGENT_TOOL_NAMES: ReadonlySet = new Set([ + 'task', +]); + /** * Tools that should not be shown to the user. These are internal tools * used by the CLI for its own purposes (e.g., reporting intent to the model). @@ -313,10 +318,13 @@ export function getToolInputString(toolName: string, parameters: Record { - const result: (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[] = []; +): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent | IAgentSubagentStartedEvent)[]> { + const result: (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent | IAgentSubagentStartedEvent)[] = []; const toolInfoByCallId = new Map | undefined }>(); // Collect all tool call IDs for edit tools so we can batch-query the database @@ -223,6 +233,16 @@ export async function mapSessionEvents( toolTelemetry: d.toolTelemetry !== undefined ? tryStringify(d.toolTelemetry) : undefined, parentToolCallId: d.parentToolCallId, }); + } else if (e.type === 'subagent.started') { + const d = (e as ISessionEventSubagentStarted).data; + result.push({ + session, + type: 'subagent_started', + toolCallId: d.toolCallId, + agentName: d.agentName, + agentDisplayName: d.agentDisplayName, + agentDescription: d.agentDescription, + }); } } return result; diff --git a/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts b/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts index 2c63114fc4571..114b931f85be9 100644 --- a/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts +++ b/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts @@ -339,4 +339,23 @@ suite('AgentEventMapper', () => { assert.strictEqual(action.session, session.toString()); assert.strictEqual(action.request, request); }); + + test('tool_start with subagent toolKind extracts agent metadata from toolArguments', () => { + const event: IAgentToolStartEvent = { + session, + type: 'tool_start', + toolCallId: 'tc-sub', + toolName: 'task', + displayName: 'Task', + invocationMessage: 'Delegating...', + toolKind: 'subagent', + toolArguments: JSON.stringify({ description: 'Review the code', agentName: 'code-reviewer' }), + }; + + const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); + const startAction = actions[0] as IToolCallStartAction; + assert.strictEqual(startAction._meta?.toolKind, 'subagent'); + assert.strictEqual(startAction._meta?.subagentDescription, 'Review the code'); + assert.strictEqual(startAction._meta?.subagentAgentName, 'code-reviewer'); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 6a216512c0daf..bb9a653014831 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -10,7 +10,7 @@ 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 IActionEnvelope, type INotification } from '../../common/state/sessionActions.js'; -import { ISessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, type IMarkdownResponsePart, type ISessionState } from '../../common/state/sessionState.js'; +import { ISessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildSubagentSessionUri, isSubagentSession, parseSubagentSessionUri, type IMarkdownResponsePart, type ISessionState } from '../../common/state/sessionState.js'; import { type ISessionSummaryChangedNotification } from '../../common/state/protocol/notifications.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; @@ -376,3 +376,32 @@ suite('AgentHostStateManager', () => { }); }); }); + +suite('Subagent URI helpers', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('buildSubagentSessionUri creates correct URI', () => { + assert.strictEqual( + buildSubagentSessionUri('copilot:/session-1', 'tc-1'), + 'copilot:/session-1/subagent/tc-1', + ); + }); + + test('parseSubagentSessionUri extracts parent and toolCallId', () => { + const parsed = parseSubagentSessionUri('copilot:/session-1/subagent/tc-1'); + assert.deepStrictEqual(parsed, { + parentSession: 'copilot:/session-1', + toolCallId: 'tc-1', + }); + }); + + test('parseSubagentSessionUri returns undefined for non-subagent URIs', () => { + assert.strictEqual(parseSubagentSessionUri('copilot:/session-1'), undefined); + }); + + test('isSubagentSession identifies subagent URIs', () => { + assert.strictEqual(isSubagentSession('copilot:/session-1/subagent/tc-1'), true); + assert.strictEqual(isSubagentSession('copilot:/session-1'), false); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index d50a9f040c151..44718849a2c86 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -4,21 +4,48 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; import { VSBuffer } from '../../../../base/common/buffer.js'; -import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { DisposableStore, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { hasKey } from '../../../../base/common/types.js'; import { NullLogService } from '../../../log/common/log.js'; import { FileService } from '../../../files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { AgentSession } from '../../common/agentService.js'; +import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, IActionEnvelope } from '../../common/state/sessionActions.js'; -import { ResponsePartKind, SessionLifecycle, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, type IMarkdownResponsePart, type IToolCallCompletedState, type IToolCallResponsePart } from '../../common/state/sessionState.js'; -import { createNullSessionDataService, createSessionDataService } from '../common/sessionTestHelpers.js'; +import { ResponsePartKind, SessionLifecycle, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type IMarkdownResponsePart, type IToolCallCompletedState, type IToolCallResponsePart } from '../../common/state/sessionState.js'; import { AgentService } from '../../node/agentService.js'; import { MockAgent } from './mockAgent.js'; +import { mapSessionEvents, type ISessionEvent } from '../../node/copilot/mapSessionEvents.js'; + +/** + * Loads a JSONL fixture of raw Copilot SDK events, runs them through + * {@link mapSessionEvents}, and returns the result suitable for setting + * on {@link MockAgent.sessionMessages}. This tests the full pipeline: + * SDK events → mapSessionEvents → _buildTurnsFromMessages → ITurn[]. + * + * Fixture files live in `test-cases/` and are sanitized copies of real + * `events.jsonl` files from `~/.copilot/session-state/`. + */ +async function loadFixtureMessages(fixtureName: string, session: URI) { + // Resolve the fixture from the source tree (test-cases/ is not compiled to out/) + const thisFile = fileURLToPath(import.meta.url); + // Navigate from out/vs/... to src/vs/... by replacing the out/ prefix. + // Use a regex that handles both / and \ separators for Windows compat. + const srcFile = thisFile.replace(/[/\\]out[/\\]/, (m) => m.replace('out', 'src')); + const lastSep = Math.max(srcFile.lastIndexOf('/'), srcFile.lastIndexOf('\\')); + const fixtureDir = srcFile.substring(0, lastSep); + const sep = srcFile.includes('\\') ? '\\' : '/'; + const raw = readFileSync(`${fixtureDir}${sep}test-cases${sep}${fixtureName}`, 'utf-8'); + const events: ISessionEvent[] = raw.trim().split('\n').map(line => JSON.parse(line)); + return mapSessionEvents(session, undefined, events); +} suite('AgentService (node dispatcher)', () => { @@ -28,6 +55,15 @@ suite('AgentService (node dispatcher)', () => { let fileService: FileService; setup(async () => { + const nullSessionDataService: ISessionDataService = { + _serviceBrand: undefined, + getSessionDataDir: () => URI.parse('inmemory:/session-data'), + getSessionDataDirById: () => URI.parse('inmemory:/session-data'), + openDatabase: () => { throw new Error('not implemented'); }, + tryOpenDatabase: async () => undefined, + deleteSessionData: async () => { }, + cleanupOrphanedData: async () => { }, + }; fileService = disposables.add(new FileService(new NullLogService())); disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); @@ -35,7 +71,7 @@ suite('AgentService (node dispatcher)', () => { await fileService.createFolder(URI.from({ scheme: Schemas.inMemory, path: '/testDir' })); await fileService.writeFile(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' }), VSBuffer.fromString('hello')); - service = disposables.add(new AgentService(new NullLogService(), fileService, createNullSessionDataService())); + service = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService)); copilotAgent = new MockAgent('copilot'); disposables.add(toDisposable(() => copilotAgent.dispose())); }); @@ -143,7 +179,21 @@ suite('AgentService (node dispatcher)', () => { const sessionId = 'test-session-abc'; const sessionUri = AgentSession.uri('copilot', sessionId); - const sessionDataService = createSessionDataService(db); + const sessionDataService: ISessionDataService = { + _serviceBrand: undefined, + getSessionDataDir: () => URI.parse('inmemory:/session-data'), + getSessionDataDirById: () => URI.parse('inmemory:/session-data'), + openDatabase: (): IReference => ({ + object: db, + dispose: () => { }, + }), + tryOpenDatabase: async (): Promise | undefined> => ({ + object: db, + dispose: () => { }, + }), + deleteSessionData: async () => { }, + cleanupOrphanedData: async () => { }, + }; // Create a mock that returns a session with that ID const agent = new MockAgent('copilot'); @@ -293,6 +343,118 @@ suite('AgentService (node dispatcher)', () => { /Session not found on backend/, ); }); + + test('restores a session with subagent tool calls', async () => { + service.registerProvider(copilotAgent); + const session = await copilotAgent.createSession(); + const sessions = await copilotAgent.listSessions(); + const sessionResource = sessions[0].session; + + copilotAgent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Review this code', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: '', toolRequests: [{ toolCallId: 'tc-sub', name: 'task' }] }, + { type: 'tool_start', session, toolCallId: 'tc-sub', toolName: 'task', displayName: 'Task', invocationMessage: 'Delegating...', toolKind: 'subagent' as const, toolArguments: JSON.stringify({ description: 'Find related files', agentName: 'explore' }) }, + { type: 'subagent_started', session, toolCallId: 'tc-sub', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores the codebase' }, + // Inner tool calls from the subagent (have parentToolCallId) + { type: 'tool_start', session, toolCallId: 'tc-inner-1', toolName: 'bash', displayName: 'Bash', invocationMessage: 'Running ls...', parentToolCallId: 'tc-sub' }, + { type: 'tool_complete', session, toolCallId: 'tc-inner-1', result: { success: true, pastTenseMessage: 'Ran ls', content: [{ type: ToolResultContentType.Text, text: 'file1.ts' }] }, parentToolCallId: 'tc-sub' }, + { type: 'tool_start', session, toolCallId: 'tc-inner-2', toolName: 'view', displayName: 'View File', invocationMessage: 'Reading file1.ts', parentToolCallId: 'tc-sub' }, + { type: 'tool_complete', session, toolCallId: 'tc-inner-2', result: { success: true, pastTenseMessage: 'Read file1.ts' }, parentToolCallId: 'tc-sub' }, + // Parent tool completes + { type: 'tool_complete', session, toolCallId: 'tc-sub', result: { success: true, pastTenseMessage: 'Delegated task', content: [{ type: ToolResultContentType.Text, text: 'Found 3 issues' }] } }, + { type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'The review found 3 issues.', toolRequests: [] }, + ]; + + await service.restoreSession(sessionResource); + + const state = service.stateManager.getSessionState(sessionResource.toString()); + assert.ok(state); + + // Should produce exactly one turn + assert.strictEqual(state!.turns.length, 1, `Expected 1 turn but got ${state!.turns.length}`); + + const turn = state!.turns[0]; + assert.strictEqual(turn.userMessage.text, 'Review this code'); + + // The parent turn should only have the parent tool call — inner + // tool calls are excluded from the parent and belong to the + // child subagent session instead. + const toolCallParts = turn.responseParts.filter((p): p is IToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); + assert.strictEqual(toolCallParts.length, 1, `Expected 1 tool call (parent only) but got ${toolCallParts.length}`); + + // Parent subagent tool call + const parentTc = toolCallParts[0].toolCall as IToolCallCompletedState; + assert.strictEqual(parentTc.toolCallId, 'tc-sub'); + assert.strictEqual(parentTc.status, ToolCallStatus.Completed); + assert.strictEqual(parentTc._meta?.toolKind, 'subagent'); + assert.strictEqual(parentTc._meta?.subagentDescription, 'Find related files'); + assert.strictEqual(parentTc._meta?.subagentAgentName, 'explore'); + + // Parent tool should have subagent content entry + const content = parentTc.content ?? []; + const subagentEntry = content.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent); + assert.ok(subagentEntry, 'Completed tool call should have subagent content entry'); + + // Subscribing to the child session should restore it with inner tool calls + const childSessionUri = buildSubagentSessionUri(sessionResource.toString(), 'tc-sub'); + const snapshot = await service.subscribe(URI.parse(childSessionUri)); + const childState = service.stateManager.getSessionState(childSessionUri); + assert.ok(snapshot?.state, 'Child session snapshot should exist'); + assert.ok(childState, 'Child session state should exist'); + assert.strictEqual(childState!.turns.length, 1, 'Child session should have 1 turn'); + const childToolParts = childState!.turns[0].responseParts.filter((p): p is IToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); + assert.strictEqual(childToolParts.length, 2, `Child session should have 2 inner tool calls but got ${childToolParts.length}`); + assert.ok(childToolParts.some(p => p.toolCall.toolCallId === 'tc-inner-1'), 'Should have tc-inner-1'); + assert.ok(childToolParts.some(p => p.toolCall.toolCallId === 'tc-inner-2'), 'Should have tc-inner-2'); + + // The turn should also have the final markdown + const mdParts = turn.responseParts.filter((p): p is IMarkdownResponsePart => p.kind === ResponsePartKind.Markdown); + assert.ok(mdParts.some(p => p.content.includes('3 issues')), 'Should have the final markdown response'); + }); + + test('inner assistant messages from subagent do not create extra turns (fixture)', async () => { + service.registerProvider(copilotAgent); + const session = await copilotAgent.createSession(); + const sessions = await copilotAgent.listSessions(); + const sessionResource = sessions[0].session; + + // Load real SDK events from fixture (sanitized from ~/.copilot/session-state/) + copilotAgent.sessionMessages = await loadFixtureMessages('subagent-session.jsonl', session); + + await service.restoreSession(sessionResource); + + const state = service.stateManager.getSessionState(sessionResource.toString()); + assert.ok(state); + assert.strictEqual(state!.turns.length, 1, `Expected 1 turn but got ${state!.turns.length}: ${state!.turns.map(t => `"${t.userMessage.text.substring(0, 40)}"`).join(', ')}`); + assert.strictEqual(state!.turns[0].userMessage.text, 'Run a sync subagent to do some searches, just testing subagent rendering'); + assert.strictEqual(state!.turns[0].state, TurnState.Complete); + + // Should have the parent subagent tool call with subagent content + const toolCallParts = state!.turns[0].responseParts.filter((p): p is IToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); + const parentTc = toolCallParts.find(p => p.toolCall.toolName === 'task'); + assert.ok(parentTc, 'Should have a task tool call'); + assert.strictEqual(parentTc!.toolCall._meta?.toolKind, 'subagent'); + + // Inner tool calls should NOT be in the parent turn — they belong + // to the child subagent session. + const parentToolCallId = parentTc!.toolCall.toolCallId; + const nonParentTools = toolCallParts.filter(p => p.toolCall.toolCallId !== parentToolCallId); + assert.strictEqual(nonParentTools.length, 0, `Parent turn should only contain the task tool call, but found ${nonParentTools.length} extra tool calls`); + + // Subscribe to the child subagent session and verify inner tools + const childSessionUri = buildSubagentSessionUri(sessionResource.toString(), parentToolCallId); + const snapshot = await service.subscribe(URI.parse(childSessionUri)); + assert.ok(snapshot?.state, 'Child session snapshot should exist'); + const childState = service.stateManager.getSessionState(childSessionUri); + assert.ok(childState, 'Child session state should exist'); + assert.strictEqual(childState!.turns.length, 1, 'Child session should have 1 turn'); + const childToolParts = childState!.turns[0].responseParts.filter((p): p is IToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); + assert.ok(childToolParts.length > 0, `Child session should have inner tool calls but got ${childToolParts.length}`); + + // Should have the final markdown + const mdParts = state!.turns[0].responseParts.filter((p): p is IMarkdownResponsePart => p.kind === ResponsePartKind.Markdown); + assert.ok(mdParts.length > 0, 'Should have markdown content'); + }); }); // ---- resourceList ------------------------------------------------ diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 7b3e8da75187c..4445ffa0f78e7 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -8,6 +8,7 @@ import { VSBuffer } from '../../../../base/common/buffer.js'; import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { observableValue } from '../../../../base/common/observable.js'; +import { hasKey } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { FileService } from '../../../files/common/fileService.js'; @@ -16,7 +17,7 @@ import { NullLogService } from '../../../log/common/log.js'; import { AgentSession, IAgent } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType, IActionEnvelope, ISessionAction } from '../../common/state/sessionActions.js'; -import { PendingMessageKind, SessionStatus } from '../../common/state/sessionState.js'; +import { PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType } from '../../common/state/sessionState.js'; import { AgentService } from '../../node/agentService.js'; import { AgentSideEffects } from '../../node/agentSideEffects.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; @@ -844,4 +845,215 @@ suite('AgentSideEffects', () => { assert.strictEqual(state!.summary.title, 'Restored Title'); }); }); + + // ---- Subagent sessions ---------------------------------------------- + + suite('subagent sessions', () => { + + test('subagent_started creates a subagent session and dispatches content on parent tool call', () => { + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + // Start a parent tool call + agent.fireProgress({ + session: sessionUri, + type: 'tool_start', + toolCallId: 'tc-1', + toolName: 'runSubagent', + displayName: 'Run Subagent', + invocationMessage: 'Delegating task...', + }); + + // Fire subagent_started + agent.fireProgress({ + session: sessionUri, + type: 'subagent_started', + toolCallId: 'tc-1', + agentName: 'code-reviewer', + agentDisplayName: 'Code Reviewer', + agentDescription: 'Reviews code', + }); + + // Verify the subagent session was created + const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; + const subState = stateManager.getSessionState(subagentUri); + assert.ok(subState, 'subagent session should exist'); + assert.strictEqual(subState!.summary.title, 'Code Reviewer'); + assert.ok(subState!.activeTurn, 'subagent should have an active turn'); + + // Verify content was dispatched on the parent tool call + const parentState = stateManager.getSessionState(sessionUri.toString()); + assert.ok(parentState?.activeTurn); + const parentToolCall = parentState!.activeTurn!.responseParts.find( + rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-1' + ); + assert.ok(parentToolCall); + if (parentToolCall?.kind === ResponsePartKind.ToolCall && parentToolCall.toolCall.status === ToolCallStatus.Running) { + assert.ok(parentToolCall.toolCall.content); + assert.strictEqual(parentToolCall.toolCall.content![0].type, ToolResultContentType.Subagent); + } + }); + + test('events with parentToolCallId route to subagent session', () => { + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + // Start parent tool + subagent + agent.fireProgress({ session: sessionUri, type: 'tool_start', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', invocationMessage: 'Delegating...' }); + agent.fireProgress({ session: sessionUri, type: 'subagent_started', toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + + // Fire an inner tool start with parentToolCallId + agent.fireProgress({ + session: sessionUri, + type: 'tool_start', + toolCallId: 'inner-tc-1', + toolName: 'readFile', + displayName: 'Read File', + invocationMessage: 'Reading file...', + parentToolCallId: 'tc-1', + }); + + // Verify the inner tool call is on the subagent session's turn, not the parent + const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; + const subState = stateManager.getSessionState(subagentUri); + assert.ok(subState?.activeTurn); + const innerTool = subState!.activeTurn!.responseParts.find( + rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-tc-1' + ); + assert.ok(innerTool, 'inner tool call should be in subagent session'); + + // Verify the parent session does NOT have the inner tool call + const parentState = stateManager.getSessionState(sessionUri.toString()); + const parentInnerTool = parentState!.activeTurn!.responseParts.find( + rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-tc-1' + ); + assert.strictEqual(parentInnerTool, undefined, 'inner tool call should NOT be in parent session'); + }); + + test('completeSubagentSession completes the subagent turn when parent tool completes', () => { + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + // Start parent tool + subagent + agent.fireProgress({ session: sessionUri, type: 'tool_start', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', invocationMessage: 'Delegating...' }); + agent.fireProgress({ session: sessionUri, type: 'subagent_started', toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + + // Complete the parent tool call + agent.fireProgress({ + session: sessionUri, + type: 'tool_complete', + toolCallId: 'tc-1', + result: { success: true, pastTenseMessage: 'Done' }, + }); + + // Verify the subagent session's turn was completed + const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; + const subState = stateManager.getSessionState(subagentUri); + assert.ok(subState); + assert.strictEqual(subState!.activeTurn, undefined, 'subagent turn should be completed'); + assert.strictEqual(subState!.turns.length, 1); + }); + + test('cancelSubagentSessions cancels all subagent sessions', () => { + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + // Start two parent tool calls with subagents + agent.fireProgress({ session: sessionUri, type: 'tool_start', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Sub 1', invocationMessage: 'Delegating 1...' }); + agent.fireProgress({ session: sessionUri, type: 'subagent_started', toolCallId: 'tc-1', agentName: 'sub1', agentDisplayName: 'Sub 1', agentDescription: 'First' }); + + agent.fireProgress({ session: sessionUri, type: 'tool_start', toolCallId: 'tc-2', toolName: 'runSubagent', displayName: 'Sub 2', invocationMessage: 'Delegating 2...' }); + agent.fireProgress({ session: sessionUri, type: 'subagent_started', toolCallId: 'tc-2', agentName: 'sub2', agentDisplayName: 'Sub 2', agentDescription: 'Second' }); + + // Cancel via parent turn cancellation + sideEffects.handleAction({ + type: ActionType.SessionTurnCancelled, + session: sessionUri.toString(), + turnId: 'turn-1', + }); + + // Both subagent sessions should have their turns completed (cancelled) + const sub1 = stateManager.getSessionState(`${sessionUri.toString()}/subagent/tc-1`); + const sub2 = stateManager.getSessionState(`${sessionUri.toString()}/subagent/tc-2`); + assert.strictEqual(sub1?.activeTurn, undefined, 'sub1 turn should be cancelled'); + assert.strictEqual(sub2?.activeTurn, undefined, 'sub2 turn should be cancelled'); + }); + + test('removeSubagentSessions removes all subagent sessions from state', () => { + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + agent.fireProgress({ session: sessionUri, type: 'tool_start', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Sub 1', invocationMessage: 'Delegating...' }); + agent.fireProgress({ session: sessionUri, type: 'subagent_started', toolCallId: 'tc-1', agentName: 'sub', agentDisplayName: 'Sub', agentDescription: 'Has subagent' }); + + const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; + assert.ok(stateManager.getSessionState(subagentUri)); + + sideEffects.removeSubagentSessions(sessionUri.toString()); + + assert.strictEqual(stateManager.getSessionState(subagentUri), undefined, 'subagent session should be removed'); + }); + + test('deltas with parentToolCallId route to subagent session', () => { + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + agent.fireProgress({ session: sessionUri, type: 'tool_start', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', invocationMessage: 'Delegating...' }); + agent.fireProgress({ session: sessionUri, type: 'subagent_started', toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + + // Fire a delta with parentToolCallId + agent.fireProgress({ session: sessionUri, type: 'delta', messageId: 'msg-sub', content: 'thinking...', parentToolCallId: 'tc-1' }); + + // Verify the delta went to the subagent session + const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; + const subState = stateManager.getSessionState(subagentUri); + assert.ok(subState?.activeTurn); + const markdownPart = subState!.activeTurn!.responseParts.find( + rp => rp.kind === ResponsePartKind.Markdown + ); + assert.ok(markdownPart, 'delta should create a markdown part in subagent session'); + }); + + test('tool_complete preserves subagent content in completed tool call', () => { + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + agent.fireProgress({ session: sessionUri, type: 'tool_start', toolCallId: 'tc-1', toolName: 'task', displayName: 'Task', invocationMessage: 'Delegating...' }); + agent.fireProgress({ session: sessionUri, type: 'subagent_started', toolCallId: 'tc-1', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' }); + + // Verify subagent content is on the running tool + const runningState = stateManager.getSessionState(sessionUri.toString()); + const runningTool = runningState?.activeTurn?.responseParts.find( + rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-1' + ); + assert.ok(runningTool?.kind === ResponsePartKind.ToolCall); + assert.strictEqual(runningTool.toolCall.status, ToolCallStatus.Running); + + // Complete the tool — the SDK result has its own content + agent.fireProgress({ + session: sessionUri, type: 'tool_complete', toolCallId: 'tc-1', + result: { success: true, pastTenseMessage: 'Delegated', content: [{ type: ToolResultContentType.Text, text: 'Done' }] }, + }); + + // Verify the completed tool still has the subagent content entry + const completedState = stateManager.getSessionState(sessionUri.toString()); + const completedTool = completedState?.activeTurn?.responseParts.find( + rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-1' + ); + assert.ok(completedTool?.kind === ResponsePartKind.ToolCall); + assert.strictEqual(completedTool.toolCall.status, ToolCallStatus.Completed); + const content = completedTool.toolCall.content ?? []; + const subagentEntry = content.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent); + assert.ok(subagentEntry, 'Completed tool should preserve subagent content entry'); + const textEntry = content.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Text); + assert.ok(textEntry, 'Completed tool should also have the SDK result content'); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts index 3472a3019a717..9872642c422f3 100644 --- a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts @@ -225,4 +225,31 @@ suite('mapSessionEvents', () => { assert.strictEqual(content[0].type, ToolResultContentType.Text); }); }); + + // ---- Subagent events ------------------------------------------------ + + suite('subagent events', () => { + + test('maps subagent.started event to subagent_started progress event', async () => { + const events: ISessionEvent[] = [ + { + type: 'subagent.started', + data: { + toolCallId: 'tc-1', + agentName: 'code-reviewer', + agentDisplayName: 'Code Reviewer', + agentDescription: 'Reviews code', + }, + }, + ]; + + const result = await mapSessionEvents(session, undefined, events); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].type, 'subagent_started'); + const event = result[0] as { type: string; toolCallId: string; agentName: string; agentDisplayName: string }; + assert.strictEqual(event.toolCallId, 'tc-1'); + assert.strictEqual(event.agentName, 'code-reviewer'); + assert.strictEqual(event.agentDisplayName, 'Code Reviewer'); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index f7c1af2637511..534ab922e8f67 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -8,9 +8,9 @@ import { Emitter } from '../../../../base/common/event.js'; import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; import { URI } from '../../../../base/common/uri.js'; import { type ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSession, type AgentProvider, type IAgent, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentDescriptor, type IAgentMessageEvent, type IAgentModelInfo, type IAgentProgressEvent, type IAgentSessionMetadata, type IAgentToolCompleteEvent, type IAgentToolStartEvent } from '../../common/agentService.js'; +import { AgentSession, type AgentProvider, type IAgent, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentDescriptor, type IAgentMessageEvent, type IAgentModelInfo, type IAgentProgressEvent, type IAgentSessionMetadata, type IAgentSubagentStartedEvent, type IAgentToolCompleteEvent, type IAgentToolStartEvent } from '../../common/agentService.js'; import { IProtectedResourceMetadata } from '../../common/state/protocol/state.js'; -import { CustomizationStatus, SessionInputResponseKind, ToolResultContentType, type ICustomizationRef, type IPendingMessage, type ISessionInputAnswer, type IToolCallResult } from '../../common/state/sessionState.js'; +import { CustomizationStatus, ToolResultContentType, type ICustomizationRef, type IPendingMessage, type IToolCallResult } from '../../common/state/sessionState.js'; /** Well-known auto-generated title used by the 'with-title' prompt. */ export const MOCK_AUTO_TITLE = 'Automatically generated title'; @@ -41,7 +41,7 @@ export class MockAgent implements IAgent { customizations: ICustomizationRef[] = []; /** Configurable return value for getSessionMessages. */ - sessionMessages: (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[] = []; + sessionMessages: (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent | IAgentSubagentStartedEvent)[] = []; /** Optional overrides applied to session metadata from listSessions. */ sessionMetadataOverrides: Partial> = {}; @@ -82,7 +82,7 @@ export class MockAgent implements IAgent { this.setPendingMessagesCalls.push({ session, steeringMessage, queuedMessages }); } - async getSessionMessages(_session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]> { + async getSessionMessages(_session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent | IAgentSubagentStartedEvent)[]> { return this.sessionMessages; } @@ -99,8 +99,8 @@ export class MockAgent implements IAgent { this.respondToPermissionCalls.push({ requestId, approved }); } - respondToUserInputRequest(_requestId: string, _response: SessionInputResponseKind, _answers?: Record): void { - // no-op in mock + respondToUserInputRequest(): void { + // no-op for tests } async changeModel(session: URI, model: string): Promise { @@ -441,8 +441,8 @@ export class ScriptedMockAgent implements IAgent { } } - respondToUserInputRequest(_requestId: string, _response: SessionInputResponseKind, _answers?: Record): void { - // no-op in mock + respondToUserInputRequest(): void { + // no-op for tests } async authenticate(_resource: string, _token: string): Promise { diff --git a/src/vs/platform/agentHost/test/node/test-cases/subagent-session.jsonl b/src/vs/platform/agentHost/test/node/test-cases/subagent-session.jsonl new file mode 100644 index 0000000000000..46d959ef7d457 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/test-cases/subagent-session.jsonl @@ -0,0 +1,26 @@ +{"type": "session.start"} +{"type": "user.message", "data": {"messageId": "", "content": "Run a sync subagent to do some searches, just testing subagent rendering"}} +{"type": "assistant.turn_start", "data": {}} +{"type": "assistant.message", "data": {"messageId": "86916d6d-68d9-4c0b-83ed-f0b9f564f227", "content": "", "toolRequests": [{"toolCallId": "tooluse_KqCBFCvT6KWh5CW2BNRqrt", "name": "report_intent"}, {"toolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE", "name": "task"}]}} +{"type": "tool.execution_start", "data": {"toolCallId": "tooluse_KqCBFCvT6KWh5CW2BNRqrt", "toolName": "report_intent"}} +{"type": "tool.execution_start", "data": {"toolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE", "toolName": "task", "arguments": {"description": "", "agentName": ""}}} +{"type": "subagent.started", "data": {"toolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE", "agentName": "explore", "agentDisplayName": "Explore Agent", "agentDescription": "Fast codebase exploration and answering questions. Uses code intelligence, grep, glob, view, bash/powershell tools in a separate context window to search files and understand code structure. Safe to call in parallel.\n"}} +{"type": "tool.execution_complete", "data": {"toolCallId": "tooluse_KqCBFCvT6KWh5CW2BNRqrt", "success": true}} +{"type": "assistant.message", "data": {"messageId": "e10025da-18e4-449e-ad65-86b40c2df734", "content": "", "toolRequests": [{"toolCallId": "tooluse_Xfy4dl4QSeYSlpz41wtI6G", "name": "view"}, {"toolCallId": "tooluse_IE6Eh9fuAxZmSl3eFnLXd7", "name": "glob"}, {"toolCallId": "tooluse_IwGL2Zh0XKZrONGMGAnEGo", "name": "glob"}, {"toolCallId": "tooluse_qLbiToWVQLgou5o7yWXS9m", "name": "view"}, {"toolCallId": "tooluse_I7WV3WzgdLr4FK5gyU5MaR", "name": "view"}], "parentToolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE"}} +{"type": "tool.execution_start", "data": {"toolCallId": "tooluse_Xfy4dl4QSeYSlpz41wtI6G", "toolName": "view", "parentToolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE"}} +{"type": "tool.execution_start", "data": {"toolCallId": "tooluse_IE6Eh9fuAxZmSl3eFnLXd7", "toolName": "glob", "parentToolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE"}} +{"type": "tool.execution_start", "data": {"toolCallId": "tooluse_IwGL2Zh0XKZrONGMGAnEGo", "toolName": "glob", "parentToolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE"}} +{"type": "tool.execution_start", "data": {"toolCallId": "tooluse_qLbiToWVQLgou5o7yWXS9m", "toolName": "view", "parentToolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE"}} +{"type": "tool.execution_start", "data": {"toolCallId": "tooluse_I7WV3WzgdLr4FK5gyU5MaR", "toolName": "view", "parentToolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE"}} +{"type": "tool.execution_complete", "data": {"toolCallId": "tooluse_Xfy4dl4QSeYSlpz41wtI6G", "success": true, "parentToolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE"}} +{"type": "tool.execution_complete", "data": {"toolCallId": "tooluse_qLbiToWVQLgou5o7yWXS9m", "success": true, "parentToolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE"}} +{"type": "tool.execution_complete", "data": {"toolCallId": "tooluse_I7WV3WzgdLr4FK5gyU5MaR", "success": true, "parentToolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE"}} +{"type": "tool.execution_complete", "data": {"toolCallId": "tooluse_IwGL2Zh0XKZrONGMGAnEGo", "success": true, "parentToolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE"}} +{"type": "tool.execution_complete", "data": {"toolCallId": "tooluse_IE6Eh9fuAxZmSl3eFnLXd7", "success": true, "parentToolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE"}} +{"type": "assistant.message", "data": {"messageId": "3a9290cb-6637-4cd8-a3bc-ed79c3bb3abc", "content": "Perfect! I now have enough information to answer all the questions. Let me provi", "parentToolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE"}} +{"type": "subagent.completed", "data": {"toolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE", "agentName": "explore", "agentDisplayName": "Explore Agent"}} +{"type": "tool.execution_complete", "data": {"toolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE", "success": true}} +{"type": "assistant.turn_end", "data": {}} +{"type": "assistant.turn_start", "data": {}} +{"type": "assistant.message", "data": {"messageId": "aab83f2f-d9ac-4076-a318-2604bafe7078", "content": "Here's what the sync subagent found:\n\n**Repo overview** \u2014 This is a Python-focus"}} +{"type": "assistant.turn_end", "data": {}} 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 d788ef0dedab5..aeb24fd536538 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -20,7 +20,7 @@ import { ISessionTruncatedAction } from '../../../../../../platform/agentHost/co import { ICustomizationRef, TerminalClaimKind, type IProtectedResourceMetadata } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, ISessionTurnStartedAction, type ISessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { AttachmentType, getToolFileEdits, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, type IMessageAttachment, type IRootState, type ISessionInputAnswer, type ISessionInputRequest, type ISessionState, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { AttachmentType, buildSubagentSessionUri, getToolFileEdits, getToolSubagentContent, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ICompletedToolCall, type IMessageAttachment, type IRootState, type IResponsePart, type ISessionInputAnswer, type ISessionInputRequest, type ISessionState, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; @@ -34,10 +34,12 @@ import { IChatEditingService } from '../../../common/editing/chatEditingService. import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { ChatQuestionCarouselData } from '../../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; import { IChatAgentData, IChatAgentImplementation, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../../../common/participants/chatAgents.js'; +import { ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; import { getAgentHostIcon } from '../agentSessions.js'; import { AgentHostEditingSession } from './agentHostEditingSession.js'; import { IAgentHostTerminalService } from '../../../../terminal/browser/agentHostTerminalService.js'; -import { activeTurnToProgress, finalizeToolInvocation, getTerminalContentUri, toolCallStateToInvocation, turnsToHistory, type IToolCallFileEdit } from './stateToProgressAdapter.js'; +import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, getTerminalContentUri, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, type IToolCallFileEdit } from './stateToProgressAdapter.js'; +import { getToolKind } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; // ============================================================================= // AgentHostSessionHandler - renderer-side handler for a single agent host @@ -365,6 +367,11 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (sessionState) { history.push(...turnsToHistory(sessionState.turns, this._config.agentId)); + // Enrich history with inner tool calls from subagent + // child sessions. Subscribes to each child session so + // its tool calls appear grouped under the parent widget. + await this._enrichHistoryWithSubagentCalls(history, resolvedSession); + // Store turns with file edits so the editing session // can be hydrated when it's created lazily. const hasTurnsWithEdits = sessionState.turns.some(t => @@ -734,6 +741,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const activeToolInvocations = new Map(); const lastEmittedLengths = new Map(); const activeInputRequests = new Map(); + const observedSubagentToolIds = new Set(); const throttler = new Throttler(); turnDisposables.add(throttler); @@ -772,6 +780,10 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } const isActive = this._processSessionState(sessionState, ctx); this._syncInputRequests(activeInputRequests, sessionState.inputRequests, backendSession, CancellationToken.None, progress); + + // Observe subagent sessions for subagent tool calls + this._observeSubagentToolCalls(sessionState, turnId, activeToolInvocations, observedSubagentToolIds, backendSession, progress, turnDisposables); + if (!isActive && !finished) { finish(); } @@ -885,6 +897,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // Track last-emitted content lengths per response part to compute deltas const lastEmittedLengths = new Map(); + // Track subagent child sessions we're already observing + const observedSubagentToolIds = new Set(); + const turnDisposables = new DisposableStore(); // We throttle updates because generation of edits is async, if this breaks @@ -938,6 +953,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // Process input requests (ask_user tool elicitations) this._syncInputRequests(activeInputRequests, rawSessionState.inputRequests, session, cancellationToken, progress); + // Observe subagent sessions for subagent tool calls + this._observeSubagentToolCalls(rawSessionState, turnId, activeToolInvocations, observedSubagentToolIds, session, progress, turnDisposables); + if (!isActive && !finished) { finish(); } @@ -1033,6 +1051,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC ? tc.invocationMessage : new MarkdownString(tc.invocationMessage.markdown); this._reviveTerminalIfNeeded(existing, tc, ctx.backendSession); + updateRunningToolSpecificData(existing, tc); } // Finalize terminal-state tools @@ -1307,6 +1326,219 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return carousel; } + // ---- Subagent child session observation --------------------------------- + + /** + * Scans the response parts of a turn for subagent tool calls and starts + * observing their child sessions. Deduplicates against previously observed + * tool call IDs. + */ + private _observeSubagentToolCalls( + sessionState: ISessionState, + turnId: string, + activeToolInvocations: Map, + observedSubagentToolIds: Set, + backendSession: URI, + progress: (parts: IChatProgress[]) => void, + disposables: DisposableStore, + ): void { + const activeTurn = sessionState.activeTurn; + const isActiveTurn = activeTurn?.id === turnId; + const parts = isActiveTurn + ? activeTurn.responseParts + : sessionState.turns.find(t => t.id === turnId)?.responseParts; + if (!parts) { + return; + } + for (const rp of parts) { + if (rp.kind === ResponsePartKind.ToolCall) { + const tc = rp.toolCall; + const existing = activeToolInvocations.get(tc.toolCallId); + if (existing && !observedSubagentToolIds.has(tc.toolCallId) && (getToolKind(tc) === 'subagent' || ((tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Completed) && getToolSubagentContent(tc)))) { + observedSubagentToolIds.add(tc.toolCallId); + this._observeSubagentSession(backendSession, tc.toolCallId, progress, disposables, observedSubagentToolIds); + } + } + } + } + + /** + * Enriches serialized history with inner tool calls from subagent child + * sessions. For each subagent tool call found in the history, subscribes + * to the corresponding child session and appends its inner tool calls + * (with `subAgentInvocationId` set) to the response parts. + */ + private async _enrichHistoryWithSubagentCalls( + history: IChatSessionHistoryItem[], + parentSession: URI, + ): Promise { + const parentSessionStr = parentSession.toString(); + + for (const item of history) { + if (item.type !== 'response') { + continue; + } + + // Collect subagent tool calls from this response's parts + const subagentInsertions: { index: number; toolCallId: string }[] = []; + for (let i = 0; i < item.parts.length; i++) { + const part = item.parts[i]; + if (part.kind === 'toolInvocationSerialized' && part.toolSpecificData?.kind === 'subagent') { + subagentInsertions.push({ index: i, toolCallId: part.toolCallId }); + } + } + + // Process insertions in reverse order so indices remain valid + for (let j = subagentInsertions.length - 1; j >= 0; j--) { + const { index, toolCallId } = subagentInsertions[j]; + const childSessionUri = buildSubagentSessionUri(parentSessionStr, toolCallId); + + try { + const childSub = this._ensureSessionSubscription(childSessionUri); + let childState = this._getSessionState(childSessionUri); + if (!childState) { + if (childSub.value instanceof Error) { + throw childSub.value; + } + await new Promise(resolve => { + const d = childSub.onDidChange(() => { d.dispose(); resolve(); }); + }); + if (childSub.value instanceof Error) { + throw childSub.value; + } + childState = this._getSessionState(childSessionUri); + } + if (childState) { + const innerParts: IChatProgress[] = []; + for (const turn of childState.turns) { + for (const rp of turn.responseParts) { + if (rp.kind === ResponsePartKind.ToolCall) { + const tc = rp.toolCall; + if (tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Cancelled) { + const completedTc = tc as ICompletedToolCall; + const fileEditParts = completedToolCallToEditParts(completedTc); + const serialized = completedToolCallToSerialized(completedTc, toolCallId); + if (fileEditParts.length > 0) { + serialized.presentation = ToolInvocationPresentation.Hidden; + } + innerParts.push(serialized); + innerParts.push(...fileEditParts); + } + } + } + } + if (innerParts.length > 0) { + // Insert inner tool calls right after the subagent tool call + item.parts.splice(index + 1, 0, ...innerParts); + } + } + } catch (err) { + this._logService.warn(`[AgentHost] Failed to enrich history with subagent calls: ${childSessionUri}`, err); + } finally { + this._releaseSessionSubscription(childSessionUri); + } + } + } + } + + /** + * Subscribes to a child subagent session and forwards its tool calls + * as progress parts into the parent session's response, with + * `subAgentInvocationId` set so the renderer groups them under the parent + * subagent widget. + */ + private _observeSubagentSession( + parentSession: URI, + parentToolCallId: string, + emitProgress: (parts: IChatProgress[]) => void, + disposables: DisposableStore, + observedSet: Set, + ): void { + const childSessionUri = buildSubagentSessionUri(parentSession.toString(), parentToolCallId); + + const activeChildToolInvocations = new Map(); + const childCts = new CancellationTokenSource(); + disposables.add(toDisposable(() => childCts.dispose(true))); + + // Helper to process response parts from a child turn + const processChildParts = (responseParts: readonly IResponsePart[], turnId: string) => { + for (const rp of responseParts) { + if (rp.kind === ResponsePartKind.ToolCall) { + const tc = rp.toolCall; + let existing = activeChildToolInvocations.get(tc.toolCallId); + + if (!existing) { + existing = toolCallStateToInvocation(tc, parentToolCallId); + activeChildToolInvocations.set(tc.toolCallId, existing); + emitProgress([existing]); + + if (tc.status === ToolCallStatus.PendingConfirmation) { + this._awaitToolConfirmation(existing, tc.toolCallId, URI.parse(childSessionUri), turnId, childCts.token); + } + } else if (tc.status === ToolCallStatus.PendingConfirmation) { + const existingState = existing.state.get(); + if (existingState.type !== IChatToolInvocation.StateKind.WaitingForConfirmation) { + existing.didExecuteTool(undefined); + const confirmInvocation = toolCallStateToInvocation(tc, parentToolCallId); + activeChildToolInvocations.set(tc.toolCallId, confirmInvocation); + emitProgress([confirmInvocation]); + this._awaitToolConfirmation(confirmInvocation, tc.toolCallId, URI.parse(childSessionUri), turnId, childCts.token); + } + } else if (tc.status === ToolCallStatus.Running) { + updateRunningToolSpecificData(existing, tc); + } + + if (existing && (tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Cancelled) && !IChatToolInvocation.isComplete(existing)) { + finalizeToolInvocation(existing, tc); + } + } + } + }; + + try { + const childSub = this._ensureSessionSubscription(childSessionUri); + + // Attach the state listener BEFORE replaying the snapshot so any + // state change arriving in the gap is not lost. This mirrors the + // pattern used for parent turn observation. + disposables.add(childSub.onDidChange(state => { + if (disposables.isDisposed) { + return; + } + + const activeTurn = state.activeTurn; + const turnId = activeTurn?.id ?? state.turns[state.turns.length - 1]?.id; + const responseParts = activeTurn?.responseParts + ?? state.turns[state.turns.length - 1]?.responseParts; + + if (responseParts && turnId) { + processChildParts(responseParts, turnId); + } + })); + + // Replay any existing content from the child session snapshot + // (handles both active turns and already-completed ones) + const childState = this._getSessionState(childSessionUri); + if (childState) { + for (const turn of childState.turns) { + processChildParts(turn.responseParts, turn.id); + } + if (childState.activeTurn) { + processChildParts(childState.activeTurn.responseParts, childState.activeTurn.id); + } + } + + // Clean up when disposables are disposed + disposables.add(toDisposable(() => { + this._releaseSessionSubscription(childSessionUri); + })); + } catch (err) { + // Remove from observed set so a later state change can retry + observedSet.delete(parentToolCallId); + this._logService.warn(`[AgentHost] Failed to subscribe to subagent session: ${childSessionUri}`, err); + } + } + // ---- Reconnection to active turn ---------------------------------------- /** @@ -1346,6 +1578,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } const reconnectDisposables = chatSession.registerDisposable(new DisposableStore()); + const observedSubagentToolIds = new Set(); const throttler = new Throttler(); reconnectDisposables.add(throttler); @@ -1363,12 +1596,17 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // Wire up awaitConfirmation for tool calls that were already pending // confirmation at snapshot time so the user can approve/deny them. + // Also start observing any subagent tools that were already running. const cts = new CancellationTokenSource(); reconnectDisposables.add(toDisposable(() => cts.dispose(true))); for (const [toolCallId, invocation] of activeToolInvocations) { if (!IChatToolInvocation.isComplete(invocation)) { this._awaitToolConfirmation(invocation, toolCallId, backendSession, turnId, cts.token); } + if (invocation.toolSpecificData?.kind === 'subagent' && !observedSubagentToolIds.has(toolCallId)) { + observedSubagentToolIds.add(toolCallId); + this._observeSubagentSession(backendSession, toolCallId, (parts) => chatSession.appendProgress(parts), reconnectDisposables, observedSubagentToolIds); + } } // Track live input request carousels for reconnection @@ -1390,6 +1628,10 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const processStateChange = (sessionState: ISessionState) => { const isActive = this._processSessionState(sessionState, ctx); this._syncInputRequests(activeInputRequests, sessionState.inputRequests, backendSession, cts.token, appendProgress); + + // Observe subagent sessions for subagent tool calls + this._observeSubagentToolCalls(sessionState, turnId, activeToolInvocations, observedSubagentToolIds, backendSession, (parts: IChatProgress[]) => chatSession.appendProgress(parts), reconnectDisposables); + if (!isActive) { chatSession.complete(); reconnectDisposables.dispose(); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index d1b5a9460ac48..9bde042166a93 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -5,7 +5,8 @@ import { IMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { ToolCallStatus, TurnState, ResponsePartKind, getToolFileEdits, getToolOutputText, type IActiveTurn, type ICompletedToolCall, type IToolCallState, type ITurn, FileEditKind, ToolResultContentType, type IToolResultContent } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ToolCallStatus, TurnState, ResponsePartKind, getToolFileEdits, getToolOutputText, getToolSubagentContent, type IActiveTurn, type ICompletedToolCall, type IToolCallState, type ITurn, FileEditKind, ToolResultContentType, type IToolResultContent } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { getToolKind, getToolLanguage } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { type IChatProgress, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { type IChatSessionHistoryItem } from '../../../common/chatSessionsService.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; @@ -13,6 +14,52 @@ import { type IToolConfirmationMessages, type IToolData, ToolDataSource, ToolInv import { isEqual } from '../../../../../../base/common/resources.js'; import { hasKey } from '../../../../../../base/common/types.js'; +/** + * Extracts the task description from `_meta.subagentDescription`, which is + * populated from the tool's arguments at `tool_start` time by the event + * mapper. This is the short task description (e.g., "Find related files"), + * NOT the agent's own description. + */ +function getSubagentTaskDescription(tc: { _meta?: Record }): string | undefined { + const v = tc._meta?.subagentDescription; + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +/** + * Extracts the agent name from `_meta.subagentAgentName`. + */ +function getSubagentAgentName(tc: { _meta?: Record }): string | undefined { + const v = tc._meta?.subagentAgentName; + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +/** + * Known tool names that spawn subagent sessions. Used as a client-side + * fallback when the server hasn't set `_meta.toolKind` or subagent content + * (e.g. sessions restored by an older server version). + */ +const SUBAGENT_TOOL_NAMES: ReadonlySet = new Set(['task']); + +function isSubagentToolName(toolName: string): boolean { + return SUBAGENT_TOOL_NAMES.has(toolName); +} + +function getPtyTerminalData(meta: Record | undefined): { input?: string; output?: string } | undefined { + if (!meta) { + return undefined; + } + const value = meta['ptyTerminal']; + if (!value || typeof value !== 'object') { + return undefined; + } + const input = (value as { input?: unknown }).input; + const output = (value as { output?: unknown }).output; + return { + input: typeof input === 'string' ? input : undefined, + output: typeof output === 'string' ? output : undefined, + }; +} + /** * Finds a terminal content block in a tool call's content array. * Returns the terminal URI if found. @@ -126,28 +173,57 @@ export function activeTurnToProgress(activeTurn: IActiveTurn): IChatProgress[] { * Converts a completed tool call from the protocol state into a serialized * tool invocation suitable for history replay. */ -function completedToolCallToSerialized(tc: ICompletedToolCall): IChatToolInvocationSerialized { - const terminalUri = tc.status === ToolCallStatus.Completed ? getTerminalContentUri(tc.content) : undefined; - const isTerminal = !!terminalUri; +export function completedToolCallToSerialized(tc: ICompletedToolCall, subAgentInvocationId?: string): IChatToolInvocationSerialized { + const terminalContentUri = tc.status === ToolCallStatus.Completed ? getTerminalContentUri(tc.content) : undefined; + const isTerminal = getToolKind(tc) === 'terminal' || !!terminalContentUri; const isSuccess = tc.status === ToolCallStatus.Completed && tc.success; const invocationMsg = stringOrMarkdownToString(tc.invocationMessage) ?? ''; + // Check for subagent content + const subagentContent = tc.status === ToolCallStatus.Completed ? getToolSubagentContent(tc) : undefined; + const isSubagent = subagentContent || getToolKind(tc) === 'subagent' || isSubagentToolName(tc.toolName); + if (isSubagent && tc.status === ToolCallStatus.Completed) { + const resultText = getToolOutputText(tc); + const pastTenseMsg = isSuccess + ? stringOrMarkdownToString(tc.pastTenseMessage) ?? invocationMsg + : invocationMsg; + return { + kind: 'toolInvocationSerialized', + toolCallId: tc.toolCallId, + toolId: tc.toolName, + source: ToolDataSource.Internal, + invocationMessage: invocationMsg, + originMessage: undefined, + pastTenseMessage: pastTenseMsg, + isConfirmed: isSuccess + ? { type: ToolConfirmKind.ConfirmationNotNeeded } + : { type: ToolConfirmKind.Denied }, + isComplete: true, + presentation: undefined, + subAgentInvocationId: subAgentInvocationId, + toolSpecificData: { + kind: 'subagent', + description: getSubagentTaskDescription(tc) ?? tc.displayName, + agentName: subagentContent?.agentName ?? getSubagentAgentName(tc), + result: resultText, + }, + }; + } + let toolSpecificData: IChatTerminalToolInvocationData | undefined; if (isTerminal) { - const commandInput = tc.toolInput; - const toolOutput = tc.status === ToolCallStatus.Completed ? getToolOutputText(tc) : undefined; - if (!commandInput && toolOutput === undefined && !terminalUri) { - toolSpecificData = undefined; - } else { - toolSpecificData = { - kind: 'terminal', - commandLine: { original: commandInput ?? '' }, - language: 'shellscript', - terminalCommandOutput: toolOutput !== undefined ? { text: toolOutput } : undefined, - terminalCommandState: tc.status === ToolCallStatus.Completed ? { exitCode: isSuccess ? 0 : 1 } : undefined, - terminalCommandUri: terminalUri ? URI.parse(terminalUri) : undefined, - }; - } + const ptyTerminal = getPtyTerminalData(tc._meta); + const commandInput = ptyTerminal?.input ?? tc.toolInput; + const toolOutput = tc.status === ToolCallStatus.Completed ? (ptyTerminal?.output ?? getToolOutputText(tc)) : undefined; + toolSpecificData = { + kind: 'terminal', + commandLine: { original: commandInput ?? '' }, + language: getToolLanguage(tc) ?? 'shellscript', + terminalToolSessionId: terminalContentUri, + terminalCommandUri: terminalContentUri ? URI.parse(terminalContentUri) : undefined, + terminalCommandOutput: toolOutput !== undefined ? { text: toolOutput } : undefined, + terminalCommandState: { exitCode: isSuccess ? 0 : 1 }, + }; } const pastTenseMsg = isSuccess @@ -167,6 +243,7 @@ function completedToolCallToSerialized(tc: ICompletedToolCall): IChatToolInvocat : { type: ToolConfirmKind.Denied }, isComplete: true, presentation: undefined, + subAgentInvocationId: subAgentInvocationId, toolSpecificData, }; } @@ -176,7 +253,7 @@ function completedToolCallToSerialized(tc: ICompletedToolCall): IChatToolInvocat * produced file edits. Returns an empty array if the tool call has no edits. * These parts replay the undo stops and code-block UI when restoring history. */ -function completedToolCallToEditParts(tc: ICompletedToolCall): IChatProgress[] { +export function completedToolCallToEditParts(tc: ICompletedToolCall): IChatProgress[] { if (tc.status !== ToolCallStatus.Completed) { return []; } @@ -233,7 +310,7 @@ function stringOrMarkdownToString(value: string | { readonly markdown: string } * Creates a live {@link ChatToolInvocation} from the protocol's tool-call * state. Used during active turns to represent running tool calls in the UI. */ -export function toolCallStateToInvocation(tc: IToolCallState): ChatToolInvocation { +export function toolCallStateToInvocation(tc: IToolCallState, subAgentInvocationId?: string): ChatToolInvocation { const toolData: IToolData = { id: tc.toolName, source: ToolDataSource.Internal, @@ -251,7 +328,13 @@ export function toolCallStateToInvocation(tc: IToolCallState): ChatToolInvocatio }; let toolSpecificData: IChatTerminalToolInvocationData | IChatToolInputInvocationData | undefined; - if (tc.toolInput) { + if (getToolKind(tc) === 'terminal' && tc.toolInput) { + toolSpecificData = { + kind: 'terminal', + commandLine: { original: tc.toolInput }, + language: getToolLanguage(tc) ?? 'shellscript', + }; + } else if (tc.toolInput) { let rawInput: unknown; try { rawInput = JSON.parse(tc.toolInput); } catch { rawInput = { input: tc.toolInput }; } toolSpecificData = { kind: 'input', rawInput }; @@ -266,30 +349,89 @@ export function toolCallStateToInvocation(tc: IToolCallState): ChatToolInvocatio }, toolData, tc.toolCallId, - undefined, + subAgentInvocationId, undefined, ); } - const invocation = new ChatToolInvocation(undefined, toolData, tc.toolCallId, undefined, undefined); + const invocation = new ChatToolInvocation(undefined, toolData, tc.toolCallId, subAgentInvocationId, undefined); invocation.invocationMessage = stringOrMarkdownToString(tc.invocationMessage) ?? ''; - if (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Completed) { - const terminalUri = getTerminalContentUri(tc.content); - if (terminalUri) { + const terminalContentUri = (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Completed) + ? getTerminalContentUri(tc.content) + : undefined; + if (getToolKind(tc) === 'terminal' || terminalContentUri) { + const ptyTerminal = getPtyTerminalData(tc._meta); + const commandInput = ptyTerminal?.input ?? (tc.status !== ToolCallStatus.Streaming ? (tc.toolInput ?? '') : ''); + invocation.toolSpecificData = { + kind: 'terminal', + commandLine: { original: commandInput }, + language: getToolLanguage(tc) ?? 'shellscript', + terminalToolSessionId: terminalContentUri, + terminalCommandUri: terminalContentUri ? URI.parse(terminalContentUri) : undefined, + terminalCommandOutput: ptyTerminal?.output !== undefined ? { text: ptyTerminal.output } : undefined, + } satisfies IChatTerminalToolInvocationData; + } else if (getToolKind(tc) === 'subagent' || isSubagentToolName(tc.toolName)) { + // Subagent-spawning tool: set subagent toolSpecificData eagerly so the + // renderer groups it correctly from the start (before content arrives). + // Agent metadata is extracted from tool arguments in the event mapper. + const metaDesc = tc._meta?.subagentDescription; + const metaAgent = tc._meta?.subagentAgentName; + invocation.toolSpecificData = { + kind: 'subagent', + description: typeof metaDesc === 'string' ? metaDesc : undefined, + agentName: typeof metaAgent === 'string' ? metaAgent : undefined, + }; + } else if (tc.status === ToolCallStatus.Running) { + // Check for subagent content on initial creation (e.g. from snapshot) + const subagentContent = getToolSubagentContent(tc); + if (subagentContent) { invocation.toolSpecificData = { - kind: 'terminal', - commandLine: { original: tc.toolInput || '' }, - language: 'shellscript', - terminalCommandUri: URI.parse(terminalUri), - terminalToolSessionId: terminalUri, - } satisfies IChatTerminalToolInvocationData; + kind: 'subagent', + description: getSubagentTaskDescription(tc), + agentName: subagentContent.agentName, + }; } } return invocation; } +/** + * Updates a running tool invocation's `toolSpecificData` based on the + * protocol tool call state. Handles terminal and subagent content detection. + * + * Called from the session handler when a tool transitions to Running state + * to set the initial `toolSpecificData`, or when content changes arrive. + */ +export function updateRunningToolSpecificData(existing: ChatToolInvocation, tc: IToolCallState): void { + if (tc.status !== ToolCallStatus.Running) { + return; + } + existing.invocationMessage = typeof tc.invocationMessage === 'string' + ? tc.invocationMessage + : new MarkdownString(tc.invocationMessage.markdown); + if (getToolKind(tc) === 'terminal' && tc.toolInput) { + existing.toolSpecificData = { + kind: 'terminal', + commandLine: { original: tc.toolInput }, + language: getToolLanguage(tc) ?? 'shellscript', + }; + } else { + const subagentContent = getToolSubagentContent(tc); + if (subagentContent) { + existing.toolSpecificData = { + kind: 'subagent', + description: getSubagentTaskDescription(tc), + agentName: subagentContent.agentName, + }; + // toolSpecificData is a plain property — notify state observers + // so ChatSubagentContentPart re-reads the updated metadata. + existing.notifyToolSpecificDataChanged(); + } + } +} + /** * Data returned by {@link finalizeToolInvocation} describing file edits * that should be routed through the editing session's external edits pipeline. @@ -330,6 +472,20 @@ export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: ITool invocation.invocationMessage = stringOrMarkdownToString(tc.invocationMessage) ?? invocation.invocationMessage; } + // Check for subagent content — set toolSpecificData so the UI renders a subagent widget + if (isCompleted) { + const subagentContent = getToolSubagentContent(tc); + if (subagentContent) { + const resultText = getToolOutputText(tc); + invocation.toolSpecificData = { + kind: 'subagent', + description: getSubagentTaskDescription(tc), + agentName: subagentContent.agentName, + result: resultText, + }; + } + } + if (isTerminal && (isCompleted || isCancelled)) { const toolOutput = isCompleted ? getToolOutputText(tc) : undefined; const existing = invocation.toolSpecificData as IChatTerminalToolInvocationData | undefined; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts index 6ef9bbf8d1575..df4f02c00ea49 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -195,7 +195,8 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen const { description, isDefaultDescription, agentName, prompt, modelName } = ChatSubagentContentPart.extractSubagentInfo(toolInvocation); // Build title: "AgentName: description" or "Subagent: description" - const prefix = agentName || localize('chat.subagent.prefix', 'Subagent'); + const rawPrefix = agentName || localize('chat.subagent.prefix', 'Subagent'); + const prefix = rawPrefix.charAt(0).toUpperCase() + rawPrefix.slice(1); const initialTitle = `${prefix}: ${description}`; super(initialTitle, context, undefined, hoverService, configurationService); @@ -469,7 +470,8 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen } private updateTitle(): void { - const prefix = this.agentName || localize('chat.subagent.prefix', 'Subagent'); + const rawName = this.agentName || localize('chat.subagent.prefix', 'Subagent'); + const prefix = rawName.charAt(0).toUpperCase() + rawName.slice(1); const shimmerText = `${prefix}: ${this.description}`; const toolCallText = this.currentRunningToolMessage && this.isActive ? ` \u2014 ${this.currentRunningToolMessage}` : ``; @@ -738,6 +740,18 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen } this.renderPromptSection(); this.updateTitle(); + } else if (this._isDefaultDescription && toolInvocation.toolSpecificData?.kind === 'subagent') { + // toolSpecificData was updated after initial render (e.g. + // subagent content arrived via SessionToolCallContentChanged). + // Re-read metadata and update the title if real values are + // now available. + const { description, isDefaultDescription, agentName } = ChatSubagentContentPart.extractSubagentInfo(toolInvocation); + if (!isDefaultDescription || agentName) { + this.description = description; + this._isDefaultDescription = isDefaultDescription; + this.agentName = agentName; + this.updateTitle(); + } } })); } else if (toolInvocation.toolSpecificData?.kind === 'subagent' && toolInvocation.toolSpecificData.result) { diff --git a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts index 3789147dfd2e5..d6a3b9a1e2cee 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts @@ -177,6 +177,16 @@ export class ChatToolInvocation implements IChatToolInvocation { this._streamingMessage.set(message, undefined); } + /** + * Notifies state observers that `toolSpecificData` has been mutated. + * Since `toolSpecificData` isn't observable, this re-sets the internal + * state to trigger autoruns that need to re-read tool metadata. + */ + public notifyToolSpecificDataChanged(): void { + const current = this._state.get(); + this._state.set({ ...current }, undefined); + } + /** * Cancel a streaming invocation directly (e.g., when preToolUse hook denies). * Only works when in Streaming state. diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index e40e7c7fd496f..3754648020311 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -4,12 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { autorun } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, type IActiveTurn, type ICompletedToolCall, type IToolCallRunningState, type ITurn, type IToolCallResponsePart, ToolCallCancellationReason } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IChatToolInvocationSerialized, type IChatMarkdownContent } from '../../../common/chatService/chatService.js'; import { ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; -import { turnsToHistory, activeTurnToProgress, toolCallStateToInvocation, finalizeToolInvocation } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; +import { turnsToHistory, activeTurnToProgress, toolCallStateToInvocation, finalizeToolInvocation, updateRunningToolSpecificData } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; // ---- Helper factories ------------------------------------------------------- @@ -117,6 +118,65 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(termData.terminalCommandState.exitCode, 0); }); + test('subagent tool call in history has correct subagent data', () => { + const turn = createTurn({ + responseParts: [{ + kind: ResponsePartKind.ToolCall, toolCall: createCompletedToolCall({ + _meta: { toolKind: 'subagent', subagentDescription: 'Find related files' }, + content: [ + { type: ToolResultContentType.Text, text: 'Agent result' }, + { type: ToolResultContentType.Subagent, resource: 'copilot://session/subagent/tc-1', title: 'Explore', agentName: 'explore', description: 'Explores the codebase' }, + ], + success: true, + }) + } as IToolCallResponsePart], + }); + + const history = turnsToHistory([turn], 'p'); + const response = history[1]; + assert.strictEqual(response.type, 'response'); + if (response.type !== 'response') { return; } + const serialized = response.parts[0] as IChatToolInvocationSerialized; + + assert.ok(serialized.toolSpecificData); + assert.strictEqual(serialized.toolSpecificData.kind, 'subagent'); + if (serialized.toolSpecificData.kind === 'subagent') { + assert.strictEqual(serialized.toolSpecificData.agentName, 'explore'); + // description is the TASK description from _meta, not the agent description + assert.strictEqual(serialized.toolSpecificData.description, 'Find related files'); + assert.strictEqual(serialized.toolSpecificData.result, 'Agent result'); + } + }); + + test('subagent tool without content falls back to toolKind meta', () => { + // This happens when the in-memory state lost subagent content + // (e.g. tool_complete overwrote it before the merge fix) + const turn = createTurn({ + responseParts: [{ + kind: ResponsePartKind.ToolCall, toolCall: createCompletedToolCall({ + toolName: 'task', + displayName: 'Task', + _meta: { toolKind: 'subagent' }, + content: [{ type: ToolResultContentType.Text, text: 'Result text' }], + success: true, + }) + } as IToolCallResponsePart], + }); + + const history = turnsToHistory([turn], 'p'); + const response = history[1]; + assert.strictEqual(response.type, 'response'); + if (response.type !== 'response') { return; } + const serialized = response.parts[0] as IChatToolInvocationSerialized; + + assert.ok(serialized.toolSpecificData); + assert.strictEqual(serialized.toolSpecificData.kind, 'subagent'); + if (serialized.toolSpecificData.kind === 'subagent') { + assert.strictEqual(serialized.toolSpecificData.description, 'Task'); + assert.strictEqual(serialized.toolSpecificData.result, 'Result text'); + } + }); + test('turn with responseText produces markdown content in history', () => { const turn = createTurn({ responseParts: [{ kind: ResponsePartKind.Markdown, id: 'md-1', content: 'Hello world' }], @@ -212,6 +272,27 @@ suite('stateToProgressAdapter', () => { const invocation = toolCallStateToInvocation(tc); assert.strictEqual(invocation.toolCallId, 'tc-1'); }); + + test('sets subagent toolSpecificData from _meta for subagent toolKind', () => { + const tc = createToolCallState({ + _meta: { toolKind: 'subagent', subagentDescription: 'Review code', subagentAgentName: 'code-reviewer' }, + }); + + const invocation = toolCallStateToInvocation(tc); + assert.ok(invocation.toolSpecificData); + assert.strictEqual(invocation.toolSpecificData.kind, 'subagent'); + if (invocation.toolSpecificData.kind === 'subagent') { + assert.strictEqual(invocation.toolSpecificData.description, 'Review code'); + assert.strictEqual(invocation.toolSpecificData.agentName, 'code-reviewer'); + } + }); + + test('passes subAgentInvocationId to ChatToolInvocation', () => { + const tc = createToolCallState({}); + + const invocation = toolCallStateToInvocation(tc, 'parent-tc-42'); + assert.strictEqual(invocation.subAgentInvocationId, 'parent-tc-42'); + }); }); suite('finalizeToolInvocation', () => { @@ -632,4 +713,63 @@ suite('stateToProgressAdapter', () => { }); }); + + suite('updateRunningToolSpecificData', () => { + + test('sets subagent toolSpecificData from content and notifies state observers', () => { + const tc = createToolCallState({ + _meta: { toolKind: 'subagent', subagentDescription: 'Find related files' }, + }); + const invocation = toolCallStateToInvocation(tc); + assert.strictEqual(invocation.toolSpecificData?.kind, 'subagent'); + + // Simulate subagent content arriving via SessionToolCallContentChanged + const runningTc: IToolCallRunningState = { + ...tc, + status: ToolCallStatus.Running, + _meta: { toolKind: 'subagent', subagentDescription: 'Find related files' }, + content: [{ + type: ToolResultContentType.Subagent, + resource: 'copilot://session/subagent/tc-1', + title: 'Explore', + agentName: 'explore', + description: 'Explores the codebase', + }], + }; + + let stateChanged = false; + const disposable = autorun(r => { + invocation.state.read(r); + stateChanged = true; + }); + stateChanged = false; // reset after initial read + const before = invocation.toolSpecificData; + + updateRunningToolSpecificData(invocation, runningTc); + + assert.strictEqual(stateChanged, true, 'state observers should be notified'); + assert.notStrictEqual(invocation.toolSpecificData, before, 'toolSpecificData should be replaced'); + assert.strictEqual(invocation.toolSpecificData?.kind, 'subagent'); + if (invocation.toolSpecificData?.kind === 'subagent') { + assert.strictEqual(invocation.toolSpecificData.agentName, 'explore'); + // description is the TASK description from _meta, not the agent description + assert.strictEqual(invocation.toolSpecificData.description, 'Find related files'); + } + disposable.dispose(); + }); + + test('does not notify when no subagent content is present', () => { + const tc = createToolCallState({}); + const invocation = toolCallStateToInvocation(tc); + const originalData = invocation.toolSpecificData; + + const runningTc: IToolCallRunningState = { + ...tc, + status: ToolCallStatus.Running, + }; + + updateRunningToolSpecificData(invocation, runningTc); + assert.strictEqual(invocation.toolSpecificData, originalData, 'toolSpecificData should not change'); + }); + }); }); From b8323ca4fe01bfe8a72be3cff996ceca4a47a7e1 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Thu, 9 Apr 2026 22:40:08 -0700 Subject: [PATCH 04/15] perf: Fix leak in rendering markdown/edits in thinking/subagent parts (#308939) Co-authored-by: Copilot --- .../chatSubagentContentPart.ts | 26 ++++- .../chatThinkingContentPart.ts | 19 ++- .../chat/browser/widget/chatListRenderer.ts | 11 +- .../chatThinkingContentPart.test.ts | 110 ++++++++++++++++++ 4 files changed, 158 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts index df4f02c00ea49..ea877c2f8577e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -58,6 +58,12 @@ interface ILazyToolItem { interface ILazyMarkdownItem { kind: 'markdown'; lazy: Lazy<{ domNode: HTMLElement; disposable?: IDisposable }>; + /** + * True when the caller passed an eagerDisposable that has already been registered on this + * subagent part. In that case, materializeLazyItem must not register the factory's returned + * disposable again. + */ + eagerlyRegistered?: boolean; } /** @@ -864,18 +870,31 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen /** * Appends a markdown item (e.g., an edit pill) to the subagent content part. * This is used to route codeblockUri parts with subAgentInvocationId to this subagent's container. + * + * When the caller has already created the content part eagerly (for example, a + * pre-built `ChatMarkdownContentPart` wrapped in a factory), the caller MUST pass + * that part as `eagerDisposable` so it is registered on this subagent part + * immediately. Otherwise, if the subagent section is collapsed and the lazy item + * is never materialized, the eagerly-created part would leak. */ public appendMarkdownItem( factory: () => { domNode: HTMLElement; disposable?: IDisposable }, _codeblocksPartId: string | undefined, _markdown: IChatMarkdownContent, - _originalParent?: HTMLElement + _originalParent?: HTMLElement, + eagerDisposable?: IDisposable, ): void { + // Register any caller-owned disposable up-front so it is always cleaned up + // with this subagent part, even if the lazy item is never materialized. + if (eagerDisposable) { + this._register(eagerDisposable); + } + // If expanded or has been expanded once, render immediately if (this.isExpanded() || this.hasExpandedOnce) { const result = factory(); this.appendMarkdownItemToDOM(result.domNode); - if (result.disposable) { + if (result.disposable && result.disposable !== eagerDisposable) { this._register(result.disposable); } } else { @@ -883,6 +902,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen const item: ILazyMarkdownItem = { kind: 'markdown', lazy: new Lazy(factory), + eagerlyRegistered: !!eagerDisposable, }; this.lazyItems.push(item); } @@ -1079,7 +1099,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen } else if (item.kind === 'markdown') { const result = item.lazy.value; this.appendMarkdownItemToDOM(result.domNode); - if (result.disposable) { + if (result.disposable && !item.eagerlyRegistered) { this._register(result.disposable); } } else if (item.kind === 'hook') { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index 343f22dbd32f2..2a0ab1b862092 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -1330,13 +1330,22 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): * Appends a tool invocation or content item to the thinking group. * The factory is called lazily - only when the thinking section is expanded. * If already expanded, the factory is called immediately. + * + * When the caller has already created the content part eagerly (for example, a + * pre-built `ChatMarkdownContentPart` wrapped in a factory), the caller MUST pass + * that part as `eagerDisposable` so it is registered on this thinking part + * immediately. Otherwise, if the thinking section is collapsed and the lazy item + * is never materialized (because the user never expands it), the eagerly-created + * part would leak: its disposable is only referenced from inside the factory's + * closure, which nothing ever calls. */ public appendItem( factory: () => { domNode: HTMLElement; disposable?: IDisposable }, toolInvocationId?: string, toolInvocationOrMarkdown?: IChatToolInvocation | IChatToolInvocationSerialized | IChatMarkdownContent, originalParent?: HTMLElement, - onDidChangeDiff?: Event + onDidChangeDiff?: Event, + eagerDisposable?: IDisposable, ): void { this.processPendingRemovals(); @@ -1352,6 +1361,12 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): })); } + // Register any caller-owned disposable up-front so it is always cleaned up + // with this thinking part, even if the lazy item is never materialized. + if (eagerDisposable) { + this._register(eagerDisposable); + } + // get random message based on tool type if (this.workingSpinnerLabel) { const isTerminalTool = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') && toolInvocationOrMarkdown.toolSpecificData?.kind === 'terminal'; @@ -1379,7 +1394,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): toolInvocationId, toolInvocationOrMarkdown, originalParent, - isHook: !toolInvocationOrMarkdown && !!toolInvocationId + isHook: !toolInvocationOrMarkdown && !!toolInvocationId, }; this.lazyItems.push(item); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 2f95f10093c94..16ddb589eeecd 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -2689,7 +2689,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer ({ domNode: markdownPart.domNode, disposable: markdownPart }), markdownPart.codeblocksPartId, markdown, - templateData.value + templateData.value, + markdownPart, ); return subagentPart; } @@ -2709,7 +2710,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer ({ domNode: markdownPart.domNode, disposable: markdownPart }), markdownPart.codeblocksPartId, diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts index f8e6269b79352..4bdd4654441f1 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts @@ -1606,4 +1606,114 @@ suite('ChatThinkingContentPart', () => { assert.strictEqual(diffContainer, null, 'Should not render diff container when no diffs exist'); }); }); + + suite('eagerDisposable lifecycle', () => { + setup(() => { + mockConfigurationService.setUserConfiguration('chat.agent.thinkingStyle', ThinkingDisplayMode.Collapsed); + }); + + test('eagerDisposable is disposed when thinking part is disposed even if factory was never called', () => { + const content = createThinkingPart('**Working**'); + const context = createMockRenderContext(false); + + const part = instantiationService.createInstance( + ChatThinkingContentPart, + content, + context, + mockMarkdownRenderer, + false + ); + + mainWindow.document.body.appendChild(part.domNode); + + let disposed = false; + const eagerDisposable = toDisposable(() => { disposed = true; }); + const factory = () => ({ + domNode: $('div.test-item'), + disposable: eagerDisposable, + }); + + // Append while collapsed — factory is NOT called + part.appendItem(factory, 'test-tool', undefined, undefined, undefined, eagerDisposable); + + assert.strictEqual(disposed, false, 'Should not be disposed yet'); + + // Dispose the thinking part without ever expanding + part.domNode.remove(); + part.dispose(); + + assert.strictEqual(disposed, true, 'eagerDisposable should be disposed with the thinking part'); + }); + + test('eagerDisposable is disposed when thinking part is disposed after factory was called', () => { + const content = createThinkingPart('**Working**\nSome detailed analysis'); + const context = createMockRenderContext(false); + + const part = instantiationService.createInstance( + ChatThinkingContentPart, + content, + context, + mockMarkdownRenderer, + false + ); + + mainWindow.document.body.appendChild(part.domNode); + + let disposed = false; + const eagerDisposable = toDisposable(() => { disposed = true; }); + const factory = () => ({ + domNode: $('div.test-item'), + disposable: eagerDisposable, + }); + + // Append while collapsed + part.appendItem(factory, 'test-tool', undefined, undefined, undefined, eagerDisposable); + + // Expand to trigger factory call + const button = part.domNode.querySelector('.monaco-button') as HTMLElement; + button?.click(); + + assert.strictEqual(disposed, false, 'Should not be disposed yet'); + + // Dispose + part.domNode.remove(); + part.dispose(); + + assert.strictEqual(disposed, true, 'eagerDisposable should be disposed even after being materialized'); + }); + + test('appendItem without eagerDisposable disposes factory result on thinking part disposal', () => { + const content = createThinkingPart('**Working**\nSome detailed analysis'); + const context = createMockRenderContext(false); + + const part = instantiationService.createInstance( + ChatThinkingContentPart, + content, + context, + mockMarkdownRenderer, + false + ); + + mainWindow.document.body.appendChild(part.domNode); + + // Expand first so factory is called immediately + const button = part.domNode.querySelector('.monaco-button') as HTMLElement; + button?.click(); + + let disposed = false; + const factory = () => ({ + domNode: $('div.test-item'), + disposable: toDisposable(() => { disposed = true; }), + }); + + part.appendItem(factory, 'test-tool'); + + assert.strictEqual(disposed, false, 'Should not be disposed yet'); + + part.domNode.remove(); + part.dispose(); + + assert.strictEqual(disposed, true, 'Factory disposable should be disposed with thinking part'); + }); + }); }); From 78e6db311e35d3b3532f74b17c0f4e99acaf3e65 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 10 Apr 2026 15:48:05 +1000 Subject: [PATCH 05/15] Enhance welcome view to pre-select and deduplicate selected folder (#308948) feat: enhance welcome view to pre-select and deduplicate selected folder in chat session options --- .../vscode-node/sessionOptionGroupBuilder.ts | 22 +++++++++----- .../test/sessionOptionGroupBuilder.spec.ts | 30 +++++++++++++++++++ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts index ced3dab0e42e4..aa06a9a288782 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts @@ -401,6 +401,15 @@ export class SessionOptionGroupBuilder implements ISessionOptionGroupBuilder { const repositories = await this.copilotCLIFolderMruService.getRecentlyUsedFolders(CancellationToken.None); const newFolder = previousInputState ? this._inputStateNewFolders.get(previousInputState) : undefined; items = folderMRUToChatProviderOptions(repositories); + const selectedFolderItem = selectedFolderUri ? items.find(i => i.id === selectedFolderUri.fsPath) : undefined; + const selectedItem = selectedFolderItem + ?? (previouslySelected + ? items.find(i => i.id === previouslySelected.id) ?? items[0] + : items[0]); + if (selectedItem) { + defaultRepoUri = vscode.Uri.file(selectedItem.id); + } + items.splice(MAX_MRU_ENTRIES); // Limit to max entries if (newFolder) { const newFolderRepo = await this.getTrustedRepository(newFolder, true); @@ -411,19 +420,16 @@ export class SessionOptionGroupBuilder implements ISessionOptionGroupBuilder { items = items.filter(item => item.id !== newFolderItem.id); items.unshift(newFolderItem); } + // If user selected something from the list but it's not there anymore (perhaps its an item at the end of MRU). + if (selectedItem && !items.some(item => item.id === selectedItem.id)) { + items.push(selectedItem); + } + commands.push({ command: OPEN_REPOSITORY_COMMAND_ID, title: l10n.t('Browse folders...') }); - const selectedFolderItem = selectedFolderUri ? items.find(i => i.id === selectedFolderUri.fsPath) : undefined; - const selectedItem = selectedFolderItem - ?? (previouslySelected - ? items.find(i => i.id === previouslySelected.id) ?? items[0] - : items[0]); - if (selectedItem) { - defaultRepoUri = vscode.Uri.file(selectedItem.id); - } optionGroups.push({ id: REPOSITORY_OPTION_ID, name: l10n.t('Folder'), diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts index 3db300834b09a..d975dc953ad1a 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts @@ -895,6 +895,36 @@ describe('SessionOptionGroupBuilder', () => { expect(repoGroup!.items[0].id).toBe(sharedUri.fsPath); }); + it('does not duplicate selected item when new folder replaces its MRU entry', async () => { + // Regression: the selected item was resolved from MRU before + // deduplication replaced it with a fresh object. Using reference + // equality (Array.includes) caused the stale reference to be + // re-appended, creating a duplicate. + workspaceService = new NullWorkspaceService([]); + builder = new SessionOptionGroupBuilder( + gitService, configurationService, context, workspaceService, + folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager, + ); + const repoUri = URI.file('/my-repo'); + folderMruService.getRecentlyUsedFolders.mockResolvedValue([ + { folder: repoUri, repository: repoUri, lastAccessed: Date.now() }, + ]); + gitService.getRepository.mockResolvedValue(makeRepo(repoUri.fsPath)); + + const previousState: vscode.ChatSessionInputState = { + onDidChange: Event.None, + groups: [], + }; + builder.setNewFolderForInputState(previousState, repoUri as any); + + const groups = await builder.provideChatSessionProviderOptionGroups(previousState); + const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID)!; + // Selected item must reference an object that is in the items list + expect(repoGroup.items.some(i => i.id === repoGroup.selected?.id)).toBe(true); + // And there must be exactly one item with that id + expect(repoGroup.items.filter(i => i.id === repoUri.fsPath)).toHaveLength(1); + }); + it('does not add new folder when no previousInputState', async () => { workspaceService = new NullWorkspaceService([]); builder = new SessionOptionGroupBuilder( From 05d42a27ac24075638640df784aa64db0c8ad551 Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Thu, 9 Apr 2026 22:55:00 -0700 Subject: [PATCH 06/15] Fix double compaction on first-turn budget exceeded (#308949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix double compaction on first-turn budget exceeded When the first render of a turn throws BudgetExceededError and the background summarizer is Idle, we fall back to a synchronous foreground 'full' summarization via renderWithSummarization. That path did not set the 'summary applied this iteration' flag, so the post-render gate (>= 80% + Idle) would also kick off a background 'inline' compaction in the same buildPrompt call — producing both summarizeConversationHistory-full and summarizeConversationHistory-inline. - Set the flag on both foreground fallback call sites so the post-render gate correctly short-circuits. - Rename 'summaryAppliedThisIteration' to 'didSummarizeThisIteration' to better reflect that it covers any summarization work (pre-render bg apply, budget-exceeded bg apply, or foreground fallback). * Update extensions/copilot/src/extension/intents/node/agentIntent.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/extension/intents/node/agentIntent.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/extensions/copilot/src/extension/intents/node/agentIntent.ts b/extensions/copilot/src/extension/intents/node/agentIntent.ts index a5272f7b092d7..a2cdae7fa7de8 100644 --- a/extensions/copilot/src/extension/intents/node/agentIntent.ts +++ b/extensions/copilot/src/extension/intents/node/agentIntent.ts @@ -465,9 +465,10 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I ? (this._lastRenderTokenCount + toolTokens) / baseBudget : 0; - // Track whether we applied a summary in this iteration so we don't - // immediately re-trigger background compaction in the post-render check. - let summaryAppliedThisIteration = false; + // Track whether this iteration already performed compaction-related work + // (including applying a summary or using a foreground fallback path) so + // we don't immediately re-trigger background compaction in the post-render check. + let didSummarizeThisIteration = false; // If a previous background pass completed, apply its summary now. if (summarizationEnabled && backgroundSummarizer?.state === BackgroundSummarizationState.Completed) { @@ -478,7 +479,7 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I this._applySummaryToRounds(bgResult, promptContext); this._persistSummaryOnTurn(bgResult, promptContext, this._lastRenderTokenCount); this._sendBackgroundCompactionTelemetry('preRender', 'applied', contextRatio, promptContext); - summaryAppliedThisIteration = true; + didSummarizeThisIteration = true; } else { this.logService.warn(`[ConversationHistorySummarizer] background compaction state was Completed but consumeAndReset returned no result`); this._sendBackgroundCompactionTelemetry('preRender', 'noResult', contextRatio, promptContext); @@ -611,7 +612,7 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I this._applySummaryToRounds(bgResult, promptContext); this._persistSummaryOnTurn(bgResult, promptContext, contextLengthBefore); this._sendBackgroundCompactionTelemetry(budgetExceededTrigger, 'applied', contextRatio, promptContext); - summaryAppliedThisIteration = true; + didSummarizeThisIteration = true; // Re-render with the compacted history const renderer = PromptRenderer.create(this.instantiationService, endpoint, this.prompt, { ...props, promptContext }); result = await renderer.render(progress, token); @@ -621,9 +622,11 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I this._recordBackgroundCompactionFailure(promptContext, budgetExceededTrigger); // Background compaction failed — fall back to synchronous summarization result = await renderWithSummarization(`budget exceeded(${e.message}), background compaction failed`); + didSummarizeThisIteration = true; } } else { result = await renderWithSummarization(`budget exceeded(${e.message})`); + didSummarizeThisIteration = true; } } else { throw e; @@ -656,7 +659,7 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I } // Post-render: kick off background compaction at ≥ 80% if idle. - if (summarizationEnabled && backgroundSummarizer && !summaryAppliedThisIteration) { + if (summarizationEnabled && backgroundSummarizer && !didSummarizeThisIteration) { const postRenderRatio = baseBudget > 0 ? (result.tokenCount + toolTokens) / baseBudget : 0; From e9d8794d1de7da01ab1ea005699ba9dfe82a3a1a Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 10 Apr 2026 16:29:15 +1000 Subject: [PATCH 07/15] feat(CopilotCLI): support reasoning effort (#308951) * feat(CopilotCLI): support reasoning effort * Enable reasoning effort for Copilot CLI --- extensions/copilot/package.json | 2 +- .../copilotCLIChatSessionsContribution.ts | 46 +++++++++++++------ .../common/configurationService.ts | 2 +- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index e5f7629bd5bdf..7e20226b77530 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -4582,7 +4582,7 @@ }, "github.copilot.chat.cli.thinkingEffort.enabled": { "type": "boolean", - "default": false, + "default": true, "markdownDescription": "%github.copilot.config.cli.thinkingEffort.enabled%", "tags": [ "advanced" diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index daa601e5bdb7f..089a54b882d04 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -44,7 +44,7 @@ import { emptyWorkspaceInfo, getWorkingDirectory, isIsolationEnabled, IWorkspace import { ICustomSessionTitleService } from '../copilotcli/common/customSessionTitleService'; import { IChatDelegationSummaryService } from '../copilotcli/common/delegationSummaryService'; import { getCopilotCLISessionDir } from '../copilotcli/node/cliHelpers'; -import { ICopilotCLIAgents, ICopilotCLIModels, ICopilotCLISDK, isWelcomeView } from '../copilotcli/node/copilotCli'; +import { COPILOT_CLI_REASONING_EFFORT_PROPERTY, ICopilotCLIAgents, ICopilotCLIModels, ICopilotCLISDK, isWelcomeView } from '../copilotcli/node/copilotCli'; import { CopilotCLIPromptResolver } from '../copilotcli/node/copilotcliPromptResolver'; import { builtinSlashSCommands, CopilotCLICommand, copilotCLICommands, ICopilotCLISession } from '../copilotcli/node/copilotcliSession'; import { ICopilotCLISessionItem, ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionService'; @@ -447,6 +447,10 @@ function isIsolationOptionFeatureEnabled(configurationService: IConfigurationSer return configurationService.getConfig(ConfigKey.Advanced.CLIIsolationOption); } +function isReasoningEffortFeatureEnabled(configurationService: IConfigurationService): boolean { + return configurationService.getConfig(ConfigKey.Advanced.CLIThinkingEffortEnabled); +} + export class CopilotCLIChatSessionContentProvider extends Disposable implements vscode.ChatSessionContentProvider { private readonly _onDidChangeChatSessionOptions = this._register(new Emitter()); readonly onDidChangeChatSessionOptions = this._onDidChangeChatSessionOptions.event; @@ -1349,23 +1353,23 @@ export class CopilotCLIChatSessionParticipant extends Disposable { // This is a request that was created in createCLISessionAndSubmitRequest with attachments already resolved. const { prompt, attachments } = contextForRequest; this.contextForRequest.delete(session.object.sessionId); - await session.object.handleRequest(request, { prompt }, attachments, model ? { model } : undefined, authInfo, token); + await session.object.handleRequest(request, { prompt }, attachments, model, authInfo, token); await this.commitWorktreeChangesIfNeeded(request, session.object, token); } else if (request.command && !request.prompt && !isUntitled) { const input = (copilotCLICommands as readonly string[]).includes(request.command) ? { command: request.command as CopilotCLICommand, prompt: '' } : { prompt: `/${request.command}` }; - await session.object.handleRequest(request, input, [], model ? { model } : undefined, authInfo, token); + await session.object.handleRequest(request, input, [], model, authInfo, token); await this.commitWorktreeChangesIfNeeded(request, session.object, token); } else if (request.prompt && Object.values(builtinSlashSCommands).some(command => request.prompt.startsWith(command))) { // Sessions app built-in slash commands const { prompt, attachments } = await this.promptResolver.resolvePrompt(request, undefined, [], session.object.workspace, [], token); - await session.object.handleRequest(request, { prompt }, attachments, model ? { model } : undefined, authInfo, token); + await session.object.handleRequest(request, { prompt }, attachments, model, authInfo, token); await this.commitWorktreeChangesIfNeeded(request, session.object, token); } else { // Construct the full prompt with references to be sent to CLI. const { prompt, attachments } = await this.promptResolver.resolvePrompt(request, undefined, [], session.object.workspace, [], token); - await session.object.handleRequest(request, { prompt }, attachments, model ? { model } : undefined, authInfo, token); + await session.object.handleRequest(request, { prompt }, attachments, model, authInfo, token); await this.commitWorktreeChangesIfNeeded(request, session.object, token); } @@ -1671,7 +1675,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { } } - private async getOrCreateSession(request: vscode.ChatRequest, chatSessionContext: vscode.ChatSessionContext, stream: vscode.ChatResponseStream, options: { model: string | undefined; agent: SweCustomAgent | undefined }, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference | undefined; trusted: boolean }> { + private async getOrCreateSession(request: vscode.ChatRequest, chatSessionContext: vscode.ChatSessionContext, stream: vscode.ChatResponseStream, options: { model: { model: string; reasoningEffort?: string } | undefined; agent: SweCustomAgent | undefined }, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference | undefined; trusted: boolean }> { const { resource } = chatSessionContext.chatSessionItem; const existingSessionId = this.sessionItemProvider.untitledSessionIdMapping.get(SessionIdForCLI.parse(resource)); const id = existingSessionId ?? SessionIdForCLI.parse(resource); @@ -1689,8 +1693,8 @@ export class CopilotCLIChatSessionParticipant extends Disposable { const debugTargetSessionIds = extractDebugTargetSessionIds(request.references); const mcpServerMappings = buildMcpServerMappings(request.tools); const session = isNewSession ? - await this.sessionService.createSession({ model, workspace: workspaceInfo, agent, debugTargetSessionIds, mcpServerMappings }, token) : - await this.sessionService.getSession({ sessionId: id, model, workspace: workspaceInfo, agent, debugTargetSessionIds, mcpServerMappings }, token); + await this.sessionService.createSession({ model: model?.model, reasoningEffort: model?.reasoningEffort, workspace: workspaceInfo, agent, debugTargetSessionIds, mcpServerMappings }, token) : + await this.sessionService.getSession({ sessionId: id, model: model?.model, reasoningEffort: model?.reasoningEffort, workspace: workspaceInfo, agent, debugTargetSessionIds, mcpServerMappings }, token); this.sessionItemProvider.notifySessionsChange(); // TODO @DonJayamanne We need to refresh to add this new session, but we need a label. // So when creating a session we need a dummy label (or an initial prompt). @@ -1718,15 +1722,29 @@ export class CopilotCLIChatSessionParticipant extends Disposable { return { session, trusted }; } - private async getModelId(request: vscode.ChatRequest | undefined, token: vscode.CancellationToken): Promise { + private async getModelId(request: vscode.ChatRequest | undefined, token: vscode.CancellationToken): Promise<{ model: string; reasoningEffort?: string } | undefined> { const promptFile = request ? await this.getPromptInfoFromRequest(request, token) : undefined; const model = promptFile?.header?.model ? await getModelFromPromptFile(promptFile.header.model, this.copilotCLIModels) : undefined; - if (model || token.isCancellationRequested) { - return model; + if (token.isCancellationRequested) { + return undefined; + } + if (model) { + return { model }; } // Get model from request. const preferredModelInRequest = request?.model?.id ? await this.copilotCLIModels.resolveModel(request.model.id) : undefined; - return preferredModelInRequest ?? await this.copilotCLIModels.getDefaultModel(); + if (preferredModelInRequest) { + const reasoningEffort = isReasoningEffortFeatureEnabled(this.configurationService) ? request?.modelConfiguration?.[COPILOT_CLI_REASONING_EFFORT_PROPERTY] : undefined; + return { + model: preferredModelInRequest, + reasoningEffort: typeof reasoningEffort === 'string' && reasoningEffort ? reasoningEffort : undefined + }; + } + const defaultModel = await this.copilotCLIModels.getDefaultModel(); + if (!defaultModel) { + return undefined; + } + return { model: defaultModel }; } private async handleDelegationToCloud(session: ICopilotCLISession, request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) { @@ -1835,7 +1853,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { const { prompt, attachments, references } = await this.promptResolver.resolvePrompt(request, await requestPromptPromise, (otherReferences || []).concat([]), workspaceInfo, [], token); const mcpServerMappings = buildMcpServerMappings(request.tools); - const session = await this.sessionService.createSession({ workspace: workspaceInfo, agent, model, mcpServerMappings }, token); + const session = await this.sessionService.createSession({ workspace: workspaceInfo, agent, model: model?.model, reasoningEffort: model?.reasoningEffort, mcpServerMappings }, token); const modeInstructions = this.createModeInstructions(request); this.chatSessionMetadataStore.updateRequestDetails(session.object.sessionId, [{ vscodeRequestId: request.id, agentId: agent?.name ?? '', modeInstructions }]).catch(ex => this.logService.error(ex, 'Failed to update request details')); if (summary) { @@ -1869,7 +1887,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { // The caller is most likely a chat editor or the like. // Now that we've delegated it to a session, we can get out of here. // Else if the request takes say 10 minutes, the caller would be blocked for that long. - session.object.handleRequest(request, { prompt }, attachments, model ? { model } : undefined, authInfo, token) + session.object.handleRequest(request, { prompt }, attachments, model, authInfo, token) .then(() => this.commitWorktreeChangesIfNeeded(request, session.object, token)) .catch(error => { this.logService.error(`Failed to handle CLI session request: ${error}`); diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index bda44f90c2a14..5d681a30669fe 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -618,7 +618,7 @@ export namespace ConfigKey { export const CLIIsolationOption = defineSetting('chat.cli.isolationOption.enabled', ConfigType.Simple, true); export const CLIAutoCommitEnabled = defineSetting('chat.cli.autoCommit.enabled', ConfigType.Simple, true); export const CLISessionController = defineSetting('chat.cli.sessionController.enabled', ConfigType.Simple, false); - export const CLIThinkingEffortEnabled = defineSetting('chat.cli.thinkingEffort.enabled', ConfigType.Simple, false); + export const CLIThinkingEffortEnabled = defineSetting('chat.cli.thinkingEffort.enabled', ConfigType.Simple, true); export const CLISessionControllerForSessionsApp = defineSetting('chat.cli.sessionControllerForSessionsApp.enabled', ConfigType.Simple, false); export const CLITerminalLinks = defineSetting('chat.cli.terminalLinks.enabled', ConfigType.Simple, true); export const RequestLoggerMaxEntries = defineAndMigrateSetting('chat.advanced.debug.requestLogger.maxEntries', 'chat.debug.requestLogger.maxEntries', 100); From eb62869277872b9cf8a0efaa7a9d46ac121bb80c Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Fri, 10 Apr 2026 06:55:42 +0000 Subject: [PATCH 08/15] Background - validate upstream branch before creating the worktree (#308953) * Background - validate upstream branch before creating the worktree * Pull request feedback --- .../chatSessionWorktreeServiceImpl.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts index 41c8967cc4a20..c12da9763d6f8 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts @@ -72,6 +72,7 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi const autoCommit = this.configurationService.getConfig(ConfigKey.Advanced.CLIAutoCommitEnabled); + let baseCommit: string | undefined = undefined; const branch = await this.generateBranchName(branchName, activeRepository); // When a base branch is provided, we attempt to resolve it, to see whether it has an @@ -82,8 +83,19 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi // Attempt to resolve the provided base branch const branchDetails = await this.gitService.getBranch(activeRepository.rootUri, baseBranch); if (branchDetails?.upstream?.remote && branchDetails.upstream?.name) { - // If the base branch has an upstream, use it as the base for the worktree - baseBranch = `${branchDetails.upstream.remote}/${branchDetails.upstream.name}`; + const upstreamBranchName = `${branchDetails.upstream.remote}/${branchDetails.upstream.name}`; + + try { + // Attempt to resolve the upstream branch before using it as the base for the worktree + const upstreamBranch = await this.gitService.getBranch(activeRepository.rootUri, upstreamBranchName); + if (upstreamBranch) { + baseBranch = upstreamBranchName; + baseCommit = upstreamBranch.commit; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logService.warn(`[ChatSessionWorktreeService][_createWorktree] Failed to resolve upstream branch ${upstreamBranchName}. Error: ${errorMessage}`); + } } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -97,8 +109,7 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi const baseBranchName = baseBranch ?? activeRepository.headBranchName; const baseBranchProtected = await this.gitService.isBranchProtected(activeRepository.rootUri, baseBranchName); - let baseCommit: string | undefined = undefined; - if (baseBranch) { + if (baseBranch && !baseCommit) { const refs = await this.gitService.getRefs(activeRepository.rootUri, { pattern: `refs/heads/${baseBranch}` }); baseCommit = refs.length === 1 && refs[0].commit ? refs[0].commit : undefined; } From e8b419162f096f6bf8246758a08b5ef8708b2cb5 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 10 Apr 2026 16:56:14 +1000 Subject: [PATCH 09/15] refactor(copilotcli): enhance session handling with branch name generation (#308956) --- .../chatSessions/vscode-node/chatSessions.ts | 11 +++++++--- .../copilotCLIChatSessionsContribution.ts | 22 ++++++++++++++----- .../copilotCLIChatSessionParticipant.spec.ts | 6 ++++- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts index 8a64e7667fe20..e234e02b592c0 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts @@ -42,6 +42,7 @@ import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeServic import { IChatFolderMruService, IFolderRepositoryManager } from '../common/folderRepositoryManager'; import { ICustomSessionTitleService } from '../copilotcli/common/customSessionTitleService'; import { ChatDelegationSummaryService, IChatDelegationSummaryService } from '../copilotcli/common/delegationSummaryService'; +import { SessionIdForCLI } from '../copilotcli/common/utils'; import { CopilotCLIAgents, CopilotCLIModels, CopilotCLISDK, ICopilotCLIAgents, ICopilotCLIModels, ICopilotCLISDK } from '../copilotcli/node/copilotCli'; import { CopilotCLIImageSupport, ICopilotCLIImageSupport } from '../copilotcli/node/copilotCLIImageSupport'; import { CopilotCLIPromptResolver } from '../copilotcli/node/copilotcliPromptResolver'; @@ -57,7 +58,6 @@ import { GHPR_EXTENSION_ID } from '../vscode/chatSessionsUriHandler'; import { AgentSessionsWorkspace } from './agentSessionsWorkspace'; import { UserQuestionHandler } from './askUserQuestionHandler'; import { ChatPromptFileService } from './chatPromptFileService'; -import { CopilotCLIChatSessionInitializer, ICopilotCLIChatSessionInitializer } from './copilotCLIChatSessionInitializer'; import { ChatSessionMetadataStore } from './chatSessionMetadataStoreImpl'; import { ChatSessionRepositoryTracker } from './chatSessionRepositoryTracker'; import { ChatSessionWorkspaceFolderService } from './chatSessionWorkspaceFolderServiceImpl'; @@ -65,8 +65,8 @@ import { ChatSessionWorktreeCheckpointService } from './chatSessionWorktreeCheck import { ChatSessionWorktreeService } from './chatSessionWorktreeServiceImpl'; import { ClaudeChatSessionContentProvider } from './claudeChatSessionContentProvider'; import { ClaudeCustomizationProvider } from './claudeCustomizationProvider'; +import { CopilotCLIChatSessionInitializer, ICopilotCLIChatSessionInitializer } from './copilotCLIChatSessionInitializer'; import { CopilotCLIChatSessionContentProvider, CopilotCLIChatSessionParticipant, registerCLIChatCommands } from './copilotCLIChatSessions'; -import { SessionIdForCLI } from '../copilotcli/common/utils'; import { CopilotCLIChatSessionContentProvider as CopilotCLIChatSessionContentProviderV1, CopilotCLIChatSessionItemProvider as CopilotCLIChatSessionItemProviderV1, CopilotCLIChatSessionParticipant as CopilotCLIChatSessionParticipantV1, registerCLIChatCommands as registerCLIChatCommandsV1 } from './copilotCLIChatSessionsContribution'; import { CopilotCLICustomizationProvider } from './copilotCLICustomizationProvider'; import { CopilotCLITerminalIntegration, ICopilotCLITerminalIntegration } from './copilotCLITerminalIntegration'; @@ -75,8 +75,8 @@ import { ClaudeFolderRepositoryManager, CopilotCLIFolderRepositoryManager } from import { PRContentProvider } from './prContentProvider'; import { IPullRequestDetectionService, PullRequestDetectionService } from './pullRequestDetectionService'; import { IPullRequestFileChangesService, PullRequestFileChangesService } from './pullRequestFileChangesService'; -import { ISessionRequestLifecycle, SessionRequestLifecycle } from './sessionRequestLifecycle'; import { ISessionOptionGroupBuilder, SessionOptionGroupBuilder } from './sessionOptionGroupBuilder'; +import { ISessionRequestLifecycle, SessionRequestLifecycle } from './sessionRequestLifecycle'; // https://github.com/microsoft/vscode-pull-request-github/blob/8a5c9a145cd80ee364a3bed9cf616b2bd8ac74c2/src/github/copilotApi.ts#L56-L71 @@ -273,6 +273,10 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib const gitService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IGitService)); const gitExtensionService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IGitExtensionService)); const toolsService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IToolsService)); + const aiGeneratedBranchNamesV1 = instantiationService.invokeFunction(accessor => + accessor.get(IConfigurationService).getConfig(ConfigKey.Advanced.CLIAIGenerateBranchNames) + ); + const branchNameGeneratorV1 = aiGeneratedBranchNamesV1 ? copilotcliAgentInstaService.createInstance(GitBranchNameGenerator) : undefined; const copilotcliChatSessionParticipant = this._register(copilotcliAgentInstaService.createInstance( CopilotCLIChatSessionParticipantV1, @@ -280,6 +284,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib promptResolver, copilotcliSessionItemProvider, cloudSessionProvider, + branchNameGeneratorV1, )); const copilotCLISessionService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLISessionService)); const copilotCLIWorktreeManagerService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionWorktreeService)); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index 089a54b882d04..f7d2bfe518592 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -6,7 +6,7 @@ import type { Attachment, SessionOptions, SweCustomAgent } from '@github/copilot/sdk'; import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; -import { ChatExtendedRequestHandler, ChatSessionProviderOptionItem, Uri } from 'vscode'; +import { ChatExtendedRequestHandler, ChatRequestTurn2, ChatSessionProviderOptionItem, Uri } from 'vscode'; import { IRunCommandExecutionService } from '../../../platform/commands/common/runCommandExecutionService'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { INativeEnvService } from '../../../platform/env/common/envService'; @@ -33,6 +33,7 @@ import { StopWatch } from '../../../util/vs/base/common/stopwatch'; import { URI } from '../../../util/vs/base/common/uri'; import { EXTENSION_ID } from '../../common/constants'; import { ChatVariablesCollection, extractDebugTargetSessionIds, isPromptFile } from '../../prompt/common/chatVariablesCollection'; +import { GitBranchNameGenerator } from '../../prompt/node/gitBranch'; import { IToolsService } from '../../tools/common/toolsService'; import { IChatSessionMetadataStore, RepositoryProperties, StoredModeInstructions } from '../common/chatSessionMetadataStore'; import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService'; @@ -1097,6 +1098,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { private readonly promptResolver: CopilotCLIPromptResolver, private readonly sessionItemProvider: CopilotCLIChatSessionItemProvider, private readonly cloudSessionProvider: CopilotCloudSessionsProvider | undefined, + private readonly branchNameGenerator: GitBranchNameGenerator | undefined, @IGitService private readonly gitService: IGitService, @ICopilotCLIModels private readonly copilotCLIModels: ICopilotCLIModels, @ICopilotCLIAgents private readonly copilotCLIAgents: ICopilotCLIAgents, @@ -1311,7 +1313,14 @@ export class CopilotCLIChatSessionParticipant extends Disposable { this.getAgent(id, request, token), ]); - const sessionResult = await this.getOrCreateSession(request, chatSessionContext, stream, { model, agent }, disposables, token); + const requestTurn = new ChatRequestTurn2(request.prompt ?? '', request.command, [], '', [], [], undefined, undefined, undefined); + const fakeContext: vscode.ChatContext = { + history: [requestTurn], + yieldRequested: false, + }; + const newBranch = (isUntitled && request.prompt && this.branchNameGenerator) ? this.branchNameGenerator.generateBranchName(fakeContext, token) : undefined; + + const sessionResult = await this.getOrCreateSession(request, chatSessionContext, stream, { model, agent, newBranch }, disposables, token); const session = sessionResult.session; if (session) { disposables.add(session); @@ -1675,13 +1684,13 @@ export class CopilotCLIChatSessionParticipant extends Disposable { } } - private async getOrCreateSession(request: vscode.ChatRequest, chatSessionContext: vscode.ChatSessionContext, stream: vscode.ChatResponseStream, options: { model: { model: string; reasoningEffort?: string } | undefined; agent: SweCustomAgent | undefined }, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference | undefined; trusted: boolean }> { + private async getOrCreateSession(request: vscode.ChatRequest, chatSessionContext: vscode.ChatSessionContext, stream: vscode.ChatResponseStream, options: { model: { model: string; reasoningEffort?: string } | undefined; agent: SweCustomAgent | undefined; newBranch?: Promise }, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference | undefined; trusted: boolean }> { const { resource } = chatSessionContext.chatSessionItem; const existingSessionId = this.sessionItemProvider.untitledSessionIdMapping.get(SessionIdForCLI.parse(resource)); const id = existingSessionId ?? SessionIdForCLI.parse(resource); const isNewSession = chatSessionContext.isUntitled && !existingSessionId; - const { workspaceInfo, cancelled, trusted } = await this.getOrInitializeWorkingDirectory(chatSessionContext, stream, request.toolInvocationToken, token); + const { workspaceInfo, cancelled, trusted } = await this.getOrInitializeWorkingDirectory(chatSessionContext, stream, request.toolInvocationToken, token, options.newBranch); const workingDirectory = getWorkingDirectory(workspaceInfo); const worktreeProperties = workspaceInfo.worktreeProperties; if (cancelled || token.isCancellationRequested) { @@ -1772,7 +1781,8 @@ export class CopilotCLIChatSessionParticipant extends Disposable { chatSessionContext: vscode.ChatSessionContext | undefined, stream: vscode.ChatResponseStream, toolInvocationToken: vscode.ChatParticipantToolToken, - token: vscode.CancellationToken + token: vscode.CancellationToken, + newBranch?: Promise ): Promise<{ workspaceInfo: IWorkspaceInfo; cancelled: boolean; @@ -1788,7 +1798,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { // Use FolderRepositoryManager to initialize folder/repository with worktree creation const branch = _sessionBranch.get(id); const isolation = _sessionIsolation.get(id) ?? undefined; - folderInfo = await this.folderRepositoryManager.initializeFolderRepository(id, { stream, toolInvocationToken, branch: branch ?? undefined, isolation, folder: undefined }, token); + folderInfo = await this.folderRepositoryManager.initializeFolderRepository(id, { stream, toolInvocationToken, branch: branch ?? undefined, isolation, folder: undefined, newBranch }, token); } else { // Existing session - use getFolderRepository for resolution with trust check folderInfo = await this.folderRepositoryManager.getFolderRepository(id, { promptForTrust: true, stream }, token); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts index 308b3609cf836..989838f7eb9b1 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts @@ -42,6 +42,7 @@ import { RepositoryProperties } from '../../common/chatSessionMetadataStore'; import { IChatSessionWorkspaceFolderService } from '../../common/chatSessionWorkspaceFolderService'; import { IChatSessionWorktreeCheckpointService } from '../../common/chatSessionWorktreeCheckpointService'; import { IChatSessionWorktreeService, type ChatSessionWorktreeFile, type ChatSessionWorktreeProperties, type ChatSessionWorktreePropertiesV2 } from '../../common/chatSessionWorktreeService'; +import { IChatFolderMruService } from '../../common/folderRepositoryManager'; import { MockChatSessionMetadataStore } from '../../common/test/mockChatSessionMetadataStore'; import { getWorkingDirectory, IWorkspaceInfo } from '../../common/workspaceInfo'; import { IChatDelegationSummaryService } from '../../copilotcli/common/delegationSummaryService'; @@ -55,7 +56,6 @@ import { IQuestion, IQuestionAnswer, IUserQuestionHandler } from '../../copilotc import { CustomSessionTitleService } from '../../copilotcli/vscode-node/customSessionTitleServiceImpl'; import { MockChatPromptFileService } from '../../copilotcli/vscode-node/test/testHelpers'; import { CopilotCLIChatSessionContentProvider, CopilotCLIChatSessionItemProvider, CopilotCLIChatSessionParticipant } from '../copilotCLIChatSessionsContribution'; -import { IChatFolderMruService } from '../../common/folderRepositoryManager'; import { CopilotCloudSessionsProvider } from '../copilotCloudSessionsProvider'; import { CopilotCLIFolderRepositoryManager } from '../folderRepositoryManagerImpl'; @@ -413,6 +413,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { promptResolver, itemProvider, cloudProvider, + undefined, git, models as unknown as ICopilotCLIModels, new NullCopilotCLIAgents(), @@ -761,6 +762,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { promptResolver, itemProvider, cloudProvider, + undefined, git, models as unknown as ICopilotCLIModels, new NullCopilotCLIAgents(), @@ -1907,6 +1909,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { promptResolver, itemProvider, cloudProvider, + undefined, git, models as unknown as ICopilotCLIModels, agents, @@ -2039,6 +2042,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { promptResolver, itemProvider, cloudProvider, + undefined, git, models as unknown as ICopilotCLIModels, new NullCopilotCLIAgents(), From e9aba237c959a035ed81d9cc1fdc6e76d16bb398 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 10 Apr 2026 16:58:22 +1000 Subject: [PATCH 10/15] refactor(copilotcli): update action descriptions and adjust plan path handling (#308954) --- extensions/copilot/package.json | 4 +-- .../copilotcli/node/copilotcliSession.ts | 27 +++++++++++-------- .../node/test/copilotcliSession.spec.ts | 1 + 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 7e20226b77530..5525c801c6717 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -6106,12 +6106,12 @@ { "name": "plan", "description": "%github.copilot.command.cli.plan.description%", - "when": "config.github.copilot.chat.cli.planExitMode.enabled" + "when": "false" }, { "name": "fleet", "description": "%github.copilot.command.cli.fleet.description%", - "when": "config.github.copilot.chat.cli.planExitMode.enabled" + "when": "false" } ], "customAgentTarget": "github-copilot", diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index 0e98767b108cd..5bf76427d2062 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -473,20 +473,25 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes this._sdkSession.respondToExitPlanMode(event.data.requestId, { approved: false }); return; } - const actionDescriptions: Record = { - 'autopilot': l10n.t('Auto-approve all tool calls and continue until the task is done'), - 'interactive': l10n.t('Let the agent continue in interactive mode, asking for user input and approval for each action.'), - 'exit_only': l10n.t('Exit plan mode, but do not execute the plan. I will execute the plan myself after reviewing it.'), - 'autopilot_fleet': l10n.t('Auto-approve all tool calls, including fleet management actions, and continue until the task is done.'), - }; + const actionDescriptions: Record = { + 'autopilot': { label: 'Autopilot', description: l10n.t('Auto-approve all tool calls and continue until the task is done') }, + 'interactive': { label: 'Interactive', description: l10n.t('Let the agent continue in interactive mode, asking for input and approval for each action.') }, + 'exit_only': { label: 'Approve and exit', description: l10n.t('Exit planning, but do not execute the plan. I will execute the plan myself.') }, + 'autopilot_fleet': { label: 'Autopilot Fleet', description: l10n.t('Auto-approve all tool calls, including fleet management actions, and continue until the task is done.') }, + } satisfies Record; const approved = true; - event.data.actions; try { + const planPath = this._sdkSession.getPlanPath(); + const userInputRequest: IQuestion = { - question: l10n.t('Approve this plan?'), + question: planPath ? l10n.t('Approve this plan {0}?', `[Plan.md](${Uri.file(planPath).toString()})`) : l10n.t('Approve this plan?'), header: l10n.t('Approve this plan?'), - options: event.data.actions.map(a => ({ label: (actionDescriptions as Record)[a] ?? a, recommended: a === event.data.recommendedAction })), + options: event.data.actions.map(a => ({ + label: actionDescriptions[a]?.label ?? a, + recommended: a === event.data.recommendedAction, + description: actionDescriptions[a]?.description ?? '', + })), allowFreeformInput: true, }; const answer = await this._userQuestionHandler.askUserQuestion(userInputRequest, this._toolInvocationToken as unknown as never, token); @@ -499,8 +504,8 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes this._sdkSession.respondToExitPlanMode(event.data.requestId, { approved: false, feedback: answer.freeText }); } else { let selectedAction: ActionType = answer.selected[0] as ActionType; - Object.entries(actionDescriptions).forEach(([action, description]) => { - if (description === selectedAction) { + Object.entries(actionDescriptions).forEach(([action, item]) => { + if (item.label === selectedAction) { selectedAction = action as ActionType; } }); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts index e222412efe909..e2e66595e7285 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts @@ -132,6 +132,7 @@ class MockSdkSession { async getSelectedModel() { return this._selectedModel; } async setSelectedModel(model: string, _reasoningEffort?: string) { this._selectedModel = model; } async getEvents() { return []; } + getPlanPath(): string | null { return null; } } function createWorkspaceService(root: string): IWorkspaceService { From f83607587d30f4a0c6d4dfd5ffdb41519954065d Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Fri, 10 Apr 2026 09:56:37 +0200 Subject: [PATCH 11/15] add cpu-profile analysis skill (#308963) --- .github/skills/cpu-profile-analysis/SKILL.md | 517 +++++++++++++++++++ 1 file changed, 517 insertions(+) create mode 100644 .github/skills/cpu-profile-analysis/SKILL.md diff --git a/.github/skills/cpu-profile-analysis/SKILL.md b/.github/skills/cpu-profile-analysis/SKILL.md new file mode 100644 index 0000000000000..d1bab98454842 --- /dev/null +++ b/.github/skills/cpu-profile-analysis/SKILL.md @@ -0,0 +1,517 @@ +--- +name: cpu-profile-analysis +description: "Analyze V8/Chrome CPU profiles (.cpuprofile) and DevTools trace files (Trace-*.json). Use when: profiling performance, investigating slow functions, comparing code paths, finding bottlenecks, analyzing timeToRequest, understanding call trees from sampling profiler data, analyzing layout/paint/rendering, investigating user timing marks." +--- + +# Analyze Performance Profiles + +Analyze `.cpuprofile` files (V8 sampling profiler) and DevTools trace files (`Trace-*.json`, Chrome Trace Event Format) to find performance bottlenecks, compare code paths, and understand timing. + +## When to Use +- User provides a `.cpuprofile` or `Trace-*.json` file and wants to understand performance +- Investigating why one code path is slower than another +- Finding what functions consume the most time +- Comparing "before/after" or "old/new" implementations in a single profile +- Investigating layout thrashing, long tasks, or rendering bottlenecks (trace files) +- Analyzing VS Code user timing marks like `code/didResolveTextFileEditorModel` (trace files) +- Understanding multi-process behavior (Browser, Renderer, GPU processes in trace files) + +## Detecting File Type + +- **`.cpuprofile`**: Top-level JSON with `nodes`, `samples`, `timeDeltas` keys. Created by the VS Code profiler. +- **`Trace-*.json`**: Top-level JSON with `traceEvents` array (and optional `metadata`). Created by Chrome/Electron DevTools (Performance tab). These are richer than `.cpuprofile` -- they contain CPU samples, layout/paint events, user timing marks, GC events, input events, and multi-process data. + +## Key Concepts + +- **Sampling profiler**: The profiler periodically snapshots the call stack. Not every function appears -- only those on the stack when the profiler sampled. Don't expect exact function names; look for patterns and nearby activity. +- **Self time**: Time spent in the function itself (the leaf/innermost frame). +- **Total time**: Time the function was anywhere on the stack (includes callees). +- **Idle samples**: Frames labeled `(idle)`, `(program)`, or `(garbage collector)` represent no user code running. + +--- + +## Part 1: `.cpuprofile` Files + +### Profile Format + +A `.cpuprofile` is JSON with these top-level keys: +- `nodes`: Array of call frame nodes forming a tree (each has `id`, `callFrame`, `children`) +- `samples`: Array of node IDs -- one per profiler tick, referencing the leaf (innermost) frame +- `timeDeltas`: Array of microsecond deltas between consecutive samples +- `startTime` / `endTime`: Absolute timestamps in microseconds +- `$vscode`: Optional VS Code metadata + +### Procedure + +### 1. Check File Size and Parse + +Profile and trace files can exceed V8's string limit (~512MB). Always check the file size first and choose the right parsing strategy: + +```javascript +import { readFileSync, statSync } from 'fs'; + +const stat = statSync(profilePath); +const sizeMB = stat.size / (1024 * 1024); +console.log(`File size: ${sizeMB.toFixed(0)}MB`); + +let data; +if (sizeMB < 400) { + // Small enough for JSON.parse + data = JSON.parse(readFileSync(profilePath, 'utf8')); +} else { + // Too large -- use Buffer-based extraction (see "Handling Huge Files" section) + data = parseProfileFromBuffer(readFileSync(profilePath)); +} +``` + +For files under ~400MB, `JSON.parse(readFileSync(..., 'utf8'))` works fine. For larger files, see the **Handling Huge Files** section below. + +### 2. Reformat the File (small files only) + +Profiles are often single-line JSON. Reformat for inspection (only if small enough): + +```javascript +if (sizeMB < 400) { + const data = JSON.parse(fs.readFileSync(profilePath, 'utf8')); + fs.writeFileSync(profilePath, JSON.stringify(data, null, 2)); +} +``` + +### 3. Build Data Structures + +Write a Node.js analysis script. Build these structures: + +```javascript +// Node lookup +const nodeMap = new Map(); // id -> node +const parentMap = new Map(); // id -> parent id + +// Absolute timestamps from deltas +const timestamps = [data.startTime]; +for (let i = 0; i < data.timeDeltas.length; i++) { + timestamps.push(timestamps[i] + data.timeDeltas[i]); +} + +// Stack walker (leaf to root) +function getStack(sampleNodeId) { + const stack = []; + let id = sampleNodeId; + while (id !== undefined) { + const node = nodeMap.get(id); + if (node) stack.push(node.callFrame.functionName); + id = parentMap.get(id); + } + return stack; // [leaf, ..., root] +} +``` + +### 4. Identify Activity Regions + +Split the timeline into buckets (e.g. 500ms) and find which contain relevant function names. Use marker functions related to the user's question to detect activity windows. Allow small gaps (1-2 empty buckets) when merging regions. + +**Important**: Because this is a sampling profiler, don't require exact function names. Use sets of related marker functions and look for the broader flow. + +### 5. Measure Timing Between Milestones + +For questions like "time from X to Y": +1. Find the first non-idle sample containing a marker for X on the stack +2. Find the first sample containing a marker for Y on the stack +3. The gap in absolute timestamps is the approximate duration +4. List all non-idle samples between these points to see what work happens in the gap + +### 6. Compare Code Paths + +When comparing two implementations: +1. Identify the activity region for each +2. For each region, compute self-time per function (time attributed to the leaf frame) +3. Sort by self-time descending to find the top cost centers +4. Show the first N non-idle stacks in each region to visualize the startup sequence + +### 7. Report Findings + +Present results as: +- **Timeline**: When each activity region occurred relative to profile start +- **Duration**: How long each region lasted +- **Top functions by self-time**: Where CPU time was actually spent +- **Comparison table**: Side-by-side metrics when comparing paths +- **Stack traces**: Key sample stacks showing the critical path + +--- + +## Part 2: DevTools Trace Files (`Trace-*.json`) + +DevTools traces are the future of perf tracing for VS Code. They are created from the built-in Electron/Chrome DevTools Performance tab and contain far more information than `.cpuprofile` files. + +### Trace Format + +A `Trace-*.json` file has these top-level keys: +- `traceEvents`: Array of trace event objects (hundreds of thousands of entries) +- `metadata`: Object with `source`, `startTime`, `dataOrigin`, and optional DevTools state (breadcrumbs, annotations) + +### Trace Event Structure + +Each event in `traceEvents` follows the Chrome Trace Event Format: + +```javascript +{ + "pid": 3406, // Process ID + "tid": 7534980, // Thread ID + "ts": 200420830729, // Timestamp in microseconds + "ph": "X", // Phase (event type) + "cat": "devtools.timeline", // Category + "name": "EventDispatch", // Event name + "dur": 9, // Duration in microseconds (for complete events) + "tdur": 8, // Thread duration (excludes time thread was suspended) + "args": { ... }, // Event-specific arguments + "tts": 7078808 // Thread timestamp +} +``` + +### Phase Types (`ph`) + +| Phase | Name | Meaning | +|-------|------|---------| +| `X` | Complete | Event with duration (`dur` field). Most common. | +| `B` | Begin | Start of a duration event (paired with `E`). | +| `E` | End | End of a duration event (paired with `B`). | +| `I` | Instant | Point-in-time event (no duration). | +| `P` | Sample | CPU profiler sample. | +| `R` | Mark | Navigation timing mark. | +| `M` | Metadata | Process/thread name metadata. | +| `N` | Object Created | Object lifecycle tracking. | +| `D` | Object Destroyed | Object lifecycle tracking. | +| `s` | Flow Start | Async flow connection start. | +| `f` | Flow End | Async flow connection end. | +| `b` | Async Begin | Async event begin. | +| `e` | Async End | Async event end. | +| `n` | Async Instant | Async event instant. | + +### Key Categories and What They Contain + +| Category | What it captures | +|----------|-----------------| +| `disabled-by-default-devtools.timeline` | `RunTask`, `EvaluateScript`, `TracingStartedInBrowser` -- core task scheduling | +| `devtools.timeline` | `FunctionCall`, `EventDispatch`, `TimerInstall/Fire`, `PrePaint`, `Paint` -- main thread activity | +| `blink.user_timing` | VS Code performance marks (e.g. `code/willResolveTextFileEditorModel`, `code/didResolveTextFileEditorModel`) | +| `blink,devtools.timeline` | `UpdateLayoutTree`, `HitTest`, `IntersectionObserver`, `ParseAuthorStyleSheet` -- layout/rendering | +| `disabled-by-default-v8.cpu_profiler` | `Profile`, `ProfileChunk` -- embedded CPU profile data (same as `.cpuprofile` but chunked) | +| `v8` | `v8.callFunction`, `v8.newInstance`, `V8.DeoptimizeCode` -- V8 engine events | +| `v8,devtools.timeline` | `v8.compile` -- script compilation | +| `devtools.timeline,v8` | `MinorGC`, `MajorGC` -- garbage collection | +| `cppgc` | C++ GC events (Blink garbage collection) | +| `loading` | `LayoutShift`, `URLLoader` -- resource loading and layout shifts | +| `cc,benchmark,disabled-by-default-devtools.timeline.frame` | Frame pipeline events (`PipelineReporter`, `Commit`, etc.) | +| `__metadata` | `process_name`, `thread_name` -- process/thread identification | + +### Processes and Threads + +Trace files contain events from multiple processes: + +| Process | Role | Key Thread | +|---------|------|------------| +| **Renderer** (pid varies) | VS Code's renderer process -- where JS runs | `CrRendererMain` (main thread) | +| **Browser** (pid varies) | Electron's main/browser process | `CrBrowserMain` | +| **GPU Process** (pid varies) | GPU compositing and rendering | `CrGpuMain`, `VizCompositorThread` | + +Identify processes/threads via metadata events: +```javascript +const procNames = events.filter(e => e.name === 'process_name'); +// => [{args: {name: 'Renderer'}, pid: 3406}, {args: {name: 'Browser'}, pid: 3348}, ...] + +const threadNames = events.filter(e => e.name === 'thread_name'); +// => [{args: {name: 'CrRendererMain'}, pid: 3406, tid: 7534980}, ...] +``` + +For VS Code perf analysis, focus on the **Renderer process, CrRendererMain thread** -- this is where JavaScript execution, layout, and painting happen. + +### Procedure + +#### 1. Check File Size and Parse + +Trace files are typically 50-200MB but can exceed V8's string limit (~512MB). Always check first: + +```javascript +import { readFileSync, statSync } from 'fs'; + +const stat = statSync(tracePath); +const sizeMB = stat.size / (1024 * 1024); +console.log(`File size: ${sizeMB.toFixed(0)}MB`); + +let data; +if (sizeMB < 400) { + data = JSON.parse(readFileSync(tracePath, 'utf8')); +} else { + // Too large -- use Buffer-based extraction (see "Handling Huge Files" section) + data = parseTraceFromBuffer(readFileSync(tracePath)); +} +const events = data.traceEvents; +``` + +#### 2. Reformat the File (small files only) + +For small trace files, reformat for inspection: +```javascript +if (sizeMB < 400) { + fs.writeFileSync(tracePath, JSON.stringify(data, null, 2)); +} +``` + +#### 3. Build Data Structures + +```javascript +const data = JSON.parse(fs.readFileSync(tracePath, 'utf8')); +const events = data.traceEvents; + +// Identify Renderer main thread +const rendererPid = events.find(e => e.name === 'process_name' && e.args?.name === 'Renderer')?.pid; +const mainTid = events.find(e => e.name === 'thread_name' && e.pid === rendererPid && e.args?.name === 'CrRendererMain')?.tid; + +// Filter to main thread events for most analysis +const mainEvents = events.filter(e => e.pid === rendererPid && e.tid === mainTid); +``` + +#### 4. Analyze User Timing Marks + +VS Code emits `performance.mark()` calls that appear as `blink.user_timing` events. These are the most direct way to measure VS Code-specific milestones: + +```javascript +const userTimings = events.filter(e => e.cat?.includes('blink.user_timing') && !e.cat.includes('rail')); +// Each has: name (e.g. 'code/didResolveTextFileEditorModel'), ts (microseconds), args.data.startTime (ms from navigation) +``` + +#### 5. Analyze Long Tasks + +Find expensive tasks on the main thread: +```javascript +const longTasks = mainEvents + .filter(e => e.name === 'RunTask' && e.ph === 'X' && e.dur > 50000) // > 50ms + .sort((a, b) => b.dur - a.dur); +``` + +#### 6. Analyze Function Calls + +`FunctionCall` events include source location info: +```javascript +const funcCalls = mainEvents + .filter(e => e.name === 'FunctionCall' && e.dur > 10000) // > 10ms + .sort((a, b) => b.dur - a.dur); +// args.data contains: functionName, url, lineNumber, columnNumber, scriptId +``` + +#### 7. Analyze Layout and Rendering + +Find layout thrashing and expensive paints: +```javascript +const layoutEvents = mainEvents.filter(e => + e.name === 'UpdateLayoutTree' || e.name === 'Layout' || + e.name === 'PrePaint' || e.name === 'Paint' +); +// UpdateLayoutTree.args.elementCount tells you how many elements were restyled +``` + +#### 8. Extract Embedded CPU Profile + +Trace files contain the full CPU profile as `ProfileChunk` events. Reconstruct it: +```javascript +const profileEvent = events.find(e => e.name === 'Profile' && e.pid === rendererPid); +const chunks = events.filter(e => e.name === 'ProfileChunk' && e.pid === rendererPid && e.id === profileEvent.id); + +// Each chunk's args.data.cpuProfile contains: {nodes: [...], samples: [...]} +// Each chunk's args.data.timeDeltas contains sample timing +// Merge all chunks to reconstruct a full cpuprofile-like structure +const allNodes = []; +const allSamples = []; +const allDeltas = []; +for (const chunk of chunks) { + const cp = chunk.args.data.cpuProfile; + if (cp.nodes) allNodes.push(...cp.nodes); + if (cp.samples) allSamples.push(...cp.samples); + if (chunk.args.data.timeDeltas) allDeltas.push(...chunk.args.data.timeDeltas); +} +// Now analyze allNodes/allSamples/allDeltas using the same approach as .cpuprofile +``` + +#### 9. Analyze GC Pressure + +```javascript +const gcEvents = mainEvents.filter(e => e.name === 'MinorGC' || e.name === 'MajorGC'); +const totalGcTime = gcEvents.reduce((sum, e) => sum + (e.dur || 0), 0); +// Also check cppgc events for Blink GC +const cppgcEvents = events.filter(e => e.cat?.includes('cppgc')); +``` + +#### 10. Analyze Input Latency + +```javascript +const dispatches = mainEvents.filter(e => e.name === 'EventDispatch'); +// args.data.type tells you the event type: 'click', 'keydown', 'mousedown', etc. +// dur tells you how long the handler took +const longHandlers = dispatches.filter(e => e.dur > 50000).sort((a, b) => b.dur - a.dur); +``` + +#### 11. Report Findings + +Present results as: +- **Timeline**: When each activity region occurred relative to trace start +- **User timing marks**: VS Code milestone events and their timestamps +- **Long tasks**: Tasks > 50ms that block the main thread +- **Top functions by duration**: Where CPU time was spent, with source locations +- **Layout/rendering**: Expensive style recalculations and paints +- **GC pressure**: Total GC time and frequency +- **Input latency**: Slow event handlers that degrade responsiveness +- **Process breakdown**: What work happened in Browser vs Renderer vs GPU + +--- + +## Handling Huge Files + +When a `.cpuprofile` or `Trace-*.json` file exceeds ~400MB, `readFileSync(..., 'utf8')` may fail because V8 cannot create a string that large. Use Buffer-based extraction instead: read the file as a raw `Buffer` and extract sections by scanning for known JSON keys. This is the same technique used for heap snapshots (see `parseSnapshot.ts`). + +**Key principle**: Read the file as a Buffer, locate JSON array/object boundaries by scanning bytes, extract individual sections as sub-buffers that are small enough for `JSON.parse`, then assemble the result. + +Always run analysis scripts with extra memory: `node --max-old-space-size=16384 script.mjs` + +### Buffer-based Parsing for `.cpuprofile` + +A `.cpuprofile` has top-level keys `nodes`, `samples`, `timeDeltas`, `startTime`, `endTime`. Extract each section from the buffer: + +```javascript +import { readFileSync, statSync } from 'fs'; + +function parseProfileFromBuffer(buf) { + // Helper: find the array value for a given key, return parsed array + function extractArray(key) { + const keyBuf = Buffer.from(`"${key}":[`); + const pos = buf.indexOf(keyBuf); + if (pos === -1) throw new Error(`${key} not found`); + const arrayStart = pos + keyBuf.length; + // Find matching ']' -- arrays of numbers have no nested brackets + const arrayEnd = buf.indexOf(0x5D, arrayStart); // 0x5D = ']' + return JSON.parse('[' + buf.subarray(arrayStart, arrayEnd).toString('utf8') + ']'); + } + + // Helper: find a scalar value for a given key + function extractScalar(key) { + const keyBuf = Buffer.from(`"${key}":`); + const pos = buf.indexOf(keyBuf); + if (pos === -1) throw new Error(`${key} not found`); + const valueStart = pos + keyBuf.length; + // Scan to next comma or closing brace + let end = valueStart; + while (end < buf.length && buf[end] !== 0x2C && buf[end] !== 0x7D) end++; + return JSON.parse(buf.subarray(valueStart, end).toString('utf8')); + } + + // Extract the nodes array -- contains objects, so we need bracket matching + function extractNodesArray() { + const keyBuf = Buffer.from('"nodes":['); + const pos = buf.indexOf(keyBuf); + if (pos === -1) throw new Error('nodes not found'); + const start = pos + keyBuf.length - 1; // include '[' + let depth = 0, end = -1; + for (let i = start; i < buf.length; i++) { + if (buf[i] === 0x5B) depth++; + else if (buf[i] === 0x5D) { depth--; if (depth === 0) { end = i + 1; break; } } + // Skip strings to avoid counting brackets inside them + if (buf[i] === 0x22) { i++; while (i < buf.length) { if (buf[i] === 0x5C) i++; else if (buf[i] === 0x22) break; i++; } } + } + if (end === -1) throw new Error('nodes array end not found'); + return JSON.parse(buf.subarray(start, end).toString('utf8')); + } + + const nodes = extractNodesArray(); + const samples = extractArray('samples'); + const timeDeltas = extractArray('timeDeltas'); + const startTime = extractScalar('startTime'); + const endTime = extractScalar('endTime'); + + return { nodes, samples, timeDeltas, startTime, endTime }; +} +``` + +### Buffer-based Parsing for `Trace-*.json` + +Trace files have two top-level keys: `metadata` (small object) and `traceEvents` (huge array of objects). The strategy is to extract `metadata` normally and stream-parse `traceEvents` by scanning for individual event objects: + +```javascript +import { readFileSync } from 'fs'; + +function parseTraceFromBuffer(buf) { + // 1. Extract metadata (small, near the top of the file) + let metadata = {}; + const metaKeyBuf = Buffer.from('"metadata":{'); + const metaPos = buf.indexOf(metaKeyBuf); + if (metaPos !== -1) { + const metaStart = metaPos + metaKeyBuf.length - 1; // include '{' + let depth = 0, metaEnd = -1; + for (let i = metaStart; i < buf.length; i++) { + if (buf[i] === 0x7B) depth++; + else if (buf[i] === 0x7D) { depth--; if (depth === 0) { metaEnd = i + 1; break; } } + if (buf[i] === 0x22) { i++; while (i < buf.length) { if (buf[i] === 0x5C) i++; else if (buf[i] === 0x22) break; i++; } } + } + if (metaEnd !== -1) { + metadata = JSON.parse(buf.subarray(metaStart, metaEnd).toString('utf8')); + } + } + + // 2. Extract traceEvents by parsing in chunks + // Each event is a JSON object {...}. Scan for object boundaries. + const eventsKeyBuf = Buffer.from('"traceEvents":['); + const eventsPos = buf.indexOf(eventsKeyBuf); + if (eventsPos === -1) throw new Error('traceEvents not found'); + const arrayStart = eventsPos + eventsKeyBuf.length; + + const traceEvents = []; + let i = arrayStart; + while (i < buf.length) { + // Skip whitespace and commas + while (i < buf.length && (buf[i] === 0x20 || buf[i] === 0x0A || buf[i] === 0x0D || buf[i] === 0x09 || buf[i] === 0x2C)) i++; + if (i >= buf.length || buf[i] === 0x5D) break; // end of array + + if (buf[i] !== 0x7B) { i++; continue; } // expect '{' + + // Find matching '}' + let depth = 0, objEnd = -1; + for (let j = i; j < buf.length; j++) { + if (buf[j] === 0x7B) depth++; + else if (buf[j] === 0x7D) { depth--; if (depth === 0) { objEnd = j + 1; break; } } + if (buf[j] === 0x22) { j++; while (j < buf.length) { if (buf[j] === 0x5C) j++; else if (buf[j] === 0x22) break; j++; } } + } + if (objEnd === -1) break; + + // Parse this individual event object -- each is small enough for JSON.parse + traceEvents.push(JSON.parse(buf.subarray(i, objEnd).toString('utf8'))); + i = objEnd; + } + + return { metadata, traceEvents }; +} +``` + +### When to Use Buffer Parsing + +| File size | Approach | +|-----------|----------| +| < 400MB | `JSON.parse(readFileSync(path, 'utf8'))` is fine | +| 400MB - 1GB | Use Buffer-based extraction functions above | +| > 1GB | Use Buffer-based extraction + `--max-old-space-size=16384` | + +### Tips for Large File Analysis + +- Always run with `node --max-old-space-size=16384` to give Node.js enough heap space. +- For trace files, consider filtering events during parsing (e.g. skip categories you don't need) to reduce memory. +- The Buffer approach reads the entire file into memory as bytes (which is fine -- Buffer doesn't have the ~512MB string limit). Individual `JSON.parse` calls operate on small sub-buffers. +- When reformatting would create a file too large to write back, skip reformatting and work directly with the parsed data structures. +- If you only need specific event types from a huge trace, add a filter callback to the parsing loop to avoid allocating objects you'll discard. + +## Tips + +- Timestamps in both formats are microseconds. Divide by 1000 for milliseconds. +- For bundled/minified code (e.g. `extension.js`), function names may be mangled. Use line numbers from `callFrame.lineNumber` to cross-reference with source maps. +- Filter out idle/program/GC samples when measuring active CPU work. +- When the user asks about a gap, check if it's truly idle (event loop waiting) vs. active work in unrelated code. +- Clean up any analysis scripts you create when done. +- Trace files are large (50-200MB). Always filter to the relevant process/thread before analysis to reduce memory and noise. +- When a trace file contains embedded `ProfileChunk` events, prefer analyzing those over asking for a separate `.cpuprofile` -- the data is equivalent but already correlated with other trace events. +- Use `args.data.url` in `FunctionCall` and `EvaluateScript` events to map back to VS Code source files (paths like `vscode-file://vscode-app/Users/.../out/vs/...`). +- The `dur` field is wall-clock duration; `tdur` is thread-time duration. The difference reveals time the thread was suspended (e.g. waiting for I/O or preempted). From 4660585937f28ea51ba45eeccff248ce5e3ad57b Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 10 Apr 2026 18:06:10 +1000 Subject: [PATCH 12/15] refactor(copilotcli): move worktree properties and metadata tracking to session request lifecycle (#308960) refactor: move worktree properties and metadata tracking to session request lifecycle --- .../copilotCLIChatSessionInitializer.ts | 44 +----- .../vscode-node/copilotCLIChatSessions.ts | 23 +-- .../vscode-node/sessionRequestLifecycle.ts | 32 +++- .../test/chatSessionInitializer.spec.ts | 64 ++------ .../test/sessionRequestLifecycle.spec.ts | 138 ++++++++++++++++-- 5 files changed, 178 insertions(+), 123 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionInitializer.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionInitializer.ts index efd6a0461a197..12fe91fabace9 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionInitializer.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionInitializer.ts @@ -6,6 +6,7 @@ import type { SweCustomAgent } from '@github/copilot/sdk'; import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; +import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { ILogService } from '../../../platform/log/common/logService'; import { IPromptsService, ParsedPromptFile } from '../../../platform/promptFiles/common/promptsService'; import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; @@ -13,17 +14,13 @@ import { createServiceIdentifier } from '../../../util/common/services'; import { DisposableStore, IReference } from '../../../util/vs/base/common/lifecycle'; import { URI } from '../../../util/vs/base/common/uri'; import { ChatVariablesCollection, extractDebugTargetSessionIds, isPromptFile } from '../../prompt/common/chatVariablesCollection'; -import { IChatSessionMetadataStore, StoredModeInstructions } from '../common/chatSessionMetadataStore'; -import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService'; -import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService'; import { FolderRepositoryInfo, IFolderRepositoryManager, IsolationMode } from '../common/folderRepositoryManager'; -import { SessionIdForCLI } from '../copilotcli/common/utils'; import { emptyWorkspaceInfo, getWorkingDirectory, isIsolationEnabled, IWorkspaceInfo } from '../common/workspaceInfo'; +import { SessionIdForCLI } from '../copilotcli/common/utils'; import { COPILOT_CLI_REASONING_EFFORT_PROPERTY, ICopilotCLIAgents, ICopilotCLIModels } from '../copilotcli/node/copilotCli'; import { ICopilotCLISession } from '../copilotcli/node/copilotcliSession'; import { ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionService'; import { buildMcpServerMappings, McpServerMappings } from '../copilotcli/node/mcpHandler'; -import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; function isReasoningEffortFeatureEnabled(configurationService: IConfigurationService): boolean { return configurationService.getConfig(ConfigKey.Advanced.CLIThinkingEffortEnabled); @@ -85,13 +82,10 @@ export class CopilotCLIChatSessionInitializer implements ICopilotCLIChatSessionI constructor( @ICopilotCLISessionService private readonly sessionService: ICopilotCLISessionService, @IFolderRepositoryManager private readonly folderRepositoryManager: IFolderRepositoryManager, - @IChatSessionWorktreeService private readonly worktreeService: IChatSessionWorktreeService, - @IChatSessionWorkspaceFolderService private readonly workspaceFolderService: IChatSessionWorkspaceFolderService, @IWorkspaceService private readonly workspaceService: IWorkspaceService, @ICopilotCLIModels private readonly copilotCLIModels: ICopilotCLIModels, @ICopilotCLIAgents private readonly copilotCLIAgents: ICopilotCLIAgents, @IPromptsService private readonly promptsService: IPromptsService, - @IChatSessionMetadataStore private readonly chatSessionMetadataStore: IChatSessionMetadataStore, @ILogService private readonly logService: ILogService, @IConfigurationService private readonly configurationService: IConfigurationService, ) { } @@ -129,15 +123,6 @@ export class CopilotCLIChatSessionInitializer implements ICopilotCLIChatSessionI return { session: undefined, isNewSession, model, agent, trusted }; } this.logService.info(`Using Copilot CLI session: ${session.object.sessionId} (isNewSession: ${isNewSession}, isolationEnabled: ${isIsolationEnabled(workspaceInfo)}, workingDirectory: ${workingDirectory}, worktreePath: ${worktreeProperties?.worktreePath})`); - if (isNewSession) { - if (worktreeProperties) { - void this.worktreeService.setWorktreeProperties(session.object.sessionId, worktreeProperties); - } - this.finalizeSessionCreation(session.object.sessionId, session.object.workspace); - } - - const modeInstructions = this.createModeInstructions(request); - this.chatSessionMetadataStore.updateRequestDetails(sessionId, [{ vscodeRequestId: request.id, agentId: agent?.name ?? '', modeInstructions }]).catch(ex => this.logService.error(ex, 'Failed to update request details')); disposables.add(session); disposables.add(session.object.attachStream(stream)); @@ -198,14 +183,6 @@ export class CopilotCLIChatSessionInitializer implements ICopilotCLIChatSessionI ]); const session = await this.sessionService.createSession({ workspace, agent, model: model?.model, reasoningEffort: model?.reasoningEffort, mcpServerMappings: options.mcpServerMappings }, token); - const worktreeProperties = workspace.worktreeProperties; - if (worktreeProperties) { - void this.worktreeService.setWorktreeProperties(session.object.sessionId, worktreeProperties); - } - this.finalizeSessionCreation(session.object.sessionId, workspace); - - const modeInstructions = this.createModeInstructions(request); - this.chatSessionMetadataStore.updateRequestDetails(session.object.sessionId, [{ vscodeRequestId: request.id, agentId: agent?.name ?? '', modeInstructions }]).catch(ex => this.logService.error(ex, 'Failed to update request details')); return { session, model, agent }; } @@ -255,23 +232,6 @@ export class CopilotCLIChatSessionInitializer implements ICopilotCLIChatSessionI return undefined; } - private finalizeSessionCreation(sessionId: string, workspace: IWorkspaceInfo): void { - const workingDirectory = getWorkingDirectory(workspace); - if (workingDirectory && !isIsolationEnabled(workspace)) { - void this.workspaceFolderService.trackSessionWorkspaceFolder(sessionId, workingDirectory.fsPath, workspace.repositoryProperties); - } - } - - private createModeInstructions(request: vscode.ChatRequest): StoredModeInstructions | undefined { - return request.modeInstructions2 ? { - uri: request.modeInstructions2.uri?.toString(), - name: request.modeInstructions2.name, - content: request.modeInstructions2.content, - metadata: request.modeInstructions2.metadata, - isBuiltin: request.modeInstructions2.isBuiltin, - } : undefined; - } - private async getPromptInfoFromRequest(request: vscode.ChatRequest, token: vscode.CancellationToken): Promise { const promptFile = new ChatVariablesCollection(request.references).find(isPromptFile); if (!promptFile || !URI.isUri(promptFile.reference.value)) { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index 30bb414410ea7..f2b379c464941 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { Attachment, SessionOptions } from '@github/copilot/sdk'; +import type { Attachment, SessionOptions, SweCustomAgent } from '@github/copilot/sdk'; import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; import { ChatExtendedRequestHandler, ChatRequestTurn2, Uri } from 'vscode'; @@ -47,10 +47,10 @@ import { ICopilotCLIChatSessionInitializer, SessionInitOptions } from './copilot import { convertReferenceToVariable } from './copilotCLIPromptReferences'; import { ICopilotCLITerminalIntegration, TerminalOpenLocation } from './copilotCLITerminalIntegration'; import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider'; +import { UNTRUSTED_FOLDER_MESSAGE } from './folderRepositoryManagerImpl'; import { IPullRequestDetectionService } from './pullRequestDetectionService'; import { getSelectedSessionOptions, ISessionOptionGroupBuilder, OPEN_REPOSITORY_COMMAND_ID, toRepositoryOptionItem, toWorkspaceFolderOptionItem } from './sessionOptionGroupBuilder'; import { ISessionRequestLifecycle } from './sessionRequestLifecycle'; -import { UNTRUSTED_FOLDER_MESSAGE } from './folderRepositoryManagerImpl'; /** * ODO: @@ -580,7 +580,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { return this.handleRequest.bind(this); } - private readonly contextForRequest = new Map(); + private readonly contextForRequest = new Map(); /** * Outer request handler that supports *yielding* for session steering. @@ -733,14 +733,14 @@ export class CopilotCLIChatSessionParticipant extends Disposable { const selectedOptions = getSelectedSessionOptions(chatSessionContext.inputState); const sessionResult = await this.getOrCreateSession(request, chatSessionContext.chatSessionItem.resource, { ...selectedOptions, newBranch: branchNamePromise, stream }, disposables, token); ({ session } = sessionResult); - const { model } = sessionResult; + const { model, agent } = sessionResult; if (!session || token.isCancellationRequested) { return {}; } sdkSessionId = session.object.sessionId; - await this.sessionRequestLifecycle.startRequest(sdkSessionId, request, context.history.length === 0); + await this.sessionRequestLifecycle.startRequest(sdkSessionId, request, context.history.length === 0, session.object.workspace, agent?.name ?? this.contextForRequest.get(session.object.sessionId)?.agent); if (request.command === 'delegate') { await this.handleDelegationToCloud(session.object, request, context, stream, token); @@ -769,18 +769,18 @@ export class CopilotCLIChatSessionParticipant extends Disposable { } } - private async getOrCreateSession(request: vscode.ChatRequest, chatResource: vscode.Uri, options: SessionInitOptions, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference | undefined; isNewSession: boolean; model: { model: string; reasoningEffort?: string } | undefined; trusted: boolean }> { + private async getOrCreateSession(request: vscode.ChatRequest, chatResource: vscode.Uri, options: SessionInitOptions, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference | undefined; isNewSession: boolean; model: { model: string; reasoningEffort?: string } | undefined; agent: SweCustomAgent | undefined; trusted: boolean }> { const result = await this.sessionInitializer.getOrCreateSession(request, chatResource, options, disposables, token); - const { session, isNewSession, model, trusted } = result; + const { session, isNewSession, model, agent, trusted } = result; if (!session || token.isCancellationRequested) { - return { session: undefined, isNewSession, model, trusted }; + return { session: undefined, isNewSession, model, agent, trusted }; } if (isNewSession) { this.sessionItemProvider.refreshSession({ reason: 'update', sessionId: session.object.sessionId }); } - return { session, isNewSession, model, trusted }; + return { session, isNewSession, model, agent, trusted }; } private async handleDelegationToCloud(session: ICopilotCLISession, request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) { @@ -835,7 +835,8 @@ export class CopilotCLIChatSessionParticipant extends Disposable { const { prompt, attachments, references } = await this.promptResolver.resolvePrompt(request, await requestPromptPromise, (otherReferences || []).concat([]), workspaceInfo, [], token); const mcpServerMappings = buildMcpServerMappings(request.tools); - const { session, model } = await this.sessionInitializer.createDelegatedSession(request, workspaceInfo, { mcpServerMappings }, token); + const { session, model, agent } = await this.sessionInitializer.createDelegatedSession(request, workspaceInfo, { mcpServerMappings }, token); + if (summary) { const summaryRef = await this.chatDelegationSummaryService.trackSummaryUsage(session.object.sessionId, summary); if (summaryRef) { @@ -844,7 +845,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { } try { - this.contextForRequest.set(session.object.sessionId, { prompt, attachments }); + this.contextForRequest.set(session.object.sessionId, { prompt, attachments, agent: agent?.name }); // this.sessionItemProvider.notifySessionsChange(); // TODO @DonJayamanne I don't think we need to refresh the list of session here just yet, or perhaps we do, // Same as getOrCreate session, we need a dummy title or the initial prompt to show in the sessions list. diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/sessionRequestLifecycle.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/sessionRequestLifecycle.ts index e9e898cab3082..5a1793878f08f 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/sessionRequestLifecycle.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/sessionRequestLifecycle.ts @@ -4,8 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { ILogService } from '../../../platform/log/common/logService'; import { createServiceIdentifier } from '../../../util/common/services'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { IChatSessionMetadataStore, StoredModeInstructions } from '../common/chatSessionMetadataStore'; import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService'; import { IChatSessionWorktreeCheckpointService } from '../common/chatSessionWorktreeCheckpointService'; import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService'; @@ -17,9 +19,10 @@ export interface ISessionRequestLifecycle { /** * Begin tracking a request for a session. Creates a baseline checkpoint - * if this is the first request in the session. + * if this is the first request in the session. Records request details + * (agent, mode instructions) in the metadata store. */ - startRequest(sessionId: string, request: vscode.ChatRequest, isFirstRequest: boolean): Promise; + startRequest(sessionId: string, request: vscode.ChatRequest, isFirstRequest: boolean, workspace: IWorkspaceInfo, agentName?: string): Promise; /** * Finalize a request: commit worktree changes, create checkpoints, detect @@ -59,18 +62,39 @@ export class SessionRequestLifecycle extends Disposable implements ISessionReque @IChatSessionWorktreeCheckpointService private readonly checkpointService: IChatSessionWorktreeCheckpointService, @IChatSessionWorkspaceFolderService private readonly workspaceFolderService: IChatSessionWorkspaceFolderService, @IPullRequestDetectionService private readonly prDetectionService: IPullRequestDetectionService, + @IChatSessionMetadataStore private readonly metadataStore: IChatSessionMetadataStore, + @ILogService private readonly logService: ILogService, ) { super(); } - async startRequest(sessionId: string, request: vscode.ChatRequest, isFirstRequest: boolean): Promise { + async startRequest(sessionId: string, request: vscode.ChatRequest, isFirstRequest: boolean, workspace: IWorkspaceInfo, agentName?: string): Promise { if (isFirstRequest) { - await this.checkpointService.handleRequest(sessionId); + if (workspace.worktreeProperties) { + void this.worktreeService.setWorktreeProperties(sessionId, workspace.worktreeProperties); + } + const workingDirectory = getWorkingDirectory(workspace); + if (workingDirectory && !isIsolationEnabled(workspace)) { + void this.workspaceFolderService.trackSessionWorkspaceFolder(sessionId, workingDirectory.fsPath, workspace.repositoryProperties); + } } + const modeInstructions: StoredModeInstructions | undefined = request.modeInstructions2 ? { + uri: request.modeInstructions2.uri?.toString(), + name: request.modeInstructions2.name, + content: request.modeInstructions2.content, + metadata: request.modeInstructions2.metadata, + isBuiltin: request.modeInstructions2.isBuiltin, + } : undefined; + this.metadataStore.updateRequestDetails(sessionId, [{ vscodeRequestId: request.id, agentId: agentName ?? '', modeInstructions }]).catch(ex => this.logService.error(ex, 'Failed to update request details')); + const requests = this.pendingRequestBySession.get(sessionId) ?? new Set(); requests.add(request); this.pendingRequestBySession.set(sessionId, requests); + + if (isFirstRequest) { + await this.checkpointService.handleRequest(sessionId); + } } async endRequest(sessionId: string, request: vscode.ChatRequest, session: SessionCompletionInfo, token: vscode.CancellationToken): Promise { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionInitializer.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionInitializer.spec.ts index fc0f0503ac0cc..3817b06ad5313 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionInitializer.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionInitializer.spec.ts @@ -173,13 +173,10 @@ function createInitializer(overrides?: { const initializer = new CopilotCLIChatSessionInitializer( sessionService, folderRepoManager, - worktreeService, - workspaceFolderService, workspaceService, models, agents, promptsService, - metadataStore, logService, configurationService, ); @@ -507,7 +504,7 @@ describe('ChatSessionInitializer', () => { disposables.dispose(); }); - it('sets worktree properties for new session with worktree', async () => { + it('does not set worktree properties (moved to startRequest)', async () => { const sessionService = new TestSessionService(); sessionService.isNewSessionId.mockReturnValue(true); const folderRepoManager = new TestFolderRepositoryManager(); @@ -534,14 +531,11 @@ describe('ChatSessionInitializer', () => { disposables, CancellationToken.None ); - expect(worktreeService.setWorktreeProperties).toHaveBeenCalledWith( - 'test-session-id', - expect.objectContaining({ branchName: 'copilot/test' }) - ); + expect(worktreeService.setWorktreeProperties).not.toHaveBeenCalled(); disposables.dispose(); }); - it('tracks workspace folder for new non-isolated session', async () => { + it('does not track workspace folder (moved to startRequest)', async () => { const sessionService = new TestSessionService(); sessionService.isNewSessionId.mockReturnValue(true); const { initializer, workspaceFolderService } = createInitializer({ sessionService }); @@ -552,11 +546,11 @@ describe('ChatSessionInitializer', () => { disposables, CancellationToken.None ); - expect(workspaceFolderService.trackSessionWorkspaceFolder).toHaveBeenCalled(); + expect(workspaceFolderService.trackSessionWorkspaceFolder).not.toHaveBeenCalled(); disposables.dispose(); }); - it('records request metadata', async () => { + it('does not record request metadata (moved to startRequest)', async () => { const { initializer, metadataStore } = createInitializer(); const disposables = new DisposableStore(); @@ -565,19 +559,14 @@ describe('ChatSessionInitializer', () => { disposables, CancellationToken.None ); - expect(metadataStore.updateRequestDetails).toHaveBeenCalledWith( - expect.any(String), - expect.arrayContaining([ - expect.objectContaining({ vscodeRequestId: 'request-1' }) - ]) - ); + expect(metadataStore.updateRequestDetails).not.toHaveBeenCalled(); disposables.dispose(); }); }); describe('createDelegatedSession', () => { - it('creates session and finalizes', async () => { - const { initializer, sessionService, workspaceFolderService, metadataStore } = createInitializer(); + it('creates session and resolves model', async () => { + const { initializer, sessionService } = createInitializer(); const workspace: IWorkspaceInfo = { folder: URI.file('/workspace') as unknown as vscode.Uri, repository: undefined, @@ -594,40 +583,10 @@ describe('ChatSessionInitializer', () => { expect(result.session).toBeDefined(); expect(result.model).toEqual(expect.objectContaining({ model: 'resolved-model' })); expect(sessionService.createSession).toHaveBeenCalled(); - expect(workspaceFolderService.trackSessionWorkspaceFolder).toHaveBeenCalled(); - expect(metadataStore.updateRequestDetails).toHaveBeenCalled(); - }); - - it('sets worktree properties when workspace has worktree', async () => { - const { initializer, worktreeService } = createInitializer(); - const workspace: IWorkspaceInfo = { - folder: URI.file('/workspace') as unknown as vscode.Uri, - repository: URI.file('/repo') as unknown as vscode.Uri, - repositoryProperties: undefined, - worktree: URI.file('/worktree') as unknown as vscode.Uri, - worktreeProperties: { - version: 2, - baseCommit: 'abc', - baseBranchName: 'main', - branchName: 'copilot/test', - repositoryPath: '/repo', - worktreePath: '/worktree', - }, - }; - - await initializer.createDelegatedSession( - makeRequest(), workspace, { mcpServerMappings: new Map() }, - CancellationToken.None - ); - - expect(worktreeService.setWorktreeProperties).toHaveBeenCalledWith( - 'test-session-id', - expect.objectContaining({ branchName: 'copilot/test' }) - ); }); - it('does not track workspace folder for isolated session', async () => { - const { initializer, workspaceFolderService } = createInitializer(); + it('does not set worktree properties or track workspace folder (moved to startRequest)', async () => { + const { initializer, worktreeService, workspaceFolderService, metadataStore } = createInitializer(); const workspace: IWorkspaceInfo = { folder: URI.file('/workspace') as unknown as vscode.Uri, repository: URI.file('/repo') as unknown as vscode.Uri, @@ -648,8 +607,9 @@ describe('ChatSessionInitializer', () => { CancellationToken.None ); - // Isolated session (has worktreeProperties) should NOT track workspace folder + expect(worktreeService.setWorktreeProperties).not.toHaveBeenCalled(); expect(workspaceFolderService.trackSessionWorkspaceFolder).not.toHaveBeenCalled(); + expect(metadataStore.updateRequestDetails).not.toHaveBeenCalled(); }); }); }); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionRequestLifecycle.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionRequestLifecycle.spec.ts index eca0089c739dc..dd9356818b6cc 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionRequestLifecycle.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionRequestLifecycle.spec.ts @@ -5,21 +5,25 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type * as vscode from 'vscode'; +import { ILogService } from '../../../../platform/log/common/logService'; import { mock } from '../../../../util/common/test/simpleMock'; import { Event } from '../../../../util/vs/base/common/event'; import { URI } from '../../../../util/vs/base/common/uri'; import { ChatSessionStatus } from '../../../../vscodeTypes'; +import { IChatSessionMetadataStore } from '../../common/chatSessionMetadataStore'; import { IChatSessionWorkspaceFolderService } from '../../common/chatSessionWorkspaceFolderService'; import { IChatSessionWorktreeCheckpointService } from '../../common/chatSessionWorktreeCheckpointService'; import { IChatSessionWorktreeService } from '../../common/chatSessionWorktreeService'; +import { IWorkspaceInfo } from '../../common/workspaceInfo'; import { IPullRequestDetectionService } from '../pullRequestDetectionService'; -import { SessionRequestLifecycle, SessionCompletionInfo } from '../sessionRequestLifecycle'; +import { SessionCompletionInfo, SessionRequestLifecycle } from '../sessionRequestLifecycle'; // ─── Test Helpers ──────────────────────────────────────────────── class TestWorktreeService extends mock() { declare readonly _serviceBrand: undefined; override handleRequestCompleted = vi.fn(async () => { }); + override setWorktreeProperties = vi.fn(async () => { }); } class TestCheckpointService extends mock() { @@ -31,6 +35,7 @@ class TestCheckpointService extends mock( class TestWorkspaceFolderService extends mock() { declare readonly _serviceBrand: undefined; override handleRequestCompleted = vi.fn(async () => { }); + override trackSessionWorkspaceFolder = vi.fn(async () => { }); } class TestPrDetectionService extends mock() { @@ -39,6 +44,16 @@ class TestPrDetectionService extends mock() { override handlePullRequestCreated = vi.fn(); } +class TestMetadataStore extends mock() { + declare readonly _serviceBrand: undefined; + override updateRequestDetails = vi.fn(async () => { }); +} + +class TestLogService extends mock() { + declare readonly _serviceBrand: undefined; + override error = vi.fn(); +} + function makeRequest(id: string = 'req-1'): vscode.ChatRequest { return { id } as unknown as vscode.ChatRequest; } @@ -82,6 +97,32 @@ function makeToken(cancelled: boolean = false): vscode.CancellationToken { return { isCancellationRequested: cancelled, onCancellationRequested: vi.fn() } as unknown as vscode.CancellationToken; } +function makeWorkspace(overrides?: Partial): IWorkspaceInfo { + return { + folder: URI.file('/workspace') as unknown as vscode.Uri, + repository: undefined, + repositoryProperties: undefined, + worktree: undefined, + worktreeProperties: undefined, + ...overrides, + }; +} + +function makeIsolatedWorkspace(): IWorkspaceInfo { + return makeWorkspace({ + repository: URI.file('/repo') as unknown as vscode.Uri, + worktree: URI.file('/worktree') as unknown as vscode.Uri, + worktreeProperties: { + version: 2, + baseCommit: 'abc', + baseBranchName: 'main', + branchName: 'copilot/test', + repositoryPath: '/repo', + worktreePath: '/worktree', + }, + }); +} + // ─── Tests ─────────────────────────────────────────────────────── describe('SessionRequestLifecycle', () => { @@ -89,6 +130,8 @@ describe('SessionRequestLifecycle', () => { let checkpointService: TestCheckpointService; let workspaceFolderService: TestWorkspaceFolderService; let prDetectionService: TestPrDetectionService; + let metadataStore: TestMetadataStore; + let logService: TestLogService; let handler: SessionRequestLifecycle; beforeEach(() => { @@ -97,26 +140,93 @@ describe('SessionRequestLifecycle', () => { checkpointService = new TestCheckpointService(); workspaceFolderService = new TestWorkspaceFolderService(); prDetectionService = new TestPrDetectionService(); + metadataStore = new TestMetadataStore(); + logService = new TestLogService(); handler = new SessionRequestLifecycle( worktreeService, checkpointService, workspaceFolderService, prDetectionService, + metadataStore, + logService, ); }); describe('startRequest', () => { it('creates baseline checkpoint on first request', async () => { const request = makeRequest(); - await handler.startRequest('session-1', request, true); + await handler.startRequest('session-1', request, true, makeWorkspace()); expect(checkpointService.handleRequest).toHaveBeenCalledWith('session-1'); }); it('skips baseline checkpoint on subsequent requests', async () => { const request = makeRequest(); - await handler.startRequest('session-1', request, false); + await handler.startRequest('session-1', request, false, makeWorkspace()); expect(checkpointService.handleRequest).not.toHaveBeenCalled(); }); + + it('records request metadata with modeInstructions', async () => { + const request = makeRequest(); + (request as any).modeInstructions2 = { + name: 'test', + content: 'instructions', + }; + await handler.startRequest('session-1', request, false, makeWorkspace(), 'test-agent'); + + expect(metadataStore.updateRequestDetails).toHaveBeenCalledWith( + 'session-1', + [{ + vscodeRequestId: 'req-1', + agentId: 'test-agent', + modeInstructions: expect.objectContaining({ name: 'test', content: 'instructions' }), + }] + ); + }); + + it('records metadata without modeInstructions when request has no modeInstructions2', async () => { + const request = makeRequest(); + await handler.startRequest('session-1', request, false, makeWorkspace(), 'test-agent'); + + expect(metadataStore.updateRequestDetails).toHaveBeenCalledWith( + 'session-1', + [{ + vscodeRequestId: 'req-1', + agentId: 'test-agent', + modeInstructions: undefined, + }] + ); + }); + + it('sets worktree properties on first request with worktree', async () => { + const workspace = makeIsolatedWorkspace(); + await handler.startRequest('session-1', makeRequest(), true, workspace); + + expect(worktreeService.setWorktreeProperties).toHaveBeenCalledWith( + 'session-1', + expect.objectContaining({ branchName: 'copilot/test' }) + ); + }); + + it('does not set worktree properties on subsequent requests', async () => { + const workspace = makeIsolatedWorkspace(); + await handler.startRequest('session-1', makeRequest(), false, workspace); + + expect(worktreeService.setWorktreeProperties).not.toHaveBeenCalled(); + }); + + it('tracks workspace folder for non-isolated session on first request', async () => { + const workspace = makeWorkspace(); + await handler.startRequest('session-1', makeRequest(), true, workspace); + + expect(workspaceFolderService.trackSessionWorkspaceFolder).toHaveBeenCalled(); + }); + + it('does not track workspace folder for isolated session', async () => { + const workspace = makeIsolatedWorkspace(); + await handler.startRequest('session-1', makeRequest(), true, workspace); + + expect(workspaceFolderService.trackSessionWorkspaceFolder).not.toHaveBeenCalled(); + }); }); describe('endRequest', () => { @@ -124,7 +234,7 @@ describe('SessionRequestLifecycle', () => { const request = makeRequest(); const session = makeIsolatedSession(); - await handler.startRequest('session-1', request, false); + await handler.startRequest('session-1', request, false, makeWorkspace()); await handler.endRequest('session-1', request, session, makeToken()); expect(worktreeService.handleRequestCompleted).toHaveBeenCalledWith('session-1'); @@ -136,7 +246,7 @@ describe('SessionRequestLifecycle', () => { const request = makeRequest(); const session = makeSession(); // non-isolated, has folder - await handler.startRequest('session-1', request, false); + await handler.startRequest('session-1', request, false, makeWorkspace()); await handler.endRequest('session-1', request, session, makeToken()); expect(workspaceFolderService.handleRequestCompleted).toHaveBeenCalledWith('session-1'); @@ -148,7 +258,7 @@ describe('SessionRequestLifecycle', () => { const request = makeRequest(); const session = makeSession({ status: ChatSessionStatus.InProgress }); - await handler.startRequest('session-1', request, false); + await handler.startRequest('session-1', request, false, makeWorkspace()); await handler.endRequest('session-1', request, session, makeToken()); expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled(); @@ -160,7 +270,7 @@ describe('SessionRequestLifecycle', () => { const request = makeRequest(); const session = makeSession({ status: undefined }); - await handler.startRequest('session-1', request, false); + await handler.startRequest('session-1', request, false, makeWorkspace()); await handler.endRequest('session-1', request, session, makeToken()); expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled(); @@ -179,7 +289,7 @@ describe('SessionRequestLifecycle', () => { }, }); - await handler.startRequest('session-1', request, false); + await handler.startRequest('session-1', request, false, makeWorkspace()); await handler.endRequest('session-1', request, session, makeToken()); expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled(); @@ -193,8 +303,8 @@ describe('SessionRequestLifecycle', () => { const req2 = makeRequest('req-2'); const session = makeSession(); - await handler.startRequest('session-1', req1, false); - await handler.startRequest('session-1', req2, false); + await handler.startRequest('session-1', req1, false, makeWorkspace()); + await handler.startRequest('session-1', req2, false, makeWorkspace()); // First request completes — should defer (2 pending) await handler.endRequest('session-1', req1, session, makeToken()); @@ -212,7 +322,7 @@ describe('SessionRequestLifecycle', () => { const request = makeRequest(); const session = makeSession(); - await handler.startRequest('session-1', request, false); + await handler.startRequest('session-1', request, false, makeWorkspace()); await handler.endRequest('session-1', request, session, makeToken(true)); expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled(); @@ -224,7 +334,7 @@ describe('SessionRequestLifecycle', () => { const request = makeRequest(); const session = makeSession(); - await handler.startRequest('session-1', request, false); + await handler.startRequest('session-1', request, false, makeWorkspace()); await handler.endRequest('session-1', request, session, makeToken()); // PR detection is fire-and-forget; wait for microtask @@ -237,13 +347,13 @@ describe('SessionRequestLifecycle', () => { const request = makeRequest(); const session = makeSession(); - await handler.startRequest('session-1', request, false); + await handler.startRequest('session-1', request, false, makeWorkspace()); await expect(handler.endRequest('session-1', request, session, makeToken())).rejects.toThrow('commit failed'); // After the error, a new request for the same session should proceed normally workspaceFolderService.handleRequestCompleted.mockResolvedValue(); const req2 = makeRequest('req-2'); - await handler.startRequest('session-1', req2, false); + await handler.startRequest('session-1', req2, false, makeWorkspace()); await handler.endRequest('session-1', req2, session, makeToken()); expect(workspaceFolderService.handleRequestCompleted).toHaveBeenCalledTimes(2); }); From c88afdaf7722e8c35f480098a5269c7b8009751f Mon Sep 17 00:00:00 2001 From: Robo Date: Fri, 10 Apr 2026 17:33:39 +0900 Subject: [PATCH 13/15] chore: update electron@39.8.7 (#308959) * chore: update electron@39.8.7 * chore: update command * chore: remove showNodeSystemCertificates command * chore: bump distro --- .npmrc | 4 +- build/checksums/electron.txt | 150 +++++++++--------- cgmanifest.json | 6 +- extensions/copilot/package.json | 5 - extensions/copilot/package.nls.json | 1 - .../log/vscode-node/loggingActions.ts | 40 ----- package-lock.json | 8 +- package.json | 6 +- src/vs/workbench/api/node/proxyResolver.ts | 79 --------- 9 files changed, 87 insertions(+), 212 deletions(-) diff --git a/.npmrc b/.npmrc index 1030b17d15c34..8255ca3e4b0b6 100644 --- a/.npmrc +++ b/.npmrc @@ -1,6 +1,6 @@ disturl="https://electronjs.org/headers" -target="39.8.5" -ms_build_id="13703022" +target="39.8.7" +ms_build_id="13797146" runtime="electron" ignore-scripts=false build_from_source="true" diff --git a/build/checksums/electron.txt b/build/checksums/electron.txt index b1a8c5ad2f900..6aacfecc747bb 100644 --- a/build/checksums/electron.txt +++ b/build/checksums/electron.txt @@ -1,75 +1,75 @@ -6441cb87d6c90e2371b7ddbc97f5985d120d320806efa31f147d1efc4e218f18 *chromedriver-v39.8.5-darwin-arm64.zip -2a64fee49920ba1f49bb803ffd7e0434ba619c51633817c926b3ad13122f5318 *chromedriver-v39.8.5-darwin-x64.zip -14502c0bbe15c43f2f4fa6b19c4f2d65a1bc80fc4d446751ad74d52a1f6462e8 *chromedriver-v39.8.5-linux-arm64.zip -adb219dc60834c475a538a7362270069de3e41c1a8c67f53826f3590d208f611 *chromedriver-v39.8.5-linux-armv7l.zip -d126f6b09f6a52921589af076e2a6d141500916af8be28323bddfda89a30d4ab *chromedriver-v39.8.5-linux-x64.zip -d40009137af99e0d36e2fabc3c3cd111ee427bfcc7c9c61c236ad6d69fd349d4 *chromedriver-v39.8.5-mas-arm64.zip -f4f77e87952a843bd97f2c8c957c506f68ae6c1c5141957a7b4ad1ac088c0dde *chromedriver-v39.8.5-mas-x64.zip -c391a3a26d1a078ea67576076d5994c26bad0b86f571aaac397e81f634041ccd *chromedriver-v39.8.5-win32-arm64.zip -8895e250359bb152f1e6525a9655521ba71fd4eb148127ac52c470a3828d431f *chromedriver-v39.8.5-win32-ia32.zip -b34958b53c9a1b2e9ba03e31ad612b7386269b41d562d54b5559d026209f9309 *chromedriver-v39.8.5-win32-x64.zip -979e63cc78437830414f6eb4d825b499eea7ad9d0a7f5b5fd5f9b8cfa67c6c3e *electron-api.json -14aeda5bd4b2f8b6e5531cb5c71af9d8aff57fd740750ed730eb807b44c5c786 *electron-v39.8.5-darwin-arm64-dsym-snapshot.zip -6d97f6ed2fbf3e97f7360c93b7b733be41e5ca86a3ea7fc3880693e5c2329458 *electron-v39.8.5-darwin-arm64-dsym.zip -e84ab05a4b5b6a98c56ff7ce29f090cfc20fdbf298ab7de996c06f0779f6504d *electron-v39.8.5-darwin-arm64-symbols.zip -51b448f6a4c8d53a5f28e8f2183ee1e894e1e543440a5d1b8a04ae33f5bf8920 *electron-v39.8.5-darwin-arm64.zip -2744cb346770606372332075955d09d4373b53f1124a2963238137f847e9e1e6 *electron-v39.8.5-darwin-x64-dsym-snapshot.zip -a33f27ef64824765620e853269b82a910f8a3aa8b9743af40fc90ba995e11f07 *electron-v39.8.5-darwin-x64-dsym.zip -458dc048fab4269345313525c078945fee854ada68fe128f9f9023299f7ab02c *electron-v39.8.5-darwin-x64-symbols.zip -d85f84e1f6e9088c7cdd0355bc4b62f93152a22759ff99b0e53fc25fb94bfe94 *electron-v39.8.5-darwin-x64.zip -68bdeb6f87b67eca2fd05582350d214200eeb7f9572fe5d4f30a28a92e754121 *electron-v39.8.5-linux-arm64-debug.zip -ff6b21dce38e7276d76bdc22b83ea8840b40a8b1948d1430d9168eb459ba90d3 *electron-v39.8.5-linux-arm64-symbols.zip -af9405705b982dba979809e9e964d12d0a4541239d3459b0cbd4f96186623c34 *electron-v39.8.5-linux-arm64.zip -0919549d1da862aa8c723a33cc577e234b9e677ba690145c637685a3175afb5e *electron-v39.8.5-linux-armv7l-debug.zip -a193012557c7b0b8d71af17a7f5ed83795b602b28c3ba2ae57fba0c08f044c53 *electron-v39.8.5-linux-armv7l-symbols.zip -bc53789fefdb5b05e9de26af64367d5e458f20c0b854231226527c572797a484 *electron-v39.8.5-linux-armv7l.zip -f55e4db40c61b6741ea081ddac674a9ccb121b0ffda9121442d47d4d847bc10e *electron-v39.8.5-linux-x64-debug.zip -3a78174f670458fd0ffe45f8f4a1a836da2887c575eba69ae3af50ea8efcff8a *electron-v39.8.5-linux-x64-symbols.zip -dd5f4b21682e9d031defff525809dc58028521925f42ec9caa5ca6535d1524e7 *electron-v39.8.5-linux-x64.zip -3410d48e14f1507308a8b83da57c19bc383a38a33d817110fa9dd68ad25046f7 *electron-v39.8.5-mas-arm64-dsym-snapshot.zip -bb44b96825125c2a581c072d35dbc766af0933f293a566f66ee408a957be4482 *electron-v39.8.5-mas-arm64-dsym.zip -b1c7e83d30e5790a1ec3ef23765bec7b1ceb70bd133f6a7c9300d4d466b50ff5 *electron-v39.8.5-mas-arm64-symbols.zip -33f98522714a6954b2360a46e4dc30c6e4d5e7143327eb0d77c0e8b5767a5821 *electron-v39.8.5-mas-arm64.zip -1b33649e8472851c195c087a499747eeba9c9df1b4b55015f62da3d265fe77b2 *electron-v39.8.5-mas-x64-dsym-snapshot.zip -d9f4d607c87dfe14d2b0bbce50af07ddd9f6d0f19a4b6fc2e523400a7b47b115 *electron-v39.8.5-mas-x64-dsym.zip -40d40299c95d0b3801a46be7ccc4ce783162f0a6f6b05887d50c8bac5541315d *electron-v39.8.5-mas-x64-symbols.zip -2f9ca4b40d49adc416a39d7abda08d73974fc685b644cec8339d2e43fe170de8 *electron-v39.8.5-mas-x64.zip -8c2cb9f4e325c5e33bde838d1340706a8864f90efbc3fe37faca87b32d4132d8 *electron-v39.8.5-win32-arm64-pdb.zip -bd3b659755bdbfa1b3b1800c34934f950d233ae0343406a3787afe5e7830331b *electron-v39.8.5-win32-arm64-symbols.zip -86f657848477077247c3cc8a29bbe68ab3c6af6c48e58ea11ccb83ae7cde09c4 *electron-v39.8.5-win32-arm64-toolchain-profile.zip -97f534856e01a7025835fab4ffec702b3aa654ec22bb0e992803e33bb9e40671 *electron-v39.8.5-win32-arm64.zip -d8198af8420e08cfef2b393551f91599bbaa4ded6cf223bc6afcfb8929960efe *electron-v39.8.5-win32-ia32-pdb.zip -2890de447c78762d4d1608d5607decb1b837e24c3d51131bf759ff49106b1d63 *electron-v39.8.5-win32-ia32-symbols.zip -86f657848477077247c3cc8a29bbe68ab3c6af6c48e58ea11ccb83ae7cde09c4 *electron-v39.8.5-win32-ia32-toolchain-profile.zip -80ae355c419a6c0a64072710cd4147c426e840e5595529ff3713680b6b0c3656 *electron-v39.8.5-win32-ia32.zip -94d5215bc025cbc0308712b89292102a80bdb178294f585a940f7074f9e1bf42 *electron-v39.8.5-win32-x64-pdb.zip -8ec7cb5727a3f95172e927823ed83d7397fc240e7f241711618ae1da8df41c42 *electron-v39.8.5-win32-x64-symbols.zip -86f657848477077247c3cc8a29bbe68ab3c6af6c48e58ea11ccb83ae7cde09c4 *electron-v39.8.5-win32-x64-toolchain-profile.zip -d75c0057fd58c08023ff82ed9dd38443f90b4a962c9a9359aa74d9070f4add34 *electron-v39.8.5-win32-x64.zip -3ecbec0c125266a5813efeabd010b4dd0b439adfd6ad7372e662f0ab9effa6e1 *electron.d.ts -27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.5-darwin-arm64.zip -321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.5-darwin-x64.zip -52ae6eccbdb4a9403a6c3eb46b356a28940ec25958b6b9181fb2f38e612e40ed *ffmpeg-v39.8.5-linux-arm64.zip -622cb781fb1e3b9617e7e60c36384427f7b0d9b5ad888e9bc356a83b050e13f1 *ffmpeg-v39.8.5-linux-armv7l.zip -ba441851788008362f013bf2983b22b0042af8df31bf90123328f928cc067492 *ffmpeg-v39.8.5-linux-x64.zip -27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.5-mas-arm64.zip -321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.5-mas-x64.zip -71d478432519dda32a53c47eb5fcb97d23265273ff7939da54cccf7a19f6f87e *ffmpeg-v39.8.5-win32-arm64.zip -3c52c43a62ebf1ebbe0c02611ad4399eb0131257849b707156417ffcbaa144c8 *ffmpeg-v39.8.5-win32-ia32.zip -a83ee4e4c09eb741942d7421822a58e6ad167cd0085a5470679641f1f89f4ddb *ffmpeg-v39.8.5-win32-x64.zip -ab36c3b4b42994616f93acb84337c94b9c8622b4e568f148eb8c7eb292e35acb *hunspell_dictionaries.zip -6104e4446cd246d953026379f8cd46dc56c109efb35d46c466e814de237b9999 *libcxx-objects-v39.8.5-linux-arm64.zip -dcebd0c67d70010a66558d82d53d33b642b3016dd0fbf305aa6f13d95ebafdfc *libcxx-objects-v39.8.5-linux-armv7l.zip -0831658f5b7feee6ded0d811e738ca191021e228696a07b3a615aaa7dd376b85 *libcxx-objects-v39.8.5-linux-x64.zip -8e22df064ecc116052d2518596a3391ea0a275a2907931b50b46968815bb6499 *libcxx_headers.zip -edf24979ef27199ad5cd9de9b52a56ef9bad022c7d9daff59c81a873bf0185cc *libcxxabi_headers.zip -d6d8535cbd733e6b2dfffc22d06030ff795d2981b11c1f66658e98919246a4ef *mksnapshot-v39.8.5-darwin-arm64.zip -c10436ad7436dda07f5d42a5664389a3a5fbb25a49693e21cc4aa57713a0df73 *mksnapshot-v39.8.5-darwin-x64.zip -909ca6e3c6b701b3ec8a6bdd33ee8825534fbd224fa3977763d9dcbba0311da9 *mksnapshot-v39.8.5-linux-arm64-x64.zip -2d78e66afccb846fe0b0709265a85fc3af2243beb846b6b76dc5b61f4e41cc59 *mksnapshot-v39.8.5-linux-armv7l-x64.zip -9a9d9059854fd703e68d14c625e56e8d9ee22c882bbaaff28f76f960e99aff3f *mksnapshot-v39.8.5-linux-x64.zip -90645c495177ec4a9ef531d2d305cf78a5d1f7d740aabb272d09b4489a05d1ff *mksnapshot-v39.8.5-mas-arm64.zip -e8f3abd074b97e62f7692c5701355c54d9e2eca6ab75d441b61c31a4d7bb7c01 *mksnapshot-v39.8.5-mas-x64.zip -b76318111bcbf976651583bddc20f69ecc502eb1f2a9494273f96e3c026c2f40 *mksnapshot-v39.8.5-win32-arm64-x64.zip -51ccb7b7b42345edff911d0bde33c8aafc2fb4bd1ce83accc48bfd28170c24f3 *mksnapshot-v39.8.5-win32-ia32.zip -06d773593a664bc3c55a7ad94edefe40da32394c39962b8745a7e3864432b2ff *mksnapshot-v39.8.5-win32-x64.zip +2e2a3533f9969ded3b11eb0baa5357abeb652975d9bcaea0b0725c9bd0866061 *chromedriver-v39.8.7-darwin-arm64.zip +c74882bcbdd53f6e8cd65906809ac446bb032dce3ce8f109e2376d49b9b394ee *chromedriver-v39.8.7-darwin-x64.zip +a8caf72372eb47deb336dc440eb183c30d228b3fef5349dac7571d86103f117c *chromedriver-v39.8.7-linux-arm64.zip +5d5f02b2e28e8328435d2fd83207098e69dc3e5fecbbbdc2612792370ab2c4ec *chromedriver-v39.8.7-linux-armv7l.zip +e336dc2dce9d11d44f6eb5b5cc655d3311a9a109ea184625da3ac51181c3ad27 *chromedriver-v39.8.7-linux-x64.zip +8c795231a7d143cb242083e466763007609ffa63b65f6c7a8b46c4e95bf04748 *chromedriver-v39.8.7-mas-arm64.zip +5033c9550cb25a228fee9ecb2179f4258847585ee1d8609aec14f42d5aeb654b *chromedriver-v39.8.7-mas-x64.zip +9834624a8f92bec9931a8b74ce2e1195e0527f61f88c18367129371cfeb7d87b *chromedriver-v39.8.7-win32-arm64.zip +fffd2a04a1e3a9d0b2aeb47044c308c6ef9e361f23b2d120e19f507f4de53e1c *chromedriver-v39.8.7-win32-ia32.zip +7b25598ba3db1b0df6253e7233ef68e4cb9764aa7f62d854fe3c620edfbb2a7c *chromedriver-v39.8.7-win32-x64.zip +ca234cdbdf5cd724adaf5079d860a3d510d8cdfbff7c9392d7b6b0e6948593f7 *electron-api.json +62fe91fbbe83d68e713ee48d1dbbad06dc3f2be739eb649002e390a585a4639d *electron-v39.8.7-darwin-arm64-dsym-snapshot.zip +c837b62c12dc16dd41244fbc2c4f8c97ceb93d56ae6a41064782f6876bf01ac0 *electron-v39.8.7-darwin-arm64-dsym.zip +e4d96c888bbe699e7be9dc90aa39b64dd23dbe3f42d86f354c490f77a9fb6c41 *electron-v39.8.7-darwin-arm64-symbols.zip +86fa117ba10e36149ca33d7c22de2cfc3fb7490ca88b07ce953d2efe1f2a41cd *electron-v39.8.7-darwin-arm64.zip +4182678fadb19e0d9be6b7411e18a1e8e5801d14c99fcb5faa5d8a32f3af2cab *electron-v39.8.7-darwin-x64-dsym-snapshot.zip +765c509e1f3090bdf9610e9b618264bf8251ac708b2d7ffb4550ce75f036a6aa *electron-v39.8.7-darwin-x64-dsym.zip +52804dec7a659502d4d2df194d4a14abc70dad315cff001712100feb640c589d *electron-v39.8.7-darwin-x64-symbols.zip +5dfe5559fd283c3962221c674b30a5b986895b644b1b4bc179e0c7673a14f1cf *electron-v39.8.7-darwin-x64.zip +bdc78aa93b64543885997c16e198270a2b8b8b955db3956f491681c01134f925 *electron-v39.8.7-linux-arm64-debug.zip +8581d058382d70afd48bc0d1ace4189ada18770b5ebe1347adc667e30bb81650 *electron-v39.8.7-linux-arm64-symbols.zip +fd721650a0e25829b76d307e944383be828533cdddd53e44a0b772e96e3e019b *electron-v39.8.7-linux-arm64.zip +d17f1d655ca2b056da6b8ba5e59368e3061d38450e3616e5e9faa2a4e0cbbff6 *electron-v39.8.7-linux-armv7l-debug.zip +22b4ed4f566432ff040491caae6d926d4623d24d28e96e5f818245433dab93d4 *electron-v39.8.7-linux-armv7l-symbols.zip +5d0a75a53cdba1ecfc678910084802fe500f13f470310ae1d2c66840d3c7390b *electron-v39.8.7-linux-armv7l.zip +b2e5d0c1025204aa0f026996490a4b33fff7e89b88eee995c88399eed4439951 *electron-v39.8.7-linux-x64-debug.zip +5868e2cadc566968692b44bc9e2aa5815eec2b7852c4dc8474719bc90f0ae689 *electron-v39.8.7-linux-x64-symbols.zip +233b2775f1c46e5ebd5afeb4fb95ce9fda61229bad20aef1031468eb54b3656e *electron-v39.8.7-linux-x64.zip +350782483b59fe6a96ecf90b4095b7f5b2f941030e946140490697f29c94f85e *electron-v39.8.7-mas-arm64-dsym-snapshot.zip +6f850ac7faf11413513bf916b336053d5f73d262220e6b4cd88f2be79a902c26 *electron-v39.8.7-mas-arm64-dsym.zip +c505efd13d3b328f662d6853bfc13c8683bd1dd06113d403d8a58fdc0c82fd3d *electron-v39.8.7-mas-arm64-symbols.zip +bd27cbfa54c1f816bd865b134d9b10cfbc7631adb7c21ade60d98d100e83a745 *electron-v39.8.7-mas-arm64.zip +ac83e48c77a745e19e78b0feca136af2e8d309d6a584ea18d2d86c33258517ea *electron-v39.8.7-mas-x64-dsym-snapshot.zip +30c8f8a7a810b39408e4e19ec6ec42ac47aa945be6085f31b9977743f001cbea *electron-v39.8.7-mas-x64-dsym.zip +cda7da0f54c8a13fa8426320f688e51b2c4a9581998876d7d22346d6a81d4f69 *electron-v39.8.7-mas-x64-symbols.zip +818b0d948d09f73deb55de108799e963ff8ea432f81574c8000c5377b55e4119 *electron-v39.8.7-mas-x64.zip +e6c7fb13390a59e40bc5a26ec1d90370c2a055c964b01eddbf97520dc93f5571 *electron-v39.8.7-win32-arm64-pdb.zip +7e1c2becc143e2af3d59cc7832fb32f5208fef866fbe729e13ff58da67d68744 *electron-v39.8.7-win32-arm64-symbols.zip +86f657848477077247c3cc8a29bbe68ab3c6af6c48e58ea11ccb83ae7cde09c4 *electron-v39.8.7-win32-arm64-toolchain-profile.zip +798a54b33d0841098428809fca3aa1332b46c9858e6bb2d415a8a7ac09784f4e *electron-v39.8.7-win32-arm64.zip +772a636300d4196205d57cb486ac6b49c6209138e78ce2a3ba97bb822855be22 *electron-v39.8.7-win32-ia32-pdb.zip +5d3680f53a0abbf9e4caec9abf9fcf1728aa5dfb71d323c2dccf7161e10f10c9 *electron-v39.8.7-win32-ia32-symbols.zip +86f657848477077247c3cc8a29bbe68ab3c6af6c48e58ea11ccb83ae7cde09c4 *electron-v39.8.7-win32-ia32-toolchain-profile.zip +669b5dd7aee565b594f0f786304827f6fa4c40710fa71b6101b3356f50f68f2a *electron-v39.8.7-win32-ia32.zip +ebcb34179d1bda0b8be55354fb9a21a7d45093653475a23a6c84535b8e279e1d *electron-v39.8.7-win32-x64-pdb.zip +8c386ea127b2944832053519badcb596e34d84adf7efb9f96822dd751f018a51 *electron-v39.8.7-win32-x64-symbols.zip +86f657848477077247c3cc8a29bbe68ab3c6af6c48e58ea11ccb83ae7cde09c4 *electron-v39.8.7-win32-x64-toolchain-profile.zip +272b94970b8c7669c2367a2bd9e52a673665aaf33eb5e54e32ca7551497859b2 *electron-v39.8.7-win32-x64.zip +d8c5d7fd580c05250687262c700ac4ab20c3dc366e06887a99d806079393a14e *electron.d.ts +966ecdbe01413fb2813421c9bedf3a5ca74b561c5db3d6a4541670a38bddbef6 *ffmpeg-v39.8.7-darwin-arm64.zip +acbab76adefccc9d2adca16d8e3942e75f11fd7c4be7775db7f8a5c304ea1e35 *ffmpeg-v39.8.7-darwin-x64.zip +52ae6eccbdb4a9403a6c3eb46b356a28940ec25958b6b9181fb2f38e612e40ed *ffmpeg-v39.8.7-linux-arm64.zip +622cb781fb1e3b9617e7e60c36384427f7b0d9b5ad888e9bc356a83b050e13f1 *ffmpeg-v39.8.7-linux-armv7l.zip +ba441851788008362f013bf2983b22b0042af8df31bf90123328f928cc067492 *ffmpeg-v39.8.7-linux-x64.zip +966ecdbe01413fb2813421c9bedf3a5ca74b561c5db3d6a4541670a38bddbef6 *ffmpeg-v39.8.7-mas-arm64.zip +acbab76adefccc9d2adca16d8e3942e75f11fd7c4be7775db7f8a5c304ea1e35 *ffmpeg-v39.8.7-mas-x64.zip +de8643e5d52bcbb39432d1d32d93a9609cd98418a603dda05b7bbac6156f7c9b *ffmpeg-v39.8.7-win32-arm64.zip +c2be0960b6325757e401fa9926a86421cafb050a41ecdf925f657adce091d114 *ffmpeg-v39.8.7-win32-ia32.zip +c03a89f7acdb6abc22829ab241fcaf55842c463e58fc5fcc1405326cd3d0ba29 *ffmpeg-v39.8.7-win32-x64.zip +e06e1bd83d8d9641f614048bd60da15a6237f050bb345a62eaedab9fbeb98d33 *hunspell_dictionaries.zip +8b5d4ca6ff70993a688414ba64b5acf34c924f480b08d4399491fd96bf060b1e *libcxx-objects-v39.8.7-linux-arm64.zip +c40682d02395a3c585f07905a2e5a9ef9fd307a2c92cc1a76f23114cfe95564b *libcxx-objects-v39.8.7-linux-armv7l.zip +4c6e0a4ebc40cb2db5361b40deb5acbd693d082f4bb58d84b6baf0280163d603 *libcxx-objects-v39.8.7-linux-x64.zip +4269d16db215839a546e3f72d17f450ea5623539e85d3cab85d13f47e14e60f5 *libcxx_headers.zip +06659d8c13cf63ef52ee06be71be0e4d83612c577539f630c97274cbe1ec9ad2 *libcxxabi_headers.zip +222f0e94b6e61b336a437e33a7815ff70d6aaafa504e14dfc3667c2aea84c3c2 *mksnapshot-v39.8.7-darwin-arm64.zip +15fef5c087e84569d7539ad95f51501995aed6149890bff9a05a4445e123c010 *mksnapshot-v39.8.7-darwin-x64.zip +7ca75c9fa6a9be45298532b7718644e53b54ff2572f2208739f5eb8e4aeb1358 *mksnapshot-v39.8.7-linux-arm64-x64.zip +6706f623f0be74d69159ed47642bffbf3e9c37730c7025a99492afbbce94b524 *mksnapshot-v39.8.7-linux-armv7l-x64.zip +923926bb76fbaec25780fc202a662af9d02ce75dc0cbe81ae926000be75b7214 *mksnapshot-v39.8.7-linux-x64.zip +815ac4296876b9fb769eeb75ab8c542e913d1a6eb6f5dd4669099ffe6ee3d4dd *mksnapshot-v39.8.7-mas-arm64.zip +dbbccaf64c18da3d41c22de222d187b1b30ca3016778a962751f177a79d0d4be *mksnapshot-v39.8.7-mas-x64.zip +d21ff9be73f0307bc67d8d62b8df5d50c0a9b7cb0f7ea3f12683af051dfad994 *mksnapshot-v39.8.7-win32-arm64-x64.zip +e15e35f5952e88b115e30e5e8be9a003926acafaafc2f70363e5f41a2449e26a *mksnapshot-v39.8.7-win32-ia32.zip +100471d1064189d03f68b81a68f289b06b231911ab42c715aec956d0a4a11df4 *mksnapshot-v39.8.7-win32-x64.zip diff --git a/cgmanifest.json b/cgmanifest.json index 69806e5710ee4..41466b8883d7e 100644 --- a/cgmanifest.json +++ b/cgmanifest.json @@ -529,13 +529,13 @@ "git": { "name": "electron", "repositoryUrl": "https://github.com/electron/electron", - "commitHash": "9d2f8cb4da0d35e2daf7e7f60e35313b508cb224", - "tag": "39.8.5" + "commitHash": "2d7e11a76ca841e08e31eb0121056d875f731f30", + "tag": "39.8.7" } }, "isOnlyProductionDependency": true, "license": "MIT", - "version": "39.8.5" + "version": "39.8.7" }, { "component": { diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 5525c801c6717..d931c5aba5596 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -2487,11 +2487,6 @@ "title": "%github.copilot.command.collectDiagnostics%", "category": "Developer" }, - { - "command": "github.copilot.debug.showNodeSystemCertificatesErrors", - "title": "%github.copilot.command.showNodeSystemCertificatesErrors%", - "category": "Developer" - }, { "command": "github.copilot.debug.inlineEdit.clearCache", "title": "%github.copilot.command.inlineEdit.clearCache%", diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 7b421582ee41b..b5a96f98edc9d 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -208,7 +208,6 @@ "github.copilot.chat.attachFile": "Add File to Chat", "github.copilot.chat.attachSelection": "Add Selection to Chat", "github.copilot.command.collectDiagnostics": "Chat Diagnostics", - "github.copilot.command.showNodeSystemCertificatesErrors": "Show Node.js System Certificates Errors", "github.copilot.command.inlineEdit.clearCache": "Clear Inline Suggestion Cache", "github.copilot.command.inlineEdit.reportNotebookNESIssue": "Report Notebook Inline Suggestion Issue", "github.copilot.command.showNotebookLog": "Show Chat Log Notebook", diff --git a/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts b/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts index f78b761d4c72e..b1d893e3c4bef 100644 --- a/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts +++ b/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts @@ -250,46 +250,6 @@ In corporate networks: [Troubleshooting firewall settings for GitHub Copilot](ht // Internal command is not declared in package.json so it can be used from the welcome views while the extension is being activated. this._context.subscriptions.push(vscode.commands.registerCommand('github.copilot.debug.collectDiagnostics.internal', collectDiagnostics)); this._context.subscriptions.push(vscode.commands.registerCommand('github.copilot.debug.showOutputChannel.internal', () => outputChannel.show())); - this._context.subscriptions.push(vscode.commands.registerCommand('github.copilot.debug.showNodeSystemCertificatesErrors', async () => { - const result: Record = {}; - try { - const certs = tls.getCACertificates('system'); - result.certificateCount = Array.isArray(certs) ? certs.length : 'unavailable'; - } catch (err: any) { - result.certificateCount = `Error: ${err?.message}`; - } - if (typeof (tls as any).getSystemCACertificatesErrors === 'function') { - try { - const errors = (tls as any).getSystemCACertificatesErrors(); - if (errors && typeof errors === 'object') { - const counts = new Map(); - for (const [category, entries] of Object.entries(errors)) { - if (Array.isArray(entries)) { - for (const entry of entries as { errorMessage?: string; errorCode?: number }[]) { - const code = entry.errorCode ?? 'missing code'; - const error = `${category}: ${entry.errorMessage ?? 'missing message'}`; - const key = `${error} (${code})`; - const existing = counts.get(key); - if (existing) { - existing.count++; - } else { - counts.set(key, { error, code, count: 1 }); - } - } - } - } - result.errorSummary = [...counts.values()].sort((a, b) => b.count - a.count); - } - result.errors = errors; - } catch (err: any) { - result.errors = `Error: ${err?.message}`; - } - } else { - result.errors = 'tls.getSystemCACertificatesErrors is not available'; - } - const document = await vscode.workspace.openTextDocument({ language: 'json', content: JSON.stringify(result, null, 2) }); - await vscode.window.showTextDocument(document); - })); this._context.subscriptions.push(new NetworkStatus(this.fetcherService, this.configurationService, this.experimentationService)); } diff --git a/package-lock.json b/package-lock.json index 2ad7243503ff5..c9bd92277cfcd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -113,7 +113,7 @@ "cookie": "^0.7.2", "debounce": "^1.0.0", "deemon": "^1.13.6", - "electron": "39.8.5", + "electron": "39.8.7", "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", @@ -7741,9 +7741,9 @@ "dev": true }, "node_modules/electron": { - "version": "39.8.5", - "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.5.tgz", - "integrity": "sha512-q6+LiQIcTadSyvtPgLDQkCtVA9jQJXQVMrQcctfOJILh6OFMN+UJJLRkuUTy8CZDYeCIBn1ZycqsL1dAXugxZA==", + "version": "39.8.7", + "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.7.tgz", + "integrity": "sha512-B3TmzbUEeIvrhJ0QcoFp8/tgnVA3vsm0wkdYWzC22hsk9zTVqkzyrrz40cjd0nMTTIrGWxxfDO2tdQTCMe9Bjw==", "dev": true, "hasInstallScript": true, "license": "MIT", diff --git a/package.json b/package.json index 3d187ad190072..c3a544010f214 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.116.0", - "distro": "581d88ed0574e027f4498cc473242aa91587d3fc", + "distro": "8c8cff4b820485d6eae5cdf5bf0e1f30f7dbb995", "author": { "name": "Microsoft Corporation" }, @@ -190,7 +190,7 @@ "cookie": "^0.7.2", "debounce": "^1.0.0", "deemon": "^1.13.6", - "electron": "39.8.5", + "electron": "39.8.7", "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", @@ -266,4 +266,4 @@ "optionalDependencies": { "windows-foreground-love": "0.6.1" } -} \ No newline at end of file +} diff --git a/src/vs/workbench/api/node/proxyResolver.ts b/src/vs/workbench/api/node/proxyResolver.ts index 62dcfc6b44137..9a96891bf86a7 100644 --- a/src/vs/workbench/api/node/proxyResolver.ts +++ b/src/vs/workbench/api/node/proxyResolver.ts @@ -102,13 +102,11 @@ export function connectProxyResolver( } } const result = (await Promise.all(promises)).flat(); - const nodeSystemCertErrors = collectNodeSystemCertErrors(useNodeSystemCerts, extHostLogService); mainThreadTelemetry.$publicLog2('additionalCertificates', { count: result.length, isRemote, loadLocalCertificates, useNodeSystemCerts, - nodeSystemCertErrors, }); return result; }, @@ -280,7 +278,6 @@ type AdditionalCertificatesClassification = { isRemote: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether this is a remote extension host' }; loadLocalCertificates: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether local certificates are loaded' }; useNodeSystemCerts: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether Node.js system certificates are used' }; - nodeSystemCertErrors: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Summary of certificate loading errors from tls.getSystemCACertificatesErrors() or a sentinel string when unavailable/disabled' }; }; type AdditionalCertificatesEvent = { @@ -288,84 +285,8 @@ type AdditionalCertificatesEvent = { isRemote: boolean; loadLocalCertificates: boolean; useNodeSystemCerts: boolean; - nodeSystemCertErrors: string; }; -function collectNodeSystemCertErrors(useNodeSystemCerts: boolean, logService: ILogService): string { - if (!useNodeSystemCerts) { - const result = 'Not using Node.js system certificates'; - logService.debug(`ProxyResolver#collectNodeSystemCertErrors: ${result}`); - return result; - } - // eslint-disable-next-line local/code-no-any-casts - if (typeof (tls as any).getSystemCACertificatesErrors !== 'function') { - const result = 'tls.getSystemCACertificatesErrors is not available'; - logService.debug(`ProxyResolver#collectNodeSystemCertErrors: ${result}`); - return result; - } - try { - // eslint-disable-next-line local/code-no-any-casts - const errors = (tls as any).getSystemCACertificatesErrors(); - if (!errors || typeof errors !== 'object') { - const result = 'tls.getSystemCACertificatesErrors() did not return an object'; - logService.debug(`ProxyResolver#collectNodeSystemCertErrors: ${result}`); - return result; - } - const counts = new Map(); - for (const [category, entries] of Object.entries(errors)) { - if (Array.isArray(entries)) { - for (const entry of entries as { errorMessage?: string; errorCode?: number }[]) { - const code = entry.errorCode ?? 'missing code'; - const error = `${category}: ${sanitizeCertErrorMessage(entry.errorMessage ?? 'missing message')}`; - const key = `${error} (${code})`; - const existing = counts.get(key); - if (existing) { - existing.count++; - } else { - counts.set(key, { error, code, count: 1 }); - } - } - } - } - const result = JSON.stringify([...counts.values()].sort((a, b) => b.count - a.count)); - logService.trace(`ProxyResolver#collectNodeSystemCertErrors: ${result}`); - return result; - } catch (err) { - logService.debug('ProxyResolver#collectNodeSystemCertErrors: Failed to get certificate errors', err); - return `Error: ${err instanceof Error ? err.message : String(err)}`; - } -} - -// Sanitize known error messages to avoid false-positive redaction by the -// telemetry scrubbing regex in telemetryUtils.ts (the Generic Secret pattern -// matches "key", "sig", "signature" followed by a non-alphanumeric character). -// Source strings from Node.js RecordCertError() and OpenSSL's x509_err.c / asn1_err.c. -const certErrorReplacements: [string, string][] = [ - // Node.js RecordCertError: - ['key usage flags', 'k usage flags'], - // x509_err.c: - ['check dh key', 'check dh k'], - ['key type mismatch', 'k type mismatch'], - ['key values mismatch', 'k values mismatch'], - ['public key decode error', 'public k decode error'], - ['public key encode error', 'public k encode error'], - ['unable to get certs public key', 'unable to get certs public k'], - ['unknown key type', 'unknown k type'], - // asn1_err.c: - ['key type not supported', 'k type not supported'], - ['public key type', 'public k type'], - ['sig parse error', 's parse error'], - ['sig invalid mime type', 's invalid mime type'], - ['sig content type', 's content type'], - ['signature algorithm', 's algorithm'], -]; -function sanitizeCertErrorMessage(message: string): string { - for (const [search, replacement] of certErrorReplacements) { - message = message.replaceAll(search, replacement); - } - return message; -} - type ProxyResolveStatsClassification = { owner: 'chrmarti'; comment: 'Performance statistics for proxy resolution'; From a6e49120535ba3e92fb6f538f5bf0fb6516861ba Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 10 Apr 2026 11:33:25 +0200 Subject: [PATCH 14/15] guard installing built in extensions in cli (#308967) guard instlaling built in extensions in cli --- .../common/extensionManagementCLI.ts | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/vs/platform/extensionManagement/common/extensionManagementCLI.ts b/src/vs/platform/extensionManagement/common/extensionManagementCLI.ts index 3bc65f49b03f7..f1c3f891d02b5 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagementCLI.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagementCLI.ts @@ -182,6 +182,11 @@ export class ExtensionManagementCLI { const { id, version, installOptions } = installExtensionInfo; const installedExtension = installed.find(i => areSameExtensions(i.identifier, { id })); if (installedExtension) { + const builtinAutoUpdateMessage = this.validateBuiltinExtensionEnabledWithAutoUpdates(installedExtension); + if (builtinAutoUpdateMessage) { + this.logger.info(builtinAutoUpdateMessage); + return false; + } if (!force && (!version || (version === 'prerelease' && installedExtension.preRelease))) { this.logger.info(localize('alreadyInstalled-checkAndUpdate', "Extension '{0}' v{1} is already installed. Use '--force' option to update to latest version or provide '@' to install a specific version, for example: '{2}@1.2.3'.", id, installedExtension.manifest.version, id)); return false; @@ -301,13 +306,22 @@ export class ExtensionManagementCLI { } private async validateVSIX(manifest: IExtensionManifest, force: boolean, profileLocation: URI | undefined, installedExtensions: ILocalExtension[]): Promise { - if (!force) { - const extensionIdentifier = { id: getGalleryExtensionId(manifest.publisher, manifest.name) }; - const newer = installedExtensions.find(local => areSameExtensions(extensionIdentifier, local.identifier) && gt(local.manifest.version, manifest.version)); - if (newer) { - this.logger.info(localize('forceDowngrade', "A newer version of extension '{0}' v{1} is already installed. Use '--force' option to downgrade to older version.", newer.identifier.id, newer.manifest.version, manifest.version)); + const extensionIdentifier = { id: getGalleryExtensionId(manifest.publisher, manifest.name) }; + const existingExtension = installedExtensions.find(local => areSameExtensions(extensionIdentifier, local.identifier)); + + if (existingExtension) { + const builtinAutoUpdateMessage = this.validateBuiltinExtensionEnabledWithAutoUpdates(existingExtension); + if (builtinAutoUpdateMessage) { + this.logger.info(builtinAutoUpdateMessage); return false; } + + if (!force) { + if (gt(existingExtension.manifest.version, manifest.version)) { + this.logger.info(localize('forceDowngrade', "A newer version of extension '{0}' v{1} is already installed. Use '--force' option to downgrade to older version.", existingExtension.identifier.id, existingExtension.manifest.version, manifest.version)); + return false; + } + } } return this.validateExtensionKind(manifest); @@ -371,4 +385,11 @@ export class ExtensionManagementCLI { return this.location ? localize('notInstalleddOnLocation', "Extension '{0}' is not installed on {1}.", id, this.location) : localize('notInstalled', "Extension '{0}' is not installed.", id); } + private validateBuiltinExtensionEnabledWithAutoUpdates(extension: ILocalExtension): string | undefined { + if (extension.isBuiltin && this.productService.builtInExtensionsEnabledWithAutoUpdates.some(e => e.toLowerCase() === extension.identifier.id.toLowerCase()) && !extension.forceAutoUpdate) { + return localize('builtinAutoUpdate', "Extension '{0}' is a built-in extension and not allowed to be updated in the current product quality '{1}'.", extension.identifier.id, this.productService.quality); + } + return undefined; + } + } From 8b5518b08847f2694c33fdb9b0b94b600c13c79b Mon Sep 17 00:00:00 2001 From: Alex Ross <38270282+alexr00@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:05:03 +0200 Subject: [PATCH 15/15] Show combination tool approval args (#308747) --- .../instructions/api-version.instructions.md | 4 +- extensions/copilot/package.json | 2 +- .../copilot/script/build/vscodeDtsCheck.js | 45 ++++++----- .../copilot/script/build/vscodeDtsUpdate.js | 66 +++++++--------- .../extension/tools/node/vscodeCmdTool.tsx | 15 +++- ...osed.toolInvocationApproveCombination.d.ts | 17 +++- .../api/common/extHostLanguageModelTools.ts | 7 +- .../languageModelToolsConfirmationService.ts | 77 ++++++++++++++----- .../chatToolConfirmationSubPart.ts | 1 + .../languageModelToolsConfirmationService.ts | 2 + .../common/tools/languageModelToolsService.ts | 2 + ...guageModelToolsConfirmationService.test.ts | 39 +++++++++- ...osed.toolInvocationApproveCombination.d.ts | 17 +++- 13 files changed, 197 insertions(+), 97 deletions(-) diff --git a/.github/instructions/api-version.instructions.md b/.github/instructions/api-version.instructions.md index cd91d08124263..a109edad41523 100644 --- a/.github/instructions/api-version.instructions.md +++ b/.github/instructions/api-version.instructions.md @@ -3,7 +3,7 @@ description: Read this when changing proposed API in vscode.proposed.*.d.ts file applyTo: 'src/vscode-dts/**/vscode.proposed.*.d.ts' --- -The following is only required for proposed API related to chat and languageModel proposals. It's optional for other proposed API, but recommended. +The following is only useful for proposed API related to chat and languageModel proposals. It's optional for other proposed API, and not recommended. When a proposed API is changed in a non-backwards-compatible way, the version number at the top of the file must be incremented. If it doesn't have a version number, we must add one. The format of the number like this: @@ -16,3 +16,5 @@ No semver, just a basic incrementing integer. See existing examples in `vscode.p An example of a non-backwards-compatible change is removing a non-optional property or changing the type to one that is incompatible with the previous type. An example of a backwards-compatible change is an additive change or deleting a property that was already optional. + +Whenever possible, make a backwards-compatible change! diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index d931c5aba5596..348df3867c039 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -6399,7 +6399,7 @@ "node-gyp": "npm:node-gyp@10.3.1", "zod": "3.25.76" }, - "vscodeCommit": "afba0a4a1fc1e34dae9073d6787b6b541bda23eb", + "vscodeCommit": "94c8e2adc50e26ef70af85a0de3a9efed757acaa", "__metadata": { "id": "7ec7d6e6-b89e-4cc5-a59b-d6c4d238246f", "publisherId": { diff --git a/extensions/copilot/script/build/vscodeDtsCheck.js b/extensions/copilot/script/build/vscodeDtsCheck.js index 97472ed508393..0baf318fc97c2 100644 --- a/extensions/copilot/script/build/vscodeDtsCheck.js +++ b/extensions/copilot/script/build/vscodeDtsCheck.js @@ -4,48 +4,47 @@ *--------------------------------------------------------------------------------------------*/ // Usage: node script/build/vscodeDtsCheck.js -// Reads vscodeCommit from package.json, re-downloads proposed d.ts files -// at that commit, checks if any differ from what's committed, then restores -// the originals. Exits with code 1 if files are out of date. +// Compares proposed d.ts files in src/extension/ against the repo's +// src/vscode-dts/ directory. Exits with code 1 if files are out of date. -const { execSync } = require('child_process'); const fs = require('fs'); const path = require('path'); +const vscodeDtsDir = path.resolve('..', '..', 'src', 'vscode-dts'); const targetDir = path.resolve('src', 'extension'); function main() { const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8')); - const sha = pkg.vscodeCommit; - if (!sha) { - console.error('No vscodeCommit found in package.json. Run "npm run vscode-dts:update" first.'); + const proposals = pkg.enabledApiProposals; + if (!proposals || proposals.length === 0) { + console.error('No enabledApiProposals found in package.json.'); process.exit(1); } - console.log(`Checking proposed d.ts files against vscodeCommit: ${sha}`); + console.log('Checking proposed d.ts files against src/vscode-dts/...'); - // Download proposed d.ts files using the commit SHA - execSync(`node node_modules/@vscode/dts/index.js dev ${sha}`, { stdio: 'inherit' }); - - // Compare downloaded files with committed ones - const downloaded = fs.readdirSync('.').filter(f => f.startsWith('vscode.') && f.endsWith('.ts')); const mismatched = []; - for (const f of downloaded) { - const committedPath = path.join(targetDir, f); - const newContent = fs.readFileSync(f, 'utf-8'); + for (const proposal of proposals) { + const fileName = `vscode.proposed.${proposal}.d.ts`; + const sourcePath = path.join(vscodeDtsDir, fileName); + const committedPath = path.join(targetDir, fileName); + + if (!fs.existsSync(sourcePath)) { + console.warn(`Warning: ${fileName} not found in src/vscode-dts/, skipping`); + continue; + } + + const sourceContent = fs.readFileSync(sourcePath, 'utf-8'); if (!fs.existsSync(committedPath)) { - mismatched.push(f + ' (missing)'); + mismatched.push(fileName + ' (missing)'); } else { - const oldContent = fs.readFileSync(committedPath, 'utf-8'); - if (oldContent !== newContent) { - mismatched.push(f); + const committedContent = fs.readFileSync(committedPath, 'utf-8'); + if (sourceContent !== committedContent) { + mismatched.push(fileName); } } - - // Clean up the downloaded file - fs.unlinkSync(f); } if (mismatched.length > 0) { diff --git a/extensions/copilot/script/build/vscodeDtsUpdate.js b/extensions/copilot/script/build/vscodeDtsUpdate.js index 7748412430e74..b69a3ed6c07c6 100644 --- a/extensions/copilot/script/build/vscodeDtsUpdate.js +++ b/extensions/copilot/script/build/vscodeDtsUpdate.js @@ -3,58 +3,44 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// Usage: node script/build/vscodeDtsUpdate.js [branch] -// Downloads proposed API d.ts files from the given branch (default: main) -// of microsoft/vscode and writes the resolved commit SHA to package.json. +// Usage: node script/build/vscodeDtsUpdate.js +// Copies proposed API d.ts files from the repo's src/vscode-dts/ directory +// into this extension's src/extension/ folder based on enabledApiProposals. const { execSync } = require('child_process'); const fs = require('fs'); const path = require('path'); -const https = require('https'); -const branch = process.argv[2] || 'main'; +const vscodeDtsDir = path.resolve('..', '..', 'src', 'vscode-dts'); +const targetDir = path.resolve('src', 'extension'); -function resolveCommitSha(branch) { - return new Promise((resolve, reject) => { - const options = { - hostname: 'api.github.com', - path: `/repos/microsoft/vscode/commits/${encodeURIComponent(branch)}`, - headers: { 'User-Agent': 'vscode-copilot-chat', 'Accept': 'application/vnd.github.sha' } - }; - https.get(options, res => { - if (res.statusCode !== 200) { - reject(new Error(`Failed to resolve commit for branch "${branch}": HTTP ${res.statusCode}`)); - return; - } - let data = ''; - res.on('data', chunk => data += chunk); - res.on('end', () => resolve(data.trim())); - }).on('error', reject); - }); -} - -async function main() { - const sha = await resolveCommitSha(branch); - console.log(`Resolved branch "${branch}" to commit ${sha}`); - - // Download proposed d.ts files using the commit SHA - execSync(`node node_modules/@vscode/dts/index.js dev ${sha}`, { stdio: 'inherit' }); +function main() { + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8')); + const proposals = pkg.enabledApiProposals; + if (!proposals || proposals.length === 0) { + console.error('No enabledApiProposals found in package.json.'); + process.exit(1); + } - // Move downloaded files to src/extension/ - const files = fs.readdirSync('.').filter(f => f.startsWith('vscode.') && f.endsWith('.ts')); - for (const f of files) { - fs.renameSync(f, path.join('src', 'extension', f)); + let copied = 0; + for (const proposal of proposals) { + const fileName = `vscode.proposed.${proposal}.d.ts`; + const sourcePath = path.join(vscodeDtsDir, fileName); + if (!fs.existsSync(sourcePath)) { + console.warn(`Warning: ${fileName} not found in src/vscode-dts/`); + continue; + } + fs.copyFileSync(sourcePath, path.join(targetDir, fileName)); + copied++; } + console.log(`Copied ${copied} proposed API type definitions from src/vscode-dts/.`); - // Write the commit SHA to package.json + // Write the current commit SHA to package.json for reference + const sha = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim(); const pkgPath = path.resolve('package.json'); - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); pkg.vscodeCommit = sha; fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, '\t') + '\n'); console.log(`Wrote vscodeCommit: ${sha} to package.json`); } -main().catch(err => { - console.error(err); - process.exit(1); -}); +main(); diff --git a/extensions/copilot/src/extension/tools/node/vscodeCmdTool.tsx b/extensions/copilot/src/extension/tools/node/vscodeCmdTool.tsx index bda85343278da..e4f95d6a94218 100644 --- a/extensions/copilot/src/extension/tools/node/vscodeCmdTool.tsx +++ b/extensions/copilot/src/extension/tools/node/vscodeCmdTool.tsx @@ -94,14 +94,25 @@ class VSCodeCmdTool implements vscode.LanguageModelTool // Populate the Quick Open box with command ID rather than command name to avoid issues where Copilot didn't use the precise name, // or when the Copilot response language (Spanish, French, etc.) might be different here than the UI one. const commandStr = commandUri(quickOpenCommand, ['>' + commandId]); - const markdownString = new MarkdownString(l10n.t(`Copilot will execute the [{0}]({1}) (\`{2}\`) command.`, options.input.name, commandStr, options.input.commandId)); + const hasArguments = !!options.input.args?.length; + const markdownString = new MarkdownString(); + markdownString.appendMarkdown(l10n.t(`Copilot will execute the [{0}]({1}) (\`{2}\`) command.`, options.input.name, commandStr, options.input.commandId)); + if (hasArguments) { + markdownString.appendMarkdown(`\n\n${l10n.t('Arguments')}:\n\n`); + markdownString.appendCodeblock(JSON.stringify(options.input.args, undefined, 2), 'json'); + } markdownString.isTrusted = { enabledCommands: [quickOpenCommand] }; return { invocationMessage, confirmationMessages: { title: l10n.t`Run Command \`${options.input.name}\` (\`${options.input.commandId}\`)?`, message: markdownString, - approveCombination: options.input.args ? l10n.t`Allow running command \`${options.input.commandId}\` with specific arguments` : l10n.t`Allow running command \`${options.input.commandId}\` without arguments`, + approveCombination: { + message: hasArguments + ? l10n.t`Allow running command \`${options.input.commandId}\` with specific arguments` + : l10n.t`Allow running command \`${options.input.commandId}\` without arguments`, + arguments: hasArguments ? JSON.stringify(options.input.args) : undefined, + }, }, }; } diff --git a/extensions/copilot/src/extension/vscode.proposed.toolInvocationApproveCombination.d.ts b/extensions/copilot/src/extension/vscode.proposed.toolInvocationApproveCombination.d.ts index 070cf51ef409d..b3f1a41f857a0 100644 --- a/extensions/copilot/src/extension/vscode.proposed.toolInvocationApproveCombination.d.ts +++ b/extensions/copilot/src/extension/vscode.proposed.toolInvocationApproveCombination.d.ts @@ -10,13 +10,22 @@ declare module 'vscode' { export interface LanguageModelToolConfirmationMessages { /** * When set, a button will be shown allowing the user to approve this particular - * combination of tool and arguments. The value is shown as the label for the - * approval option. + * combination of tool and arguments. * - * For example, a tool that reads files could set this to `"Allow reading 'foo.txt'"`, + * For example, a tool that reads files could set this to + * `{ message: "Allow reading 'foo.txt'", arguments: JSON.stringify({ file: 'foo.txt' }) }`, * so that the user can approve that specific file without approving all invocations * of the tool. */ - approveCombination?: string | MarkdownString; + approveCombination?: { + /** + * The label shown for the approval option. + */ + message: string | MarkdownString; + /** + * A string representation of the arguments that can be shown to the user. + */ + arguments?: string; + }; } } diff --git a/src/vs/workbench/api/common/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index 0ea35b655f6c1..f10129cfb766f 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -311,8 +311,9 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape checkProposedApiEnabled(item.extension, 'toolInvocationApproveCombination'); } - const approveCombinationLabel = result.confirmationMessages?.approveCombination - ? typeConvert.MarkdownString.fromStrict(result.confirmationMessages.approveCombination) + const approveCombination = result.confirmationMessages?.approveCombination; + const approveCombinationLabel = approveCombination + ? typeConvert.MarkdownString.fromStrict(approveCombination.message) : undefined; const approveCombinationKey = approveCombinationLabel ? await computeCombinationKey(toolId, context.parameters) @@ -322,7 +323,7 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape confirmationMessages: result.confirmationMessages ? { title: typeof result.confirmationMessages.title === 'string' ? result.confirmationMessages.title : typeConvert.MarkdownString.from(result.confirmationMessages.title), message: typeof result.confirmationMessages.message === 'string' ? result.confirmationMessages.message : typeConvert.MarkdownString.from(result.confirmationMessages.message), - approveCombination: approveCombinationLabel && approveCombinationKey ? { label: approveCombinationLabel, key: approveCombinationKey } : undefined, + approveCombination: approveCombinationLabel && approveCombinationKey ? { label: approveCombinationLabel, key: approveCombinationKey, arguments: approveCombination!.arguments } : undefined, } : undefined, invocationMessage: typeConvert.MarkdownString.fromStrict(result.invocationMessage), pastTenseMessage: typeConvert.MarkdownString.fromStrict(result.pastTenseMessage), diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts index ed9457893d3ef..6ad89c9a47f0f 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts @@ -7,11 +7,13 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { Lazy } from '../../../../../base/common/lazy.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; import { LRUCache } from '../../../../../base/common/map.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { localize } from '../../../../../nls.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { IQuickInputButtonWithToggle, IQuickInputService, IQuickTreeItem, QuickInputButtonLocation } from '../../../../../platform/quickinput/common/quickInput.js'; +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 { ConfirmedReason, ToolConfirmKind } from '../../common/chatService/chatService.js'; import { ILanguageModelToolConfirmationActions, ILanguageModelToolConfirmationContribution, ILanguageModelToolConfirmationContributionQuickTreeItem, ILanguageModelToolConfirmationRef, ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; import { IToolData, ToolDataSource } from '../../common/tools/languageModelToolsService.js'; @@ -28,6 +30,7 @@ const CONTINUE_WITHOUT_REVIEWING_RESULTS = localize('continueWithoutReviewingRes interface IAutoConfirmEntry { readonly confirmed: true; readonly label?: string; + readonly arguments?: string; } @@ -45,13 +48,13 @@ class GenericConfirmStore extends Disposable { this._profileStore = new Lazy(() => this._register(this._instantiationService.createInstance(ToolConfirmStore, StorageScope.PROFILE, this._storageKey))); } - public setAutoConfirmation(id: string, scope: 'workspace' | 'profile' | 'session' | 'never', label?: string): void { + public setAutoConfirmation(id: string, scope: 'workspace' | 'profile' | 'session' | 'never', label?: string, args?: string): void { // Clear from all scopes first this._workspaceStore.value.setAutoConfirm(id, undefined); this._profileStore.value.setAutoConfirm(id, undefined); this._memoryStore.delete(id); - const entry: IAutoConfirmEntry = { confirmed: true, label }; + const entry: IAutoConfirmEntry = { confirmed: true, label, arguments: args }; // Set in the appropriate scope if (scope === 'workspace') { this._workspaceStore.value.setAutoConfirm(id, entry); @@ -91,6 +94,12 @@ class GenericConfirmStore extends Disposable { ?? this._memoryStore.get(id)?.label; } + public getArguments(id: string): string | undefined { + return this._workspaceStore.value.getAutoConfirm(id)?.arguments + ?? this._profileStore.value.getAutoConfirm(id)?.arguments + ?? this._memoryStore.get(id)?.arguments; + } + public reset(): void { this._workspaceStore.value.reset(); this._profileStore.value.reset(); @@ -136,7 +145,7 @@ class ToolConfirmStore extends Disposable { ) { super(); - // Read stored data — supports both legacy string[] and new Record formats + // Read stored data — supports both legacy string[] and new Record formats const raw = storageService.get(this._storageKey, this._scope); if (raw) { try { @@ -147,9 +156,15 @@ class ToolConfirmStore extends Disposable { this._autoConfirmTools.set(key, { confirmed: true }); } } else if (typeof parsed === 'object' && parsed !== null) { - // New format: Record for (const [key, value] of Object.entries(parsed)) { - this._autoConfirmTools.set(key, { confirmed: true, label: typeof value === 'string' ? value : undefined }); + if (typeof value === 'object' && value !== null) { + // New format: { label?: string; arguments?: string } + const obj = value as { label?: string; arguments?: string }; + this._autoConfirmTools.set(key, { confirmed: true, label: obj.label, arguments: obj.arguments }); + } else { + // Legacy format: string | boolean + this._autoConfirmTools.set(key, { confirmed: true, label: typeof value === 'string' ? value : undefined }); + } } } } catch { @@ -159,9 +174,13 @@ class ToolConfirmStore extends Disposable { this._register(storageService.onWillSaveState(() => { if (this._didChange) { - const data: Record = {}; + const data: Record = {}; for (const [key, entry] of this._autoConfirmTools) { - data[key] = entry.label ?? true; + if (entry.arguments) { + data[key] = { label: entry.label, arguments: entry.arguments }; + } else { + data[key] = entry.label ?? true; + } } this.storageService.store(this._storageKey, JSON.stringify(data), this._scope, StorageTarget.MACHINE); this._didChange = false; @@ -211,6 +230,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements constructor( @IInstantiationService private readonly _instantiationService: IInstantiationService, @IQuickInputService private readonly _quickInputService: IQuickInputService, + @IDialogService private readonly _dialogService: IDialogService, ) { super(); @@ -309,7 +329,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements // Add combination-level actions when approveCombination is provided if (ref.combination) { - const { label: combinationLabel, key: combinationKey } = ref.combination; + const { label: combinationLabel, key: combinationKey, arguments: combinationArgs } = ref.combination; actions.push( { label: localize('allowCombinationSession', '{0} in this Session', combinationLabel), @@ -317,7 +337,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements divider: !!actions.length, scope: 'session', select: async () => { - this._combinationConfirmStore.setAutoConfirmation(combinationKey, 'session', combinationLabel); + this._combinationConfirmStore.setAutoConfirmation(combinationKey, 'session', combinationLabel, combinationArgs); return true; } }, @@ -326,7 +346,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements detail: localize('allowCombinationWorkspaceTooltip', 'Allow this particular combination of tool and arguments in this workspace without confirmation.'), scope: 'workspace', select: async () => { - this._combinationConfirmStore.setAutoConfirmation(combinationKey, 'workspace', combinationLabel); + this._combinationConfirmStore.setAutoConfirmation(combinationKey, 'workspace', combinationLabel, combinationArgs); return true; } }, @@ -335,7 +355,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements detail: localize('allowCombinationGloballyTooltip', 'Always allow this particular combination of tool and arguments without confirmation.'), scope: 'profile', select: async () => { - this._combinationConfirmStore.setAutoConfirmation(combinationKey, 'profile', combinationLabel); + this._combinationConfirmStore.setAutoConfirmation(combinationKey, 'profile', combinationLabel, combinationArgs); return true; } }, @@ -524,13 +544,14 @@ export class LanguageModelToolsConfirmationService extends Disposable implements return false; } - private _getCombinationApprovalsForTool(toolId: string, scope: 'workspace' | 'profile' | 'session'): { key: string; label: string }[] { + private _getCombinationApprovalsForTool(toolId: string, scope: 'workspace' | 'profile' | 'session'): { key: string; label: string; arguments?: string }[] { const prefix = toolId + ':combination:'; - const results: { key: string; label: string }[] = []; + const results: { key: string; label: string; arguments?: string }[] = []; for (const key of this._combinationConfirmStore.getAllConfirmed()) { if (key.startsWith(prefix) && this._combinationConfirmStore.getAutoConfirmationIn(key, scope)) { const label = this._combinationConfirmStore.getLabel(key) ?? key; - results.push({ key, label }); + const args = this._combinationConfirmStore.getArguments(key); + results.push({ key, label, arguments: args }); } } return results; @@ -543,8 +564,14 @@ export class LanguageModelToolsConfirmationService extends Disposable implements serverId?: string; scope?: 'workspace' | 'profile'; combinationKey?: string; + combinationArgs?: string; } + const viewArgsButton: IQuickInputButton = { + iconClass: ThemeIcon.asClassName(Codicon.info), + tooltip: localize('viewCombinationArguments', "View Arguments"), + }; + // Helper to track tools under servers const trackServerTool = (serverId: string, label: string, toolId: string, serversWithTools: Map }>) => { if (!serversWithTools.has(serverId)) { @@ -661,13 +688,15 @@ export class LanguageModelToolsConfirmationService extends Disposable implements // Add combination approval children const combinationApprovals = this._getCombinationApprovalsForTool(tool.id, currentScope); - for (const { key, label } of combinationApprovals) { + for (const { key, label, arguments: args } of combinationApprovals) { toolChildren.push({ type: 'combination', toolId: tool.id, combinationKey: key, + combinationArgs: args, label, checked: true, + buttons: args ? [viewArgsButton] : undefined, }); } @@ -802,13 +831,15 @@ export class LanguageModelToolsConfirmationService extends Disposable implements // Add combination approval children const combinationApprovals = this._getCombinationApprovalsForTool(tool.id, currentScope); - for (const { key, label } of combinationApprovals) { + for (const { key, label, arguments: args } of combinationApprovals) { toolChildren.push({ type: 'combination', toolId: tool.id, combinationKey: key, + combinationArgs: args, label, checked: true, + buttons: args ? [viewArgsButton] : undefined, }); } @@ -922,7 +953,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements } else if (item.type === 'manage') { (item as ILanguageModelToolConfirmationContributionQuickTreeItem).onDidChangeChecked?.(!!item.checked); } else if (item.type === 'combination' && item.combinationKey) { - this._combinationConfirmStore.setAutoConfirmation(item.combinationKey, newState); + this._combinationConfirmStore.setAutoConfirmation(item.combinationKey, newState, item.label, item.combinationArgs); quickTree.setItemTree(buildTreeItems()); } })); @@ -930,6 +961,16 @@ export class LanguageModelToolsConfirmationService extends Disposable implements disposables.add(quickTree.onDidTriggerItemButton(i => { if (i.item.type === 'manage') { (i.item as ILanguageModelToolConfirmationContributionQuickTreeItem).onDidTriggerItemButton?.(i.button); + } else if (i.item.type === 'combination' && i.button === viewArgsButton && i.item.combinationArgs) { + this._dialogService.prompt({ + message: localize('combinationArguments', "Arguments"), + buttons: [], + custom: { + markdownDetails: [{ + markdown: new MarkdownString().appendCodeblock('json', i.item.combinationArgs), + }], + }, + }); } })); 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 0d187e8168697..2ada03f701601 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 @@ -91,6 +91,7 @@ export class ToolConfirmationSubPart extends AbstractToolConfirmationSubPart { ? { label: typeof approveCombination.label === 'string' ? approveCombination.label : approveCombination.label.value, key: approveCombination.key, + arguments: approveCombination.arguments, } : undefined; diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts index fb2e048ded0a7..c294eafca4d2b 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts @@ -47,6 +47,8 @@ export interface ILanguageModelToolConfirmationRef { label: string; /** Precomputed SHA-256 key for the combination */ key: string; + /** String representation of the arguments for this combination */ + arguments?: string; }; } diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 318347b6be2e2..a44cb3a8e00cc 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -336,6 +336,8 @@ export interface IToolConfirmationMessages { label: string | IMarkdownString; /** Precomputed SHA-256 key for the combination (set during tool preparation) */ key: string; + /** String representation of the arguments for this combination */ + arguments?: string; }; } 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 94e98ecbadc60..5fbdef75ca71b 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 @@ -44,12 +44,13 @@ suite('LanguageModelToolsConfirmationService', () => { }; } - async function createCombinationRef(toolId: string, parameters: unknown, combinationLabel: string): Promise { + async function createCombinationRef(toolId: string, parameters: unknown, combinationLabel: string, combinationArgs?: string): Promise { return { ...createToolRef(toolId, ToolDataSource.Internal, parameters), combination: { label: combinationLabel, key: await computeCombinationKey(toolId, parameters), + arguments: combinationArgs, }, }; } @@ -665,4 +666,40 @@ suite('LanguageModelToolsConfirmationService', () => { const ref2 = createToolRef('tool2'); assert.deepStrictEqual(newService.getPreConfirmAction(ref2), { type: ToolConfirmKind.LmServicePerTool, scope: 'workspace' }); }); + + test('object storage format with arguments round-trips across restart', () => { + // Pre-seed storage with the new object format containing arguments + const storageService = instantiationService.get(IStorageService); + const data: Record = { + 'tool1:combination:12345': { label: 'Allow reading foo.txt', arguments: '["foo.txt"]' }, + 'tool2:combination:67890': { label: 'Allow command with args' }, + }; + storageService.store('chat/autoconfirm-combination', JSON.stringify(data), StorageScope.WORKSPACE, StorageTarget.MACHINE); + + const newService = store.add(instantiationService.createInstance(LanguageModelToolsConfirmationService)); + + // Both combination keys should be auto-confirmed + const ref1: ILanguageModelToolConfirmationRef = { + ...createToolRef('tool1'), + combination: { label: 'Allow reading foo.txt', key: 'tool1:combination:12345', arguments: '["foo.txt"]' }, + }; + const ref2: ILanguageModelToolConfirmationRef = { + ...createToolRef('tool2'), + combination: { label: 'Allow command with args', key: 'tool2:combination:67890' }, + }; + + assert.deepStrictEqual(newService.getPreConfirmAction(ref1), { type: ToolConfirmKind.LmServicePerTool, scope: 'workspace' }); + assert.deepStrictEqual(newService.getPreConfirmAction(ref2), { type: ToolConfirmKind.LmServicePerTool, scope: 'workspace' }); + }); + + test('combination approval with arguments persists via workspace scope', async () => { + const ref = await createCombinationRef('testTool', { file: 'foo.txt' }, 'Allow reading "foo.txt"', '{"file":"foo.txt"}'); + + const actions = service.getPreConfirmActions(ref); + const combinationAction = actions.find(a => a.label.includes('Allow reading "foo.txt"') && a.scope === 'workspace'); + assert.ok(combinationAction); + await combinationAction.select(); + + assert.deepStrictEqual(service.getPreConfirmAction(ref), { type: ToolConfirmKind.LmServicePerTool, scope: 'workspace' }); + }); }); diff --git a/src/vscode-dts/vscode.proposed.toolInvocationApproveCombination.d.ts b/src/vscode-dts/vscode.proposed.toolInvocationApproveCombination.d.ts index 070cf51ef409d..b3f1a41f857a0 100644 --- a/src/vscode-dts/vscode.proposed.toolInvocationApproveCombination.d.ts +++ b/src/vscode-dts/vscode.proposed.toolInvocationApproveCombination.d.ts @@ -10,13 +10,22 @@ declare module 'vscode' { export interface LanguageModelToolConfirmationMessages { /** * When set, a button will be shown allowing the user to approve this particular - * combination of tool and arguments. The value is shown as the label for the - * approval option. + * combination of tool and arguments. * - * For example, a tool that reads files could set this to `"Allow reading 'foo.txt'"`, + * For example, a tool that reads files could set this to + * `{ message: "Allow reading 'foo.txt'", arguments: JSON.stringify({ file: 'foo.txt' }) }`, * so that the user can approve that specific file without approving all invocations * of the tool. */ - approveCombination?: string | MarkdownString; + approveCombination?: { + /** + * The label shown for the approval option. + */ + message: string | MarkdownString; + /** + * A string representation of the arguments that can be shown to the user. + */ + arguments?: string; + }; } }