diff --git a/extensions/github-authentication/src/common/env.ts b/extensions/github-authentication/src/common/env.ts index 5456fb864ee4c..56cad4beb339a 100644 --- a/extensions/github-authentication/src/common/env.ts +++ b/extensions/github-authentication/src/common/env.ts @@ -9,9 +9,9 @@ const VALID_DESKTOP_CALLBACK_SCHEMES = [ 'vscode', 'vscode-insiders', 'vscode-exploration', - 'vscode-sessions', - 'vscode-sessions-insiders', - 'vscode-sessions-exploration', + 'vscode-agents', + 'vscode-agents-insiders', + 'vscode-agents-exploration', // On Windows, some browsers don't seem to redirect back to OSS properly. // As a result, you get stuck in the auth flow. We exclude this from the // list until we can figure out a way to fix this behavior in browsers. diff --git a/extensions/microsoft-authentication/src/common/env.ts b/extensions/microsoft-authentication/src/common/env.ts index b63c94195a9e9..1c8505503ada2 100644 --- a/extensions/microsoft-authentication/src/common/env.ts +++ b/extensions/microsoft-authentication/src/common/env.ts @@ -10,9 +10,9 @@ const VALID_DESKTOP_CALLBACK_SCHEMES = [ 'vscode', 'vscode-insiders', 'vscode-exploration', - 'vscode-sessions', - 'vscode-sessions-insiders', - 'vscode-sessions-exploration', + 'vscode-agents', + 'vscode-agents-insiders', + 'vscode-agents-exploration', // On Windows, some browsers don't seem to redirect back to OSS properly. // As a result, you get stuck in the auth flow. We exclude this from the // list until we can figure out a way to fix this behavior in browsers. diff --git a/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts index ee6881ce49a5b..443bd8c2a1e86 100644 --- a/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts +++ b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts @@ -13,6 +13,7 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; import { localize } from '../../../nls.js'; import { ILogService } from '../../log/common/log.js'; +import { IProductService } from '../../product/common/productService.js'; import { ISSHRemoteAgentHostMainService, SSHAuthMethod, @@ -50,8 +51,13 @@ interface SSHClient { const LOG_PREFIX = '[SSHRemoteAgentHost]'; /** Install location for the VS Code CLI on the remote machine. */ -const REMOTE_CLI_DIR = '~/.vscode-cli'; -const REMOTE_CLI_BIN = `${REMOTE_CLI_DIR}/code`; +function getRemoteCLIDir(quality: string): string { + return quality === 'stable' || !quality ? '~/.vscode-cli' : `~/.vscode-cli-${quality}`; +} +function getRemoteCLIBin(quality: string): string { + const binaryName = quality === 'stable' ? 'code' : 'code-insiders'; + return `${getRemoteCLIDir(quality)}/${binaryName}`; +} /** Escape a string for use as a single shell argument (single-quote wrapping). */ function shellEscape(s: string): string { @@ -135,10 +141,11 @@ function redactToken(text: string): string { function startRemoteAgentHost( client: SSHClient, logService: ILogService, + quality: string, commandOverride?: string, ): Promise<{ port: number; connectionToken: string | undefined; stream: SSHChannel }> { return new Promise((resolve, reject) => { - const baseCmd = commandOverride ?? `${REMOTE_CLI_BIN} agent-host --port 0 --accept-server-license-terms`; + const baseCmd = commandOverride ?? `${getRemoteCLIBin(quality)} agent-host --port 0 --accept-server-license-terms`; // Wrap in a login shell so the agent host process inherits the // user's PATH and environment from ~/.bash_profile / ~/.bashrc // (ssh2 exec runs a non-interactive non-login shell by default). @@ -338,6 +345,7 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem constructor( @ILogService private readonly _logService: ILogService, + @IProductService private readonly _productService: IProductService, ) { super(); } @@ -393,7 +401,7 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem // 4. Start agent-host and capture port/token reportProgress(localize('sshProgressStartingAgent', "Starting remote agent host...")); - const { port: remotePort, connectionToken, stream: agentStream } = await startRemoteAgentHost(sshClient, this._logService, config.remoteAgentHostCommand); + const { port: remotePort, connectionToken, stream: agentStream } = await startRemoteAgentHost(sshClient, this._logService, this._quality, config.remoteAgentHostCommand); // 5. Connect to remote agent host via WebSocket relay (no local TCP port) reportProgress(localize('sshProgressForwarding', "Connecting to remote agent host...")); @@ -627,21 +635,26 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem }); } + private get _quality(): string { + return this._productService.quality || 'insider'; + } + private async _ensureCLIInstalled(client: SSHClient, platform: { os: string; arch: string }, reportProgress: (message: string) => void): Promise { - const { code } = await sshExec(client, `${REMOTE_CLI_BIN} --version`, { ignoreExitCode: true }); + const cliDir = getRemoteCLIDir(this._quality); + const cliBin = getRemoteCLIBin(this._quality); + const { code } = await sshExec(client, `${cliBin} --version`, { ignoreExitCode: true }); if (code === 0) { this._logService.info(`${LOG_PREFIX} VS Code CLI already installed on remote`); return; } reportProgress(localize('sshProgressDownloadingCLI', "Installing VS Code CLI on remote...")); - const quality = 'stable'; - const url = buildCLIDownloadUrl(platform.os, platform.arch, quality); + const url = buildCLIDownloadUrl(platform.os, platform.arch, this._quality); const installCmd = [ - `mkdir -p ${REMOTE_CLI_DIR}`, - `curl -fsSL '${url}' | tar xz -C ${REMOTE_CLI_DIR}`, - `chmod +x ${REMOTE_CLI_BIN}`, + `mkdir -p ${cliDir}`, + `curl -fsSL '${url}' | tar xz -C ${cliDir}`, + `chmod +x ${cliBin}`, ].join(' && '); await sshExec(client, installCmd); diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 61ecb4ebd42b3..11dd2b9c92b8b 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -115,6 +115,7 @@ const hasPullRequestContextKey = new RawContextKey('sessions.hasPullReq const hasOpenPullRequestContextKey = new RawContextKey('sessions.hasOpenPullRequest', false); const hasIncomingChangesContextKey = new RawContextKey('sessions.hasIncomingChanges', false); const hasOutgoingChangesContextKey = new RawContextKey('sessions.hasOutgoingChanges', false); +const hasUncommittedChangesContextKey = new RawContextKey('sessions.hasUncommittedChanges', true); // --- List Item @@ -129,8 +130,6 @@ interface IChangesFileItem { readonly changeType: ChangeType; readonly linesAdded: number; readonly linesRemoved: number; - readonly reviewCommentCount: number; - readonly agentFeedbackCount: number; } interface IChangesRootItem { @@ -243,9 +242,7 @@ function toChangesFileItem(changes: GitDiffChange[], modifiedRef: string | undef isDeletion, changeType: isDeletion ? 'deleted' : isAddition ? 'added' : 'modified', linesAdded: change.insertions, - linesRemoved: change.deletions, - reviewCommentCount: 0, - agentFeedbackCount: 0, + linesRemoved: change.deletions } satisfies IChangesFileItem; }); } @@ -752,8 +749,6 @@ export class ChangesViewPane extends ViewPane { // Convert session file changes to list items (cloud/background sessions) const sessionFilesObs = derived(reader => { - const reviewCommentCountByFile = this.viewModel.activeSessionReviewCommentCountByFileObs.read(reader); - const agentFeedbackCountByFile = this.viewModel.activeSessionAgentFeedbackCountByFileObs.read(reader); const changes = [...this.viewModel.activeSessionChangesObs.read(reader)]; return changes.map((entry): IChangesFileItem => { @@ -770,9 +765,7 @@ export class ChangesViewPane extends ViewPane { isDeletion, changeType: isDeletion ? 'deleted' : isAddition ? 'added' : 'modified', linesAdded: entry.insertions, - linesRemoved: entry.deletions, - reviewCommentCount: reviewCommentCountByFile.get(uri.fsPath) ?? 0, - agentFeedbackCount: agentFeedbackCountByFile.get(uri.fsPath) ?? 0, + linesRemoved: entry.deletions }; }); }); @@ -912,6 +905,10 @@ export class ChangesViewPane extends ViewPane { const outgoingChangesObs = derived(reader => { const repository = this.viewModel.activeSessionRepositoryObs.read(reader); const repositoryState = repository?.state.read(reader); + if (!repositoryState) { + return 0; + } + return repositoryState?.HEAD?.ahead ?? 0; }); @@ -920,6 +917,19 @@ export class ChangesViewPane extends ViewPane { return outgoingChanges > 0; })); + this.renderDisposables.add(bindContextKey(hasUncommittedChangesContextKey, this.scopedContextKeyService, reader => { + const repository = this.viewModel.activeSessionRepositoryObs.read(reader); + const repositoryState = repository?.state.read(reader); + if (!repositoryState) { + return true; + } + + return (repositoryState?.mergeChanges.length ?? 0) > 0 || + (repositoryState?.indexChanges.length ?? 0) > 0 || + (repositoryState?.workingTreeChanges.length ?? 0) > 0 || + (repositoryState?.untrackedChanges.length ?? 0) > 0; + })); + const scopedServiceCollection = new ServiceCollection([IContextKeyService, this.scopedContextKeyService]); const scopedInstantiationService = this.instantiationService.createChild(scopedServiceCollection); this.renderDisposables.add(scopedInstantiationService); @@ -965,7 +975,10 @@ export class ChangesViewPane extends ViewPane { ? { args: [sessionResource, this.agentSessionsService.getSession(sessionResource)?.metadata] } : { shouldForwardArgs: true }, buttonConfigProvider: (action) => { - if (action.id === 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.updatePR') { + if ( + action.id === 'github.copilot.sessions.sync' || + action.id === 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.updatePR' + ) { const customLabel = outgoingChanges > 0 ? `${action.label} ${outgoingChanges}↑` : action.label; @@ -995,7 +1008,7 @@ export class ChangesViewPane extends ViewPane { action.id === 'github.copilot.chat.checkoutPullRequestReroute' || action.id === 'pr.checkoutFromChat' || action.id === 'github.copilot.sessions.initializeRepository' || - action.id === 'github.copilot.sessions.commitChanges' || + action.id === 'github.copilot.sessions.commit' || action.id === 'agentSession.markAsDone' ) { return { showIcon: true, showLabel: true, isSecondary: false }; @@ -1496,7 +1509,6 @@ class ChangesTreeDelegate implements IListVirtualDelegate { interface IChangesTreeTemplate { readonly label: IResourceLabel; - readonly templateDisposables: DisposableStore; readonly toolbar: MenuWorkbenchToolBar | undefined; readonly contextKeyService: IContextKeyService | undefined; readonly reviewCommentsBadge: HTMLElement; @@ -1505,6 +1517,8 @@ interface IChangesTreeTemplate { readonly addedSpan: HTMLElement; readonly removedSpan: HTMLElement; readonly lineCountsContainer: HTMLElement; + readonly elementDisposables: DisposableStore; + readonly templateDisposables: DisposableStore; } class ChangesTreeRenderer implements ICompressibleTreeRenderer { @@ -1561,7 +1575,7 @@ class ChangesTreeRenderer implements ICompressibleTreeRenderer, _index: number, templateData: IChangesTreeTemplate): void { @@ -1630,29 +1644,41 @@ class ChangesTreeRenderer implements ICompressibleTreeRenderer 0) { - templateData.reviewCommentsBadge.style.display = ''; - templateData.reviewCommentsBadge.className = 'changes-review-comments-badge'; - templateData.reviewCommentsBadge.replaceChildren( - dom.$('.codicon.codicon-comment-unresolved'), - dom.$('span', undefined, `${data.reviewCommentCount}`) - ); - } else { - templateData.reviewCommentsBadge.style.display = 'none'; - templateData.reviewCommentsBadge.replaceChildren(); - } + // Review comments + templateData.elementDisposables.add(autorun(reader => { + const reviewCommentByFile = this.viewModel.activeSessionReviewCommentCountByFileObs.read(reader); + const reviewCommentCount = reviewCommentByFile?.get(data.uri.fsPath) ?? 0; + + if (reviewCommentCount > 0) { + templateData.reviewCommentsBadge.style.display = ''; + templateData.reviewCommentsBadge.className = 'changes-review-comments-badge'; + templateData.reviewCommentsBadge.replaceChildren( + dom.$('.codicon.codicon-comment-unresolved'), + dom.$('span', undefined, `${reviewCommentCount}`) + ); + } else { + templateData.reviewCommentsBadge.style.display = 'none'; + templateData.reviewCommentsBadge.replaceChildren(); + } + })); - if (data.agentFeedbackCount > 0) { - templateData.agentFeedbackBadge.style.display = ''; - templateData.agentFeedbackBadge.className = 'changes-agent-feedback-badge'; - templateData.agentFeedbackBadge.replaceChildren( - dom.$('.codicon.codicon-comment'), - dom.$('span', undefined, `${data.agentFeedbackCount}`) - ); - } else { - templateData.agentFeedbackBadge.style.display = 'none'; - templateData.agentFeedbackBadge.replaceChildren(); - } + // Agent feedback + templateData.elementDisposables.add(autorun(reader => { + const agentFeedbackByFile = this.viewModel.activeSessionAgentFeedbackCountByFileObs.read(reader); + const agentFeedbackCount = agentFeedbackByFile?.get(data.uri.fsPath) ?? 0; + + if (agentFeedbackCount > 0) { + templateData.agentFeedbackBadge.style.display = ''; + templateData.agentFeedbackBadge.className = 'changes-agent-feedback-badge'; + templateData.agentFeedbackBadge.replaceChildren( + dom.$('.codicon.codicon-comment'), + dom.$('span', undefined, `${agentFeedbackCount}`) + ); + } else { + templateData.agentFeedbackBadge.style.display = 'none'; + templateData.agentFeedbackBadge.replaceChildren(); + } + })); const badge = templateData.decorationBadge; badge.className = 'changes-decoration-badge'; @@ -1735,7 +1761,16 @@ class ChangesTreeRenderer implements ICompressibleTreeRenderer, _index: number, templateData: IChangesTreeTemplate): void { + templateData.elementDisposables.clear(); + } + + disposeCompressedElements(_element: ITreeNode, void>, _index: number, templateData: IChangesTreeTemplate): void { + templateData.elementDisposables.clear(); + } + disposeTemplate(templateData: IChangesTreeTemplate): void { + templateData.elementDisposables.dispose(); templateData.templateDisposables.dispose(); } } diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index c37cdf36a5b22..c37ed9ac42ed5 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -684,8 +684,16 @@ registerAction2(class MarkSessionAsDoneAction extends Action2 { order: 1, when: ContextKeyExpr.and( IsSessionsWindowContext, - ContextKeyExpr.equals('sessions.hasPullRequest', true), - ContextKeyExpr.equals('sessions.hasOpenPullRequest', false), + ContextKeyExpr.or( + ContextKeyExpr.and( + ContextKeyExpr.equals('sessions.hasPullRequest', false), + ContextKeyExpr.equals('sessions.hasOutgoingChanges', false), + ), + ContextKeyExpr.and( + ContextKeyExpr.equals('sessions.hasPullRequest', true), + ContextKeyExpr.equals('sessions.hasOpenPullRequest', false), + ) + ) ) }] }); diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index dd38a35d25bf7..2317c79d8a61d 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -644,13 +644,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat } private _applyOptionGroups(handle: number, chatSessionType: string, optionGroups: readonly IChatSessionProviderOptionGroup[]): void { - const groupsWithCallbacks = optionGroups.map(group => ({ - ...group, - onSearch: group.searchable ? async (query: string, token: CancellationToken) => { - return await this._proxy.$invokeOptionGroupSearch(handle, group.id, query, token); - } : undefined, - })); - this._chatSessionsService.setOptionGroupsForSessionType(chatSessionType, handle, groupsWithCallbacks); + this._chatSessionsService.setOptionGroupsForSessionType(chatSessionType, handle, optionGroups); } private getController(handle: number): MainThreadChatSessionItemController { @@ -929,13 +923,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat private _refreshProviderOptions(handle: number, chatSessionScheme: string): void { this._proxy.$provideChatSessionProviderOptions(handle, CancellationToken.None).then(options => { if (options?.optionGroups && options.optionGroups.length) { - const groupsWithCallbacks = options.optionGroups.map(group => ({ - ...group, - onSearch: group.searchable ? async (query: string, token: CancellationToken) => { - return await this._proxy.$invokeOptionGroupSearch(handle, group.id, query, token); - } : undefined, - })); - this._chatSessionsService.setOptionGroupsForSessionType(chatSessionScheme, handle, groupsWithCallbacks); + this._chatSessionsService.setOptionGroupsForSessionType(chatSessionScheme, handle, [...options.optionGroups]); } }).catch(err => this._logService.error('Error fetching chat session options', err)); } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 4ced8585290a4..187a771114d4d 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3676,7 +3676,6 @@ export interface ExtHostChatSessionsShape { $disposeChatSessionContent(providerHandle: number, sessionResource: UriComponents): Promise; $invokeChatSessionRequestHandler(providerHandle: number, sessionResource: UriComponents, request: IChatAgentRequest, history: any[], token: CancellationToken): Promise; $provideChatSessionProviderOptions(providerHandle: number, token: CancellationToken): Promise; - $invokeOptionGroupSearch(providerHandle: number, optionGroupId: string, query: string, token: CancellationToken): Promise; $provideHandleOptionsChange(providerHandle: number, sessionResource: UriComponents, updates: Record, token: CancellationToken): Promise; $forkChatSession(providerHandle: number, sessionResource: UriComponents, request: IChatSessionRequestHistoryItemDto | undefined, token: CancellationToken): Promise>; $provideChatSessionInputState(controllerHandle: number, sessionResource: UriComponents | undefined, token: CancellationToken): Promise; diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index a572fbedda787..6afab0855b5ef 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -343,6 +343,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio readonly extension: IExtensionDescription; readonly disposable: DisposableStore; readonly onDidChangeChatSessionItemStateEmitter: Emitter; + readonly inputStates: Set; optionGroups?: readonly vscode.ChatSessionProviderOptionGroup[]; }>(); @@ -404,7 +405,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio createChatSessionItem: (_resource: vscode.Uri, _label: string) => { throw new Error('Not implemented for providers'); }, - createChatSessionInputState: (_options: vscode.ChatSessionProviderOptionItem[]) => { + createChatSessionInputState: (_options: vscode.ChatSessionProviderOptionGroup[]) => { return new ChatSessionInputStateImpl([]); }, onDidChangeChatSessionItemState: onDidChangeChatSessionItemStateEmitter.event, @@ -418,7 +419,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }, }; - this._chatSessionItemControllers.set(controllerHandle, { chatSessionType: chatSessionType, controller, extension, disposable: disposables, onDidChangeChatSessionItemStateEmitter }); + this._chatSessionItemControllers.set(controllerHandle, { chatSessionType: chatSessionType, controller, extension, disposable: disposables, onDidChangeChatSessionItemStateEmitter, inputStates: new Set() }); this._proxy.$registerChatSessionItemController(controllerHandle, chatSessionType); if (provider.onDidChangeChatSessionItems) { @@ -456,6 +457,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio let forkHandler: vscode.ChatSessionItemController['forkHandler']; let provideChatSessionInputStateHandler: vscode.ChatSessionItemController['getChatSessionInputState']; const onDidChangeChatSessionItemStateEmitter = disposables.add(new Emitter()); + const inputStates = new Set(); const collection = new ChatSessionItemCollectionImpl(controllerHandle, this._proxy); @@ -508,12 +510,12 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio items: g.items, selected: g.selected, when: g.when, - searchable: g.searchable, icon: g.icon, commands: g.commands, })); void this._proxy.$updateChatSessionInputState(controllerHandle, serializableGroups); }); + inputStates.add(inputState); return inputState; }, dispose: () => { @@ -522,7 +524,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }, }); - this._chatSessionItemControllers.set(controllerHandle, { controller, extension, disposable: disposables, chatSessionType: id, onDidChangeChatSessionItemStateEmitter }); + this._chatSessionItemControllers.set(controllerHandle, { controller, extension, disposable: disposables, chatSessionType: id, onDidChangeChatSessionItemStateEmitter, inputStates }); // Register the controller with the main thread this._proxy.$registerChatSessionItemController(controllerHandle, id); @@ -664,6 +666,13 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } catch (error) { this._logService.error(`Error calling provideHandleOptionsChange for handle ${handle}, sessionResource ${sessionResource}:`, error); } + + // Temporary workaround: input state changes for one resource are propagated to all + // input states for the same resource type until we can make this session-specific. + const controllerData = this.getChatSessionItemController(sessionResource.scheme); + for (const inputState of controllerData?.inputStates ?? []) { + inputState._fireDidChange(); + } } async $provideChatSessionProviderOptions(handle: number, token: CancellationToken): Promise { @@ -783,14 +792,6 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio return undefined; } - private _getControllerForContentProviderHandle(handle: number) { - const entry = this._chatSessionContentProviders.get(handle); - if (!entry) { - return undefined; - } - return this.getChatSessionItemController(entry.chatSessionScheme); - } - private _createInputStateFromOptions( groups: readonly vscode.ChatSessionProviderOptionGroup[], sessionOptions?: ReadonlyArray<{ optionId: string; value: string }>, @@ -908,29 +909,6 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }; } - async $invokeOptionGroupSearch(providerHandle: number, optionGroupId: string, query: string, token: CancellationToken): Promise { - const optionGroups = this._chatSessionItemControllers.get(providerHandle)?.optionGroups - ?? this._getControllerForContentProviderHandle(providerHandle)?.optionGroups; - if (!optionGroups) { - this._logService.warn(`No option groups found for provider handle ${providerHandle}`); - return []; - } - - const group = optionGroups.find((g: vscode.ChatSessionProviderOptionGroup) => g.id === optionGroupId); - if (!group || !group.onSearch) { - this._logService.warn(`No onSearch callback found for option group ${optionGroupId}`); - return []; - } - - try { - const results = await group.onSearch(query, token); - return results ?? []; - } catch (error) { - this._logService.error(`Error calling onSearch for option group ${optionGroupId}:`, error); - return []; - } - } - async $refreshChatSessionItems(handle: number, token: CancellationToken): Promise { const controllerData = this._chatSessionItemControllers.get(handle); if (!controllerData) { @@ -1022,7 +1000,6 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio items: g.items, selected: g.selected, when: g.when, - searchable: g.searchable, icon: g.icon, commands: g.commands, })); diff --git a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts index 078871fe36575..ff01cfe1bc24e 100644 --- a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts @@ -66,7 +66,6 @@ suite('ObservableChatSession', function () { $provideChatSessionContent: sinon.stub(), $provideChatSessionProviderOptions: sinon.stub<[providerHandle: number, token: CancellationToken], Promise>().resolves(undefined), $provideHandleOptionsChange: sinon.stub(), - $invokeOptionGroupSearch: sinon.stub().resolves([]), $interruptChatSessionActiveResponse: sinon.stub(), $invokeChatSessionRequestHandler: sinon.stub(), $disposeChatSessionContent: sinon.stub(), @@ -517,7 +516,6 @@ suite('MainThreadChatSessions', function () { $provideChatSessionContent: sinon.stub(), $provideChatSessionProviderOptions: sinon.stub<[providerHandle: number, token: CancellationToken], Promise>().resolves(undefined), $provideHandleOptionsChange: sinon.stub(), - $invokeOptionGroupSearch: sinon.stub().resolves([]), $interruptChatSessionActiveResponse: sinon.stub(), $invokeChatSessionRequestHandler: sinon.stub(), $disposeChatSessionContent: sinon.stub(), diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 06e553b252893..690322fdd1170 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -1359,6 +1359,15 @@ configurationRegistry.registerConfiguration({ mode: 'auto' } }, + [ChatConfiguration.GeneralPurposeAgentEnabled]: { + type: 'boolean', + description: nls.localize('chat.generalPurposeAgent.enabled', "Controls whether the built-in General Purpose agent is available as a subagent."), + default: false, + tags: ['experimental', 'advanced'], + experiment: { + mode: 'auto' + } + }, [ChatConfiguration.SubagentsAllowInvocationsFromSubagents]: { type: 'boolean', description: nls.localize('chat.subagents.allowInvocationsFromSubagents', "Allow subagents to invoke subagents."), diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.ts deleted file mode 100644 index 45145ec7ec30f..0000000000000 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.ts +++ /dev/null @@ -1,233 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import './media/chatSessionPickerActionItem.css'; -import { IAction } from '../../../../../base/common/actions.js'; -import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; -import { Delayer } from '../../../../../base/common/async.js'; -import * as dom from '../../../../../base/browser/dom.js'; -import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; -import { IActionWidgetDropdownAction } from '../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; -import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; -import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; -import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../common/chatSessionsService.js'; -import { DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; -import { renderLabelWithIcons, renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { localize } from '../../../../../nls.js'; -import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; -import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from './chatSessionPickerActionItem.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; -import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; -import { IChatInputPickerOptions } from '../widget/input/chatInputPickerActionItem.js'; - -interface ISearchableOptionQuickPickItem extends IQuickPickItem { - readonly optionItem: IChatSessionProviderOptionItem; -} - -function isSearchableOptionQuickPickItem(item: IQuickPickItem | undefined): item is ISearchableOptionQuickPickItem { - return !!item && typeof (item as ISearchableOptionQuickPickItem).optionItem === 'object'; -} - -/** - * Action view item for searchable option groups with QuickPick. - * Used when an option group has `searchable: true` (e.g., repository selection). - * Shows an inline dropdown with items + "See more..." option that opens a searchable QuickPick. - */ -export class SearchableOptionPickerActionItem extends ChatSessionPickerActionItem { - private static readonly SEE_MORE_ID = '__see_more__'; - - constructor( - action: IAction, - initialState: { group: IChatSessionProviderOptionGroup; item: IChatSessionProviderOptionItem | undefined }, - delegate: IChatSessionPickerDelegate, - pickerOptions: IChatInputPickerOptions | undefined, - @IActionWidgetService actionWidgetService: IActionWidgetService, - @IContextKeyService contextKeyService: IContextKeyService, - @IKeybindingService keybindingService: IKeybindingService, - @IQuickInputService private readonly quickInputService: IQuickInputService, - @ILogService private readonly logService: ILogService, - @ICommandService commandService: ICommandService, - @ITelemetryService telemetryService: ITelemetryService, - ) { - super(action, initialState, delegate, pickerOptions, actionWidgetService, contextKeyService, keybindingService, commandService, telemetryService); - } - - protected override getDropdownActions(): IActionWidgetDropdownAction[] { - // If locked, show the current option only - const currentOption = this.delegate.getCurrentOption(); - if (currentOption?.locked) { - return [this.createLockedOptionAction(currentOption)]; - } - - const optionGroup = this.delegate.getOptionGroup(); - if (!optionGroup) { - return []; - } - - // Build actions from items - const actions: IActionWidgetDropdownAction[] = optionGroup.items.map(optionItem => { - const isCurrent = optionItem.id === currentOption?.id; - return { - id: optionItem.id, - enabled: !optionItem.locked, - icon: optionItem.icon, - checked: isCurrent, - class: undefined, - description: optionItem.description, - tooltip: optionItem.description ?? optionItem.name, - label: optionItem.name, - run: () => { - this.delegate.setOption(optionItem); - } - }; - }); - - // Add "See more..." action if onSearch is available - if (optionGroup.onSearch) { - actions.push({ - id: SearchableOptionPickerActionItem.SEE_MORE_ID, - enabled: true, - checked: false, - class: 'searchable-picker-see-more', - description: undefined, - tooltip: localize('seeMore.tooltip', "Search for more options"), - label: localize('seeMore', "See more..."), - run: () => { - this.showSearchableQuickPick(optionGroup); - } - } satisfies IActionWidgetDropdownAction); - } - - return actions; - } - - protected override renderLabel(element: HTMLElement): IDisposable | null { - const domChildren = []; - const optionGroup = this.delegate.getOptionGroup(); - - element.classList.add('chat-session-option-picker'); - - if (optionGroup?.icon) { - domChildren.push(renderIcon(optionGroup.icon)); - } - - // Label - const label = this.currentOption?.name ?? optionGroup?.name ?? localize('selectOption', "Select..."); - domChildren.push(dom.$('span.chat-session-option-label', undefined, label)); - - domChildren.push(...renderLabelWithIcons(`$(chevron-down)`)); - - dom.reset(element, ...domChildren); - this.setAriaLabelAttributes(element); - return null; - } - - protected override getContainerClass(): string { - return 'chat-searchable-option-picker-item'; - } - - /** - * Shows the full searchable QuickPick with all items (initial + search results) - * Called when user clicks "See more..." from the dropdown - */ - private async showSearchableQuickPick(optionGroup: IChatSessionProviderOptionGroup): Promise { - if (optionGroup.onSearch) { - const disposables = new DisposableStore(); - const quickPick = this.quickInputService.createQuickPick(); - disposables.add(quickPick); - quickPick.placeholder = optionGroup.description ?? localize('selectOption.placeholder', "Select {0}", optionGroup.name); - quickPick.matchOnDescription = true; - quickPick.matchOnDetail = true; - quickPick.matchOnLabelMode = 'fuzzy'; - quickPick.ignoreFocusOut = true; - quickPick.busy = true; - quickPick.show(); - - // Debounced search state - let currentSearchCts: CancellationTokenSource | undefined; - const searchDelayer = disposables.add(new Delayer(300)); - - const performSearch = async (query: string) => { - // Cancel previous search - currentSearchCts?.cancel(); - currentSearchCts?.dispose(); - currentSearchCts = new CancellationTokenSource(); - const token = currentSearchCts.token; - - quickPick.busy = true; - try { - const items = await optionGroup.onSearch!(query, token); - if (!token.isCancellationRequested) { - quickPick.items = items.map(item => this.createQuickPickItem(item)); - } - } catch (error) { - if (!token.isCancellationRequested) { - this.logService.error('Error fetching searchable option items:', error); - } - } finally { - if (!token.isCancellationRequested) { - quickPick.busy = false; - } - } - }; - - // Initial search with empty query - await performSearch(''); - - // Listen for value changes and perform debounced search - disposables.add(quickPick.onDidChangeValue(value => { - searchDelayer.trigger(() => performSearch(value)); - })); - - - // Handle selection - return new Promise((resolve) => { - disposables.add(quickPick.onDidAccept(() => { - const pick = quickPick.selectedItems[0]; - if (isSearchableOptionQuickPickItem(pick)) { - const selectedItem = pick.optionItem; - if (!selectedItem.locked) { - this.delegate.setOption(selectedItem); - } - } - quickPick.hide(); - })); - - disposables.add(quickPick.onDidHide(() => { - currentSearchCts?.cancel(); - currentSearchCts?.dispose(); - disposables.dispose(); - resolve(); - })); - }); - } - } - - private createQuickPickItem( - item: IChatSessionProviderOptionItem, - ): ISearchableOptionQuickPickItem { - const iconClass = item.icon ? ThemeIcon.asClassName(item.icon) : undefined; - - return { - label: item.name, - description: item.description, - iconClass, - disabled: item.locked, - optionItem: item, - }; - } - - /** - * Opens the picker programmatically. - */ - override show(): void { - const optionGroup = this.delegate.getOptionGroup(); - if (optionGroup) { - this.showSearchableQuickPick(optionGroup); - } - } -} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 790ec52be56ee..3c9a9d37ce0e3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -109,7 +109,6 @@ import { IChatWidget, IChatWidgetViewModelChangeEvent, ISessionTypePickerDelegat import { ChatEditingShowChangesAction, ViewAllSessionChangesAction, ViewPreviousEditsAction } from '../../chatEditing/chatEditingActions.js'; import { resizeImage } from '../../chatImageUtils.js'; import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../chatSessions/chatSessionPickerActionItem.js'; -import { SearchableOptionPickerActionItem } from '../../chatSessions/searchableOptionPickerActionItem.js'; import { IChatContextService } from '../../contextContrib/chatContextService.js'; import { IDisposableReference } from '../chatContentParts/chatCollections.js'; import { ChatQuestionCarouselPart, IChatQuestionCarouselOptions } from '../chatContentParts/chatQuestionCarouselPart.js'; @@ -388,7 +387,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private permissionWidget: PermissionPickerActionItem | undefined; private sessionTargetWidget: SessionTypePickerActionItem | undefined; private delegationWidget: DelegationSessionPickerActionItem | undefined; - private readonly chatSessionPickerWidgets = this._register(new DisposableMap()); + private readonly chatSessionPickerWidgets = this._register(new DisposableMap()); private chatSessionPickerContainer: HTMLElement | undefined; private _lastSessionPickerAction: MenuItemAction | undefined; private _lastSessionPickerOptions: IChatInputPickerOptions | undefined; @@ -826,7 +825,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge /** * Create picker widgets for all option groups available for the current session type. */ - private createChatSessionPickerWidgets(action: MenuItemAction, pickerOptions?: IChatInputPickerOptions): (ChatSessionPickerActionItem | SearchableOptionPickerActionItem)[] { + private createChatSessionPickerWidgets(action: MenuItemAction, pickerOptions?: IChatInputPickerOptions): ChatSessionPickerActionItem[] { this._lastSessionPickerAction = action; this._lastSessionPickerOptions = pickerOptions; @@ -838,7 +837,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const { visibleGroupIds, optionGroups, effectiveSessionType } = result; this.chatSessionPickerWidgets.clearAndDisposeAll(); - const widgets: (ChatSessionPickerActionItem | SearchableOptionPickerActionItem)[] = []; + const widgets: ChatSessionPickerActionItem[] = []; for (const optionGroup of optionGroups) { if (!visibleGroupIds.has(optionGroup.id)) { continue; @@ -874,7 +873,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } }; - const widget = this.instantiationService.createInstance(optionGroup.searchable ? SearchableOptionPickerActionItem : ChatSessionPickerActionItem, action, initialState, itemDelegate, pickerOptions); + const widget = this.instantiationService.createInstance(ChatSessionPickerActionItem, action, initialState, itemDelegate, pickerOptions); this.chatSessionPickerWidgets.set(optionGroup.id, widget); widgets.push(widget); } @@ -3323,7 +3322,7 @@ function getLastPosition(model: ITextModel): IPosition { const chatInputEditorContainerSelector = '.interactive-input-editor'; setupSimpleEditorSelectionStyling(chatInputEditorContainerSelector); -type ChatSessionPickerWidget = ChatSessionPickerActionItem | SearchableOptionPickerActionItem; +type ChatSessionPickerWidget = ChatSessionPickerActionItem; class ChatSessionPickersContainerActionItem extends ActionViewItem { constructor( diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index c8d88c9d2f319..627fa3c29e515 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -53,8 +53,6 @@ export interface IChatSessionProviderOptionGroup { readonly description?: string; readonly selected?: IChatSessionProviderOptionItem; readonly items: readonly IChatSessionProviderOptionItem[]; - readonly searchable?: boolean; - readonly onSearch?: (query: string, token: CancellationToken) => Thenable; /** * A context key expression that controls visibility of this option group picker. * When specified, the picker is only visible when the expression evaluates to true. @@ -432,7 +430,7 @@ export interface IChatSessionsService { readonly onDidChangeOptionGroups: Event; getOptionGroupsForSessionType(chatSessionType: string): IChatSessionProviderOptionGroup[] | undefined; - setOptionGroupsForSessionType(chatSessionType: string, handle: number, optionGroups?: IChatSessionProviderOptionGroup[]): void; + setOptionGroupsForSessionType(chatSessionType: string, handle: number, optionGroups?: readonly IChatSessionProviderOptionGroup[]): void; /** * Get the default options for new sessions of this type, derived from option groups' diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index eeba510b72b6e..0f8c8034f1b12 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -48,6 +48,7 @@ export enum ChatConfiguration { ChatViewProgressBadgeEnabled = 'chat.viewProgressBadge.enabled', ChatContextUsageEnabled = 'chat.contextUsage.enabled', SubagentToolCustomAgents = 'chat.customAgentInSubagent.enabled', + GeneralPurposeAgentEnabled = 'chat.generalPurposeAgent.enabled', SubagentsAllowInvocationsFromSubagents = 'chat.subagents.allowInvocationsFromSubagents', ShowCodeBlockProgressAnimation = 'chat.agent.codeBlockProgress', RestoreLastPanelSession = 'chat.restoreLastPanelSession', @@ -196,3 +197,9 @@ export const ChatEditorTitleMaxLength = 30; export const CHAT_TERMINAL_OUTPUT_MAX_PREVIEW_LINES = 1000; export const CONTEXT_MODELS_EDITOR = new RawContextKey('inModelsEditor', false); export const CONTEXT_MODELS_SEARCH_FOCUS = new RawContextKey('inModelsSearch', false); + +/** + * The built-in general-purpose agent name. When the model uses this name, + * the subagent inherits the parent's system prompt, model, and tools. + */ +export const GeneralPurposeAgentName = 'General Purpose'; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 4849423cd9607..1b6fed663b4c6 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -27,7 +27,7 @@ import { ParsedPromptFile } from './promptFileParser.js'; import { AgentInstructionFileType, IAgentSkill, ICustomAgent, IInstructionFile, IPromptsService } from './service/promptsService.js'; import { AGENT_DEBUG_LOG_ENABLED_SETTING, AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING, TROUBLESHOOT_SKILL_PATH } from './promptTypes.js'; import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; -import { ChatConfiguration, ChatModeKind } from '../constants.js'; +import { ChatConfiguration, ChatModeKind, GeneralPurposeAgentName } from '../constants.js'; import { UserSelectedTools } from '../participants/chatAgents.js'; import { hash } from '../../../../../base/common/hash.js'; import { IAgentPlugin, IAgentPluginService } from '../plugins/agentPluginService.js'; @@ -431,7 +431,10 @@ export class ComputeAutomaticInstructions { entries.push('', '', ''); // add trailing newline } } - if (runSubagentTool && this._configurationService.getValue(ChatConfiguration.SubagentToolCustomAgents)) { + if (runSubagentTool) { + const generalPurposeAgentEnabled = !!this._configurationService.getValue(ChatConfiguration.GeneralPurposeAgentEnabled); + + const customAgentsEnabled = !!this._configurationService.getValue(ChatConfiguration.SubagentToolCustomAgents); const canUseAgent = (() => { if (!this._enabledSubagents || this._enabledSubagents.includes('*')) { return (agent: ICustomAgent) => agent.visibility.agentInvocable; @@ -440,12 +443,22 @@ export class ComputeAutomaticInstructions { return (agent: ICustomAgent) => subagents.includes(agent.name); } })(); - const agents = await this._promptsService.getCustomAgents(token); - if (agents.length > 0) { + const agents = customAgentsEnabled ? await this._promptsService.getCustomAgents(token) : []; + + if (generalPurposeAgentEnabled || agents.length > 0) { entries.push(''); entries.push('Here is a list of agents that can be used when running a subagent.'); entries.push('Each agent has optionally a description with the agent\'s purpose and expertise. When asked to run a subagent, choose the most appropriate agent from this list.'); entries.push(`Use the ${runSubagentTool.variable} tool with the agent name to run the subagent.`); + + if (generalPurposeAgentEnabled) { + // Built-in General Purpose agent, always available when experiment is on + entries.push(''); + entries.push(`${GeneralPurposeAgentName}`); + entries.push(`Full-capability agent for complex multi-step tasks requiring high-quality reasoning. Has access to the same tools and capabilities as the current agent and inherits the parent agent's model and system prompt. Use for tasks that don't fit a more specialized agent.`); + entries.push(''); + } + for (const agent of agents) { if (canUseAgent(agent)) { entries.push(''); diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/chatExternalPathConfirmation.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/chatExternalPathConfirmation.ts index ee75a3bc056f5..526fb48384ece 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/chatExternalPathConfirmation.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/chatExternalPathConfirmation.ts @@ -193,15 +193,13 @@ export class ChatExternalPathConfirmationContribution implements ILanguageModelT select: async () => { const gitRootUri = await findGitRoot(pathUri); gitRootCache.set(pathUri, gitRootUri ?? null); - if (!gitRootUri) { - return false; - } let folders = allowlist.get(sessionResource); if (!folders) { folders = new ResourceSet(); allowlist.set(sessionResource, folders); } - folders.add(gitRootUri); + // If we found the git root, allow the entire repo; otherwise fall back to just this folder + folders.add(gitRootUri ?? folderUri); return true; } }); diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index 92b43a4993632..f953732e3414f 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -5,20 +5,20 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { Event } from '../../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { IJSONSchema, IJSONSchemaMap } from '../../../../../../base/common/jsonSchema.js'; import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; import { localize } from '../../../../../../nls.js'; -import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { ChatRequestVariableSet } from '../../attachments/chatVariableEntries.js'; import { IChatProgress, IChatService } from '../../chatService/chatService.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind, GeneralPurposeAgentName } from '../../constants.js'; import { ILanguageModelsService } from '../../languageModels.js'; import { ChatModel, IChatRequestModeInstructions } from '../../model/chatModel.js'; import { IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../../participants/chatAgents.js'; @@ -50,7 +50,8 @@ const BaseModelDescription = `Launch a new agent to handle complex, multi-step t - When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result. - Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you. - The agent's outputs should generally be trusted -- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user\'s intent`; +- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user\'s intent +- If the user asks for a certain agent, you MUST provide that EXACT agent name (case-sensitive) to invoke that specific agent.`; export interface IRunSubagentToolInputParams { prompt: string; @@ -64,7 +65,8 @@ export class RunSubagentTool extends Disposable implements IToolImpl { static readonly Id = 'runSubagent'; - readonly onDidUpdateToolData: Event; + private readonly _onDidUpdateToolData = this._register(new Emitter()); + readonly onDidUpdateToolData: Event = this._onDidUpdateToolData.event; /** Hack to port data between prepare/invoke */ private readonly _resolvedModels = new Map(); @@ -78,42 +80,54 @@ export class RunSubagentTool extends Disposable implements IToolImpl { @ILanguageModelToolsService private readonly languageModelToolsService: ILanguageModelToolsService, @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @ILogService private readonly logService: ILogService, - @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService, @IConfigurationService private readonly configurationService: IConfigurationService, @IPromptsService private readonly promptsService: IPromptsService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IProductService private readonly productService: IProductService, ) { super(); - this.onDidUpdateToolData = Event.filter(this.configurationService.onDidChangeConfiguration, e => - e.affectsConfiguration(ChatConfiguration.SubagentToolCustomAgents) - ); + + this._register(Event.filter(this.configurationService.onDidChangeConfiguration, e => + e.affectsConfiguration(ChatConfiguration.SubagentToolCustomAgents) || + e.affectsConfiguration(ChatConfiguration.GeneralPurposeAgentEnabled) + )(() => this._onDidUpdateToolData.fire())); } getToolData(): IToolData { - let modelDescription = BaseModelDescription; - const inputSchema: IJSONSchema & { properties: IJSONSchemaMap } = { - type: 'object', - properties: { - prompt: { - type: 'string', - description: 'A detailed description of the task for the agent to perform' - }, - description: { - type: 'string', - description: 'A short (3-5 word) description of the task' - } + const modelDescription = BaseModelDescription; + const generalPurposeAgentEnabled = this.configurationService.getValue(ChatConfiguration.GeneralPurposeAgentEnabled); + const customAgentsEnabled = this.configurationService.getValue(ChatConfiguration.SubagentToolCustomAgents); + + const properties: IJSONSchemaMap = { + prompt: { + type: 'string', + description: 'A detailed description of the task for the agent to perform' }, - required: ['prompt', 'description'] + description: { + type: 'string', + description: 'A short (3-5 word) description of the task' + } }; - if (this.configurationService.getValue(ChatConfiguration.SubagentToolCustomAgents)) { - inputSchema.properties.agentName = { + if (customAgentsEnabled || generalPurposeAgentEnabled) { + properties.agentName = { type: 'string', - description: 'Optional name of a specific agent to invoke. If not provided, uses the current agent.' + description: generalPurposeAgentEnabled + ? 'Name of the agent to invoke.' + : 'Optional name of a specific agent to invoke. If not provided, uses the current agent.' }; - modelDescription += `\n- If the user asks for a certain agent, you MUST provide that EXACT agent name (case-sensitive) to invoke that specific agent.`; } + + const required: string[] = ['prompt', 'description']; + if (generalPurposeAgentEnabled) { + required.push('agentName'); + } + + const inputSchema: IJSONSchema & { properties: IJSONSchemaMap } = { + type: 'object', + properties, + required + }; const runSubagentToolData: IToolData = { id: RunSubagentTool.Id, toolReferenceName: VSCodeToolReference.runSubagent, @@ -161,8 +175,14 @@ export class RunSubagentTool extends Disposable implements IToolImpl { let resolvedModelName: string | undefined; const subAgentName = args.agentName; - if (subAgentName) { - subagent = await this.getSubAgentByName(subAgentName); + // Defensive: model may omit agentName despite schema requiring it + const gpEnabled = this.configurationService.getValue(ChatConfiguration.GeneralPurposeAgentEnabled); + const customAgentsEnabled = this.configurationService.getValue(ChatConfiguration.SubagentToolCustomAgents); + const isGeneralPurpose = gpEnabled && (!subAgentName || subAgentName === GeneralPurposeAgentName); + const effectiveSubAgentName = isGeneralPurpose ? GeneralPurposeAgentName : subAgentName; + + if (subAgentName && !isGeneralPurpose) { + subagent = customAgentsEnabled ? await this.getSubAgentByName(subAgentName) : undefined; if (subagent) { // Check the pre-resolved model cache from prepareToolInvocation const cached = this._resolvedModels.get(invocation.callId); @@ -195,12 +215,15 @@ export class RunSubagentTool extends Disposable implements IToolImpl { modeInstructions = instructions && { name: subAgentName, content: instructions.content, - toolReferences: this.toolsService.toToolReferences(instructions.toolReferences), + toolReferences: this.languageModelToolsService.toToolReferences(instructions.toolReferences), metadata: instructions.metadata, isBuiltin: isBuiltinAgent(subagent.source, subagent.uri, this.productService), }; } else { - throw new Error(`Requested agent '${subAgentName}' not found. Try again with the correct agent name, or omit the agentName to use the current agent.`); + this._resolvedModels.delete(invocation.callId); + const baseHint = ' Try again with the correct agent name, or omit agentName to use the current agent.'; + const gpHint = gpEnabled ? ` Additionally, you can use '${GeneralPurposeAgentName}' for a full-capability agent.` : ''; + throw new Error(`Requested agent '${subAgentName}' not found.${baseHint}${gpHint}`); } } else { // No subagent name - clean up any cached entry and resolve model name from main model @@ -312,7 +335,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { variables: { variables: variableSet.asArray() }, location: ChatAgentLocation.Chat, subAgentInvocationId: subAgentInvocationId, - subAgentName: subAgentName, + subAgentName: effectiveSubAgentName, userSelectedModelId: modeModelId, modelConfiguration: modeModelId ? this.languageModelsService.getModelConfiguration(modeModelId) : undefined, userSelectedTools: modeTools, @@ -433,7 +456,11 @@ export class RunSubagentTool extends Disposable implements IToolImpl { async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { const args = context.parameters as IRunSubagentToolInputParams; - const subagent = args.agentName ? await this.getSubAgentByName(args.agentName) : undefined; + // Defensive: model may omit agentName despite schema requiring it + const gpEnabled = this.configurationService.getValue(ChatConfiguration.GeneralPurposeAgentEnabled); + const customAgentsEnabled = this.configurationService.getValue(ChatConfiguration.SubagentToolCustomAgents); + const isGeneralPurpose = gpEnabled && (!args.agentName || args.agentName === GeneralPurposeAgentName); + const subagent = (args.agentName && !isGeneralPurpose && customAgentsEnabled) ? await this.getSubAgentByName(args.agentName) : undefined; // Resolve the model early and cache it for invoke() const resolved = this.resolveSubagentModel(subagent, context.modelId); @@ -444,7 +471,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { toolSpecificData: { kind: 'subagent', description: args.description, - agentName: subagent?.name, + agentName: isGeneralPurpose ? GeneralPurposeAgentName : (subagent?.name ?? args.agentName), prompt: args.prompt, modelName: resolved.resolvedModelName, }, diff --git a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts index ee9d0eb3206df..f371c6474d1aa 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts @@ -42,10 +42,10 @@ export class OpenAgentsWindowAction extends Action2 { if (environmentService.isBuilt && (isMacintosh || isWindows)) { const scheme = productService.quality === 'stable' - ? 'vscode-sessions' + ? 'vscode-agents' : productService.quality === 'exploration' - ? 'vscode-sessions-exploration' - : 'vscode-sessions-insiders'; + ? 'vscode-agents-exploration' + : 'vscode-agents-insiders'; await openerService.open(URI.from({ scheme, authority: Schemas.file }), { openExternal: true }); } else { diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts index af0c6acb7cc0d..7c6aa3fd2ea58 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts @@ -45,7 +45,7 @@ import { ILanguageModelToolsService } from '../../../common/tools/languageModelT import { IRemoteAgentService } from '../../../../../../workbench/services/remote/common/remoteAgentService.js'; import { basename } from '../../../../../../base/common/resources.js'; import { match } from '../../../../../../base/common/glob.js'; -import { ChatModeKind } from '../../../common/constants.js'; +import { ChatModeKind, GeneralPurposeAgentName } from '../../../common/constants.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { MockContextKeyService } from '../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { IAgentPlugin, IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; @@ -1505,6 +1505,87 @@ suite('ComputeAutomaticInstructions', () => { assert.equal(xmlContents(agents[2], 'name')[0], `test-agent-5`); }); + test('should include General Purpose agent first when experiment is enabled', async () => { + const rootFolderName = 'gp-agents-list-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + testConfigService.setUserConfiguration('chat.customAgentInSubagent.enabled', true); + + testConfigService.setUserConfiguration('chat.generalPurposeAgent.enabled', true); + + testConfigService.setUserConfiguration(PromptsConfig.AGENTS_LOCATION_KEY, { + [AGENTS_SOURCE_FOLDER]: true, + }); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/agents/test-agent-1.agent.md`, + contents: [ + '---', + 'description: \'Test agent 1\'', + '---', + 'Test agent content', + ] + }, + ]); + + const contextComputer = instaService.createInstance( + ComputeAutomaticInstructions, + ChatModeKind.Agent, + { 'vscode_runSubagent': true }, + ['*'], + ); + const variables = new ChatRequestVariableSet(); + + await contextComputer.collect(variables, CancellationToken.None); + + const textVariables = variables.asArray().filter(v => isPromptTextVariableEntry(v)); + assert.equal(textVariables.length, 1, 'There should be one text variable for agents list'); + + const agentsList = xmlContents(textVariables[0].value, 'agents'); + assert.equal(agentsList.length, 1, 'There should be one agents list'); + + const agents = xmlContents(agentsList[0], 'agent'); + assert.equal(agents.length, 2, 'There should be two agents (General Purpose + 1 custom)'); + + // First agent should always be the built-in General Purpose agent + assert.equal(xmlContents(agents[0], 'name')[0], GeneralPurposeAgentName); + + assert.equal(xmlContents(agents[1], 'name')[0], 'test-agent-1'); + assert.equal(xmlContents(agents[1], 'description')[0], 'Test agent 1'); + }); + + test('should include General Purpose agent even without custom agents config', async () => { + workspaceContextService.setWorkspace(testWorkspace(URI.file('/gp-only-test'))); + + // Explicitly do NOT set chat.customAgentInSubagent.enabled + + testConfigService.setUserConfiguration('chat.generalPurposeAgent.enabled', true); + + const contextComputer = instaService.createInstance( + ComputeAutomaticInstructions, + ChatModeKind.Agent, + { 'vscode_runSubagent': true }, + ['*'], + ); + const variables = new ChatRequestVariableSet(); + + await contextComputer.collect(variables, CancellationToken.None); + + const textVariables = variables.asArray().filter(v => isPromptTextVariableEntry(v)); + assert.equal(textVariables.length, 1, 'There should be one text variable for agents list'); + + const agentsList = xmlContents(textVariables[0].value, 'agents'); + assert.equal(agentsList.length, 1, 'There should be one agents list'); + + const agents = xmlContents(agentsList[0], 'agent'); + assert.equal(agents.length, 1, 'There should be only the GP agent'); + assert.equal(xmlContents(agents[0], 'name')[0], GeneralPurposeAgentName); + }); + test('should include skills list when readFile tool available', async () => { const rootFolderName = 'skills-list-test'; const rootFolder = `/${rootFolderName}`; diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/chatExternalPathConfirmation.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/chatExternalPathConfirmation.test.ts new file mode 100644 index 0000000000000..a046a5302bb76 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/chatExternalPathConfirmation.test.ts @@ -0,0 +1,204 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { ILabelService } from '../../../../../../../platform/label/common/label.js'; +import { ToolConfirmKind } from '../../../../common/chatService/chatService.js'; +import { ChatExternalPathConfirmationContribution, IExternalPathInfo } from '../../../../common/tools/builtinTools/chatExternalPathConfirmation.js'; +import { ILanguageModelToolConfirmationRef } from '../../../../common/tools/languageModelToolsConfirmationService.js'; + +suite('ChatExternalPathConfirmationContribution', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + const sessionResource = URI.parse('vscode-chat-session:/session/1'); + const source = { type: 'internal' as const, label: 'test' }; + const mockLabelService = { getUriLabel: (uri: URI) => uri.fsPath } as ILabelService; + + function createRef(filePath: string, isDirectory = false): ILanguageModelToolConfirmationRef { + return { + toolId: 'copilot_readFile', + source, + parameters: isDirectory ? { path: filePath } : { filePath }, + chatSessionResource: sessionResource, + }; + } + + function createContribution(findGitRoot?: (pathUri: URI) => Promise): ChatExternalPathConfirmationContribution { + const getPathInfo = (ref: ILanguageModelToolConfirmationRef): IExternalPathInfo | undefined => { + const params = ref.parameters as { filePath?: string; path?: string }; + if (params?.filePath) { + return { path: params.filePath, isDirectory: false }; + } + if (params?.path) { + return { path: params.path, isDirectory: true }; + } + return undefined; + }; + + const contribution = new ChatExternalPathConfirmationContribution( + getPathInfo, + mockLabelService, + findGitRoot, + ); + disposables.add(contribution); + return contribution; + } + + test('getPreConfirmAction returns undefined with no allowlist entries', () => { + const contribution = createContribution(); + const ref = createRef('/external/repo/src/file.ts'); + const result = contribution.getPreConfirmAction(ref); + assert.strictEqual(result, undefined); + }); + + test('allow folder in session works', async () => { + const contribution = createContribution(); + const ref = createRef('/external/repo/src/file.ts'); + + const actions = contribution.getPreConfirmActions(ref); + assert.ok(actions.length >= 1); + const folderAction = actions[0]; + assert.ok(folderAction.label.includes('folder')); + + const shouldConfirm = await folderAction.select(); + assert.strictEqual(shouldConfirm, true); + + // Same folder should now be auto-approved + const result = contribution.getPreConfirmAction(ref); + assert.deepStrictEqual(result, { type: ToolConfirmKind.UserAction }); + }); + + test('allow repo in session - first time resolves git root', async () => { + const gitRootUri = URI.file('/external/repo'); + const contribution = createContribution(async () => gitRootUri); + + const ref = createRef('/external/repo/src/file.ts'); + + const actions = contribution.getPreConfirmActions(ref); + // Should have "allow folder" and "allow repo" actions + assert.strictEqual(actions.length, 2); + const repoAction = actions[1]; + assert.ok(repoAction.label.includes('repository')); + + const shouldConfirm = await repoAction.select(); + assert.strictEqual(shouldConfirm, true); + + // File in the same repo should now be auto-approved + const ref2 = createRef('/external/repo/src/other.ts'); + const result = contribution.getPreConfirmAction(ref2); + assert.deepStrictEqual(result, { type: ToolConfirmKind.UserAction }); + }); + + test('allow repo in session - cached git root', async () => { + const gitRootUri = URI.file('/external/repo'); + const contribution = createContribution(async () => gitRootUri); + + const ref = createRef('/external/repo/src/file.ts'); + + // First call - resolves git root + const actions1 = contribution.getPreConfirmActions(ref); + const repoAction1 = actions1[1]; + await repoAction1.select(); + + // Second call with same path - should use cached git root + const actions2 = contribution.getPreConfirmActions(ref); + assert.strictEqual(actions2.length, 2); + const repoAction2 = actions2[1]; + assert.ok(repoAction2.detail!.includes(gitRootUri.fsPath)); + + const shouldConfirm = await repoAction2.select(); + assert.strictEqual(shouldConfirm, true); + }); + + test('allow repo in session - git root not found falls back to folder', async () => { + const contribution = createContribution(async () => undefined); + + const ref = createRef('/not-in-repo/file.ts'); + + const actions = contribution.getPreConfirmActions(ref); + assert.strictEqual(actions.length, 2); + const repoAction = actions[1]; + + // Should still confirm (falls back to allowing the folder) + const shouldConfirm = await repoAction.select(); + assert.strictEqual(shouldConfirm, true); + + // The containing folder should be auto-approved + const result = contribution.getPreConfirmAction(ref); + assert.deepStrictEqual(result, { type: ToolConfirmKind.UserAction }); + }); + + test('allow repo in session - hides option after git root not found', async () => { + const contribution = createContribution(async () => undefined); + + const ref = createRef('/not-in-repo/file.ts'); + + // First call - resolve returns undefined, caches null + const actions1 = contribution.getPreConfirmActions(ref); + assert.strictEqual(actions1.length, 2); + await actions1[1].select(); + + // Second call - should not show repo option (cached === null) + const actions2 = contribution.getPreConfirmActions(ref); + assert.strictEqual(actions2.length, 1); + }); + + test('allow repo in session - different files in same repo', async () => { + const gitRootUri = URI.file('/external/repo'); + const contribution = createContribution(async () => gitRootUri); + + const ref1 = createRef('/external/repo/src/a.ts'); + const ref2 = createRef('/external/repo/lib/b.ts'); + const ref3 = createRef('/external/repo/deep/nested/c.ts'); + + // Allow repo via first file + const actions = contribution.getPreConfirmActions(ref1); + await actions[1].select(); + + // All files in the repo should be auto-approved + assert.deepStrictEqual(contribution.getPreConfirmAction(ref1), { type: ToolConfirmKind.UserAction }); + assert.deepStrictEqual(contribution.getPreConfirmAction(ref2), { type: ToolConfirmKind.UserAction }); + assert.deepStrictEqual(contribution.getPreConfirmAction(ref3), { type: ToolConfirmKind.UserAction }); + + // File outside the repo should NOT be auto-approved + const refOutside = createRef('/other/place/file.ts'); + assert.strictEqual(contribution.getPreConfirmAction(refOutside), undefined); + }); + + test('session allowlist is per-session', async () => { + const gitRootUri = URI.file('/external/repo'); + const contribution = createContribution(async () => gitRootUri); + + const ref = createRef('/external/repo/src/file.ts'); + const actions = contribution.getPreConfirmActions(ref); + await actions[1].select(); + + // Same file, different session + const refOtherSession: ILanguageModelToolConfirmationRef = { + toolId: 'copilot_readFile', + source, + parameters: { filePath: '/external/repo/src/file.ts' }, + chatSessionResource: URI.parse('vscode-chat-session:/session/2'), + }; + assert.strictEqual(contribution.getPreConfirmAction(refOtherSession), undefined); + }); + + test('reset clears all allowlists', async () => { + const gitRootUri = URI.file('/external/repo'); + const contribution = createContribution(async () => gitRootUri); + + const ref = createRef('/external/repo/src/file.ts'); + const actions = contribution.getPreConfirmActions(ref); + await actions[1].select(); + + assert.deepStrictEqual(contribution.getPreConfirmAction(ref), { type: ToolConfirmKind.UserAction }); + + contribution.reset(); + + assert.strictEqual(contribution.getPreConfirmAction(ref), undefined); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts index fdc8346147587..19fb1f155f8b3 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts @@ -22,7 +22,7 @@ import { MockPromptsService } from '../../promptSyntax/service/mockPromptsServic import { ExtensionIdentifier } from '../../../../../../../platform/extensions/common/extensions.js'; import { IToolInvocation, ToolProgress } from '../../../../common/tools/languageModelToolsService.js'; import { IChatModel } from '../../../../common/model/chatModel.js'; -import { ChatConfiguration } from '../../../../common/constants.js'; +import { ChatConfiguration, GeneralPurposeAgentName } from '../../../../common/constants.js'; suite('RunSubagentTool', () => { const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -50,7 +50,6 @@ suite('RunSubagentTool', () => { suite('prepareToolInvocation', () => { test('returns correct toolSpecificData', async () => { const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); - const configService = new TestConfigurationService(); const promptsService = new MockPromptsService(); const customMode: ICustomAgent = { @@ -71,8 +70,7 @@ suite('RunSubagentTool', () => { mockToolsService, {} as ILanguageModelsService, new NullLogService(), - mockToolsService, - configService, + new TestConfigurationService(), promptsService, {} as IInstantiationService, {} as IProductService, @@ -101,12 +99,124 @@ suite('RunSubagentTool', () => { modelName: undefined, }); }); + + function createToolWithGP(opts?: { customAgents?: ICustomAgent[] }) { + const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); + const promptsService = new MockPromptsService(); + if (opts?.customAgents) { + promptsService.setCustomModes(opts.customAgents); + } + + const tool = testDisposables.add(new RunSubagentTool( + {} as IChatAgentService, + {} as IChatService, + mockToolsService, + {} as ILanguageModelsService, + new NullLogService(), + new TestConfigurationService({ [ChatConfiguration.GeneralPurposeAgentEnabled]: true }), + promptsService, + {} as IInstantiationService, + {} as IProductService, + )); + return tool; + } + + async function createToolWithGPReady(opts?: { customAgents?: ICustomAgent[] }) { + return createToolWithGP(opts); + } + + test('treats undefined agentName as General Purpose when experiment is enabled', async () => { + const tool = await createToolWithGPReady(); + + const result = await tool.prepareToolInvocation( + { + parameters: { prompt: 'Test prompt', description: 'Test task', agentName: undefined }, + toolCallId: 'test-call-undef', + chatSessionResource: URI.parse('test://session'), + }, + CancellationToken.None + ); + + assert.ok(result); + assert.deepStrictEqual(result.toolSpecificData, { + kind: 'subagent', + description: 'Test task', + agentName: GeneralPurposeAgentName, + prompt: 'Test prompt', + modelName: undefined, + }); + }); + + test('treats empty string agentName as General Purpose when experiment is enabled', async () => { + const tool = await createToolWithGPReady(); + + const result = await tool.prepareToolInvocation( + { + parameters: { prompt: 'Test prompt', description: 'Test task', agentName: '' }, + toolCallId: 'test-call-empty', + chatSessionResource: URI.parse('test://session'), + }, + CancellationToken.None + ); + + assert.ok(result); + assert.deepStrictEqual(result.toolSpecificData, { + kind: 'subagent', + description: 'Test task', + agentName: GeneralPurposeAgentName, + prompt: 'Test prompt', + modelName: undefined, + }); + }); + + test('treats explicit General Purpose agentName as GP path', async () => { + const tool = await createToolWithGPReady(); + + const result = await tool.prepareToolInvocation( + { + parameters: { prompt: 'Test prompt', description: 'Test task', agentName: GeneralPurposeAgentName }, + toolCallId: 'test-call-gp', + chatSessionResource: URI.parse('test://session'), + }, + CancellationToken.None + ); + + assert.ok(result); + assert.deepStrictEqual(result.toolSpecificData, { + kind: 'subagent', + description: 'Test task', + agentName: GeneralPurposeAgentName, + prompt: 'Test prompt', + modelName: undefined, + }); + }); + + test('passes through unknown agentName when experiment is enabled', async () => { + const tool = await createToolWithGPReady(); + + const result = await tool.prepareToolInvocation( + { + parameters: { prompt: 'Test prompt', description: 'Test task', agentName: 'NonExistentAgent' }, + toolCallId: 'test-call-unknown', + chatSessionResource: URI.parse('test://session'), + }, + CancellationToken.None + ); + + assert.ok(result); + assert.deepStrictEqual(result.toolSpecificData, { + kind: 'subagent', + description: 'Test task', + agentName: 'NonExistentAgent', + prompt: 'Test prompt', + modelName: undefined, + }); + }); }); suite('getToolData', () => { test('returns basic tool data', () => { const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); - const configService = new TestConfigurationService(); const promptsService = new MockPromptsService(); const tool = testDisposables.add(new RunSubagentTool( @@ -115,8 +225,7 @@ suite('RunSubagentTool', () => { mockToolsService, {} as ILanguageModelsService, new NullLogService(), - mockToolsService, - configService, + new TestConfigurationService(), promptsService, {} as IInstantiationService, {} as IProductService, @@ -128,14 +237,12 @@ suite('RunSubagentTool', () => { assert.ok(toolData.inputSchema); assert.ok(toolData.inputSchema.properties?.prompt); assert.ok(toolData.inputSchema.properties?.description); + assert.strictEqual(toolData.inputSchema.properties?.agentName, undefined, 'agentName should not be in schema when neither GP nor custom agents is enabled'); assert.deepStrictEqual(toolData.inputSchema.required, ['prompt', 'description']); }); - test('includes agentName property when SubagentToolCustomAgents is enabled', () => { + test('marks agentName as required when GP experiment is enabled', async () => { const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); - const configService = new TestConfigurationService({ - 'chat.customAgentInSubagent.enabled': true, - }); const promptsService = new MockPromptsService(); const tool = testDisposables.add(new RunSubagentTool( @@ -144,16 +251,15 @@ suite('RunSubagentTool', () => { mockToolsService, {} as ILanguageModelsService, new NullLogService(), - mockToolsService, - configService, + new TestConfigurationService({ [ChatConfiguration.GeneralPurposeAgentEnabled]: true }), promptsService, {} as IInstantiationService, {} as IProductService, )); const toolData = tool.getToolData(); - - assert.ok(toolData.inputSchema?.properties?.agentName, 'agentName should be in schema when custom agents enabled'); + assert.ok(toolData.inputSchema?.properties?.agentName); + assert.deepStrictEqual(toolData.inputSchema.required, ['prompt', 'description', 'agentName']); }); }); @@ -244,7 +350,6 @@ suite('RunSubagentTool', () => { customAgents?: ICustomAgent[]; }) { const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); - const configService = new TestConfigurationService(); const promptsService = new MockPromptsService(); if (opts.customAgents) { promptsService.setCustomModes(opts.customAgents); @@ -265,8 +370,7 @@ suite('RunSubagentTool', () => { mockToolsService, mockLanguageModelsService as ILanguageModelsService, new NullLogService(), - mockToolsService, - configService, + new TestConfigurationService({ [ChatConfiguration.SubagentToolCustomAgents]: true }), promptsService, {} as IInstantiationService, {} as IProductService, @@ -542,7 +646,6 @@ suite('RunSubagentTool', () => { mockToolsService, {} as ILanguageModelsService, new NullLogService(), - mockToolsService, configService, promptsService, mockInstantiationService as IInstantiationService, diff --git a/src/vs/workbench/contrib/update/browser/media/updateTitleBarEntry.css b/src/vs/workbench/contrib/update/browser/media/updateTitleBarEntry.css index 266a0a4484895..5ae71bac4789b 100644 --- a/src/vs/workbench/contrib/update/browser/media/updateTitleBarEntry.css +++ b/src/vs/workbench/contrib/update/browser/media/updateTitleBarEntry.css @@ -8,8 +8,9 @@ align-items: center; border-radius: var(--vscode-cornerRadius-medium); white-space: nowrap; - padding: 0px 12px; + padding: 0px 8px; height: 22px; + box-sizing: border-box; background-color: transparent; border: 1px solid transparent; } diff --git a/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts b/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts index d6c9fdaf0ce19..09da755a08058 100644 --- a/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts +++ b/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts @@ -309,36 +309,47 @@ export class UpdateTitleBarEntry extends BaseActionViewItem { this.content.style.removeProperty('--update-progress'); const label = dom.append(this.content, dom.$('.indicator-label')); - label.textContent = localize('updateIndicator.update', "Update"); - switch (state.type) { case StateType.Disabled: + label.textContent = localize('updateIndicator.update', "Update"); this.content.classList.add('update-disabled'); break; case StateType.CheckingForUpdates: - case StateType.Overwriting: + label.textContent = localize('updateIndicator.checking', "Checking..."); this.renderProgressState(this.content); break; - case StateType.Restarting: - label.textContent = localize('updateIndicator.restarting', "Restarting"); + case StateType.Overwriting: + label.textContent = localize('updateIndicator.overwriting', "Updating..."); this.renderProgressState(this.content); break; case StateType.AvailableForDownload: case StateType.Downloaded: case StateType.Ready: + label.textContent = localize('updateIndicator.update', "Update"); this.content.classList.add('prominent'); break; case StateType.Downloading: + label.textContent = localize('updateIndicator.downloading', "Downloading..."); this.renderProgressState(this.content, computeProgressPercent(state.downloadedBytes, state.totalBytes)); break; case StateType.Updating: + label.textContent = localize('updateIndicator.installing', "Installing..."); this.renderProgressState(this.content, computeProgressPercent(state.currentProgress, state.maxProgress)); break; + + case StateType.Restarting: + label.textContent = localize('updateIndicator.restarting', "Restarting..."); + this.renderProgressState(this.content); + break; + + default: + label.textContent = localize('updateIndicator.update', "Update"); + break; } } diff --git a/src/vs/workbench/contrib/update/browser/updateTooltip.ts b/src/vs/workbench/contrib/update/browser/updateTooltip.ts index e08c8fd237ecd..b64a06871d4c2 100644 --- a/src/vs/workbench/contrib/update/browser/updateTooltip.ts +++ b/src/vs/workbench/contrib/update/browser/updateTooltip.ts @@ -4,8 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../base/browser/dom.js'; -import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; -import { toAction } from '../../../../base/common/actions.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; @@ -15,7 +13,7 @@ import { localize } from '../../../../nls.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IHoverService, nativeHoverDelegate } from '../../../../platform/hover/browser/hover.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { IMarkdownRendererService, openLinkFromMarkdown } from '../../../../platform/markdown/browser/markdownRenderer.js'; import { IMeteredConnectionService } from '../../../../platform/meteredConnection/common/meteredConnection.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; @@ -88,14 +86,6 @@ export class UpdateTooltip extends Disposable { const header = dom.append(this.domNode, dom.$('.header')); this.titleNode = dom.append(header, dom.$('.title')); - const actionBar = this._register(new ActionBar(header, { hoverDelegate: nativeHoverDelegate })); - actionBar.push(toAction({ - id: 'update.openSettings', - label: localize('updateTooltip.settingsTooltip', "Update Settings"), - class: ThemeIcon.asClassName(Codicon.gear), - run: () => this.runCommandAndClose('workbench.action.openSettings', '@id:update*'), - }), { icon: true, label: false }); - // Product info section this.productInfoNode = dom.append(this.domNode, dom.$('.product-info')); @@ -142,7 +132,7 @@ export class UpdateTooltip extends Disposable { this.buttonBar = dom.append(this.domNode, dom.$('.button-bar')); this.releaseNotesButton = dom.append(this.buttonBar, dom.$('button.release-notes-button')) as HTMLButtonElement; - this.releaseNotesButton.textContent = localize('updateTooltip.viewReleaseNotes', "View Release Notes"); + this.releaseNotesButton.textContent = localize('updateTooltip.viewReleaseNotes', "Release Notes"); this._register(dom.addDisposableListener(this.releaseNotesButton, 'click', () => { if (this.releaseNotesVersion) { this.runCommandAndClose(ShowCurrentReleaseNotesActionId, this.releaseNotesVersion); @@ -374,8 +364,12 @@ export class UpdateTooltip extends Disposable { } private renderReady({ update }: Ready) { - this.renderTitleAndInfo(localize('updateTooltip.updateInstalledTitle', "Update Installed"), update); - this.renderActionButton(localize('updateTooltip.restartButton', "Restart"), 'update.restart'); + if (this.configurationService.getValue('update.mode') === 'manual') { + this.renderTitleAndInfo(localize('updateTooltip.updateInstalledTitle', "Update Installed"), update); + this.renderActionButton(localize('updateTooltip.restartButton', "Restart"), 'update.restart'); + } else { + this.renderTitleAndInfo(localize('updateTooltip.restartToUpdateTitle', "Restart to Update"), update); + } } private renderOverwriting({ update }: Overwriting) { @@ -461,6 +455,7 @@ export class UpdateTooltip extends Disposable { // Release notes button this.releaseNotesVersion = version ?? this.productService.version; this.releaseNotesButton.style.display = this.releaseNotesVersion ? '' : 'none'; + this.releaseNotesButton.style.marginRight = this.releaseNotesVersion ? 'auto' : ''; this.buttonBar.style.display = this.releaseNotesVersion ? '' : 'none'; } @@ -468,7 +463,6 @@ export class UpdateTooltip extends Disposable { this.actionButton.textContent = label; this.actionButton.dataset.commandId = commandId; this.actionButton.style.display = ''; - this.releaseNotesButton.style.marginRight = 'auto'; } private renderMessage(message: string, icon?: ThemeIcon) { diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index 5075750b9dfd0..961247310655d 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -1094,8 +1094,9 @@ export class ChatEntitlementContext extends Disposable { const migrated = this.storageService.getBoolean(ChatEntitlementContext.CHAT_ENTITLEMENT_CONTEXT_MIGRATED_STORAGE_KEY, StorageScope.PROFILE) === true; if (!migrated) { this.storageService.store(ChatEntitlementContext.CHAT_ENTITLEMENT_CONTEXT_MIGRATED_STORAGE_KEY, true, StorageScope.PROFILE, StorageTarget.MACHINE); - if (this._state.installed) { + if (this._state.installed && !this._state.completed) { this._state.completed = true; // treat installation signal as completed signal once + this.storageService.store(ChatEntitlementContext.CHAT_ENTITLEMENT_CONTEXT_STORAGE_KEY, this._state, StorageScope.PROFILE, StorageTarget.MACHINE); } } diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index 82d619ec4992f..06d0649b18481 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -682,27 +682,11 @@ declare module 'vscode' { */ readonly when?: string; - /** - * When true, displays a searchable QuickPick with a "See more..." option. - * Recommended for option groups with additional async items (e.g., repositories). - */ - readonly searchable?: boolean; - /** * An icon for the option group shown in UI. */ readonly icon?: ThemeIcon; - /** - * Handler for dynamic search when `searchable` is true. - * Called when the user types in the searchable QuickPick or clicks "See more..." to load additional items. - * - * @param query The search query entered by the user. Empty string for initial load. - * @param token A cancellation token. - * @returns Additional items to display in the searchable QuickPick. - */ - readonly onSearch?: (query: string, token: CancellationToken) => Thenable; - /** * Optional commands. *